Compare commits
101 Commits
3697e3ba0e
...
feat/push-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c371c9775f | ||
|
|
931cf13217 | ||
|
|
fd9efdbbf5 | ||
|
|
19187e633e | ||
|
|
9a34ecded6 | ||
|
|
bd1c9d2c8a | ||
|
|
6907ecc82f | ||
|
|
7dd5c872df | ||
|
|
bb1ea42318 | ||
|
|
b062e2cca7 | ||
|
|
4cb173a8ec | ||
|
|
882e4e1f3a | ||
|
|
cac965cef9 | ||
|
|
ebed4d7546 | ||
|
|
9a8242add5 | ||
|
|
f9a0531325 | ||
|
|
ab78ae390a | ||
|
|
e59489e2cd | ||
|
|
86e31ada34 | ||
|
|
94fd93ad19 | ||
|
|
50f34b5727 | ||
|
|
a1b3062991 | ||
|
|
a210e5ee32 | ||
|
|
5d235e49f1 | ||
|
|
e679804cfd | ||
|
|
e57b54a100 | ||
|
|
d34c1f2697 | ||
|
|
61664c9a36 | ||
|
|
9ab5ed4671 | ||
|
|
144a5c288a | ||
|
|
e3f61de4ad | ||
|
|
2a1b1ed80e | ||
|
|
f09b8b8cfd | ||
|
|
6a78a0059b | ||
|
|
813b33b47e | ||
|
|
a50057d499 | ||
|
|
3ed9798f06 | ||
|
|
b65710ae43 | ||
|
|
509a026cfc | ||
|
|
a62b720144 | ||
|
|
14b1bf844a | ||
|
|
c82829f2bb | ||
|
|
6075717353 | ||
|
|
13f760a3b9 | ||
|
|
9883cad012 | ||
|
|
5ed5ae2d4b | ||
|
|
7fb58195fb | ||
|
|
fccc06e4a2 | ||
|
|
6461f0a21b | ||
|
|
e84cdee393 | ||
|
|
30d8f65e9a | ||
|
|
8e4d09594c | ||
|
|
46ad5973d1 | ||
|
|
4a38000e74 | ||
|
|
2597ca9110 | ||
|
|
bbe897e614 | ||
|
|
a29b7a2f21 | ||
|
|
105ade959d | ||
|
|
29cb466595 | ||
|
|
de73cbd404 | ||
|
|
1b491326be | ||
|
|
3b592dd867 | ||
|
|
c9b7cdabb7 | ||
|
|
74df0822e2 | ||
|
|
a86c1ebb83 | ||
|
|
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 | ||
|
|
6d34b3cb68 | ||
|
|
f18de016d7 | ||
|
|
549ea0631b | ||
|
|
0e215da842 | ||
|
|
d00fe7b00b | ||
|
|
5b2afa3629 | ||
|
|
0f122a512f | ||
|
|
806cc04b82 | ||
|
|
4dc7d840d6 | ||
|
|
4e7c2a7628 |
40
.gitignore
vendored
40
.gitignore
vendored
@@ -121,8 +121,46 @@ results_vlm_bench.json
|
||||
# Scripts locaux one-shot d'intervention/bench, non réutilisables tels quels.
|
||||
tools/bench_qwen35_evidence.py
|
||||
tools/codex_windows_correction_rapport.py
|
||||
|
||||
tools/diagnostic_lea_chat_win11.ps1
|
||||
tools/poc_lecture_ecran.py
|
||||
tools/watch_emilie_agent.py
|
||||
test_sanitizer_live.py
|
||||
# Verbatims clients (sensibles, à valider avant push)
|
||||
docs/clients/
|
||||
|
||||
.qw-baseline.log
|
||||
# Coordination ephemeral — inbox messages, active decisions, loop state
|
||||
docs/coordination/.loop_state/
|
||||
docs/coordination/.inbox_baseline.txt
|
||||
docs/coordination/.loop_log.txt
|
||||
docs/coordination/inbox_qwen/
|
||||
docs/coordination/inbox_codex/
|
||||
docs/coordination/inbox_claude/
|
||||
docs/coordination/active/
|
||||
|
||||
# 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/
|
||||
# Embed tgz working (37M, local build artifact)
|
||||
deploy/installer/lea_python_embed_working.tgz
|
||||
|
||||
# Agent/Codex state (local, session-specific)
|
||||
.agents/
|
||||
.codex/
|
||||
agent_chat/state/
|
||||
|
||||
# Graphify tool + generated output (1.2G)
|
||||
graphify/
|
||||
graphify-out/
|
||||
|
||||
# Local PostScript artifact (webbrowser = 11M DSC)
|
||||
webbrowser
|
||||
|
||||
# Bench predictions (generated, not source)
|
||||
benchmarks/computer_use/predictions/
|
||||
|
||||
# DB backups (instance level, runtime artifact)
|
||||
**/instance/*.db.bak*
|
||||
|
||||
24
AGENTS.md
Normal file
24
AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
## coordination watcher
|
||||
|
||||
At the beginning of every session, the coordination watcher is mandatory and must be operational for Codex, Claude, and Qwen before coordination work continues.
|
||||
|
||||
Session-start checklist:
|
||||
- Run `docs/coordination/coordination_loop.sh ensure`.
|
||||
- Read every pending message relevant to the current agent.
|
||||
- After messages are processed, run `docs/coordination/coordination_loop.sh ack`.
|
||||
- If the watcher cannot be started or checked, report that blocker immediately in the handoff/status response.
|
||||
|
||||
Every new handoff or restart prompt must include this watcher requirement by default.
|
||||
@@ -10,7 +10,9 @@ Tu n'es pas en autonomie. Dom valide avant chaque étape. Tu proposes, il décid
|
||||
|
||||
## Priorité absolue
|
||||
|
||||
**La démo Urgence_aiva_demo doit fonctionner.** Workflow 22+ steps sur Easily Assure, patiente MOREL Catherine, audience mixte DG/DSI/médecins/DIM/TIM. Tout arbitrage technique se tranche par : "est-ce que ça rapproche ou éloigne de la démo qui tourne ?"
|
||||
**Le POC clinique Wallerstein doit tourner.** 5 postes Léa live ; les TIM travaillent sur leurs **vrais logiciels métier en mode web** (navigateur intégré au logiciel / navigateur du PC, instances **RDP** et **Citrix**), sur **2 écrans** → capture de la **fenêtre active**. Objectif produit : Léa **apprend** ces parcours et les **rejoue intelligemment** (pas du record-and-replay). Tout arbitrage technique se tranche par : « est-ce que ça rapproche ou éloigne du POC clinique qui tourne ? »
|
||||
|
||||
> Historique : `Urgence_aiva_demo` (22+ steps) sur la **maquette Easily Assure** (patiente fictive MOREL Catherine) était le banc de démo/test — **maquette abandonnée comme cible** (recadrage Dom 2026-06-25). Ne plus raisonner « Easily ».
|
||||
|
||||
## Méthode obligatoire — non négociable
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from werkzeug.utils import secure_filename
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core.workflow import SemanticMatcher, VariableManager
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
# Import des composants conversationnels
|
||||
from .intent_parser import IntentParser, IntentType, get_intent_parser
|
||||
@@ -237,6 +238,7 @@ def init_system():
|
||||
global matcher, gpu_manager
|
||||
global intent_parser, confirmation_loop, response_generator, conversation_manager
|
||||
global autonomous_planner
|
||||
reasoning_model = get_reasoning_model()
|
||||
|
||||
# 1. SemanticMatcher — multi-répertoires (P0-6) + matching LLM (P0-7)
|
||||
# Scan data/workflows/ + data/training/workflows/ + data/training/live_sessions/workflows/
|
||||
@@ -244,7 +246,7 @@ def init_system():
|
||||
matcher = SemanticMatcher(
|
||||
workflows_dir=None, # None = scan tous les répertoires par défaut
|
||||
use_llm=True, # Matching sémantique via Ollama (P0-7)
|
||||
llm_model="qwen2.5:7b",
|
||||
llm_model=reasoning_model,
|
||||
)
|
||||
dirs_info = matcher.get_directories()
|
||||
dirs_summary = ", ".join(
|
||||
@@ -269,7 +271,10 @@ def init_system():
|
||||
|
||||
# 3. Composants conversationnels
|
||||
try:
|
||||
intent_parser = get_intent_parser(use_llm=True) # LLM activé (Ollama)
|
||||
intent_parser = get_intent_parser(
|
||||
use_llm=True,
|
||||
llm_model=reasoning_model,
|
||||
) # LLM activé (Ollama)
|
||||
confirmation_loop = get_confirmation_loop()
|
||||
response_generator = get_response_generator()
|
||||
conversation_manager = get_conversation_manager()
|
||||
@@ -350,7 +355,7 @@ def init_system():
|
||||
|
||||
# 5. Autonomous Planner (Agent Libre)
|
||||
try:
|
||||
autonomous_planner = get_autonomous_planner(llm_model="qwen2.5:7b")
|
||||
autonomous_planner = get_autonomous_planner(llm_model=reasoning_model)
|
||||
|
||||
# Configurer les callbacks pour l'exécution
|
||||
if screen_capturer:
|
||||
@@ -726,7 +731,7 @@ def api_history():
|
||||
# =============================================================================
|
||||
|
||||
# Modèle texte pour les réponses conversationnelles (pas besoin de vision)
|
||||
_LEA_LLM_MODEL = os.environ.get("LEA_LLM_MODEL", "qwen3:8b")
|
||||
_LEA_LLM_MODEL = os.environ.get("LEA_LLM_MODEL") or get_reasoning_model()
|
||||
|
||||
_LEA_SYSTEM_PROMPT = """Tu es Léa, une assistante professionnelle chaleureuse et bienveillante.
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import requests
|
||||
# Ajouter le chemin du projet pour les imports core
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Essayer d'importer les composants de détection visuelle
|
||||
@@ -113,11 +115,11 @@ class AutonomousPlanner:
|
||||
def __init__(
|
||||
self,
|
||||
llm_endpoint: str = "http://localhost:11434/api/generate",
|
||||
llm_model: str = "qwen2.5:7b",
|
||||
llm_model: Optional[str] = None,
|
||||
timeout: int = 60
|
||||
):
|
||||
self.llm_endpoint = llm_endpoint
|
||||
self.llm_model = llm_model
|
||||
self.llm_model = llm_model or get_reasoning_model()
|
||||
self.timeout = timeout
|
||||
self.llm_available = self._check_llm()
|
||||
|
||||
@@ -1028,12 +1030,12 @@ _planner_instance: Optional[AutonomousPlanner] = None
|
||||
|
||||
|
||||
def get_autonomous_planner(
|
||||
llm_model: str = "qwen2.5:7b"
|
||||
llm_model: Optional[str] = None
|
||||
) -> AutonomousPlanner:
|
||||
"""Retourne l'instance singleton du planner."""
|
||||
global _planner_instance
|
||||
|
||||
if _planner_instance is None:
|
||||
_planner_instance = AutonomousPlanner(llm_model=llm_model)
|
||||
_planner_instance = AutonomousPlanner(llm_model=llm_model or get_reasoning_model())
|
||||
|
||||
return _planner_instance
|
||||
|
||||
@@ -19,6 +19,8 @@ from enum import Enum
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -280,7 +282,7 @@ class IntentParser:
|
||||
self,
|
||||
use_llm: bool = False,
|
||||
llm_endpoint: str = "http://localhost:11434",
|
||||
llm_model: str = "qwen2.5:7b"
|
||||
llm_model: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Initialiser le parseur d'intentions.
|
||||
@@ -292,7 +294,7 @@ class IntentParser:
|
||||
"""
|
||||
self.use_llm = use_llm
|
||||
self.llm_endpoint = llm_endpoint
|
||||
self.llm_model = llm_model
|
||||
self.llm_model = llm_model or get_reasoning_model()
|
||||
self.llm_available = False
|
||||
self._workflows_cache: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -687,7 +689,7 @@ _intent_parser: Optional[IntentParser] = None
|
||||
|
||||
def get_intent_parser(
|
||||
use_llm: bool = False,
|
||||
llm_model: str = "qwen2.5:7b",
|
||||
llm_model: Optional[str] = None,
|
||||
llm_endpoint: str = "http://localhost:11434"
|
||||
) -> IntentParser:
|
||||
"""
|
||||
@@ -695,20 +697,21 @@ def get_intent_parser(
|
||||
|
||||
Args:
|
||||
use_llm: Activer le LLM (Ollama)
|
||||
llm_model: Modèle à utiliser (qwen2.5:7b par défaut)
|
||||
llm_model: Modèle à utiliser (défaut: modèle reasoning central)
|
||||
llm_endpoint: URL de l'endpoint Ollama
|
||||
"""
|
||||
global _intent_parser
|
||||
resolved_model = llm_model or get_reasoning_model()
|
||||
if _intent_parser is None:
|
||||
_intent_parser = IntentParser(
|
||||
use_llm=use_llm,
|
||||
llm_endpoint=llm_endpoint,
|
||||
llm_model=llm_model
|
||||
llm_model=resolved_model
|
||||
)
|
||||
elif use_llm and not _intent_parser.use_llm:
|
||||
# Réactiver le LLM si demandé
|
||||
_intent_parser.use_llm = True
|
||||
_intent_parser.llm_model = llm_model
|
||||
_intent_parser.llm_model = resolved_model
|
||||
_intent_parser._check_llm_availability()
|
||||
return _intent_parser
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ if platform.system() == "Windows":
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
AGENT_VERSION = "1.0.0"
|
||||
AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.2")
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS
|
||||
@@ -82,6 +82,38 @@ BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true"
|
||||
# Configurable via variable d'environnement pour permettre l'ajustement
|
||||
LOG_RETENTION_DAYS = int(os.environ.get("RPA_LOG_RETENTION_DAYS", "180"))
|
||||
|
||||
# Remontée automatique des logs vers le serveur (push-log-DGX).
|
||||
# Diagnostic des postes clinique SANS AnyDesk : les logs (déjà écrits sur disque)
|
||||
# sont poussés au serveur, rangés par machine_id, consultables au dashboard.
|
||||
# Défaut PRUDENT = désactivé : on l'active poste par poste via config.txt /
|
||||
# variable d'environnement, sans rebuild de l'installateur.
|
||||
LOG_SHIP_ENABLED = os.environ.get("RPA_LOG_SHIP_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes",
|
||||
)
|
||||
# Intervalle de flush du buffer de logs (secondes).
|
||||
LOG_SHIP_INTERVAL_S = float(os.environ.get("RPA_LOG_SHIP_INTERVAL_S", "30"))
|
||||
|
||||
# Mise à jour silencieuse du client Léa (DETTE-022 v2).
|
||||
# Le client interroge le serveur (GET /api/v1/agents/update/check), télécharge
|
||||
# le ZIP en staging et vérifie le SHA256. Le SWAP réel des fichiers / l'édition
|
||||
# de Lea.bat / le redémarrage restent RÉSERVÉS RÉVISION HUMAINE (voir
|
||||
# network/updater.py : stubs apply_update / write_boot_ok_marker).
|
||||
# Défaut PRUDENT = désactivé : activé poste par poste via config.txt / variable
|
||||
# d'environnement, sans rebuild de l'installateur (même esprit que LOG_SHIP).
|
||||
AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes", "on",
|
||||
)
|
||||
# Intervalle entre deux interrogations serveur pour une MAJ (secondes).
|
||||
# Défaut 1 h : une MAJ n'est jamais urgente ; on interroge peu pour ne pas
|
||||
# charger le réseau clinique. Le check ne fait de toute façon aucun swap.
|
||||
AUTO_UPDATE_INTERVAL_S = float(os.environ.get("RPA_AUTO_UPDATE_INTERVAL_S", "3600"))
|
||||
# Dossier de STAGING des ZIP d'update (jamais les fichiers vivants). Équivalent
|
||||
# de `Lea_next\\`. Sous LOCALAPPDATA en prod Windows, sinon à côté de l'agent.
|
||||
AUTO_UPDATE_STAGING_DIR = os.environ.get(
|
||||
"RPA_AUTO_UPDATE_STAGING_DIR",
|
||||
str(BASE_DIR / "_update_staging"),
|
||||
)
|
||||
|
||||
# Monitoring
|
||||
PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
@@ -32,6 +32,7 @@ from pynput.keyboard import Key, KeyCode
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
from ..vision.system_info import get_screen_metadata
|
||||
from .log_safe import _sanitize_metadata
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -676,7 +677,7 @@ class EventCaptorV1:
|
||||
metadata = get_screen_metadata()
|
||||
with self._screen_metadata_lock:
|
||||
self._screen_metadata = metadata
|
||||
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
|
||||
logger.debug(f"Métadonnées système rafraîchies : {_sanitize_metadata(metadata)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur refresh métadonnées système : {e}")
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Dict, Optional
|
||||
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
|
||||
# Sans cela, pynput et mss utilisent des coordonnees logiques (virtualisees).
|
||||
from ..config import MACHINE_ID as _ # noqa: F401 — side-effect import
|
||||
from .log_safe import _title_hash
|
||||
|
||||
import mss
|
||||
from pynput.mouse import Button, Controller as MouseController
|
||||
@@ -862,7 +863,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
|
||||
f"fenetre -> bouton '{button_text}' "
|
||||
f"[{resolved.get('method', 'server')}]"
|
||||
)
|
||||
@@ -890,7 +891,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
|
||||
f"fenetre -> bouton '{button_text}' [dialog_window_text_template]"
|
||||
)
|
||||
return handled
|
||||
@@ -917,7 +918,7 @@ class ActionExecutorV1:
|
||||
)
|
||||
if handled:
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere par geometrie "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere par geometrie "
|
||||
f"fenetre -> bouton '{button_text}'"
|
||||
)
|
||||
return handled
|
||||
@@ -967,7 +968,7 @@ class ActionExecutorV1:
|
||||
if not handled:
|
||||
continue
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
|
||||
f"-> bouton '{button_text}' [{resolved.get('method', 'server')}]"
|
||||
)
|
||||
return handled
|
||||
@@ -992,13 +993,13 @@ class ActionExecutorV1:
|
||||
if not handled:
|
||||
continue
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
|
||||
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
|
||||
f"-> bouton '{button_text}' [dialog_text_template]"
|
||||
)
|
||||
return handled
|
||||
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] Aucun bouton resolu pour '{current_title}'"
|
||||
f"[RUNTIME-DIALOG] Aucun bouton resolu pour [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1258,7 +1259,7 @@ class ActionExecutorV1:
|
||||
|
||||
if dialog_spec.get("skip_current_action_after_handle", False):
|
||||
logger.info(
|
||||
f"[RUNTIME-DIALOG] Dialogue '{current_title}' gere -> "
|
||||
f"[RUNTIME-DIALOG] Dialogue [title_hash={_title_hash(current_title)}] gere -> "
|
||||
f"action {action.get('action_id', 'unknown')} skippée"
|
||||
)
|
||||
return {
|
||||
@@ -1587,7 +1588,7 @@ class ActionExecutorV1:
|
||||
]
|
||||
for pattern in popup_patterns:
|
||||
if pattern in current_title:
|
||||
logger.info(f"Observer : popup détectée par titre — '{current_title}'")
|
||||
logger.info(f"Observer : popup détectée par titre — [title_hash={_title_hash(current_title)}]")
|
||||
# On ne peut pas résoudre les coords juste par le titre
|
||||
# → retourner popup sans coords, le caller fera handle_popup_vlm()
|
||||
return {
|
||||
@@ -1874,8 +1875,8 @@ class ActionExecutorV1:
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
|
||||
f"actuel '{current_title}'"
|
||||
f"[LEA] Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
auto_result = self._maybe_handle_runtime_dialog_before_pause(
|
||||
action=action,
|
||||
@@ -1888,8 +1889,8 @@ class ActionExecutorV1:
|
||||
if auto_result is not None:
|
||||
return auto_result
|
||||
print(
|
||||
f" [PRÉ-VÉRIF] Fenêtre '{current_title}' ≠ "
|
||||
f"attendu '{expected_title}' → mode apprentissage"
|
||||
f" [PRÉ-VÉRIF] Fenêtre [title_hash={_title_hash(current_title)}] ≠ "
|
||||
f"attendu [title_hash={_title_hash(expected_title)}] → mode apprentissage"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_learning_mode(
|
||||
@@ -1936,8 +1937,8 @@ class ActionExecutorV1:
|
||||
# des coordonnées devenues invalides.
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"Fenêtre incorrecte : attendu '{expected_title}', "
|
||||
f"actuel '{current_title}'"
|
||||
f"Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
result["warning"] = "wrong_window"
|
||||
result["target_description"] = expected_title
|
||||
@@ -1945,11 +1946,11 @@ class ActionExecutorV1:
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
logger.warning(
|
||||
f"[LEA] Wrong window sans correction → pause "
|
||||
f"(attendu '{expected_title}', actuel '{current_title}')"
|
||||
f"(attendu [title_hash={_title_hash(expected_title)}], actuel [title_hash={_title_hash(current_title)}])"
|
||||
)
|
||||
return result
|
||||
else:
|
||||
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
|
||||
logger.info(f"[LEA] Pré-vérif OK : [title_hash={_title_hash(current_title)}]")
|
||||
|
||||
# ── OBSERVER : pré-analyse écran avant résolution ──
|
||||
# Détecte popups, dialogues, états inattendus AVANT de chercher la cible.
|
||||
@@ -1964,8 +1965,8 @@ class ActionExecutorV1:
|
||||
# Popup détectée AVANT la résolution — la fermer
|
||||
popup_label = observation.get("popup_label", "popup")
|
||||
popup_coords = observation.get("popup_coords")
|
||||
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
|
||||
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
|
||||
print(f" [OBSERVER] Popup détectée : [title_hash={_title_hash(popup_label)}] — fermeture")
|
||||
logger.info(f"Observer : popup [title_hash={_title_hash(popup_label)}] détectée avant résolution")
|
||||
|
||||
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
|
||||
# Avant de suivre les coordonnées du serveur (VLM-based,
|
||||
@@ -2365,8 +2366,8 @@ class ActionExecutorV1:
|
||||
recheck_title = recheck_info.get("title", "")
|
||||
if not _matches_expected_window(recheck_title):
|
||||
logger.warning(
|
||||
f"P0.9 transition instable : matched '{post_title}' "
|
||||
f"puis '{recheck_title}' à T+0.5s ≠ '{expected_after}'"
|
||||
f"P0.9 transition instable : matched [title_hash={_title_hash(post_title)}] "
|
||||
f"puis [title_hash={_title_hash(recheck_title)}] à T+0.5s ≠ [title_hash={_title_hash(expected_after)}]"
|
||||
)
|
||||
matched = False
|
||||
post_title = recheck_title
|
||||
@@ -2376,19 +2377,19 @@ class ActionExecutorV1:
|
||||
result["runtime_dialog"] = runtime_dialog_handled
|
||||
print(
|
||||
f" [POST-VÉRIF] Dialogue runtime géré "
|
||||
f"→ retour '{post_title}'"
|
||||
f"→ retour [title_hash={_title_hash(post_title)}]"
|
||||
)
|
||||
logger.info(
|
||||
"POST-VÉRIF runtime dialog géré : '%s' -> '%s'",
|
||||
runtime_dialog_handled.get("dialog_title", ""),
|
||||
post_title,
|
||||
"POST-VÉRIF runtime dialog géré : [title_hash=%s] -> [title_hash=%s]",
|
||||
_title_hash(runtime_dialog_handled.get("dialog_title", "")),
|
||||
_title_hash(post_title),
|
||||
)
|
||||
else:
|
||||
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — '{post_title}'")
|
||||
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : '{post_title}'")
|
||||
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — [title_hash={_title_hash(post_title)}]")
|
||||
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : [title_hash={_title_hash(post_title)}]")
|
||||
else:
|
||||
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — '{post_title}' ≠ '{expected_after}'")
|
||||
logger.warning(f"POST-VÉRIF TIMEOUT : '{post_title}' ≠ '{expected_after}'")
|
||||
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
|
||||
logger.warning(f"POST-VÉRIF TIMEOUT : [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
|
||||
if runtime_dialog_handled:
|
||||
result["warning"] = (
|
||||
f"runtime_dialog_handled_post_verify:{post_title}"
|
||||
@@ -2396,9 +2397,9 @@ class ActionExecutorV1:
|
||||
result["runtime_dialog"] = runtime_dialog_handled
|
||||
logger.warning(
|
||||
"POST-VÉRIF runtime dialog géré mais "
|
||||
"fenêtre finale inattendue : '%s' ≠ '%s'",
|
||||
post_title,
|
||||
expected_after,
|
||||
"fenêtre finale inattendue : [title_hash=%s] ≠ [title_hash=%s]",
|
||||
_title_hash(post_title),
|
||||
_title_hash(expected_after),
|
||||
)
|
||||
# Contrôle strict : si success_strict, on STOP.
|
||||
# On durcit aussi les vrais changements de fenêtre
|
||||
@@ -2416,8 +2417,8 @@ class ActionExecutorV1:
|
||||
if bool(action.get("success_strict")) or requires_transition:
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"Post-vérif échouée : fenêtre '{post_title}' "
|
||||
f"au lieu de '{expected_after}'"
|
||||
f"Post-vérif échouée : fenêtre [title_hash={_title_hash(post_title)}] "
|
||||
f"au lieu de [title_hash={_title_hash(expected_after)}]"
|
||||
)
|
||||
result["warning"] = "wrong_window"
|
||||
result["needs_human"] = True
|
||||
@@ -2458,7 +2459,7 @@ class ActionExecutorV1:
|
||||
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
|
||||
self._type_text(text, paste=bool(action.get("paste", False)))
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
|
||||
logger.info(f"Replay type : [{len(text)} chars] (raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "key_combo":
|
||||
keys = action.get("keys", [])
|
||||
@@ -2524,12 +2525,12 @@ class ActionExecutorV1:
|
||||
if not self._window_title_matches_any(current_title, patterns):
|
||||
logger.warning(
|
||||
"[LEA] verify_screen garde KO : attendu un titre "
|
||||
"contenant %s, actuel '%s'",
|
||||
patterns, current_title,
|
||||
"contenant %s, actuel [title_hash=%s]",
|
||||
patterns, _title_hash(current_title),
|
||||
)
|
||||
print(
|
||||
f" [VERIFY] Garde titre KO "
|
||||
f"(patterns={patterns}, actuel='{current_title}') "
|
||||
f"(patterns={patterns}, actuel=[title_hash={_title_hash(current_title)}]) "
|
||||
"→ apprentissage humain"
|
||||
)
|
||||
try:
|
||||
@@ -2557,15 +2558,15 @@ class ActionExecutorV1:
|
||||
result["error"] = (
|
||||
f"verify_screen titre fenêtre KO : attendu "
|
||||
f"un titre contenant {patterns}, "
|
||||
f"actuel '{current_title}'"
|
||||
f"actuel [title_hash={_title_hash(current_title)}]"
|
||||
)
|
||||
result["warning"] = "setup_guard_window_mismatch"
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
logger.info(
|
||||
"[LEA] verify_screen garde OK : '%s' matche %s",
|
||||
current_title, patterns,
|
||||
"[LEA] verify_screen garde OK : [title_hash=%s] matche %s",
|
||||
_title_hash(current_title), patterns,
|
||||
)
|
||||
|
||||
print(f" [VERIFY] Termine (verification deferred au serveur).")
|
||||
@@ -3736,8 +3737,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
real_x = int(x_pct * sw)
|
||||
real_y = int(y_pct * sh)
|
||||
label = server_result.get("matched_element", {}).get("label", "popup")
|
||||
print(f" [POPUP-SERVER] Popup détectée ! Clic sur '{label}' → ({real_x}, {real_y})")
|
||||
logger.info(f"[POPUP-SERVER] Clic popup '{label}' à ({real_x}, {real_y})")
|
||||
print(f" [POPUP-SERVER] Popup détectée ! Clic sur [title_hash={_title_hash(label)}] → ({real_x}, {real_y})")
|
||||
logger.info(f"[POPUP-SERVER] Clic popup [title_hash={_title_hash(label)}] à ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
@@ -3856,8 +3857,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
raw_content = resp.json().get("message", {}).get("content", "")
|
||||
full_response = prefill + raw_content
|
||||
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : {full_response.strip()}")
|
||||
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : {full_response.strip()}")
|
||||
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : [len={len(full_response)}, has_target={'target' in full_response}]")
|
||||
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : [len={len(full_response)}, has_target={'target' in full_response}]")
|
||||
|
||||
# Extraire le texte du bouton depuis la réponse
|
||||
button_text = raw_content.strip().strip('"').strip("'").strip(".")
|
||||
@@ -4172,7 +4173,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
try:
|
||||
self.keyboard.type(char)
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de taper '{char}': {e}")
|
||||
logger.debug(f"Impossible de taper [1 char typed]: {e}")
|
||||
# Délai humain entre les frappes (40-120ms)
|
||||
time.sleep(random.uniform(0.04, 0.12))
|
||||
|
||||
|
||||
48
agent_v0/agent_v1/core/log_safe.py
Normal file
48
agent_v0/agent_v1/core/log_safe.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Helpers de logging PII-safe pour le client Léa (agent_v1).
|
||||
|
||||
Convention : ne jamais logger le contenu brut d'une variable utilisateur
|
||||
(texte tapé, titre de fenêtre, nom de workflow, réponse VLM, chemin fichier).
|
||||
Le remplacer par :
|
||||
- une longueur ou un hash court (corrélation de diagnostic sans révéler) ;
|
||||
- un dict de métadonnées filtré (sans titre / fenêtre active).
|
||||
|
||||
À importer dans tout module d'agent_v1 qui logge une donnée potentiellement
|
||||
sensible. Branche feat/push-log-dgx — DETTE-020 (assainissement à la source).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
def _title_hash(title: str) -> str:
|
||||
"""Hash SHA1 tronqué (8 hex) d'un titre.
|
||||
|
||||
Corrélation stable (même titre → même hash → « même popup re-détectée »)
|
||||
sans exposer le contenu. `errors="replace"` pour ne jamais lever sur un
|
||||
encodage exotique (titres Windows multi-langues).
|
||||
"""
|
||||
return hashlib.sha1((title or "").encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||
|
||||
|
||||
# Clés de métadonnées susceptibles de contenir du contenu utilisateur (PII).
|
||||
_PII_METADATA_KEYS = ("title", "active_window", "window_title")
|
||||
|
||||
|
||||
def _sanitize_metadata(metadata: dict) -> dict:
|
||||
"""Copie d'un dict de métadonnées sans les clés porteuses de PII.
|
||||
|
||||
Garde les champs techniques (resolution, dpi, theme, langue…), retire
|
||||
titre / fenêtre active. Ne mute pas le dict d'origine.
|
||||
"""
|
||||
return {k: v for k, v in metadata.items() if k not in _PII_METADATA_KEYS}
|
||||
|
||||
|
||||
def _path_ext(path: str) -> str:
|
||||
"""Extension seule d'un chemin (ex. « .png »), sans nom ni dossier.
|
||||
|
||||
Un chemin peut nommer un patient ; l'extension suffit au diagnostic.
|
||||
Chaîne vide si pas de chemin ou pas d'extension.
|
||||
"""
|
||||
return os.path.splitext(path)[1] if path else ""
|
||||
@@ -24,6 +24,8 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .log_safe import _title_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -168,8 +170,8 @@ class RecoveryEngine:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
active = get_active_window_info()
|
||||
active_title = active.get("title", "")
|
||||
logger.info(f"Recovery : Alt+F4 sur '{active_title}'")
|
||||
print(f" [RECOVERY] Alt+F4 — fermeture de '{active_title}'")
|
||||
logger.info(f"Recovery : Alt+F4 sur [title_hash={_title_hash(active_title)}]")
|
||||
print(f" [RECOVERY] Alt+F4 — fermeture de [title_hash={_title_hash(active_title)}]")
|
||||
except Exception:
|
||||
logger.info("Recovery : Alt+F4 (fenêtre active inconnue)")
|
||||
print(" [RECOVERY] Alt+F4 — fermeture fenêtre indésirable")
|
||||
@@ -182,7 +184,7 @@ class RecoveryEngine:
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.CLOSE_WINDOW,
|
||||
success=True,
|
||||
detail=f"Alt+F4 exécuté sur '{active_title if 'active_title' in dir() else '?'}'",
|
||||
detail=f"Alt+F4 exécuté sur [title_hash={_title_hash(active_title) if 'active_title' in dir() else '?'}]",
|
||||
)
|
||||
|
||||
elif strategy == RecoveryAction.CLICK_AWAY:
|
||||
|
||||
56
agent_v0/agent_v1/logging_setup.py
Normal file
56
agent_v0/agent_v1/logging_setup.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Journalisation client Léa — DETTE-021.
|
||||
|
||||
Branche un handler **fichier** (`TimedRotatingFileHandler`) sur le logger racine,
|
||||
en plus de la console. Sans cela, sous `pythonw.exe` (pas de console), les logs
|
||||
partent sur stderr et sont **perdus** — diagnostic terrain impossible.
|
||||
|
||||
Rotation quotidienne + rétention `retention_days` (Règlement IA Art. 12 :
|
||||
journalisation automatique + conservation minimum 180 j).
|
||||
"""
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
_FMT = "%(asctime)s %(levelname)-7s %(name)-25s %(message)s"
|
||||
|
||||
|
||||
def setup_logging(log_file, level=logging.INFO, retention_days=180):
|
||||
"""Configure le logging racine : fichier (rotation quotidienne, `retention_days`
|
||||
fichiers conservés) + console. **Idempotent** : ne réempile pas nos handlers.
|
||||
|
||||
Args:
|
||||
log_file: chemin du fichier de log (`config.LOG_FILE` en prod).
|
||||
level: niveau racine (INFO par défaut ; DEBUG géré par l'appelant).
|
||||
retention_days: nb de fichiers quotidiens conservés (180 = Règlement IA Art. 12).
|
||||
|
||||
Returns:
|
||||
Le `TimedRotatingFileHandler` créé.
|
||||
"""
|
||||
log_file = Path(log_file)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
# Idempotence : retirer nos propres handlers posés par un appel précédent.
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "_lea_managed", False):
|
||||
h.close()
|
||||
root.removeHandler(h)
|
||||
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
str(log_file), when="midnight", backupCount=retention_days, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(_FMT, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
file_handler.setLevel(level)
|
||||
file_handler._lea_managed = True
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# Console conservée (utile en dev / si lancé avec une console).
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(logging.Formatter(_FMT, datefmt="%H:%M:%S"))
|
||||
console.setLevel(level)
|
||||
console._lea_managed = True
|
||||
root.addHandler(console)
|
||||
|
||||
return file_handler
|
||||
@@ -15,9 +15,10 @@ import time
|
||||
import logging
|
||||
import threading
|
||||
from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
|
||||
STREAMING_ENDPOINT,
|
||||
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S,
|
||||
AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
@@ -29,6 +30,7 @@ from .ui.capture_server import CaptureServer
|
||||
from .session.storage import SessionStorage
|
||||
from .vision.capturer import VisionCapturer
|
||||
from .finalize_contract import dispatch_finalize_result
|
||||
from .core.log_safe import _title_hash
|
||||
|
||||
# Import optionnel du client serveur (pour le chat et les workflows)
|
||||
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
|
||||
@@ -43,16 +45,44 @@ except (ImportError, ValueError):
|
||||
# Configuration du logging — format structuré et lisible pour un TIM
|
||||
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
|
||||
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
# DETTE-021 : journaliser dans un FICHIER (rotation quotidienne + rétention 180 j,
|
||||
# Règlement IA Art. 12). Sous `pythonw.exe` (sans console), un basicConfig→stderr
|
||||
# serait perdu. Fallback console si le fichier est indisponible — ne JAMAIS
|
||||
# empêcher Léa de démarrer pour un problème de log.
|
||||
try:
|
||||
from .logging_setup import setup_logging
|
||||
setup_logging(LOG_FILE, level=_log_level, retention_days=LOG_RETENTION_DAYS)
|
||||
except Exception:
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# Réduire le bruit de certaines libs
|
||||
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
# push-log-DGX : remontée automatique des logs vers le serveur (diagnostic des
|
||||
# postes SANS AnyDesk). GARDÉ derrière RPA_LOG_SHIP_ENABLED (défaut désactivé) —
|
||||
# activable poste par poste via config.txt, sans rebuild. Le handler est attaché
|
||||
# au logger racine APRÈS setup_logging (les logs partent aussi dans le fichier).
|
||||
_log_shipper = None
|
||||
if LOG_SHIP_ENABLED:
|
||||
try:
|
||||
from .network.log_shipper import LogShipper
|
||||
_log_shipper = LogShipper(
|
||||
machine_id=MACHINE_ID,
|
||||
max_batch=int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000")),
|
||||
flush_interval_s=LOG_SHIP_INTERVAL_S,
|
||||
)
|
||||
logging.getLogger().addHandler(_log_shipper.handler)
|
||||
_log_shipper.start()
|
||||
except Exception as _e:
|
||||
# Ne JAMAIS empêcher Léa de démarrer pour un problème de remontée de logs.
|
||||
logging.getLogger(__name__).warning("Log shipper non démarré : %s", _e)
|
||||
_log_shipper = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalle de polling replay (secondes)
|
||||
@@ -129,6 +159,31 @@ class AgentV1:
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||
|
||||
# DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF).
|
||||
# Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap
|
||||
# réel reste réservé révision humaine (updater.apply_update = stub no-op).
|
||||
# Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild.
|
||||
if AUTO_UPDATE_ENABLED:
|
||||
threading.Thread(
|
||||
target=self._auto_update_loop, daemon=True, name="lea-auto-update"
|
||||
).start()
|
||||
|
||||
# MAJ silencieuse — confirmation de boot post-swap. Si Lea.bat vient
|
||||
# d'appliquer une MAJ (marqueur PENDING_BOOT), on désarme le rollback
|
||||
# après ~90 s de tourne STABLE (liveness LOCALE, indépendante du DGX).
|
||||
# Un quit propre avant 90 s confirme aussi (cf. main()). Seul un vrai
|
||||
# crash laisse PENDING_BOOT → rollback au prochain lancement.
|
||||
if _pending_boot_marker_exists():
|
||||
def _boot_confirm():
|
||||
import os as _os
|
||||
import time as _time
|
||||
_time.sleep(float(_os.environ.get("RPA_BOOT_CONFIRM_DELAY_S", "90")))
|
||||
if self.running:
|
||||
_confirm_boot_ok()
|
||||
threading.Thread(
|
||||
target=_boot_confirm, daemon=True, name="lea-boot-confirm"
|
||||
).start()
|
||||
|
||||
# Mini-serveur HTTP pour captures a la demande (port 5006)
|
||||
self._capture_server = CaptureServer()
|
||||
self._capture_server.start()
|
||||
@@ -253,7 +308,7 @@ class AgentV1:
|
||||
# Ne PAS en relancer une ici — deux threads poll simultanés causent
|
||||
# une race condition où les actions sont consommées mais pas exécutées.
|
||||
|
||||
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
|
||||
logger.info(f"Session {self.session_id} [wf_hash={_title_hash(workflow_name)}] sur machine {self.machine_id} en cours...")
|
||||
|
||||
def _command_watchdog_loop(self):
|
||||
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
||||
@@ -412,6 +467,67 @@ class AgentV1:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _auto_update_loop(self):
|
||||
"""DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF).
|
||||
|
||||
Interroge périodiquement le serveur (endpoint canary-aware), et si une
|
||||
MAJ est proposée pour CE poste, la télécharge dans le STAGING après
|
||||
vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle`
|
||||
s'arrête au staging (apply_update = stub réservé révision humaine + swap
|
||||
hors-process par Lea.bat au prochain démarrage).
|
||||
|
||||
SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement
|
||||
ou un replay actif (self.session_id / self._replay_active), pour ne pas
|
||||
perturber le travail utilisateur ni consommer du réseau au mauvais
|
||||
moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa).
|
||||
"""
|
||||
try:
|
||||
from .network.updater import run_update_cycle
|
||||
except Exception as e:
|
||||
logger.warning("[UPDATE] Module updater indisponible : %s", e)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, "
|
||||
"version=%s) — check seul, swap réservé révision humaine",
|
||||
AUTO_UPDATE_INTERVAL_S, AGENT_VERSION,
|
||||
)
|
||||
|
||||
while self.running:
|
||||
# Découpe l'attente pour réagir vite à l'arrêt.
|
||||
waited = 0.0
|
||||
step = 1.0
|
||||
while self.running and waited < AUTO_UPDATE_INTERVAL_S:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# « Au bon moment » : jamais en plein travail (enregistrement/replay).
|
||||
if self.session_id or getattr(self, "_replay_active", False):
|
||||
logger.debug("[UPDATE] Report du check (session/replay active)")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = run_update_cycle(
|
||||
local_version=AGENT_VERSION,
|
||||
machine_id=self.machine_id,
|
||||
staging_dir=AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
status = result.get("status")
|
||||
if status == "staged":
|
||||
logger.info(
|
||||
"[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — "
|
||||
"swap réservé révision humaine, non appliqué",
|
||||
result.get("target_version"),
|
||||
result.get("sha256_verified"),
|
||||
)
|
||||
elif status not in ("up_to_date", "disabled"):
|
||||
logger.debug("[UPDATE] Cycle: %s", result)
|
||||
except Exception as e:
|
||||
# run_update_cycle est déjà best-effort ; double filet ici.
|
||||
logger.debug("[UPDATE] Erreur boucle MAJ : %s", e)
|
||||
|
||||
def stop_session(self):
|
||||
# Sauvegarder le session_id avant de l'annuler (pour les logs)
|
||||
ended_session_id = self.session_id
|
||||
@@ -578,29 +694,20 @@ class AgentV1:
|
||||
def run(self):
|
||||
self.ui.run()
|
||||
|
||||
def _headless_keepalive(agent):
|
||||
"""Maintient le main thread vivant quand l'UI tray ne peut pas tourner.
|
||||
def _install_signal_handlers(agent, watchdog) -> None:
|
||||
"""Installe SIGTERM/SIGINT/SIGBREAK pour un arrêt propre du main thread.
|
||||
|
||||
Sans cela, ``agent.run()`` retourne immédiatement (pystray échoue quand
|
||||
Léa est lancée via SSH sans session interactive Windows), le main thread
|
||||
se termine, et TOUS les daemon threads — y compris ``_replay_poll_loop``
|
||||
— meurent avec lui. Observé 3 fois en 24h les 24/05 :
|
||||
- SSH ``Permission denied`` (1231)
|
||||
- polls morts après relance distante (1620)
|
||||
- polls morts ``replay_sess_506d6fa2`` (1627)
|
||||
|
||||
Le keepalive ne se déclenche QUE si ``agent.run()`` est sorti tout en
|
||||
laissant ``agent.running=True`` (cas anormal). En mode interactif
|
||||
normal, ``pystray.Icon.run()`` ne sort jamais, donc ce code est
|
||||
invisible.
|
||||
Met ``agent.running=False`` (les daemon threads s'arrêtent) et réveille
|
||||
le watchdog (qui sort de sa boucle de surveillance). Sans session
|
||||
interactive (pystray.Icon.stop indisponible), c'est le SEUL moyen
|
||||
d'arrêter Léa proprement : ``kill -TERM <pid>`` ou Ctrl+C.
|
||||
"""
|
||||
import signal as _sig
|
||||
_stop = threading.Event()
|
||||
|
||||
def _handler(sig, frame):
|
||||
logger.info(f"[MAIN] Signal {sig} recu — arret propre")
|
||||
_stop.set()
|
||||
agent.running = False
|
||||
watchdog.stop()
|
||||
|
||||
for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"):
|
||||
sig_obj = getattr(_sig, sig_name, None)
|
||||
@@ -611,33 +718,78 @@ def _headless_keepalive(agent):
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"[MAIN] Keepalive headless actif — main thread bloque pour maintenir "
|
||||
"les daemon threads (_replay_poll_loop, heartbeat, capture_server) vivants. "
|
||||
"Pour stopper Lea : kill -TERM <pid> ou Ctrl+C."
|
||||
)
|
||||
|
||||
def _agent_should_live(agent) -> bool:
|
||||
"""Vrai tant que Léa doit vivre : agent actif ET pas de Quitter explicite.
|
||||
|
||||
Un « Quitter » utilisateur (``ui._quit_requested``) doit stopper le
|
||||
watchdog pour de bon ; une simple déconnexion RDP ne met JAMAIS ce flag
|
||||
→ le tray revient tout seul à la reconnexion.
|
||||
"""
|
||||
if not getattr(agent, "running", False):
|
||||
return False
|
||||
ui = getattr(agent, "ui", None)
|
||||
if ui is not None and getattr(ui, "_quit_requested", False):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _pending_boot_marker_exists() -> bool:
|
||||
"""True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider)."""
|
||||
try:
|
||||
_stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
agent.running = False
|
||||
logger.info("[MAIN] Keepalive termine — agent.running=False, daemon threads vont s'arreter")
|
||||
from .network.updater import _resolve_app_dir
|
||||
return (_resolve_app_dir(None) / "PENDING_BOOT").exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _confirm_boot_ok() -> None:
|
||||
"""Confirme un boot post-MAJ : écrit boot_ok + retire PENDING_BOOT.
|
||||
|
||||
Désarme le rollback de Lea.bat. No-op si pas de PENDING_BOOT (boot normal).
|
||||
Best-effort — ne doit jamais casser l'arrêt/la vie de Léa.
|
||||
"""
|
||||
try:
|
||||
if not _pending_boot_marker_exists():
|
||||
return
|
||||
from .network import updater
|
||||
updater.write_boot_ok_marker(AGENT_VERSION)
|
||||
logger.info("[MAJ] Boot confirmé (v%s) — rollback désarmé", AGENT_VERSION)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("confirm_boot_ok: %s", e)
|
||||
|
||||
|
||||
def main():
|
||||
agent = AgentV1()
|
||||
try:
|
||||
agent.run()
|
||||
except Exception:
|
||||
logger.exception("[MAIN] agent.run() a leve une exception")
|
||||
from .ui.session_watchdog import InteractiveSessionWatchdog
|
||||
|
||||
if getattr(agent, "running", False):
|
||||
logger.warning(
|
||||
"[MAIN] agent.run() est sorti mais agent.running=True — "
|
||||
"probablement pystray sans session interactive (SSH). "
|
||||
"Bascule en keepalive headless."
|
||||
)
|
||||
_headless_keepalive(agent)
|
||||
agent = AgentV1()
|
||||
|
||||
# Résilience RDP/Citrix : au lieu de bloquer le main thread pour toujours
|
||||
# quand pystray sort (session interactive perdue), on surveille la
|
||||
# session et on ré-affiche le tray + le chat à chaque reconnexion.
|
||||
# agent.run() (== agent.ui.run()) est ré-entrant : les threads de fond
|
||||
# ne démarrent qu'une fois, seule l'icône est recréée. Les daemon threads
|
||||
# de capture/heartbeat/replay tournent contre agent.running et restent
|
||||
# uniques — le watchdog n'y touche pas.
|
||||
watchdog = InteractiveSessionWatchdog(
|
||||
run_ui=agent.run,
|
||||
is_running=lambda: _agent_should_live(agent),
|
||||
)
|
||||
_install_signal_handlers(agent, watchdog)
|
||||
|
||||
try:
|
||||
watchdog.run()
|
||||
# Sortie normale du watchdog = quit propre (tray / session) → le boot
|
||||
# était sain : on confirme (couvre un quit AVANT les 90 s, évite un faux
|
||||
# rollback). No-op si ce n'est pas un boot post-MAJ.
|
||||
_confirm_boot_ok()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[MAIN] Interruption clavier — arret propre")
|
||||
except Exception:
|
||||
logger.exception("[MAIN] Le watchdog de session a leve une exception")
|
||||
finally:
|
||||
agent.running = False
|
||||
logger.info("[MAIN] Sortie — agent.running=False, daemon threads vont s'arreter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
317
agent_v0/agent_v1/network/log_shipper.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# agent_v1/network/log_shipper.py
|
||||
"""Remontée AUTOMATIQUE des logs du client Léa vers le serveur (push-log-DGX).
|
||||
|
||||
But : diagnostiquer les postes Windows clinique SANS AnyDesk. Les logs déjà
|
||||
écrits sur disque par `logging_setup.py` (rotation quotidienne, rétention 180 j,
|
||||
Règlement IA Art. 12) sont en plus poussés au serveur, rangés par `machine_id`,
|
||||
consultables au dashboard.
|
||||
|
||||
Serveur (déjà prêt — NE PAS toucher) :
|
||||
POST /api/v1/agents/logs
|
||||
body = {machine_id: str, logs: [{ts, level, logger, message}]}
|
||||
borne RPA_AGENT_LOGS_MAX_BATCH (défaut 1000) — 413 si dépassée.
|
||||
|
||||
Conception :
|
||||
- `LogShipperHandler(logging.Handler)` : sur `emit(record)`, formate au
|
||||
schéma EXACT `{ts, level, logger, message}`, applique un assainissement
|
||||
PII au message (défense en profondeur — la discipline `log_safe` à la
|
||||
source logue déjà des hashes/longueurs, pas du contenu brut), puis
|
||||
empile dans un buffer borné.
|
||||
- `LogShipper` : flush par BATCH (≤ max_batch) via un `sender` callable
|
||||
INJECTABLE `(machine_id, logs) -> bool`. Défaut = POST réel Bearer
|
||||
(pattern `streamer.py`).
|
||||
- Résilience (ZÉRO perte) : si `sender` renvoie False ou lève, les logs
|
||||
RESTENT dans le buffer et sont rejoués au flush suivant. Le fichier de
|
||||
log local reste de toute façon la source durable (survit au crash) ; le
|
||||
buffer RAM est un best-effort de remontée, volontairement NON persisté en
|
||||
SQLite (le `PersistentBuffer` est session/event-scoped — y mêler des logs
|
||||
polluerait la DB d'events). Borne mémoire = `max_buffer` (drop des plus
|
||||
VIEUX au-delà — un log récent vaut mieux qu'un vieux pour le diagnostic).
|
||||
|
||||
Pattern d'import PII : on tente `anonymize_text` (server_v1.pii_sanitizer,
|
||||
source de vérité des tokens typés) via le même import paresseux tolérant que
|
||||
`ui/messages.py`. Sur un vrai poste (sans server_v1), on retombe sur l'identité :
|
||||
acceptable car la PII de message est déjà neutralisée à la source par la
|
||||
discipline `log_safe`. Le sanitizer reste INJECTABLE pour les tests/évolutions.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Callable, Deque, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Schéma d'une entrée de log poussée au serveur.
|
||||
# ts : epoch (float) — l'heure de l'évènement
|
||||
# level : nom du niveau ("INFO", "WARNING"...)
|
||||
# logger : nom du logger (record.name)
|
||||
# message : message formaté (args interpolés) ET assaini PII
|
||||
|
||||
# Défaut aligné sur la borne serveur RPA_AGENT_LOGS_MAX_BATCH (api_stream.py).
|
||||
DEFAULT_MAX_BATCH = 1000
|
||||
|
||||
# Borne mémoire du buffer : au-delà, on droppe les plus VIEUX (diagnostic =
|
||||
# on préfère les logs récents). Quelques milliers d'entrées = quelques Mo RAM.
|
||||
DEFAULT_MAX_BUFFER = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assainissement PII du message (défense en profondeur)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_message_sanitizer(text: str) -> str:
|
||||
"""Sanitizer par défaut côté client = identité.
|
||||
|
||||
Le **rempart PII des logs est le SERVEUR** : `sanitize_log_entries`
|
||||
ré-assainit chaque message à la réception (`/api/v1/agents/logs`), via le
|
||||
même `anonymize_text` que les events. Tenter un import de `server_v1` côté
|
||||
poste à CHAQUE ligne de log est inutile (absent du bundle client) et coûteux
|
||||
(exception attrapée par emit). La discipline `log_safe` neutralise déjà la
|
||||
PII à la source. Reste INJECTABLE pour tests/évolutions.
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler — empile les LogRecords dans un buffer partagé
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipperHandler(logging.Handler):
|
||||
"""Handler logging qui sérialise chaque record et l'empile pour envoi.
|
||||
|
||||
Ne fait AUCUN réseau : il alimente seulement le buffer du `LogShipper`.
|
||||
L'envoi est piloté par `LogShipper.flush()` (thread dédié périodique).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buffer: Deque[Dict],
|
||||
lock: threading.Lock,
|
||||
message_sanitizer: Callable[[str], str],
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
level=logging.NOTSET,
|
||||
):
|
||||
super().__init__(level=level)
|
||||
self._buffer = buffer
|
||||
self._lock = lock
|
||||
self._sanitize = message_sanitizer
|
||||
self._max_buffer = max_buffer
|
||||
|
||||
def _format_record(self, record: logging.LogRecord) -> Dict:
|
||||
"""Construit l'entrée au schéma EXACT {ts, level, logger, message}.
|
||||
|
||||
`record.getMessage()` interpole les args (%s...). Le message est ensuite
|
||||
passé au sanitizer PII. Tolérant : un message non formatable ne doit pas
|
||||
faire perdre l'entrée.
|
||||
"""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
message = str(record.msg)
|
||||
try:
|
||||
message = self._sanitize(message)
|
||||
except Exception:
|
||||
# Le sanitizer ne doit jamais casser le logging.
|
||||
pass
|
||||
return {
|
||||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Sérialise et empile le record (best-effort, ne lève jamais)."""
|
||||
try:
|
||||
entry = self._format_record(record)
|
||||
with self._lock:
|
||||
# deque(maxlen) droppe automatiquement le plus VIEUX au-delà
|
||||
# de la borne — pas de croissance mémoire non bornée.
|
||||
self._buffer.append(entry)
|
||||
except Exception:
|
||||
# handleError respecte logging.raiseExceptions (silencieux en prod).
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shipper — flush périodique par batch via un sender injectable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LogShipper:
|
||||
"""Orchestre la remontée des logs : buffer + flush par batch.
|
||||
|
||||
Args:
|
||||
machine_id : identifiant du poste (config.MACHINE_ID en prod).
|
||||
sender : callable INJECTABLE `(machine_id, logs) -> bool`. True =
|
||||
accusé de réception serveur. Défaut = POST réel Bearer.
|
||||
max_batch : taille max d'un batch (≤ borne serveur). Défaut 1000.
|
||||
max_buffer : borne mémoire du buffer (drop des plus vieux au-delà).
|
||||
message_sanitizer : assainissement PII du message. Défaut = pii_sanitizer
|
||||
si disponible, sinon identité.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
machine_id: str,
|
||||
sender: Optional[Callable[[str, List[Dict]], bool]] = None,
|
||||
max_batch: int = DEFAULT_MAX_BATCH,
|
||||
max_buffer: int = DEFAULT_MAX_BUFFER,
|
||||
message_sanitizer: Optional[Callable[[str], str]] = None,
|
||||
flush_interval_s: float = 30.0,
|
||||
):
|
||||
self.machine_id = machine_id
|
||||
self.max_batch = max(1, int(max_batch))
|
||||
self.flush_interval_s = flush_interval_s
|
||||
self._sender = sender if sender is not None else self._default_sender
|
||||
self._sanitize = message_sanitizer or _default_message_sanitizer
|
||||
self._lock = threading.Lock()
|
||||
self._buffer: Deque[Dict] = deque(maxlen=max_buffer)
|
||||
self.handler = LogShipperHandler(
|
||||
buffer=self._buffer,
|
||||
lock=self._lock,
|
||||
message_sanitizer=self._sanitize,
|
||||
max_buffer=max_buffer,
|
||||
)
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection (diagnostic / tests)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def peek_buffer(self) -> List[Dict]:
|
||||
"""Copie des entrées en attente (lecture seule, pour diagnostic/tests)."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def pending(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._buffer)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flush — envoie le buffer par batches ≤ max_batch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def flush(self) -> int:
|
||||
"""Envoie le buffer par batches successifs. Retourne le nb de logs ACK.
|
||||
|
||||
Résilience ZÉRO perte : on retire un batch du buffer, on tente l'envoi.
|
||||
- Succès → les entrées sont définitivement consommées.
|
||||
- Échec (False ou exception) → on REMET les entrées en tête du buffer
|
||||
et on ARRÊTE la passe (serveur probablement down) ; rejeu au flush
|
||||
suivant. Les entrées non encore extraites restent en place.
|
||||
"""
|
||||
sent = 0
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._buffer:
|
||||
break
|
||||
batch: List[Dict] = []
|
||||
for _ in range(min(self.max_batch, len(self._buffer))):
|
||||
batch.append(self._buffer.popleft())
|
||||
|
||||
try:
|
||||
ok = self._sender(self.machine_id, batch)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
logger.debug("Log shipper sender a levé : %s", e)
|
||||
|
||||
if ok:
|
||||
sent += len(batch)
|
||||
continue
|
||||
|
||||
# Échec : on remet le batch en tête (ordre préservé) et on arrête.
|
||||
with self._lock:
|
||||
self._buffer.extendleft(reversed(batch))
|
||||
break
|
||||
|
||||
return sent
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sender réel — POST Bearer (pattern streamer.py)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers() -> dict:
|
||||
"""Headers Bearer (pattern streamer.py)."""
|
||||
try:
|
||||
from ..config import API_TOKEN
|
||||
except Exception:
|
||||
API_TOKEN = ""
|
||||
if API_TOKEN:
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
return {}
|
||||
|
||||
def _default_sender(self, machine_id: str, logs: List[Dict]) -> bool:
|
||||
"""POST réel vers /api/v1/agents/logs. True si HTTP 2xx.
|
||||
|
||||
Best-effort : tout échec réseau/serveur → False (logs conservés,
|
||||
rejoués). Aucune exception ne remonte au-delà du sender.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
from ..config import SERVER_URL
|
||||
|
||||
url = f"{SERVER_URL}/agents/logs"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"machine_id": machine_id, "logs": logs},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
return bool(resp.ok)
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper POST échoué : %s", e)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boucle de flush périodique (thread daemon)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Démarre le thread de flush périodique (idempotent)."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="lea-log-shipper"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"Log shipper démarré (machine_id=%s, intervalle=%.0fs, batch≤%d)",
|
||||
self.machine_id, self.flush_interval_s, self.max_batch,
|
||||
)
|
||||
|
||||
def stop(self, final_flush: bool = True) -> None:
|
||||
"""Arrête la boucle et tente un dernier flush (best-effort)."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
if final_flush:
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
# Découpe l'attente pour réagir vite à stop().
|
||||
waited = 0.0
|
||||
step = 0.5
|
||||
while self._running and waited < self.flush_interval_s:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self._running:
|
||||
break
|
||||
try:
|
||||
self.flush()
|
||||
except Exception as e:
|
||||
logger.debug("Log shipper flush loop : %s", e)
|
||||
@@ -36,6 +36,7 @@ import requests
|
||||
from PIL import Image
|
||||
|
||||
from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT
|
||||
from ..core.log_safe import _title_hash
|
||||
from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer
|
||||
|
||||
|
||||
@@ -138,7 +139,7 @@ class TraceStreamer:
|
||||
target=self._buffer_drain_loop, daemon=True
|
||||
)
|
||||
self._drain_thread.start()
|
||||
logger.info(f"Streamer pour {self.session_id} démarré")
|
||||
logger.info(f"Streamer démarré")
|
||||
|
||||
def stop(self):
|
||||
"""Arrêter le streaming et finaliser la session côté serveur.
|
||||
@@ -166,7 +167,7 @@ class TraceStreamer:
|
||||
self._drain_thread.join(timeout=2.0)
|
||||
|
||||
self._finalize_session()
|
||||
logger.info(f"Streamer pour {self.session_id} arrêté")
|
||||
logger.info(f"Streamer arrêté")
|
||||
|
||||
def push_event(self, event_data: dict):
|
||||
"""Enfile un événement pour envoi immédiat.
|
||||
@@ -632,7 +633,7 @@ class TraceStreamer:
|
||||
self._check_redirect(resp, url)
|
||||
if resp.ok:
|
||||
result = resp.json()
|
||||
logger.info(f"Session finalisée: {result}")
|
||||
logger.info(f"Session finalisée [status={result.get('status')}, wf_hash={_title_hash(result.get('workflow_name',''))}]")
|
||||
if self._on_finalize_result is not None:
|
||||
try:
|
||||
self._on_finalize_result(result)
|
||||
|
||||
481
agent_v0/agent_v1/network/updater.py
Normal file
481
agent_v0/agent_v1/network/updater.py
Normal file
@@ -0,0 +1,481 @@
|
||||
# agent_v1/network/updater.py
|
||||
"""NOYAU client de la mise à jour silencieuse de Léa (DETTE-022 v2).
|
||||
|
||||
GATED — flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF). Tant qu'il est OFF,
|
||||
rien ne se déclenche : l'intégration de ce module au runtime (boucle de poll
|
||||
de `main.py`) ne fait aucune MAJ.
|
||||
|
||||
Ce module ne contient que les parties PURES / testables, sans réseau réel :
|
||||
|
||||
- `parse_version` / `is_newer` (R3) : self-contained (le bundle client
|
||||
n'embarque PAS `server_v1` — duplication assumée, même algorithme).
|
||||
- `should_update(local_version, server_response)` : décide « faut-il
|
||||
updater ? quelle version/type ? » à partir de la réponse serveur. Double
|
||||
garde semver côté client (jamais de downgrade) = défense en profondeur.
|
||||
- `download_update(plan, staging_dir, downloader)` : télécharge le ZIP via un
|
||||
`downloader` callable INJECTABLE (aucun réseau réel en test), vérifie le
|
||||
SHA256, écrit le ZIP dans le **staging** (`Lea_next\\`-like) — JAMAIS dans
|
||||
les fichiers vivants. Retourne un plan d'application.
|
||||
- `auto_update_enabled()` : lit le flag (défaut OFF).
|
||||
|
||||
⚠️ SWAP — répartition claire des responsabilités :
|
||||
`apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté
|
||||
Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un
|
||||
fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le
|
||||
rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne).
|
||||
|
||||
Pattern d'import / résilience aligné sur `log_shipper.py` (même branche).
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Niveaux de livraison (R2). `code-only` par défaut = 99 % des MAJ (~500 Ko).
|
||||
VALID_UPDATE_TYPES = ("code-only", "full")
|
||||
DEFAULT_UPDATE_TYPE = "code-only"
|
||||
|
||||
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag d'activation — OFF par défaut (lu à chaque appel pour faciliter tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def auto_update_enabled() -> bool:
|
||||
"""True si la MAJ auto client est activée (flag RPA_AUTO_UPDATE_ENABLED).
|
||||
|
||||
Défaut PRUDENT = OFF. On l'active poste par poste via config.txt / variable
|
||||
d'environnement, sans rebuild de l'installateur (même esprit que
|
||||
LOG_SHIP_ENABLED).
|
||||
"""
|
||||
return os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes", "on",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R3 — parse_version self-contained (le bundle client n'a pas server_v1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_version(v) -> Tuple[int, ...]:
|
||||
"""Parse une version semver en tuple d'entiers. Voir server_v1/update_check.
|
||||
|
||||
"1.0.2" → (1, 0, 2) ; "1.0.10" → (1, 0, 10) ; "v1.2.3" → (1, 2, 3).
|
||||
Tolérant et SANS exception : invalide → fallback `(0,)`.
|
||||
"""
|
||||
if not isinstance(v, str):
|
||||
return _FALLBACK_VERSION
|
||||
s = v.strip().lstrip("vV").strip()
|
||||
if not s:
|
||||
return _FALLBACK_VERSION
|
||||
try:
|
||||
from packaging.version import Version
|
||||
|
||||
return tuple(Version(s).release)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return tuple(int(x) for x in s.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return _FALLBACK_VERSION
|
||||
|
||||
|
||||
def is_newer(candidate: str, baseline: str) -> bool:
|
||||
"""True si `candidate` strictement plus récent que `baseline` (semver)."""
|
||||
return parse_version(candidate) > parse_version(baseline)
|
||||
|
||||
|
||||
def _normalize_update_type(update_type) -> str:
|
||||
if update_type in VALID_UPDATE_TYPES:
|
||||
return update_type
|
||||
return DEFAULT_UPDATE_TYPE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Décision client : faut-il updater ?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def should_update(local_version: str, server_response) -> Optional[dict]:
|
||||
"""Décide à partir de la réponse serveur s'il faut updater.
|
||||
|
||||
Args:
|
||||
local_version : version courante du client (config.AGENT_VERSION).
|
||||
server_response : dict renvoyé par l'endpoint serveur
|
||||
{update_available, latest_version, update_type, url, [sha256]}.
|
||||
|
||||
Returns:
|
||||
Un PLAN d'update `{target_version, update_type, url, sha256}` si une MAJ
|
||||
valide est à faire, sinon None.
|
||||
|
||||
Défense en profondeur : même si `update_available` est True, le client
|
||||
REVÉRIFIE en semver (`is_newer`) — il ne descend JAMAIS vers une version
|
||||
<= locale. Tolérant : réponse malformée → None (jamais d'exception).
|
||||
"""
|
||||
if not isinstance(server_response, dict):
|
||||
return None
|
||||
if not server_response.get("update_available"):
|
||||
return None
|
||||
|
||||
target = server_response.get("latest_version")
|
||||
url = server_response.get("url")
|
||||
if not target or not url:
|
||||
return None
|
||||
|
||||
# Double garde semver : pas de downgrade, pas d'égalité.
|
||||
if not is_newer(target, local_version):
|
||||
return None
|
||||
|
||||
return {
|
||||
"target_version": target,
|
||||
"update_type": _normalize_update_type(server_response.get("update_type")),
|
||||
"url": url,
|
||||
"sha256": server_response.get("sha256"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Téléchargement — downloader INJECTABLE, SHA256, staging only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_downloader(url: str) -> bytes:
|
||||
"""Téléchargement réel du ZIP (best-effort, pattern streamer/log_shipper).
|
||||
|
||||
Résout l'URL relative contre SERVER_BASE, ajoute le Bearer si présent.
|
||||
INJECTABLE : remplacé par un fake en test (aucun réseau réel).
|
||||
"""
|
||||
import requests # import tardif (absent de certains envs de test)
|
||||
|
||||
full_url = url
|
||||
headers = {}
|
||||
try:
|
||||
from ..config import SERVER_BASE, API_TOKEN
|
||||
|
||||
if url.startswith("/"):
|
||||
full_url = f"{SERVER_BASE}{url}"
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
except Exception:
|
||||
# Hors package (test isolé) : on utilise l'URL telle quelle.
|
||||
pass
|
||||
|
||||
resp = requests.get(full_url, headers=headers, timeout=30, stream=False)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def download_update(
|
||||
plan: dict,
|
||||
staging_dir,
|
||||
downloader: Optional[Callable[[str], bytes]] = None,
|
||||
) -> dict:
|
||||
"""Télécharge le ZIP d'update dans le staging et vérifie son intégrité.
|
||||
|
||||
NE TOUCHE PAS aux fichiers vivants : écrit uniquement dans `staging_dir`
|
||||
(équivalent de `Lea_next\\`). L'application réelle (swap) est un stub
|
||||
réservé révision humaine (voir `apply_update`).
|
||||
|
||||
Args:
|
||||
plan : sortie de `should_update` (target_version, update_type, url, sha256).
|
||||
staging_dir : dossier de staging (créé si absent).
|
||||
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
|
||||
|
||||
Returns:
|
||||
Succès : {ok: True, staged_zip: str, update_type, target_version,
|
||||
sha256_verified: bool}
|
||||
Échec : {ok: False, error: str}
|
||||
Best-effort : aucune exception ne remonte ; un échec laisse le staging propre
|
||||
(pas de ZIP corrompu).
|
||||
"""
|
||||
dl = downloader if downloader is not None else _default_downloader
|
||||
staging = Path(staging_dir)
|
||||
|
||||
try:
|
||||
data = dl(plan["url"])
|
||||
except Exception as e:
|
||||
logger.warning("Téléchargement update échoué : %s", e)
|
||||
return {"ok": False, "error": f"download_failed: {e}"}
|
||||
|
||||
expected_sha = (plan.get("sha256") or "").strip().lower()
|
||||
sha256_verified = False
|
||||
if expected_sha:
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual != expected_sha:
|
||||
logger.warning(
|
||||
"SHA256 mismatch update (attendu=%s, obtenu=%s) — rejeté",
|
||||
expected_sha, actual,
|
||||
)
|
||||
return {"ok": False, "error": "sha256 mismatch — ZIP rejeté"}
|
||||
sha256_verified = True
|
||||
else:
|
||||
# Best-effort : pas de SHA fourni → on accepte mais on le signale.
|
||||
logger.info("Pas de SHA256 fourni pour l'update — intégrité non vérifiée")
|
||||
|
||||
try:
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
target_version = plan.get("target_version", "unknown")
|
||||
staged_zip = staging / f"lea_update_{target_version}.zip"
|
||||
staged_zip.write_bytes(data)
|
||||
except Exception as e:
|
||||
logger.warning("Écriture ZIP staging échouée : %s", e)
|
||||
return {"ok": False, "error": f"staging_write_failed: {e}"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"staged_zip": str(staged_zip),
|
||||
"update_type": _normalize_update_type(plan.get("update_type")),
|
||||
"target_version": plan.get("target_version"),
|
||||
"sha256_verified": sha256_verified,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interrogation serveur — checker INJECTABLE (GET /agents/update/check)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_update_checker(local_version: str, machine_id: str):
|
||||
"""Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE).
|
||||
|
||||
GET SERVER_URL/agents/update/check?current_version=..&machine_id=..
|
||||
(endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF,
|
||||
auquel cas on renvoie None : pas de MAJ). Bearer si présent. Pattern aligné
|
||||
sur `log_shipper._default_sender`. INJECTABLE : remplacé par un fake en test.
|
||||
|
||||
Returns:
|
||||
Le dict réponse serveur (`should_update` sait le lire), ou None si
|
||||
indisponible / gated / erreur (jamais d'exception ne remonte).
|
||||
"""
|
||||
try:
|
||||
import requests # import tardif
|
||||
|
||||
headers = {}
|
||||
try:
|
||||
from ..config import SERVER_URL, API_TOKEN
|
||||
|
||||
base = SERVER_URL
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
except Exception:
|
||||
base = ""
|
||||
url = f"{base}/agents/update/check"
|
||||
resp = requests.get(
|
||||
url,
|
||||
params={"current_version": local_version, "machine_id": machine_id},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
# 503 = endpoint gated OFF côté serveur → pas de MAJ (silencieux).
|
||||
if resp.status_code == 503:
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.debug("update/check HTTP %s", resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("update/check indisponible : %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrateur GATED — check → décide → download (staging) → stub apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_update_cycle(
|
||||
local_version: str,
|
||||
machine_id: str,
|
||||
staging_dir,
|
||||
checker: Optional[Callable[[str, str], object]] = None,
|
||||
downloader: Optional[Callable[[str], bytes]] = None,
|
||||
app_dir=None,
|
||||
) -> dict:
|
||||
"""Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap.
|
||||
|
||||
Enchaîne :
|
||||
1. GATE `auto_update_enabled()` (RPA_AUTO_UPDATE_ENABLED, défaut OFF) —
|
||||
si OFF, ne fait STRICTEMENT rien (aucun appel réseau).
|
||||
2. `checker(local_version, machine_id)` → réponse serveur (canary-aware).
|
||||
3. `should_update(...)` → plan (double garde semver, jamais de downgrade).
|
||||
4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche
|
||||
JAMAIS les fichiers vivants.
|
||||
5. `apply_update` ARME le swap (extraction `agent_v1_new/` + marqueur
|
||||
UPDATE_READY) mais NE swappe PAS : le remplacement atomique + le
|
||||
redémarrage sont faits par Lea.bat au prochain démarrage. `applied`
|
||||
reste False tant que Léa n'a pas redémarré sur la nouvelle version.
|
||||
|
||||
Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict
|
||||
d'état pour le diagnostic / le log :
|
||||
status ∈ {disabled, check_failed, up_to_date, download_failed, staged}
|
||||
|
||||
Args:
|
||||
checker : callable `(local_version, machine_id) -> dict|None`
|
||||
INJECTABLE (défaut = HTTP réel vers l'endpoint gated).
|
||||
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
|
||||
"""
|
||||
if not auto_update_enabled():
|
||||
return {"status": "disabled", "applied": False}
|
||||
|
||||
chk = checker if checker is not None else _default_update_checker
|
||||
|
||||
try:
|
||||
server_response = chk(local_version, machine_id)
|
||||
except Exception as e:
|
||||
logger.warning("update check a levé : %s", e)
|
||||
return {"status": "check_failed", "applied": False, "error": str(e)}
|
||||
|
||||
plan = should_update(local_version, server_response)
|
||||
if plan is None:
|
||||
return {"status": "up_to_date", "applied": False}
|
||||
|
||||
staged = download_update(plan, staging_dir, downloader=downloader)
|
||||
if not staged.get("ok"):
|
||||
return {
|
||||
"status": "download_failed",
|
||||
"applied": False,
|
||||
"error": staged.get("error"),
|
||||
}
|
||||
|
||||
# Armement du swap : extraction du ZIP vers agent_v1_new\ + marqueur
|
||||
# UPDATE_READY. Le swap ATOMIQUE (renames) et le redémarrage sont faits
|
||||
# HORS-PROCESS par Lea.bat au prochain démarrage — JAMAIS depuis ici
|
||||
# (on n'écrase pas les fichiers d'un Léa en cours d'exécution).
|
||||
armed = apply_update(staged, app_dir=app_dir)
|
||||
|
||||
return {
|
||||
"status": "armed" if armed.get("armed") else "arm_failed",
|
||||
"applied": False, # le swap effectif est fait par Lea.bat, pas ici
|
||||
"armed": bool(armed.get("armed", False)),
|
||||
"target_version": staged.get("target_version"),
|
||||
"update_type": staged.get("update_type"),
|
||||
"staged_zip": staged.get("staged_zip"),
|
||||
"sha256_verified": staged.get("sha256_verified", False),
|
||||
"marker": armed.get("marker"),
|
||||
"error": armed.get("error"),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SWAP — côté Python : ARMEMENT SEULEMENT (extraction + marqueurs).
|
||||
# Le remplacement ATOMIQUE des fichiers vivants + le redémarrage + le
|
||||
# rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (renames).
|
||||
# Python n'écrase JAMAIS les fichiers d'un Léa en cours d'exécution.
|
||||
# ===========================================================================
|
||||
|
||||
def _resolve_app_dir(app_dir) -> Path:
|
||||
"""Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`).
|
||||
|
||||
INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1.
|
||||
"""
|
||||
if app_dir is not None:
|
||||
return Path(app_dir)
|
||||
try:
|
||||
from ..config import BASE_DIR # BASE_DIR = dossier du package agent_v1
|
||||
return Path(BASE_DIR).parent
|
||||
except Exception:
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def apply_update(prepared: dict, app_dir=None) -> dict:
|
||||
"""ARME le swap : extrait le ZIP staging vers `agent_v1_new/` + marqueur.
|
||||
|
||||
NE swappe PAS et NE redémarre PAS (c'est le rôle de `Lea.bat`). Écrit
|
||||
uniquement à côté des fichiers vivants (dossier neuf + marqueur), donc
|
||||
l'opération est sûre même sur un Léa en cours d'exécution.
|
||||
|
||||
1. Extrait `prepared["staged_zip"]` → `<app_dir>/agent_v1_new/`
|
||||
(nettoyé au préalable ; garde-fou zip-slip).
|
||||
2. Écrit `<app_dir>/UPDATE_READY` (JSON : version, type, chemins) que
|
||||
`Lea.bat` lira au prochain démarrage pour faire le swap atomique.
|
||||
|
||||
Best-effort : aucune exception ne remonte (ne doit jamais casser Léa).
|
||||
|
||||
Returns:
|
||||
succès : {armed: True, applied: False, target_version, update_type,
|
||||
marker, extracted_to}
|
||||
échec : {armed: False, applied: False, error}
|
||||
"""
|
||||
if not isinstance(prepared, dict):
|
||||
return {"armed": False, "applied": False, "error": "prepared invalide"}
|
||||
staged_zip = prepared.get("staged_zip")
|
||||
target_version = prepared.get("target_version", "unknown")
|
||||
update_type = _normalize_update_type(prepared.get("update_type"))
|
||||
try:
|
||||
root = _resolve_app_dir(app_dir)
|
||||
zip_path = Path(staged_zip) if staged_zip else None
|
||||
if zip_path is None or not zip_path.is_file():
|
||||
return {"armed": False, "applied": False, "error": "ZIP staging introuvable"}
|
||||
|
||||
new_dir = root / "agent_v1_new"
|
||||
if new_dir.exists():
|
||||
shutil.rmtree(new_dir, ignore_errors=True) # nettoie un staging partiel
|
||||
new_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import zipfile
|
||||
new_root = new_dir.resolve()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for name in zf.namelist(): # garde-fou zip-slip (chemins ../)
|
||||
dest = (new_dir / name).resolve()
|
||||
if not str(dest).startswith(str(new_root)):
|
||||
shutil.rmtree(new_dir, ignore_errors=True)
|
||||
return {"armed": False, "applied": False,
|
||||
"error": f"zip-slip refusé : {name}"}
|
||||
zf.extractall(new_dir)
|
||||
|
||||
marker = root / "UPDATE_READY"
|
||||
marker.write_text(json.dumps({
|
||||
"target_version": target_version,
|
||||
"update_type": update_type,
|
||||
"extracted_to": str(new_dir),
|
||||
"staged_zip": str(zip_path),
|
||||
}), encoding="utf-8")
|
||||
|
||||
logger.info(
|
||||
"Update ARMÉ : %s (%s) → %s ; swap au prochain démarrage (Lea.bat)",
|
||||
target_version, update_type, new_dir,
|
||||
)
|
||||
return {"armed": True, "applied": False, "target_version": target_version,
|
||||
"update_type": update_type, "marker": str(marker),
|
||||
"extracted_to": str(new_dir)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("apply_update (armement) a échoué : %s", e)
|
||||
return {"armed": False, "applied": False, "error": f"arm_failed: {e}"}
|
||||
|
||||
|
||||
def write_boot_ok_marker(version: str, app_dir=None) -> dict:
|
||||
"""Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback.
|
||||
|
||||
Appelé par `main.py` après ~90 s de tourne STABLE (liveness LOCALE,
|
||||
indépendante du DGX — évite un faux rollback quand le réseau est coupé).
|
||||
Retirer `PENDING_BOOT*` dit à `Lea.bat` que la nouvelle version a démarré
|
||||
correctement (sinon, au prochain lancement, Lea.bat rollback vers la
|
||||
version précédente).
|
||||
|
||||
Best-effort : aucune exception ne remonte.
|
||||
"""
|
||||
try:
|
||||
root = _resolve_app_dir(app_dir)
|
||||
marker = root / f"boot_ok_{version}"
|
||||
marker.write_text("ok", encoding="utf-8")
|
||||
cleared = []
|
||||
for p in root.glob("PENDING_BOOT*"):
|
||||
try:
|
||||
p.unlink()
|
||||
cleared.append(p.name)
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("boot_ok écrit (%s) ; PENDING_BOOT retiré : %s",
|
||||
version, cleared or "aucun")
|
||||
return {"written": True, "marker": str(marker), "cleared_pending": cleared}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("write_boot_ok_marker a échoué : %s", e)
|
||||
return {"written": False, "error": str(e)}
|
||||
@@ -3,6 +3,7 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
httpx>=0.27 # Client HTTP orchestrateur Léa (POST /api/learn/start) — brique conversationnelle
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
|
||||
@@ -29,6 +29,8 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from ..core.log_safe import _title_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,7 +134,7 @@ class ActivityPanel:
|
||||
)
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
|
||||
logger.info(f"[ACTIVITY] Workflow démarré : [wf_hash={_title_hash(nom)}] ({nb_etapes} étapes)")
|
||||
|
||||
def mettre_a_jour(
|
||||
self,
|
||||
|
||||
@@ -27,6 +27,8 @@ import os
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
from ..core.log_safe import _path_ext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
|
||||
@@ -312,7 +314,7 @@ class _FileActionHandlerLocal:
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
|
||||
logger.info(f"Liste dossier [ext={_path_ext(path_str)}] : {len(files)} fichiers")
|
||||
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
|
||||
|
||||
def _create_dir(self, params: dict) -> dict:
|
||||
@@ -328,7 +330,7 @@ class _FileActionHandlerLocal:
|
||||
target = _Path(path_str)
|
||||
existed = target.exists()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
|
||||
logger.info(f"Dossier [ext={_path_ext(path_str)}] {'existait deja' if existed else 'cree'}")
|
||||
return {"created": not existed, "path": path_str, "already_existed": existed}
|
||||
|
||||
def _move_file(self, params: dict) -> dict:
|
||||
@@ -350,7 +352,7 @@ class _FileActionHandlerLocal:
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.move(src, dst)
|
||||
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
|
||||
logger.info(f"Fichier deplace : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
|
||||
return {"moved": True, "source": src, "destination": dst}
|
||||
|
||||
def _copy_file(self, params: dict) -> dict:
|
||||
@@ -376,7 +378,7 @@ class _FileActionHandlerLocal:
|
||||
_shutil.copytree(src, dst)
|
||||
else:
|
||||
_shutil.copy2(src, dst)
|
||||
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
|
||||
logger.info(f"Fichier copie : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
|
||||
return {"copied": True, "source": src, "destination": dst}
|
||||
|
||||
def _sort_by_extension(self, params: dict) -> dict:
|
||||
@@ -425,7 +427,7 @@ class _FileActionHandlerLocal:
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
|
||||
f"Classement par extension [ext={_path_ext(source_dir_str)}] : {len(moved)} fichiers"
|
||||
)
|
||||
return {
|
||||
"moved": moved,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
197
agent_v0/agent_v1/ui/session_watchdog.py
Normal file
197
agent_v0/agent_v1/ui/session_watchdog.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# agent_v1/ui/session_watchdog.py
|
||||
"""Watchdog de session interactive Windows — résilience RDP/Citrix.
|
||||
|
||||
Problème résolu (preuve poste clinique Émilie, 01/07) :
|
||||
09:46:28 [MAIN] agent.run() est sorti mais agent.running=True — probablement
|
||||
pystray sans session interactive (SSH)
|
||||
09:46:28 [MAIN] Keepalive headless actif — main thread bloque...
|
||||
|
||||
Sur les postes cliniques (tous RDP/Citrix), la session interactive
|
||||
disparaît quand l'utilisateur se déconnecte / la session bascule en
|
||||
verrouillage. `pystray.Icon.run()` sort alors immédiatement (plus de
|
||||
bureau interactif `WinSta0\\Default` pour recevoir les entrées et afficher
|
||||
l'icône). L'ancien `_headless_keepalive` bloquait le main thread *pour
|
||||
toujours* : l'icône tray + la fenêtre chat DISPARAISSAIENT et ne
|
||||
revenaient JAMAIS, même après reconnexion RDP. Les soignants croyaient
|
||||
que Léa avait planté (la capture continuait pourtant en fond).
|
||||
|
||||
Solution : un watchdog qui surveille la disponibilité du bureau
|
||||
interactif via `OpenInputDesktop()` (signal Win32 canonique — échoue quand
|
||||
la session est déconnectée/verrouillée, réussit à la reconnexion) et
|
||||
(re)lance l'UI tray dès qu'une session redevient disponible. Les threads
|
||||
de fond (heartbeat, replay poll, capture_server) NE SONT JAMAIS touchés :
|
||||
ils tournent contre `agent.running` et restent uniques. On ne relance
|
||||
JAMAIS un second `AgentV1` — seulement la couche UI (tray + chat).
|
||||
|
||||
État de l'art (recherche 01/07) :
|
||||
- `OpenInputDesktop()` échoue (ERROR_ACCESS_DENIED / ERROR_INVALID_...)
|
||||
quand le processus n'est pas rattaché au windowstation interactif
|
||||
`WinSta0` — c'est exactement le cas quand la session RDP est
|
||||
déconnectée. C'est la méthode fiable recommandée (comparer les
|
||||
*noms* de bureau via GetUserObjectInformation n'apporte rien de plus
|
||||
ici : on a juste besoin d'un booléen « input desktop dispo ? »).
|
||||
- `WTSGetActiveConsoleSessionId` renvoie une pseudo-session même sans
|
||||
login → PAS fiable pour ce besoin.
|
||||
- `pystray.Icon.run()` ne sort jamais en session interactive normale ;
|
||||
il sort immédiatement sinon → c'est notre signal de « session perdue ».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalle de sondage du bureau interactif (secondes).
|
||||
# 3s = compromis : réactif à la reconnexion sans marteler l'API Win32.
|
||||
POLL_INTERVAL_S = 3.0
|
||||
|
||||
|
||||
def is_interactive_desktop_available() -> bool:
|
||||
"""Retourne True si un bureau interactif Windows est disponible.
|
||||
|
||||
Utilise `OpenInputDesktop()` : succès => le windowstation interactif
|
||||
(`WinSta0\\Default`) est accessible et peut afficher un tray. Échec =>
|
||||
session RDP/Citrix déconnectée ou verrouillée sans bureau d'entrée.
|
||||
|
||||
Hors Windows (Linux/dev/tests) : renvoie toujours True (pas de notion
|
||||
de bureau interactif verrouillable ici — on laisse l'UI tourner).
|
||||
Toute erreur d'appel Win32 est traitée comme « indisponible » (prudent)
|
||||
SAUF l'indisponibilité de l'API elle-même (pywin32 absent) → True pour
|
||||
ne pas priver un poste de son tray à cause d'une dépendance manquante.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return True
|
||||
|
||||
try:
|
||||
import win32con # type: ignore
|
||||
import win32service # type: ignore
|
||||
except Exception:
|
||||
# pywin32 indisponible : on ne peut pas sonder → on suppose dispo
|
||||
# (comportement historique : tenter l'UI plutôt que la bloquer).
|
||||
logger.debug("pywin32 indisponible — sondage bureau interactif ignoré")
|
||||
return True
|
||||
|
||||
hdesk = None
|
||||
try:
|
||||
# DESKTOP_SWITCHDESKTOP (0x0100) = droit minimal, aligné sur l'usage
|
||||
# documenté pour tester la présence du bureau d'entrée.
|
||||
hdesk = win32service.OpenInputDesktop(0, False, win32con.DESKTOP_SWITCHDESKTOP)
|
||||
return hdesk is not None
|
||||
except Exception:
|
||||
# OpenInputDesktop lève quand aucun bureau d'entrée n'est accessible
|
||||
# (session déconnectée / verrouillée). C'est le cas « indisponible ».
|
||||
return False
|
||||
finally:
|
||||
if hdesk is not None:
|
||||
try:
|
||||
# PyHANDLE se ferme via .Close() (pywin32) ; fallback silencieux.
|
||||
hdesk.Close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class InteractiveSessionWatchdog:
|
||||
"""Surveille la session interactive et (re)lance l'UI tray à la reconnexion.
|
||||
|
||||
Ne détient AUCUN état de capture. Sa seule responsabilité : garantir
|
||||
qu'il existe au plus UN tray vivant à la fois, et le ressusciter quand
|
||||
une session interactive redevient disponible. Les daemon threads de
|
||||
l'agent (heartbeat/replay/capture) sont indépendants et intacts.
|
||||
|
||||
Paramètres :
|
||||
run_ui : callable bloquant qui lance le tray (typiquement
|
||||
``agent.ui.run`` / ``agent.run``). Retourne quand le
|
||||
tray sort (normal en fin de session interactive).
|
||||
is_running : callable -> bool ; True tant que l'agent doit vivre
|
||||
(typiquement ``lambda: agent.running``).
|
||||
is_available : callable -> bool de détection de session (injectable
|
||||
pour les tests). Défaut = is_interactive_desktop_available.
|
||||
poll_interval_s : période de sondage quand la session est absente.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_ui: Callable[[], None],
|
||||
is_running: Callable[[], bool],
|
||||
is_available: Optional[Callable[[], bool]] = None,
|
||||
poll_interval_s: float = POLL_INTERVAL_S,
|
||||
) -> None:
|
||||
self._run_ui = run_ui
|
||||
self._is_running = is_running
|
||||
self._is_available = is_available or is_interactive_desktop_available
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._wake = threading.Event()
|
||||
# Sérialise le lancement de l'UI : jamais deux trays en parallèle.
|
||||
self._ui_lock = threading.Lock()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Réveille le watchdog pour qu'il réévalue ``is_running`` et sorte."""
|
||||
self._wake.set()
|
||||
|
||||
def _run_ui_once(self) -> None:
|
||||
"""Lance l'UI tray une fois (bloquant) sous verrou, avec garde d'erreur.
|
||||
|
||||
Le verrou empêche formellement qu'un second appel démarre un tray
|
||||
alors qu'un premier tourne encore (invariant « un seul tray »).
|
||||
"""
|
||||
with self._ui_lock:
|
||||
try:
|
||||
self._run_ui()
|
||||
except Exception:
|
||||
# Un crash du tray ne doit jamais tuer le watchdog : on log et
|
||||
# on laisse la boucle décider (retry ou sortie selon is_running).
|
||||
logger.exception("[WATCHDOG] Le tray UI a levé une exception")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Boucle principale (bloque le main thread à la place du keepalive).
|
||||
|
||||
Cycle :
|
||||
1. Attendre qu'un bureau interactif soit disponible.
|
||||
2. (Re)lancer le tray — bloque jusqu'à sa sortie (déconnexion RDP).
|
||||
3. Recommencer tant que ``is_running`` est vrai.
|
||||
|
||||
Ne consomme pas de CPU en boucle serrée : sonde toutes les
|
||||
``poll_interval_s`` via un Event interruptible (réveil immédiat au stop).
|
||||
"""
|
||||
logger.info(
|
||||
"[WATCHDOG] Surveillance session interactive active "
|
||||
"(re-affichage auto du tray + chat à la reconnexion RDP/Citrix)."
|
||||
)
|
||||
first_cycle = True
|
||||
|
||||
while self._is_running():
|
||||
if not self._is_available():
|
||||
# Session absente : sonder périodiquement sans brûler le CPU.
|
||||
if first_cycle:
|
||||
logger.warning(
|
||||
"[WATCHDOG] Aucune session interactive — Léa reste active "
|
||||
"en fond (capture/heartbeat), tray masqué. En attente de "
|
||||
"reconnexion RDP/Citrix pour ré-afficher l'interface."
|
||||
)
|
||||
# Event.wait renvoie True si stop() a été appelé → on sort.
|
||||
if self._wake.wait(timeout=self._poll_interval_s):
|
||||
break
|
||||
first_cycle = False
|
||||
continue
|
||||
|
||||
# Session disponible : (re)lancer le tray.
|
||||
if not first_cycle:
|
||||
logger.info(
|
||||
"[WATCHDOG] Session interactive détectée — ré-affichage du "
|
||||
"tray et de la fenêtre chat de Léa."
|
||||
)
|
||||
first_cycle = False
|
||||
|
||||
# Bloque jusqu'à la sortie du tray (fin de session interactive).
|
||||
self._run_ui_once()
|
||||
|
||||
# Le tray est sorti. Si l'agent doit vivre, on reboucle (le
|
||||
# prochain tour re-sondera la session et re-affichera le tray).
|
||||
if not self._is_running():
|
||||
break
|
||||
|
||||
logger.info("[WATCHDOG] Arrêt de la surveillance de session interactive.")
|
||||
@@ -137,6 +137,15 @@ class SmartTrayV1:
|
||||
self._state_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Résilience RDP/Citrix : run() peut être rappelé plusieurs fois par le
|
||||
# watchdog de session (ré-affichage du tray à la reconnexion). Les
|
||||
# threads de fond (connexion, cache workflows, hotkey) et l'accueil ne
|
||||
# doivent démarrer QU'UNE fois — sinon on duplique les threads.
|
||||
self._bg_started = False
|
||||
# Signalé quand l'utilisateur a demandé Quitter : le watchdog ne doit
|
||||
# alors PAS relancer le tray.
|
||||
self._quit_requested = False
|
||||
|
||||
# Notifications
|
||||
self._notifier = NotificationManager()
|
||||
|
||||
@@ -709,6 +718,11 @@ class SmartTrayV1:
|
||||
"""Arrete proprement l'agent et quitte."""
|
||||
logger.info("Arret demande par l'utilisateur")
|
||||
|
||||
# Marquer l'arret volontaire : le watchdog de session ne doit PAS
|
||||
# relancer le tray après un Quitter explicite (à distinguer d'une
|
||||
# simple déconnexion RDP où le tray doit revenir tout seul).
|
||||
self._quit_requested = True
|
||||
|
||||
# Arreter la session si en cours
|
||||
if self.is_recording:
|
||||
self.on_stop()
|
||||
@@ -885,17 +899,24 @@ class SmartTrayV1:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
"""Demarre le tray, les threads de fond, et entre dans la boucle principale."""
|
||||
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
|
||||
self._notifier.greet()
|
||||
"""Demarre (ou ré-affiche) le tray et entre dans la boucle pystray.
|
||||
|
||||
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
|
||||
self._start_hotkey()
|
||||
Ré-entrant : le watchdog de session (session_watchdog.py) rappelle
|
||||
cette méthode à chaque reconnexion RDP/Citrix pour ré-afficher le
|
||||
tray + la fenêtre chat. Les initialisations one-shot (accueil,
|
||||
hotkey, threads de fond connexion/cache) sont protégées par
|
||||
``_bg_started`` pour ne PAS dupliquer les threads. Seule l'icône
|
||||
pystray est recréée à chaque appel (l'ancienne est morte avec la
|
||||
session précédente).
|
||||
"""
|
||||
self._start_background_once()
|
||||
|
||||
# Tooltip avec identifiant machine pour le multi-machine
|
||||
tray_title = f"Agent V1 - {self.machine_id}"
|
||||
|
||||
# Menu statique — reconstruit via _update_icon() quand l'état change
|
||||
# Menu statique — reconstruit via _update_icon() quand l'état change.
|
||||
# Nouvelle icône à chaque (ré)affichage : l'objet pystray précédent
|
||||
# est invalide une fois sa boucle sortie (session interactive perdue).
|
||||
self.icon = pystray.Icon(
|
||||
"AgentV1",
|
||||
self._current_icon(),
|
||||
@@ -903,6 +924,33 @@ class SmartTrayV1:
|
||||
menu=pystray.Menu(*self._get_menu_items()),
|
||||
)
|
||||
|
||||
# Rafraîchir les workflows au (ré)affichage — utile après reconnexion.
|
||||
if self._bg_started and self.server_client is not None:
|
||||
threading.Thread(target=self._fetch_workflows, daemon=True).start()
|
||||
|
||||
# Boucle principale pystray (bloquante). Sort quand la session
|
||||
# interactive disparaît (RDP déconnecté) OU sur _on_quit → le
|
||||
# watchdog décide alors de relancer ou non.
|
||||
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
|
||||
self.icon.run()
|
||||
|
||||
def _start_background_once(self) -> None:
|
||||
"""Initialisations one-shot : accueil, hotkey, threads de fond.
|
||||
|
||||
Idempotent : les appels suivants (ré-affichage tray) sont des no-op.
|
||||
Garantit qu'on n'accumule pas de threads connexion/cache à chaque
|
||||
reconnexion RDP.
|
||||
"""
|
||||
if self._bg_started:
|
||||
return
|
||||
self._bg_started = True
|
||||
|
||||
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
|
||||
self._notifier.greet()
|
||||
|
||||
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
|
||||
self._start_hotkey()
|
||||
|
||||
# Demarrer le thread de verification connexion
|
||||
if self.server_client is not None:
|
||||
conn_thread = threading.Thread(
|
||||
@@ -924,7 +972,3 @@ class SmartTrayV1:
|
||||
threading.Thread(
|
||||
target=self._fetch_workflows, daemon=True
|
||||
).start()
|
||||
|
||||
# Boucle principale pystray (bloquante)
|
||||
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
|
||||
self.icon.run()
|
||||
|
||||
110
agent_v0/agent_v1/vision/capture_io.py
Normal file
110
agent_v0/agent_v1/vision/capture_io.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Politique de sauvegarde des captures — réduction du poids disque.
|
||||
|
||||
Constat : tous les shots étaient sauvés en PNG plein écran lossless
|
||||
(``img.save(path, "PNG", quality=...)`` — PNG ignore ``quality``), d'où
|
||||
~90 Go pour 13 sessions. La majorité de ce poids n'a aucune valeur de
|
||||
grounding (full + full_blurred en doublon, heartbeats plein écran).
|
||||
|
||||
Cette politique distingue le **type** de shot et écrit le format adapté :
|
||||
|
||||
- ``crop`` → PNG lossless. C'est la cible de grounding qwen3-vl ; on
|
||||
préserve chaque pixel (perte JPEG = bruit sur de petites icônes). Le crop
|
||||
fait 80×80 → poids négligeable, aucun intérêt à le dégrader.
|
||||
- ``full`` / ``window`` / ``context`` → JPEG ``quality=SCREENSHOT_QUALITY,
|
||||
optimize=True``. Ce sont des vues contextuelles / humaines : la
|
||||
compression JPEG (~5-10x) est sans impact fonctionnel.
|
||||
- ``heartbeat`` → JPEG **downscalé** (largeur max ``HEARTBEAT_MAX_WIDTH``,
|
||||
ratio préservé). C'est de la *liveness* (le serveur vérifie juste qu'un
|
||||
écran a changé), pas du grounding → la pleine résolution est du gaspillage.
|
||||
|
||||
``save_capture`` retourne le chemin RÉELLEMENT écrit, extension ajustée selon
|
||||
le format. L'appelant doit utiliser ce retour (et non un chemin ``.png``
|
||||
présumé) pour streamer / référencer le bon fichier.
|
||||
|
||||
⚠️ Contrat avec le serveur : l'extension du crop NE DOIT PAS changer (le
|
||||
serveur retrouve le crop par basename via ``vision_info.crop`` — voir
|
||||
``stream_processor._extract_crop_b64`` stratégie 1). C'est pourquoi ``crop``
|
||||
reste PNG. Les full/window/context/heartbeat sont retrouvés par
|
||||
``screenshot_id`` avec extension ``.png`` hardcodée côté serveur, mais le
|
||||
serveur réécrit toujours l'upload sous ``{shot_id}.png`` (le suffixe envoyé
|
||||
sur le fil est ignoré) → changer l'extension LOCALE de ces types est sûr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..config import SCREENSHOT_QUALITY
|
||||
|
||||
# Types sauvés en JPEG (vue contextuelle / humaine, pas de grounding pixel).
|
||||
_JPEG_KINDS: frozenset = frozenset({"full", "window", "context"})
|
||||
|
||||
# Largeur max d'un heartbeat downscalé. 1280 px suffit largement pour de la
|
||||
# liveness (détecter qu'un écran a changé) ; on divise le poids d'un 2560 px
|
||||
# par ~4 (surface) avant compression JPEG.
|
||||
HEARTBEAT_MAX_WIDTH = 1280
|
||||
|
||||
|
||||
def _ensure_jpeg_ready(img: Image.Image) -> Image.Image:
|
||||
"""Convertit en RGB si nécessaire (JPEG ne supporte ni alpha ni palette)."""
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
return img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def _downscale_to_width(img: Image.Image, max_width: int) -> Image.Image:
|
||||
"""Réduit l'image à ``max_width`` en préservant le ratio (no-op si plus petite)."""
|
||||
if img.width <= max_width:
|
||||
return img
|
||||
new_height = max(1, round(img.height * max_width / img.width))
|
||||
return img.resize((max_width, new_height), Image.LANCZOS)
|
||||
|
||||
|
||||
def save_capture(img: Image.Image, path_base: str, kind: str) -> str:
|
||||
"""Sauve ``img`` selon la politique du ``kind`` et retourne le chemin écrit.
|
||||
|
||||
Args:
|
||||
img: image PIL à sauvegarder.
|
||||
path_base: chemin SANS extension (ex.
|
||||
``.../shots/shot_0001_full``). L'extension finale (``.png`` ou
|
||||
``.jpg``) est ajoutée par la politique.
|
||||
kind: type de shot — ``"crop"`` | ``"full"`` | ``"window"`` |
|
||||
``"context"`` | ``"heartbeat"``.
|
||||
|
||||
Returns:
|
||||
Le chemin RÉELLEMENT écrit, avec la bonne extension.
|
||||
|
||||
Raises:
|
||||
ValueError: si ``kind`` n'est pas reconnu (fail-closed : on refuse
|
||||
d'écrire un fichier dont la politique est indéterminée).
|
||||
"""
|
||||
if kind == "crop":
|
||||
out_path = f"{path_base}.png"
|
||||
img.save(out_path, "PNG")
|
||||
return out_path
|
||||
|
||||
if kind in _JPEG_KINDS:
|
||||
out_path = f"{path_base}.jpg"
|
||||
_ensure_jpeg_ready(img).save(
|
||||
out_path, "JPEG", quality=SCREENSHOT_QUALITY, optimize=True
|
||||
)
|
||||
return out_path
|
||||
|
||||
if kind == "heartbeat":
|
||||
out_path = f"{path_base}.jpg"
|
||||
small = _downscale_to_width(_ensure_jpeg_ready(img), HEARTBEAT_MAX_WIDTH)
|
||||
small.save(out_path, "JPEG", quality=SCREENSHOT_QUALITY)
|
||||
return out_path
|
||||
|
||||
raise ValueError(
|
||||
f"kind de capture inconnu : {kind!r} "
|
||||
f"(attendu: crop, full, window, context, heartbeat)"
|
||||
)
|
||||
|
||||
|
||||
def known_kinds() -> Iterable[str]:
|
||||
"""Retourne les ``kind`` supportés (utile pour la validation appelant)."""
|
||||
return ("crop", *sorted(_JPEG_KINDS), "heartbeat")
|
||||
@@ -18,8 +18,9 @@ import platform
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
from ..config import TARGETED_CROP_SIZE, BLUR_SENSITIVE
|
||||
from .blur_sensitive import blur_sensitive_regions
|
||||
from .capture_io import save_capture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -425,6 +426,18 @@ class VisionCapturer:
|
||||
# On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows
|
||||
self.last_img_hash = None
|
||||
|
||||
def _ensure_shots_dir(self) -> None:
|
||||
"""Garantit l'existence de `shots/` avant toute écriture.
|
||||
|
||||
Le dossier est créé dans `__init__`, mais l'auto-cleanup de
|
||||
`SessionStorage` (`shutil.rmtree` par âge/taille) peut supprimer tout
|
||||
le dossier de session — y compris la session permanente `_background`.
|
||||
Sans ce garde, la capture suivante lève `[Errno 2] No such file or
|
||||
directory` (bug observé poste Émilie). On recrée donc le répertoire
|
||||
cible juste avant chaque sauvegarde.
|
||||
"""
|
||||
os.makedirs(self.shots_dir, exist_ok=True)
|
||||
|
||||
def capture_full_context(self, name_suffix: str, force=False) -> str:
|
||||
"""
|
||||
Capture l'écran complet.
|
||||
@@ -460,9 +473,15 @@ class VisionCapturer:
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(img)
|
||||
|
||||
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
|
||||
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
return path
|
||||
# Politique d'écriture : les heartbeats sont de la liveness pure
|
||||
# (le serveur vérifie juste qu'un écran a changé) → JPEG downscalé.
|
||||
# Les autres contextes (focus_change, result_of_*) → JPEG q85.
|
||||
kind = "heartbeat" if "heartbeat" in name_suffix else "context"
|
||||
self._ensure_shots_dir()
|
||||
path_base = os.path.join(
|
||||
self.shots_dir, f"context_{int(time.time())}_{name_suffix}"
|
||||
)
|
||||
return save_capture(img, path_base, kind)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Context Capture: {e}")
|
||||
return ""
|
||||
@@ -506,10 +525,10 @@ class VisionCapturer:
|
||||
return result
|
||||
return {}
|
||||
|
||||
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
|
||||
full_base = os.path.join(self.shots_dir, f"{screenshot_id}_full")
|
||||
|
||||
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
|
||||
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
|
||||
crop_base = os.path.join(self.shots_dir, f"{screenshot_id}_crop")
|
||||
w, h = TARGETED_CROP_SIZE
|
||||
left = max(0, x - w // 2)
|
||||
top = max(0, y - h // 2)
|
||||
@@ -523,8 +542,11 @@ class VisionCapturer:
|
||||
blur_sensitive_regions(img)
|
||||
blur_sensitive_regions(crop_img)
|
||||
|
||||
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
# Politique d'écriture : full = vue contextuelle → JPEG q85 ;
|
||||
# crop = cible de grounding qwen3-vl → PNG lossless (contrat serveur).
|
||||
self._ensure_shots_dir()
|
||||
full_path = save_capture(img, full_base, "full")
|
||||
crop_path = save_capture(crop_img, crop_base, "crop")
|
||||
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
@@ -648,11 +670,12 @@ class VisionCapturer:
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(window_img)
|
||||
|
||||
# Sauvegarde
|
||||
window_path = os.path.join(
|
||||
self.shots_dir, f"{screenshot_id}_window.png"
|
||||
# Sauvegarde — fenêtre = vue contextuelle → JPEG q85 (politique).
|
||||
self._ensure_shots_dir()
|
||||
window_base = os.path.join(
|
||||
self.shots_dir, f"{screenshot_id}_window"
|
||||
)
|
||||
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
window_path = save_capture(window_img, window_base, "window")
|
||||
|
||||
result = {
|
||||
"window_image": window_path,
|
||||
|
||||
@@ -19,6 +19,8 @@ import platform
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .core.log_safe import _title_hash
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
||||
@@ -372,7 +374,7 @@ if __name__ == "__main__":
|
||||
for i in range(5):
|
||||
info = get_active_window_info()
|
||||
rect = get_active_window_rect()
|
||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
|
||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: [title_hash={_title_hash(info['title'])}]")
|
||||
if rect:
|
||||
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
|
||||
else:
|
||||
|
||||
77
agent_v0/server_v1/agent_logs_store.py
Normal file
77
agent_v0/server_v1/agent_logs_store.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Store des logs poussés par les clients Léa (push-log-DGX).
|
||||
|
||||
Persiste les logs reçus du client, rangés par `machine_id`, pour consultation
|
||||
au dashboard (diagnostic des postes sans AnyDesk). Stockage fichier JSONL
|
||||
(un fichier par jour et par machine_id), rétention configurable.
|
||||
|
||||
DETTE-020/021 (observabilité). Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# machine_id = entrée réseau → neutraliser tout caractère hors liste blanche
|
||||
# (anti path-traversal : '/', '\\', '..' ne doivent pas s'échapper du base_dir).
|
||||
_SAFE_MACHINE_ID_RE = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
class AgentLogsStore:
|
||||
"""Persiste et relit les logs clients rangés par machine_id (JSONL)."""
|
||||
|
||||
def __init__(self, base_dir: str | Path = "data/agent_logs"):
|
||||
self.base_dir = Path(base_dir)
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _machine_dir(self, machine_id: str) -> Path:
|
||||
safe = _SAFE_MACHINE_ID_RE.sub("_", machine_id or "").strip("._") or "unknown"
|
||||
d = self.base_dir / safe
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def append(self, machine_id: str, entries: list[dict]) -> int:
|
||||
"""Ajoute un batch de logs pour un poste. Retourne le nb de lignes écrites."""
|
||||
if not entries:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc)
|
||||
day_file = self._machine_dir(machine_id) / f"{now.date().isoformat()}.jsonl"
|
||||
with day_file.open("a", encoding="utf-8") as f:
|
||||
for entry in entries:
|
||||
record = dict(entry)
|
||||
record.setdefault("received_at", now.isoformat())
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
return len(entries)
|
||||
|
||||
def read(self, machine_id: str) -> list[dict]:
|
||||
"""Relit toutes les entrées d'un poste, triées par fichier (date) puis ordre d'écriture."""
|
||||
d = self._machine_dir(machine_id)
|
||||
out: list[dict] = []
|
||||
for jsonl in sorted(d.glob("*.jsonl")):
|
||||
with jsonl.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
out.append(json.loads(line))
|
||||
return out
|
||||
|
||||
def purge_old(self, retention_days: int = 30, now: datetime | None = None) -> int:
|
||||
"""Supprime les fichiers-jour antérieurs à la rétention. Retourne le nb supprimé.
|
||||
|
||||
Rétention basée sur la date encodée dans le nom du fichier (`YYYY-MM-DD.jsonl`),
|
||||
pas sur le mtime (déterministe, non altérable). `now` injectable pour les tests.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
cutoff = (now - timedelta(days=retention_days)).date()
|
||||
removed = 0
|
||||
for jsonl in self.base_dir.rglob("*.jsonl"):
|
||||
try:
|
||||
file_date = datetime.strptime(jsonl.stem, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue # nom inattendu → on ne touche pas
|
||||
if file_date < cutoff:
|
||||
jsonl.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
@@ -28,12 +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__)
|
||||
|
||||
@@ -47,6 +51,30 @@ 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.
|
||||
|
||||
Pilote par l'env `RPA_FLEET_ENROLL_LOCKED` (true/1/yes), reversible (relu a
|
||||
chaque appel). Ferme le contournement « poste revoque + nouveau machine_id +
|
||||
token global » : les machines deja connues gardent leur comportement, seul
|
||||
l'enrolement d'un machine_id inconnu est refuse quand le parc est verrouille.
|
||||
"""
|
||||
return os.getenv("RPA_FLEET_ENROLL_LOCKED", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
"""Gestion CRUD des agents enrolles (SQLite)."""
|
||||
|
||||
@@ -99,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
|
||||
@@ -131,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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -180,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
|
||||
@@ -193,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()
|
||||
@@ -207,21 +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
|
||||
# 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()
|
||||
@@ -229,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,
|
||||
@@ -310,3 +399,15 @@ class AgentRevokedError(Exception):
|
||||
f"machine_id={existing_row.get('machine_id')} revoque "
|
||||
f"(reason={existing_row.get('uninstall_reason')})"
|
||||
)
|
||||
|
||||
|
||||
class FleetEnrollLockedError(Exception):
|
||||
"""Levee si le parc est verrouille (RPA_FLEET_ENROLL_LOCKED) et qu'on tente
|
||||
d'enroler un nouveau machine_id inconnu (WP-B)."""
|
||||
|
||||
def __init__(self, machine_id: str):
|
||||
self.machine_id = machine_id
|
||||
super().__init__(
|
||||
f"enrolement refuse : parc verrouille (RPA_FLEET_ENROLL_LOCKED), "
|
||||
f"machine_id={machine_id} inconnu"
|
||||
)
|
||||
|
||||
@@ -27,11 +27,17 @@ from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Requ
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .pii_sanitizer import sanitize_event, sanitize_log_entries
|
||||
from .replay_failure_logger import log_replay_failure
|
||||
from .replay_verifier import ReplayVerifier, VerificationResult
|
||||
from .replay_learner import ReplayLearner
|
||||
from .audit_trail import AuditTrail, AuditEntry
|
||||
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError, AgentRevokedError
|
||||
from .agent_registry import (
|
||||
AgentRegistry,
|
||||
AgentAlreadyEnrolledError,
|
||||
AgentRevokedError,
|
||||
FleetEnrollLockedError,
|
||||
)
|
||||
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
|
||||
from .worker_stream import StreamWorker
|
||||
from .monitor_router import resolve_target_monitor # QW1 — résolution écran cible
|
||||
@@ -414,9 +420,11 @@ from .replay_engine import (
|
||||
_edge_to_normalized_actions,
|
||||
_substitute_variables,
|
||||
_resolve_runtime_vars,
|
||||
_coerce_action_coords,
|
||||
_SERVER_SIDE_ACTION_TYPES,
|
||||
_handle_extract_text_action,
|
||||
_handle_extract_table_action,
|
||||
_handle_extract_dossier_action,
|
||||
_handle_t2a_decision_action,
|
||||
_handle_llm_generate_action,
|
||||
_handle_concat_text_vars_action,
|
||||
@@ -429,6 +437,9 @@ from .replay_engine import (
|
||||
_notify_error_callback as _notify_error_callback_impl,
|
||||
)
|
||||
|
||||
# Navigate handler — import direct depuis core/navigation (pas via replay_engine)
|
||||
from core.navigation import _handle_navigate_action
|
||||
|
||||
|
||||
|
||||
# Wrappers pour les fonctions replay_engine qui accèdent aux variables globales du module.
|
||||
@@ -550,6 +561,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 —
|
||||
@@ -577,6 +589,17 @@ _AGENTS_DB_PATH = os.environ.get(
|
||||
)
|
||||
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
|
||||
|
||||
# push-log-DGX : store des logs poussés par les clients, rangés par machine_id
|
||||
# (observabilité des postes sans AnyDesk — DETTE-020/021).
|
||||
from .agent_logs_store import AgentLogsStore # noqa: E402
|
||||
|
||||
_AGENT_LOGS_DIR = os.environ.get(
|
||||
"RPA_AGENT_LOGS_DIR", str(ROOT_DIR / "data" / "agent_logs")
|
||||
)
|
||||
# Garde-fou anti-flood (G3) : nb max d'entrées acceptées par batch.
|
||||
_AGENT_LOGS_MAX_BATCH = int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000"))
|
||||
agent_logs_store = AgentLogsStore(base_dir=_AGENT_LOGS_DIR)
|
||||
|
||||
|
||||
def _agent_registry_has_entries() -> bool:
|
||||
try:
|
||||
@@ -802,7 +825,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:
|
||||
@@ -814,16 +837,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)
|
||||
# =========================================================================
|
||||
@@ -1464,6 +1579,16 @@ class AgentUninstallRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class AgentLogsRequest(BaseModel):
|
||||
"""Batch de logs poussé par un client Léa (push-log-DGX).
|
||||
|
||||
`logs` = liste d'entrées {ts, level, logger, message} (format libre côté
|
||||
serveur ; le client garantit le PII-safe avant push).
|
||||
"""
|
||||
machine_id: str
|
||||
logs: list[dict] = []
|
||||
|
||||
|
||||
# Thread de nettoyage périodique des replays terminés et sessions expirées
|
||||
_cleanup_thread: Optional[threading.Thread] = None
|
||||
_cleanup_running = False
|
||||
@@ -1595,13 +1720,28 @@ async def startup():
|
||||
logger.info("VLM model: %s", _vlm_model_name)
|
||||
print(f"\n VLM model: {_vlm_model_name}")
|
||||
|
||||
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
|
||||
# Smoke-test santé des modèles VLM/grounding (NON bloquant, thread daemon) :
|
||||
# détecte les modèles « aveugles » (sans capacité vision) au démarrage plutôt qu'en
|
||||
# échec silencieux runtime (incident 2026-06-08, UI-TARS réimporté sans mmproj → 500 masqué).
|
||||
def _smoke_model_health():
|
||||
try:
|
||||
from core.detection.model_health import smoke_check_models
|
||||
from core.detection import vlm_config
|
||||
_models = [vlm_config.get_vlm_model()] + list(getattr(vlm_config, "FALLBACK_VLM_MODELS", []))
|
||||
smoke_check_models(sorted({m for m in _models if m}))
|
||||
except Exception as _e: # ne jamais bloquer le démarrage
|
||||
logger.debug("smoke santé modèles ignoré: %s", _e)
|
||||
|
||||
threading.Thread(target=_smoke_model_health, name="model-health-smoke", daemon=True).start()
|
||||
|
||||
# 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)
|
||||
@@ -1648,7 +1788,15 @@ async def startup():
|
||||
)
|
||||
|
||||
|
||||
def _load_existing_workflows():
|
||||
def _iter_workflow_json_files(wf_dir: Path):
|
||||
"""Iterate workflow JSON files root-first, including machine subdirectories."""
|
||||
return sorted(
|
||||
wf_dir.rglob("*.json"),
|
||||
key=lambda p: (len(p.relative_to(wf_dir).parts), str(p.relative_to(wf_dir))),
|
||||
)
|
||||
|
||||
|
||||
def _load_existing_workflows(clear: bool = False) -> int:
|
||||
"""Charger les workflows JSON existants dans processor._workflows.
|
||||
|
||||
Supporte deux formats :
|
||||
@@ -1657,6 +1805,10 @@ def _load_existing_workflows():
|
||||
"""
|
||||
from core.models.workflow_graph import Workflow
|
||||
|
||||
if clear:
|
||||
with processor._data_lock:
|
||||
processor._workflows.clear()
|
||||
|
||||
workflow_dirs = [
|
||||
ROOT_DIR / "data" / "workflows",
|
||||
ROOT_DIR / "data" / "training" / "workflows",
|
||||
@@ -1667,7 +1819,7 @@ def _load_existing_workflows():
|
||||
for wf_dir in workflow_dirs:
|
||||
if not wf_dir.exists():
|
||||
continue
|
||||
for wf_file in wf_dir.glob("*.json"):
|
||||
for wf_file in _iter_workflow_json_files(wf_dir):
|
||||
try:
|
||||
wf = Workflow.load_from_file(str(wf_file))
|
||||
if wf and hasattr(wf, 'workflow_id'):
|
||||
@@ -1689,7 +1841,10 @@ def _load_existing_workflows():
|
||||
except Exception as e:
|
||||
logger.debug(f"Skip workflow {wf_file.name}: {e}")
|
||||
|
||||
logger.info(f"Workflows chargés depuis disque: {loaded}")
|
||||
with processor._data_lock:
|
||||
total = len(processor._workflows)
|
||||
logger.info(f"Workflows chargés depuis disque: {loaded} fichier(s), {total} en mémoire")
|
||||
return total
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -1773,6 +1928,11 @@ async def stream_event(data: StreamEvent):
|
||||
# Auto-enregistrer la session si inconnue (robustesse au redémarrage serveur)
|
||||
_ensure_session_registered(session_id, machine_id=machine_id)
|
||||
|
||||
# ── Assainissement PII : sanitize une fois, les 3 chemins reçoivent la copie ──
|
||||
sanitized_event = sanitize_event(
|
||||
data.event, mapping=_session_pii_mapping[session_id]
|
||||
)
|
||||
|
||||
# Persister sur disque (journal JSONL, dans un sous-dossier par machine si multi-machine)
|
||||
if machine_id and machine_id != "default":
|
||||
session_path = LIVE_SESSIONS_DIR / machine_id / session_id
|
||||
@@ -1781,21 +1941,26 @@ async def stream_event(data: StreamEvent):
|
||||
session_path.mkdir(parents=True, exist_ok=True)
|
||||
event_file = session_path / "live_events.jsonl"
|
||||
with open(event_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(data.dict()) + "\n")
|
||||
f.write(json.dumps({
|
||||
"session_id": data.session_id,
|
||||
"timestamp": data.timestamp,
|
||||
"event": sanitized_event,
|
||||
"machine_id": machine_id,
|
||||
}) + "\n")
|
||||
|
||||
# Traitement direct via StreamProcessor
|
||||
result = worker.process_event_direct(session_id, data.event)
|
||||
result = worker.process_event_direct(session_id, sanitized_event)
|
||||
|
||||
# ── Observation Shadow (si mode Shadow activé pour cette session) ──
|
||||
# L'appel est protégé et non bloquant : si l'observer n'est pas
|
||||
# actif, ou s'il lève, la capture continue normalement.
|
||||
shadow_observe_event(session_id, data.event)
|
||||
shadow_observe_event(session_id, sanitized_event)
|
||||
|
||||
# ── Enrichissement SomEngine temps réel pour les mouse_click ──
|
||||
# Après l'enregistrement de l'event, tenter l'enrichissement si le
|
||||
# screenshot est déjà arrivé. Sinon, l'event est mis en attente et
|
||||
# sera enrichi quand le screenshot arrivera (voir stream_image).
|
||||
event = data.event
|
||||
event = sanitized_event
|
||||
if event.get("type") == "mouse_click" and event.get("screenshot_id"):
|
||||
session = processor.session_manager.get_session(session_id)
|
||||
if session:
|
||||
@@ -1813,6 +1978,9 @@ async def stream_event(data: StreamEvent):
|
||||
# =========================================================================
|
||||
|
||||
# Ensemble des screenshots déjà analysés (évite les doublons de retry)
|
||||
# Mapping PII par session — tokens cohérents intra-session (même patient → même [NOM_1])
|
||||
_session_pii_mapping: Dict[str, Dict] = defaultdict(dict)
|
||||
|
||||
_analyzed_shots: Dict[str, set] = defaultdict(set)
|
||||
|
||||
# Hash du dernier screenshot analysé par session (déduplication par similarité)
|
||||
@@ -2209,9 +2377,12 @@ async def stream_image(
|
||||
# Le fichier brut (shot_XXXX_full.png) reste intact pour le replay,
|
||||
# le grounding VLM et l'entraînement. La version floutée est écrite en
|
||||
# parallèle sous shot_XXXX_full_blurred.png.
|
||||
# focus_* : plein écran avec PII dans les titres (blind spot Qwen 28/06,
|
||||
# 1440 fichiers/350 Mo non floutés) — désormais inclus dans le blur.
|
||||
if _PII_BLUR_ENABLED and _blur_pii_on_image is not None and (
|
||||
("_full" in shot_id and shot_id.startswith("shot_"))
|
||||
or shot_id.startswith("heartbeat_")
|
||||
or shot_id.startswith("focus_")
|
||||
):
|
||||
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
|
||||
|
||||
@@ -2858,7 +3029,7 @@ async def reload_workflows():
|
||||
Appelé par le VWB après un export-for-lea pour que le streaming server
|
||||
voie immédiatement les nouveaux workflows sans redémarrage.
|
||||
"""
|
||||
count = processor.reload_workflows()
|
||||
count = _load_existing_workflows(clear=True)
|
||||
return {"success": True, "workflows_count": count}
|
||||
|
||||
|
||||
@@ -2901,6 +3072,129 @@ async def get_session(session_id: str):
|
||||
# =========================================================================
|
||||
|
||||
|
||||
# Marqueurs de dialogues/popups connus, détectables statiquement dans un workflow.
|
||||
_DIALOG_MARKERS = (
|
||||
"enregistrer sous",
|
||||
"confirmer l'enregistrement",
|
||||
"overwrite",
|
||||
"remplacer",
|
||||
"unsaved",
|
||||
"modifications non enregistrées",
|
||||
"save as",
|
||||
)
|
||||
|
||||
|
||||
def _iter_workflow_nodes(workflow: Any):
|
||||
"""Itère les nodes d'un workflow (objet Workflow OU dict), de façon tolérante."""
|
||||
if isinstance(workflow, dict):
|
||||
yield from workflow.get("nodes", [])
|
||||
return
|
||||
nodes = getattr(workflow, "nodes", None)
|
||||
if nodes is None:
|
||||
return
|
||||
# nodes peut être un dict {id: node} ou une liste
|
||||
yield from (nodes.values() if isinstance(nodes, dict) else nodes)
|
||||
|
||||
|
||||
def _node_text_blob(node: Any) -> str:
|
||||
"""Concatène les champs texte pertinents d'un node pour la détection de dialogue."""
|
||||
parts: List[str] = []
|
||||
if isinstance(node, dict):
|
||||
parts.append(str(node.get("label", "")))
|
||||
tmpl = node.get("template", {}) or {}
|
||||
window = tmpl.get("window", {}) if isinstance(tmpl, dict) else {}
|
||||
if isinstance(window, dict):
|
||||
parts.append(str(window.get("title_contains", "")))
|
||||
parts.append(str(window.get("title_pattern", "")))
|
||||
parts.append(str(node.get("expected_window_title", "")))
|
||||
else:
|
||||
parts.append(str(getattr(node, "label", "")))
|
||||
tmpl = getattr(node, "template", None)
|
||||
window = getattr(tmpl, "window", None) if tmpl is not None else None
|
||||
if window is not None:
|
||||
parts.append(str(getattr(window, "title_contains", "") or ""))
|
||||
return " ".join(p for p in parts if p).lower()
|
||||
|
||||
|
||||
def _detect_dialogs_static(workflow: Any) -> List[str]:
|
||||
"""Détecte statiquement les dialogues/popups attendus d'un workflow.
|
||||
|
||||
Analyse les nodes (titres de fenêtre, labels) sans aucune exécution ni session.
|
||||
Retourne la liste dédupliquée des marqueurs de dialogue trouvés.
|
||||
"""
|
||||
found: List[str] = []
|
||||
for node in _iter_workflow_nodes(workflow):
|
||||
blob = _node_text_blob(node)
|
||||
for marker in _DIALOG_MARKERS:
|
||||
if marker in blob and marker not in found:
|
||||
found.append(marker)
|
||||
return found
|
||||
|
||||
|
||||
def _sanitize_action(action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Réduit une action à des champs non sensibles pour l'aperçu préflight."""
|
||||
return {
|
||||
"type": action.get("type") or action.get("action"),
|
||||
"target": (str(action.get("by_text") or action.get("target_spec") or "")[:60]) or None,
|
||||
"has_coords": action.get("x_pct") is not None,
|
||||
}
|
||||
|
||||
|
||||
def _build_preflight_report(
|
||||
workflow: Any, workflow_id: str, actions: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Construit le rapport de préflight (analyse pure, AUCUN effet de bord).
|
||||
|
||||
Ne touche NI `_replay_queues`, NI `_replay_states`, NI aucun lock.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
action_types = dict(Counter(
|
||||
(a.get("type") or a.get("action") or "unknown") for a in actions
|
||||
))
|
||||
name = workflow.get("name") if isinstance(workflow, dict) else getattr(workflow, "name", "")
|
||||
return {
|
||||
"workflow_known": True,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow_name": name or "",
|
||||
"n_actions": len(actions),
|
||||
"action_types": action_types,
|
||||
"dialogs_detected": _detect_dialogs_static(workflow),
|
||||
"sample_actions": [_sanitize_action(a) for a in actions[:3]],
|
||||
"non_destructive": True,
|
||||
}
|
||||
|
||||
|
||||
class PreflightRequest(BaseModel):
|
||||
"""Requête de préflight replay (inspection non destructive d'un workflow)."""
|
||||
workflow_id: str
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/replay/preflight")
|
||||
async def preflight_replay(request: PreflightRequest):
|
||||
"""Préflight non destructif d'un workflow de replay.
|
||||
|
||||
Prouve `commande → workflow connu → actions non vides → dialogues détectables`
|
||||
SANS injecter d'action, sans modifier `_replay_queues`/`_replay_states`, sans lock.
|
||||
"""
|
||||
workflow_id = request.workflow_id
|
||||
params = request.params or {}
|
||||
|
||||
with processor._data_lock:
|
||||
workflow = processor._workflows.get(workflow_id)
|
||||
|
||||
if not workflow:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Workflow '{workflow_id}' non trouvé. "
|
||||
f"Workflows disponibles : {list(processor._workflows.keys())[:20]}"
|
||||
)
|
||||
|
||||
# Conversion en actions (fonction pure, sans effet de bord sur les queues)
|
||||
actions = _workflow_to_actions(workflow, params)
|
||||
|
||||
return _build_preflight_report(workflow, workflow_id, actions)
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/replay")
|
||||
@@ -4041,6 +4335,9 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
if runtime_vars:
|
||||
action = _resolve_runtime_vars(action, runtime_vars)
|
||||
|
||||
# Coercion coords: cast x_pct/y_pct en float après resolver
|
||||
action = _coerce_action_coords(action)
|
||||
|
||||
type_ = action.get("type")
|
||||
|
||||
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
|
||||
@@ -4154,6 +4451,24 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "extract_dossier":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_dossier_action,
|
||||
action, owning_replay, session_id,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "navigate":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_navigate_action,
|
||||
action, owning_replay, session_id,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "t2a_decision":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
@@ -6859,6 +7174,18 @@ async def agents_enroll(request: AgentEnrollRequest):
|
||||
"existing": existing,
|
||||
},
|
||||
)
|
||||
except FleetEnrollLockedError:
|
||||
logger.warning(
|
||||
f"[FLEET] Enrolement refuse machine_id={machine_id} : parc verrouille "
|
||||
f"(RPA_FLEET_ENROLL_LOCKED)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "fleet_enroll_locked",
|
||||
"message": "enrolement de nouveaux postes desactive (parc verrouille)",
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -6937,6 +7264,62 @@ async def agents_fleet():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/agents/logs")
|
||||
async def agents_logs(request: AgentLogsRequest):
|
||||
"""Réception des logs poussés par un client Léa (push-log-DGX).
|
||||
|
||||
Range les logs par machine_id (AgentLogsStore) pour consultation au
|
||||
dashboard — diagnostic des postes sans AnyDesk. Mêmes garde-fous fleet
|
||||
que stream/poll : un poste révoqué/inconnu est refusé (403).
|
||||
"""
|
||||
machine_id = (request.machine_id or "").strip()
|
||||
if not machine_id:
|
||||
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
|
||||
|
||||
if len(request.logs) > _AGENT_LOGS_MAX_BATCH:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"error": "batch_too_large",
|
||||
"max_batch": _AGENT_LOGS_MAX_BATCH,
|
||||
"received": len(request.logs),
|
||||
},
|
||||
)
|
||||
|
||||
# Bloque les postes révoqués/désinstallés + met à jour last_seen_at.
|
||||
_guard_agent_registry_access(machine_id, endpoint="agents/logs")
|
||||
|
||||
# Assainissement PII côté serveur avant persistance (couche 1 regex, sans NER).
|
||||
# Un mapping partagé sur le batch garantit la cohérence des tokens ([NOM_1]…).
|
||||
safe_logs = sanitize_log_entries(request.logs)
|
||||
received = agent_logs_store.append(machine_id, safe_logs)
|
||||
return {"status": "ok", "received": received, "machine_id": machine_id}
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/logs/{machine_id}")
|
||||
async def get_agents_logs(machine_id: str, limit: int = 1000):
|
||||
"""Lecture des logs poussés par un poste (push-log-DGX, brique 3).
|
||||
|
||||
Route de diagnostic dashboard : restitue les logs rangés par machine_id
|
||||
(poste sans AnyDesk). Lecture admin read-only — volontairement SANS garde
|
||||
fleet : on doit pouvoir consulter un poste révoqué ou en panne. Seul le
|
||||
Bearer (dépendance globale `_verify_token`) protège l'accès.
|
||||
|
||||
`limit` borne la réponse aux N entrées les plus récentes (tail) pour éviter
|
||||
de renvoyer plusieurs jours de logs d'un coup.
|
||||
"""
|
||||
entries = agent_logs_store.read(machine_id)
|
||||
total = len(entries)
|
||||
if limit and limit > 0:
|
||||
entries = entries[-limit:]
|
||||
return {
|
||||
"machine_id": machine_id,
|
||||
"total": total,
|
||||
"count": len(entries),
|
||||
"logs": entries,
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# R2 MVP P0 — DialogResolver (catalogue centralisé des modaux runtime)
|
||||
# Flag OFF par défaut. Activer en posant RPA_DIALOG_RESOLVER_ENABLED=true.
|
||||
@@ -7473,6 +7856,81 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
|
||||
return payload_out
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DETTE-022 v2 — GET /api/v1/agents/update/check (MAJ silencieuse client Léa)
|
||||
# Flag OFF par défaut (RPA_AUTO_UPDATE_SERVER_ENABLED). Best-effort, additif :
|
||||
# expose la DÉCISION d'update (logique PURE dans update_check.py, testée hors
|
||||
# serveur — DETTE-013). NE FAIT PAS le swap (réservé révision humaine côté
|
||||
# client + Lea.bat).
|
||||
# =========================================================================
|
||||
from .update_check import decide_update as _decide_update # noqa: E402
|
||||
from .update_policy import ( # noqa: E402
|
||||
resolve_target_version_from_env as _resolve_target_version_from_env,
|
||||
)
|
||||
|
||||
|
||||
def _auto_update_server_enabled() -> bool:
|
||||
"""Flag d'activation serveur — lu à chaque appel (faciliter les tests)."""
|
||||
return os.environ.get("RPA_AUTO_UPDATE_SERVER_ENABLED", "").lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
|
||||
|
||||
def _latest_agent_version(machine_id: Optional[str] = None) -> str:
|
||||
"""Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
|
||||
|
||||
⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
|
||||
politique canary (`update_policy.resolve_target_version_from_env`) : un
|
||||
poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ;
|
||||
tous les autres restent sur le floor stable. Piloté 100 % par env, sans
|
||||
rebuild :
|
||||
RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte.
|
||||
RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary.
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est
|
||||
positionnée, elle prime — évite toute régression d'un déploiement existant.
|
||||
"""
|
||||
legacy = os.environ.get("RPA_AGENT_LATEST_VERSION")
|
||||
if legacy:
|
||||
return legacy
|
||||
return _resolve_target_version_from_env(machine_id)
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/update/check")
|
||||
async def check_agent_update(
|
||||
current_version: str,
|
||||
machine_id: Optional[str] = None,
|
||||
update_type: Optional[str] = None,
|
||||
):
|
||||
"""Indiquer au client Léa si une MAJ est disponible (DETTE-022 v2).
|
||||
|
||||
Réponse : {update_available, latest_version, update_type, url}.
|
||||
|
||||
La version cible est résolue PAR MACHINE (canary) : voir
|
||||
`_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la
|
||||
version canary (blast radius borné à la liste canary).
|
||||
|
||||
GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
|
||||
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer
|
||||
requise (dépendance globale `_verify_token`).
|
||||
"""
|
||||
if not _auto_update_server_enabled():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"MAJ auto désactivée (flag RPA_AUTO_UPDATE_SERVER_ENABLED). "
|
||||
"DETTE-022 : endpoint exposé mais OFF par défaut."
|
||||
),
|
||||
)
|
||||
return _decide_update(
|
||||
current_version=current_version,
|
||||
latest_version=_latest_agent_version(machine_id),
|
||||
update_type=update_type,
|
||||
machine_id=machine_id,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -7480,4 +7938,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)
|
||||
|
||||
@@ -51,6 +51,8 @@ import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -399,7 +401,10 @@ class DomainContext:
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
port = os.environ.get("GEMMA4_PORT", "11435")
|
||||
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
|
||||
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
|
||||
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
|
||||
port = os.environ.get("GEMMA4_PORT", _default_port)
|
||||
url = f"http://localhost:{port}/api/chat"
|
||||
|
||||
base = ""
|
||||
@@ -427,7 +432,7 @@ class DomainContext:
|
||||
resp = _requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": 200},
|
||||
|
||||
273
agent_v0/server_v1/pii_sanitizer.py
Normal file
273
agent_v0/server_v1/pii_sanitizer.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Assainissement PII des données capturées (titres de fenêtre, texte saisi, OCR).
|
||||
|
||||
Côté serveur. Remplace la PII par des **tokens typés et cohérents**
|
||||
(`[IPP_1]`, `[AGE_1]`, `[NOM_1]`…) : on protège la donnée **et** on garde la
|
||||
structure (champ de type NOM/IPP) utile à l'apprentissage des variables.
|
||||
|
||||
Couche 1 (ce module, sans modèle) : filet **regex** sur la PII structurée
|
||||
(IPP, NIR, téléphone, email, âge) + règles **structurelles** des titres
|
||||
cliniques (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` des fenêtres PACS). Regex
|
||||
réutilisées du projet `anonymisation`.
|
||||
Couche 2 (à venir) : NER CamemBERT-bio (ONNX) pour les noms libres que la
|
||||
couche 1 ne capte pas — branchée plus tard, ce module marche sans.
|
||||
|
||||
Branche feat/push-log-dgx — assainissement PII clinique.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# --- Filet regex (réutilisé de anonymisation/anonymizer_core_refactored_onnx.py) ---
|
||||
RE_IPP = re.compile(r"\b(?:I\.?P\.?P\.?|IPP|N°\s*Ipp)\s*[:\-]?\s*([A-Za-z0-9]{6,})\b", re.IGNORECASE)
|
||||
RE_NIR = re.compile(r"(?<!\d)[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}\s?\d{2}(?!\d)")
|
||||
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
RE_TEL = re.compile(r"(?<!\d)(?:\+33\s?|0)\d(?:[ .\-]?\d){8}(?!\d)")
|
||||
# Âge format « titre » (« 90 ans »), plus large que le regex prose de anonymisation.
|
||||
RE_AGE = re.compile(r"\b(\d{1,3})\s*ans\b", re.IGNORECASE)
|
||||
|
||||
_MAJ = r"A-ZÉÈÀÂÊÎÔÛÄËÏÖÜÇ"
|
||||
_MIN = r"a-zàâäéèêëïîôöùûüç"
|
||||
# Format clinique « NOM (NOM_NAISSANCE) Prénom » (ex. « ROSSIGNOL (SOUBIE) Pierrette »).
|
||||
RE_NOM_NAISSANCE = re.compile(
|
||||
rf"\b[{_MAJ}][{_MAJ}\-']+\s+\([{_MAJ}][{_MAJ}\-']+\)\s+[{_MAJ}][{_MIN}\-']+\b"
|
||||
)
|
||||
# Patient entre crochets des fenêtres PACS (ex. « [DATTIN Alix] »), ≥ 2 tokens capitalisés.
|
||||
RE_NOM_BRACKET = re.compile(
|
||||
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
|
||||
)
|
||||
# « Prénom NOM » inversé, sans parenthèses ni crochets (ex. « Alix DATTIN »).
|
||||
# 2e mot tout en MAJUSCULES → faible risque de FP (« Mozilla Firefox » ne matche pas).
|
||||
RE_PRENOM_NOM = re.compile(rf"\b[{_MAJ}][{_MIN}]+\s+[{_MAJ}][{_MAJ}\-']+\b")
|
||||
|
||||
# GXD5 Diagnostics : numéro de dossier + nom patient tout-majuscules.
|
||||
# Format réel : « GXD5 Diagnostics - 128008 - BENVENISTE MARIE-LAURENCE »
|
||||
# Le numéro (128008) = ID dossier patient (PII). Le nom = PII.
|
||||
# 2 groupes de capture : (1)=numéro, (2)=nom complet.
|
||||
RE_GXD5_DIAG = re.compile(
|
||||
rf"GXD5\s+Diagnostics\s*-\s*(\d+)\s*-\s*([{_MAJ}][{_MAJ}\-' ]+)"
|
||||
)
|
||||
|
||||
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
|
||||
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
|
||||
(RE_NOM_NAISSANCE, "NOM", 0),
|
||||
(RE_NOM_BRACKET, "NOM", 0),
|
||||
(RE_GXD5_DIAG, "DOSSIER", 1), # numéro de dossier
|
||||
(RE_PRENOM_NOM, "NOM", 0),
|
||||
(RE_EMAIL, "EMAIL", 0),
|
||||
(RE_NIR, "NIR", 0),
|
||||
(RE_IPP, "IPP", 1),
|
||||
(RE_TEL, "TEL", 0),
|
||||
(RE_AGE, "AGE", 0),
|
||||
]
|
||||
# GXD5 nom (groupe 2) traité séparément — même regex, priorité juste après.
|
||||
_DETECTORS.append((RE_GXD5_DIAG, "NOM", 2))
|
||||
|
||||
# Anti-faux-positifs : termes logiciels/UI à ne jamais prendre pour un nom.
|
||||
# (Sous-ensemble inline ; les gazetteers complets arrivent avec la couche NER.)
|
||||
_SOFTWARE_BLACKLIST = {
|
||||
"FIREFOX", "MOZILLA", "CHROME", "EDGE", "EXPERT", "SANTE", "SANTÉ", "PACS",
|
||||
"CIM", "ARES", "EASILY", "CONSULTATION", "URGENCES", "SAISIE", "COURRIER",
|
||||
"DOSSIER", "PATIENT", "FENETRE", "FENÊTRE", "GXD", "WINDOWS", "CITRIX",
|
||||
}
|
||||
|
||||
|
||||
def _normalize(etype: str, value: str) -> str:
|
||||
"""Clé de cohérence : même entité -> même token."""
|
||||
if etype in ("IPP", "NIR", "TEL"):
|
||||
return re.sub(r"\s+", "", value)
|
||||
if etype == "EMAIL":
|
||||
return value.lower()
|
||||
return re.sub(r"\s+", " ", value).strip().upper()
|
||||
|
||||
|
||||
def _is_blacklisted_name(value: str) -> bool:
|
||||
toks = [t for t in re.split(r"[^\wÀ-ÿ]+", value) if t]
|
||||
return bool(toks) and all(t.upper() in _SOFTWARE_BLACKLIST for t in toks)
|
||||
|
||||
|
||||
def _assign_token(mapping: Dict, etype: str, norm: str) -> str:
|
||||
key = (etype, norm)
|
||||
if key in mapping:
|
||||
return mapping[key]
|
||||
n = 1 + sum(1 for k in mapping if isinstance(k, tuple) and k[0] == etype)
|
||||
token = f"[{etype}_{n}]"
|
||||
mapping[key] = token
|
||||
return token
|
||||
|
||||
|
||||
def anonymize_text(
|
||||
text: str, *, mapping: Optional[Dict] = None
|
||||
) -> Tuple[str, List[Dict]]:
|
||||
"""Remplace la PII de `text` par des tokens typés cohérents.
|
||||
|
||||
`mapping` : table de cohérence partagée (ex. à l'échelle d'une session) —
|
||||
la même valeur PII reçoit le même token d'un appel à l'autre. Mutée en place ;
|
||||
si None, une table locale est utilisée.
|
||||
|
||||
Retourne `(texte_assaini, entités)` où chaque entité =
|
||||
`{"type", "original", "token", "start", "end"}` (positions dans le texte source).
|
||||
"""
|
||||
if not text:
|
||||
return text, []
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
|
||||
# 1) collecte des candidats (start, end, type, valeur)
|
||||
spans: List[Tuple[int, int, str, str]] = []
|
||||
for pattern, etype, group in _DETECTORS:
|
||||
for m in pattern.finditer(text):
|
||||
start, end = m.span(group)
|
||||
if start == end:
|
||||
continue
|
||||
value = m.group(group)
|
||||
if etype == "NOM" and _is_blacklisted_name(value):
|
||||
continue
|
||||
spans.append((start, end, etype, value))
|
||||
|
||||
# 2) résolution des chevauchements (priorité = rang détecteur, puis -longueur)
|
||||
# _DETECTORS est ordonné par priorité ; le rang dans cette liste détermine
|
||||
# qui gagne quand deux patterns chevauchent. Plus prioritaire + plus long
|
||||
# = accepté en premier, les plus courts/moins prioritaires sont éliminés.
|
||||
# Fix FN « Dossier VIOLA (VIOLA) Liliane » : RE_PRENOM_NOM captait
|
||||
# « Dossier VIOLA » (rang 2) et bloquait RE_NOM_NAISSANCE « VIOLA (VIOLA)
|
||||
# Liliane » (rang 0, plus prioritaire et plus long).
|
||||
det_rank = {p: i for i, (p, _, _) in enumerate(_DETECTORS)}
|
||||
spans.sort(key=lambda s: (det_rank.get(s[2], 999), -(s[1] - s[0]), s[0]))
|
||||
occupied: List[Tuple[int, int]] = []
|
||||
accepted: List[Tuple[int, int, str, str]] = []
|
||||
for start, end, etype, value in spans:
|
||||
if all(start >= oe or end <= os for os, oe in occupied):
|
||||
accepted.append((start, end, etype, value))
|
||||
occupied.append((start, end))
|
||||
|
||||
# 3) substitution (de droite à gauche pour préserver les indices)
|
||||
entities: List[Dict] = []
|
||||
out = text
|
||||
for start, end, etype, value in sorted(accepted, key=lambda s: s[0], reverse=True):
|
||||
token = _assign_token(mapping, etype, _normalize(etype, value))
|
||||
out = out[:start] + token + out[end:]
|
||||
entities.append(
|
||||
{"type": etype, "original": value, "token": token, "start": start, "end": end}
|
||||
)
|
||||
entities.reverse()
|
||||
return out, entities
|
||||
|
||||
|
||||
# Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event
|
||||
# (top-level `active_window_title`, `window/to/from.title`, et surtout
|
||||
# `vision_info.window_capture.window_title` — blind spot signalé par Qwen).
|
||||
_TITLE_KEYS = ("title", "window_title", "active_window_title")
|
||||
_PLACEHOLDER_SAISIE = "[SAISIE]"
|
||||
|
||||
|
||||
def _walk_titles(obj, mapping: Dict) -> None:
|
||||
"""Parcourt récursivement l'event et assainit toute valeur de titre de fenêtre."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in _TITLE_KEYS and isinstance(v, str):
|
||||
obj[k] = anonymize_text(v, mapping=mapping)[0]
|
||||
else:
|
||||
_walk_titles(v, mapping)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk_titles(item, mapping)
|
||||
|
||||
|
||||
def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict:
|
||||
"""Assainit un event capturé avant persistance (copie, ne mute pas l'original).
|
||||
|
||||
Principe « Léa apprend l'interface, pas la donnée » (décision Dom 28/06) :
|
||||
- `text_input` : le **contenu tapé** (`text`, `raw_keys`) = donnée de santé →
|
||||
remplacé par `[SAISIE]` (on garde le champ, pas la valeur — option b) ;
|
||||
- **titres de fenêtre** (`active_window_title`, et `title` dans `window`/`to`/
|
||||
`from`) : l'**identité patient** est tokenisée, l'app/écran est gardé
|
||||
(contexte d'apprentissage), via `anonymize_text` + `mapping` partagé (cohérence).
|
||||
"""
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
ev = copy.deepcopy(event)
|
||||
|
||||
# text_input : on ne garde pas le contenu
|
||||
if ev.get("type") == "text_input":
|
||||
for k in ("text", "raw_keys"):
|
||||
if ev.get(k) not in (None, ""):
|
||||
ev[k] = _PLACEHOLDER_SAISIE
|
||||
|
||||
# tous les titres de fenêtre, où qu'ils soient imbriqués
|
||||
# (active_window_title, window/to/from.title, vision_info.window_capture.window_title…)
|
||||
_walk_titles(ev, mapping)
|
||||
|
||||
return ev
|
||||
|
||||
|
||||
def sanitize_log_entries(
|
||||
entries: List[Dict], *, mapping: Optional[Dict] = None
|
||||
) -> List[Dict]:
|
||||
"""Assainit un batch de log-entries reçues d'un client Léa avant persistance.
|
||||
|
||||
Pour chaque entrée, renvoie une **copie** où les champs texte porteurs de PII
|
||||
sont passés par `anonymize_text` :
|
||||
- `message` (str) : assaini par `anonymize_text`.
|
||||
- `logger` (str) : assaini de la même façon (peut porter un chemin patient).
|
||||
- `ts` et `level` : préservés à l'identique, jamais touchés.
|
||||
|
||||
Un `mapping` partagé est utilisé pour **toutes** les entrées du batch afin de
|
||||
garantir la cohérence des tokens (même PII → même token). Si `mapping` est
|
||||
None, un mapping local est créé et partagé entre toutes les entrées du batch.
|
||||
|
||||
Tolère les valeurs absentes, None ou non-str sans lever d'exception.
|
||||
N'utilise que `anonymize_text` — aucune regex supplémentaire.
|
||||
"""
|
||||
if not entries:
|
||||
return []
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
|
||||
result: List[Dict] = []
|
||||
for entry in entries:
|
||||
item = copy.copy(entry) # copie superficielle suffit (valeurs scalaires)
|
||||
for field in ("message", "logger"):
|
||||
v = item.get(field)
|
||||
if isinstance(v, str):
|
||||
item[field] = anonymize_text(v, mapping=mapping)[0]
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
# Clés d'un workflow core portant du texte potentiellement PII : cible OCR
|
||||
# (`by_text`), noms d'écrans/labels dérivés des titres. Le contenu saisi est
|
||||
# déjà neutralisé à la source (sanitize_event → [SAISIE]).
|
||||
_WORKFLOW_TEXT_KEYS = ("by_text", "name", "label")
|
||||
|
||||
|
||||
def _walk_workflow_text(obj, mapping: Dict) -> None:
|
||||
"""Parcourt un workflow core et tokenise la PII des champs texte (cibles, noms)."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in _WORKFLOW_TEXT_KEYS and isinstance(v, str) and v:
|
||||
obj[k] = anonymize_text(v, mapping=mapping)[0]
|
||||
else:
|
||||
_walk_workflow_text(v, mapping)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk_workflow_text(item, mapping)
|
||||
|
||||
|
||||
def sanitize_workflow_dict(workflow_dict: Dict, *, mapping: Optional[Dict] = None) -> Dict:
|
||||
"""Assainit un workflow core (JSON appris) avant import/persistance en DB VWB.
|
||||
|
||||
Tokenise la PII des champs texte (cible OCR `by_text`, noms d'écrans, labels)
|
||||
via `anonymize_text`, en gardant l'interface intacte (« Léa apprend
|
||||
l'interface, pas la donnée »). Copie — l'original n'est pas muté.
|
||||
|
||||
Limite (couche 1) : ne capte que la PII structurée (IPP, NOM clinique…) ;
|
||||
les noms libres relèvent de la couche 2 NER.
|
||||
"""
|
||||
if mapping is None:
|
||||
mapping = {}
|
||||
wf = copy.deepcopy(workflow_dict)
|
||||
_walk_workflow_text(wf, mapping)
|
||||
return wf
|
||||
@@ -40,6 +40,8 @@ _ALLOWED_ACTION_TYPES = {
|
||||
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
|
||||
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
|
||||
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
|
||||
"extract_dossier", # OCR grille structurée → dossier patient persisté (brique 3)
|
||||
"navigate", # Navigation visuelle → coords login/recherche (brique navigation)
|
||||
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
|
||||
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
|
||||
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
|
||||
@@ -53,6 +55,8 @@ _ALLOWED_ACTION_TYPES = {
|
||||
_SERVER_SIDE_ACTION_TYPES = {
|
||||
"extract_text",
|
||||
"extract_table",
|
||||
"extract_dossier",
|
||||
"navigate",
|
||||
"t2a_decision",
|
||||
"llm_generate",
|
||||
"_concat_text_vars",
|
||||
@@ -1944,6 +1948,21 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
|
||||
normalized["parameters"]["temperature"] = action_params.get("temperature")
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "navigate":
|
||||
normalized["type"] = "navigate"
|
||||
normalized["parameters"] = {
|
||||
"action": action_params.get("action", "login"),
|
||||
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
|
||||
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
|
||||
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
|
||||
}
|
||||
login_config_keys = ("login_field", "password_field", "submit_button",
|
||||
"success_elements", "context")
|
||||
for key in login_config_keys:
|
||||
if action_params.get(key) is not None:
|
||||
normalized["parameters"][key] = action_params[key]
|
||||
return [normalized]
|
||||
|
||||
else:
|
||||
logger.warning(f"Type d'action inconnu : {action_type}")
|
||||
return []
|
||||
@@ -2041,6 +2060,38 @@ def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _coerce_action_coords(action: dict) -> dict:
|
||||
"""Cast x_pct/y_pct en float après template resolution par _resolve_runtime_vars.
|
||||
|
||||
Politique : si string non convertible ou template encore present → skip + pause_for_human.
|
||||
Idempotent sur les actions qui ont déjà des floats (mouse_click existant).
|
||||
Jamais fallback 0.0/0.0 — un clic sur coords (0,0) = top-left = potentiellement dangereux.
|
||||
|
||||
Appelé APRÈS _resolve_runtime_vars dans la boucle dispatch (api_stream.py).
|
||||
"""
|
||||
for key in ("x_pct", "y_pct"):
|
||||
val = action.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, float):
|
||||
continue # déjà float, idempotent
|
||||
if isinstance(val, str):
|
||||
# Template encore présent = non résolu par _resolve_runtime_vars
|
||||
if val.startswith("{{") and val.endswith("}}"):
|
||||
action["_skip_reason"] = f"coords_var non résolu: {key}={val}"
|
||||
action["type"] = "pause_for_human"
|
||||
action["safety_level"] = "high"
|
||||
return action
|
||||
try:
|
||||
action[key] = float(val)
|
||||
except (ValueError, TypeError):
|
||||
action["_skip_reason"] = f"coords invalide: {key}={val}"
|
||||
action["type"] = "pause_for_human"
|
||||
action["safety_level"] = "high"
|
||||
return action
|
||||
return action
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
|
||||
# =========================================================================
|
||||
@@ -2216,6 +2267,146 @@ def _handle_extract_table_action(
|
||||
return bool(rows)
|
||||
|
||||
|
||||
def _resolve_screenshot_path(replay_state: Dict[str, Any]) -> Optional[str]:
|
||||
"""Résout le chemin du dernier screenshot (path disque ou base64 → temp).
|
||||
|
||||
Calque la source utilisée par extract_text/extract_table : priorité au
|
||||
``last_screenshot`` (path ou data-URI base64). Retourne None si absent.
|
||||
"""
|
||||
raw_screenshot = replay_state.get("last_screenshot") or ""
|
||||
if not raw_screenshot:
|
||||
return None
|
||||
if raw_screenshot.startswith("data:"):
|
||||
try:
|
||||
import base64 as _b64, tempfile
|
||||
header, b64data = raw_screenshot.split(",", 1)
|
||||
suffix = ".jpg" if "jpeg" in header else ".png"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.write(_b64.b64decode(b64data))
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
except Exception as e:
|
||||
logger.warning("extract_dossier: décodage base64 screenshot échoué: %s", e)
|
||||
return None
|
||||
if os.path.isfile(raw_screenshot):
|
||||
return raw_screenshot
|
||||
return None
|
||||
|
||||
|
||||
def _gate_dossier_quality(
|
||||
grid: List[List[Dict[str, Any]]],
|
||||
*,
|
||||
min_confidence: float,
|
||||
expected_cols: Optional[int],
|
||||
) -> str:
|
||||
"""Gate qualité simple → 'complete' ou 'needs_review'.
|
||||
|
||||
'complete' SSI : grille non vide ET confiance médiane ≥ seuil ET (si
|
||||
expected_cols fourni) au moins une ligne avec ce nombre de colonnes.
|
||||
Sinon 'needs_review'. Volontairement conservatrice (default-review).
|
||||
"""
|
||||
confs = [
|
||||
cell.get("confidence")
|
||||
for row in grid for cell in row
|
||||
if isinstance(cell.get("confidence"), (int, float))
|
||||
]
|
||||
if not confs:
|
||||
return "needs_review"
|
||||
confs.sort()
|
||||
median = confs[len(confs) // 2]
|
||||
if median < min_confidence:
|
||||
return "needs_review"
|
||||
if expected_cols is not None:
|
||||
if not any(len(row) == expected_cols for row in grid):
|
||||
return "needs_review"
|
||||
return "complete"
|
||||
|
||||
|
||||
def _handle_extract_dossier_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Traite une action extract_dossier côté serveur (brique 3).
|
||||
|
||||
Lit le dernier screenshot, extrait une grille structurée via
|
||||
``extract_grid_from_image``, applique une gate qualité, puis PERSISTE un
|
||||
« dossier patient extrait » (Job/Table/Field) dans la DB VWB avec preuve
|
||||
(screenshot_ref + screen_bbox + confidences). Le job_id est stocké dans
|
||||
``replay_state["variables"][output_var]``.
|
||||
|
||||
Paramètres reconnus (action.parameters) :
|
||||
output_var : nom de variable runtime (default "extracted_dossier")
|
||||
patient_ref : référence patient EN CLAIR (volontaire) — non tokenisée
|
||||
region : (x, y, w, h) px pour cropper avant OCR (None = plein)
|
||||
min_confidence : seuil de confiance médiane pour 'complete' (default 0.6)
|
||||
expected_cols : nb de colonnes attendu (optionnel) pour la gate
|
||||
|
||||
N'ÉCHOUE JAMAIS le replay : toute erreur → log + needs_review.
|
||||
Retourne True SSI le dossier est persisté avec statut 'complete'.
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_dossier").strip()
|
||||
patient_ref = params.get("patient_ref")
|
||||
region = params.get("region") or None
|
||||
try:
|
||||
min_confidence = float(params.get("min_confidence", 0.6))
|
||||
except (TypeError, ValueError):
|
||||
min_confidence = 0.6
|
||||
expected_cols = params.get("expected_cols")
|
||||
if isinstance(expected_cols, str):
|
||||
try:
|
||||
expected_cols = int(expected_cols)
|
||||
except ValueError:
|
||||
expected_cols = None
|
||||
|
||||
job_id = ""
|
||||
status = "needs_review"
|
||||
try:
|
||||
path = _resolve_screenshot_path(replay_state)
|
||||
grid: List[List[Dict[str, Any]]] = []
|
||||
if path:
|
||||
from core.llm import extract_grid_from_image
|
||||
grid = extract_grid_from_image(
|
||||
path, region=tuple(region) if region else None
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"extract_dossier : pas de screenshot pour session %s — needs_review",
|
||||
session_id,
|
||||
)
|
||||
|
||||
status = _gate_dossier_quality(
|
||||
grid, min_confidence=min_confidence, expected_cols=expected_cols
|
||||
)
|
||||
|
||||
from . import vwb_db
|
||||
with vwb_db.vwb_app_context():
|
||||
job_id = vwb_db.persist_extracted_dossier(
|
||||
grid,
|
||||
patient_ref=patient_ref,
|
||||
source_session_id=session_id,
|
||||
screenshot_ref=path,
|
||||
screen_bbox=({"x": region[0], "y": region[1], "width": region[2], "height": region[3]}
|
||||
if region and len(region) == 4 else None),
|
||||
status=status,
|
||||
)
|
||||
except Exception as e:
|
||||
# Ne JAMAIS échouer le replay : on log, on marque needs_review.
|
||||
logger.warning(
|
||||
"extract_dossier : échec persistance (%s) — needs_review, replay %s",
|
||||
e, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
status = "needs_review"
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = job_id
|
||||
logger.info(
|
||||
"extract_dossier → variable '%s' job=%s statut=%s replay %s",
|
||||
output_var, job_id or "?", status, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return status == "complete"
|
||||
|
||||
|
||||
def _handle_t2a_decision_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
|
||||
@@ -20,6 +20,8 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Seuils de détection configurables
|
||||
@@ -434,7 +436,7 @@ class ReplayVerifier:
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Appeler le VLM pour évaluer sémantiquement le résultat de l'action.
|
||||
|
||||
Utilise gemma4 en mode texte+images (Docker port 11435) pour analyser
|
||||
Utilise le VLM (résolu via vlm_config) en mode texte+images pour analyser
|
||||
les screenshots avant/après et dire si le résultat attendu est atteint.
|
||||
|
||||
Sur Citrix (image plate), c'est la SEULE façon de vérifier intelligemment
|
||||
@@ -449,7 +451,10 @@ class ReplayVerifier:
|
||||
if not screenshot_after:
|
||||
return None
|
||||
|
||||
gemma4_port = os.environ.get("GEMMA4_PORT", "11435")
|
||||
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
|
||||
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
|
||||
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
|
||||
gemma4_port = os.environ.get("GEMMA4_PORT", _default_port)
|
||||
gemma4_url = f"http://localhost:{gemma4_port}/api/chat"
|
||||
|
||||
# Construire le prompt Critic
|
||||
@@ -497,7 +502,7 @@ class ReplayVerifier:
|
||||
resp = _requests.post(
|
||||
gemma4_url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": True,
|
||||
|
||||
@@ -27,6 +27,7 @@ from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.grounding.bbox_parser import parse_bbox_to_norm, parse_bbox_to_norm_validated
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger("api_stream")
|
||||
|
||||
@@ -869,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,
|
||||
@@ -878,8 +923,8 @@ def _resolve_by_grounding(
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Résoudre une cible via grounding VLM direct.
|
||||
|
||||
Le modèle VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL)
|
||||
reçoit le screenshot + une description textuelle et retourne
|
||||
Le modèle de grounding bbox (résolu via vlm_config.get_bbox_grounding_model,
|
||||
défaut qwen2.5vl:7b-rpa) reçoit le screenshot + une description et retourne
|
||||
directement les coordonnées de l'élément. Pas de SomEngine,
|
||||
pas de numérotation — le VLM fait du grounding UI natif.
|
||||
|
||||
@@ -944,32 +989,66 @@ def _resolve_by_grounding(
|
||||
# Le grounding nécessite un modèle entraîné pour les coordonnées (bbox_2d).
|
||||
# Qwen2.5-VL est le seul qui retourne des positions précises.
|
||||
# gemma4 comprend les images mais ne sait pas localiser en coordonnées.
|
||||
_grounding_model = os.environ.get("RPA_GROUNDING_MODEL", "qwen2.5vl:7b")
|
||||
# D5-v3b : résolution via helper dédié (var RPA_BBOX_GROUNDING_MODEL,
|
||||
# défaut qwen2.5vl:7b-rpa présent sur DGX) — désambiguïse RPA_GROUNDING_MODEL.
|
||||
_grounding_model = vlm_config.get_bbox_grounding_model()
|
||||
|
||||
# Appel VLM — vLLM (GPU, rapide) en priorité, Ollama en fallback
|
||||
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:
|
||||
@@ -979,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,
|
||||
@@ -1000,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"
|
||||
@@ -1068,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1645,6 +1744,15 @@ def _resolve_by_ocr_text(
|
||||
reco_arch='crnn_vgg16_bn',
|
||||
pretrained=True,
|
||||
)
|
||||
# Device paramétrable avec garde-fou VRAM (VLM sur DGX distant).
|
||||
# cuda si VRAM locale libre, cpu sinon — jamais de hardcode cuda.
|
||||
try:
|
||||
from core.gpu.device_policy import resolve_device
|
||||
if resolve_device("auto") == "cuda":
|
||||
_V4_OCR_PREDICTOR = _V4_OCR_PREDICTOR.cuda()
|
||||
logger.info("docTR V4 OCR chargé sur cuda")
|
||||
except Exception as e:
|
||||
logger.debug("docTR V4 OCR reste sur CPU (%s)", e)
|
||||
|
||||
doc = DocumentFile.from_images([screenshot_path])
|
||||
result = _V4_OCR_PREDICTOR(doc)
|
||||
@@ -2909,7 +3017,7 @@ def _pre_analyze_screen_sync(
|
||||
) -> Dict[str, Any]:
|
||||
"""Pré-analyse synchrone de l'écran via VLM.
|
||||
|
||||
Utilise gemma4 (Docker port 11435) pour détecter :
|
||||
Utilise le VLM (résolu via vlm_config, endpoint Ollama) pour détecter :
|
||||
1. Popups/dialogues modaux (avec coordonnées du bouton à cliquer)
|
||||
2. États incohérents avec l'attendu
|
||||
|
||||
@@ -2917,7 +3025,10 @@ def _pre_analyze_screen_sync(
|
||||
"""
|
||||
import requests as _requests
|
||||
|
||||
gemma4_port = os.environ.get("GEMMA4_PORT", "11435")
|
||||
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
|
||||
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
|
||||
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
|
||||
gemma4_port = os.environ.get("GEMMA4_PORT", _default_port)
|
||||
gemma4_url = f"http://localhost:{gemma4_port}/api/chat"
|
||||
|
||||
# Charger le contexte métier pour l'Observer
|
||||
@@ -2945,7 +3056,7 @@ def _pre_analyze_screen_sync(
|
||||
resp = _requests.post(
|
||||
gemma4_url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": True,
|
||||
@@ -3030,7 +3141,7 @@ def _locate_popup_button(
|
||||
resp = _requests.post(
|
||||
ollama_url,
|
||||
json={
|
||||
"model": "qwen2.5vl:7b",
|
||||
"model": vlm_config.get_bbox_grounding_model(),
|
||||
"messages": [{"role": "user", "content": prompt, "images": [screenshot_b64]}],
|
||||
"stream": False,
|
||||
# D5-v3a (2026-05-25) num_ctx=4096 explicite : éviter fuite 8192
|
||||
|
||||
@@ -18,6 +18,8 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -184,10 +186,11 @@ def _call_llm_for_contextual_checks(
|
||||
"""
|
||||
import requests
|
||||
|
||||
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
|
||||
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
|
||||
# retournait systématiquement [] (refus de signaler).
|
||||
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
|
||||
# Modèle : override explicite RPA_SAFETY_CHECKS_LLM_MODEL prioritaire ; sinon
|
||||
# résolution centralisée vlm_config (gemma4:latest si dispo — meilleur bench
|
||||
# 2026-05-06 cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md — sinon fallback DGX).
|
||||
# Pas de fallback silencieux vers un modèle absent : get_vlm_model vérifie /api/tags.
|
||||
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "") or vlm_config.get_vlm_model()
|
||||
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
|
||||
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
|
||||
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)
|
||||
|
||||
@@ -2486,30 +2486,25 @@ class StreamProcessor:
|
||||
from core.models.workflow_graph import Workflow
|
||||
|
||||
count = 0
|
||||
# Charger les workflows du dossier racine (rétrocompatibilité)
|
||||
for wf_file in sorted(workflows_dir.glob("*.json")):
|
||||
workflow_files = sorted(
|
||||
workflows_dir.rglob("*.json"),
|
||||
key=lambda p: (
|
||||
len(p.relative_to(workflows_dir).parts),
|
||||
str(p.relative_to(workflows_dir)),
|
||||
),
|
||||
)
|
||||
|
||||
for wf_file in workflow_files:
|
||||
try:
|
||||
wf = Workflow.load_from_file(wf_file)
|
||||
rel_parts = wf_file.relative_to(workflows_dir).parts
|
||||
if len(rel_parts) > 1 and not hasattr(wf, '_machine_id'):
|
||||
wf._machine_id = rel_parts[0]
|
||||
self._workflows[wf.workflow_id] = wf
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
|
||||
|
||||
# Charger les workflows des sous-dossiers par machine
|
||||
for machine_dir in sorted(workflows_dir.iterdir()):
|
||||
if not machine_dir.is_dir():
|
||||
continue
|
||||
for wf_file in sorted(machine_dir.glob("*.json")):
|
||||
try:
|
||||
wf = Workflow.load_from_file(wf_file)
|
||||
# Stocker le machine_id dans les métadonnées du workflow
|
||||
if not hasattr(wf, '_machine_id'):
|
||||
wf._machine_id = machine_dir.name
|
||||
self._workflows[wf.workflow_id] = wf
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
|
||||
|
||||
if count:
|
||||
logger.info(f"{count} workflow(s) chargé(s) depuis {workflows_dir}")
|
||||
except ImportError:
|
||||
@@ -3071,6 +3066,8 @@ class StreamProcessor:
|
||||
saved_path = self._persist_workflow(workflow, session_id, machine_id=machine_id)
|
||||
# Stocker le machine_id dans le workflow pour le filtrage
|
||||
workflow._machine_id = machine_id
|
||||
# R1 : import auto en DB VWB (rejouable) — gated RPA_R1_AUTO_IMPORT, non bloquant.
|
||||
self._maybe_import_to_vwb(workflow, session_id, machine_id)
|
||||
|
||||
# Récupérer les métadonnées applicatives de la session
|
||||
session_state = self.session_manager.get_session(session_id)
|
||||
@@ -4449,6 +4446,45 @@ class StreamProcessor:
|
||||
logger.error(f"Erreur sauvegarde workflow {session_id}: {e}")
|
||||
return None
|
||||
|
||||
def _import_workflow_to_vwb(self, workflow, session_id: str, machine_id: str) -> Dict[str, Any]:
|
||||
"""Importer le workflow appris dans la DB VWB rejouable (Maillon A / R1).
|
||||
|
||||
Rend l'appris rejouable sans geste manuel, de façon idempotente (fusion
|
||||
par signature de trajectoire). Suppose un app-context VWB actif fournissant
|
||||
``db.session`` (créé par l'appelant côté worker).
|
||||
"""
|
||||
from .pii_sanitizer import sanitize_workflow_dict
|
||||
from services.learned_workflow_bridge import import_core_workflow_to_db
|
||||
from db.models import db
|
||||
# Assainir la PII (cibles OCR `by_text`, noms) avant dépôt en DB VWB.
|
||||
core_dict = sanitize_workflow_dict(workflow.to_dict())
|
||||
return import_core_workflow_to_db(
|
||||
core_dict,
|
||||
machine_id=machine_id,
|
||||
source_session_id=session_id,
|
||||
db_session=db.session,
|
||||
)
|
||||
|
||||
def _vwb_app_context(self):
|
||||
"""Couplage worker→DB VWB mutualisé (un seul pont, cf. vwb_db).
|
||||
|
||||
Délègue au helper module ``vwb_db.vwb_app_context`` partagé entre R1 et
|
||||
l'extraction métier — pas de duplication de l'app Flask/init_app.
|
||||
"""
|
||||
from .vwb_db import vwb_app_context
|
||||
return vwb_app_context()
|
||||
|
||||
def _maybe_import_to_vwb(self, workflow, session_id: str, machine_id: str) -> None:
|
||||
"""Import auto de l'appris en DB VWB, gated par RPA_R1_AUTO_IMPORT (OFF
|
||||
par défaut) et NON bloquant : un échec ne casse jamais la finalisation."""
|
||||
if os.environ.get("RPA_R1_AUTO_IMPORT", "false").lower() not in ("true", "1", "yes"):
|
||||
return
|
||||
try:
|
||||
with self._vwb_app_context():
|
||||
self._import_workflow_to_vwb(workflow, session_id, machine_id)
|
||||
except Exception as e:
|
||||
logger.warning("[R1] import VWB auto échoué (non bloquant): %s", e)
|
||||
|
||||
def _build_raw_session_fallback(self, session, raw_dict):
|
||||
"""Construire un RawSession manuellement si from_dict échoue."""
|
||||
from core.models.raw_session import RawSession, Event, Screenshot, RawWindowContext
|
||||
|
||||
@@ -26,6 +26,8 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -94,7 +96,10 @@ class TaskPlanner:
|
||||
"""
|
||||
|
||||
def __init__(self, gemma4_port: str = "", domain_id: str = ""):
|
||||
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", "11435")
|
||||
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
|
||||
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
|
||||
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
|
||||
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", _default_port)
|
||||
self._gemma4_url = f"http://localhost:{self._gemma4_port}/api/chat"
|
||||
self._domain_id = domain_id or os.environ.get("RPA_DOMAIN", "generic")
|
||||
|
||||
@@ -176,7 +181,7 @@ class TaskPlanner:
|
||||
resp = _requests.post(
|
||||
self._gemma4_url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"think": True,
|
||||
@@ -499,7 +504,7 @@ class TaskPlanner:
|
||||
resp = _requests.post(
|
||||
self._gemma4_url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"think": True,
|
||||
|
||||
138
agent_v0/server_v1/update_check.py
Normal file
138
agent_v0/server_v1/update_check.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# agent_v0/server_v1/update_check.py
|
||||
"""Logique PURE de décision de mise à jour du client Léa (DETTE-022 v2).
|
||||
|
||||
But : centraliser, SANS dépendance FastAPI, le cœur testable de la MAJ
|
||||
silencieuse :
|
||||
|
||||
- `parse_version()` (R3) : parse une version semver en tuple d'entiers, pour
|
||||
une comparaison correcte ("1.0.2" < "1.0.10" — le piège lexicographique
|
||||
classique). Tolérant : préfixe « v », espaces, et format invalide → fallback
|
||||
`(0,)` (la plus basse) SANS jamais lever.
|
||||
- `decide_update()` (R2) : compare la version courante à la dernière dispo,
|
||||
choisit l'`update_type` (`code-only` par défaut, ~500 Ko / `full` ~33 Mo
|
||||
rare) et construit la réponse
|
||||
`{update_available, latest_version, update_type, url}`.
|
||||
|
||||
Ce module est volontairement IMPORTABLE seul (aucun import lourd, pas de
|
||||
`api_stream`) pour être testé sans démarrer le serveur (DETTE-013). Le
|
||||
branchement HTTP (endpoint gated) vit dans `api_stream.py`.
|
||||
|
||||
⚠️ Cette brique ne fait QUE décider. Le swap réel des fichiers, l'édition de
|
||||
Lea.bat et le redémarrage sont HORS de ce module (réservé révision humaine).
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Niveaux de livraison valides (R2). `code-only` par défaut = 99 % des MAJ.
|
||||
VALID_UPDATE_TYPES = ("code-only", "full")
|
||||
DEFAULT_UPDATE_TYPE = "code-only"
|
||||
|
||||
# Fallback de version « la plus basse » pour une chaîne illisible : ainsi une
|
||||
# version valide est toujours > à une version invalide, et une *latest* illisible
|
||||
# ne déclenche jamais de MAJ douteuse.
|
||||
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
|
||||
|
||||
|
||||
def parse_version(v) -> Tuple[int, ...]:
|
||||
"""Parse une version semver en tuple d'entiers (R3).
|
||||
|
||||
"1.0.2" → (1, 0, 2), "1.0.10" → (1, 0, 10), "v1.2.3" → (1, 2, 3).
|
||||
|
||||
Tolérant et SANS exception : préfixe « v/V » et espaces tolérés ; tout
|
||||
format non numérique (vide, None, "abc", "1.x.3") retombe sur `(0,)`.
|
||||
|
||||
Stratégie : `packaging.version` si présent (déjà dans le venv via
|
||||
setuptools/pip), sinon parse manuel. Aucune nouvelle dépendance.
|
||||
"""
|
||||
if not isinstance(v, str):
|
||||
return _FALLBACK_VERSION
|
||||
s = v.strip().lstrip("vV").strip()
|
||||
if not s:
|
||||
return _FALLBACK_VERSION
|
||||
try:
|
||||
from packaging.version import Version
|
||||
|
||||
return tuple(Version(s).release)
|
||||
except Exception:
|
||||
# packaging absent (python-embed minimal) OU version non-PEP440.
|
||||
pass
|
||||
try:
|
||||
return tuple(int(x) for x in s.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return _FALLBACK_VERSION
|
||||
|
||||
|
||||
def is_newer(candidate: str, baseline: str) -> bool:
|
||||
"""True si `candidate` est strictement plus récent que `baseline` (semver)."""
|
||||
return parse_version(candidate) > parse_version(baseline)
|
||||
|
||||
|
||||
def _normalize_update_type(update_type: Optional[str]) -> str:
|
||||
"""Normalise l'update_type sur un niveau valide (défaut code-only)."""
|
||||
if update_type in VALID_UPDATE_TYPES:
|
||||
return update_type
|
||||
return DEFAULT_UPDATE_TYPE
|
||||
|
||||
|
||||
def build_download_url(
|
||||
machine_id: Optional[str],
|
||||
version: str,
|
||||
update_type: str,
|
||||
) -> str:
|
||||
"""Construit l'URL de téléchargement RELATIVE (R2, 2 niveaux).
|
||||
|
||||
Forme alignée sur les endpoints fleet existants :
|
||||
/api/fleet/download/<machine_id>?type=<update_type>&version=<version>
|
||||
|
||||
On garde une URL relative : le client la résout contre son SERVER_BASE.
|
||||
`machine_id` absent → segment « default » (rétrocompatible).
|
||||
"""
|
||||
mid = (machine_id or "default").strip() or "default"
|
||||
return f"/api/fleet/download/{mid}?type={update_type}&version={version}"
|
||||
|
||||
|
||||
def decide_update(
|
||||
current_version: str,
|
||||
latest_version: str,
|
||||
update_type: Optional[str] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Décision PURE de mise à jour (R2 + R3).
|
||||
|
||||
Compare `current_version` à `latest_version` en semver. Si la dernière est
|
||||
strictement plus récente, construit une réponse d'update ; sinon réponse
|
||||
« à jour ». Aucune exception : versions illisibles → pas de MAJ (prudence).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"update_available": bool,
|
||||
"latest_version": str,
|
||||
"update_type": "code-only" | "full" | None, # None si pas de MAJ
|
||||
"url": str | None, # None si pas de MAJ
|
||||
}
|
||||
"""
|
||||
no_update = {
|
||||
"update_available": False,
|
||||
"latest_version": latest_version,
|
||||
"update_type": None,
|
||||
"url": None,
|
||||
}
|
||||
|
||||
# latest illisible → on ne propose RIEN (pas de MAJ douteuse).
|
||||
if parse_version(latest_version) == _FALLBACK_VERSION:
|
||||
return no_update
|
||||
|
||||
if not is_newer(latest_version, current_version):
|
||||
return no_update
|
||||
|
||||
chosen_type = _normalize_update_type(update_type)
|
||||
return {
|
||||
"update_available": True,
|
||||
"latest_version": latest_version,
|
||||
"update_type": chosen_type,
|
||||
"url": build_download_url(machine_id, latest_version, chosen_type),
|
||||
}
|
||||
139
agent_v0/server_v1/update_policy.py
Normal file
139
agent_v0/server_v1/update_policy.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# agent_v0/server_v1/update_policy.py
|
||||
"""Politique de déploiement CANARY de la MAJ silencieuse Léa (DETTE-022 v2).
|
||||
|
||||
⭐ Brique de SÉCURITÉ centrale ⭐ — 10+ postes cliniques live (Wallerstein).
|
||||
|
||||
Une MAJ ratée peut briquer toute la flotte. La règle non négociable : on ne
|
||||
pousse JAMAIS une nouvelle version sur tous les postes d'un coup. On la déploie
|
||||
d'abord sur UN poste (canary = Émilie `lea-4zbgwxty`), on vérifie, puis on
|
||||
élargit. Ce module résout, PAR MACHINE, la version cible :
|
||||
|
||||
- poste dans la liste canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par variables d'environnement (config serveur, sans rebuild) :
|
||||
RPA_AGENT_STABLE_VERSION — version servie à toute la flotte (défaut floor).
|
||||
RPA_AGENT_CANARY_VERSION — version servie AUX SEULS postes canary (optionnel).
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Promotion = quand le canary est validé, on met RPA_AGENT_STABLE_VERSION à la
|
||||
version canary (toute la flotte suit) et on vide RPA_AGENT_CANARY_MACHINES.
|
||||
Rollback canary = on remet RPA_AGENT_CANARY_VERSION à l'ancienne / on vide la
|
||||
liste : le prochain check ne proposera plus la MAJ (le swap réel côté client
|
||||
reste réservé révision humaine — cf. updater.py).
|
||||
|
||||
Module PUR (aucun import FastAPI, aucune IO) → importable et testable seul
|
||||
(DETTE-013). Le branchement HTTP vit dans api_stream.py.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Set
|
||||
|
||||
# Réutilise le comparateur semver de la décision (même module serveur, pas de
|
||||
# duplication) : "1.0.2" < "1.0.10" correctement, tolérant aux formats invalides.
|
||||
try: # import relatif quand chargé comme package
|
||||
from .update_check import is_newer
|
||||
except Exception: # chargé par chemin (tests importlib) : import du voisin
|
||||
import importlib.util as _ilu
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_uc_path = _Path(__file__).resolve().parent / "update_check.py"
|
||||
_spec = _ilu.spec_from_file_location("_rpa_update_check_for_policy", _uc_path)
|
||||
_uc = _ilu.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_uc)
|
||||
is_newer = _uc.is_newer
|
||||
|
||||
|
||||
# Séparateurs tolérés dans l'allow-list canary (CSV, espaces, point-virgule).
|
||||
_CANARY_SEPARATORS = (",", ";")
|
||||
|
||||
|
||||
def parse_canary_machines(raw: Optional[str]) -> Set[str]:
|
||||
"""Parse l'allow-list canary en un ensemble de machine_id.
|
||||
|
||||
Tolérant : virgule / point-virgule / espace comme séparateurs, entrées
|
||||
vides ignorées. `None` ou chaîne vide → ensemble vide (aucun canary).
|
||||
"""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return set()
|
||||
normalized = raw
|
||||
for sep in _CANARY_SEPARATORS:
|
||||
normalized = normalized.replace(sep, " ")
|
||||
return {tok for tok in (t.strip() for t in normalized.split()) if tok}
|
||||
|
||||
|
||||
def resolve_target_version(
|
||||
machine_id: Optional[str],
|
||||
stable_version: str,
|
||||
canary_version: Optional[str],
|
||||
canary_machines: Set[str],
|
||||
) -> str:
|
||||
"""Résout la version cible POUR CE POSTE (cœur canary — sécurité).
|
||||
|
||||
Règles (toutes prudentes par défaut) :
|
||||
1. Poste HORS liste canary → `stable_version` (jamais la nouvelle).
|
||||
2. machine_id absent / liste vide / pas de canary_version → `stable_version`.
|
||||
3. Poste DANS la liste canary ET `canary_version` fournie ET STRICTEMENT
|
||||
plus récente que stable → `canary_version`.
|
||||
4. Garde-fou : si `canary_version` <= `stable_version` (config douteuse,
|
||||
ex. downgrade), on sert quand même `stable_version` (jamais de recul).
|
||||
|
||||
Ne lève jamais. Une version illisible retombe naturellement sur le stable
|
||||
via le comparateur semver tolérant.
|
||||
"""
|
||||
# Cas 1/2 : hors canary → stable.
|
||||
if not machine_id or machine_id not in canary_machines:
|
||||
return stable_version
|
||||
if not canary_version:
|
||||
return stable_version
|
||||
|
||||
# Cas 4 : garde-fou anti-recul — le canary doit être STRICTEMENT plus récent.
|
||||
if not is_newer(canary_version, stable_version):
|
||||
return stable_version
|
||||
|
||||
# Cas 3 : poste canary → nouvelle version.
|
||||
return canary_version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture de la politique depuis l'environnement (pilotage sans rebuild).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Défaut historique aligné sur AGENT_VERSION client (config.py) et sur le
|
||||
# fallback de _latest_agent_version().
|
||||
_DEFAULT_STABLE_VERSION = "1.0.1"
|
||||
|
||||
|
||||
def stable_version_from_env() -> str:
|
||||
"""Version servie à toute la flotte (floor). Défaut = 1.0.1."""
|
||||
return os.environ.get("RPA_AGENT_STABLE_VERSION", _DEFAULT_STABLE_VERSION)
|
||||
|
||||
|
||||
def canary_version_from_env() -> Optional[str]:
|
||||
"""Version canary (nouvelle), servie aux seuls postes canary. Optionnel."""
|
||||
val = os.environ.get("RPA_AGENT_CANARY_VERSION", "").strip()
|
||||
return val or None
|
||||
|
||||
|
||||
def canary_machines_from_env() -> Set[str]:
|
||||
"""Allow-list canary (machine_id) depuis RPA_AGENT_CANARY_MACHINES."""
|
||||
return parse_canary_machines(os.environ.get("RPA_AGENT_CANARY_MACHINES", ""))
|
||||
|
||||
|
||||
def resolve_target_version_from_env(machine_id: Optional[str]) -> str:
|
||||
"""Raccourci : résout la version cible pour `machine_id` d'après l'env.
|
||||
|
||||
C'est le point d'entrée que l'endpoint serveur appelle. Il isole toute la
|
||||
lecture d'environnement ici (testable en injectant les paramètres via
|
||||
`resolve_target_version`).
|
||||
"""
|
||||
return resolve_target_version(
|
||||
machine_id=machine_id,
|
||||
stable_version=stable_version_from_env(),
|
||||
canary_version=canary_version_from_env(),
|
||||
canary_machines=canary_machines_from_env(),
|
||||
)
|
||||
106
agent_v0/server_v1/vwb_db.py
Normal file
106
agent_v0/server_v1/vwb_db.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Couplage worker → DB VWB (mutualisé) + persistance « dossier patient extrait ».
|
||||
|
||||
Le worker/serveur streaming est un process distinct du backend VWB : il n'a
|
||||
pas d'app Flask en mémoire. Ce module fournit :
|
||||
|
||||
- ``vwb_app_context()`` : un app-context Flask lazy (singleton module) lié au
|
||||
fichier SQLite VWB ``visual_workflow_builder/backend/instance/workflows.db``,
|
||||
avec ``db.init_app`` (db de ``db.models``). Réutilisable par tout module
|
||||
serveur qui doit écrire dans la DB VWB (R1, extraction métier, …).
|
||||
|
||||
- ``persist_extracted_dossier(...)`` : depuis une grille OCR
|
||||
(``List[List[cell]]``), crée ExtractionJob → ExtractedTable → ExtractedField
|
||||
et commit. Suppose un app-context actif (comme le pont R1 existant).
|
||||
|
||||
⚠️ CANAL EXTRACTION = données patient EN CLAIR (volontaire) : aucune
|
||||
tokenisation/assainissement PII ici (cf. note dans db/models.py).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Ajout du backend VWB au sys.path à l'import → rend ``db.models`` importable
|
||||
# (couplage worker→DB VWB mutualisé ; identique au pattern stream_processor).
|
||||
_VWB_BACKEND = Path(__file__).resolve().parents[2] / "visual_workflow_builder" / "backend"
|
||||
if str(_VWB_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_VWB_BACKEND))
|
||||
|
||||
# App Flask lazy (singleton module) — un seul db.init_app pour tout le process.
|
||||
_vwb_app = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def vwb_app_context():
|
||||
"""App-context Flask VWB (lazy singleton) sur instance/workflows.db.
|
||||
|
||||
À utiliser via ``with vwb_app_context(): ...`` autour des appels qui
|
||||
nécessitent ``db.session`` (ex. persist_extracted_dossier).
|
||||
"""
|
||||
global _vwb_app
|
||||
if _vwb_app is None:
|
||||
from flask import Flask
|
||||
from db.models import db
|
||||
|
||||
db_path = _VWB_BACKEND / "instance" / "workflows.db"
|
||||
app = Flask("worker_vwb")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db.init_app(app)
|
||||
_vwb_app = app
|
||||
with _vwb_app.app_context():
|
||||
yield
|
||||
|
||||
|
||||
def persist_extracted_dossier(
|
||||
grid: List[List[Dict[str, Any]]],
|
||||
*,
|
||||
patient_ref: Optional[str],
|
||||
source_session_id: Optional[str],
|
||||
screenshot_ref: Optional[str],
|
||||
screen_bbox: Optional[Dict[str, Any]],
|
||||
status: str,
|
||||
) -> str:
|
||||
"""Persiste un « dossier patient extrait » et retourne le job_id.
|
||||
|
||||
Crée 1 ExtractionJob → 1 ExtractedTable → N ExtractedField (une par
|
||||
cellule de la grille), puis commit. Suppose un app-context VWB actif
|
||||
(fourni par ``vwb_app_context()`` ou par l'appelant, comme le pont R1).
|
||||
|
||||
⚠️ ``patient_ref`` et ``cell["text"]`` sont stockés EN CLAIR (volontaire) :
|
||||
le but est de constituer le dossier, pas d'anonymiser.
|
||||
"""
|
||||
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
|
||||
|
||||
job = ExtractionJob(
|
||||
id=uuid.uuid4().hex,
|
||||
patient_ref=patient_ref,
|
||||
source_session_id=source_session_id,
|
||||
status=status,
|
||||
)
|
||||
db.session.add(job)
|
||||
|
||||
table = ExtractedTable(
|
||||
id=uuid.uuid4().hex,
|
||||
job_id=job.id,
|
||||
screen_bbox=screen_bbox,
|
||||
screenshot_ref=screenshot_ref,
|
||||
)
|
||||
db.session.add(table)
|
||||
|
||||
for row in grid or []:
|
||||
for cell in row or []:
|
||||
db.session.add(ExtractedField(
|
||||
id=uuid.uuid4().hex,
|
||||
table_id=table.id,
|
||||
row=cell.get("row"),
|
||||
col=cell.get("col"),
|
||||
value=cell.get("text"),
|
||||
bbox=cell.get("bbox"),
|
||||
confidence=cell.get("confidence"),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
return job.id
|
||||
@@ -0,0 +1,21 @@
|
||||
{"case_id": "easily_rec_shot_0001_72_538", "screenshot_path": "/tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003362", "ocr_dist": 0.0067, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0002_380_919", "screenshot_path": "/tmp/easily_session/shots/shot_0002_full.png", "task": {"intent": "cliquer sur « iméicamentset-substancs »", "target_text": "iméicamentset-substancs", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « iméicamentset-substancs » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1484, "y_pct": 0.5744, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "iméicamentset-substancs", "ocr_dist": 0.0107, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0003_388_380", "screenshot_path": "/tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "cliniques", "ocr_dist": 0.0075, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0004_552_381", "screenshot_path": "/tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Imagerie", "ocr_dist": 0.009, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0005_685_385", "screenshot_path": "/tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0127, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0007_947_381", "screenshot_path": "/tmp/easily_session/shots/shot_0007_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3699, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0057, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0011_72_288", "screenshot_path": "/tmp/easily_session/shots/shot_0011_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.18, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0109, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0012_95_613", "screenshot_path": "/tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003451", "ocr_dist": 0.0111, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0014_910_378", "screenshot_path": "/tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Synthèse", "ocr_dist": 0.0172, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0015_638_381", "screenshot_path": "/tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Notes", "ocr_dist": 0.0051, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0016_970_393", "screenshot_path": "/tmp/easily_session/shots/shot_0016_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3789, "y_pct": 0.2456, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.008, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0020_695_379", "screenshot_path": "/tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0057, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0021_127_395", "screenshot_path": "/tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "d'admission", "ocr_dist": 0.0089, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0022_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP:", "ocr_dist": 0.0147, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0024_84_269", "screenshot_path": "/tmp/easily_session/shots/shot_0024_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0328, "y_pct": 0.1681, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0018, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0025_67_790", "screenshot_path": "/tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25012257", "ocr_dist": 0.0053, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0028_770_385", "screenshot_path": "/tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0166, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0029_766_378", "screenshot_path": "/tmp/easily_session/shots/shot_0029_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2992, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0196, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0032_954_375", "screenshot_path": "/tmp/easily_session/shots/shot_0032_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3727, "y_pct": 0.2344, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0095, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0037_2028_1290", "screenshot_path": "/tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "terminé", "ocr_dist": 0.0395, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0041_2010_1013", "screenshot_path": "/tmp/easily_session/shots/shot_0041_full.png", "task": {"intent": "cliquer sur « mémorisées »", "target_text": "mémorisées", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « mémorisées » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7852, "y_pct": 0.6331, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "mémorisées", "ocr_dist": 0.0454, "needs_human_check": false}}
|
||||
15
benchmarks/computer_use/cases/leabench_easily_clean_v2.jsonl
Normal file
15
benchmarks/computer_use/cases/leabench_easily_clean_v2.jsonl
Normal file
@@ -0,0 +1,15 @@
|
||||
{"case_id": "easily_shot_0001_72_538", "screenshot_path": "/tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0003_388_380", "screenshot_path": "/tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0004_552_381", "screenshot_path": "/tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0005_685_385", "screenshot_path": "/tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0012_95_613", "screenshot_path": "/tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0013_2393_1215", "screenshot_path": "/tmp/easily_session/shots/shot_0013_full.png", "task": {"intent": "cliquer sur « Oui »", "target_text": "Oui", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Oui » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9348, "y_pct": 0.7594, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0014_910_378", "screenshot_path": "/tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0015_638_381", "screenshot_path": "/tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0020_695_379", "screenshot_path": "/tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0021_127_395", "screenshot_path": "/tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0022_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0023_73_304", "screenshot_path": "/tmp/easily_session/shots/shot_0023_full.png", "task": {"intent": "cliquer sur « IPP »", "target_text": "IPP", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0025_67_790", "screenshot_path": "/tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0028_770_385", "screenshot_path": "/tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": true, "ocr_occurrences": 1}}
|
||||
{"case_id": "easily_shot_0037_2028_1290", "screenshot_path": "/tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "contained_in_line": false, "ocr_occurrences": 1}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{"case_id": "easily_rec_shot_0001_72_538", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0001_full.png", "task": {"intent": "cliquer sur « 25003362 »", "target_text": "25003362", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003362 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.3362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003362", "ocr_dist": 0.0067, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0002_380_919", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0002_full.png", "task": {"intent": "cliquer sur « iméicamentset-substancs »", "target_text": "iméicamentset-substancs", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « iméicamentset-substancs » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1484, "y_pct": 0.5744, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "iméicamentset-substancs", "ocr_dist": 0.0107, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0003_388_380", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0003_full.png", "task": {"intent": "cliquer sur « cliniques »", "target_text": "cliniques", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « cliniques » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.1516, "y_pct": 0.2375, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "cliniques", "ocr_dist": 0.0075, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0004_552_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0004_full.png", "task": {"intent": "cliquer sur « Imagerie »", "target_text": "Imagerie", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Imagerie » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2156, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Imagerie", "ocr_dist": 0.009, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0005_685_385", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0005_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2676, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0127, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0006_2547_962", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0006_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9949, "y_pct": 0.6012, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0007_947_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0007_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3699, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0057, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0008_903_552", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0008_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3527, "y_pct": 0.345, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0009_903_552", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0009_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3527, "y_pct": 0.345, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0010_2546_1042", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0010_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9945, "y_pct": 0.6512, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0011_72_288", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0011_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003362 — LAFFONT Alice — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0281, "y_pct": 0.18, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0109, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0012_95_613", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0012_full.png", "task": {"intent": "cliquer sur « 25003451 »", "target_text": "25003451", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25003451 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0371, "y_pct": 0.3831, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25003451", "ocr_dist": 0.0111, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0013_2393_1215", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0013_full.png", "task": {"intent": "cliquer sur « Oui »", "target_text": "Oui", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Oui » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9348, "y_pct": 0.7594, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Oui", "ocr_dist": 0.0036, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0014_910_378", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0014_full.png", "task": {"intent": "cliquer sur « Synthèse »", "target_text": "Synthèse", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Synthèse » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3555, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Synthèse", "ocr_dist": 0.0172, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0015_638_381", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0015_full.png", "task": {"intent": "cliquer sur « Notes »", "target_text": "Notes", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Notes » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2492, "y_pct": 0.2381, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Notes", "ocr_dist": 0.0051, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0016_970_393", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0016_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3789, "y_pct": 0.2456, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.008, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0017_2506_1205", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0017_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9789, "y_pct": 0.7531, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0018_2549_1203", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0018_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9957, "y_pct": 0.7519, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0019_2557_244", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0019_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9988, "y_pct": 0.1525, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0020_695_379", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0020_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2715, "y_pct": 0.2369, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0057, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0021_127_395", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0021_full.png", "task": {"intent": "cliquer sur « d'admission »", "target_text": "d'admission", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « d'admission » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0496, "y_pct": 0.2469, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "d'admission", "ocr_dist": 0.0089, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0022_73_304", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0022_full.png", "task": {"intent": "cliquer sur « IPP: »", "target_text": "IPP:", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP: » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP:", "ocr_dist": 0.0147, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0023_73_304", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0023_full.png", "task": {"intent": "cliquer sur « IPP »", "target_text": "IPP", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « IPP » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0285, "y_pct": 0.19, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "IPP", "ocr_dist": 0.0163, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0024_84_269", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0024_full.png", "task": {"intent": "cliquer sur « Patients »", "target_text": "Patients", "current_window": "Dossier 25003451 — ROUX Lou — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Patients » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0328, "y_pct": 0.1681, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Patients", "ocr_dist": 0.0018, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0025_67_790", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0025_full.png", "task": {"intent": "cliquer sur « 25012257 »", "target_text": "25012257", "current_window": "Dossier 25003284 — MOREL Catherine — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 25012257 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.0262, "y_pct": 0.4938, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "25012257", "ocr_dist": 0.0053, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0026_2545_1356", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0026_full.png", "task": {"intent": "cliquer sur « 95 »", "target_text": "95", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « 95 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9941, "y_pct": 0.8475, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "95", "ocr_dist": 0.0291, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0027_2541_284", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0027_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9926, "y_pct": 0.1775, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0028_770_385", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0028_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3008, "y_pct": 0.2406, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0166, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0029_766_378", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0029_full.png", "task": {"intent": "cliquer sur « médicales »", "target_text": "médicales", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « médicales » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.2992, "y_pct": 0.2362, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "médicales", "ocr_dist": 0.0196, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0030_2546_595", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0030_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9945, "y_pct": 0.3719, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0031_2558_1415", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0031_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9992, "y_pct": 0.8844, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0032_954_375", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0032_full.png", "task": {"intent": "cliquer sur « Urgences »", "target_text": "Urgences", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « Urgences » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.3727, "y_pct": 0.2344, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "Urgences", "ocr_dist": 0.0095, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0033_2544_704", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0033_full.png", "task": {"intent": "cliquer sur la cible", "target_text": "", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "Clique sur l'élément ciblé."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.9938, "y_pct": 0.44, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "", "ocr_dist": null, "needs_human_check": true}}
|
||||
{"case_id": "easily_rec_shot_0034_2188_1570", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0034_full.png", "task": {"intent": "cliquer sur « a »", "target_text": "a", "current_window": "Dossier 25012257 — BRUNEL Henri — DPI (maquette POC) - Google Chrome", "expected_next_window": "", "question": "L'élément « a » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8547, "y_pct": 0.9812, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "a", "ocr_dist": 0.0391, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0035_2166_1296", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0035_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre d’état système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8461, "y_pct": 0.81, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0133, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0036_2196_1285", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0036_full.png", "task": {"intent": "cliquer sur « - »", "target_text": "-", "current_window": "Fenêtre de dépassement de capacité de la barre d’état système.", "expected_next_window": "", "question": "L'élément « - » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8578, "y_pct": 0.8031, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "-", "ocr_dist": 0.0239, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0037_2028_1290", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0037_full.png", "task": {"intent": "cliquer sur « terminé »", "target_text": "terminé", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « terminé » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7922, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "terminé", "ocr_dist": 0.0395, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0038_2031_1283", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0038_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre d’état système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7934, "y_pct": 0.8019, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0407, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0039_2192_1298", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0039_full.png", "task": {"intent": "cliquer sur « 0 »", "target_text": "0", "current_window": "Fenêtre de dépassement de capacité de la barre d’état système.", "expected_next_window": "", "question": "L'élément « 0 » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8562, "y_pct": 0.8113, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "0", "ocr_dist": 0.0235, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0040_2131_1290", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0040_full.png", "task": {"intent": "cliquer sur « 9 0 - »", "target_text": "9 0 -", "current_window": "Fenêtre de dépassement de capacité de la barre d’état système.", "expected_next_window": "", "question": "L'élément « 9 0 - » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.8324, "y_pct": 0.8063, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "9 0 -", "ocr_dist": 0.0125, "needs_human_check": false}}
|
||||
{"case_id": "easily_rec_shot_0041_2010_1013", "screenshot_path": "../../../../tmp/easily_session/shots/shot_0041_full.png", "task": {"intent": "cliquer sur « mémorisées »", "target_text": "mémorisées", "current_window": "unknown_window", "expected_next_window": "", "question": "L'élément « mémorisées » est-il visible ? Clique uniquement dessus."}, "expectation": {"decision": "click", "click_region": {"x_pct": 0.7852, "y_pct": 0.6331, "radius_pct": 0.05}, "accepted_reasons": ["human_click_groundtruth"]}, "metadata": {"source": "easily_record", "session": "easily_session", "click_type": "mouse_click", "ocr_target": "mémorisées", "ocr_dist": 0.0454, "needs_human_check": false}}
|
||||
@@ -3,9 +3,19 @@ Orchestrateur VRAM — gère le chargement/déchargement des modèles selon le m
|
||||
|
||||
Deux modes :
|
||||
- SHADOW : streaming server + agent_chat actifs, VLM raisonnement déchargé
|
||||
- REPLAY : VLM raisonnement (qwen2.5vl:7b) chargé, services non-essentiels stoppés
|
||||
- REPLAY : VLM raisonnement (cf. get_reasoning_model) chargé, services non-essentiels stoppés
|
||||
|
||||
Bascule automatique ou manuelle selon le contexte.
|
||||
|
||||
⚠️ LIMITE POST-DGX (2026-06-05) — DETTE CONNUE :
|
||||
Cet orchestrateur a été conçu pour un Ollama **local** : le `sudo systemctl
|
||||
restart ollama` (switch_to_replay / switch_to_shadow) et `nvidia-smi`
|
||||
(get_free_vram_gb / get_used_vram_gb) ne ciblent que la machine locale.
|
||||
Or Ollama tourne désormais sur le **DGX via tunnel SSH** (OLLAMA_URL pointe
|
||||
le tunnel). Dans ce cas le restart local est **inopérant** : il ne purge PAS
|
||||
la VRAM des VLM distants et nvidia-smi mesure le GPU local, pas celui du DGX.
|
||||
À rendre conditionnel (tunnel distant vs Ollama local) avant tout usage en
|
||||
mode DGX — logique runtime inchangée ici (correction = décision Dom).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,10 +25,12 @@ import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
REASONING_MODEL = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
REASONING_MODEL = get_reasoning_model()
|
||||
MIN_VRAM_FOR_REASONING = 5.0 # Go minimum pour charger le modèle de raisonnement
|
||||
|
||||
|
||||
|
||||
97
core/detection/model_health.py
Normal file
97
core/detection/model_health.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Santé des modèles VLM/grounding — détection des modèles « aveugles ».
|
||||
|
||||
Motivation (incident 2026-06-08) : un modèle de grounding réimporté sans son projecteur
|
||||
vision (`mmproj`) déclare des `capabilities` sans `vision` et renvoie HTTP 500 sur toute
|
||||
requête image. Dans la cascade `find_element_on_screen`, l'échec était avalé (`return None`)
|
||||
et masqué par le fallback VLM → panne invisible malgré les tests.
|
||||
|
||||
Ce module permet de :
|
||||
- **gater** un appel image : vérifier que le modèle a `vision` avant de lui envoyer une image
|
||||
(évite le 500, skip propre vers le niveau suivant) ;
|
||||
- **smoke-tester** les modèles de grounding/VLM au démarrage : rendre une panne visible
|
||||
immédiatement plutôt que noyée dans un `warning` runtime.
|
||||
|
||||
Volontairement sans dépendance lourde : un simple appel `/api/show` Ollama.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ENDPOINT = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
|
||||
# Cache (endpoint::model) -> bool. Un modèle ne change pas de capacité en cours de session.
|
||||
_VISION_CACHE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def has_vision_capability(
|
||||
model: str,
|
||||
endpoint: str = DEFAULT_ENDPOINT,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
timeout: float = 5.0,
|
||||
) -> bool:
|
||||
"""Retourne True si le modèle Ollama déclare la capacité ``vision``.
|
||||
|
||||
Interroge ``/api/show`` et lit ``capabilities``. Résultat mis en cache par
|
||||
``(endpoint, model)``.
|
||||
|
||||
**Fail-open** : en cas d'erreur réseau/HTTP sur ``/api/show`` (indisponibilité
|
||||
transitoire), retourne ``True`` — on ne bloque pas le grounding sur un doute ;
|
||||
l'appel image en aval gérera l'échec. Seule une réponse explicite **sans** ``vision``
|
||||
retourne ``False`` (modèle réellement aveugle).
|
||||
"""
|
||||
key = f"{endpoint}::{model}"
|
||||
if use_cache and key in _VISION_CACHE:
|
||||
return _VISION_CACHE[key]
|
||||
try:
|
||||
resp = requests.post(f"{endpoint}/api/show", json={"name": model}, timeout=timeout)
|
||||
if resp.status_code != 200:
|
||||
logger.debug("model_health: /api/show %s → HTTP %s (fail-open)", model, resp.status_code)
|
||||
return True
|
||||
caps = resp.json().get("capabilities", []) or []
|
||||
has_vision = "vision" in caps
|
||||
_VISION_CACHE[key] = has_vision
|
||||
if not has_vision:
|
||||
logger.warning(
|
||||
"model_health: modèle '%s' SANS capacité 'vision' (capabilities=%s) — "
|
||||
"modèle aveugle, les requêtes image échoueront",
|
||||
model,
|
||||
caps,
|
||||
)
|
||||
return has_vision
|
||||
except Exception as e: # réseau, JSON, timeout
|
||||
logger.debug("model_health: échec vérification vision %s: %s (fail-open)", model, e)
|
||||
return True
|
||||
|
||||
|
||||
def smoke_check_models(models: List[str], endpoint: str = DEFAULT_ENDPOINT) -> Dict[str, bool]:
|
||||
"""Vérifie la capacité ``vision`` d'une liste de modèles (au démarrage/healthcheck).
|
||||
|
||||
Non bloquant : logue ``info`` par modèle sain, ``error`` par modèle aveugle.
|
||||
Retourne ``{model: has_vision}``.
|
||||
"""
|
||||
results: Dict[str, bool] = {}
|
||||
for m in models:
|
||||
if not m:
|
||||
continue
|
||||
ok = has_vision_capability(m, endpoint, use_cache=False)
|
||||
results[m] = ok
|
||||
if ok:
|
||||
logger.info("model_health[smoke]: %s → vision OK", m)
|
||||
else:
|
||||
logger.error(
|
||||
"model_health[smoke]: %s → AVEUGLE (pas de vision) — grounding image KO sur ce modèle",
|
||||
m,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Vide le cache de capacités (tests, ou après réimport d'un modèle)."""
|
||||
_VISION_CACHE.clear()
|
||||
@@ -89,8 +89,11 @@ class SomResult:
|
||||
class SomEngine:
|
||||
"""Moteur Set-of-Mark : YOLO + docTR + annotation."""
|
||||
|
||||
def __init__(self, device: str = "cuda"):
|
||||
self._device = device
|
||||
def __init__(self, device: str = "auto"):
|
||||
# Résolution paramétrable avec garde-fou VRAM (cf. core/gpu/device_policy).
|
||||
# "auto" → cuda si VRAM libre suffisante (VLM sur DGX distant), sinon cpu.
|
||||
from core.gpu.device_policy import resolve_device
|
||||
self._device = resolve_device(device)
|
||||
self._yolo = None
|
||||
self._ocr = None
|
||||
self._loaded = False
|
||||
@@ -300,8 +303,12 @@ _shared_engine: Optional[SomEngine] = None
|
||||
_shared_lock = __import__("threading").Lock()
|
||||
|
||||
|
||||
def get_shared_engine(device: str = "cpu") -> Optional[SomEngine]:
|
||||
"""Singleton SomEngine partagé entre tous les modules."""
|
||||
def get_shared_engine(device: str = "auto") -> Optional[SomEngine]:
|
||||
"""Singleton SomEngine partagé entre tous les modules.
|
||||
|
||||
device="auto" (défaut) délègue à core.gpu.device_policy.resolve_device :
|
||||
cuda si la VRAM locale est libre, cpu sinon. Passer "cpu" force le CPU.
|
||||
"""
|
||||
global _shared_engine
|
||||
if _shared_engine is None:
|
||||
with _shared_lock:
|
||||
|
||||
@@ -11,7 +11,7 @@ Basée sur l'architecture éprouvée de la V2.
|
||||
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from ..models.ui_element import UIElement, UIElementEmbeddings, VisualFeatures
|
||||
from .ollama_client import OllamaClient, check_ollama_available
|
||||
from . import vlm_config
|
||||
|
||||
# Import OWL-v2 (optionnel)
|
||||
try:
|
||||
@@ -71,10 +72,13 @@ class BoundingBox:
|
||||
@dataclass
|
||||
class DetectionConfig:
|
||||
"""Configuration de la détection UI hybride"""
|
||||
# VLM — modèle configurable via variable d'environnement RPA_VLM_MODEL
|
||||
# Par défaut : gemma4:e4b (meilleur grounding + contextualisation)
|
||||
# Fallback : qwen3-vl:8b si gemma4 non disponible
|
||||
vlm_model: str = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
|
||||
# VLM — modèle configurable via RPA_VLM_MODEL / VLM_MODEL.
|
||||
# default_factory : lu à l'instanciation (pas figé à l'import) ; None si non
|
||||
# défini → résolution lazy via vlm_config.get_vlm_model() dans _initialize_vlm
|
||||
# (pas de hardcode, pas d'appel réseau à l'import).
|
||||
vlm_model: Optional[str] = field(
|
||||
default_factory=lambda: os.environ.get("RPA_VLM_MODEL") or os.environ.get("VLM_MODEL")
|
||||
)
|
||||
vlm_endpoint: str = "http://localhost:11434"
|
||||
use_vlm_classification: bool = True # Utiliser VLM pour classifier
|
||||
|
||||
@@ -136,11 +140,16 @@ class UIDetector:
|
||||
"""Initialiser le client VLM"""
|
||||
try:
|
||||
if check_ollama_available(self.config.vlm_endpoint):
|
||||
# Résolution lazy : si aucun modèle explicite, vlm_config résout
|
||||
# (avec fallback) en interrogeant /api/tags. On normalise la config
|
||||
# pour que les métadonnées de sortie reflètent le modèle réel.
|
||||
model = self.config.vlm_model or vlm_config.get_vlm_model(self.config.vlm_endpoint)
|
||||
self.config.vlm_model = model
|
||||
self.vlm_client = OllamaClient(
|
||||
endpoint=self.config.vlm_endpoint,
|
||||
model=self.config.vlm_model
|
||||
model=model
|
||||
)
|
||||
logger.info(f"✓ VLM initialized: {self.config.vlm_model}")
|
||||
logger.info(f"✓ VLM initialized: {model}")
|
||||
else:
|
||||
logger.warning("Ollama not available, VLM classification disabled")
|
||||
self.vlm_client = None
|
||||
|
||||
@@ -23,13 +23,19 @@ import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Modèle VLM par défaut — Gemma 4 latest (8B dense, Q4_K_M)
|
||||
# Nécessite think=false dans le payload (sinon tokens vides sur Ollama >=0.20)
|
||||
# Bench 2026-05-16 : tentatives qwen2.5vl:7b et :3b écartées (runtime Ollama
|
||||
# avec context = 10-13 GB → débordent toutes en 100% CPU sur RTX 5070 12 GB).
|
||||
# qwen3-vl:8b écarté : think:false ignoré → tout en thinking field, pas de réponse.
|
||||
# gemma4:latest reste le seul stable malgré son cold start ~20s (1 fois par run).
|
||||
DEFAULT_VLM_MODEL = "gemma4:latest"
|
||||
# Modèle VLM par défaut — DGX-safe (P1.w, 2026-06-05).
|
||||
# Historiquement `gemma4:latest`, mais ce modèle peut être absent du tunnel DGX
|
||||
# (dépull) : sans env `RPA_VLM_MODEL`/`VLM_MODEL`, le fallback tombait alors en
|
||||
# 404 Ollama et tout le pipeline VLM échouait avant un test Lea humain.
|
||||
# `qwen2.5vl:7b-rpa` est confirmé présent sur DGX et déjà utilisé par les chemins
|
||||
# reasoning (cf. get_reasoning_model) et bbox grounding (DEFAULT_GROUNDING_FALLBACK)
|
||||
# → default cohérent et sûr. `gemma4:latest` reste accessible via env explicite.
|
||||
DEFAULT_VLM_MODEL = "qwen2.5vl:7b-rpa"
|
||||
|
||||
# Allow-list des modèles VLM généralistes confirmés présents sur le DGX et donc
|
||||
# utilisables comme default sans risque de 404. `gemma4:31b-cloud` est réservé au
|
||||
# benchmark P1.y (≈20 Go VRAM, latence élevée), pas au default runtime.
|
||||
DGX_SAFE_VLM_MODELS = ("qwen2.5vl:7b-rpa", "qwen2.5vl:7b")
|
||||
|
||||
# Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo
|
||||
FALLBACK_VLM_MODELS = ["qwen3-vl:8b", "0000/ui-tars-1.5-7b-q8_0:7b"]
|
||||
@@ -155,6 +161,10 @@ def is_thinking_model(model_name: str) -> bool:
|
||||
# Profil grounding par défaut — qwen3.5:9b avec ctx 4096 et prefill JSON.
|
||||
# Cohérent avec décision Codex après revue Gemini : empêcher rechauffe
|
||||
# qwen2.5vl en ctx 8192 et garantir un chemin grounding reproductible.
|
||||
# ⚠️ DETTE (2026-06-05) : qwen3.5:9b est ABSENT du endpoint Ollama/DGX → le
|
||||
# chemin grounding JSON retombe en pratique sur DEFAULT_GROUNDING_FALLBACK
|
||||
# (qwen2.5vl:7b-rpa). Ce chemin JSON est donc peu/pas exercé au runtime DGX.
|
||||
# À pull sur le DGX OU nettoyer (aligner sur le fallback) — décision Dom.
|
||||
DEFAULT_GROUNDING_MODEL = "qwen3.5:9b"
|
||||
DEFAULT_GROUNDING_CTX = 4096
|
||||
DEFAULT_GROUNDING_PREFILL = '{"x_pct":'
|
||||
@@ -234,6 +244,69 @@ def get_grounding_profile(endpoint: str = DEFAULT_OLLAMA_ENDPOINT) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_bbox_grounding_model() -> str:
|
||||
"""Retourne le modèle pour le grounding **format bbox_2d natif** (qwen2.5vl).
|
||||
|
||||
Distinct de get_grounding_profile() (format JSON {x_pct,y_pct} via prefill,
|
||||
défaut qwen3.5:9b). Les chemins bbox_2d de resolve_engine
|
||||
(`parse_bbox_to_norm` / `parse_bbox_to_norm_validated`) exigent un modèle
|
||||
de la famille qwen2.5vl qui émet des coordonnées en pixels.
|
||||
|
||||
D5-v3b (2026-06-03) : désambiguïse l'env var. Historiquement le site bbox
|
||||
lisait `RPA_GROUNDING_MODEL`, partagé avec get_grounding_profile() qui
|
||||
attend un modèle JSON → conflit documenté. On introduit une var dédiée.
|
||||
|
||||
Ordre de résolution :
|
||||
1. RPA_BBOX_GROUNDING_MODEL (dédié, prioritaire)
|
||||
2. RPA_GROUNDING_MODEL (rétrocompat — ancien comportement)
|
||||
3. DEFAULT_GROUNDING_FALLBACK (qwen2.5vl:7b-rpa, présent sur DGX)
|
||||
|
||||
Returns:
|
||||
Nom du modèle bbox_2d (ex: "qwen2.5vl:7b-rpa")
|
||||
"""
|
||||
return (
|
||||
os.environ.get("RPA_BBOX_GROUNDING_MODEL")
|
||||
or os.environ.get("RPA_GROUNDING_MODEL")
|
||||
or DEFAULT_GROUNDING_FALLBACK
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# P1.z (2026-06-04) : résolution centralisée du modèle V4/reasoning, DGX-safe
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Modèle de raisonnement V4/ORA par défaut — DGX-safe.
|
||||
# Les chemins reasoning (ORALoop, détection dialogue/popup, vram_orchestrator)
|
||||
# font du VLM généraliste sur screenshot (JSON action/decision), pas du grounding
|
||||
# bbox. Le default est aligné sur le modèle présent sur le tunnel DGX
|
||||
# (qwen2.5vl:7b-rpa), PAS sur `qwen2.5vl:7b` brut qui est absent du DGX → 404.
|
||||
DEFAULT_REASONING_MODEL = "qwen2.5vl:7b-rpa"
|
||||
|
||||
|
||||
def get_reasoning_model() -> str:
|
||||
"""Retourne le modèle pour les chemins V4/reasoning (ORALoop, détection
|
||||
dialogue/popup, orchestration VRAM).
|
||||
|
||||
Distinct du grounding (get_grounding_profile / get_bbox_grounding_model) :
|
||||
ici on raisonne en langage naturel + JSON sur un screenshot, pas de
|
||||
coordonnées. Pas d'appel réseau (résolution lazy, safe à l'import).
|
||||
|
||||
Ordre de résolution :
|
||||
1. RPA_REASONING_MODEL (dédié, prioritaire)
|
||||
2. RPA_VLM_MODEL / VLM_MODEL (hérite de la config VLM existante)
|
||||
3. DEFAULT_REASONING_MODEL (qwen2.5vl:7b-rpa, présent sur DGX)
|
||||
|
||||
Returns:
|
||||
Nom du modèle de raisonnement (ex: "qwen2.5vl:7b-rpa").
|
||||
"""
|
||||
return (
|
||||
os.environ.get("RPA_REASONING_MODEL")
|
||||
or os.environ.get("RPA_VLM_MODEL")
|
||||
or os.environ.get("VLM_MODEL")
|
||||
or DEFAULT_REASONING_MODEL
|
||||
)
|
||||
|
||||
|
||||
def needs_think_false(model_name: str) -> bool:
|
||||
"""Détermine si un modèle nécessite think=false dans le payload.
|
||||
|
||||
|
||||
191
core/evaluation/openai_compat_lea_bench_adapter.py
Normal file
191
core/evaluation/openai_compat_lea_bench_adapter.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""OpenAI-compatible adapter that writes LeaBench-compatible prediction JSONL.
|
||||
|
||||
Benchmark only — strictly outside Lea runtime. It targets any server exposing
|
||||
`POST /v1/chat/completions` with vision support (vLLM, SGLang, TGI, ...) and
|
||||
never controls the desktop.
|
||||
|
||||
Réutilise la logique de prompt/parsing/normalisation de l'adapter Ollama
|
||||
(`ollama_lea_bench_adapter`) pour garantir un comportement strictement aligné ;
|
||||
seuls le format du payload (data URL `image_url`) et le parsing de la réponse
|
||||
(`choices[0].message.content`) diffèrent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import requests
|
||||
|
||||
from core.evaluation.computer_use_bench import BenchCase, load_cases
|
||||
from core.evaluation.ollama_lea_bench_adapter import (
|
||||
OLLAMA_SYSTEM_PROMPT,
|
||||
build_ollama_user_prompt,
|
||||
encode_screenshot_base64,
|
||||
extract_json_object,
|
||||
normalize_prediction,
|
||||
_safe_abstain,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_MODEL = "qwen3-vl:8b"
|
||||
DEFAULT_BASE_URL = "http://localhost:8001"
|
||||
|
||||
HttpPost = Callable[..., Any]
|
||||
ImageEncoder = Callable[[Path], str]
|
||||
|
||||
|
||||
def build_openai_compat_payload(
|
||||
case: BenchCase,
|
||||
*,
|
||||
model: str,
|
||||
image_b64: str,
|
||||
temperature: float = 0.1,
|
||||
max_tokens: int = 200,
|
||||
json_response_format: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Construit un payload `/v1/chat/completions` compatible vision.
|
||||
|
||||
L'image est passée en data URL JPEG (`data:image/jpeg;base64,...`), format
|
||||
`image_url` standard OpenAI/vLLM/SGLang. Le prompt système et utilisateur
|
||||
sont ceux de l'adapter Ollama (provider-neutral).
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": OLLAMA_SYSTEM_PROMPT.strip()},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": build_ollama_user_prompt(case)},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if json_response_format:
|
||||
# Supporté par OpenAI, vLLM (>=0.4) et SGLang ; ignoré silencieusement
|
||||
# par les serveurs qui ne le connaissent pas.
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_content(response_json: Any) -> str | None:
|
||||
"""Extrait `choices[0].message.content` d'une réponse OpenAI-compatible."""
|
||||
if not isinstance(response_json, dict):
|
||||
return None
|
||||
choices = response_json.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return None
|
||||
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
content = message.get("content")
|
||||
return content if isinstance(content, str) else None
|
||||
|
||||
|
||||
def run_openai_compat_case(
|
||||
case: BenchCase,
|
||||
*,
|
||||
model: str = DEFAULT_MODEL,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: int = 45,
|
||||
post: HttpPost = requests.post,
|
||||
image_encoder: ImageEncoder = encode_screenshot_base64,
|
||||
retries: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
image_b64 = image_encoder(case.screenshot_path)
|
||||
payload = build_openai_compat_payload(case, model=model, image_b64=image_b64)
|
||||
url = f"{base_url.rstrip('/')}/v1/chat/completions"
|
||||
|
||||
last_error = ""
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = post(url, json=payload, timeout=timeout)
|
||||
if getattr(response, "status_code", 0) != 200:
|
||||
last_error = f"HTTP {getattr(response, 'status_code', 'unknown')}"
|
||||
else:
|
||||
text = _extract_content(response.json())
|
||||
if text is None:
|
||||
last_error = "missing_choices_content"
|
||||
else:
|
||||
parsed = extract_json_object(text)
|
||||
if parsed is None and attempt < retries:
|
||||
# On relance une fois en rappelant le contrat JSON.
|
||||
text_msg = payload["messages"][1]["content"][0]
|
||||
text_msg["text"] += (
|
||||
"\nYour previous answer was not valid JSON. Output JSON only."
|
||||
)
|
||||
continue
|
||||
return normalize_prediction(case, parsed, model=model, raw_text=text)
|
||||
except Exception as exc: # pragma: no cover - exercised via fake response paths
|
||||
last_error = str(exc)
|
||||
|
||||
if attempt < retries:
|
||||
time.sleep(2)
|
||||
|
||||
return _safe_abstain(case, model, f"openai_compat_error: {last_error[:80]}")
|
||||
|
||||
|
||||
def write_openai_compat_predictions(
|
||||
cases: list[BenchCase],
|
||||
output_path: str | Path,
|
||||
*,
|
||||
model: str = DEFAULT_MODEL,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: int = 45,
|
||||
post: HttpPost = requests.post,
|
||||
image_encoder: ImageEncoder = encode_screenshot_base64,
|
||||
) -> None:
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", encoding="utf-8") as f:
|
||||
for case in cases:
|
||||
prediction = run_openai_compat_case(
|
||||
case,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
post=post,
|
||||
image_encoder=image_encoder,
|
||||
)
|
||||
f.write(json.dumps(prediction, ensure_ascii=False) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run an OpenAI-compatible vision server on LeaBench cases."
|
||||
)
|
||||
parser.add_argument("--cases", required=True, help="Path to LeaBench cases JSONL.")
|
||||
parser.add_argument("--output", required=True, help="Output predictions JSONL.")
|
||||
parser.add_argument("--repo-root", default=".", help="Repository root for relative screenshot paths.")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="OpenAI-compatible base URL.")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name served by the endpoint.")
|
||||
parser.add_argument("--timeout", type=int, default=45, help="Per-case timeout in seconds.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cases = load_cases(args.cases, repo_root=args.repo_root)
|
||||
write_openai_compat_predictions(
|
||||
cases,
|
||||
args.output,
|
||||
model=args.model,
|
||||
base_url=args.base_url,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
print(f"Wrote OpenAI-compatible predictions: {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -14,6 +14,8 @@ import shutil
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -291,7 +293,7 @@ Si l'écran est normal sans action nécessaire, réponds action="nothing".
|
||||
Réponds UNIQUEMENT le JSON, pas d'explication."""
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
model = get_reasoning_model()
|
||||
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
@@ -588,6 +590,16 @@ def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_i
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = "0000/ui-tars-1.5-7b-q8_0:7b"
|
||||
|
||||
# Gate santé : ne pas envoyer d'image à un modèle « aveugle » (sans capacité vision).
|
||||
# Évite le HTTP 500 silencieux qui masquait la panne (incident 2026-06-08, UI-TARS sans mmproj).
|
||||
from core.detection.model_health import has_vision_capability
|
||||
if not has_vision_capability(model, ollama_url):
|
||||
logger.warning(
|
||||
"[Grounding/UI-TARS] modèle '%s' sans capacité 'vision' — skip propre vers niveau 3",
|
||||
model,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f"[Grounding/UI-TARS] Envoi à {model}: '{prompt}'")
|
||||
|
||||
response = requests.post(
|
||||
|
||||
@@ -21,6 +21,8 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import du contexte cognitif (mémoire de travail)
|
||||
@@ -407,7 +409,7 @@ Règles:
|
||||
|
||||
# --- Appel VLM (Ollama) ---
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
model = get_reasoning_model()
|
||||
|
||||
print(f"🧠 [ORA/reason_instruction] Appel VLM {model}...")
|
||||
|
||||
@@ -1207,7 +1209,7 @@ Règles:
|
||||
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
model = get_reasoning_model()
|
||||
|
||||
resp = requests.post(f"{ollama_url}/api/generate", json={
|
||||
"model": model,
|
||||
@@ -1963,7 +1965,7 @@ Règles:
|
||||
)
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
model = get_reasoning_model()
|
||||
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
|
||||
156
core/execution/trajectory_signature.py
Normal file
156
core/execution/trajectory_signature.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Signature de trajectoire — identité stable d'un parcours appris (décision F1).
|
||||
|
||||
Une trajectoire = séquence ordonnée d'actions sur des cibles stables. La signature
|
||||
hashe uniquement `(action_type, target)` de chaque étape, dans l'ordre, en **ignorant
|
||||
les champs session-spécifiques** (IDs de nœuds, timestamps, coordonnées). Deux
|
||||
apprentissages du même parcours produisent donc la même signature → create-or-update.
|
||||
|
||||
Primitive partagée (Phase 0) : consommée par SP-4 (dédup/persist), SP-2 (rejeu) et le
|
||||
cycle compétences (dédup des skills). Pour composer avec un descripteur d'écran stable,
|
||||
passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `target`.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
_FIELD_SEP = "\x1f" # sépare action_type et target dans une étape
|
||||
_STEP_SEP = "\x1e" # sépare les étapes
|
||||
|
||||
# --- Cible stable : anonymisation PII + normalisation déterministes ----------
|
||||
# Verdict QG Qwen (2026-06-25) : regex DÉDIÉES à la signature (PAS `pii_blur`,
|
||||
# qui protège les dates alors qu'ici on les NEUTRALISE), PAS de NER (un hash
|
||||
# d'identité doit être déterministe et identique labo↔DGX, donc indépendant
|
||||
# d'un modèle versionné). Les noms propres sans titre ne sont pas neutralisés
|
||||
# ici (stratégie « (b) » : impact 0 sur l'audit labo ; gate = audit agrégat
|
||||
# `by_text` DGX avant prod, ajouter une regex ciblée si des noms apparaissent).
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
# Ordre d'application : motifs structurés d'abord, identifiant numérique long
|
||||
# en dernier (sinon il mangerait des fragments de date/téléphone).
|
||||
_RE_EMAIL = re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
|
||||
_RE_DATE = re.compile(r"\b\d{1,4}[/.\-]\d{1,2}[/.\-]\d{1,4}\b")
|
||||
_RE_PHONE = re.compile(r"\b(?:\+?33|0)\s?[1-9](?:[\s.\-]?\d{2}){4}\b")
|
||||
_RE_LONGNUM = re.compile(r"\d{6,}") # IPP / NIR collé / autre identifiant long
|
||||
|
||||
|
||||
def _anonymize_pii(text: str) -> str:
|
||||
"""Neutralise la PII structurée par des tokens stables : deux sessions sur le
|
||||
même champ (patients/dates différents) → même texte cible → même signature."""
|
||||
text = _RE_EMAIL.sub("[email]", text)
|
||||
text = _RE_DATE.sub("[date]", text)
|
||||
text = _RE_PHONE.sub("[tel]", text)
|
||||
text = _RE_LONGNUM.sub("[ipp]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _norm_text(text: str) -> str:
|
||||
"""Normalisation déterministe (même logique que `action_executor._norm_text`,
|
||||
redéfinie ici pour garder ce module léger et sans effet de bord d'import) :
|
||||
minuscules, suppression des accents (NFKD), espaces normalisés."""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace(" ", " ").strip().lower()
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
return _WS_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def _normalize_target(target: str) -> str:
|
||||
"""Cible stable : PII neutralisée PUIS normalisée (casse/accents/espaces)."""
|
||||
return _norm_text(_anonymize_pii(target))
|
||||
|
||||
|
||||
def _normalize_step(step: Mapping[str, Any]) -> str:
|
||||
action_type = str(step.get("action_type", "unknown")).strip().lower()
|
||||
target = _normalize_target(str(step.get("target", "")))
|
||||
return f"{action_type}{_FIELD_SEP}{target}"
|
||||
|
||||
|
||||
def trajectory_signature(steps: Iterable[Mapping[str, Any]]) -> str:
|
||||
"""Retourne la signature SHA-256 (hex, 64 car.) d'une séquence d'étapes.
|
||||
|
||||
Chaque étape est un mapping ; seuls `action_type` et `target` sont pris en compte.
|
||||
Tous les autres champs (node_id, timestamp, coordonnées…) sont ignorés afin de
|
||||
garantir la stabilité de la signature entre deux sessions du même parcours.
|
||||
"""
|
||||
canonical = _STEP_SEP.join(_normalize_step(step) for step in steps)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adaptateur : workflow core (dict) → signature de trajectoire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stable_target(target: Any) -> str:
|
||||
"""Descripteur de cible **stable** entre sessions.
|
||||
|
||||
S'appuie sur le texte sémantique de la cible (`by_text`), volontairement
|
||||
indépendant du moteur de grounding : `by_role` peut valoir 'yolo'/'ocr'/'vlm'
|
||||
(méthode de détection, instable entre sessions) et n'entre donc PAS dans la
|
||||
signature. Fallback quand `by_text` est absent : titre de fenêtre / description VLM.
|
||||
"""
|
||||
if not isinstance(target, Mapping):
|
||||
return ""
|
||||
by_text = str(target.get("by_text") or "").strip()
|
||||
if by_text:
|
||||
return by_text
|
||||
hints = target.get("context_hints")
|
||||
if isinstance(hints, Mapping):
|
||||
return str(hints.get("window_title") or hints.get("vlm_description") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _ordered_edges(workflow: Mapping[str, Any]) -> list:
|
||||
"""Edges dans l'ordre du parcours (BFS depuis entry_nodes), comme le bridge d'import."""
|
||||
edges = list(workflow.get("edges") or [])
|
||||
if not edges:
|
||||
return []
|
||||
by_from: dict = {}
|
||||
for edge in edges:
|
||||
by_from.setdefault((edge or {}).get("from_node"), []).append(edge)
|
||||
entry = list(workflow.get("entry_nodes") or [])
|
||||
nodes = workflow.get("nodes") or []
|
||||
if not entry and nodes:
|
||||
entry = [(nodes[0] or {}).get("node_id")]
|
||||
if not entry:
|
||||
return edges # pas de point d'entrée : ordre brut de la liste
|
||||
ordered: list = []
|
||||
seen_edges: set = set()
|
||||
visited: set = set()
|
||||
queue = list(entry)
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
if node in visited:
|
||||
continue
|
||||
visited.add(node)
|
||||
for edge in by_from.get(node, []):
|
||||
key = id(edge)
|
||||
if key in seen_edges:
|
||||
continue
|
||||
seen_edges.add(key)
|
||||
ordered.append(edge)
|
||||
to_node = (edge or {}).get("to_node")
|
||||
if to_node and to_node not in visited:
|
||||
queue.append(to_node)
|
||||
for edge in edges: # edges non atteints : ajout déterministe en fin
|
||||
if id(edge) not in seen_edges:
|
||||
ordered.append(edge)
|
||||
return ordered
|
||||
|
||||
|
||||
def workflow_step_descriptors(workflow: Mapping[str, Any]) -> list:
|
||||
"""Séquence ordonnée de descripteurs `(action_type, target stable)` d'un workflow core."""
|
||||
descriptors: list = []
|
||||
for edge in _ordered_edges(workflow):
|
||||
action = (edge or {}).get("action") or {}
|
||||
descriptors.append({
|
||||
"action_type": action.get("type", "unknown"),
|
||||
"target": _stable_target(action.get("target")),
|
||||
})
|
||||
return descriptors
|
||||
|
||||
|
||||
def workflow_trajectory_signature(workflow: Mapping[str, Any]) -> str:
|
||||
"""Signature de trajectoire d'un workflow core (dict). Cf. `trajectory_signature`."""
|
||||
return trajectory_signature(workflow_step_descriptors(workflow))
|
||||
@@ -16,13 +16,13 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.detection import vlm_config
|
||||
from .schema import ExtractionField, ExtractionSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration Ollama (coherente avec le reste du projet)
|
||||
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_DEFAULT_MODEL = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
|
||||
|
||||
|
||||
class FieldExtractor:
|
||||
@@ -38,19 +38,34 @@ class FieldExtractor:
|
||||
def __init__(
|
||||
self,
|
||||
ollama_url: str = OLLAMA_DEFAULT_URL,
|
||||
ollama_model: str = OLLAMA_DEFAULT_MODEL,
|
||||
ollama_model: Optional[str] = None,
|
||||
timeout: int = 60,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
ollama_url: URL du serveur Ollama
|
||||
ollama_model: Modele VLM a utiliser
|
||||
ollama_model: Modele VLM a utiliser (None = resolution lazy via vlm_config)
|
||||
timeout: Timeout en secondes pour les appels VLM
|
||||
"""
|
||||
self.ollama_url = ollama_url.rstrip("/")
|
||||
self.ollama_model = ollama_model
|
||||
self._ollama_model = ollama_model # None → resolu paresseusement
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def ollama_model(self) -> str:
|
||||
"""Modele VLM, resolu paresseusement via vlm_config si non fourni.
|
||||
|
||||
Resolution differee au premier acces (pas a l'import ni a la
|
||||
construction) : evite tout hardcode gemma4 et tout appel reseau a froid.
|
||||
"""
|
||||
if not self._ollama_model:
|
||||
self._ollama_model = vlm_config.get_vlm_model(self.ollama_url)
|
||||
return self._ollama_model
|
||||
|
||||
@ollama_model.setter
|
||||
def ollama_model(self, value: Optional[str]) -> None:
|
||||
self._ollama_model = value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
279
core/extraction/role_mapper.py
Normal file
279
core/extraction/role_mapper.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""role_mapper — reconstruction de champs ANCRÉS sur l'OCR.
|
||||
|
||||
Principe cardinal (gate validé le 30/06 sur DPI urgences réel) :
|
||||
le VLM ne fournit QUE des ids de tokens OCR (`value_ids`) ; la valeur est
|
||||
reconstruite ici depuis l'OCR. Aucun texte produit par le VLM ne peut entrer
|
||||
dans une valeur → **0 hallucination par construction**.
|
||||
|
||||
Ce module est volontairement PUR (pas d'appel réseau/VLM) : il prend les tokens
|
||||
OCR (issus de `core.llm.ocr_extractor.extract_grid_from_image`) et la réponse
|
||||
déjà désérialisée du VLM, et produit des champs ancrés. L'appel VLM lui-même
|
||||
est orchestré ailleurs (et mockable), pour rester testable hors-ligne.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
BBox = Tuple[int, int, int, int] # (x_min, y_min, x_max, y_max)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrToken:
|
||||
"""Un token OCR indexé par un id stable."""
|
||||
id: int
|
||||
text: str
|
||||
confidence: float = 1.0
|
||||
bbox: Optional[BBox] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappedField:
|
||||
"""Un champ {rôle → valeur} dont la valeur est 100% issue de l'OCR."""
|
||||
label: str
|
||||
value: str
|
||||
value_ids: List[int]
|
||||
confidence: float
|
||||
bbox: Optional[BBox]
|
||||
anchored: bool
|
||||
invalid_ids: List[int]
|
||||
|
||||
|
||||
def _norm_bbox(bbox) -> Optional[BBox]:
|
||||
"""Normalise une bbox en (x_min, y_min, x_max, y_max).
|
||||
|
||||
Accepte soit 4 points EasyOCR `[[x,y], ...]`, soit un quadruplet déjà plat.
|
||||
"""
|
||||
if bbox is None:
|
||||
return None
|
||||
if len(bbox) == 4 and all(isinstance(v, (int, float)) for v in bbox):
|
||||
return (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]))
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
return (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))
|
||||
|
||||
|
||||
def tokens_from_grid(grid: Sequence[Sequence[dict]]) -> List[OcrToken]:
|
||||
"""Convertit une grille `extract_grid_from_image` en tokens indexés (id séquentiel).
|
||||
|
||||
L'ordre des ids suit l'ordre de lecture de la grille (lignes top→bottom,
|
||||
colonnes left→right), ce qui donne au VLM un référentiel stable.
|
||||
"""
|
||||
tokens: List[OcrToken] = []
|
||||
tid = 0
|
||||
for row in grid:
|
||||
for cell in row:
|
||||
tokens.append(OcrToken(
|
||||
id=tid,
|
||||
text=cell["text"],
|
||||
confidence=float(cell.get("confidence", 1.0)),
|
||||
bbox=_norm_bbox(cell.get("bbox")),
|
||||
))
|
||||
tid += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def _enclosing_bbox(bboxes: Sequence[Optional[BBox]]) -> Optional[BBox]:
|
||||
present = [b for b in bboxes if b is not None]
|
||||
if not present:
|
||||
return None
|
||||
return (
|
||||
min(b[0] for b in present),
|
||||
min(b[1] for b in present),
|
||||
max(b[2] for b in present),
|
||||
max(b[3] for b in present),
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_fields(
|
||||
tokens: Sequence[OcrToken],
|
||||
vlm_fields: Sequence[dict],
|
||||
) -> List[MappedField]:
|
||||
"""Reconstruit les champs à partir des tokens OCR et des `value_ids` du VLM.
|
||||
|
||||
Pour chaque champ VLM `{label, value_ids:[...]}` :
|
||||
- déduplique les ids en préservant l'ordre de lecture donné par le VLM ;
|
||||
- filtre les ids hors OCR (listés dans `invalid_ids`) ;
|
||||
- reconstruit la valeur par concaténation des `text` des tokens valides ;
|
||||
- confidence = min des tokens ancrés (le plus prudent), bbox = englobante.
|
||||
|
||||
Tout champ `value`/texte fourni par le VLM est IGNORÉ : seule la liste
|
||||
d'ids fait foi (anti-hallucination).
|
||||
"""
|
||||
by_id = {t.id: t for t in tokens}
|
||||
out: List[MappedField] = []
|
||||
for vf in vlm_fields:
|
||||
label = vf.get("label", "")
|
||||
seen: List[int] = []
|
||||
for i in (vf.get("value_ids") or []):
|
||||
if i not in seen:
|
||||
seen.append(i)
|
||||
valid = [i for i in seen if i in by_id]
|
||||
invalid = [i for i in seen if i not in by_id]
|
||||
toks = [by_id[i] for i in valid]
|
||||
out.append(MappedField(
|
||||
label=label,
|
||||
value=" ".join(t.text for t in toks),
|
||||
value_ids=valid,
|
||||
confidence=min((t.confidence for t in toks), default=0.0),
|
||||
bbox=_enclosing_bbox([t.bbox for t in toks]),
|
||||
anchored=bool(valid),
|
||||
invalid_ids=invalid,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
# --- Orchestration VLM (client injectable pour rester testable hors-ligne) ---
|
||||
|
||||
# Un client VLM est un callable (image_path, prompt) -> texte de réponse.
|
||||
VlmClient = Callable[[str, str], str]
|
||||
|
||||
|
||||
def build_role_prompt(
|
||||
tokens: Sequence[OcrToken],
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
) -> str:
|
||||
"""Construit le prompt d'attribution de rôles (ancrage strict par ids).
|
||||
|
||||
Mode *guidé* si `roles` est fourni (rôles attendus de l'écran), sinon *libre*
|
||||
(le VLM nomme lui-même les champs). Dans les deux cas le VLM ne renvoie que
|
||||
des `value_ids` — jamais de texte recopié.
|
||||
"""
|
||||
ocr_list = [{"id": t.id, "text": t.text} for t in tokens]
|
||||
if roles:
|
||||
roles_line = (
|
||||
"Rôles attendus sur cet écran (associe chacun s'il est présent) : "
|
||||
+ ", ".join(roles) + ".\n"
|
||||
)
|
||||
else:
|
||||
roles_line = (
|
||||
"Identifie librement les champs présents — le 'label' est le rôle du champ.\n"
|
||||
)
|
||||
return (
|
||||
"Tu reçois une capture d'écran d'un dossier patient et la liste des tokens "
|
||||
"détectés par OCR (chaque token : id, text).\n"
|
||||
+ roles_line +
|
||||
"Pour chaque champ, désigne les tokens OCR qui composent sa VALEUR.\n"
|
||||
"RÈGLES STRICTES :\n"
|
||||
"- Tu ne recopies AUCUN texte. Tu renvoies seulement 'value_ids' : la liste "
|
||||
"des id de tokens OCR (dans l'ordre de lecture) qui forment la valeur.\n"
|
||||
"- 'label' = le rôle du champ. N'invente aucun champ.\n"
|
||||
"- Réponds UNIQUEMENT en JSON PLAT :\n"
|
||||
'{"ecran":"<type en 3 mots>","champs":[{"label":"...","value_ids":[<int>,...]}]}\n\n'
|
||||
"Tokens OCR :\n" + json.dumps(ocr_list, ensure_ascii=False)
|
||||
)
|
||||
|
||||
|
||||
def parse_vlm_json(text: str) -> dict:
|
||||
"""Extrait le 1er objet JSON d'une réponse VLM (tolère les fences ```json).
|
||||
|
||||
Robuste : renvoie `{}` si la réponse n'est pas du JSON exploitable (pas de
|
||||
crash en batch).
|
||||
"""
|
||||
if not text:
|
||||
return {}
|
||||
s = text.strip()
|
||||
if "```" in s:
|
||||
parts = s.split("```")
|
||||
if len(parts) >= 2:
|
||||
s = parts[1]
|
||||
if s.lstrip().lower().startswith("json"):
|
||||
s = s.lstrip()[4:]
|
||||
a, b = s.find("{"), s.rfind("}")
|
||||
if a < 0 or b <= a:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(s[a:b + 1])
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _norm_label(label: str) -> str:
|
||||
"""Normalise un label pour comparaison : minuscules + strip espaces."""
|
||||
return label.strip().lower()
|
||||
|
||||
|
||||
def assess_quality(
|
||||
fields: Sequence[MappedField],
|
||||
required_roles: Optional[Sequence[str]] = None,
|
||||
min_confidence: float = 0.6,
|
||||
) -> str:
|
||||
"""Évalue la qualité d'extraction d'un dossier à partir des champs reconstruits.
|
||||
|
||||
Renvoie l'un des 4 statuts (par priorité décroissante) :
|
||||
- "failed" : aucun champ, OU aucun champ ancré.
|
||||
- "needs_review" : au moins un rôle requis absent ou non ancré.
|
||||
- "partial" : rôles requis ok mais confidence insuffisante OU champs non ancrés.
|
||||
- "complete" : tout ancré, toutes confidences >= min_confidence, aucun non ancré.
|
||||
|
||||
Le matching required_role ↔ field.label est insensible à la casse et aux espaces.
|
||||
"""
|
||||
# --- failed : aucun champ du tout, ou aucun ancré ---
|
||||
anchored = [f for f in fields if f.anchored]
|
||||
if not fields or not anchored:
|
||||
return "failed"
|
||||
|
||||
# --- needs_review : rôle requis absent ou non ancré ---
|
||||
if required_roles:
|
||||
anchored_labels = {_norm_label(f.label) for f in anchored}
|
||||
for role in required_roles:
|
||||
if _norm_label(role) not in anchored_labels:
|
||||
return "needs_review"
|
||||
|
||||
# --- partial : confidence basse sur un champ ancré OU champs non ancrés ---
|
||||
has_low_confidence = any(f.confidence < min_confidence for f in anchored)
|
||||
has_unanchored = any(not f.anchored for f in fields)
|
||||
if has_low_confidence or has_unanchored:
|
||||
return "partial"
|
||||
|
||||
# --- complete ---
|
||||
return "complete"
|
||||
|
||||
|
||||
def map_roles(
|
||||
image_path: str,
|
||||
tokens: Sequence[OcrToken],
|
||||
vlm_client: VlmClient,
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
) -> List[MappedField]:
|
||||
"""Orchestre l'attribution de rôles : prompt → VLM → parse → reconstruction ancrée.
|
||||
|
||||
`vlm_client` est injecté (testable hors-ligne). Le résultat est toujours
|
||||
ancré sur l'OCR via `reconstruct_fields`.
|
||||
"""
|
||||
prompt = build_role_prompt(tokens, roles)
|
||||
raw = vlm_client(image_path, prompt)
|
||||
data = parse_vlm_json(raw)
|
||||
vlm_fields = data.get("champs", []) if isinstance(data, dict) else []
|
||||
return reconstruct_fields(tokens, vlm_fields)
|
||||
|
||||
|
||||
def extract_dossier_from_image(
|
||||
image_path: str,
|
||||
vlm_client: VlmClient,
|
||||
roles: Optional[Sequence[str]] = None,
|
||||
ocr_fn: Optional[Callable[[str], Sequence[Sequence[dict]]]] = None,
|
||||
min_confidence: float = 0.6,
|
||||
required_roles: Optional[Sequence[str]] = None,
|
||||
) -> dict:
|
||||
"""Orchestre l'extraction d'un dossier depuis une capture : OCR → rôles → qualité.
|
||||
|
||||
Enchaîne `ocr_fn` (grille OCR) → `tokens_from_grid` → `map_roles` (VLM, ancrage
|
||||
strict) → `assess_quality`. C'est la brique que le handler runtime
|
||||
`_handle_extract_dossier_action` appellera, avec le vrai OCR et le vrai client
|
||||
vLLM. `ocr_fn` et `vlm_client` sont INJECTABLES (testable hors-ligne).
|
||||
|
||||
`ocr_fn` par défaut = `core.llm.ocr_extractor.extract_grid_from_image` (import
|
||||
LAZY : le module reste pur quand l'OCR est injecté en test).
|
||||
|
||||
Returns:
|
||||
{fields: List[MappedField], status: str, n_tokens: int}
|
||||
"""
|
||||
if ocr_fn is None:
|
||||
from core.llm.ocr_extractor import extract_grid_from_image as ocr_fn
|
||||
grid = ocr_fn(image_path)
|
||||
tokens = tokens_from_grid(grid)
|
||||
fields = map_roles(image_path, tokens, vlm_client, roles)
|
||||
status = assess_quality(fields, required_roles=required_roles, min_confidence=min_confidence)
|
||||
return {"fields": fields, "status": status, "n_tokens": len(tokens)}
|
||||
86
core/extraction/vlm_client.py
Normal file
86
core/extraction/vlm_client.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Client vLLM serveur : (image_path, prompt) -> texte de réponse.
|
||||
|
||||
Petit client réutilisable pour la lecture d'écran (extraction de dossier). Le
|
||||
grounder (`resolve_engine`) fait déjà un POST vers vLLM:8001 mais en INLINE, non
|
||||
exposé ; on factorise ici un client propre, configurable et testable.
|
||||
|
||||
- Image downscalée (largeur max) avant envoi : la fenêtre vLLM est limitée
|
||||
(`max_model_len`), un écran plein déborde sinon (vu 30/06 : 6193+2000 > 8192).
|
||||
- `thinking` désactivé (vérifié : think=on -> sortie vide/lente sur ce modèle).
|
||||
- `post_fn` injectable -> testable sans vLLM réel.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Callable, Optional
|
||||
|
||||
VlmClient = Callable[[str, str], str]
|
||||
|
||||
_DEFAULT_PORT = os.environ.get("VLLM_PORT", "8001")
|
||||
DEFAULT_URL = f"http://localhost:{_DEFAULT_PORT}/v1/chat/completions"
|
||||
DEFAULT_MODEL = os.environ.get("VLLM_MODEL", "Qwen/Qwen3-VL-4B-Instruct")
|
||||
|
||||
|
||||
def img_data_url(image_path: str, max_w: int = 1280) -> str:
|
||||
"""Encode l'image en data-URL PNG base64, downscalée à `max_w` si plus large."""
|
||||
from PIL import Image
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
if img.width > max_w:
|
||||
h = int(img.height * max_w / img.width)
|
||||
img = img.resize((max_w, h), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def build_chat_body(
|
||||
image_path: str,
|
||||
prompt: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
max_tokens: int = 1500,
|
||||
max_w: int = 1280,
|
||||
) -> dict:
|
||||
"""Construit le body chat/completions (image + prompt, thinking off)."""
|
||||
return {
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_data_url(image_path, max_w)}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}],
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
}
|
||||
|
||||
|
||||
def make_vllm_client(
|
||||
url: str = DEFAULT_URL,
|
||||
model: str = DEFAULT_MODEL,
|
||||
max_tokens: int = 1500,
|
||||
max_w: int = 1280,
|
||||
timeout: float = 120,
|
||||
post_fn: Optional[Callable] = None,
|
||||
) -> VlmClient:
|
||||
"""Construit un client `(image_path, prompt) -> texte`, branché sur vLLM.
|
||||
|
||||
`post_fn` (signature `requests.post`) est injectable pour les tests.
|
||||
Lève `RuntimeError` si le serveur ne répond pas 200 (message technique, sans PII).
|
||||
"""
|
||||
def client(image_path: str, prompt: str) -> str:
|
||||
body = build_chat_body(image_path, prompt, model=model, max_tokens=max_tokens, max_w=max_w)
|
||||
poster = post_fn
|
||||
if poster is None:
|
||||
import requests
|
||||
poster = requests.post
|
||||
r = poster(url, json=body, headers={}, timeout=timeout)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"vLLM {r.status_code}: {str(getattr(r, 'text', ''))[:300]}")
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
return client
|
||||
@@ -2,7 +2,7 @@
|
||||
GPU Resource Management Module for RPA Vision V3
|
||||
|
||||
This module provides dynamic GPU resource allocation between ML models:
|
||||
- Ollama VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL) for UI classification
|
||||
- Ollama VLM (modèle central configurable via RPA_VLM_MODEL) for UI classification
|
||||
- CLIP (ViT-B-32) for embedding matching
|
||||
|
||||
The GPUResourceManager optimizes VRAM usage by:
|
||||
|
||||
164
core/gpu/device_policy.py
Normal file
164
core/gpu/device_policy.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Résolution de device paramétrable (auto/cuda/cpu) avec garde-fou VRAM.
|
||||
|
||||
Permet de basculer les étages CPU-par-défaut de la cascade vision (OCR docTR,
|
||||
EasyOCR, YOLO/SoM) vers le GPU local **quand la VRAM est libre**, SANS jamais
|
||||
hardcoder cuda. La politique anti-concurrence VRAM (tout sur CPU) datait d'une
|
||||
époque où les VLM tournaient sur la RTX 5070 locale ; ils tournent désormais
|
||||
sur un DGX distant (tunnel SSH `:11434`), libérant ~9 Go localement.
|
||||
|
||||
Logique de garde-fou inspirée de `core/embedding/clip_embedder.py` (lignes
|
||||
~65-82) : `torch.cuda.is_available()` + `torch.cuda.mem_get_info()`.
|
||||
|
||||
Contraintes :
|
||||
- JAMAIS de hardcode cuda ;
|
||||
- aucun appel réseau ;
|
||||
- import-safe : aucun chargement de modèle, aucune allocation GPU à l'import ;
|
||||
- fallback CPU propre partout (jamais de crash si pas de GPU).
|
||||
|
||||
Override global : variable d'environnement `RPA_VISION_DEVICE` ∈ {cpu, cuda, auto}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GB = 1024 ** 3
|
||||
|
||||
# Valeurs reconnues pour l'argument `requested` et l'override env.
|
||||
_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]:
|
||||
"""Lit l'override `RPA_VISION_DEVICE` s'il est présent et valide.
|
||||
|
||||
Retourne None si absent ou invalide (on retombe alors sur `requested`).
|
||||
"""
|
||||
raw = os.getenv("RPA_VISION_DEVICE", "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
if raw in _VALID:
|
||||
return raw
|
||||
logger.warning(
|
||||
"RPA_VISION_DEVICE='%s' invalide (attendu cpu/cuda/auto) — ignoré",
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _cuda_available() -> bool:
|
||||
"""`torch.cuda.is_available()` protégé contre toute exception driver."""
|
||||
try:
|
||||
return bool(torch.cuda.is_available())
|
||||
except Exception as e: # pragma: no cover - dépend du driver
|
||||
logger.debug("torch.cuda.is_available a levé : %s — CPU", e)
|
||||
return False
|
||||
|
||||
|
||||
def _free_total_gb() -> Optional[tuple[float, float]]:
|
||||
"""VRAM (libre, totale) en Go via mem_get_info, ou None si indisponible."""
|
||||
try:
|
||||
free_bytes, total_bytes = torch.cuda.mem_get_info()
|
||||
return free_bytes / _GB, total_bytes / _GB
|
||||
except Exception as e: # pragma: no cover - dépend du driver
|
||||
logger.debug("torch.cuda.mem_get_info a levé : %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_device(
|
||||
requested: str = "auto",
|
||||
min_free_gb: float = DEFAULT_MIN_FREE_GB,
|
||||
max_total_gb: float = DEFAULT_MAX_TOTAL_GB,
|
||||
) -> str:
|
||||
"""Résout le device effectif ("cuda" ou "cpu") selon la politique VRAM.
|
||||
|
||||
Args:
|
||||
requested: "cpu", "cuda" ou "auto" (défaut). L'override env
|
||||
`RPA_VISION_DEVICE` prime sur cet argument s'il est présent/valide.
|
||||
min_free_gb: VRAM libre minimale (Go) pour autoriser cuda en mode auto.
|
||||
max_total_gb: plafond d'usage VRAM total (Go). Si basculer cuda ferait
|
||||
dépasser ce plafond (used = total - free), on reste CPU. Garde-fou
|
||||
contre la saturation quand d'autres process occupent déjà le GPU.
|
||||
|
||||
Returns:
|
||||
"cuda" ou "cpu". Toujours "cpu" en cas de doute (fallback propre).
|
||||
|
||||
Politique :
|
||||
- "cpu" → "cpu" ;
|
||||
- "cuda" → "cuda" si cuda dispo, sinon "cpu" (fallback loggé) ;
|
||||
- "auto" → "cuda" si cuda dispo ET free ≥ min_free_gb ET
|
||||
used ≤ max_total_gb, sinon "cpu".
|
||||
"""
|
||||
effective = _env_override() or (requested or "auto").strip().lower()
|
||||
if effective not in _VALID:
|
||||
logger.warning(
|
||||
"device demandé '%s' invalide (attendu cpu/cuda/auto) — auto",
|
||||
effective,
|
||||
)
|
||||
effective = "auto"
|
||||
|
||||
if effective == "cpu":
|
||||
return "cpu"
|
||||
|
||||
if not _cuda_available():
|
||||
if effective == "cuda":
|
||||
logger.info("device=cuda demandé mais CUDA indisponible — fallback CPU")
|
||||
return "cpu"
|
||||
|
||||
if effective == "cuda":
|
||||
# Demande explicite : on respecte sans appliquer le garde-fou VRAM
|
||||
# (l'appelant assume). CUDA est dispo → cuda.
|
||||
return "cuda"
|
||||
|
||||
# effective == "auto" : garde-fou VRAM.
|
||||
mem = _free_total_gb()
|
||||
if mem is None:
|
||||
logger.info("auto: mem_get_info indisponible — CPU par prudence")
|
||||
return "cpu"
|
||||
|
||||
free_gb, total_gb = mem
|
||||
used_gb = total_gb - free_gb
|
||||
|
||||
if free_gb < min_free_gb:
|
||||
logger.info(
|
||||
"auto: VRAM libre %.1f Go < seuil %.1f Go — CPU",
|
||||
free_gb, min_free_gb,
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
# 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 (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,
|
||||
)
|
||||
return "cuda"
|
||||
@@ -2,7 +2,7 @@
|
||||
GPU Resource Manager - Central orchestrator for GPU resource allocation
|
||||
|
||||
Manages dynamic allocation of GPU resources between:
|
||||
- Ollama VLM (gemma4:e4b par défaut) - ~10 GB VRAM for UI classification
|
||||
- Ollama VLM (modèle reasoning/VLM central) - ~10 GB VRAM for UI classification
|
||||
- CLIP (ViT-B-32) - ~500 MB VRAM for embedding matching
|
||||
|
||||
Optimizes VRAM usage based on execution mode:
|
||||
@@ -21,6 +21,8 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -54,7 +56,7 @@ class VRAMInfo:
|
||||
class GPUResourceConfig:
|
||||
"""Configuration for GPU resource management."""
|
||||
ollama_endpoint: str = "http://localhost:11434"
|
||||
vlm_model: str = "gemma4:e4b"
|
||||
vlm_model: str = field(default_factory=get_reasoning_model)
|
||||
clip_model: str = "ViT-B-32"
|
||||
idle_timeout_seconds: int = 300 # 5 minutes
|
||||
vram_threshold_for_clip_gpu_mb: int = 1024 # 1 GB
|
||||
|
||||
@@ -13,6 +13,8 @@ from typing import List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -32,7 +34,7 @@ class OllamaManager:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "http://localhost:11434",
|
||||
model: str = "gemma4:e4b",
|
||||
model: Optional[str] = None,
|
||||
default_keep_alive: str = "5m"
|
||||
):
|
||||
"""
|
||||
@@ -44,7 +46,7 @@ class OllamaManager:
|
||||
default_keep_alive: Default keep-alive duration
|
||||
"""
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._model = model
|
||||
self._model = model or get_reasoning_model()
|
||||
self._default_keep_alive = default_keep_alive
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from .t2a_decision import (
|
||||
)
|
||||
from .ocr_extractor import (
|
||||
extract_digits_tesseract_from_image,
|
||||
extract_grid_from_image,
|
||||
extract_table_from_image,
|
||||
extract_text_from_image,
|
||||
)
|
||||
@@ -19,5 +20,6 @@ __all__ = [
|
||||
"build_dpi_enriched",
|
||||
"extract_text_from_image",
|
||||
"extract_table_from_image",
|
||||
"extract_grid_from_image",
|
||||
"extract_digits_tesseract_from_image",
|
||||
]
|
||||
|
||||
@@ -25,14 +25,24 @@ _easyocr_reader = None
|
||||
def easyocr_gpu_enabled(default: bool = False) -> bool:
|
||||
"""Return whether EasyOCR may allocate GPU memory.
|
||||
|
||||
The replay server shares the GPU with Ollama. Defaulting EasyOCR to CPU
|
||||
keeps VRAM available for the VLM; set RPA_EASYOCR_GPU=1 only for a measured
|
||||
OCR benchmark or a runtime that has spare VRAM.
|
||||
Priorité :
|
||||
1. RPA_EASYOCR_GPU explicite (1/0) → décision forcée, compat héritée.
|
||||
2. Sinon, délègue à core.gpu.device_policy.resolve_device("auto") :
|
||||
GPU autorisé uniquement si la VRAM locale est libre (les VLM tournent
|
||||
désormais sur DGX distant, ~9 Go libres localement). Garde-fou VRAM
|
||||
intégré ; fallback CPU propre si pas de GPU.
|
||||
|
||||
`default` n'est utilisé que si la résolution échoue (sécurité).
|
||||
"""
|
||||
raw = os.getenv("RPA_EASYOCR_GPU", "")
|
||||
if not raw:
|
||||
if raw:
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
try:
|
||||
from core.gpu.device_policy import resolve_device
|
||||
return resolve_device("auto") == "cuda"
|
||||
except Exception as e: # pragma: no cover - fallback prudent
|
||||
logger.debug("easyocr_gpu_enabled: resolve_device a échoué (%s)", e)
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_reader():
|
||||
@@ -233,3 +243,107 @@ def extract_table_from_image(
|
||||
except Exception as e:
|
||||
logger.warning("extract_table échoué sur %s : %s", image_path, e)
|
||||
return []
|
||||
|
||||
|
||||
def _cluster_1d(centers: List[float], tol: float) -> List[Tuple[float, int]]:
|
||||
"""Regroupe des positions 1D par proximité (centres triés, gap > tol = nouveau cluster).
|
||||
|
||||
Retourne, pour chaque centre d'entrée (ordre d'origine), un couple
|
||||
(centre_du_cluster, index_du_cluster), les clusters étant indexés dans
|
||||
l'ordre croissant. Permet de mapper lignes (y) et colonnes (x).
|
||||
"""
|
||||
order = sorted(range(len(centers)), key=lambda i: centers[i])
|
||||
cluster_of = [0] * len(centers)
|
||||
cluster_centers: List[List[float]] = []
|
||||
prev = None
|
||||
idx = -1
|
||||
for i in order:
|
||||
c = centers[i]
|
||||
if prev is None or (c - prev) > tol:
|
||||
idx += 1
|
||||
cluster_centers.append([])
|
||||
cluster_centers[idx].append(c)
|
||||
cluster_of[i] = idx
|
||||
prev = c
|
||||
means = [sum(g) / len(g) for g in cluster_centers]
|
||||
return [(means[cluster_of[i]], cluster_of[i]) for i in range(len(centers))]
|
||||
|
||||
|
||||
def extract_grid_from_image(
|
||||
image_path: str,
|
||||
region: Optional[Tuple[int, int, int, int]] = None,
|
||||
row_tol: float = 12.0,
|
||||
col_tol: float = 25.0,
|
||||
) -> List[List[dict]]:
|
||||
"""Extrait un tableau STRUCTURÉ (lignes ET colonnes) via OCR EasyOCR.
|
||||
|
||||
Contrairement à `extract_table_from_image` (liste plate triée par y, x jeté),
|
||||
on conserve la coordonnée x pour reconstruire une grille. Clustering :
|
||||
lignes par proximité du centre y, colonnes par proximité du centre x.
|
||||
|
||||
Args:
|
||||
image_path: chemin du PNG sur disque.
|
||||
region: (x, y, w, h) pour cropper avant OCR. None = image entière.
|
||||
row_tol: écart vertical max (px) entre 2 tokens d'une même ligne.
|
||||
col_tol: écart horizontal max (px) entre 2 tokens d'une même colonne.
|
||||
|
||||
Returns:
|
||||
Grille `List[List[cell]]`, lignes top→bottom, colonnes left→right.
|
||||
`cell = {"text", "bbox", "confidence", "row", "col"}`.
|
||||
En cas d'erreur ou d'absence de tokens, retourne [].
|
||||
"""
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
logger.warning("extract_grid: fichier introuvable %s", image_path)
|
||||
return []
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
img = Image.open(path)
|
||||
if region:
|
||||
x, y, w, h = region
|
||||
img = img.crop((x, y, x + w, y + h))
|
||||
|
||||
reader = _get_reader()
|
||||
results = reader.readtext(np.array(img), detail=1, paragraph=False)
|
||||
|
||||
toks = []
|
||||
for bbox, text, conf in results:
|
||||
t = str(text).strip()
|
||||
if not t:
|
||||
continue
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
toks.append({
|
||||
"text": t,
|
||||
"bbox": bbox,
|
||||
"confidence": conf,
|
||||
"xc": sum(xs) / len(xs),
|
||||
"yc": sum(ys) / len(ys),
|
||||
})
|
||||
if not toks:
|
||||
return []
|
||||
|
||||
rows_cl = _cluster_1d([tk["yc"] for tk in toks], row_tol)
|
||||
cols_cl = _cluster_1d([tk["xc"] for tk in toks], col_tol)
|
||||
for tk, (_yc, r), (_xc, c) in zip(toks, rows_cl, cols_cl):
|
||||
tk["row"], tk["col"] = r, c
|
||||
|
||||
n_rows = max(tk["row"] for tk in toks) + 1
|
||||
grid: List[List[dict]] = [[] for _ in range(n_rows)]
|
||||
for tk in toks:
|
||||
grid[tk["row"]].append({
|
||||
"text": tk["text"],
|
||||
"bbox": tk["bbox"],
|
||||
"confidence": tk["confidence"],
|
||||
"row": tk["row"],
|
||||
"col": tk["col"],
|
||||
})
|
||||
for row in grid:
|
||||
row.sort(key=lambda cell: cell["col"])
|
||||
return grid
|
||||
except Exception as e:
|
||||
logger.warning("extract_grid échoué sur %s : %s", image_path, e)
|
||||
return []
|
||||
|
||||
@@ -1250,12 +1250,16 @@ class Workflow:
|
||||
}
|
||||
if self.chain_config:
|
||||
result["chain_config"] = self.chain_config.to_dict() if hasattr(self.chain_config, 'to_dict') else self.chain_config
|
||||
# machine_id : attribut d'instance posé au runtime (pas un champ dataclass)
|
||||
machine_id = getattr(self, "_machine_id", None)
|
||||
if machine_id:
|
||||
result["machine_id"] = machine_id
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Workflow':
|
||||
"""Désérialiser depuis JSON"""
|
||||
return cls(
|
||||
wf = cls(
|
||||
workflow_id=data["workflow_id"],
|
||||
name=data.get("name", data["workflow_id"]),
|
||||
description=data.get("description", ""),
|
||||
@@ -1277,7 +1281,13 @@ class Workflow:
|
||||
references=data.get("references", []),
|
||||
chain_config=data.get("chain_config")
|
||||
)
|
||||
|
||||
# Reposer machine_id (attribut d'instance) : priorité au champ explicite,
|
||||
# sinon depuis metadata['machine_id'] (rétrocompat des workflows déjà sur disque)
|
||||
machine_id = data.get("machine_id") or (wf.metadata or {}).get("machine_id")
|
||||
if machine_id:
|
||||
wf._machine_id = machine_id
|
||||
return wf
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialiser en JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
119
core/navigation/__init__.py
Normal file
119
core/navigation/__init__.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Navigation brique — login visuel, recherche dossiers, vérification écran.
|
||||
|
||||
Modules :
|
||||
- visual_verifier : verify_before / verify_after chaque action (vision = validateur, OCR-ancré)
|
||||
- grounding : résolution visuelle d'éléments UI (OCR-anchor first, VLM fallback, coords cache)
|
||||
- visual_login : login form resolution + verification (DPI urgences default config)
|
||||
- action_resolver : pont navigation → runtime (coords normalisés, OCR/VLM adapters)
|
||||
|
||||
Pattern d'injection : VlmClient + OcrClient + OcrDetailedClient injectables
|
||||
"""
|
||||
|
||||
from .visual_verifier import verify_screen_match, ScreenMatchResult
|
||||
from .action_resolver import navigate_login, NavigateResult
|
||||
|
||||
__all__ = [
|
||||
"verify_screen_match",
|
||||
"ScreenMatchResult",
|
||||
"navigate_login",
|
||||
"NavigateResult",
|
||||
"_handle_navigate_action",
|
||||
]
|
||||
|
||||
# Handler pour replay_engine — importé par api_stream.py
|
||||
def _handle_navigate_action(
|
||||
action: dict,
|
||||
replay_state: dict,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Handler serveur pour action navigate (branchement replay_engine).
|
||||
|
||||
Thin wrapper : résout coords du login form et les stocke dans
|
||||
replay_state["variables"] pour les actions type/click suivantes.
|
||||
|
||||
N'échoue jamais le replay — toute erreur → log + needs_review.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger("navigation._handle_navigate_action")
|
||||
|
||||
params = action.get("parameters") or {}
|
||||
navigate_action = params.get("action", "login")
|
||||
|
||||
# Noms des variables output (configurable)
|
||||
login_var = (params.get("login_coords_var") or "navigate_login_coords").strip()
|
||||
password_var = (params.get("password_coords_var") or "navigate_password_coords").strip()
|
||||
submit_var = (params.get("submit_coords_var") or "navigate_submit_coords").strip()
|
||||
|
||||
variables = replay_state.setdefault("variables", {})
|
||||
|
||||
try:
|
||||
screenshot_path = ""
|
||||
# Résoudre screenshot depuis replay_state
|
||||
if "last_screenshot_path" in replay_state:
|
||||
screenshot_path = replay_state["last_screenshot_path"]
|
||||
elif "last_heartbeat" in replay_state:
|
||||
hb = replay_state["last_heartbeat"]
|
||||
screenshot_path = hb.get("screenshot_path", "") if isinstance(hb, dict) else ""
|
||||
|
||||
if not screenshot_path:
|
||||
logger.warning("navigate: no screenshot for session %s", session_id)
|
||||
variables[login_var] = {"error": "no_screenshot"}
|
||||
return False
|
||||
|
||||
# Dimensions écran (fallback 1920×1080)
|
||||
screen_width = replay_state.get("screen_width", 1920)
|
||||
screen_height = replay_state.get("screen_height", 1080)
|
||||
|
||||
# OCR/VLM clients — lazy import pour éviter circular dependency
|
||||
from core.llm import extract_grid_from_image
|
||||
from core.extraction.vlm_client import make_vllm_client
|
||||
from core.navigation.action_resolver import make_ocr_detailed_from_grid
|
||||
|
||||
ocr_detailed = make_ocr_detailed_from_grid(extract_grid_from_image)
|
||||
vlm_client = make_vllm_client()
|
||||
|
||||
# Config login
|
||||
from core.navigation.visual_login import LoginFormConfig, dpi_urgences_login_config
|
||||
config = dpi_urgences_login_config()
|
||||
if "login_field" in params:
|
||||
config = LoginFormConfig(
|
||||
login_field=params.get("login_field", config.login_field),
|
||||
password_field=params.get("password_field", config.password_field),
|
||||
submit_button=params.get("submit_button", config.submit_button),
|
||||
success_elements=params.get("success_elements", config.success_elements),
|
||||
context=params.get("context", config.context),
|
||||
)
|
||||
|
||||
# Orchestration navigate
|
||||
from core.navigation.action_resolver import navigate_login
|
||||
result = navigate_login(
|
||||
screenshot_path, config=config,
|
||||
ocr_client=ocr_detailed, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
)
|
||||
|
||||
# Stocker coords dans variables (format dict pour substitution)
|
||||
if result.login_coords:
|
||||
variables[login_var] = result.login_coords.to_dict()
|
||||
if result.password_coords:
|
||||
variables[password_var] = result.password_coords.to_dict()
|
||||
if result.submit_coords:
|
||||
variables[submit_var] = result.submit_coords.to_dict()
|
||||
|
||||
variables["navigate_result"] = {
|
||||
"all_resolved": result.all_resolved,
|
||||
"method": result.login_coords.method if result.login_coords else "",
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
if not result.all_resolved:
|
||||
logger.warning("navigate: incomplete — %s", result.error)
|
||||
return False
|
||||
|
||||
logger.info("navigate: login form resolved OK (method=%s)", result.login_coords.method if result.login_coords else "?")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("navigate: exception (%s) — needs_review", e)
|
||||
variables["navigate_result"] = {"all_resolved": False, "error": str(e)}
|
||||
return False
|
||||
205
core/navigation/action_resolver.py
Normal file
205
core/navigation/action_resolver.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Action resolver — pont entre modules navigation et runtime replay.
|
||||
|
||||
Orchestre verify → ground → store coords pour le handler replay_engine.
|
||||
Convertit coords pixels → normalisé (x_pct/y_pct) pour le client Agent V1.
|
||||
|
||||
Architecture :
|
||||
- handler replay_engine = thin wrapper (appelle action_resolver)
|
||||
- action_resolver = bridge (adapte OCR/VLM runtime → interfaces navigation)
|
||||
- modules navigation = pure functions (ne connaissent pas le runtime)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.grounding import (
|
||||
BBox,
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrDetailedClient,
|
||||
OcrTokenInfo,
|
||||
ground_element,
|
||||
)
|
||||
from core.navigation.visual_login import (
|
||||
LoginFormConfig,
|
||||
LoginResolution,
|
||||
dpi_urgences_login_config,
|
||||
resolve_login_form,
|
||||
verify_login_visible,
|
||||
verify_login_success,
|
||||
)
|
||||
from core.navigation.visual_verifier import (
|
||||
OcrClient,
|
||||
ScreenMatchResult,
|
||||
VlmClient,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavigateCoords:
|
||||
"""Normalized coords for a grounded element — format Agent V1 client."""
|
||||
|
||||
x_pct: float # center x normalized [0-1]
|
||||
y_pct: float # center y normalized [0-1]
|
||||
bbox_pct: Optional[Tuple[float, float, float, float]] = None # (x1, y1, x2, y2) normalized
|
||||
method: str = "" # grounding method used
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {"x_pct": self.x_pct, "y_pct": self.y_pct, "method": self.method}
|
||||
if self.bbox_pct:
|
||||
d["bbox_pct"] = list(self.bbox_pct)
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class NavigateResult:
|
||||
"""Result of a navigate action — coords for each resolved field."""
|
||||
|
||||
login_coords: Optional[NavigateCoords] = None
|
||||
password_coords: Optional[NavigateCoords] = None
|
||||
submit_coords: Optional[NavigateCoords] = None
|
||||
all_resolved: bool = False
|
||||
pre_verify: Optional[ScreenMatchResult] = None
|
||||
post_verify: Optional[ScreenMatchResult] = None # set later by verify_after
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ── Coordinate conversion ────────────────────────────────────────────
|
||||
|
||||
|
||||
def grounded_to_coords(
|
||||
element: GroundedElement,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
) -> NavigateCoords:
|
||||
"""Convert GroundedElement (pixels) to NavigateCoords (normalized pct)."""
|
||||
x_pct = element.center[0] / screen_width if screen_width else 0
|
||||
y_pct = element.center[1] / screen_height if screen_height else 0
|
||||
x1_pct = element.bbox[0] / screen_width if screen_width else 0
|
||||
y1_pct = element.bbox[1] / screen_height if screen_height else 0
|
||||
x2_pct = element.bbox[2] / screen_width if screen_width else 0
|
||||
y2_pct = element.bbox[3] / screen_height if screen_height else 0
|
||||
return NavigateCoords(
|
||||
x_pct=x_pct,
|
||||
y_pct=y_pct,
|
||||
bbox_pct=(x1_pct, y1_pct, x2_pct, y2_pct),
|
||||
method=element.method,
|
||||
)
|
||||
|
||||
|
||||
# ── OCR adapter ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_ocr_detailed_from_grid(
|
||||
grid_fn: Callable[[str], List[List[Dict[str, Any]]]],
|
||||
) -> OcrDetailedClient:
|
||||
"""Adapt extract_grid_from_image → OcrDetailedClient (List[OcrTokenInfo]).
|
||||
|
||||
Converts the grid format (list of rows of cells with bbox) into
|
||||
flat OcrTokenInfo list with normalized LTRB bbox.
|
||||
"""
|
||||
from core.extraction.role_mapper import tokens_from_grid
|
||||
|
||||
def client(image_path: str) -> List[OcrTokenInfo]:
|
||||
grid = grid_fn(image_path)
|
||||
ocr_tokens = tokens_from_grid(grid)
|
||||
return [
|
||||
OcrTokenInfo(
|
||||
text=t.text,
|
||||
bbox=t.bbox,
|
||||
confidence=t.confidence,
|
||||
)
|
||||
for t in ocr_tokens
|
||||
]
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def make_ocr_simple_from_detailed(
|
||||
ocr_detailed: OcrDetailedClient,
|
||||
) -> OcrClient:
|
||||
"""Derive text-only OcrClient from OcrDetailedClient."""
|
||||
def client(image_path: str) -> List[str]:
|
||||
return [t.text for t in ocr_detailed(image_path)]
|
||||
return client
|
||||
|
||||
|
||||
# ── Navigate login orchestration ─────────────────────────────────────
|
||||
|
||||
|
||||
def navigate_login(
|
||||
screenshot_path: str,
|
||||
config: Optional[LoginFormConfig] = None,
|
||||
ocr_client: Optional[OcrDetailedClient] = None,
|
||||
vlm_client: Optional[VlmClient] = None,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
skip_pre_verify: bool = False,
|
||||
) -> NavigateResult:
|
||||
"""Orchestrate login navigation: verify → ground → convert coords.
|
||||
|
||||
Returns NavigateResult with normalized coords for each field.
|
||||
The handler stores these in replay_state variables for subsequent
|
||||
type/click actions.
|
||||
"""
|
||||
if config is None:
|
||||
config = dpi_urgences_login_config()
|
||||
|
||||
if ocr_client is None or vlm_client is None:
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
error="ocr_client and vlm_client required",
|
||||
)
|
||||
|
||||
ocr_simple = make_ocr_simple_from_detailed(ocr_client)
|
||||
|
||||
# Step 1: Pre-verification (optional)
|
||||
pre_verify = None
|
||||
if not skip_pre_verify:
|
||||
pre_verify = verify_login_visible(
|
||||
screenshot_path, config, ocr_simple, vlm_client,
|
||||
)
|
||||
if not pre_verify.match:
|
||||
logger.warning("navigate_login: pre-verify failed — %s", pre_verify.describe())
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
pre_verify=pre_verify,
|
||||
error=f"pre-verify failed: {pre_verify.describe()}",
|
||||
)
|
||||
|
||||
# Step 2: Ground all fields
|
||||
resolution = resolve_login_form(
|
||||
screenshot_path, config, ocr_client, vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache,
|
||||
)
|
||||
|
||||
if not resolution.all_resolved:
|
||||
logger.warning("navigate_login: incomplete resolution — %s", resolution.describe())
|
||||
return NavigateResult(
|
||||
all_resolved=False,
|
||||
pre_verify=pre_verify,
|
||||
error=f"incomplete resolution: {resolution.describe()}",
|
||||
)
|
||||
|
||||
# Step 3: Convert to normalized coords
|
||||
login_coords = grounded_to_coords(resolution.login_field, screen_width, screen_height) if resolution.login_field else None
|
||||
password_coords = grounded_to_coords(resolution.password_field, screen_width, screen_height) if resolution.password_field else None
|
||||
submit_coords = grounded_to_coords(resolution.submit_button, screen_width, screen_height) if resolution.submit_button else None
|
||||
|
||||
return NavigateResult(
|
||||
login_coords=login_coords,
|
||||
password_coords=password_coords,
|
||||
submit_coords=submit_coords,
|
||||
all_resolved=True,
|
||||
pre_verify=pre_verify,
|
||||
)
|
||||
375
core/navigation/grounding.py
Normal file
375
core/navigation/grounding.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""Grounding — résolution visuelle d'éléments UI → coords (bbox + center).
|
||||
|
||||
Architecture OCR-ancrée (alignée avec visual_verifier) :
|
||||
- STRATÉGIE 1 : OCR-anchor — si le texte cible est trouvé par OCR,
|
||||
utiliser le bbox du token OCR (déterministe, zero hallucination).
|
||||
- STRATÉGIE 2 : VLM grounder — si OCR ne trouve pas le texte,
|
||||
le VLM localise l'élément visuellement (fallback, risque contrôlé).
|
||||
- CACHE coords : mémorise les coords résolues, validées par vision avant usage.
|
||||
Si cached coords fail → re-résolution visuelle.
|
||||
|
||||
Coords = cache local validé par vue (Dom/Claude recadrage 01/07).
|
||||
Vision = source de vérité, coords = shortcut validé.
|
||||
|
||||
BBox format interne : LTRB (x1, y1, x2, y2) pixels absolus —
|
||||
cohérent avec SomElement, OcrToken, DetectedUIElement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.visual_verifier import (
|
||||
fuzzy_match,
|
||||
normalize_text,
|
||||
OcrClient,
|
||||
VlmClient,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# BBox format: LTRB pixels (x1, y1, x2, y2)
|
||||
BBox = Tuple[int, int, int, int]
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrTokenInfo:
|
||||
"""OCR token with bounding box — for grounding (richer than text-only)."""
|
||||
|
||||
text: str
|
||||
bbox: Optional[BBox] = None # (x1, y1, x2, y2) LTRB pixels
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
# Type alias — injectable OCR client returning tokens with bbox
|
||||
# More detailed than visual_verifier's OcrClient (which returns List[str])
|
||||
OcrDetailedClient = Callable[[str], List[OcrTokenInfo]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundedElement:
|
||||
"""A UI element grounded on screen with coordinates."""
|
||||
|
||||
role: str
|
||||
text: str
|
||||
bbox: BBox # (x1, y1, x2, y2) LTRB pixels
|
||||
center: Tuple[int, int] # (cx, cy) — click target
|
||||
confidence: float
|
||||
method: str # "ocr_anchor" or "vlm_grounder" or "cache"
|
||||
source_ocr_text: str = "" # actual OCR text that matched (for fuzzy)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoordsCacheEntry:
|
||||
"""Cached coordinates for a UI element."""
|
||||
|
||||
element_key: str # "role:text"
|
||||
bbox: BBox
|
||||
center: Tuple[int, int]
|
||||
method: str # how it was originally resolved
|
||||
validation_count: int = 0
|
||||
|
||||
|
||||
class CoordsCache:
|
||||
"""In-memory cache of grounded coordinates.
|
||||
|
||||
Entries are validated by vision before use (verify_after).
|
||||
If cached coords fail verification → invalidate + re-resolve.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: Dict[str, CoordsCacheEntry] = {}
|
||||
|
||||
def get(self, element_key: str) -> Optional[CoordsCacheEntry]:
|
||||
return self._entries.get(element_key)
|
||||
|
||||
def put(
|
||||
self,
|
||||
element_key: str,
|
||||
bbox: BBox,
|
||||
center: Tuple[int, int],
|
||||
method: str,
|
||||
) -> None:
|
||||
entry = self._entries.get(element_key)
|
||||
if entry:
|
||||
entry.bbox = bbox
|
||||
entry.center = center
|
||||
entry.method = method
|
||||
entry.validation_count += 1
|
||||
else:
|
||||
self._entries[element_key] = CoordsCacheEntry(
|
||||
element_key=element_key,
|
||||
bbox=bbox,
|
||||
center=center,
|
||||
method=method,
|
||||
validation_count=1,
|
||||
)
|
||||
|
||||
def invalidate(self, element_key: str) -> None:
|
||||
self._entries.pop(element_key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
|
||||
def keys(self) -> List[str]:
|
||||
return list(self._entries.keys())
|
||||
|
||||
|
||||
# ── Helper functions ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def bbox_center(bbox: BBox) -> Tuple[int, int]:
|
||||
"""Compute center point from LTRB bbox."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
|
||||
def make_element_key(role: str, text: str) -> str:
|
||||
"""Create a stable cache key from role + text."""
|
||||
return f"{role}:{normalize_text(text)}"
|
||||
|
||||
|
||||
# ── OCR-anchored grounding (deterministic) ───────────────────────────
|
||||
|
||||
|
||||
def ocr_anchor_ground(
|
||||
ocr_tokens: List[OcrTokenInfo],
|
||||
target: Dict[str, Any],
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Ground an element using OCR tokens with bbox (deterministic).
|
||||
|
||||
Finds the target text in OCR tokens via fuzzy match.
|
||||
Returns GroundedElement with bbox from the matching OCR token.
|
||||
"""
|
||||
target_text = target.get("text", "")
|
||||
target_role = target.get("role", "?")
|
||||
|
||||
if not target_text:
|
||||
return None
|
||||
|
||||
for token in ocr_tokens:
|
||||
if fuzzy_match(target_text, token.text, threshold=fuzzy_threshold):
|
||||
if token.bbox is None:
|
||||
continue # token found but no bbox → can't ground
|
||||
|
||||
return GroundedElement(
|
||||
role=target_role,
|
||||
text=target_text,
|
||||
bbox=token.bbox,
|
||||
center=bbox_center(token.bbox),
|
||||
confidence=token.confidence,
|
||||
method="ocr_anchor",
|
||||
source_ocr_text=token.text,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── VLM grounder (fallback) ─────────────────────────────────────────
|
||||
|
||||
|
||||
def build_grounder_prompt(
|
||||
target: Dict[str, Any],
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""Build VLM prompt for locating a UI element on screen.
|
||||
|
||||
Asks for bounding box in normalized coordinates [0-1].
|
||||
"""
|
||||
role = target.get("role", "?")
|
||||
text = target.get("text", "")
|
||||
extra = target.get("extra", "")
|
||||
|
||||
prompt = (
|
||||
"You are a UI element locator. Find the specified element on this "
|
||||
"screenshot and return its bounding box.\n"
|
||||
)
|
||||
if context:
|
||||
prompt += f"Context: {context}\n"
|
||||
prompt += f"Target element: {role} with text \"{text}\""
|
||||
if extra:
|
||||
prompt += f" ({extra})"
|
||||
prompt += (
|
||||
"\n\nRespond in JSON format:\n"
|
||||
"{\"found\": true/false, "
|
||||
"\"bbox\": [x1_norm, y1_norm, x2_norm, y2_norm], "
|
||||
"\"confidence\": 0.0-1.0, "
|
||||
"\"description\": \"...\"}\n"
|
||||
"bbox coordinates are normalized [0.0-1.0] relative to image dimensions "
|
||||
"(x1=left, y1=top, x2=right, y2=bottom). "
|
||||
"Only return found=true if you can clearly locate the element."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_grounder_response(
|
||||
vlm_text: str,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
target: Dict[str, Any],
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Parse VLM grounder response into GroundedElement.
|
||||
|
||||
Converts normalized bbox [0-1] to absolute pixels.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(vlm_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("grounding: VLM response not parseable as JSON")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if not data.get("found", False):
|
||||
return None
|
||||
|
||||
bbox_norm = data.get("bbox", [])
|
||||
if not isinstance(bbox_norm, list) or len(bbox_norm) != 4:
|
||||
logger.warning("grounding: invalid bbox format from VLM")
|
||||
return None
|
||||
|
||||
# Convert normalized [0-1] to absolute pixels
|
||||
try:
|
||||
x1 = int(float(bbox_norm[0]) * screen_width)
|
||||
y1 = int(float(bbox_norm[1]) * screen_height)
|
||||
x2 = int(float(bbox_norm[2]) * screen_width)
|
||||
y2 = int(float(bbox_norm[3]) * screen_height)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("grounding: bbox values not numeric")
|
||||
return None
|
||||
|
||||
# Clamp to screen bounds
|
||||
x1 = max(0, min(x1, screen_width))
|
||||
y1 = max(0, min(y1, screen_height))
|
||||
x2 = max(x1, min(x2, screen_width))
|
||||
y2 = max(y1, min(y2, screen_height))
|
||||
|
||||
confidence = data.get("confidence", 0.5)
|
||||
if isinstance(confidence, str):
|
||||
try:
|
||||
confidence = float(confidence)
|
||||
except ValueError:
|
||||
confidence = 0.5
|
||||
|
||||
bbox_abs: BBox = (x1, y1, x2, y2)
|
||||
|
||||
return GroundedElement(
|
||||
role=target.get("role", "?"),
|
||||
text=target.get("text", ""),
|
||||
bbox=bbox_abs,
|
||||
center=bbox_center(bbox_abs),
|
||||
confidence=confidence,
|
||||
method="vlm_grounder",
|
||||
)
|
||||
|
||||
|
||||
# ── Core grounding function (composition) ───────────────────────────
|
||||
|
||||
|
||||
def ground_element(
|
||||
screenshot_path: str,
|
||||
target: Dict[str, Any],
|
||||
ocr_client: OcrDetailedClient,
|
||||
vlm_client: VlmClient,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
context: str = "",
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> Optional[GroundedElement]:
|
||||
"""Ground a UI element on screen — OCR-anchor first, VLM fallback.
|
||||
|
||||
Resolution strategy:
|
||||
1. Cache: if cached coords exist → return cached (validated separately)
|
||||
2. OCR-anchor: deterministic, zero hallucination
|
||||
3. VLM grounder: fallback when OCR can't find the text
|
||||
|
||||
Args:
|
||||
screenshot_path: path to screenshot image
|
||||
target: {"role": "bouton", "text": "Connexion"} — element to find
|
||||
ocr_client: injectable OCR client returning List[OcrTokenInfo]
|
||||
vlm_client: injectable VLM client (image_path, prompt) -> text
|
||||
screen_width/height: screen dimensions for pixel conversion
|
||||
coords_cache: optional CoordsCache for memoization
|
||||
context: optional context (e.g. "page login DPI")
|
||||
fuzzy_threshold: fuzzy match threshold for OCR anchoring
|
||||
|
||||
Returns:
|
||||
GroundedElement with bbox + center, or None if not found
|
||||
"""
|
||||
target_text = target.get("text", "")
|
||||
target_role = target.get("role", "?")
|
||||
element_key = make_element_key(target_role, target_text)
|
||||
|
||||
# Step 0: Check cache
|
||||
if coords_cache:
|
||||
cached = coords_cache.get(element_key)
|
||||
if cached:
|
||||
cached.validation_count += 1
|
||||
logger.info("grounding: using cached coords for %s", element_key)
|
||||
return GroundedElement(
|
||||
role=target_role,
|
||||
text=target_text,
|
||||
bbox=cached.bbox,
|
||||
center=cached.center,
|
||||
confidence=1.0, # cached = previously validated
|
||||
method="cache",
|
||||
)
|
||||
|
||||
# Step 1: OCR-anchor (deterministic)
|
||||
try:
|
||||
ocr_tokens = ocr_client(screenshot_path)
|
||||
except Exception as e:
|
||||
logger.warning("grounding: OCR call failed (%s)", e)
|
||||
ocr_tokens = []
|
||||
|
||||
ocr_result = ocr_anchor_ground(ocr_tokens, target, fuzzy_threshold)
|
||||
|
||||
if ocr_result:
|
||||
if coords_cache:
|
||||
coords_cache.put(element_key, ocr_result.bbox, ocr_result.center, "ocr_anchor")
|
||||
logger.info(
|
||||
"grounding: OCR-anchor found '%s' (matched OCR='%s', conf=%.2f)",
|
||||
target_text, ocr_result.source_ocr_text, ocr_result.confidence,
|
||||
)
|
||||
return ocr_result
|
||||
|
||||
# Step 2: VLM grounder (fallback)
|
||||
if not target_text:
|
||||
logger.warning("grounding: no text for target, VLM grounder needs text")
|
||||
return None
|
||||
|
||||
prompt = build_grounder_prompt(target, context)
|
||||
|
||||
try:
|
||||
vlm_text = vlm_client(screenshot_path, prompt)
|
||||
except Exception as e:
|
||||
logger.warning("grounding: VLM grounder call failed (%s)", e)
|
||||
return None
|
||||
|
||||
vlm_result = parse_grounder_response(vlm_text, screen_width, screen_height, target)
|
||||
|
||||
if vlm_result:
|
||||
if coords_cache:
|
||||
coords_cache.put(element_key, vlm_result.bbox, vlm_result.center, "vlm_grounder")
|
||||
logger.info(
|
||||
"grounding: VLM grounder found '%s' (conf=%.2f)",
|
||||
target_text, vlm_result.confidence,
|
||||
)
|
||||
return vlm_result
|
||||
|
||||
logger.warning("grounding: element '%s' not found by OCR or VLM", target_text)
|
||||
return None
|
||||
227
core/navigation/visual_login.py
Normal file
227
core/navigation/visual_login.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Visual login — résolution + vérification du formulaire de login par grounding.
|
||||
|
||||
Architecture (alignée visual_verifier + grounding) :
|
||||
- verify_before : formulaire login visible (champs + bouton présents)
|
||||
- resolve_login_form : ground chaque champ (login, password, bouton) → coords
|
||||
- verify_after : dashboard/accueil visible (post-login)
|
||||
- Chaque étape encadrée par vision (DETTE-023 couvert)
|
||||
|
||||
Coords = cache local validé par vue (Dom/Claude recadrage).
|
||||
Le runtime exécute les actions (type/click) — ce module résout + valide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from core.navigation.grounding import (
|
||||
BBox,
|
||||
CoordsCache,
|
||||
GroundedElement,
|
||||
OcrDetailedClient,
|
||||
OcrTokenInfo,
|
||||
ground_element,
|
||||
)
|
||||
from core.navigation.visual_verifier import (
|
||||
OcrClient,
|
||||
ScreenMatchResult,
|
||||
VlmClient,
|
||||
verify_before,
|
||||
verify_after,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Dataclasses ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginFormConfig:
|
||||
"""Configuration for a login form — what to look for."""
|
||||
|
||||
login_field: Dict[str, Any] # {"role": "champ", "text": "Login"}
|
||||
password_field: Dict[str, Any] # {"role": "champ", "text": "Mot de passe"}
|
||||
submit_button: Dict[str, Any] # {"role": "bouton", "text": "Connexion"}
|
||||
success_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
context: str = "" # e.g. "DPI urgences"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResolution:
|
||||
"""Result of login form resolution — grounded coords for each field."""
|
||||
|
||||
login_field: Optional[GroundedElement] = None
|
||||
password_field: Optional[GroundedElement] = None
|
||||
submit_button: Optional[GroundedElement] = None
|
||||
all_resolved: bool = False
|
||||
method: str = "" # "ocr_anchor", "vlm_grounder", "mixed", "cache"
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = []
|
||||
if self.login_field:
|
||||
parts.append(f"login@{self.login_field.center} ({self.login_field.method})")
|
||||
else:
|
||||
parts.append("login: NOT FOUND")
|
||||
if self.password_field:
|
||||
parts.append(f"password@{self.password_field.center} ({self.password_field.method})")
|
||||
else:
|
||||
parts.append("password: NOT FOUND")
|
||||
if self.submit_button:
|
||||
parts.append(f"button@{self.submit_button.center} ({self.submit_button.method})")
|
||||
else:
|
||||
parts.append("button: NOT FOUND")
|
||||
status = "OK" if self.all_resolved else "INCOMPLETE"
|
||||
return f"Login resolution [{status}]: " + ", ".join(parts)
|
||||
|
||||
|
||||
# ── Default configs ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def dpi_urgences_login_config() -> LoginFormConfig:
|
||||
"""Default config for DPI urgences login form."""
|
||||
return LoginFormConfig(
|
||||
login_field={"role": "champ", "text": "Login", "extra": "champ identifiant"},
|
||||
password_field={"role": "champ", "text": "Mot de passe", "extra": "champ password"},
|
||||
submit_button={"role": "bouton", "text": "Connexion", "extra": "bouton submit"},
|
||||
success_elements=[
|
||||
{"role": "page", "text": "Accueil"},
|
||||
{"role": "page", "text": "Dashboard"},
|
||||
],
|
||||
context="DPI urgences — page login",
|
||||
)
|
||||
|
||||
|
||||
# ── Helper ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ocr_detailed_to_simple(ocr_detailed: OcrDetailedClient) -> OcrClient:
|
||||
"""Convert OcrDetailedClient (text+bbox) to OcrClient (text-only) for verification."""
|
||||
def client(image_path: str) -> List[str]:
|
||||
return [t.text for t in ocr_detailed(image_path)]
|
||||
return client
|
||||
|
||||
|
||||
# ── Core functions ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def verify_login_visible(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify login form is visible on screen (pre-condition).
|
||||
|
||||
Checks that login field, password field, and submit button are present.
|
||||
Uses OCR-anchored verification (deterministic presence, VLM role).
|
||||
"""
|
||||
expected = [
|
||||
config.login_field,
|
||||
config.password_field,
|
||||
config.submit_button,
|
||||
]
|
||||
return verify_before(
|
||||
screenshot_path, expected, ocr_client, vlm_client,
|
||||
context=config.context,
|
||||
)
|
||||
|
||||
|
||||
def verify_login_success(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify dashboard/accueil visible after login (post-condition).
|
||||
|
||||
Higher threshold (verify_after = 0.8) — false positive = Léa proceeds wrong.
|
||||
"""
|
||||
if not config.success_elements:
|
||||
# No success criteria defined → can't verify
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=0.0,
|
||||
reason="no success_elements defined in config",
|
||||
)
|
||||
return verify_after(
|
||||
screenshot_path, config.success_elements, ocr_client, vlm_client,
|
||||
context=f"POST-LOGIN: {config.context}",
|
||||
)
|
||||
|
||||
|
||||
def resolve_login_form(
|
||||
screenshot_path: str,
|
||||
config: LoginFormConfig,
|
||||
ocr_client: OcrDetailedClient,
|
||||
vlm_client: VlmClient,
|
||||
screen_width: int = 1920,
|
||||
screen_height: int = 1080,
|
||||
coords_cache: Optional[CoordsCache] = None,
|
||||
) -> LoginResolution:
|
||||
"""Ground all login form elements → coords for runtime action.
|
||||
|
||||
Resolution strategy per element:
|
||||
1. Cache hit → return cached coords (validated separately)
|
||||
2. OCR-anchor → deterministic bbox from OCR token
|
||||
3. VLM grounder → fallback visual grounding
|
||||
|
||||
Returns LoginResolution with grounded coords for each field.
|
||||
Runtime uses these coords to type/click.
|
||||
"""
|
||||
login_el = ground_element(
|
||||
screenshot_path, config.login_field,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
password_el = ground_element(
|
||||
screenshot_path, config.password_field,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
button_el = ground_element(
|
||||
screenshot_path, config.submit_button,
|
||||
ocr_client=ocr_client, vlm_client=vlm_client,
|
||||
screen_width=screen_width, screen_height=screen_height,
|
||||
coords_cache=coords_cache, context=config.context,
|
||||
)
|
||||
|
||||
all_resolved = login_el is not None and password_el is not None and button_el is not None
|
||||
|
||||
# Determine overall method
|
||||
methods = []
|
||||
if login_el:
|
||||
methods.append(login_el.method)
|
||||
if password_el:
|
||||
methods.append(password_el.method)
|
||||
if button_el:
|
||||
methods.append(button_el.method)
|
||||
|
||||
unique_methods = set(methods)
|
||||
if len(unique_methods) == 1:
|
||||
method = unique_methods.pop()
|
||||
elif len(unique_methods) > 1:
|
||||
method = "mixed"
|
||||
else:
|
||||
method = ""
|
||||
|
||||
resolution = LoginResolution(
|
||||
login_field=login_el,
|
||||
password_field=password_el,
|
||||
submit_button=button_el,
|
||||
all_resolved=all_resolved,
|
||||
method=method,
|
||||
)
|
||||
|
||||
if all_resolved:
|
||||
logger.info("resolve_login_form: %s", resolution.describe())
|
||||
else:
|
||||
logger.warning("resolve_login_form: incomplete — %s", resolution.describe())
|
||||
|
||||
return resolution
|
||||
408
core/navigation/visual_verifier.py
Normal file
408
core/navigation/visual_verifier.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""Visual verifier — verify_before / verify_after avec ancrage OCR.
|
||||
|
||||
Architecture OCR-ancrée (challenge Claude 01/07, gate-vert 30/06) :
|
||||
- PRESENCE = tokens OCR (déterministe, pas d'hallucination possible)
|
||||
- RÔLE = VLM confirmation (semantic, ancré sur tokens OCR trouvés)
|
||||
- VLM ne décide JAMAIS de la présence d'un élément
|
||||
- Faux positif impossible par construction ; faux négatif = retry acceptable
|
||||
|
||||
Pattern d'injection : OcrClient + VlmClient injectables (tests sans réseau).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type aliases — injectable callables for offline testing
|
||||
VlmClient = Callable[[str, str], str] # (image_path, prompt) -> text
|
||||
OcrClient = Callable[[str], List[str]] # (image_path) -> list of OCR text strings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenMatchResult:
|
||||
"""Result of a screen verification check."""
|
||||
|
||||
match: bool
|
||||
confidence: float = 0.0
|
||||
reason: str = ""
|
||||
observed_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
expected_elements: List[Dict[str, Any]] = field(default_factory=list)
|
||||
mismatches: List[str] = field(default_factory=list)
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.match:
|
||||
return f"Screen match OK (conf={self.confidence:.2f})"
|
||||
parts = [f"Screen mismatch (conf={self.confidence:.2f})"]
|
||||
if self.mismatches:
|
||||
parts.append("missing: " + ", ".join(self.mismatches))
|
||||
if self.reason:
|
||||
parts.append(self.reason)
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# ── Text normalization (pure functions) ────────────────────────────────
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for fuzzy matching: lowercase, strip accents, collapse whitespace."""
|
||||
text = text.lower().strip()
|
||||
# Strip accents: é→e, è→e, ê→e, à→a, etc.
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
# Collapse whitespace
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def fuzzy_match(expected: str, observed: str, threshold: float = 0.8) -> bool:
|
||||
"""Check if observed text fuzzy-matches expected text.
|
||||
|
||||
Three strategies (any wins):
|
||||
1. Exact match after normalization
|
||||
2. Substring containment (either direction)
|
||||
3. SequenceMatcher ratio >= threshold
|
||||
"""
|
||||
norm_expected = normalize_text(expected)
|
||||
norm_observed = normalize_text(observed)
|
||||
|
||||
if norm_expected == norm_observed:
|
||||
return True
|
||||
|
||||
if norm_expected in norm_observed or norm_observed in norm_expected:
|
||||
return True
|
||||
|
||||
ratio = SequenceMatcher(None, norm_expected, norm_observed).ratio()
|
||||
return ratio >= threshold
|
||||
|
||||
|
||||
# ── OCR presence check (deterministic, no VLM) ──────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrPresenceResult:
|
||||
"""Result of OCR-based presence check."""
|
||||
|
||||
found_texts: Dict[str, str] = field(default_factory=dict)
|
||||
missing: List[str] = field(default_factory=list)
|
||||
all_found: bool = False
|
||||
|
||||
@property
|
||||
def presence_ratio(self) -> float:
|
||||
if not self.found_texts:
|
||||
return 1.0
|
||||
found_count = sum(1 for v in self.found_texts.values() if v != "")
|
||||
return found_count / len(self.found_texts)
|
||||
|
||||
|
||||
def ocr_presence_check(
|
||||
ocr_tokens: List[str],
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
fuzzy_threshold: float = 0.8,
|
||||
) -> OcrPresenceResult:
|
||||
"""Check presence of expected texts against OCR tokens (deterministic).
|
||||
|
||||
Pure function — no VLM call, zero hallucination risk.
|
||||
"""
|
||||
found_texts: Dict[str, str] = {}
|
||||
missing: List[str] = []
|
||||
|
||||
for el in expected_elements:
|
||||
expected_text = el.get("text", "")
|
||||
if not expected_text:
|
||||
found_texts[""] = ""
|
||||
continue
|
||||
|
||||
matched_ocr = ""
|
||||
for token in ocr_tokens:
|
||||
if fuzzy_match(expected_text, token, threshold=fuzzy_threshold):
|
||||
matched_ocr = token
|
||||
break
|
||||
|
||||
if matched_ocr:
|
||||
found_texts[expected_text] = matched_ocr
|
||||
else:
|
||||
found_texts[expected_text] = ""
|
||||
missing.append(f"{el.get('role', '?')}: {expected_text}")
|
||||
|
||||
all_found = len(missing) == 0
|
||||
return OcrPresenceResult(
|
||||
found_texts=found_texts,
|
||||
missing=missing,
|
||||
all_found=all_found,
|
||||
)
|
||||
|
||||
|
||||
# ── VLM role confirmation (semantic, anchored on found OCR texts) ────
|
||||
|
||||
|
||||
def build_role_confirm_prompt(
|
||||
found_elements: List[Dict[str, Any]],
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""Build VLM prompt for role confirmation of OCR-found elements.
|
||||
|
||||
VLM receives found texts and confirms their ROLE only — never presence.
|
||||
"""
|
||||
found_lines = []
|
||||
for i, el in enumerate(found_elements):
|
||||
matched_ocr = el.get("matched_ocr", "")
|
||||
expected_role = el.get("expected_role", "?")
|
||||
line = f"{i+1}. Text \"{matched_ocr}\" — expected role: {expected_role}"
|
||||
found_lines.append(line)
|
||||
|
||||
found_block = "\n".join(found_lines)
|
||||
|
||||
prompt = (
|
||||
"You are a screen role validator. OCR has confirmed these texts are "
|
||||
"present on the screen. Your job is ONLY to confirm their ROLE — "
|
||||
"do NOT re-declare whether they are present.\n"
|
||||
)
|
||||
if context:
|
||||
prompt += f"Context: {context}\n"
|
||||
prompt += (
|
||||
f"Found texts with expected roles:\n{found_block}\n\n"
|
||||
"Respond in JSON format:\n"
|
||||
"{\"confirmed\": [{\"index\": 1, \"role_confirmed\": true/false, "
|
||||
"\"actual_role\": \"...\", \"confidence\": 0.0-1.0}], "
|
||||
"\"overall_confidence\": 0.0-1.0}\n"
|
||||
"Only confirm role_confirmed=true if the text clearly plays the "
|
||||
"expected role (e.g., a button, not just a label with the same text)."
|
||||
)
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_role_confirm_response(vlm_text: str) -> Dict[str, Any]:
|
||||
"""Parse VLM role confirmation JSON response."""
|
||||
try:
|
||||
data = json.loads(vlm_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r"\{[\s\S]*\}", vlm_text)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("role_confirm: VLM response not parseable as JSON")
|
||||
return {"confirmed": [], "overall_confidence": 0.0}
|
||||
else:
|
||||
return {"confirmed": [], "overall_confidence": 0.0}
|
||||
|
||||
confirmed = data.get("confirmed", [])
|
||||
overall_conf = data.get("overall_confidence", 0.0)
|
||||
if isinstance(overall_conf, str):
|
||||
try:
|
||||
overall_conf = float(overall_conf)
|
||||
except ValueError:
|
||||
overall_conf = 0.0
|
||||
|
||||
return {
|
||||
"confirmed": confirmed,
|
||||
"overall_confidence": float(overall_conf),
|
||||
}
|
||||
|
||||
|
||||
# ── Core verification (OCR-anchored composition) ────────────────────
|
||||
|
||||
|
||||
def verify_screen_match(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
min_confidence: float = 0.7,
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state with OCR-anchored presence + VLM role confirmation.
|
||||
|
||||
Step 1: OCR screenshot → tokens → deterministic presence check
|
||||
Step 2: VLM confirms role of found elements (not presence!)
|
||||
|
||||
Eliminates VLM self-report hallucination for presence checks.
|
||||
"""
|
||||
if not expected_elements:
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=1.0,
|
||||
reason="no expected elements to verify",
|
||||
)
|
||||
|
||||
# Step 1: OCR presence check (deterministic)
|
||||
try:
|
||||
ocr_tokens = ocr_client(screenshot_path)
|
||||
except Exception as e:
|
||||
logger.warning("verify_screen_match: OCR call failed (%s)", e)
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=0.0,
|
||||
reason=f"OCR error: {e}",
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
presence = ocr_presence_check(ocr_tokens, expected_elements)
|
||||
|
||||
if not presence.all_found:
|
||||
observed = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
matched = presence.found_texts.get(text, "")
|
||||
observed.append({
|
||||
"role": el.get("role", "?"),
|
||||
"expected_text": text,
|
||||
"matched_ocr": matched,
|
||||
"found": matched != "",
|
||||
})
|
||||
return ScreenMatchResult(
|
||||
match=False,
|
||||
confidence=presence.presence_ratio,
|
||||
reason="OCR presence check: some texts not found",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
mismatches=presence.missing,
|
||||
)
|
||||
|
||||
# Step 2: VLM role confirmation (only for found elements)
|
||||
found_elements = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
matched_ocr = presence.found_texts.get(text, "")
|
||||
if text and matched_ocr:
|
||||
found_elements.append({
|
||||
"text": text,
|
||||
"expected_role": el.get("role", "?"),
|
||||
"matched_ocr": matched_ocr,
|
||||
})
|
||||
|
||||
if not found_elements:
|
||||
# All elements had no text → presence trivially OK
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=1.0,
|
||||
reason="no text-based elements to verify",
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
prompt = build_role_confirm_prompt(found_elements, expected_elements, context)
|
||||
|
||||
try:
|
||||
vlm_text = vlm_client(screenshot_path, prompt)
|
||||
except Exception as e:
|
||||
logger.warning("verify_screen_match: VLM role confirm failed (%s)", e)
|
||||
observed = []
|
||||
for el in expected_elements:
|
||||
text = el.get("text", "")
|
||||
observed.append({
|
||||
"role": el.get("role", "?"),
|
||||
"expected_text": text,
|
||||
"matched_ocr": presence.found_texts.get(text, ""),
|
||||
"found": True,
|
||||
"role_confirmed": False,
|
||||
"role_confidence": 0.0,
|
||||
})
|
||||
return ScreenMatchResult(
|
||||
match=True,
|
||||
confidence=0.5,
|
||||
reason=f"OCR presence OK, VLM role confirm failed: {e}",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
)
|
||||
|
||||
parsed = parse_role_confirm_response(vlm_text)
|
||||
overall_conf = parsed.get("overall_confidence", 0.0)
|
||||
confirmed = parsed.get("confirmed", [])
|
||||
|
||||
observed = []
|
||||
role_mismatches = []
|
||||
for i, el in enumerate(expected_elements):
|
||||
text = el.get("text", "")
|
||||
expected_role = el.get("role", "?")
|
||||
matched_ocr = presence.found_texts.get(text, "")
|
||||
|
||||
role_entry = None
|
||||
for c in confirmed:
|
||||
if c.get("index") == i + 1:
|
||||
role_entry = c
|
||||
break
|
||||
|
||||
role_confirmed = False
|
||||
actual_role = ""
|
||||
role_confidence = 0.0
|
||||
|
||||
if role_entry:
|
||||
role_confirmed = role_entry.get("role_confirmed", False)
|
||||
actual_role = role_entry.get("actual_role", "")
|
||||
role_confidence = role_entry.get("confidence", 0.0)
|
||||
if isinstance(role_confidence, str):
|
||||
try:
|
||||
role_confidence = float(role_confidence)
|
||||
except ValueError:
|
||||
role_confidence = 0.0
|
||||
|
||||
observed.append({
|
||||
"role": expected_role,
|
||||
"expected_text": text,
|
||||
"matched_ocr": matched_ocr,
|
||||
"found": True,
|
||||
"role_confirmed": role_confirmed,
|
||||
"actual_role": actual_role,
|
||||
"role_confidence": role_confidence,
|
||||
})
|
||||
|
||||
if not role_confirmed or role_confidence < min_confidence:
|
||||
role_mismatches.append(
|
||||
f"{expected_role}: {text} (actual={actual_role}, conf={role_confidence:.2f})"
|
||||
)
|
||||
|
||||
is_match = len(role_mismatches) == 0 and overall_conf >= min_confidence
|
||||
|
||||
return ScreenMatchResult(
|
||||
match=is_match,
|
||||
confidence=overall_conf,
|
||||
reason=f"OCR presence: {presence.presence_ratio:.0%}, VLM role: {overall_conf:.2f}",
|
||||
observed_elements=observed,
|
||||
expected_elements=expected_elements,
|
||||
mismatches=presence.missing + role_mismatches,
|
||||
)
|
||||
|
||||
|
||||
def verify_before(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state BEFORE an action (OCR-anchored).
|
||||
|
||||
Checks pre-conditions: expected texts present + roles correct.
|
||||
min_confidence=0.7 — some tolerance for pre-action verification.
|
||||
"""
|
||||
return verify_screen_match(
|
||||
screenshot_path, expected_elements, ocr_client, vlm_client,
|
||||
context=f"PRE-ACTION: {context}", min_confidence=0.7,
|
||||
)
|
||||
|
||||
|
||||
def verify_after(
|
||||
screenshot_path: str,
|
||||
expected_elements: List[Dict[str, Any]],
|
||||
ocr_client: OcrClient,
|
||||
vlm_client: VlmClient,
|
||||
context: str = "",
|
||||
) -> ScreenMatchResult:
|
||||
"""Verify screen state AFTER an action (OCR-anchored).
|
||||
|
||||
Checks post-conditions with higher threshold (0.8).
|
||||
False positive = Léa proceeds on wrong assumption → stricter gate.
|
||||
"""
|
||||
return verify_screen_match(
|
||||
screenshot_path, expected_elements, ocr_client, vlm_client,
|
||||
context=f"POST-ACTION: {context}", min_confidence=0.8,
|
||||
)
|
||||
@@ -23,6 +23,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .workflow_ir import WorkflowIR, Step, Action, Variable
|
||||
from core.detection import vlm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,7 +42,10 @@ class IRBuilder:
|
||||
"""
|
||||
|
||||
def __init__(self, gemma4_port: str = ""):
|
||||
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", "11435")
|
||||
# Endpoint VLM : piloté par config (Ollama local ou tunnel DGX = 11434).
|
||||
# GEMMA4_PORT conservé comme override legacy (ancien conteneur Docker 11435).
|
||||
_default_port = vlm_config.DEFAULT_OLLAMA_ENDPOINT.rsplit(":", 1)[-1]
|
||||
self._gemma4_port = gemma4_port or os.environ.get("GEMMA4_PORT", _default_port)
|
||||
self._gemma4_url = f"http://localhost:{self._gemma4_port}/api/chat"
|
||||
|
||||
def build(
|
||||
@@ -563,7 +567,7 @@ class IRBuilder:
|
||||
resp = _requests.post(
|
||||
self._gemma4_url,
|
||||
json={
|
||||
"model": "gemma4:e4b",
|
||||
"model": vlm_config.get_vlm_model(),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"think": True,
|
||||
|
||||
@@ -20,6 +20,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from core.detection.vlm_config import get_reasoning_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Répertoires par défaut à scanner pour les workflows
|
||||
@@ -31,10 +33,72 @@ DEFAULT_WORKFLOW_DIRS = [
|
||||
|
||||
# Configuration Ollama par défaut
|
||||
DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
|
||||
DEFAULT_OLLAMA_MODEL = "qwen2.5:7b"
|
||||
DEFAULT_LLM_TIMEOUT = 10 # secondes
|
||||
|
||||
|
||||
def _default_ollama_model() -> str:
|
||||
return get_reasoning_model()
|
||||
|
||||
|
||||
DEFAULT_OLLAMA_MODEL = _default_ollama_model()
|
||||
|
||||
_WORKFLOW_TEXT_KEYS = {
|
||||
"action_type",
|
||||
"description",
|
||||
"expected_window_title",
|
||||
"label",
|
||||
"name",
|
||||
"required_texts",
|
||||
"required_window_title",
|
||||
"tags",
|
||||
"target_text",
|
||||
"text",
|
||||
"title_contains",
|
||||
"title_pattern",
|
||||
"type",
|
||||
"value",
|
||||
"vlm_description",
|
||||
"window_title",
|
||||
}
|
||||
|
||||
_WORKFLOW_TEXT_SKIP_KEYS = {
|
||||
"_prototype_vector",
|
||||
"bbox",
|
||||
"bounding_box",
|
||||
"embedding",
|
||||
"position",
|
||||
"position_x",
|
||||
"position_y",
|
||||
"vector",
|
||||
}
|
||||
|
||||
_TOKEN_SYNONYMS = {
|
||||
"blocnotes": ("bloc", "notes"),
|
||||
"blocnote": ("bloc", "notes"),
|
||||
"notepad": ("bloc", "notes"),
|
||||
"sauvegarde": ("enregistrer",),
|
||||
"sauvegarder": ("enregistrer",),
|
||||
"sauvegardes": ("enregistrer",),
|
||||
"save": ("enregistrer",),
|
||||
"saved": ("enregistrer",),
|
||||
"saving": ("enregistrer",),
|
||||
"enregistre": ("enregistrer",),
|
||||
"enregistres": ("enregistrer",),
|
||||
"enregistrez": ("enregistrer",),
|
||||
}
|
||||
|
||||
_IMPORTANT_ACTION_TOKENS = {
|
||||
"annuler",
|
||||
"dialogue",
|
||||
"ecraser",
|
||||
"enregistrer",
|
||||
"fichier",
|
||||
"ouvrir",
|
||||
"popup",
|
||||
"remplacer",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowMatch:
|
||||
"""Résultat d'un matching de workflow."""
|
||||
@@ -88,7 +152,7 @@ class SemanticMatcher:
|
||||
workflows_dir: Union[str, List[str], None] = None,
|
||||
use_embeddings: bool = True,
|
||||
use_llm: bool = True,
|
||||
llm_model: str = DEFAULT_OLLAMA_MODEL,
|
||||
llm_model: Optional[str] = None,
|
||||
llm_endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
|
||||
llm_timeout: int = DEFAULT_LLM_TIMEOUT,
|
||||
auto_reload_interval: int = 60,
|
||||
@@ -101,7 +165,7 @@ class SemanticMatcher:
|
||||
Peut être un str (un seul répertoire) ou une liste.
|
||||
use_embeddings: Utiliser les embeddings pour le matching (compatibilité)
|
||||
use_llm: Activer le matching sémantique via Ollama LLM
|
||||
llm_model: Modèle Ollama à utiliser (défaut: qwen2.5:7b)
|
||||
llm_model: Modèle Ollama à utiliser (défaut: modèle reasoning central)
|
||||
llm_endpoint: Endpoint Ollama (défaut: http://localhost:11434)
|
||||
llm_timeout: Timeout pour les appels LLM en secondes
|
||||
auto_reload_interval: Intervalle en secondes pour vérifier les nouveaux workflows (0 = désactivé)
|
||||
@@ -121,7 +185,7 @@ class SemanticMatcher:
|
||||
|
||||
self.use_embeddings = use_embeddings
|
||||
self.use_llm = use_llm
|
||||
self.llm_model = llm_model
|
||||
self.llm_model = llm_model or _default_ollama_model()
|
||||
self.llm_endpoint = llm_endpoint
|
||||
self.llm_timeout = llm_timeout
|
||||
|
||||
@@ -181,7 +245,11 @@ class SemanticMatcher:
|
||||
Nombre de workflows chargés
|
||||
"""
|
||||
count = 0
|
||||
for workflow_path in workflows_dir.glob("*.json"):
|
||||
workflow_paths = sorted(
|
||||
workflows_dir.rglob("*.json"),
|
||||
key=lambda p: (len(p.relative_to(workflows_dir).parts), str(p)),
|
||||
)
|
||||
for workflow_path in workflow_paths:
|
||||
try:
|
||||
with open(workflow_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
@@ -298,8 +366,46 @@ class SemanticMatcher:
|
||||
action_type = action.get("type", "")
|
||||
keywords.add(action_type)
|
||||
|
||||
# Workflows appris: les signaux utiles vivent souvent dans les nodes,
|
||||
# conditions et templates, pas seulement dans les tags/edges.
|
||||
for value in self._iter_workflow_text_values(workflow_data):
|
||||
keywords.update(self._tokenize(value))
|
||||
|
||||
return list(keywords)
|
||||
|
||||
def _iter_workflow_text_values(
|
||||
self,
|
||||
value: Any,
|
||||
parent_key: str = "",
|
||||
) -> List[str]:
|
||||
"""Extraire les textes courts utiles au matching depuis un workflow.
|
||||
|
||||
On évite les champs volumineux ou numériques (embeddings, bbox), mais on
|
||||
garde les titres de fenêtres, labels, valeurs et descriptions d'actions.
|
||||
"""
|
||||
texts: List[str] = []
|
||||
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
key_lower = str(key).lower()
|
||||
if key_lower in _WORKFLOW_TEXT_SKIP_KEYS:
|
||||
continue
|
||||
if key_lower in _WORKFLOW_TEXT_KEYS and isinstance(child, str):
|
||||
texts.append(child)
|
||||
elif isinstance(child, (dict, list)):
|
||||
texts.extend(self._iter_workflow_text_values(child, key_lower))
|
||||
return texts
|
||||
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
if parent_key in _WORKFLOW_TEXT_KEYS:
|
||||
texts.append(item)
|
||||
elif isinstance(item, (dict, list)):
|
||||
texts.extend(self._iter_workflow_text_values(item, parent_key))
|
||||
|
||||
return texts
|
||||
|
||||
def _tokenize(self, text: str) -> List[str]:
|
||||
"""Tokeniser un texte en mots-clés."""
|
||||
# Normaliser
|
||||
@@ -319,7 +425,17 @@ class SemanticMatcher:
|
||||
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been'
|
||||
}
|
||||
|
||||
return [w for w in words if len(w) > 2 and w not in stop_words]
|
||||
tokens: List[str] = []
|
||||
for word in words:
|
||||
if len(word) <= 2 or word in stop_words:
|
||||
continue
|
||||
replacement = _TOKEN_SYNONYMS.get(word)
|
||||
if replacement:
|
||||
tokens.extend(replacement)
|
||||
else:
|
||||
tokens.append(word)
|
||||
|
||||
return tokens
|
||||
|
||||
# =========================================================================
|
||||
# Matching LLM (Ollama)
|
||||
@@ -654,6 +770,11 @@ Réponds UNIQUEMENT au format JSON, sans texte avant ni après:
|
||||
if intersection:
|
||||
reasons.append(f"keywords:{','.join(intersection)}")
|
||||
|
||||
important = intersection & _IMPORTANT_ACTION_TOKENS
|
||||
if important:
|
||||
score += 0.2
|
||||
reasons.append(f"action_tokens:{','.join(sorted(important))}")
|
||||
|
||||
# 4. Matching de la description
|
||||
if metadata.description:
|
||||
desc_tokens = set(self._tokenize(metadata.description))
|
||||
|
||||
253
deploy/build_package_full.sh
Executable file
253
deploy/build_package_full.sh
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/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"
|
||||
"Lea/python-embed/Lib/site-packages/httpx"
|
||||
"Lea/python-embed/Lib/site-packages/httpcore"
|
||||
"Lea/python-embed/Lib/site-packages/h11"
|
||||
"Lea/python-embed/Lib/site-packages/anyio"
|
||||
"Lea/python-embed/Lib/site-packages/typing_extensions.py"
|
||||
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 ""
|
||||
60
deploy/dgx/vm_launch.sh
Executable file
60
deploy/dgx/vm_launch.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Persistent VM launch — starts swtpm first, waits for socket, then QEMU
|
||||
# VNC only, no SPICE (POC configuration)
|
||||
VMROOT=/home/aivanov/quickemu-win11-arm-lea
|
||||
SWTPM_SOCK="$VMROOT/windows-11-arm-lea.swtpm-sock"
|
||||
|
||||
/usr/bin/swtpm socket \
|
||||
--ctrl type=unixio,path="$SWTPM_SOCK" \
|
||||
--terminate \
|
||||
--tpmstate dir="$VMROOT" \
|
||||
--tpm2 &
|
||||
|
||||
# Wait for swtpm socket (up to 10s)
|
||||
for _i in $(seq 1 100); do
|
||||
if [ -S "$SWTPM_SOCK" ]; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ ! -S "$SWTPM_SOCK" ]; then
|
||||
echo "ERROR: swtpm socket not ready after 10s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec /usr/bin/qemu-system-aarch64 \
|
||||
-name windows-11-arm-lea \
|
||||
-machine virt,highmem=on,pflash0=rom,pflash1=efivars,accel=kvm \
|
||||
-global kvm-pit.lost_tick_policy=discard \
|
||||
-cpu host \
|
||||
-smp cores=8,threads=1,sockets=1 \
|
||||
-m 8G \
|
||||
-device virtio-balloon \
|
||||
-pidfile "$VMROOT/windows-11-arm-lea.pid" \
|
||||
-rtc base=utc,clock=host \
|
||||
-device ramfb \
|
||||
-vga none \
|
||||
-device virtio-gpu-pci,id=video0,xres=1280,yres=800 \
|
||||
-display none \
|
||||
-vnc 127.0.0.1:2,password=on \
|
||||
-device virtio-serial-pci \
|
||||
-chardev socket,id=agent0,path="$VMROOT/windows-11-arm-lea-agent.sock",server=on,wait=off \
|
||||
-device virtserialport,chardev=agent0,name=org.qemu.guest_agent.0 \
|
||||
-device virtio-rng-pci,rng=rng0 \
|
||||
-object rng-random,id=rng0,filename=/dev/urandom \
|
||||
-device qemu-xhci,id=input \
|
||||
-device usb-kbd,bus=input.0 \
|
||||
-k fr \
|
||||
-device usb-tablet,bus=input.0 \
|
||||
-device virtio-net-pci,netdev=nic \
|
||||
-netdev user,hostname=windows-11-arm-lea,hostfwd=tcp::22220-:22,id=nic \
|
||||
-blockdev node-name=rom,driver=file,filename=/usr/share/AAVMF/AAVMF_CODE.no-secboot.fd,read-only=true \
|
||||
-blockdev node-name=efivars,driver=file,filename="$VMROOT/OVMF_VARS.fd" \
|
||||
-device virtio-scsi-pci,id=scsi0 \
|
||||
-device scsi-hd,drive=SystemDisk,bus=scsi0.0,bootindex=2 \
|
||||
-drive id=SystemDisk,if=none,format=qcow2,file="$VMROOT/disk.qcow2",discard=unmap,detect-zeroes=unmap,cache=writeback,aio=threads \
|
||||
-chardev socket,id=chrtpm,path="$SWTPM_SOCK" \
|
||||
-tpmdev emulator,id=tpm0,chardev=chrtpm \
|
||||
-device tpm-tis-device,tpmdev=tpm0 \
|
||||
-monitor unix:"$VMROOT/windows-11-arm-lea-monitor.socket",server,nowait \
|
||||
-serial unix:"$VMROOT/windows-11-arm-lea-serial.socket",server,nowait \
|
||||
2>"$VMROOT/qemu.log"
|
||||
50
deploy/dgx/vm_stop.sh
Executable file
50
deploy/dgx/vm_stop.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Persistent VM stop — ACPI poweroff via QEMU monitor, then SIGTERM, then SIGKILL
|
||||
# Kill swtpm after QEMU exits. Cleanup PID/sockets.
|
||||
|
||||
VMROOT=/home/aivanov/quickemu-win11-arm-lea
|
||||
MONITOR_SOCKET="$VMROOT/windows-11-arm-lea-monitor.socket"
|
||||
PIDFILE="$VMROOT/windows-11-arm-lea.pid"
|
||||
|
||||
# Step 1: Send ACPI poweroff via QEMU monitor
|
||||
if [ -S "$MONITOR_SOCKET" ]; then
|
||||
echo "system_powerdown" | socat - UNIX-CONNECT:"$MONITOR_SOCKET" - > /dev/null 2>&1
|
||||
echo "ACPI poweroff sent, waiting 30s..."
|
||||
for i in $(seq 1 30); do
|
||||
if [ ! -f "$PIDFILE" ] || ! ps -p "$(cat "$PIDFILE" 2>/dev/null)" > /dev/null 2>&1; then
|
||||
echo "QEMU exited gracefully"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Step 2: SIGTERM (10s more)
|
||||
QEMU_PID=$(cat "$PIDFILE" 2>/dev/null)
|
||||
if [ -n "$QEMU_PID" ] && ps -p "$QEMU_PID" > /dev/null 2>&1; then
|
||||
echo "Still running, sending SIGTERM..."
|
||||
kill "$QEMU_PID" 2>/dev/null
|
||||
for i in $(seq 1 10); do
|
||||
if ! ps -p "$QEMU_PID" > /dev/null 2>&1; then
|
||||
echo "QEMU exited after SIGTERM"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Step 3: SIGKILL
|
||||
QEMU_PID=$(cat "$PIDFILE" 2>/dev/null)
|
||||
if [ -n "$QEMU_PID" ] && ps -p "$QEMU_PID" > /dev/null 2>&1; then
|
||||
echo "Still running, SIGKILL..."
|
||||
kill -9 "$QEMU_PID" 2>/dev/null
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Step 4: Kill swtpm
|
||||
pkill -f "swtpm.*windows-11-arm-lea" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
# Step 5: Cleanup
|
||||
rm -f "$PIDFILE" "$VMROOT"/*.sock "$VMROOT"/*.socket 2>/dev/null
|
||||
echo "VM stop complete"
|
||||
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.2"
|
||||
#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,13 +175,14 @@ 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;
|
||||
TokenPage: TInputQueryWizardPage;
|
||||
MachineIdValue: string;
|
||||
ConfigFilePath: string;
|
||||
ExistingMachineId: string;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Helper : ajoute des guillemets autour d'une chaine
|
||||
@@ -243,9 +243,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();
|
||||
@@ -263,6 +268,72 @@ end;
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadConfigFromCommandLine(); forward;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — trouve le dossier d'une install Lea existante (config.txt present)
|
||||
// --------------------------------------------------------------------
|
||||
function FindExistingInstallDir(): string;
|
||||
var
|
||||
Candidates: array[0..1] of string;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
Candidates[0] := ExpandConstant('{localappdata}\Programs\Lea');
|
||||
Candidates[1] := ExpandConstant('{autopf}\Lea');
|
||||
for I := 0 to 1 do
|
||||
begin
|
||||
if FileExists(Candidates[I] + '\config.txt') then
|
||||
begin
|
||||
Result := Candidates[I];
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — lit le config.txt existant : pre-remplit le wizard avec la
|
||||
// VRAIE conf du poste (serveur/token/user) et MEMORISE le machine_id pour
|
||||
// le PRESERVER (ne pas regenerer une nouvelle identite fleet).
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadExistingConfig();
|
||||
var
|
||||
Dir, ConfPath: string;
|
||||
Lines: TArrayOfString;
|
||||
I, EqPos: Integer;
|
||||
Line, Key, Value: string;
|
||||
begin
|
||||
ExistingMachineId := '';
|
||||
Dir := FindExistingInstallDir();
|
||||
if Dir = '' then Exit; // install neuve -> comportement par defaut
|
||||
|
||||
ConfPath := Dir + '\config.txt';
|
||||
if LoadStringsFromFile(ConfPath, Lines) then
|
||||
begin
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
Line := Trim(Lines[I]);
|
||||
if (Length(Line) = 0) or (Line[1] = '#') then Continue;
|
||||
EqPos := Pos('=', Line);
|
||||
if EqPos = 0 then Continue;
|
||||
Key := Trim(Copy(Line, 1, EqPos - 1));
|
||||
Value := Trim(Copy(Line, EqPos + 1, Length(Line)));
|
||||
|
||||
if Key = 'RPA_SERVER_URL' then TokenPage.Values[0] := Value
|
||||
else if Key = 'RPA_API_TOKEN' then TokenPage.Values[1] := Value
|
||||
else if Key = 'RPA_USER_NAME' then EnrollmentPage.Values[0] := Value
|
||||
else if Key = 'RPA_USER_EMAIL' then EnrollmentPage.Values[1] := Value
|
||||
else if Key = 'RPA_USER_ID' then EnrollmentPage.Values[2] := Value
|
||||
else if Key = 'RPA_MACHINE_ID' then ExistingMachineId := Value;
|
||||
end;
|
||||
end;
|
||||
|
||||
// Fallback : machine_id.txt si absent du config.txt
|
||||
if (ExistingMachineId = '') and FileExists(Dir + '\machine_id.txt') then
|
||||
begin
|
||||
if LoadStringsFromFile(Dir + '\machine_id.txt', Lines) and (GetArrayLength(Lines) > 0) then
|
||||
ExistingMachineId := Trim(Lines[0]);
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Initialisation : cree les pages custom d'enrollment
|
||||
// --------------------------------------------------------------------
|
||||
@@ -297,7 +368,11 @@ begin
|
||||
TokenPage.Values[0] := SERVER_URL_DEFAULT;
|
||||
TokenPage.Values[1] := DEFAULT_TOKEN;
|
||||
|
||||
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir
|
||||
// UPGRADE : si une install existe, pre-remplir avec SA config (pas les
|
||||
// defauts) et memoriser son machine_id pour le preserver.
|
||||
LoadExistingConfig();
|
||||
|
||||
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir (prioritaire)
|
||||
LoadConfigFromCommandLine();
|
||||
end;
|
||||
|
||||
@@ -404,8 +479,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);
|
||||
@@ -504,6 +579,54 @@ begin
|
||||
DeleteFile(PsFile);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// UPGRADE — AVANT la copie des fichiers : tuer une Lea en cours (via le
|
||||
// PID du lock) pour liberer les DLL de python-embed. Evite une install
|
||||
// partielle / "reboot required". Ne tue QUE le PID du lock (jamais tous
|
||||
// les pythonw du poste).
|
||||
// --------------------------------------------------------------------
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
var
|
||||
AppDir, LockPath, BackupDir, SessionsDir: string;
|
||||
Lines: TArrayOfString;
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
AppDir := ExpandConstant('{app}');
|
||||
|
||||
// 1) Tuer une Lea en cours (via le PID du lock) pour liberer les DLL
|
||||
// python-embed. Ne tue QUE ce PID, jamais tous les pythonw du poste.
|
||||
LockPath := AppDir + '\lea_agent.lock';
|
||||
if FileExists(LockPath) then
|
||||
begin
|
||||
if LoadStringsFromFile(LockPath, Lines) and (GetArrayLength(Lines) > 0) then
|
||||
Exec('taskkill.exe', '/F /PID ' + Trim(Lines[0]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
DeleteFile(LockPath);
|
||||
Sleep(1500);
|
||||
end;
|
||||
|
||||
// UPGRADE uniquement (install existante detectee via config.txt).
|
||||
if FileExists(AppDir + '\config.txt') then
|
||||
begin
|
||||
// 2) BACKUP (rollback) : copie code+config vers <app>_backup, HORS
|
||||
// python-embed / sessions / logs (leger, rapide). Filet si la nouvelle
|
||||
// version deconne : Julien restaure ce dossier.
|
||||
BackupDir := AppDir + '_backup';
|
||||
Exec(ExpandConstant('{cmd}'),
|
||||
'/c rmdir /s /q "' + BackupDir + '" 2>nul & robocopy "' + AppDir + '" "' + BackupDir +
|
||||
'" /E /XD python-embed sessions logs __pycache__ /XF *.pyc /R:1 /W:1 /NFL /NDL /NJH /NJS /NP >nul 2>&1',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
|
||||
// 3) PURGE des captures accumulees (donnees d'apprentissage internes, non
|
||||
// exploitables cote clinique) : libere le disque. Le fix capture JPEG
|
||||
// evite que la saturation reprenne. Les logs (compliance 180j) restent.
|
||||
SessionsDir := AppDir + '\agent_v1\sessions';
|
||||
if DirExists(SessionsDir) then
|
||||
Exec(ExpandConstant('{cmd}'),
|
||||
'/c rmdir /s /q "' + SessionsDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Hook : actions apres copie des fichiers (ssPostInstall)
|
||||
// --------------------------------------------------------------------
|
||||
@@ -511,8 +634,11 @@ procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
// Genere le machine_id AVANT la copie des fichiers
|
||||
MachineIdValue := GenerateMachineId();
|
||||
// UPGRADE : preserver l'identite existante ; sinon en generer une neuve.
|
||||
if ExistingMachineId <> '' then
|
||||
MachineIdValue := ExistingMachineId
|
||||
else
|
||||
MachineIdValue := GenerateMachineId();
|
||||
end;
|
||||
|
||||
if CurStep = ssPostInstall then
|
||||
|
||||
@@ -81,16 +81,29 @@ cd deploy/installer
|
||||
wget https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip
|
||||
mkdir python-3.12-embed
|
||||
unzip python-3.12.8-embed-amd64.zip -d python-3.12-embed/
|
||||
|
||||
# IMPORTANT : l'embed doit contenir TOUTES les dependances HORS LIGNE.
|
||||
# Le runtime client ne fait AUCUN pip/reseau (POC clinique). On installe donc
|
||||
# les dependances une fois dans l'embed, puis on le commit/reutilise tel quel :
|
||||
python312._pth # decommenter 'import site'
|
||||
python -m pip install --target python-3.12-embed/Lib/site-packages \
|
||||
-r ../lea_package/requirements_agent.txt
|
||||
# => doit inclure httpx (+ httpcore, h11) pour l'orchestrateur Lea (POST /api/learn/start).
|
||||
```
|
||||
|
||||
Le staging copie automatiquement ce dossier si present. Le composant
|
||||
"pythonembed" devient alors selectionnable dans l'installeur.
|
||||
|
||||
Le script `configure_embed.ps1` :
|
||||
Le script `configure_embed.ps1` (execute a l'installation, sur le poste) :
|
||||
1. Patche `python312._pth` pour activer `import site`
|
||||
2. Installe `pip` via `get-pip.py`
|
||||
3. Installe `requirements_agent.txt`
|
||||
4. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
|
||||
2. VERIFIE que les dependances sont deja embarquees (offline, aucun pip/reseau) —
|
||||
`socketio, tkinter, mss, pynput, pystray, plyer, requests, httpx, PIL, win32api` ;
|
||||
si une dependance manque, l'installation echoue explicitement.
|
||||
3. Reecrit `Lea.bat` pour pointer sur `python-embed\pythonw.exe`
|
||||
|
||||
> Note : `build_installer.sh` et `build_package_full.sh` valident aussi la presence
|
||||
> des paquets (dont `httpx`, `httpcore`, `h11`) dans `Lib/site-packages/` avant de
|
||||
> produire le paquet — un embed incomplet interrompt le build cote Linux.
|
||||
|
||||
## Installation silencieuse (deploiement de masse)
|
||||
|
||||
|
||||
@@ -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,40 @@ 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"
|
||||
"Lib/site-packages/httpx" "Lib/site-packages/httpcore" "Lib/site-packages/h11"
|
||||
"Lib/site-packages/anyio" "Lib/site-packages/typing_extensions.py"
|
||||
)
|
||||
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 ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -25,3 +25,5 @@ USER_ID=
|
||||
# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
|
||||
SERVER_URL=CONFIGURE_ME
|
||||
API_TOKEN=CONFIGURE_ME
|
||||
|
||||
AGENT_VERSION=1.0.2
|
||||
|
||||
@@ -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','httpx','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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -77,6 +76,29 @@ if exist "lea_agent.lock" (
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
:: MAJ SILENCIEUSE — swap atomique + rollback (renames uniquement)
|
||||
if exist "PENDING_BOOT" (
|
||||
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
|
||||
if exist "agent_v1_prev" (
|
||||
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
|
||||
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
|
||||
move "agent_v1_prev" "agent_v1" >nul 2>&1
|
||||
)
|
||||
del /f /q "PENDING_BOOT" >nul 2>&1
|
||||
) else if exist "UPDATE_READY" (
|
||||
if exist "agent_v1_new" (
|
||||
echo [MAJ] Application de la mise a jour...
|
||||
if exist "agent_v1" (
|
||||
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
|
||||
move "agent_v1" "agent_v1_prev" >nul 2>&1
|
||||
)
|
||||
move "agent_v1_new" "agent_v1" >nul 2>&1
|
||||
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
|
||||
) else (
|
||||
del /f /q "UPDATE_READY" >nul 2>&1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "config.txt" (
|
||||
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
|
||||
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"
|
||||
|
||||
@@ -20,6 +20,35 @@ if exist "lea_agent.lock" (
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: MAJ SILENCIEUSE — swap atomique + rollback (hors-process)
|
||||
:: L'ancienne instance est fermee ci-dessus : agent_v1\ est libre.
|
||||
:: Renames uniquement (quasi-atomiques), jamais d'ecrasement fichier par fichier.
|
||||
:: ---------------------------------------------------------------
|
||||
if exist "PENDING_BOOT" (
|
||||
:: Le boot precedent n'a JAMAIS confirme (crash) -> ROLLBACK version precedente
|
||||
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
|
||||
if exist "agent_v1_prev" (
|
||||
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
|
||||
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
|
||||
move "agent_v1_prev" "agent_v1" >nul 2>&1
|
||||
)
|
||||
del /f /q "PENDING_BOOT" >nul 2>&1
|
||||
) else if exist "UPDATE_READY" (
|
||||
:: Une MAJ est armee (agent_v1_new pret) -> SWAP
|
||||
if exist "agent_v1_new" (
|
||||
echo [MAJ] Application de la mise a jour...
|
||||
if exist "agent_v1" (
|
||||
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
|
||||
move "agent_v1" "agent_v1_prev" >nul 2>&1
|
||||
)
|
||||
move "agent_v1_new" "agent_v1" >nul 2>&1
|
||||
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
|
||||
) else (
|
||||
del /f /q "UPDATE_READY" >nul 2>&1
|
||||
)
|
||||
)
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: Verifier que l'installation a ete faite
|
||||
:: ---------------------------------------------------------------
|
||||
|
||||
@@ -36,5 +36,15 @@ RPA_MACHINE_ID=CONFIGURE_ME
|
||||
RPA_USER_LABEL=CONFIGURE_ME
|
||||
|
||||
# --- Parametres avances (ne pas modifier sauf indication) ---
|
||||
RPA_AGENT_VERSION=1.0.2
|
||||
RPA_BLUR_SENSITIVE=false
|
||||
RPA_LOG_RETENTION_DAYS=180
|
||||
|
||||
# --- MAJ silencieuse (DETTE-022 v2) — DESACTIVEE par defaut ---
|
||||
# Deploiement CANARY : on active d'ABORD ce flag sur le SEUL poste pilote
|
||||
# (Emilie), on verifie, puis on elargit. Le poste interroge le serveur et
|
||||
# telecharge la MAJ en staging ; le remplacement reel des fichiers reste manuel
|
||||
# / supervise (reserve revision humaine). Decommenter pour activer ce poste :
|
||||
# RPA_AUTO_UPDATE_ENABLED=true
|
||||
# Intervalle d'interrogation serveur en secondes (defaut 3600 = 1h) :
|
||||
# RPA_AUTO_UPDATE_INTERVAL_S=3600
|
||||
|
||||
@@ -5,9 +5,17 @@ mss>=9.0.1 # Capture d'ecran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris
|
||||
Pillow>=10.0.0 # Traitement image (crops, compression)
|
||||
requests>=2.31.0 # Communication serveur
|
||||
httpx>=0.27 # Client HTTP orchestrateur Lea (POST /api/learn/start) - brique conversationnelle
|
||||
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).
|
||||
|
||||
44
deploy/windows-rdp-launcher/Connexion-VM-Lea.cmd
Normal file
44
deploy/windows-rdp-launcher/Connexion-VM-Lea.cmd
Normal file
@@ -0,0 +1,44 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title Connexion VM Lea (via DGX)
|
||||
|
||||
REM ============================================================
|
||||
REM Connexion Bureau a distance a la VM Windows (Lea) du DGX.
|
||||
REM Ouvre un tunnel SSH, lance le RDP (presse-papier actif),
|
||||
REM puis referme le tunnel quand la session RDP est fermee.
|
||||
REM ============================================================
|
||||
|
||||
REM --- Parametres (ajuste si besoin) ---
|
||||
set "DGX_USER=aivanov"
|
||||
set "DGX_HOST=192.168.1.45"
|
||||
REM En deplacement (WireGuard, plus tard) : mettre DGX_HOST=10.10.0.1
|
||||
set "LOCAL_PORT=13389"
|
||||
set "RDP_FILE=%~dp0VM-Lea.rdp"
|
||||
|
||||
echo.
|
||||
echo [1/3] Ouverture du tunnel SSH vers %DGX_USER%@%DGX_HOST% ...
|
||||
echo (si un mot de passe est demande, saisis-le dans la fenetre "Tunnel")
|
||||
start "Tunnel-DGX-VMLea" ssh -o StrictHostKeyChecking=accept-new -o ExitOnForwardFailure=yes -N -L %LOCAL_PORT%:127.0.0.1:3390 %DGX_USER%@%DGX_HOST%
|
||||
|
||||
echo [2/3] Attente de l'etablissement du tunnel (max ~30s)...
|
||||
set /a tries=0
|
||||
:wait
|
||||
timeout /t 1 /nobreak >nul
|
||||
powershell -NoProfile -Command "try{(New-Object Net.Sockets.TcpClient).Connect('127.0.0.1',%LOCAL_PORT%);exit 0}catch{exit 1}" >nul 2>&1
|
||||
if not errorlevel 1 goto ready
|
||||
set /a tries+=1
|
||||
if %tries% lss 30 goto wait
|
||||
echo ! Tunnel non etabli. Verifie l'acces SSH au DGX (mot de passe / reseau).
|
||||
pause
|
||||
goto cleanup
|
||||
|
||||
:ready
|
||||
echo [3/3] Connexion Bureau a distance (localhost:%LOCAL_PORT%) ...
|
||||
mstsc "%RDP_FILE%"
|
||||
|
||||
:cleanup
|
||||
echo.
|
||||
echo Fermeture du tunnel SSH...
|
||||
taskkill /FI "WINDOWTITLE eq Tunnel-DGX-VMLea*" /T /F >nul 2>&1
|
||||
echo Termine.
|
||||
timeout /t 2 /nobreak >nul
|
||||
35
deploy/windows-rdp-launcher/LISEZMOI.txt
Normal file
35
deploy/windows-rdp-launcher/LISEZMOI.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
CONNEXION BUREAU A DISTANCE - VM Lea (DGX)
|
||||
==========================================
|
||||
|
||||
CONTENU
|
||||
- Connexion-VM-Lea.cmd : le lanceur (double-clic)
|
||||
- VM-Lea.rdp : le profil de connexion RDP (presse-papier active)
|
||||
|
||||
INSTALLATION (sur ton laptop Windows)
|
||||
1. Copie les DEUX fichiers dans le MEME dossier (ex: le Bureau).
|
||||
2. (Optionnel) clic droit sur Connexion-VM-Lea.cmd > Envoyer vers > Bureau
|
||||
(creer un raccourci), pour un acces rapide.
|
||||
|
||||
UTILISATION
|
||||
- Double-clic sur "Connexion-VM-Lea.cmd".
|
||||
- Une fenetre "Tunnel" s'ouvre : si un mot de passe SSH est demande,
|
||||
saisis le mot de passe du compte aivanov du DGX.
|
||||
- Le Bureau a distance s'ouvre ensuite : saisis ton identifiant + mot de
|
||||
passe WINDOWS de la VM.
|
||||
- Copier-coller (texte ET fichiers) fonctionne dans les deux sens.
|
||||
- Ferme la fenetre RDP pour finir : le tunnel se referme automatiquement.
|
||||
|
||||
PRE-REQUIS
|
||||
- Etre sur le reseau du labo (meme WiFi) pour joindre 192.168.1.45.
|
||||
- OpenSSH client (inclus dans Windows 10/11).
|
||||
- Le Bureau a distance doit etre active dans la VM (deja fait).
|
||||
|
||||
EN DEPLACEMENT (plus tard)
|
||||
- Quand WireGuard sera en place, edite Connexion-VM-Lea.cmd et remplace
|
||||
DGX_HOST=192.168.1.45 par DGX_HOST=10.10.0.1
|
||||
- Tout le reste est identique. L'adresse RDP reste localhost:13389.
|
||||
|
||||
CONFORT (optionnel, recommande)
|
||||
- Pour ne plus saisir le mot de passe SSH a chaque fois : on signe la cle
|
||||
SSH de ton laptop avec la CA (acces par certificat). Demande-le moi et
|
||||
envoie-moi la cle publique de ton laptop.
|
||||
18
deploy/windows-rdp-launcher/VM-Lea.rdp
Normal file
18
deploy/windows-rdp-launcher/VM-Lea.rdp
Normal file
@@ -0,0 +1,18 @@
|
||||
full address:s:localhost:13389
|
||||
prompt for credentials:i:1
|
||||
redirectclipboard:i:1
|
||||
redirectdrives:i:1
|
||||
drivestoredirect:s:*
|
||||
redirectprinters:i:0
|
||||
redirectsmartcards:i:0
|
||||
audiomode:i:2
|
||||
authentication level:i:0
|
||||
negotiate security layer:i:1
|
||||
enablecredsspsupport:i:1
|
||||
screen mode id:i:2
|
||||
dynamic resolution:i:1
|
||||
desktopwidth:i:1280
|
||||
desktopheight:i:800
|
||||
session bpp:i:32
|
||||
compression:i:1
|
||||
username:s:
|
||||
32
deploy/windows-rdp-launcher/connexion-vm-lea.sh
Executable file
32
deploy/windows-rdp-launcher/connexion-vm-lea.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# RDP vers la VM Windows (Lea) du DGX, depuis ce serveur Linux (.40).
|
||||
# Ouvre un tunnel SSH (par certificat) puis lance xfreerdp.
|
||||
# Presse-papier + dossier $HOME partage. Tunnel ferme a la sortie.
|
||||
#
|
||||
# Usage:
|
||||
# ./connexion-vm-lea.sh # labo (DGX = 192.168.1.45)
|
||||
# ./connexion-vm-lea.sh 10.10.0.1 # en deplacement (via WireGuard)
|
||||
# ./connexion-vm-lea.sh 192.168.1.45 /u:MonUserWindows
|
||||
set -euo pipefail
|
||||
|
||||
DGX_HOST="${1:-192.168.1.45}"
|
||||
[ $# -gt 0 ] && shift || true
|
||||
LOCAL_PORT=13389
|
||||
CTL="$(mktemp -u /tmp/rdp-vmlea-ctl.XXXXXX)"
|
||||
|
||||
cleanup(){ ssh -S "$CTL" -O exit "aivanov@${DGX_HOST}" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "[1/3] Tunnel SSH (cert) vers aivanov@${DGX_HOST} ..."
|
||||
ssh -o ExitOnForwardFailure=yes -fN -M -S "$CTL" -L "${LOCAL_PORT}:127.0.0.1:3390" "aivanov@${DGX_HOST}"
|
||||
|
||||
echo "[2/3] Attente du tunnel ..."
|
||||
for _i in $(seq 1 40); do
|
||||
ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${LOCAL_PORT} " && break
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
echo "[3/3] Connexion RDP (localhost:${LOCAL_PORT}) — presse-papier + dossier $HOME ..."
|
||||
xfreerdp /v:localhost:${LOCAL_PORT} /cert:ignore /clipboard /dynamic-resolution /drive:home,"$HOME" "$@" || true
|
||||
|
||||
echo "Session RDP terminee, fermeture du tunnel."
|
||||
59
deploy/windows-rdp-launcher/unblock_nomachine.ps1
Normal file
59
deploy/windows-rdp-launcher/unblock_nomachine.ps1
Normal file
@@ -0,0 +1,59 @@
|
||||
# Unblock NoMachine on Windows 11 — run as Administrator
|
||||
# Adds firewall rules for port 4000 (TCP+UDP) and verifies NoMachine service
|
||||
|
||||
Write-Host "=== Unblock NoMachine ===" -ForegroundColor Cyan
|
||||
|
||||
# 1. Add firewall inbound rules for NoMachine (port 4000 TCP + UDP)
|
||||
$ruleName = "NoMachine Server (Port 4000)"
|
||||
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "Firewall rule '$ruleName' already exists — enabling it" -ForegroundColor Yellow
|
||||
Enable-NetFirewallRule -DisplayName $ruleName
|
||||
} else {
|
||||
Write-Host "Creating firewall rule '$ruleName' for port 4000 TCP+UDP" -ForegroundColor Green
|
||||
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort 4000 -Action Allow -Profile Any -Enabled True -Description "Allow NoMachine remote desktop connections"
|
||||
New-NetFirewallRule -DisplayName "$ruleName (UDP)" -Direction Inbound -Protocol UDP -LocalPort 4000 -Action Allow -Profile Any -Enabled True -Description "Allow NoMachine UDP discovery"
|
||||
}
|
||||
|
||||
# 2. Check NoMachine service is running
|
||||
$svc = Get-Service -Name "nxsrv" -ErrorAction SilentlyContinue
|
||||
if (-not $svc) {
|
||||
$svc = Get-Service -Name "NoMachine Server" -ErrorAction SilentlyContinue
|
||||
if (-not $svc) {
|
||||
$svc = Get-Service | Where-Object { $_.DisplayName -like "*NoMachine*" -and $_.DisplayName -like "*Server*" } | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($svc) {
|
||||
Write-Host "NoMachine service: $($svc.Name) — Status: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
|
||||
if ($svc.Status -ne 'Running') {
|
||||
Write-Host "Starting NoMachine service..." -ForegroundColor Yellow
|
||||
Start-Service -Name $svc.Name -ErrorAction SilentlyContinue
|
||||
$svc = Get-Service -Name $svc.Name
|
||||
Write-Host "After start: $($svc.Status)" -ForegroundColor $(if ($svc.Status -eq 'Running') {'Green'} else {'Red'})
|
||||
}
|
||||
} else {
|
||||
Write-Host "WARNING: NoMachine server service not found!" -ForegroundColor Red
|
||||
}
|
||||
|
||||
# 3. Verify port 4000 is listening
|
||||
Write-Host ""
|
||||
Write-Host "Checking port 4000..." -ForegroundColor Cyan
|
||||
$port4000 = Get-NetTCPConnection -LocalPort 4000 -ErrorAction SilentlyContinue
|
||||
if ($port4000) {
|
||||
Write-Host "Port 4000 is LISTENING on $($port4000.LocalAddress):$($port4000.LocalPort) — State: $($port4000.State)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "WARNING: Port 4000 NOT listening — NoMachine server may not be active" -ForegroundColor Red
|
||||
Write-Host "Try: restart NoMachine from the Start Menu or Services app" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 4. Show this machine's IP for remote connection
|
||||
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notlike '*Loopback*' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -match '192\.168' } | Select-Object -First 1).IPAddress
|
||||
if ($ip) {
|
||||
Write-Host ""
|
||||
Write-Host "Laptop IP on LAN: $ip" -ForegroundColor Green
|
||||
Write-Host "From workstation: connect NoMachine to $ip" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Done ===" -ForegroundColor Cyan
|
||||
112
docs/ARCHITECTURE_IA_GPU_2026-06-05.md
Normal file
112
docs/ARCHITECTURE_IA_GPU_2026-06-05.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Architecture IA & GPU/VRAM — état au 2026-06-05
|
||||
|
||||
> Rapport consolidé (2 agents d'analyse + vérifications runtime directes). But : vue
|
||||
> unique « quel composant IA, quel modèle/lib, à quoi il sert, GPU ou CPU, quelle VRAM »,
|
||||
> et signaler les anomalies vitesse/précision à trancher.
|
||||
> Source de vérité : code réel + runtime (`nvidia-smi`, `ss`, `ollama ps`), pas suppositions.
|
||||
|
||||
## 0. Fait majeur — Ollama tourne sur le DGX, pas en local
|
||||
|
||||
`127.0.0.1:11434` est un **tunnel SSH** (vérifié `ss -tlnp`) :
|
||||
```
|
||||
ssh -N -T -L 127.0.0.1:11434:127.0.0.1:11434 aivanov@192.168.1.45 (pid 1883636)
|
||||
```
|
||||
→ **Tous les VLM/LLM (grounding, reasoning, t2a) s'exécutent sur le DGX `192.168.1.45`.**
|
||||
Le RTX 5070 local ne porte plus que CLIP + contextes torch.
|
||||
|
||||
**État VRAM RTX 5070 (instant t) :** `2534 / 12227 MiB utilisés → 9231 MiB libres`.
|
||||
|
||||
## 1. Tableau maître des composants IA
|
||||
|
||||
| Composant / Rôle | Modèle / lib | Device actuel | VRAM | Où | Statut |
|
||||
|---|---|---|---|---|---|
|
||||
| VLM grounding bbox (clic) | `qwen2.5vl:7b-rpa` (Ollama) | **DGX** | ~5.5 Go (DGX) | `vlm_config.get_bbox_grounding_model`, `resolve_engine` | actif |
|
||||
| VLM reasoning V4 / ORA | `qwen2.5vl:7b-rpa` | DGX | partagé | `vlm_config.get_reasoning_model` | actif |
|
||||
| VLM généraliste (default) | `qwen2.5vl:7b-rpa` (fallbacks `qwen3-vl:8b`, `ui-tars`) | DGX | ~5.5 Go | `vlm_config.get_vlm_model` | actif |
|
||||
| VLM critic | `qwen2.5vl:7b-rpa` (hardcodé) | DGX | partagé | `stream_processor._CRITIC_MODEL` | actif |
|
||||
| VLM recording VWB | `gemma4:e4b` (env) | DGX | — | `catalog_routes_v2_vlm.py` | actif (recording) ⚠ default ≠ runtime |
|
||||
| t2a_decision | `qwen2.5:7b` (texte) | DGX | — | `core/llm/t2a_decision.py` | actif |
|
||||
| OCR cascade | **docTR** `ocr_predictor` | **CPU** | 0 | `resolve_engine._resolve_by_ocr_text` | actif |
|
||||
| OCR extraction/validation | **EasyOCR** fr+en (`gpu=False` défaut, flag `easyocr_gpu_enabled`) + tesseract | **CPU** | 0 | `core/llm/ocr_extractor.py`, `resolve_engine:2480` | actif |
|
||||
| Détection UI icônes (SoM) | **YOLOv8** (OmniParser weights, ultralytics) | **CPU** (`get_shared_engine` défaut cpu ; engine supporte cuda) | 0 | `core/detection/som_engine.py`, `resolve_engine._resolve_by_yolo` | actif |
|
||||
| Embeddings / vérif état | **CLIP open_clip ViT-B-32** | **GPU local (auto-cuda si VRAM libre)** | ~1 Go | `core/embedding/clip_embedder.py` | actif |
|
||||
| Index similarité | **FAISS** | **CPU** | 0 | `core/embedding/faiss_manager.py` | actif |
|
||||
| Template matching | `cv2.matchTemplate` | CPU | 0 | `resolve_engine`, `core/grounding/template_matcher.py` | actif |
|
||||
| pHash | `imagehash.phash` | CPU | 0 | `core/analytics/screen_change_detector.py` | actif |
|
||||
| UI-DETR-1 (overlays numérotés) | `rfdetr RFDETRMedium` (model.pth 535 Mo) | **CUDA si dispo** | ~1–2 Go | `visual_workflow_builder/.../ui_detection_service.py` | actif **recording VWB only** |
|
||||
| OmniParser / Florence2 | YOLOv8 + Florence2 | GPU (lazy) | ~2 Go si chargé | `resolve_engine.py:419 _get_omniparser`, `core/detection/omniparser_adapter.py` | **WIRED** dans la cascade serveur (lazy-load) ; désactivé **uniquement au recording VWB** par choix (UI-DETR-1) |
|
||||
| UI-TARS (grounder GUI) | `ui-tars-1.5-7b` (Ollama) | DGX | — | `core/execution/input_handler.py:390/568 _grounding_ui_tars`, appelé par `observe_reason_act` | **WIRED** — Niveau 2 de la cascade grounding (~3s) |
|
||||
| InfiGUI | infigui_server | — | — | `core/grounding/` | statut à confirmer (audit P1.g-hygiene) |
|
||||
| `qwen3.5:9b` (grounding JSON) | profil `get_grounding_profile` | DGX | — | `vlm_config.get_grounding_profile` | **absent DGX** → retombe sur qwen2.5vl ; chemin peu/pas exercé |
|
||||
| ONNX | — | — | — | — | **inexistant** (mentionné CLAUDE.md mais pas dans le code) |
|
||||
|
||||
## 2. Cascade de résolution UI (ordre réel implémenté)
|
||||
|
||||
`OCR docTR (CPU)` → `template cv2 (CPU)` → `YOLO/SoM (CPU)` → `VLM (DGX)`,
|
||||
+ vérification de sortie **CLIP** (sim ≥ 0.75, GPU local) + EasyOCR title-check (CPU).
|
||||
**Tout est CPU sauf le VLM final (DGX) et CLIP (GPU local).** Conforme au contrat « 100% vision ».
|
||||
Premier essai vLLM `:8100` pour le VLM, **actuellement down** → fallback Ollama DGX.
|
||||
|
||||
## 3. ⚠ Anomalies à trancher (vitesse / précision / qualité)
|
||||
|
||||
### 3.1 CPU alors que 9 Go de VRAM libres en local — sous-optimal
|
||||
La politique « OCR/YOLO sur CPU » était justifiée **quand Ollama tournait en local** (éviter
|
||||
de concurrencer la VRAM des VLM 7B sur 12 Go). **Depuis le passage Ollama → DGX, la RTX a
|
||||
9 Go libres** : faire tourner OCR (docTR/EasyOCR) et YOLO/SoM en CPU est désormais un frein
|
||||
à la vitesse, sans raison VRAM. Les leviers existent déjà : flag `easyocr_gpu_enabled`,
|
||||
paramètre `device` de `SomEngine`/`get_shared_engine`, docTR `.cuda()`. → **Changement de
|
||||
config, pas réécriture.** CLIP s'auto-adapte déjà (cuda si VRAM libre).
|
||||
**À noter** : tout devra être réinstallé/validé sur le DGX ensuite — donc faire le travail
|
||||
GPU proprement (paramétrable par device) plutôt que de hardcoder cuda.
|
||||
|
||||
### 3.2 Statut des technos précision/qualité — CORRECTION (2026-06-05, suite QG Qwen)
|
||||
|
||||
⚠ **Rectification d'une première version erronée.** Une analyse initiale (agent, scope
|
||||
limité à `agent_v0/server_v1/` imports directs) avait classé OmniParser et UI-TARS comme
|
||||
« orphelins ». **C'est FAUX** — vérifié dans le code :
|
||||
|
||||
- **OmniParser/Florence2 : WIRED.** `resolve_engine.py:419 _get_omniparser()` (lazy-load GPU
|
||||
singleton) dans la section « YOLO/OmniParser » de la cascade serveur. Le `False` hardcodé
|
||||
vu par l'agent était dans le **VWB recording** (`ui_detection_service.py`), désactivé là
|
||||
**par choix** (UI-DETR-1) — pas dans le runtime serveur.
|
||||
- **UI-TARS : WIRED.** `input_handler.py:390` l'appelle comme « Niveau 2 — UI-TARS grounding
|
||||
(~3s) » dans `_ground_text()` ; importé aussi par `observe_reason_act`. Niveau actif de la
|
||||
cascade de grounding V4.
|
||||
- **InfiGUI** : statut non confirmé → audit P1.g-hygiene.
|
||||
- **`qwen3.5:9b`** : default du profil grounding JSON, **absent du DGX** → à pull si on veut
|
||||
ce chemin, sinon nettoyer le code mort (seul vrai « débranché » du lot).
|
||||
- **ONNX** : référencé CLAUDE.md mais inexistant → corriger la doc.
|
||||
|
||||
**Conclusion** : les technos de précision (OmniParser, UI-TARS, Florence2) **ne sont pas
|
||||
débranchées**. Le seul levier réellement ouvert ici est `qwen3.5:9b` (à pull ou nettoyer).
|
||||
Tout rebranchage/réévaluation doit s'appuyer sur un **bench précision**, pas par principe.
|
||||
|
||||
### 3.3 `vram_orchestrator` semi-inopérant
|
||||
Conçu pour Ollama-local (il fait `systemctl restart ollama` pour purger la VRAM). Avec
|
||||
Ollama sur DGX, ce restart local n'a plus d'effet sur la VRAM des VLM → à revoir / clarifier
|
||||
(utile seulement si plan B retour RTX-local).
|
||||
|
||||
## 4. Directive Dom (2026-06-05)
|
||||
|
||||
> « Pas normal de tourner sur CPU alors qu'on a du GPU/VRAM suffisant en local sur la RTX
|
||||
> pour le moment ; tout devra être installé sur le DGX par la suite. Pourquoi ces technos
|
||||
> (OmniParser/Florence2, UI-TARS/InfiGUI, qwen3.5) ne sont plus branchées ? On cherche
|
||||
> vitesse, précision, qualité. »
|
||||
|
||||
**Pistes d'action** (à cadrer avec Codex/Qwen) :
|
||||
1. Basculer OCR (docTR/EasyOCR) + YOLO/SoM sur **GPU local** (paramétrable par device, pas
|
||||
hardcodé), tant qu'Ollama est sur DGX et la RTX libre — gain de vitesse immédiat, zéro
|
||||
risque VRAM. Prévoir le portage propre sur DGX.
|
||||
2. Investiguer le statut réel (dette vs choix) de **UI-TARS/InfiGUI** et **OmniParser** :
|
||||
bench précision avant de rebrancher, ne pas rebrancher aveuglément.
|
||||
3. Décider de **`qwen3.5:9b`** : pull sur DGX (réactiver profil grounding JSON) ou retirer le
|
||||
code mort.
|
||||
4. Corriger CLAUDE.md (ONNX inexistant, préciser docTR/EasyOCR).
|
||||
|
||||
## 5. Synthèse (5 lignes)
|
||||
|
||||
1. Un seul VLM actif (`qwen2.5vl:7b-rpa`) pour grounding+reasoning+généraliste+critic, sur le **DGX** via tunnel SSH.
|
||||
2. Toute la cascade vision (docTR, EasyOCR, YOLO, cv2, pHash, FAISS) tourne en **CPU local** ; seul **CLIP** utilise le GPU RTX (~1 Go).
|
||||
3. La RTX a **9 Go libres** → opportunité immédiate de basculer OCR/YOLO sur GPU pour la vitesse.
|
||||
4. **OmniParser, UI-TARS, Florence2 sont WIRED** dans la cascade serveur/V4 (correction post-QG Qwen) ; **UI-DETR-1** ne sert qu'au recording VWB ; seuls **qwen3.5:9b** (absent DGX) et **ONNX** (inexistant) sont réellement à traiter.
|
||||
5. **`vram_orchestrator`** est semi-mort depuis le passage Ollama-DGX.
|
||||
106
docs/AUDIT_CODE_MORT_2026-07-02.md
Normal file
106
docs/AUDIT_CODE_MORT_2026-07-02.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Audit Code Mort — Classification A/B/C — 2026-07-02
|
||||
|
||||
**Auteur**: Qwen (vérifié par grep/glob/commandes réelles)
|
||||
**Date**: 2026-07-02
|
||||
**Méthode**: Parallel agent exploration + grep verification + graphify cross-check
|
||||
|
||||
---
|
||||
|
||||
## Méthodologie
|
||||
|
||||
- **A (WIRED/ACTIF)** : Code importé et appelé dans le runtime de production
|
||||
- **B (ORPHAN/PROJECTION)** : Code avec lazy import ou projection future, pas appelé actuellement mais structuré pour activation
|
||||
- **C (MORT/CONFIRMÉ)** : Code zero imports, zero callers, zero runtime activation — candidat suppression
|
||||
|
||||
**Règle**: C-MORT nécessite GO Dom avant suppression. B-ORPHELIN conserve. A-WIRED documenté.
|
||||
|
||||
---
|
||||
|
||||
## C-MORT Confirmé (8 items, ~843 lignes)
|
||||
|
||||
| # | Fichier/Zone | Lignes | Preuve C-MORT | Risque suppression |
|
||||
|---|-------------|--------|---------------|-------------------|
|
||||
| C1 | `agent_v0/deploy_windows.py` | ~244 | Comment "OBSOLETE avril 2026" + zero imports | LOW — standalone script |
|
||||
| C2 | `core/config.py`: 7 deprecated config classes | ~160 | Zero prod imports, mirrorent SystemConfig | LOW — mais vérifier .env references |
|
||||
| C3 | `core/detection/owl_detector.py`: 4 methods | ~90 | Zero callers dans prod | LOW — vérifier examples/ |
|
||||
| C4 | `core/detection/ollama_client.py`: 5 old methods | ~150 | Remplacés par classify_element_complete() | LOW — vérifier examples/ |
|
||||
| C5 | `ollama_client.py:check_ollama_available()` standalone | ~15 | 8/9 callers in examples/, 1 in VWB (duplicat D2) | LOW — VWB a sa propre copie |
|
||||
| C6 | `agent_chat/app.py`: 2 Flask 410 endpoints | ~14 | Endpoints déprecated, retour 410 Gone | LOW — API contract check |
|
||||
| C7 | `core/grounding/smart_resize.py` (77 lines) | 77 | Zero prod callers, DETTE-007 triple impl | LOW — 2 autres impls existent |
|
||||
| C8 | PP-OCRv5 (paddleocr+paddlepaddle venv) | ~deps | 0 .py imports across entire project | LOW — venv deps uninstall |
|
||||
|
||||
**Total C-MORT**: ~843 lignes code + venv deps
|
||||
|
||||
---
|
||||
|
||||
## B-ORPHELIN (5 items, ~537 lignes)
|
||||
|
||||
| # | Fichier/Zone | Lignes | Preuve B | Action |
|
||||
|---|-------------|--------|----------|--------|
|
||||
| B1 | VWB ui_detection_service OmniParser path | ~70 | HARD-DISABILÉ `_omniparser_available = False # DÉSACTIVÉ` | Conserver, documenter activation condition |
|
||||
| B2 | `fusion_engine.py:_fuse_concat_projection()` | ~15 | Stub, prévu pour future fusion modes | Conserver, marque PROJECTION |
|
||||
| B3 | `omniparser_adapter.py` | ~429 | BRANCHABLE DORMANT, try/except import | Conserver, documenter activation condition |
|
||||
| B4 | `CorrectionStatus.DEPRECATED` enum value | ~3 | Enum value, pas supprimable sans break | Conserver, marque DEPRECATED |
|
||||
| B5 | `catalog_routes_v2_vlm.py:check_ollama_available()` | ~20 | Duplicat de ollama_client.py (D2) | DÉCISION Dom : unifier ou garder 2 impls |
|
||||
|
||||
---
|
||||
|
||||
## Duplicats Identifiés (4)
|
||||
|
||||
| # | Item | Impl 1 | Impl 2 | Statut |
|
||||
|---|------|--------|--------|--------|
|
||||
| D1 | smart_resize | smart_resize.py (C7) | ui_detection_service.py resize | C-MORT vs WIRED |
|
||||
| D2 | check_ollama_available | ollama_client.py (C5) | catalog_routes_v2_vlm.py (B5) | C-MORT vs B-ORPHELIN |
|
||||
| D3 | ground_element | seeclick_adapter.py (B→provenance?) | ollama_client.py old method | B vs C4 |
|
||||
| D4 | 7 deprecated config classes | core/config.py (C2) | SystemConfig (WIRED) | C-MORT vs A-WIRED |
|
||||
|
||||
---
|
||||
|
||||
## Classification Updates (C→A upgrades confirmés)
|
||||
|
||||
| Item | Prior Status | Current Status | Preuve upgrade |
|
||||
|------|-------------|---------------|---------------|
|
||||
| autonomous_planner.py | C | **A** | Migrated to agent_chat/, wired by app.py |
|
||||
| seeclick_adapter.py | C | **B** | Lazy re-export, `_seeclick_available` never consulted mais impl ground_element indépendante |
|
||||
| grounding/server.py | C | **A** | HTTP service port 8200, standalone Flask |
|
||||
| get_grounding_profile() | C | **A** | Wired via ollama_client.py:303-304 lazy import |
|
||||
|
||||
---
|
||||
|
||||
## OmniParser — Classification 7 Zones
|
||||
|
||||
| # | Zone | Statut | Activation | Fallback |
|
||||
|---|------|---------|-----------|----------|
|
||||
| 1 | SoM engine (som_engine.py) | **A-WIRED** | YOLO weights direct | docTR OCR |
|
||||
| 2 | resolve_engine (_get_omniparser) | **B-DORMANT** | Lazy Optional[bool] | None → skipped |
|
||||
| 3 | phase25_analyzer (_OmniParserSafeWrapper) | **B-DORMANT** | Lazy import + healthcheck | docTR-only |
|
||||
| 4 | api_stream healthcheck | **A-WIRED** | Always 200 omniparser_available:bool | degraded:true |
|
||||
| 5 | omniparser_adapter.py | **B-DORMANT** | Import phase25 & resolve | empty list |
|
||||
| 6 | VWB ui_detection_service.py | **B-HARD-DISABILÉ** | `_omniparser_available = False # DÉSACTIVÉ` | ui-detr-1 only |
|
||||
| 7 | VWB catalog_routes_v2_vlm.py | **B-DORMANT** | try/except, flips True si installé | VLM fallback |
|
||||
|
||||
---
|
||||
|
||||
## QG-Gated Lots (proposé, nécessite GO Dom)
|
||||
|
||||
### Lot 1 — C-MORT Low Risk (suppression directe après GO Dom)
|
||||
- C1 deploy_windows.py
|
||||
- C7 smart_resize.py
|
||||
- C6 agent_chat 410 endpoints
|
||||
- C8 PP-OCRv5 venv deps uninstall
|
||||
|
||||
### Lot 2 — C-MORT Medium Risk (vérification examples/ avant suppression)
|
||||
- C2 7 deprecated config classes (vérifier .env)
|
||||
- C3 owl_detector 4 methods (vérifier examples/)
|
||||
- C4 ollama_client 5 old methods (vérifier examples/)
|
||||
- C5 check_ollama_available standalone (vérifier VWB duplicat)
|
||||
|
||||
### Lot 3 — Duplicats Unification (décision Dom)
|
||||
- D1 smart_resize: unifier ou garder 2 impls
|
||||
- D2 check_ollama_available: unifier VWB vs core
|
||||
- D3 ground_element: unifier seeclick vs ollama
|
||||
- D4 config classes: supprimer deprecated vs garder compat
|
||||
|
||||
---
|
||||
|
||||
**Prochaine étape**: Dom review → GO/NOGO par lot → exécution séquentielle avec tests verification après chaque lot.
|
||||
174
docs/AUDIT_GAPS_APPLI_100PCT_2026-06-10.md
Normal file
174
docs/AUDIT_GAPS_APPLI_100PCT_2026-06-10.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Audit — Ce qui manque pour une appli 100% fonctionnelle
|
||||
|
||||
- **Date** : 2026-06-10
|
||||
- **Demandeur** : Dom
|
||||
- **Auteur** : Claude (audit read-only par 4 sous-agents d'exploration + contre-vérifications manuelles)
|
||||
- **Périmètre** : agent client Windows (Léa), chaîne d'apprentissage, capacité de replay, maturité produit/fleet
|
||||
- **Statut findings** : les fichier:ligne proviennent d'agents d'exploration. Les 3 contradictions majeures ont été re-vérifiées à la main (voir annexe). **Avant d'engager du code sur un item, revalider le point au cas par cas** (méthode habituelle).
|
||||
- **Avis croisé Qwen** : reçu 2026-06-10 23:30 (`inbox_claude/2026-06-10_2330_qwen-to-claude_AVIS-GAPS-APPLI-100PCT.md`) — intégré en **Addendum (§7)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnostic central
|
||||
|
||||
L'appli aujourd'hui, honnêtement qualifiée : **un record-and-replay supervisé robuste, avec une couche sémantique réelle au grounding, mais dont la boucle d'apprentissage n'est pas fermée et dont les filets de sécurité sont écrits mais débranchés.**
|
||||
|
||||
Trois promesses produit non tenues dans le code qui tourne :
|
||||
|
||||
1. **La boucle d'apprentissage est ouverte** — Shadow observe et construit des workflows, mais ils n'arrivent jamais dans VWB (import jamais déclenché).
|
||||
2. **L'exécution ne se vérifie pas elle-même** — verify, healing, recovery : tout existe, tout est désactivé ou non branché.
|
||||
3. **Pas de généralisation** — un workflow appris ne survit pas à un changement de poste/résolution ; FAISS est construit au training mais jamais consulté au replay.
|
||||
|
||||
Point structurel transverse : **deux chemins d'exécution aux capacités différentes** :
|
||||
- `visual_workflow_builder/backend/api_v3/execute.py` (exécution locale VWB, Legacy + ORA)
|
||||
- `agent_v0/server_v1/replay_engine.py` → agent Windows Léa (chemin POC)
|
||||
|
||||
Certains manques n'existent que sur l'un des deux. `t2a_decision` et le templating profond `{{var.field.sub}}` sont **implémentés sur le chemin Léa** (replay_engine.py:1922 et :2017) mais absents du chemin local. Cette asymétrie a piégé jusqu'aux agents d'audit eux-mêmes — c'est un coût de maintenance et un risque d'erreur permanent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Axe agent client Windows (Léa) — ~80% fonctionnel, 1 bug critique
|
||||
|
||||
### Ce qui marche (vérifié wired)
|
||||
- Capture complète : clics/double-clics, clavier+buffer texte, scroll, multi-écrans, DPI awareness, floutage sensible, dédup pHash.
|
||||
- Résilience réseau : buffer SQLite persistant, retry 3×, backoff 1→30s, heartbeat 5s.
|
||||
- Purge captures après ACK serveur (`PURGE_AFTER_ACK=1` défaut).
|
||||
- Enroll Bearer token + machine_id ; détection dialogues système UAC/CredUI/SmartScreen **fail-closed** (pause supervisée).
|
||||
- Rétention logs 180 j (conforme Règlement IA art. 12).
|
||||
|
||||
### Gaps
|
||||
|
||||
| # | Gap | Sévérité | Preuve | Type |
|
||||
|---|-----|----------|--------|------|
|
||||
| A1 | **Timeout HTTP client 5s** : étape serveur > 5s (extract_text, t2a) → client coupe, action déjà sortie de la queue → **perdue silencieusement**. Incident documenté 8 mai (4 actions perdues, pause step 18). | 🔴 BLOQUANT POC | `agent_v1/core/executor.py:1786` ; `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` | Bug |
|
||||
| A2 | **Watchdog `_retry_pending` absent** côté serveur : actions perdues jamais republiées. | 🔴 BLOQUANT POC | `network/streamer.py:99-105` (constat) | Non implémenté |
|
||||
| A3 | Écran verrouillé non détecté : agent capture noir, tape dans le vide. | 🟠 Important | — | Non implémenté |
|
||||
| A4 | RecoveryEngine client : code complet, jamais appelé. | 🟡 Nice-to-have | `agent_v1/core/recovery.py` | Écrit non branché |
|
||||
| A5 | Long-polling HTTP fragile par construction (vs SSE/WebSocket). | 🟡 Post-POC | `main.py:287-366` | Architecture |
|
||||
|
||||
Note : la suspicion « appel Ollama de commentaire d'action orphelin côté client » **ne se confirme pas** côté agent_v1 (`OLLAMA_HOST` défini dans config mais aucun appel client). Le commentaire d'action est côté serveur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Axe chaîne d'apprentissage — marche jusqu'au dernier mètre, puis s'arrête
|
||||
|
||||
### Ce qui marche (vérifié wired)
|
||||
- Chaîne Shadow complète : streaming → LiveSessionManager → `_worker_queue.txt` → `run_worker.py` → ScreenAnalyzer → CLIP → FAISS indexation → GraphBuilder DBSCAN → workflow JSON dans `data/training/workflows/{machine_id}/`.
|
||||
- Apprentissage post-replay : ReplayLearner (JSONL `data/learning/replay_results/`) + TargetMemoryStore (SQLite `data/learning/target_memory.db`), consultés avant résolution, alimentés après succès/échec.
|
||||
|
||||
### Gaps
|
||||
|
||||
| # | Gap | Sévérité | Preuve | Type |
|
||||
|---|-----|----------|--------|------|
|
||||
| L1 | **Workflows Shadow jamais importés dans VWB** : `import_core_workflow()` existe (`visual_workflow_builder/backend/api/workflows.py:622`) mais aucun appel automatique post-finalize. La boucle d'apprentissage produit des fichiers que personne ne voit. | 🔴 BLOQUANT promesse produit | grep `import_core_workflow` depuis server_v1 = 0 hit | Écrit non branché |
|
||||
| L2 | ShadowLearningHook jamais importé (état avril 2026 inchangé). | 🟠 Important | `core/grounding/shadow_learning_hook.py` | Écrit non branché |
|
||||
| L3 | **FAISS construit mais jamais interrogé au replay** — la mémoire sémantique ne sert pas à la résolution. | 🟠 Important | `core/embedding/faiss_manager.py` | Écrit non branché |
|
||||
| L4 | Pas de généralisation cross-résolution/cross-poste : workflows cloisonnés par machine_id, ancres dépendantes du poste source. | 🟠 Important | `core/models/workflow_graph.py` | Non implémenté |
|
||||
| L5 | **Copilot : inexistant** (aucun moteur de suggestion). **Autonomous : AutonomousPlanner isolé du replay engine.** Le cycle Shadow→Copilot→Autonomous est aujourd'hui Shadow→(rien)→exécution déclenchée manuellement. | 🔴 BLOQUANT promesse produit | `agent_chat/autonomous_planner.py`, `agent_v0/server_v1/execution_plan_runner.py` | Squelettes |
|
||||
| L6 | Recapture anchor VWB : pas de revalidation/régénération PNG post-modification (bug connu mai 2026). | 🟠 Important | `visual_workflow_builder/backend/services/anchor_image_service.py` | Non implémenté |
|
||||
|
||||
---
|
||||
|
||||
## 4. Axe replay/exécution — ça clique bien, mais ça ne sait pas si ça a marché
|
||||
|
||||
### Ce qui marche (vérifié wired)
|
||||
- Cascade de résolution active : template matching → CLIP → OCR/UI-TARS → VLM.
|
||||
- DialogHandler branché (détection popups pré-step).
|
||||
- Pause supervisée avec choix utilisateur (skip/static/coords, timeout 120s).
|
||||
- Chemin Léa : `t2a_decision` (replay_engine.py:1922, handlers :2045+), templating profond `{{var.field.sub}}` (`path.split('.')` :2017), extract_text.
|
||||
|
||||
### Gaps
|
||||
|
||||
| # | Gap | Sévérité | Preuve | Type |
|
||||
|---|-----|----------|--------|------|
|
||||
| R1 | **`verify_level='none'` en dur** : aucune vérification post-action que le clic a produit l'effet attendu (seul pHash d'attente d'écran). Contraire au principe « vérif avant + après chaque clic ». | 🟠 Important (🔴 avant clinique) | `execute.py:1545` | Branché désactivé |
|
||||
| R2 | **VLM pre-check `if False:` en dur** : pas de validation que l'élément trouvé = l'élément attendu. | 🟠 Important | `core/execution/observe_reason_act.py:1707` | Branché désactivé |
|
||||
| R3 | Healing engine implémenté, jamais appelé. | 🟠 Important | `core/healing/healing_engine.py:21-150` | Écrit non branché |
|
||||
| R4 | **Aucune reprise après crash** : crash au step N → redémarrage à 0, pas de checkpoint. | 🔴 BLOQUANT clinique | `execute.py:1732` (thread daemon sans checkpoint) | Non implémenté |
|
||||
| R5 | **OCR-DIRECT « centre de ligne »** : substring d'une ligne docTR → coordonnées du centre de la ligne entière. Sur une barre d'onglets, Imagerie/Notes/Synthèse ≈ mêmes coords. Latent et sournois. | 🟠 Important | `agent_v0/server_v1/resolve_engine.py:1447-1527` | Bug |
|
||||
| R6 | Timeout VWB→serveur 30s vs étapes longues (t2a/Ollama lent) → 504. | 🟠 Important | `server_client.py:207` | Bug config |
|
||||
| R7 | Reporting d'exécution pauvre : méthode de grounding utilisée et raison d'échec non tracées. | 🟠 Important | ExecutionStep DB | Incomplet |
|
||||
| R8 | Popup détecté mais gestion échouée → continue (log seul), pas de pause. | 🟡 Nice-to-have | `execute.py:283` | Incomplet |
|
||||
| R9 | 3 systèmes de grounding morts (code zombie) : `fast_detector`, `smart_matcher`, `template_matcher` standalone. | 🟡 Ménage | `core/grounding/` | Poids mort |
|
||||
| R10 | TitleVerifier aveugle en VM (crop EasyOCR 45px illisible). | 🟡 Connu | `core/grounding/title_verifier.py:34-67` | Limitation |
|
||||
|
||||
---
|
||||
|
||||
## 5. Axe maturité produit / fleet — OK pour 5 TIM supervisés, pas au-delà
|
||||
|
||||
### Ce qui marche (vérifié)
|
||||
- Fleet : enroll/uninstall/revoke SQLite (`agent_registry.py`), `_guard_agent_registry_access`, last_seen heartbeat.
|
||||
- Sessions concurrentes thread-safe ; uploads images rate-limités sans sérialisation.
|
||||
- Healthcheck multi-composants + timer systemd (API, dashboard, worker heartbeat, disque).
|
||||
- Export ZIP workflows ; dashboard HTTP Basic fail-closed.
|
||||
|
||||
### Gaps
|
||||
|
||||
| # | Gap | Sévérité | Preuve | Type |
|
||||
|---|-----|----------|--------|------|
|
||||
| P1 | **DETTE-006/010 — grounding Qwen3-VL instable** (`smart_resize` non déterministe, config checkpoint factor 28 vs 32). LE risque technique du calendrier POC. | 🔴 BLOQUANT POC | `docs/MIGRATION_VLM_PLAN_2026-05-09.md`, DETTE_TECHNIQUE.md | En cours |
|
||||
| P2 | **1 seul replay simultané** (verrou global `_replay_lock`). Acceptable POC séquentiel, bloquant au-delà. | 🟠 Important post-POC | `api_stream.py` | Limitation |
|
||||
| P3 | Opérabilité non-dev : pas d'onglet « état systèmes », pas de monitoring GPU/Ollama, erreurs JSON brut. Acceptable seulement avec Dom en SSH derrière. | 🟠 Important | `web_dashboard/app.py` | Incomplet |
|
||||
| P4 | Export ZIP sans **restore** en masse ; backup exporte les JSON, pas la DB (DETTE-015 : symlink tient pour le POC). | 🟠 Important | `core/system/backup_exporter.py:58-160` | Incomplet |
|
||||
| P5 | Multi-users/RBAC : 1 user statique. Accepté POC (lié DETTE-016). | 🟡 Post-POC | `web_dashboard/app.py:67` | Accepté |
|
||||
| P6 | Pas de rotation des logs serveurs (`logs/*.log`) — artifact_retention ne couvre que les données. | 🟡 Nice-to-have | `core/system/artifact_retention.py` | Incomplet |
|
||||
| P7 | DETTE-013 : tests unit non exécutables sans `RPA_API_TOKEN` (sys.exit à l'import). | 🟠 Important dev | `agent_v0/server_v1/api_stream.py:135` | Bug |
|
||||
| P8 | Ménage pré-POC (~9-10 j-h, `MENAGE_PRE_POC_2026-05-29.md`) non engagé ; ~21 modules core/ orphelins documentés. | 🟡 Planifié | docs/POC/ | Dette |
|
||||
|
||||
---
|
||||
|
||||
## 6. Priorisation proposée
|
||||
|
||||
### Horizon 1 — avant le M2 live (jours) : fiabiliser la chaîne qui existe
|
||||
1. **A1** Timeout client 5s → 30s (1 constante) + **A2** watchdog `_retry_pending` serveur — le duo qui a tué la session du 8 mai.
|
||||
2. **P1** Trancher DETTE-006/010 (calibration grounding Qwen3-VL sur DGX) avant le bench J+6.
|
||||
3. **A3** Détection écran verrouillé.
|
||||
|
||||
### Horizon 2 — avant la clinique (semaines) : les filets de sécurité
|
||||
4. **R1/R2** Réactiver verify (au moins post-condition légère) + pre-check.
|
||||
5. **R5** Fix OCR centre-de-ligne (span du substring, pas centre de ligne).
|
||||
6. **R4** Reprise sur crash (checkpoint step) + **R7** tracer la méthode de résolution.
|
||||
7. **R6** Timeout VWB 30s → adapté aux étapes longues (ou polling asynchrone).
|
||||
|
||||
### Horizon 3 — la promesse produit (post-POC) : fermer la boucle
|
||||
8. **L1** Pont auto Shadow→VWB (`import_core_workflow` post-finalize) — LA pièce qui transforme l'outil en produit apprenant.
|
||||
9. **L3** FAISS consulté au replay + **L4** début de généralisation cross-poste.
|
||||
10. **L5** Copilot (moteur de suggestion) puis branchement AutonomousPlanner.
|
||||
11. Unifier les deux chemins d'exécution (execute.py local vs replay_engine.py Léa).
|
||||
12. **P2** Replays parallèles, **P3** opérabilité TIM, **P4** restore, RBAC.
|
||||
|
||||
---
|
||||
|
||||
## 7. Addendum — Avis croisé Qwen (historien/QG, 2026-06-10 23:30)
|
||||
|
||||
### Convergences avec l'audit code
|
||||
- **DETTE-006/010 = les deux vrais risques démo** (« si le grounding dérive, les clics ratent. Démo morte. ») — aligné avec P1/Horizon 1.
|
||||
- **Monitoring/alerting productif absent** (P3) : « si un worker crashe à 3h du matin sur un TIM, personne ne le saura ».
|
||||
- Écarts doc vs réalité confirmés par son registre : ContinuousLearner/Shadow hook orphelins (L2), cascade YOLO et OmniParser neutralisées (DETTE-004), ~1900 lignes de code mort jamais câblé (autonomous_planner, seeclick…) — cohérent avec L5/R9.
|
||||
|
||||
### Apports nouveaux de Qwen (absents de l'audit code)
|
||||
1. **Multi-TIM jamais testé > 1 agent simultané** : le Fleet existe, mais routage session, isolation mémoire et contention GPU sous charge réelle sont **inconnus**. Mon audit avait noté le verrou replay global (P2) ; Qwen élargit : c'est toute la concurrence multi-agents qui n'a aucune preuve d'exécution.
|
||||
2. **Le pipeline complet record → replay → compétence n'a jamais tourné en conditions réelles** : M2 live n'a pas encore eu lieu. « Le premier vrai test sera devant le client » si on ne fait pas M2 avant.
|
||||
3. **Incidents récurrents de son registre** : worker zombie 5 jours (résolu par watchdog N3), tunnel Ollama instable (stabilisé systemd), UI-TARS 500 non détecté (toujours 0 test dédié), OOM VRAM GB10 (fixé).
|
||||
4. **DETTE-015 jugée fragile** : le symlink a déjà cassé une fois (P0-1) ; peut resurgir si cwd change.
|
||||
|
||||
### Point de tension à arbitrer en M2 (pas tranché)
|
||||
Qwen affirme : « si le serveur redémarre, les agents Windows tombent — pas de reconnexion automatique ». Mon audit client a trouvé buffer SQLite persistant + retry + backoff + health-check 30s (`streamer.py`). Les deux peuvent être vrais : le **transport** se reconnecte, mais la **session/replay en cours** ne reprend probablement pas après un restart serveur. À vérifier explicitement pendant M2 (test : restart serveur en cours de session).
|
||||
|
||||
### Verdict Qwen
|
||||
- **1 TIM en démo contrôlée : prêt** (sous réserve DETTE-006/010).
|
||||
- **5 TIM réels en clinique : pas prêt** — le gap n'est pas dans le code métier (OCR, VLM, grounding) mais dans l'**infra multi-utilisateur** : sessions, isolation, monitoring, résilience.
|
||||
|
||||
Ce verdict est compatible avec la priorisation §6 et la renforce : l'Horizon 2 doit inclure un **test de charge multi-agents** (2-3 agents simultanés minimum) avant la clinique, en plus des filets de sécurité.
|
||||
|
||||
---
|
||||
|
||||
## Annexe — Contradictions inter-agents résolues (contre-vérifiées à la main)
|
||||
|
||||
| Affirmation agent d'audit | Verdict après vérification |
|
||||
|---|---|
|
||||
| « t2a_decision non implémenté » | **FAUX sur le chemin Léa** : implémenté `agent_v0/server_v1/replay_engine.py:1922` + handlers :2045+. Vrai uniquement pour le chemin local execute.py. |
|
||||
| « `{{var.field.sub}}` ne marche pas » | **FAUX sur le chemin Léa** : `path.split('.')` replay_engine.py:2017. Vrai uniquement chemin local. |
|
||||
| « Le chemin replay vers Léa est démis / Léa n'existe plus » | **FAUX** : pont `learned_workflow_bridge.py` côté VWB + polling `/replay/next` côté client actifs. Les deux chemins coexistent — c'est l'asymétrie connue. |
|
||||
|
||||
Leçon : tout audit ou modification doit d'abord identifier **sur quel chemin d'exécution** il porte.
|
||||
170
docs/BENCH_OCR_PPOCRV5_2026-07-02.md
Normal file
170
docs/BENCH_OCR_PPOCRV5_2026-07-02.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Benchmark OCR PP-OCRv5 CPU — 02/07/2026
|
||||
|
||||
> **Label**: baseline CPU, non verdict GPU
|
||||
> **Machine**: Ryzen 9 9950X 32 threads, 123GB RAM, RTX 5070 12GB VRAM, CUDA driver 580.159.03/13.0
|
||||
> **Image**: `shot_0172_full.png` (2560×1600, 721K, RGB) — capture écran Windows Léa
|
||||
> **PaddleOCR**: 3.4.0, paddlepaddle 3.3.1 CPU-only (non compilé CUDA)
|
||||
|
||||
---
|
||||
|
||||
## 1. Résultats synthèse
|
||||
|
||||
| Engine | Cold (s) | Warm (s) | Detections | Mem init (MB) | Mem peak (MB) | Statut |
|
||||
|--------|----------|----------|------------|---------------|---------------|--------|
|
||||
| **docTR CPU** | 0.776 | 0.717 | 139 | 263.2 | 263.2 | ✅ OK |
|
||||
| **EasyOCR CPU** | 4.878 | 4.856 | 54 | 0.6 | 156.9 | ✅ OK |
|
||||
| **PP-OCRv5 CPU** | — | — | — | — | — | ❌ BLOCKED |
|
||||
|
||||
---
|
||||
|
||||
## 2. PP-OCRv5 CPU — VERDICT: BLOCKED
|
||||
|
||||
### Crash récurrent
|
||||
|
||||
Toute inference PaddleOCR sur paddlepaddle 3.3.1 CPU-only crash systématiquement :
|
||||
|
||||
```
|
||||
(Unimplemented) ConvertPirAttribute2RuntimeAttribute not support
|
||||
[pir::ArrayAttribute<pir::DoubleAttribute>]
|
||||
(at /paddle/paddle/fluid/framework/new_executor/instruction/onednn/onednn_instruction.cc:116)
|
||||
```
|
||||
|
||||
### Root cause
|
||||
|
||||
Bug dans le **PIR new executor** de paddlepaddle 3.3.1 CPU-only : l'instruction OneDNN
|
||||
tente de convertir un `ArrayAttribute<DoubleAttribute>` en runtime attribute, opération
|
||||
non implémentée. Ce bug est :
|
||||
|
||||
- **NON model-spécifique** : v3_mobile_det, v4_mobile_det, v5_mobile_det crashent tous
|
||||
- **NON version-spécifique** : PP-OCRv3, v4 (fr absent), v5 crashent tous
|
||||
- **NON API-spécifique** : `ocr()` (deprecated) et `predict()` crashent identiquement
|
||||
- **NON contournable** par flags : `FLAGS_use_mkldnn=0`, `FLAGS_use_pir_api=0` n'ont aucun effet
|
||||
|
||||
### 7 approches testées — TOUTES FAILED
|
||||
|
||||
| # | Approche | Résultat |
|
||||
|---|----------|----------|
|
||||
| 1 | `FLAGS_use_mkldnn=0` via `os.environ` | Same crash |
|
||||
| 2 | `det='PP-OCRv5_mobile_det'` param | ValueError "Unknown argument: det" (PaddleOCR 3.4.0 rejette ce param) |
|
||||
| 3 | `FLAGS_use_mkldnn=0` shell-level avant Python | Same crash |
|
||||
| 4 | `text_detection_model_name='PP-OCRv5_mobile_det'` | mobile_det DL OK → inference crash (same OneDNN) |
|
||||
| 5 | `ocr_version='PP-OCRv4', lang='fr'` | ValueError "No models available for language 'fr' and PP-OCRv4" |
|
||||
| 6 | PP-OCRv3 + `ocr(img, cls=True)` legacy | DeprecationWarning → TypeError sur `cls` kwarg → predict() → same crash |
|
||||
| 7 | `FLAGS_use_pir_api=0` shell + os level | Same crash |
|
||||
|
||||
### PaddleOCR 3.4.0 __init__ params inspectés
|
||||
|
||||
28 paramètres au total. **Pas** de `enable_mkldnn`, `use_pir`, ou `det`. Param de détection
|
||||
remplacé par `text_detection_model_name`. API v3.4.0 : `use_angle_cls` deprecated
|
||||
→ `use_textline_orientation=True`, `show_log` supprimé (ValueError si utilisé).
|
||||
|
||||
### Incompatibilité downgrade
|
||||
|
||||
paddlepaddle 2.6.2 existe mais **incompatible** avec PaddleOCR 3.4.0 (requires ≥3.x).
|
||||
PaddleOCR 2.x serait compatible avec paddlepaddle 2.6.2 mais API/outils complètement
|
||||
différents — non évalué dans ce bench.
|
||||
|
||||
### Conclusion
|
||||
|
||||
**PP-OCRv5 CPU = BLOCKED**. Bug upstream dans paddlepaddle CPU-only binary, aucune
|
||||
workaround applicative possible. Seules alternatives :
|
||||
|
||||
1. **paddlepaddle GPU binary** (RTX 5070 + CUDA 13.0 compatible) → bench GPU séparé
|
||||
2. **Fix upstream** paddlepaddle (PR PIR executor OneDNN)
|
||||
3. **Downgrade PaddleOCR 2.x + paddlepaddle 2.6.2** (API legacy, non testé)
|
||||
|
||||
---
|
||||
|
||||
## 3. docTR CPU — Résultats détaillés
|
||||
|
||||
- **Cold latency**: 0.776s (incl. model loading)
|
||||
- **Warm latency**: 0.717s
|
||||
- **Detections**: 139 (mot-level, agressif — fragmente "Dites", "Sortie", "de", "veille")
|
||||
- **Mémoire**: 263.2MB stable (init = peak)
|
||||
- **Qualité**: haute sur mots courts, fragmente les phrases longues
|
||||
- **Confiance**: variable (0.26→0.99), nombreux tokens <0.7
|
||||
|
||||
### Observations docTR
|
||||
|
||||
- Word-level detection = 139 items → beaucoup de fragments 1-2 lettres
|
||||
- Bonne qualité sur labels UI ("Mode", "veille", "RPA", "VWB", "Python", "proxmox")
|
||||
- Fragmente les phrases ("Sortie de veille de l'accès vocal ou appuyez..." → 12 mots isolés)
|
||||
- IP correctement détecté : "192.168.1.40:3002" (conf 0.90)
|
||||
- Faux positifs : "0", "E03", "E", "€" isolés avec conf <0.4
|
||||
|
||||
---
|
||||
|
||||
## 4. EasyOCR CPU — Résultats détaillés
|
||||
|
||||
- **Cold latency**: 4.878s (heavy model loading)
|
||||
- **Warm latency**: 4.856s
|
||||
- **Detections**: 54 (line-level, plus conservatif)
|
||||
- **Mémoire**: 0.6MB init → 156.9MB peak
|
||||
- **Qualité**: bonne sur lignes complètes, plus robuste sur phrases
|
||||
|
||||
### Observations EasyOCR
|
||||
|
||||
- Line-level detection = 54 items → phrases plus cohérentes
|
||||
- Cold start très lent (5x docTR) mais warm identique
|
||||
- Meilleur sur textes longs, moins de fragmentation
|
||||
- Peak mémoire plus élevé que docTR (156.9 vs 263.2 MB init docTR)
|
||||
|
||||
---
|
||||
|
||||
## 5. Comparaison avec baselines Mai 2026
|
||||
|
||||
> Bench Mai 2026 — image `landing_wide.png`, critère 11 items de référence
|
||||
|
||||
| Engine | Score Mai (11 ref) | Score Juillet (detections) | Latency warm | Commentaire |
|
||||
|--------|-------------------|---------------------------|--------------|-------------|
|
||||
| Tesseract | **11/11** | — (non re-benché) | — | Référence May, non retesté |
|
||||
| EasyOCR brut | 8/11 | 54 det (shot_0172) | 4.856s | Fragmente moins, score < Tesseract |
|
||||
| EasyOCR preproc | 9/11 | — | — | +1 vs brut May |
|
||||
| docTR CPU | 10/11 | 139 det (shot_0172) | 0.717s | **Meilleur rapport qualité/latence** |
|
||||
| PP-OCRv5 CPU | non testé May | BLOCKED | — | Bug PIR/OneDNN, 0 inference possible |
|
||||
|
||||
### Hierarchie CPU confirmée
|
||||
|
||||
```
|
||||
docTR CPU (0.7s, 10/11) > EasyOCR preproc (4.9s, 9/11) > EasyOCR brut (4.9s, 8/11) > PP-OCRv5 CPU (BLOCKED)
|
||||
```
|
||||
|
||||
docTR reste le **meilleur moteur OCR CPU** pour Léa en termes de latence + qualité.
|
||||
Tesseract reste le plus précis (11/11) mais sans bounding boxes exploitables.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommandations
|
||||
|
||||
1. **docTR = moteur OCR CPU de production** — latence <1s, qualité 10/11, word-level bboxes
|
||||
2. **PP-OCRv5 GPU bench = action séparée** — requiere paddlepaddle GPU binary sur RTX 5070
|
||||
3. **PaddleOCR 3.4.0 = ORPHAN** — 0 imports dans le projet, pas dans requirements.txt,
|
||||
CPU-only install sans CUDA → retirer du venv si cleanup D2 (C-MORT)
|
||||
4. **Ne pas dépendre de PaddleOCR** pour POC T1 — docTR suffisant
|
||||
5. **Bug report upstream** — paddlepaddle PIR executor OneDNN, repro: any model + CPU binary
|
||||
|
||||
---
|
||||
|
||||
## 7. Annexes
|
||||
|
||||
### A. Script bench
|
||||
|
||||
`scripts/bench_ppocrv5_cpu.py` — compare PP-OCRv5, docTR, EasyOCR sur shot_0172_full.png.
|
||||
PP-OCRv5 crash → résultats JSON avec error field.
|
||||
|
||||
### B. Résultats JSON
|
||||
|
||||
`scripts/bench_ppocrv5_results.json` — 4522 lignes, contient tous texts + bboxes pour
|
||||
docTR (139 items) et EasyOCR (54 items). PP-OCRv5 = error only.
|
||||
|
||||
### C. Machine specs
|
||||
|
||||
- CPU: Ryzen 9 9950X, 32 threads
|
||||
- RAM: 123 GB
|
||||
- GPU: RTX 5070 12GB VRAM (non utilisé — bench CPU)
|
||||
- CUDA driver: 580.159.03 / runtime 13.0
|
||||
- OS: Linux (Ubuntu)
|
||||
- paddlepaddle: 3.3.1 CPU-only (pip install)
|
||||
- PaddleOCR: 3.4.0
|
||||
- docTR: (version installée dans venv)
|
||||
- EasyOCR: (version installée dans venv)
|
||||
104
docs/CARTO_APPRENTISSAGE_FONDS_COMMUN_2026-06-16.md
Normal file
104
docs/CARTO_APPRENTISSAGE_FONDS_COMMUN_2026-06-16.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Cartographie — Chaîne d'apprentissage & mise en commun des connaissances (2026-06-16)
|
||||
|
||||
> Question Dom : *« Comment le savoir appris sur chaque poste est-il mutualisé dans un fonds commun unique partagé par tous les postes, et exploité vers l'autonomie ? »*
|
||||
> Méthode : graphify (graphe code 58k nodes) + 3 agents Explore + vérif code directe. Cross-checks Codex (pipeline server_v1) & Qwen (fédération/FAISS) **en cours** — ce doc sera enrichi.
|
||||
> ⚠️ Verdicts = état runtime constaté ce jour (`poc-dgx` @ `2b1743c20`), pas la doc d'intention.
|
||||
|
||||
## TL;DR (réponse directe)
|
||||
|
||||
Le « fonds commun → autonomie » est **partiellement construit, mais les maillons clés sont soit silotés par machine, soit dormants** :
|
||||
|
||||
1. ✅ **Ce qui EST déjà commun** : les **compétences YAML** (`core/competences/`) et les **embeddings** (CLIP→FAISS) — partagés serveur, tous postes.
|
||||
2. ❌ **Ce qui est SILOTÉ par machine (codé en dur)** : le **stockage** workflows/sessions (`{machine_id}/`) et surtout le **cross-session learning** qui **refuse de matcher entre machines** (`if workflow_machine != machine_id: continue`). C'est l'anti-pattern direct vs la vision.
|
||||
3. 🌙 **Ce qui est DORMANT** : la **fédération** (`core/federation` : LearningPack, GlobalFAISSIndex) — le vrai mécanisme de fonds commun — est **bien conçue, globale et anonymisée** (Qwen confirmé : zéro machine_id, clé `pack_source_hash`), mais **doublement inerte au runtime** : (a) alimentée **seulement** par l'endpoint import **manuel** (jamais auto-déclenché) ; (b) son **`search()` n'est JAMAIS appelé** (`faiss_global.py:199` défini, zéro consommateur actif) → **index write-only, jamais consulté** → contribue **zéro** au comportement/à l'autonomie aujourd'hui.
|
||||
4. 🚧 **Ce qui manque pour l'autonomie** : la **couche graphe** (WorkflowGraph) **EST construite en live** (`finalize_session` → `GraphBuilder`, import lazy `stream_processor.py:3017-3022`, DBSCAN) — **correction d'un faux "orphelin"** — **mais le graphe est siloté par machine** (persisté sous `workflows/{machine_id}/`) et le merge cross-session est machine-filtré. La progression **Shadow→Copilot→Autonomous** reste **du design, pas du runtime** (Shadow observe+log).
|
||||
|
||||
> ⚠️ **Caveat méthodo** : ce code utilise massivement des **imports lazy dans les handlers/méthodes**. Les verdicts "orphelin" basés sur grep d'imports top-level sont **non fiables** (federation ET GraphBuilder étaient ainsi faussement classés orphelins, puis confirmés WIRED). Tout "orphelin" ci-dessous non vérifié par lazy-import est à recontrôler.
|
||||
|
||||
→ Aujourd'hui, par défaut, **chaque poste est un silo cognitif** : il n'apprend pas des autres, sauf pour les compétences YAML et les embeddings centralisés.
|
||||
|
||||
## Tableau de synthèse (WIRED ? · COMMUN/SILOTÉ)
|
||||
|
||||
### A. Capture → construction → stockage (agent Explore #1)
|
||||
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|
||||
|---|---|---|---|
|
||||
| TraceStreamer (tag machine_id) | `agent_v1/network/streamer.py:91` | ✅ (main.py:227) | tague machine_id sur chaque POST |
|
||||
| register/stream/finalize | `server_v1/api_stream.py:1748/1801/2336` | ✅ endpoints | session taguée machine_id |
|
||||
| `_persist_workflow` | `server_v1/stream_processor.py:4417` (appelé 3066) | ✅ | **SILOTÉ** : écrit `data/training/workflows/{machine_id}/` + tag `_machine_id` |
|
||||
| Store disque sessions | `data/training/live_sessions/{machine_id}/` | ✅ | **SILOTÉ** (arbo 1:1 par machine) |
|
||||
| `_run_cross_session_learning` / `_find_best_cross_session_match` | `stream_processor.py:3149 / 3273` | ✅ (via finalize) | **SILOTÉ CODÉ** : `if workflow_machine != machine_id: continue` (L3284-3286) → un poste n'apprend jamais d'un autre |
|
||||
| Listing `list_workflows` | `api_stream.py:2799` + `stream_processor.py:4518` | ✅ | **BIMODAL** : `machine_id=None` → tous ; sinon filtré |
|
||||
| Client `list_workflows()` | `lea_ui/server_client.py:228` | ✅ (smart_tray:802) | **COMMUN** : n'envoie PAS machine_id → reçoit tous |
|
||||
| Dashboard list_sessions | `web_dashboard/app.py:2289` | ✅ | filtre disque par machine_id (optionnel) |
|
||||
| Replay ciblage | `api_stream.py:3064` + `replay_engine.py:1559` | ✅ | machine_id = **ROUTE** l'exécution vers le bon poste (légitime) |
|
||||
|
||||
### B. Apprentissage / cognition / compétences (agent Explore #2)
|
||||
| Module | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|
||||
|---|---|---|---|
|
||||
| `target_memory_store` (mémoire cibles, phase 1) | `core/learning/target_memory_store.py:77` | ✅ hot-path `resolve_engine.py:1869` | **SILOTÉ par machine** (`data/learning/target_memory.db` local, sauf `RPA_LEARNING_DIR` partagé) |
|
||||
| `continuous_learner` | `core/learning/continuous_learner.py` | ✅ (`stream_processor.py:3145`) | **SILOTÉ par session** |
|
||||
| `replay_learner` | `server_v1/replay_learner.py:90` | ✅ (`api_stream.py:2436`) | **SILOTÉ par session** |
|
||||
| `learning_manager` (états workflow VWB) | `core/learning/learning_manager.py:37` | ✅ singleton VWB | **COMMUN** |
|
||||
| **Compétences YAML** (catalog/replay/persist/verdicts/promotions) | `core/competences/*` | ✅ endpoints `api_stream` + dashboard | ✅ **COMMUN** (tous lisent `data/competences/`) — **c'est le vrai fonds commun qui marche** |
|
||||
| `observe_reason_act` (ORALoop) | `core/execution/observe_reason_act.py:145` | ✅ (VWB `api_v3/execute.py`) | siloté par exécution |
|
||||
| `feedback_processor`, `versioned_store`, `core/cognition/*`, `core/knowledge`, `core/coaching`, `core/healing`, `core/supervision` | — | ⚠️ **ORPHELINS** (tests seuls) | — |
|
||||
|
||||
### C. Graphe / embeddings / autonomie (agent Explore #3)
|
||||
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|
||||
|---|---|---|---|
|
||||
| ScreenState (perception) | `core/pipeline/workflow_pipeline.py` | ✅ serveur | COMMUN |
|
||||
| StateEmbedding + Builder (fusion 512d) | `core/models/state_embedding.py:44` / `core/embedding/state_embedding_builder.py:25` | ✅ `stream_processor` startup | **COMMUN** (vecteurs `data/training/embeddings/*.npy` centralisés) |
|
||||
| CLIP (OpenCLIP ViT-B-32) | `core/embedding/clip_embedder.py` | ✅ | COMMUN |
|
||||
| FAISSManager (similarité) | `core/embedding/faiss_manager.py:40` | ✅ `stream_processor` | **COMMUN serveur** mais **index per-session en mémoire** (pas un index global persistant) |
|
||||
| **WorkflowGraph builder (couche 4)** | `core/graph/graph_builder.py:148` | ✅ **WIRED** (import lazy `stream_processor.py:3017`, instancié `:3022` dans `finalize_session`, DBSCAN) — *corrigé : Agent #3 l'avait dit orphelin à tort* | **SILOTÉ** : graphe construit par session, persisté `workflows/{machine_id}/` ; merge cross-session machine-filtré |
|
||||
| WorkflowNode/Edge (modèles couche 4) | `core/models/workflow_graph.py:384` | ✅ utilisés par GraphBuilder | siloté (idem) |
|
||||
| **Shadow** observer | `core/workflow/shadow_observer.py:25` | ⚠️ partiel (`api_stream:2700` observe + LOG seulement) | pas d'apprentissage collectif |
|
||||
| **Copilot / Autonomous** | `core/learning/learning_engine.py` | ❌ **design papier**, pas runtime | — |
|
||||
| `audit_trail.execution_mode` (shadow/assisted/autonomous) | `server_v1/audit_trail.py:50` | ✅ enregistré | mais **pas exploité** pour décider |
|
||||
|
||||
### D. Fédération = LE fonds commun par design (vérif directe + Qwen en cours)
|
||||
| Composant | fichier:ligne | WIRED | COMMUN/SILOTÉ |
|
||||
|---|---|---|---|
|
||||
| `LearningPackExporter` | `core/federation/learning_pack.py` | 🌙 **endpoint manuel** `GET /api/v1/traces/stream/learning-pack/export` (`api_stream.py:6278`, import lazy L6292) | conçu commun |
|
||||
| `LearningPack` + `GlobalFAISSIndex` | `core/federation/learning_pack.py:294` / `faiss_global.py:51` | 🌙 **endpoint manuel** `POST .../learning-pack/import` (`api_stream.py:6323`, L6334-6353 `GlobalFAISSIndex()` ré-instancié) | conçu commun |
|
||||
| Déclenchement automatique | — | ❌ **AUCUN** : rien dans le flux capture→learn n'appelle export/import | → fonds commun **dormant** |
|
||||
|
||||
**Verdict fédération (Claude + Qwen)** :
|
||||
- ✅ **Bien architecturé** (Qwen) : `LearningPack` anonymise (machine_id blacklisté `_SENSITIVE_METADATA_KEYS:64`, `source_hash` SHA-256), `GlobalFAISSIndex` est **global** (clé `pack_source_hash`+`workflow_skeleton_id`+`node_name`+`app_name`, **aucun machine_id**), persistant (`.faiss`+`.meta.json`, `save/load` L245/277). Export prend **tous** les workflows (`processor._workflows.values()` L6305) sans filtre machine.
|
||||
- ❌ **Mais doublement inerte** : (a) **pas d'auto-déclenchement** (rien dans capture→learn n'appelle export/import) ; (b) **`search()` jamais appelé** — `_global_faiss_index` n'est référencé QU'aux lignes `api_stream.py:6351-6372` (instanciation + `add_pack` à l'import). Aucun chemin de résolution/apprentissage/replay ne **lit** l'index global. → **write-only, jamais consulté** : contribue 0 au runtime.
|
||||
|
||||
Conclusion : le fonds commun fédéré est **codé correctement mais débranché du runtime** — c'est exactement le maillon à activer pour la vision.
|
||||
|
||||
## Où `machine_id` cloisonne vs route
|
||||
- **ROUTE (légitime)** : ciblage d'un replay/d'une session vers le bon poste (`replay_engine.py:1559`, `start_replay`).
|
||||
- **CLOISONNE (à corriger vs vision)** : (1) dossiers de stockage `{machine_id}/` ; (2) **filtre dur du cross-session learning** (`stream_processor.py:3284`) ; (3) `target_memory.db` local par machine. + machine_id **instable** (nouvel ID à chaque relance) qui fragmente même au sein d'un poste.
|
||||
|
||||
## Gap vs vision « fonds commun → autonomie » (constats, pas décisions)
|
||||
Pour réaliser la vision, il manque le câblage de :
|
||||
1. **Dé-siloter le savoir workflows/sessions** : retirer le filtre machine_id du cross-session learning + stockage commun (ou index commun), en gardant machine_id pour le seul routing.
|
||||
2. **Activer la fédération en continu** (auto-export/import ou store partagé) au lieu du manuel dormant — c'est l'endroit conçu pour ça.
|
||||
3. **Câbler la couche graphe (4)** en live (aujourd'hui orpheline) pour un knowledge graph commun.
|
||||
4. **Implémenter Shadow→Copilot→Autonomous** (aujourd'hui observe+log / design) consommant ce fonds commun.
|
||||
5. **Stabiliser machine_id** (persisté) pour ne pas fragmenter.
|
||||
|
||||
## Ce qui marche DÉJÀ comme fonds commun (à capitaliser)
|
||||
- **Compétences YAML** (`core/competences/`) : micro-workflows réutilisables, états supervisés, **lus par tous les postes**. C'est le modèle commun qui fonctionne → piste à étendre.
|
||||
- **Embeddings centralisés** (`data/training/embeddings/`) : matière première commune déjà là.
|
||||
|
||||
## E. Pipeline server_v1 (cross-check Codex — intégré)
|
||||
- Pipeline **WIRED** : capture → `_worker_queue.txt` → `run_worker.py` → `StreamProcessor.reprocess_session()` → workflow JSON → replay → apprentissage. `api_stream.py` importe+instancie `ReplayLearner`/`StreamProcessor`/`StreamWorker` (`:32/40/41`, `:562-563`, startup `:1626-1629`).
|
||||
- **`ReplayLearner` = commun mais faiblement effectif** : `ActionOutcome` sans `machine_id`, stockage global `data/learning/replay_results/` ; MAIS `query_similar()` ne lit que le cache mémoire `_recent` (`:273-304`) et `build_replay_from_raw_events()` crée une **nouvelle instance** (`stream_processor.py:2379-2382`) → **l'historique JSONL global n'est pas exploité** après restart/hors instance globale.
|
||||
- **Risque ambiguïté** : la queue worker ne porte que `session_id`, pas `machine_id` (`api_stream.py:734-760`) → résolution disque ambiguë si 2 machines ont le même `session_id`.
|
||||
- `machine_id` **route** légitimement : `/replay`, `/replay/next` refusent les actions d'une autre machine (`:3978-3982`, `:4033-4054`), `_find_active_agent_session` filtre `machine_id`/`bg_<machine_id>` (`replay_engine.py:1559-1588`).
|
||||
|
||||
## Pistes de correction CONVERGENTES (constats Codex+Claude — NON décidées, mapping seulement)
|
||||
1. **Cantonner `machine_id` au routing/fleet/replay-target/audit** — pas au stockage ni au matching du savoir.
|
||||
2. **Dé-siloter les workflows appris** : sortir du stockage logique `workflows/{machine_id}/` (ou neutraliser ce marqueur en lecture/matching).
|
||||
3. **Retirer/rendre optionnel le filtre machine** dans `_run_cross_session_learning()` / `_find_best_cross_session_match()` (`stream_processor.py:3193-3203`, `3283-3286`) → apprendre sur tous les workflows compatibles.
|
||||
4. **Brancher le fonds commun fédéré** : (a) alimenter le `GlobalFAISSIndex` en continu (auto export/import ou store partagé) ; (b) **appeler son `search()`** dans le hot-path résolution/apprentissage (aujourd'hui jamais lu).
|
||||
5. **Rendre `ReplayLearner` durable** : charger/interroger l'historique JSONL global, réutiliser l'instance globale (pas une neuve par session).
|
||||
6. **Stabiliser `machine_id`** (persisté) pour ne pas fragmenter intra-poste.
|
||||
7. **Étendre le modèle "compétences communes"** (`core/competences/`, déjà commun + supervisé) comme colonne vertébrale du fonds commun.
|
||||
|
||||
---
|
||||
*Sources : graphify-out (58k nodes) ; agents Explore #1/#2/#3 (⚠ verdicts "orphelin" sur imports top-level corrigés par lazy-import) ; cross-checks **Codex** (server_v1, msg 16:43) & **Qwen** (federation/FAISS, msg 16:50) intégrés ; vérifs directes `api_stream.py:6271-6372`, `faiss_global.py:199`, `stream_processor.py:3017-3022`.*
|
||||
186
docs/CARTO_CODE_NON_BRANCHE_2026-07-02.md
Normal file
186
docs/CARTO_CODE_NON_BRANCHE_2026-07-02.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# CARTO CODE NON BRANCHÉ — carte de référence wiring (2026-07-02)
|
||||
|
||||
> **But** : carte « existing-first » de référence. AVANT tout chantier/bench/proposition,
|
||||
> consulter ce doc pour savoir si une brique existe et si elle est **réellement branchée au
|
||||
> runtime**. Recadrage Dom 02/07 : « vérifier ce qui existe et non branché, c'est le BABA ».
|
||||
>
|
||||
> **Méthode** : verdict prouvé par chaîne d'imports depuis un point d'entrée actif
|
||||
> (fichier:ligne), imports lazy inclus, gates de config citées. Jamais de conclusion sur un
|
||||
> grep seul. Sources fusionnées : agent Claude « intelligence » + carto Qwen (volet 1
|
||||
> détection) + `AUDIT_CODE_MORT_2026-07-02.md` (Qwen) + vérifs ponctuelles Claude.
|
||||
>
|
||||
> **Légende** : **WIRED** (chaîne prouvée) · **GATED** (branché mais derrière flag, défaut
|
||||
> cité) · **ORPHELIN** (0 appelant runtime, recherche exhaustive) · **INCERTAIN** (non tranché,
|
||||
> raison donnée).
|
||||
>
|
||||
> **Points d'entrée actifs runtime** : `api_stream.py` (streaming 5005, `rpa-streaming`) ·
|
||||
> `run_worker.py` (worker VLM 5099) · VWB `app.py` (5002) · `web_dashboard/app.py` (5001) ·
|
||||
> `agent_chat/app.py` (5004) · `server/api_upload.py` (8000).
|
||||
|
||||
---
|
||||
|
||||
## 0. Résumé exécutif — les découvertes qui changent une décision
|
||||
|
||||
1. **Self-healing = façade morte, malgré doc « wired ».** Chaîne d'import réelle (VWB →
|
||||
`core/healing`), routes REST répondent, MAIS déclenchement **impossible** : le code teste
|
||||
`hasattr(healing_integration, 'enable_healing')` et cette méthode **n'existe nulle part**
|
||||
(`execution_integration.py:421`). `handle_execution_failure` = 0 appelant d'exécution.
|
||||
Preuve d'inertie : `logs/healing/recovery.log` = **0 octet, mtime déc. 2025**. Le pont
|
||||
manquant tient à **une méthode**, pas un module. → `PLAN_MENAGE_CODE_MORT` le classait
|
||||
« wired — NE PAS TOUCHER » : **doc fausse**.
|
||||
|
||||
2. **`core/navigation` (commit du matin `f9a053132`) = write-only.** Le handler résout le
|
||||
login et écrit `navigate_login_coords` dans `replay_state["variables"]`, mais **aucun
|
||||
consommateur** : le compilateur `_edge_to_normalized_actions` n'a pas de branche `navigate`
|
||||
et produit des coords littérales, jamais de templates `{{navigate_login_coords.x_pct}}`.
|
||||
Détail : `docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md`. Décision D1 en attente Dom.
|
||||
|
||||
3. **AutonomousPlanner : coût sans usage.** Instancié au boot d'`agent_chat` (charge LLM +
|
||||
OWL detector via `autonomous_planner.py:36`), mais **aucune route n'appelle une méthode de
|
||||
planification** — seuls des setters. Type même du « code écrit jamais invoqué ».
|
||||
|
||||
4. **PaddleOCR installé, jamais importé.** `paddleocr 3.4.0` + `paddlepaddle 3.3.1` (CPU)
|
||||
présents dans `.venv`, **0 `import paddle` dans le code**, 0 requirements, 0 deploy. Piste
|
||||
bench en cours (Qwen), pas un composant actif.
|
||||
|
||||
5. **YOLO cascade de résolution = mort.** `_resolve_by_yolo` défini
|
||||
(`resolve_engine.py:458`) + importé (`api_stream.py:6114`) mais **jamais appelé** ; aucune
|
||||
branche `yolo` dans la cascade compilée. ⚠ À NE PAS confondre avec le YOLO de `som_engine`
|
||||
(OmniParser SoM), lui **WIRED**.
|
||||
|
||||
6. **`server/api_core.py`** : blueprint Flask complet (capture/detect/embed/faiss) **jamais
|
||||
enregistré** — orphelin absent du plan ménage.
|
||||
|
||||
7. **Nos propres cartos avaient 4 erreurs** (cf. §4). Re-prouver était justifié.
|
||||
|
||||
---
|
||||
|
||||
## 1. Chaîne détection / grounding / résolution
|
||||
|
||||
| Module | Verdict | Preuve (fichier:ligne) | Remarque |
|
||||
|--------|---------|------------------------|----------|
|
||||
| `core/detection/som_engine.py` | **WIRED** | resolve_engine.py:1192 (replay) · stream_processor.py:643 (recording) · api_stream.py:1958 (temps réel) | 3 chemins indépendants, singleton thread-safe. Tire YOLO weights direct. |
|
||||
| `core/detection/omniparser_adapter.py` | **B-DORMANT** (branché lazy, fallback vide) | phase25_analyzer.py:388 · resolve_engine.py:437 · désactivé côté VWB (`_omniparser_available=False`) | Import lazy try/except, singleton. 7 zones cartographiées (§ audit Qwen). |
|
||||
| `core/detection/owl_detector.py` | **WIRED (via AutonomousPlanner) — mais planner inerte** | autonomous_planner.py:36 | Chargé au boot agent_chat pour rien (cf. §0.3). 4 méthodes internes C-MORT. |
|
||||
| `core/detection/ollama_client.py` | **WIRED partiel** | `classify_element_complete()` actif ; 5 vieilles méthodes + `check_ollama_available()` standalone = C-MORT | Duplicat VWB (D2). |
|
||||
| `_resolve_by_yolo` (resolve_engine.py:458) | **ORPHELIN** | importé api_stream.py:6114, **0 appel réel**, 0 branche cascade | ≠ YOLO de som_engine (wired). |
|
||||
| `core/grounding/bbox_parser.py` | **WIRED** | resolve_engine.py:29 | |
|
||||
| `core/grounding/smart_resize.py` | **ORPHELIN (C-MORT)** | 0 appelant prod, DETTE-007 triple impl (2 autres existent) | |
|
||||
| `core/grounding/server.py` | **WIRED** | service HTTP Flask port 8200 standalone | Upgrade C→A (Qwen). |
|
||||
| `visual_workflow_builder/.../api/ui_detection.py` | **WIRED** | VWB app.py:310 (blueprint) · fast_detector.py:117 | UI-DETR-1 du recording, modèle rfdetr RFDETRMedium, 5 endpoints `/api/ui-detection`. |
|
||||
| `core/semantic/phase25_analyzer.py` | **WIRED** | api_stream.py:7690 (route `lea_competence_persist:7435`) | |
|
||||
| `core/extraction/{field_extractor,vlm_client,role_mapper}` | **WIRED-transitif** | field_extractor ← input_handler.py:121/504/722 (lazy) · vlm_client+role_mapper ← core/navigation/__init__.py:69, action_resolver.py:109 | Le plan ménage 23/06 (« 4/5 morts ») précède navigation. |
|
||||
| `core/llm/` (ocr_extractor, extract_grid) | **WIRED** | api_stream.py:1766 · replay_engine.py:2115-2403 · resolve_engine.py:2597 | |
|
||||
| `core/navigation/` | **WIRED (boot) / write-only (fonctionnel)** | api_stream.py:440 top-level NON gardé · handler résout mais 0 consommateur coords | cf. §0.2. ⚠ import non gardé → si casse, 5005 ne boote pas (garde-fou test_navigate_wiring.py). |
|
||||
| PaddleOCR (venv) | **ORPHELIN** | 0 import, 0 requirements, 0 deploy | cf. §0.4. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Modules « intelligence »
|
||||
|
||||
| Module | Verdict | Preuve (fichier:ligne) | Remarque |
|
||||
|--------|---------|------------------------|----------|
|
||||
| `core/healing/` | **ORPHELIN de fait** (importé, indéclenchable) | chaîne VWB app.py:217 → api/self_healing.py → services/self_healing_integration.py, MAIS `enable_healing` inexistant (execution_integration.py:421) ; `handle_execution_failure` 0 appelant | cf. §0.1. `logs/healing/recovery.log` vide depuis déc. 2025. |
|
||||
| `core/coaching/` | **WIRED** | VWB app.py:284-285 (blueprint) → api/coaching_sessions.py:17,22 · exec : execution_integration.py:869 · front WebSocket | REST blueprint peut-être non consommé par l'UI (front = socket.io). |
|
||||
| `core/cognition/working_memory` | **WIRED-transitif** | observe_reason_act.py:30,506 · ORALoop ← VWB execute.py:1542,2075 | Les 4 autres sous-modules cognition = MORTS (tests only). |
|
||||
| `core/learning/` (4/5) | **WIRED** | target_memory_store: resolve_engine.py:1865 + api_stream.py:5132 · continuous_learner: stream_processor.py:3147 · learning_manager: VWB learning_integration.py:36, api/workflows.py:696 · feedback_processor: execution_loop.py:317 | `versioned_store` ORPHELIN. `record_observation` = **0 appelant** (learning_manager.py:54). |
|
||||
| `core/execution/` | **WIRED massif** | observe_reason_act ← execute.py:1542 · input_handler ← execute.py:69 · dag_executor+llm_actions ← dag_execute.py:33,40 · action_executor/target_resolver/error_handler/execution_loop ← agent_chat app.py:328-340 · +transitifs | Morts : spatial_index, target_memory, workflow_runner (⚠ encore exporté par `__init__.py:10`). |
|
||||
| `core/auth/` | **GATED — défaut OFF** | api_stream.py:278-286 : import lazy SSI `RPA_AUTH_VAULT_PATH` **et** `RPA_AUTH_VAULT_PASSWORD` définis (absents par défaut). Seul lieu qui les définit : CI `.gitea/workflows/tests.yml:35` | Vault inactif en prod. TOTP dans la même chaîne gated. |
|
||||
| `core/federation/` | **WIRED manuel, write-only** | routes actives non gated : GET learning-pack/export api_stream.py:6431 · POST import :6476 | `GlobalFAISSIndex.search()` = **0 appelant**. Aucun auto-déclenchement. |
|
||||
| `core/gpu/` (2/6) | **WIRED** | device_policy ← resolve_engine.py:1750 (hot-path) · gpu_resource_manager ← agent_chat app.py:53,266 | clip_manager, ollama_manager, vram_monitor, preflight = morts. |
|
||||
| `core/embedding/` | **WIRED (lazy)** | construction CLIP/FAISS ← stream_processor.py:2560 `_ensure_initialized` (appelé process_screenshot:2804 + finalize_session:2969) · lecture web_dashboard app.py:309+ | Se déclenche au 1er screenshot / finalisation, pas au boot. |
|
||||
| `agent_chat/autonomous_planner` | **INSTANCIÉ mais INERTE** | import app.py:48, instancié :358, mais seuls appels = setters :362,367 ; 0 route de planification | cf. §0.3. Tire owl_detector pour rien. |
|
||||
| `agent_chat/urgences_orchestrator` | **WIRED** | import lazy app.py:2740, routes `/api/urgences/*` | |
|
||||
| `agent_chat/gesture_catalog` | **WIRED ×2** | agent_chat app.py:377,955 · **api_stream.py:269,3598** (hot-path replay `optimize_replay_actions`) | Pas seulement le chat. |
|
||||
| `core/validation/` | **GATED — défaut OFF** | flag `RPA_VALIDATOR_V2_ENABLED` défaut OFF (api_stream.py:91), consommé report_action_result:4924 | |
|
||||
|
||||
**WIRED confirmés (survol)** : capture, models, competences, corrections, data, graph,
|
||||
knowledge, monitoring, persistence, pipeline, system, workflow, visual, config.py,
|
||||
anonymisation (PII), matching/training (transitifs).
|
||||
**ORPHELINS confirmés** : variants, precision, supervision, interfaces (0 importeur non-test) ·
|
||||
`core/evaluation/` (consommé seulement par `tools/lea_bench*.py`, outillage CLI) ·
|
||||
`server/api_core.py` (blueprint jamais enregistré).
|
||||
|
||||
---
|
||||
|
||||
## 3. Zones GATED (flags + défaut) — activation supervisée
|
||||
|
||||
| Flag | Défaut | Effet si ON | Preuve |
|
||||
|------|--------|-------------|--------|
|
||||
| `RPA_AUTH_VAULT_PATH` + `RPA_AUTH_VAULT_PASSWORD` | absents | active `core/auth` (vault Fernet + TOTP) | api_stream.py:278-286 |
|
||||
| `RPA_VALIDATOR_V2_ENABLED` | OFF | active validation V2 (report_action_result) | api_stream.py:91 |
|
||||
| `RPA_R1_AUTO_IMPORT` | OFF | active import auto core→DB VWB (R1) | api_stream.py:~4480 (revue en cours) |
|
||||
| `RPA_AUTO_UPDATE_ENABLED` | OFF | MAJ silencieuse client (DETTE-022) | agent_v1/config.py:103 |
|
||||
| `RPA_GROUNDING_ENGINE=qwen3vl_vllm` | legacy Qwen2.5-VL | grounder Qwen3-VL (override DGX runtime) | resolve_engine.py:1001-1007 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Divergences corrigées avec les docs existants
|
||||
|
||||
1. **`core/healing` : doc `PLAN_MENAGE` = « wired, NE PAS TOUCHER » → FAUX.** Indéclenchable
|
||||
(`enable_healing` fantôme, log vide déc. 2025). Le pont est à une méthode près.
|
||||
2. **`feedback_processor` : CARTO 16/06 = ORPHELIN → FAUX.** Instancié à chaque ExecutionLoop
|
||||
(execution_loop.py:317).
|
||||
3. **`core/cognition` : CARTO 16/06 = tout orphelin → FAUX pour working_memory** (vivant au
|
||||
runtime VWB via observe_reason_act).
|
||||
4. **`core/extraction` : plan ménage « 4/5 morts » → périmé.** vlm_client + role_mapper
|
||||
branchés via `core/navigation` (postérieur au doc).
|
||||
|
||||
Upgrades C→A/B confirmés par Qwen : autonomous_planner (C→A, mais inerte cf. §0.3),
|
||||
seeclick_adapter (C→B), grounding/server.py (C→A), get_grounding_profile (C→A).
|
||||
|
||||
---
|
||||
|
||||
## 5. Code mort candidat suppression → voir `AUDIT_CODE_MORT_2026-07-02.md`
|
||||
|
||||
Résumé : **8 C-MORT** (~843 lignes, ex. deploy_windows.py, smart_resize.py, 7 config classes
|
||||
dépréciées, agent_chat 410 endpoints) · **5 B-ORPHELIN** (à conserver, projections) · **4
|
||||
duplicats** (décision Dom). Suppression = GO Dom par lot, worktree isolé + tests après chaque
|
||||
lot. ⚠ Prudence renforcée vu les 4 erreurs de doc du §4 : re-prouver chaque item avant
|
||||
suppression.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cascade de résolution UI (`resolve_engine.py`) — ordre RÉEL prouvé
|
||||
|
||||
Point d'entrée unique au replay : le client Léa (`executor.py:2847`, **`strict_mode=True` hardcodé**
|
||||
:2870) → route `resolve_target` (`api_stream.py:6131`) → `_resolve_target_sync`
|
||||
(`resolve_engine.py:1804`). `replay_engine.py` ne résout pas (il construit le target_spec).
|
||||
|
||||
**Ordre réel au replay (mode strict VLM-first, `resolve_engine.py:1957`)** :
|
||||
```
|
||||
0. Mémoire persistante (replay_memory.memory_lookup:1869) — hit → skip toute la cascade
|
||||
0c. dialog_button → OCR seul (1920-1952)
|
||||
── strict VLM-first (1957) ──
|
||||
S0a. Grounding VLM (_resolve_by_grounding:2019) si by_text_source ∈ {ocr, vlm}
|
||||
S0b. Template matching icônes (2057) sinon
|
||||
S0.5 OCR direct (_resolve_by_ocr_text:2105) si by_text
|
||||
S1. VLM Quick Find (_vlm_quick_find:2158)
|
||||
S1.5 SoM + VLM (_resolve_by_som:2207)
|
||||
S2. Template matching fallback (2238)
|
||||
S3. STOP replay resolved=False (2283)
|
||||
```
|
||||
Note : grounding VLM (S0a) et VLM Quick Find (S1) sont **deux appels VLM distincts**.
|
||||
|
||||
**Statut resolvers** : `_resolve_by_grounding`, `_resolve_by_template_matching`,
|
||||
`_resolve_by_ocr_text`, `_vlm_quick_find`, `_resolve_by_som`, `replay_memory` = **WIRED**
|
||||
(preuves lignes ci-dessus). Grounder Qwen3-VL : bascule dans `_resolve_by_grounding:1006`
|
||||
(flag `RPA_GROUNDING_ENGINE=qwen3vl_vllm`, sinon legacy Qwen2.5-VL) — change modèle/endpoint/
|
||||
prompt/parser, pas le flux.
|
||||
|
||||
**3 branches MORTES dans la cascade** :
|
||||
- `_resolve_by_yolo` (:458) — importé api_stream.py:6114, **0 appel réel**. ORPHELIN.
|
||||
- **Vérification CLIP** (:1972-2008) — **dead gate** : lit `target_spec["clip_embedding"]`
|
||||
qui n'est **jamais peuplé** dans tout `agent_v0/` → branche jamais exécutée.
|
||||
- **V4 pré-compilé** (`_resolve_with_precompiled_order:1601`, ordre figé `["ocr","template",
|
||||
"vlm"]`) — **WIRED mais dormant en replay normal** : alimenté uniquement par l'endpoint
|
||||
`/replay/plan` (`execution_plan_runner.py:173`), jamais par le flux VWB→Léa.
|
||||
|
||||
**Verdict README « OCR→template→YOLO→VLM » = FAUX** : (1) YOLO mort, (2) l'ordre est
|
||||
VLM-first, (3) la séquence `ocr,template,vlm` n'existe que dans le V4 dormant.
|
||||
|
||||
## 7. Zones restantes non re-vérifiées (honnêteté)
|
||||
|
||||
- `core/analytics/` : ~13 sous-modules orphelins non re-vérifiés un par un (conforme doc).
|
||||
- Reste couvert : chaîne détection/grounding/résolution + intelligence = prouvés. **Carto
|
||||
considérée complète sur le périmètre runtime actif.**
|
||||
145
docs/CHECKLIST_DGX_PRE_CLINIQUE.md
Normal file
145
docs/CHECKLIST_DGX_PRE_CLINIQUE.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# CHECKLIST DGX — Contrôle avant installation clinique
|
||||
|
||||
- `Auteur`: Qwen
|
||||
- `Date`: 2026-06-19
|
||||
- `Version`: v1 — à vérifier point par point avant déploiement site clinique
|
||||
|
||||
---
|
||||
|
||||
## 1. SERVICES — Tous démarrent au reboot
|
||||
|
||||
| # | Service | Port | Statut attendu | Check |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | rpa-streaming | 5005 | `health=200` | `curl http://127.0.0.1:5005/health` |
|
||||
| 1.2 | rpa-vision-v3-dashboard | 5001 | `401 sans creds, 200 avec creds` | `curl -u lea:<password> http://127.0.0.1:5001/api/system/status` |
|
||||
| 1.3 | rpa-vision-v3-vwb-backend | 5002 | `401 LAN, 200 loopback` | `curl http://127.0.0.1:5002/health` puis `curl http://192.168.x.x:5002/health` |
|
||||
| 1.4 | rpa-agent-chat | 5004 | `200` | `curl http://127.0.0.1:5004/api/status` |
|
||||
| 1.5 | rpa-vision-v3-api | 8000 | `fermé LAN` | `curl http://192.168.x.x:8000` → timeout/refused |
|
||||
| 1.6 | rpa-vision-v3-vwb-frontend | 3002 | `200` | `curl http://127.0.0.1:3002` |
|
||||
| 1.7 | rpa-vision-v3-stream-worker | 5099 | `running` | `systemctl status rpa-vision-v3-stream-worker` |
|
||||
| 1.8 | rpa-vision-v3-worker | — | `running` | `systemctl status rpa-vision-v3-worker` |
|
||||
| 1.9 | rpa-firewall | — | `active (exited)` | `systemctl status rpa-firewall` |
|
||||
| 1.10 | Dashboard systemd | 5001 | **service system ACTIF** (pas fallback user) | ✅ **VALIDÉ reboot 20/06** — system service active, fallback user masked |
|
||||
|
||||
**Check reboot** : `systemctl list-units --type=service | grep rpa` → tous `active running` ou `active exited`
|
||||
|
||||
---
|
||||
|
||||
## 2. RÉSEAU — Ports sensibles fermés LAN
|
||||
|
||||
| # | Port | Risque | Statut attendu | Check |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | 5900 (VNC GNOME) | Remote desktop | **LAN fermé, loopback OK** | `nmap 192.168.x.x -p 5900` → filtered/closed |
|
||||
| 2.2 | 5902 (VNC VM Windows) | Remote desktop VM | **LAN fermé, tunnel SSH only** | `nmap 192.168.x.x -p 5902` → filtered/closed |
|
||||
| 2.3 | 3389 (RDP/xrdp) | Remote desktop | **LAN fermé** | `nmap 192.168.x.x -p 3389` → filtered/closed |
|
||||
| 2.4 | 22220 (SSH VM Windows) | Shell VM | **LAN fermé** | `nmap 192.168.x.x -p 22220` → filtered/closed |
|
||||
| 2.5 | 8000 (API upload) | API non protégé | **LAN fermé** | `nmap 192.168.x.x -p 8000` → filtered/closed |
|
||||
| 2.6 | 11434 (Ollama) | Modèles IA | **LAN fermé** | `nmap 192.168.x.x -p 11434` → filtered/closed |
|
||||
| 2.7 | 5002 (VWB backend) | Données workflows | **LAN : auth requise (401)** | `curl http://192.168.x.x:5002/api/workflows/` → 401 |
|
||||
| 2.8 | 5004 (Agent chat) | Chat interface | **À arbitrer** — ouvert ou fermé ? | Décision Dom |
|
||||
| 2.9 | 3002 (VWB frontend) | Interface web | **À arbitre** — ouvert ou fermé ? | Décision Dom |
|
||||
|
||||
---
|
||||
|
||||
## 3. SÉCURITÉ — Authentification + accès
|
||||
|
||||
| # | Item | Statut attendu | Check |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Dashboard Basic Auth | `401 sans creds` | `curl http://192.168.x.x:5001/api/system/status` → 401 |
|
||||
| 3.2 | VWB Basic Auth | `401 LAN, 200 loopback` | Vérifié ✅ (commit cf81ce4c7) |
|
||||
| 3.3 | Streaming Bearer Auth | `401 sans token` | `curl http://127.0.0.1:5005/api/v1/...` → 401 |
|
||||
| 3.4 | SSH clé uniquement | Pas de password login | `grep PasswordAuthentication /etc/ssh/sshd_config` → no |
|
||||
| 3.5 | Firewall persistant reboot | Ports fermés après reboot | ✅ **VALIDÉ reboot 20/06** — ports sensibles filtrés, services ouverts OK |
|
||||
| 3.6 | RPA_SIGNING_KEY défini | FAISS metadata valide | ⚠️ **À FIXER** — HMAC mismatch, Option A en attente |
|
||||
|
||||
---
|
||||
|
||||
## 4. VM WINDOWS — Autostart + stabilité
|
||||
|
||||
| # | Item | Statut attendu | Check |
|
||||
|---|---|---|---|
|
||||
| 4.1 | VM boot auto au reboot DGX | Service systemd user `aivanov` | ✅ **VALIDÉ reboot 20/06** — `win11-arm-lea.service` auto-démarre, linger=yes |
|
||||
| 4.2 | VM accessible VNC | Tunnel SSH `localhost:5902` | Vérifié ✅ |
|
||||
| 4.3 | VM ne pas libvirt en parallèle | Pas de conflit disk.qcow2 owner | ⚠️ **À DOCUMENTER** — ne pas lancer libvirt VM |
|
||||
| 4.4 | disk.qcow2 owner = aivanov | Pas libvirt-qemu | `ls -la disk.qcow2` → aivanov:aivanov |
|
||||
| 4.5 | swtpm lancé par script | Pas manuel | Script standalone gère swtpm ✅ |
|
||||
| 4.6 | Léa config.txt pointe DGX | Pas cloud URL | `cat config.txt` → DGX IP |
|
||||
|
||||
---
|
||||
|
||||
## 5. DONNÉES — Persistence + integrity
|
||||
|
||||
| # | Item | Risque | Statut attendu | Check |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | workflows.db | 24 workflows live | `curl -u lea:<pw> http://127.0.0.1:5001/api/workflows | jq '.total'` → 24 |
|
||||
| 5.2 | FAISS index | 13666 vectors | `curl ... /api/knowledge-base/stats | jq '.vectors_indexed'` → 13666 |
|
||||
| 5.3 | FAISS metadata HMAC | Test endpoint 200 | ⚠️ **À FIXER** — Option A (resigner) |
|
||||
| 5.4 | Sessions training | Non trackées git → safe au reset | `ls data/training/sessions/` |
|
||||
| 5.5 | Git aligné | HEAD = dernier commit P0 | `git log -1` → cf81ce4c7 |
|
||||
| 5.6 | workflows.db préservé au git reset | Backup avant reset | ⚠️ **Procédure à respecter** |
|
||||
|
||||
---
|
||||
|
||||
## 6. STABILITÉ — Test reboot (✅ exécuté en réel le 2026-06-20)
|
||||
|
||||
| # | Item | Check | Résultat | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| 6.1 | Reboot DGX | Coupure secteur 02:07 | 9 services reviennent | ✅ PASS |
|
||||
| 6.2 | VM Windows auto-start | `win11-arm-lea.service` | VM auto-démarre | ✅ PASS |
|
||||
| 6.3 | Firewall persisté | Ports après reboot | Sensibles filtrés, services ouverts | ✅ PASS |
|
||||
| 6.4 | Dashboard systemd | Après reboot | System service actif, user fallback masked | ✅ PASS |
|
||||
| 6.5 | Worker healthy | Après reboot | PID 2267 actif, last_cycle continu | ✅ PASS |
|
||||
| 6.6 | **IP DHCP dérive** | `.45` → `.46` | IP statique `.45` appliquée (Dom) | ⚠️ **G1 — IP statique obligatoire clinique** |
|
||||
| 6.7 | **OVMF corruption VM** | Coupure brutale | OVMF corrompu, récupération manuelle (Codex) | ⚠️ **G2 — auto-réparation OVMF à implémenter** |
|
||||
| 6.8 | **Léa guest reconnecte** | config.txt | CONFIGURE_ME, pas DGX | ⚠️ **G4 — config.txt à renseigner** |
|
||||
|
||||
---
|
||||
|
||||
## 7. PRÉ-REQUIS DSI (envoyés à Nicolas PORQUET)
|
||||
|
||||
| # | Item | Statut | Check |
|
||||
|---|---|---|---|
|
||||
| 7.1 | Proxy HTTPS | À installer clinique | Architecture validée |
|
||||
| 7.2 | Docker | À installer | — |
|
||||
| 7.3 | VLAN isolation | À configurer | — |
|
||||
| 7.4 | SSH clé uniquement | ✅ Configuré DGX | `PasswordAuthentication no` |
|
||||
| 7.5 | 100% on-premise | ✅ Aucune cloud call | Vérifier config Léa |
|
||||
| 7.6 | Pas de secrets exposés | ✅ .env.local permissions | `ls -la .env.local` → 600 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ITEMS À FIXER AVANT CLINIQUE
|
||||
|
||||
1. **Dashboard fallback user** → ✅ **FIXÉ 20/06** (mask persistant, system service actif)
|
||||
2. **Auto-start VM** → ✅ **VALIDÉ 20/06** (reboot réel prouvé)
|
||||
3. **FAISS Option A** → ✅ **FIXÉ 19/06** (metadata resigné, 13666 vectors, test success=true)
|
||||
4. **Git DGX aligné** : DGX sur ec1fb81, cible cf81ce4c7 → aligner avec backup workflows.db
|
||||
5. **Test reboot** → ✅ **exécuté en réel 20/06** (5 PASS, 3 gaps identifiés)
|
||||
6. **G1 Dérive IP DHCP** : IP statique labo `.45` OK ; clinique = Ethernet `.178` obligatoire
|
||||
7. **G2 Auto-réparation OVMF** : snapshot sain au boot + restauration auto si TianoCode loop → **À IMPLÉMENTER**
|
||||
8. **G4 Léa reprise auto** : config.txt persistant DGX + token + auto-login → **À RENSEIGNER**
|
||||
|
||||
---
|
||||
|
||||
## Commandes smoke rapide (à lancer sur DGX)
|
||||
|
||||
```bash
|
||||
# Services
|
||||
systemctl list-units --type=service | grep rpa
|
||||
|
||||
# Health endpoints
|
||||
curl -s http://127.0.0.1:5002/health
|
||||
curl -s http://127.0.0.1:5005/health
|
||||
curl -s -u lea:v_zhmqOpGYcR-t7xJFKZyW-LjpvBuOOKss0ZleyH4jQ http://127.0.0.1:5001/api/system/status | jq '{workflows_count,status}'
|
||||
curl -s -H "Authorization: Bearer o3_LHqV_7_Gc6OVPHndhsBbvG6HJ5PCgl8yIBhGUIz8" http://127.0.0.1:5005/api/v1/traces/stream/processing/status | jq '{status,processing_ready}'
|
||||
|
||||
# Firewall LAN
|
||||
nmap 192.168.1.45 -p 5900,5902,3389,22220,8000,11434
|
||||
|
||||
# VM
|
||||
virsh -c qemu:///system list # doit être VIDE (standalone, pas libvirt)
|
||||
ps aux | grep qemu-system-aarch64 | grep win11
|
||||
|
||||
# Git
|
||||
cd ~/ai/rpa_vision_v3 && git log -1 --oneline
|
||||
```
|
||||
87
docs/DECISIONS_PRODUIT_EN_ATTENTE_2026-06-23.md
Normal file
87
docs/DECISIONS_PRODUIT_EN_ATTENTE_2026-06-23.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Décisions produit en attente — à trancher à tête reposée
|
||||
|
||||
- `Date`: 2026-06-23
|
||||
- `Auteur`: Claude (questions) / Dom (réponses)
|
||||
- `Usage`: Dom remplit la ligne **Réponse Dom** quand il a la tête au calme. Chaque décision débloque un ou plusieurs chantiers du plan `PLAN_ACTION_SUITE_2026-06-23.md`. Segmentation par **fonctionnalité (F#)**.
|
||||
|
||||
> Contexte : H3 (cœur produit) traité **en premier**, décisions d'abord puis exécution séquencée (validé Dom 23/06). On ne touche pas l'archi rejeu pendant la livraison clinique du jour.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Déjà tranché (23/06)
|
||||
|
||||
| Réf | Décision | Réponse Dom |
|
||||
|---|---|---|
|
||||
| **PROC-1** | « H3 en premier » = trancher les décisions produit d'abord, exécution séquencée ensuite | ✅ OK |
|
||||
| **F6-1** | Niveau de mutualisation du fonds appris | ✅ **Cross-clinique ET intra-clinique** → la fédération anonymisée (cross) **est dans le périmètre**, + lever le silo `machine_id` entre postes d'une même clinique |
|
||||
| **F11-1** | Accès distant | ✅ **Multi-VPN par site** (WireGuard=nôtre, Stormshield=clinique, autres à venir) ; SSH cert + RDP = transport commun |
|
||||
| **F9-1** | Source de vérité workflows | ✅ **DB SQLAlchemy = vérité, JSON = échange** ; métrique produit = rejouables validés ; migration JSON→DB séquencée post-clinique (23/06) |
|
||||
| **F1-1** | Critère de fusion workflows | ✅ **Signature de trajectoire** (hash de la séquence d'actions/écrans) → débloque U-A create-or-update (23/06) |
|
||||
| **F2-1 / F14-1** | Principe rejeu intelligent | ✅ **Oui, prérequis** — le rejeu consulte le fonds appris (TargetMemory + FAISS anchors), pas de coords figées ; cohérent F6 cross-clinique (23/06) |
|
||||
|
||||
---
|
||||
|
||||
## ⏳ À trancher
|
||||
|
||||
> ✅ **Tranchés 23/06** (voir §Déjà tranché) : Q-F9-1 (DB), Q-F1-1 (signature trajectoire), Q-F2-1 + Q-F14-1 (rejeu intelligent = prérequis). **Restent** : Q-F2-2 (point d'entrée Léa — Claude trace en read-only), et les items **parkés** : Q-F8-* (natif/sécu mis de côté momentanément), Q-F11-2 (VPN).
|
||||
|
||||
### F2 — Rejeu intelligent (le plus urgent)
|
||||
|
||||
**Q-F2-1 — Principe « rejeu intelligent ».** Valides-tu que le rejeu **doit consulter le fonds appris** (TargetMemoryStore + FAISS anchors) au lieu de rejouer des coordonnées figées ? *(Vérif runtime 23/06 : aujourd'hui le rejeu est « brut », il ne consulte rien.)* C'est la décision qui change l'archi du replay direct (chantiers R2/R3).
|
||||
> ⚠️ **Quasi-entraînée par ta décision F6 = cross-clinique** : un pack fédéré anonymisé n'a ni coords ni templates → la re-résolution visuelle (anchors/FAISS) devient le seul moyen de rejouer ailleurs. Voir Q-F14-1.
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
**Q-F2-2 — Provider Léa au runtime.** Quel modèle/route sert la **résolution** pendant le rejeu ? (impacte l'import auto R1 + la cascade de résolution). Options connues : Qwen3-VL-4B/vLLM (grounder prod), gemma4 (cerveau/lecteur), autre.
|
||||
> **Tracé Claude 23/06** (read-only) : point d'entrée runtime = **agent_chat (5004)** → `SemanticMatcher.find_workflow()` (`agent_chat/app.py:906`) sur `/api/chat`. Sélection sur **fichiers JSON** (`data/workflows/`, `data/training/workflows/`, `…/live_sessions/workflows/`), **pas la DB** → ⚠️ **gap avec Q-F9-1 (DB=vérité)** : la sélection runtime devra migrer sur la DB. **Pas de filtre machine_id** à la sélection. `api_stream` (5005) = présent mais pas le chemin chat actif. Modèle de résolution = Qwen3-VL-4B grounder + gemma4 (bench 13/06).
|
||||
|
||||
### F1 / F9 — Apprentissage & workflows
|
||||
|
||||
**Q-F1-1 — Critère de fusion des workflows** *(tu as dit « on verra à tête reposée »)*. Quand deux sessions apprises sont-elles « le même » workflow (→ create-or-update plutôt que doublon) ? Par signature d'écran de départ ? nom ? séquence d'actions ? application cible ?
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
**Q-F9-1 — Source de vérité workflows + métrique produit.** → **Ma reco ci-dessous (§Reco F9-1)**, à valider ou amender.
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
### F14 — Unification Léa ↔ VWB (anchors)
|
||||
|
||||
**Q-F14-1 — Re-exécutabilité des packs fédérés (décision induite).** Un pack cross-clinique anonymisé n'emporte **ni coordonnées ni templates** (PII) → re-résolution **visuelle** obligatoire au rejeu (anchors/FAISS). ⇒ acte-t-on que **le « rejeu intelligent » (Q-F2-1) devient un prérequis** (pas une option) dès lors que F6 = cross-clinique ?
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
*(Le sous-chantier U-B « propagation des anchors aux substeps compound + ré-import » ne demande aucune décision produit — c'est un fix prêt, à exécuter post-stabilisation sous GO. Idem U-D « asymétrie grounding UI-DETR-1 vs cascade » = à trancher plus tard, sujet ouvert post-démo.)*
|
||||
|
||||
### F8 — Exécution native agentique (zéro-shot)
|
||||
|
||||
*Constat 23/06 : briques présentes et wirées (boucle ORA `/execute/instruction`, planner NL→plan gemma4), mais mode « free » peu mûr, **sans sandbox ni validation humaine**, exécution directe sur l'host.*
|
||||
|
||||
**Q-F8-1 — Périmètre du mode natif.** Desktop/navigateur **généraliste** (« ouvre YouTube ») ou **borné aux apps métier** (Easily…) ? (impacte le risque et le choix moteur)
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
**Q-F8-2 — Surface d'exécution / sandbox (sécurité).** Acte-t-on que le mode free ne tourne **QUE dans un sandbox** (VM/Xvfb + kill-switch + pause humaine), **jamais l'host** — prérequis avant tout élargissement ? (= la décision CUA P1)
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
**Q-F8-3 — Moteur agentique.** Garder ORA + gemma4/Qwen3-VL (100 % local) ou évaluer un agent computer-use dédié (UI-TARS mode agent, Qwen3-VL agent…) ? → un mini-état-de-l'art web peut éclairer (les modèles évoluent vite).
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
**Q-F8-4 — Niveau d'autonomie.** Validation humaine step-by-step au début (Copilot) puis desserrage progressif (cohérent F7 Shadow→Copilot→Autonomous + safety) ?
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
### F11 — Accès distant multi-VPN
|
||||
|
||||
**Q-F11-2 — Abstraction « fiche site → accès ».** Quels **types de site/VPN** anticiper au-delà de Stormshield + WireGuard ? Faut-il coder dès maintenant une abstraction générique « fiche site → méthode d'accès », ou gérer les 2 cas connus d'abord et généraliser plus tard ?
|
||||
> **Réponse Dom :** _____
|
||||
|
||||
---
|
||||
|
||||
## 💡 Reco Claude — Q-F9-1 (source de vérité workflows)
|
||||
|
||||
**Recommandation : la DB (SQLAlchemy/SQLite `workflows.db`) = source de vérité unique ; le JSON = format d'échange/export uniquement (packs fédération, portabilité, import VWB), PAS un 2ᵉ magasin.**
|
||||
|
||||
**Pourquoi la DB :**
|
||||
- Les workflows sont **relationnels** (workflow ↔ steps ↔ substeps ↔ anchors ↔ session/machine) : intégrité référentielle, requêtes, versionnement.
|
||||
- Le **create-or-update à la fusion** (Q-F1-1) = un upsert atomique trivial en SQL, pénible et risqué sur des fichiers JSON.
|
||||
- **Concurrence** : 5 Léa qui apprennent/s'enrôlent en parallèle (test de charge) → JSON + chemins relatifs au cwd = races + fragilité du symlink (DETTE-015 déjà constatée).
|
||||
- **F6 fédération** a justement besoin de DB-comme-vérité + JSON-comme-échange : un `LearningPack` = sérialisation d'une requête DB → pack JSON ; l'import = désérialisation → DB. JSON-comme-vérité **entre en conflit** avec ça.
|
||||
|
||||
**Nuance de timing :** la **migration** JSON→DB est un refactor persistant → **séquencée post-clinique** (après stabilisation + merge), comme le prévoit déjà `PLAN_MIGRATION_WORKFLOWS_STORE`. D'ici là le symlink reste, dette connue, **on n'y touche pas pendant la livraison**.
|
||||
|
||||
**Métrique produit (24/79/37) :** la seule défendable face à un client/DSI = **les workflows rejouables validés** (≈ les **24** VWB aujourd'hui, ce que Léa exécute vraiment de bout en bout). Les **79** (catalogue agent-chat) et **37** (KB/FAISS) = compteurs **internes**, à ne pas exposer comme « N workflows ». Une fois la DB source de vérité : métrique = `count(workflows WHERE statut = rejouable_validé)`.
|
||||
44
docs/DESIGN_ANONYMISATION_TOKENS_TYPES_2026-06-28.md
Normal file
44
docs/DESIGN_ANONYMISATION_TOKENS_TYPES_2026-06-28.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Design — Anonymisation par tokens typés (= apprentissage des variables)
|
||||
|
||||
- `Date`: 2026-06-28
|
||||
- `Auteur`: Claude (idée Dom)
|
||||
- `Statut`: design actif
|
||||
- `Origine`: PII patient en clair confirmée en production clinique (titres de fenêtre + **contenu médical des `text_input`**). Idée Dom : remplacer la PII par des **tokens typés** plutôt que flouter.
|
||||
|
||||
## Principe directeur (Dom 28/06)
|
||||
|
||||
**Léa apprend l'INTERFACE, pas la DONNÉE.** Ce qui compte pour l'apprentissage, c'est **où sont les champs, leur type, l'enchaînement** — **pas leur contenu**. Après apprentissage, Léa devra :
|
||||
- **saisir des données** dans les bons champs (contenu fourni au runtime par un agent / une extraction),
|
||||
- **lire les écrans pour extraire des données** que l'« agent » traitera.
|
||||
|
||||
→ Le contenu capturé (texte saisi, valeurs OCR, nom patient dans un titre) est une **variable**, pas une constante à mémoriser. On le remplace par un **token typé** : on **anonymise** ET on **apprend la carte des variables** d'un seul geste. Flouter détruit l'info ; tokeniser la préserve **structurellement** sans la valeur.
|
||||
|
||||
Conforme au principe d'anonymisation déjà acté (`feedback_anonymisation_stricte` : remplacer uniquement les identités, ne jamais réécrire le clinique) — ici on va plus loin : **le contenu d'un champ saisi n'est même pas nécessaire**, on garde le champ, pas la valeur.
|
||||
|
||||
## Modèle d'anonymisation par type de capture
|
||||
|
||||
| Capture | Ce qu'on garde (interface) | Ce qu'on retire (donnée) | Comment |
|
||||
|---|---|---|---|
|
||||
| **`text_input`** (texte tapé) | le **fait** qu'un champ texte a été saisi (+ éventuellement nb de caractères) | **tout le contenu** (diagnostics, notes médicales = données de santé) | **`[SAISIE]`** (option **b**, décision Dom). Pas de NER nécessaire — on ne stocke simplement pas le contenu. **Résout la fuite la plus grave.** |
|
||||
| **`active_window_title`** | l'**app/écran** (« GXD5 Pacs », « Expert Santé », « Firefox ») = contexte d'apprentissage | l'**identité patient** (nom, IPP, âge) | tokens typés `[NOM_1]`/`[IPP_1]`/`[AGE_1]` via **couche 1 (regex+structurel)** + **couche 2 (NER)** pour les noms libres |
|
||||
| **OCR / lecture d'écran** | les **zones/champs** d'où extraire | les **valeurs** extraites (PII) | token typé de la zone (« extraire un `[NOM]` ici »), valeur réinjectée au runtime, non persistée |
|
||||
|
||||
## Architecture (2 couches + intégration)
|
||||
|
||||
- **Couche 1 — regex + structurel (FAIT, `agent_v0/server_v1/pii_sanitizer.py`)** : tokens typés cohérents, **sans modèle**, déployable partout. Capte IPP/NIR/TEL/EMAIL/AGE + noms format clinique (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` PACS) + blacklist logiciels. Couvre 5/7 patients du jour, **tous les IPP**. 5 tests verts.
|
||||
- **Couche 2 — NER CamemBERT-bio (à vendorer)** : moteur `CamembertNerManager` du projet `~/ai/anonymisation` (ONNX **CPU**, ~9-12 ms/titre, labels typés PER/IPP/AGE/DATE/HOPITAL…). Pour les **noms libres** que la couche 1 rate (« Prénom NOM — Firefox »). Modèle **421 Mo → côté DGX** (postes cliniques trop légers — contrainte Dom). Lazy, optionnel.
|
||||
- **Intégration** : une fonction `sanitize_event(event, mapping)` au **point de persistance serveur** : `text_input` → `[SAISIE]` ; titres → `anonymize_text` (couche 1 + 2) ; cohérence par **mapping de session** (même entité → même token).
|
||||
|
||||
## Placement & déploiement
|
||||
|
||||
- **Côté serveur (DGX)** : les events y remontent déjà ; le client reste léger. (Cible privacy-by-design = supprimer le contenu **au plus près du poste** avant stream — évolution, quand le client pourra être modifié.)
|
||||
- **Déploiement GATED** : ne pas redémarrer le serveur DGX pendant des sessions live.
|
||||
- **Ne casse pas l'apprentissage** : `workflow_trajectory_signature` tokenise déjà la PII pour le discriminant ; les tokens typés **renforcent** la carte des variables.
|
||||
|
||||
## Décisions Dom
|
||||
- ✅ **Option (b)** pour `text_input` : placeholder `[SAISIE]`, on ne garde pas le contenu (28/06).
|
||||
- ⏳ **Donnée déjà capturée** (9 patients, 6 IPP, contenu médical) : assainir a posteriori vs **purger** — avec Amina (reco Claude : purger les sessions du jour une fois le fix en place).
|
||||
|
||||
## Connexe
|
||||
- **Config-remontée** des specs postes (CPU/RAM/GPU/OS) pour cibler + fournir des prérequis (`screen_metadata` en remonte déjà une partie).
|
||||
- Réutilise : `~/ai/anonymisation` (`camembert_ner_manager.py`, gazetteers INSEE, blacklists, regex, PLACEHOLDERS).
|
||||
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# DESIGN — MAJ silencieuse du client Léa + déploiement CANARY (DETTE-022 v2)
|
||||
|
||||
Date : 2026-07-01
|
||||
Branche : `feat/push-log-dgx`
|
||||
Statut : **premier draft fonctionnel — GATED OFF partout, aucun swap réel, revue supervisée Dom requise avant toute activation**
|
||||
|
||||
> ⚠️ RIEN N'A ÉTÉ DÉPLOYÉ. Aucun SSH poste, aucune action fleet. Ce document +
|
||||
> le code de la branche sont un livrable de conception/implémentation pour revue.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problème
|
||||
|
||||
Pousser des correctifs au client Léa sur ~19 postes cliniques live (Wallerstein)
|
||||
**sans** patch manuel DSI et **sans** déranger les TIM en plein travail. Contrainte
|
||||
absolue : une MAJ ratée peut **briquer toute la flotte**. Le mécanisme doit donc
|
||||
être **conservateur** : canary lent + rollback béton plutôt que rapide et risqué.
|
||||
|
||||
## 2. État de départ (stub commit `813b33b47`) — ce qui existait déjà
|
||||
|
||||
Le noyau était plus avancé qu'un simple squelette. Déjà présent et **testé (vert)** :
|
||||
|
||||
| Brique | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Décision serveur PURE | `agent_v0/server_v1/update_check.py` | `parse_version`/`is_newer` (semver correct : `1.0.2 < 1.0.10`), `decide_update()`, `build_download_url()` |
|
||||
| Endpoint serveur gated | `agent_v0/server_v1/api_stream.py:7843+` | `GET /api/v1/agents/update/check` — **503 si `RPA_AUTO_UPDATE_SERVER_ENABLED` OFF**, Bearer requis |
|
||||
| Noyau client PUR | `agent_v0/agent_v1/network/updater.py` | `auto_update_enabled()` (flag `RPA_AUTO_UPDATE_ENABLED`, défaut OFF), `should_update()` (double garde anti-downgrade), `download_update()` (staging + SHA256, ne touche jamais les fichiers vivants) |
|
||||
| **Stubs dangereux (no-op)** | `updater.py:246+` | `apply_update()` / `write_boot_ok_marker()` — **réservés révision humaine** (swap fichiers, édition `Lea.bat`, restart) |
|
||||
| Version agent | `agent_v0/agent_v1/config.py:30` | `AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")` (amorcé `105ade959`) |
|
||||
| Tests | `tests/unit/test_update_check_server.py`, `tests/unit/test_agent_v1_updater.py`, `tests/integration/test_update_check_endpoint.py` | R2/R3 verts |
|
||||
|
||||
### Ce qui MANQUAIT (comblé par ce draft)
|
||||
|
||||
1. **Aucune logique canary** : `decide_update` recevait `machine_id` mais l'ignorait pour choisir la version. La version cible était une seule var globale `RPA_AGENT_LATEST_VERSION` → une MAJ partait sur **toute** la flotte d'un coup. **C'est le trou de sécurité n°1.**
|
||||
2. **Le noyau client n'était pas wiré** : `updater.py` n'était appelé nulle part. `main.py` ne l'importait pas. Aucun caller HTTP de `/agents/update/check`.
|
||||
3. **Pas d'orchestrateur** reliant check → décide → download (staging) côté client.
|
||||
|
||||
## 3. Fleet / versioning existant (réutilisé, pas réinventé)
|
||||
|
||||
- Registre SQLite `enrolled_agents` (`agent_v0/server_v1/agent_registry.py:105`) : colonne `version` + `last_seen_at` par `machine_id`. Le dashboard Fleet (`web_dashboard/templates/index.html:2247`) affiche déjà la version par poste.
|
||||
- **Limite connue** : `version` n'est écrite qu'à l'`enroll` (installateur), pas rafraîchie par le heartbeat runtime. Le serveur connaît donc la version *installée*, pas forcément la *version vive*. → **inventaire de version = amélioration future** (voir §8), non bloquante pour le canary (le canary est piloté par une allow-list de `machine_id`, pas par l'inventaire).
|
||||
|
||||
## 4. Design retenu (et pourquoi)
|
||||
|
||||
Aligné sur l'état de l'art self-update desktop 2025 (canary / blue-green / A-B swap + watchdog rollback + intégrité + version) — sources en fin de doc.
|
||||
|
||||
### 4.1 CANARY côté serveur — la keystone de sécurité (IMPLÉMENTÉ)
|
||||
|
||||
Nouveau module PUR `agent_v0/server_v1/update_policy.py`. Il résout la version cible
|
||||
**PAR MACHINE** :
|
||||
|
||||
- poste dans l'allow-list canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par **variables d'environnement serveur** (aucun rebuild, aucune
|
||||
DSI) :
|
||||
|
||||
```
|
||||
RPA_AGENT_STABLE_VERSION # version servie à TOUTE la flotte (défaut 1.0.1)
|
||||
RPA_AGENT_CANARY_VERSION # version servie AUX SEULS postes canary (optionnel)
|
||||
RPA_AGENT_CANARY_MACHINES # allow-list CSV des machine_id canary
|
||||
```
|
||||
|
||||
Garde-fous du résolveur (tous prudents par défaut) :
|
||||
- machine_id absent / liste vide / pas de `canary_version` → **stable** ;
|
||||
- `canary_version` doit être **strictement plus récente** que `stable` (sinon on sert stable — jamais de recul) ;
|
||||
- ne lève jamais ; version illisible → retombe sur stable via le comparateur semver tolérant.
|
||||
|
||||
Wiring : `_latest_agent_version(machine_id)` dans `api_stream.py` appelle
|
||||
`resolve_target_version_from_env(machine_id)`. **Rétrocompat** : si l'ancienne
|
||||
`RPA_AGENT_LATEST_VERSION` est positionnée, elle prime (pas de régression d'un
|
||||
déploiement existant).
|
||||
|
||||
**Effet** : la 1.0.2 ne peut PAS fuiter hors de la liste canary. Blast radius =
|
||||
la liste. On démarre la liste = `lea-4zbgwxty` (Émilie) seul.
|
||||
|
||||
**Promotion** = quand le canary est validé : `RPA_AGENT_STABLE_VERSION=<canary>`
|
||||
+ vider `RPA_AGENT_CANARY_MACHINES` → toute la flotte suit.
|
||||
**Rollback canary** = vider `RPA_AGENT_CANARY_MACHINES` / remettre l'ancienne
|
||||
`RPA_AGENT_CANARY_VERSION` → le prochain check ne propose plus rien.
|
||||
|
||||
### 4.2 Orchestrateur client (IMPLÉMENTÉ, GATED, sans swap)
|
||||
|
||||
`updater.run_update_cycle(local_version, machine_id, staging_dir, checker?, downloader?)` :
|
||||
|
||||
1. **GATE** `auto_update_enabled()` (`RPA_AUTO_UPDATE_ENABLED`, défaut OFF) — si OFF, ne fait **strictement rien**, aucun appel réseau ;
|
||||
2. `checker(...)` → réponse serveur (défaut = `_default_update_checker` : GET vers l'endpoint gated, Bearer, 503→None, jamais d'exception) ;
|
||||
3. `should_update(...)` → plan (double garde semver anti-downgrade) ;
|
||||
4. `download_update(...)` → ZIP en **staging** + vérif **SHA256** (fichiers vivants jamais touchés) ;
|
||||
5. `apply_update(staged)` = **stub no-op** → résultat `applied: False`. **Le swap réel n'est PAS fait par du code d'agent.**
|
||||
|
||||
Statuts retournés (diagnostic/log) : `disabled | check_failed | up_to_date | download_failed | staged`. Best-effort total : aucune exception ne remonte (ne casse jamais Léa).
|
||||
|
||||
### 4.3 Wiring runtime (IMPLÉMENTÉ, GATED)
|
||||
|
||||
`main.py` : thread daemon `_auto_update_loop`, démarré **uniquement si**
|
||||
`AUTO_UPDATE_ENABLED`, à côté des boucles permanentes existantes (même pattern
|
||||
que le log shipper). Sécurité « **au bon moment** » : on ne stage PAS pendant un
|
||||
enregistrement (`self.session_id`) ou un replay actif (`self._replay_active`) —
|
||||
pas de perturbation du travail TIM. Intervalle `RPA_AUTO_UPDATE_INTERVAL_S`
|
||||
(défaut **3600 s / 1 h** : une MAJ n'est jamais urgente).
|
||||
|
||||
### 4.4 Intégrité + version
|
||||
|
||||
- **Intégrité** : SHA256 vérifié dans `download_update` (déjà présent) ; mismatch → rejet + staging propre.
|
||||
- **Version** : `AGENT_VERSION` envoyée à chaque check (`current_version`) ; le serveur choisit la cible par machine.
|
||||
- **Signature (à ajouter, §8)** : SHA256 seul protège de la corruption, pas de l'usurpation. Recommandation : signer le manifeste (le SHA256 vient d'un canal authentifié — l'endpoint Bearer — donc chaîne acceptable pour le POC ; signature détachée = durcissement futur).
|
||||
|
||||
### 4.5 Swap atomique + rollback (SPEC — réservé révision humaine, PAS codé par agent)
|
||||
|
||||
Le swap réel reste dans les stubs `apply_update` / `write_boot_ok_marker` et
|
||||
dans `Lea.bat`. **Un agent ne doit pas écrire de code qui écrase des binaires
|
||||
vivants ni relance un process.** Spec cible pour la revue humaine :
|
||||
|
||||
- **A-B / staging** : le ZIP est extrait dans `Lea_next\`. Au **prochain démarrage**, `Lea.bat` (hors-process) : backup `Lea\`→`Lea_prev\`, swap `Lea_next\`→`Lea\`, lance la nouvelle version.
|
||||
- **Watchdog rollback** : la nouvelle version doit écrire un marker `boot_ok_<version>` **après** ~60 s de heartbeat DGX sain + session OK. Si `Lea.bat` ne trouve pas le marker au démarrage suivant (crash au boot), il restaure `Lea_prev\` automatiquement. Cible « rollback latency » < 90 s (état de l'art).
|
||||
- **Cas edge** (documenté dans les stubs) : DGX down ≠ Léa N+1 buguée — le health-check doit distinguer les deux pour éviter un faux rollback.
|
||||
|
||||
## 5. Fichiers touchés (cette branche)
|
||||
|
||||
**Ajouts**
|
||||
- `agent_v0/server_v1/update_policy.py` — canary PUR (résolveur par machine + lecture env).
|
||||
- `tests/unit/test_update_policy_canary.py` — TDD canary (résolveur + env).
|
||||
|
||||
**Modifs**
|
||||
- `agent_v0/server_v1/api_stream.py` — `_latest_agent_version(machine_id)` canary-aware (rétrocompat legacy) + docstring endpoint.
|
||||
- `agent_v0/agent_v1/network/updater.py` — `_default_update_checker()` + `run_update_cycle()` (orchestrateur gated, sans swap).
|
||||
- `agent_v0/agent_v1/config.py` — `AUTO_UPDATE_INTERVAL_S`, `AUTO_UPDATE_STAGING_DIR`.
|
||||
- `agent_v0/agent_v1/main.py` — thread `_auto_update_loop` gated + import config.
|
||||
- `tests/unit/test_agent_v1_updater.py` — TDD `run_update_cycle` (gate off, up-to-date, staged, sha mismatch, checker raise).
|
||||
- `tests/integration/test_update_check_endpoint.py` — TDD canary HTTP (poste canary vs hors-canary).
|
||||
- `deploy/lea_package/config.txt` — flags client MAJ documentés (commentés, OFF).
|
||||
|
||||
**Intacts (réservés révision humaine)** : `updater.apply_update`, `updater.write_boot_ok_marker`, `Lea.bat`.
|
||||
|
||||
## 6. Matrice des flags (tout OFF par défaut)
|
||||
|
||||
| Flag | Côté | Défaut | Effet |
|
||||
|---|---|---|---|
|
||||
| `RPA_AUTO_UPDATE_SERVER_ENABLED` | serveur | OFF (503) | active l'endpoint de décision |
|
||||
| `RPA_AGENT_STABLE_VERSION` | serveur | `1.0.1` | version floor de toute la flotte |
|
||||
| `RPA_AGENT_CANARY_VERSION` | serveur | — | nouvelle version, postes canary seulement |
|
||||
| `RPA_AGENT_CANARY_MACHINES` | serveur | — | allow-list CSV canary |
|
||||
| `RPA_AGENT_LATEST_VERSION` (legacy) | serveur | — | si set, prime sur le canary (rétrocompat) |
|
||||
| `RPA_AUTO_UPDATE_ENABLED` | client | OFF | active la boucle de check + staging |
|
||||
| `RPA_AUTO_UPDATE_INTERVAL_S` | client | `3600` | intervalle de check |
|
||||
|
||||
## 7. Plan de déploiement CANARY (étapes + critères GO / ROLLBACK)
|
||||
|
||||
> Prérequis avant TOUTE étape : la mécanique de **swap réel** (§4.5) doit avoir
|
||||
> été implémentée et revue par un humain. Tant qu'elle est en stub, ce plan ne
|
||||
> fait que **stager** un ZIP (aucun poste ne change réellement de version) — ce
|
||||
> qui est déjà utile pour valider la chaîne check/download/intégrité à vide.
|
||||
|
||||
**Étape 0 — Serveur seul (aucun poste touché)**
|
||||
- Action : `RPA_AUTO_UPDATE_SERVER_ENABLED=true`, `RPA_AGENT_STABLE_VERSION=1.0.1`, PAS de canary encore.
|
||||
- GO si : `GET /agents/update/check` répond 200 pour un `machine_id` quelconque avec `update_available:false`. Aucun poste n'a la MAJ activée côté client.
|
||||
- ROLLBACK : repasser le flag serveur OFF.
|
||||
|
||||
**Étape 1 — Canary Émilie, staging seul**
|
||||
- Action serveur : `RPA_AGENT_CANARY_VERSION=<nouvelle>`, `RPA_AGENT_CANARY_MACHINES=lea-4zbgwxty`.
|
||||
- Action poste Émilie (config.txt) : `RPA_AUTO_UPDATE_ENABLED=true`.
|
||||
- GO si : dans les logs d'Émilie (remontés par le push-log DGX), `[UPDATE] MAJ <v> téléchargée en staging (SHA256=True)`, ZIP présent dans le staging, `applied:False`, Léa continue de tourner normalement (session/replay non perturbés). Vérifier qu'AUCUN autre poste ne reçoit `update_available:true`.
|
||||
- ROLLBACK : vider `RPA_AGENT_CANARY_MACHINES` (le check ne propose plus rien). Aucun impact : rien n'avait été appliqué.
|
||||
|
||||
**Étape 2 — Canary Émilie, swap réel (après implémentation humaine du §4.5)**
|
||||
- GO si : après redémarrage, Émilie tourne la nouvelle version (`AGENT_VERSION` remontée), marker `boot_ok` écrit, heartbeat DGX sain > 24 h, zéro régression fonctionnelle (enregistrement + replay OK).
|
||||
- ROLLBACK : automatique par watchdog `Lea.bat` si pas de `boot_ok` au boot ; manuel = restaurer `Lea_prev\` + vider la liste canary.
|
||||
|
||||
**Étape 3 — Élargissement progressif (rings)**
|
||||
- Ajouter 2-3 postes à `RPA_AGENT_CANARY_MACHINES`, attendre 48 h par palier.
|
||||
- GO/ROLLBACK : mêmes critères qu'étape 2, par palier.
|
||||
|
||||
**Étape 4 — Promotion générale**
|
||||
- `RPA_AGENT_STABLE_VERSION=<nouvelle>` + vider `RPA_AGENT_CANARY_MACHINES`.
|
||||
- Toute la flotte converge au rythme de son intervalle de check.
|
||||
- ROLLBACK flotte : remettre `RPA_AGENT_STABLE_VERSION` à l'ancienne (les postes ne redescendent pas seuls — le swap-down reste une opération supervisée ; les nouveaux checks ne proposeront plus la MAJ).
|
||||
|
||||
## 8. Améliorations futures (hors périmètre de ce draft)
|
||||
|
||||
1. **Swap réel + watchdog rollback** (§4.5) — la brique manquante n°1, révision humaine.
|
||||
2. **Inventaire de version vive** : rafraîchir `enrolled_agents.version` au heartbeat (le serveur saurait exactement quelle version tourne où — utile pour piloter le canary depuis le dashboard).
|
||||
3. **Signature détachée** du manifeste (durcissement au-delà du SHA256 sur canal Bearer).
|
||||
4. **Endpoint de download versionné** : aujourd'hui `/api/fleet/download/<machine_id>` (dashboard) sert l'installateur complet et **ignore `?type=&version=`** ; il faudra qu'il serve le vrai payload `code-only` incrémental attendu par le contrat d'URL.
|
||||
5. **Auto-report du résultat de swap** (succès/rollback) au serveur pour un tableau de bord canary.
|
||||
|
||||
## 9. Sources (état de l'art self-update desktop / canary 2025)
|
||||
|
||||
- [Rollback Strategies for Enterprise: 2025 Best Practices — sparkco.ai](https://sparkco.ai/blog/rollback-strategies-for-enterprise-2025-best-practices)
|
||||
- [Canary Deployment with Auto-Rollback for AI Agents — antigravitylab.net](https://antigravitylab.net/en/articles/agents/antigravity-ai-agent-canary-deployment-burn-rate-slo)
|
||||
- [awesome-agentic-patterns — canary rollout & automatic rollback](https://github.com/nibzard/awesome-agentic-patterns/blob/main/patterns/canary-rollout-and-automatic-rollback-for-agent-policy-changes.md)
|
||||
- [What is Canary Testing — aqua-cloud.io](https://aqua-cloud.io/canary-testing/)
|
||||
- [Rollback Automation Best Practices for CI/CD — hokstadconsulting.com](https://hokstadconsulting.com/blog/rollback-automation-best-practices-for-ci-cd)
|
||||
218
docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md
Normal file
218
docs/DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Design Note — NavigateCoords Consumption Gap (Write-Only)
|
||||
|
||||
**Auteur**: Qwen
|
||||
**Date**: 2026-07-02
|
||||
**Statut**: DESIGN NOTE — pas de câblage sans GO Dom
|
||||
**Référence**: `tests/unit/test_coords_consumption_gap.py` (10 tests PASSING documenting the gap)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Le module navigation (`core/navigation`) produit des coords normalisés (`NavigateCoords`) via OCR/VLM, les stocke dans `replay_state["variables"]`, mais **aucun consommateur** dans le runtime n'utilise ces coords. Le résultat est un pattern **write-only** : coords générés mais jamais consommés par les actions suivantes (click/type).
|
||||
|
||||
Trois gaps structurels confirmés par code lecture :
|
||||
|
||||
---
|
||||
|
||||
## Gap A — Compiler Produces Literals, Not Templates
|
||||
|
||||
**Localisation**: `replay_engine.py:1832-1846` (`_edge_to_normalized_actions`)
|
||||
|
||||
**Problème**: Pour `mouse_click`, le compiler bake `x_pct` et `y_pct` comme **floats littéraux** depuis `by_position` :
|
||||
|
||||
```python
|
||||
# replay_engine.py:1843-1846
|
||||
normalized["type"] = "click"
|
||||
normalized["x_pct"] = x_pct # float littéral (ex: 0.15)
|
||||
normalized["y_pct"] = y_pct # float littéral (ex: 0.07)
|
||||
```
|
||||
|
||||
Ces floats sont **hardcodés** dans le step definition. Il n'existe pas de mécanisme pour référencer les coords navigate via templates comme `{{navigate_login_coords.x_pct}}`.
|
||||
|
||||
**La substitution existante ne couvre pas ce cas** :
|
||||
- `_substitute_variables()` → `${var}` → appliqué uniquement à `text_input.text`
|
||||
- `_RUNTIME_VAR_PATTERN` → `{{var.field}}` → compilé regex, **jamais appliqué à `x_pct/y_pct`**
|
||||
|
||||
**Conséquence**: Un navigate step qui résolve coords login à (0.15, 0.07) ne peut PAS injecter ces coords dans un click step suivant, car le click step a ses propres `x_pct/y_pct` hardcodés.
|
||||
|
||||
---
|
||||
|
||||
## Gap B — Zero Consumers in Runtime
|
||||
|
||||
**Localisation**: `core/navigation/__init__.py:43-113` (`_handle_navigate_action`)
|
||||
|
||||
**Problème**: `_handle_navigate_action` stocke coords dans `replay_state["variables"]` :
|
||||
|
||||
```python
|
||||
# core/navigation/__init__.py:100-105
|
||||
if result.login_coords:
|
||||
variables[login_var] = result.login_coords.to_dict()
|
||||
# → {"x_pct": 0.15, "y_pct": 0.07, "method": "ocr_anchor"}
|
||||
```
|
||||
|
||||
**Zéro consommateur** : aucun action handler (click, type, double_click, right_click) lit `variables["navigate_login_coords"]` pour résoudre ses propres coords. Chaque action utilise exclusivement `by_position` depuis son edge definition.
|
||||
|
||||
**Preuve par grep** : `navigate_login_coords|navigate_password_coords|navigate_submit_coords` apparaît uniquement dans :
|
||||
- `core/navigation/__init__.py` (write)
|
||||
- `tests/unit/test_*.py` (test verification)
|
||||
- **0 occurrences** dans `replay_engine.py` action dispatch ou `api_stream.py` action handlers
|
||||
|
||||
---
|
||||
|
||||
## Gap C — Navigate Edge → Empty Actions List
|
||||
|
||||
**Localisation**: `replay_engine.py:1806-1955` (`_edge_to_normalized_actions`)
|
||||
|
||||
**Problème**: Le type `navigate` est dans `_ALLOWED_ACTION_TYPES` (ligne 44) et possède un handler câblé dans `api_stream.py` (ligne 4459-4463 via `_handle_navigate_action`). Mais `_edge_to_normalized_actions` **n'a pas de branche** pour `navigate` :
|
||||
|
||||
```python
|
||||
# replay_engine.py:1954-1955 (else branch)
|
||||
else:
|
||||
logger.warning(f"Type d'action inconnu : {action_type}")
|
||||
return []
|
||||
```
|
||||
|
||||
**Conséquence** : Quand le BFS traverse un edge navigate, `_edge_to_normalized_actions(edge, params)` retourne `[]`. L'action navigate est **skippée** dans le path. Le handler existe dans `api_stream.py` mais est **inaccessible** car le normalized action dict n'est jamais produit.
|
||||
|
||||
**Paradoxe** : Le navigate handler est câblé et fonctionnel, mais le pipeline edge→action le bloque à l'entrée.
|
||||
|
||||
---
|
||||
|
||||
## Options de Résolution
|
||||
|
||||
### Option 1 — Compiler Injection (modifier `_edge_to_normalized_actions`)
|
||||
|
||||
**Approche**: Ajouter une branche `navigate` dans `_edge_to_normalized_actions` qui produit un normalized action dict. Modifier les actions click/type pour permettre des template refs `{{navigate_login_coords.x_pct}}` dans `x_pct/y_pct`, avec résolution runtime.
|
||||
|
||||
```python
|
||||
# Option 1 — Branch navigate dans _edge_to_normalized_actions
|
||||
elif action_type == "navigate":
|
||||
normalized["type"] = "navigate"
|
||||
normalized["parameters"] = {
|
||||
"action": action_params.get("action", "login"),
|
||||
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
|
||||
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
|
||||
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
|
||||
}
|
||||
return [normalized]
|
||||
```
|
||||
|
||||
**+ Avantages** :
|
||||
- Minimal change — 1 branche ajoutée + template resolution dans click/type
|
||||
- Compatible avec handler existant (`_handle_navigate_action`)
|
||||
- BFS path inclut navigate → handler appelé → coords stockés → consommés
|
||||
|
||||
**– Risques** :
|
||||
- Template resolution dans `x_pct/y_pct` nécessite modification de click/type dispatch
|
||||
- Float vs string : `{{navigate_login_coords.x_pct}}` résout en `"0.15"` (string), pas `0.15` (float) — nécessite conversion
|
||||
- Ordonnancement : navigate doit s'exécuter AVANT les actions click/type qui consomment ses coords — scheduling implication
|
||||
|
||||
### Option 2 — Declarative YAML Templates (step definitions avec coords_template)
|
||||
|
||||
**Approche**: Ajouter un champ `coords_template` dans les step YAML definitions. Au runtime, le template est résolu par substitution des variables navigate.
|
||||
|
||||
```yaml
|
||||
# Option 2 — YAML step definition avec coords_template
|
||||
steps:
|
||||
- action: navigate
|
||||
parameters:
|
||||
action: login
|
||||
login_coords_var: navigate_login_coords
|
||||
- action: mouse_click
|
||||
coords_template: "{{navigate_login_coords}}"
|
||||
# Au runtime : x_pct/y_pct résolus depuis navigate_login_coords dict
|
||||
```
|
||||
|
||||
**+ Avantages** :
|
||||
- Déclaratif — coords templates dans YAML, pas hardcoded
|
||||
- Séparation compiler/runtime : compiler produit templates, runtime résout
|
||||
- Extensible à autres types de coords (search, dossier)
|
||||
|
||||
**– Risques** :
|
||||
- Plus de changement : schema YAML + template resolver + compiler modifications
|
||||
- Retro-compatibilité : workflows existants sans coords_template doivent continuer à fonctionner (fallback by_position)
|
||||
- Validation : templates malformés → runtime errors subtiles
|
||||
|
||||
---
|
||||
|
||||
## Table Comparative
|
||||
|
||||
| Critère | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|
||||
|---------|-------------------------------|---------------------------|
|
||||
| Changement code | Small — 1 branch + template resolve | Medium — schema + resolver + compiler |
|
||||
| Retro-compat | Full — by_position fallback intact | Full — fallback by_position si pas de template |
|
||||
| Ordonnancement | Navigate avant click (BFS order) | Navigate avant click (step order) |
|
||||
| Extensibilité | Navigate-specific | General — coords_template applicable à tout |
|
||||
| Risque runtime | Float/string conversion | Template validation errors |
|
||||
| Tests impact | 1-3 nouveaux tests | 5-8 nouveaux tests (schema + resolver) |
|
||||
| GO Dom needed | YES | YES |
|
||||
| Timeline | ~2h implementation | ~4h implementation + schema design |
|
||||
|
||||
---
|
||||
|
||||
## Test Rouge Proposal
|
||||
|
||||
**Objectif**: Démontrer Gap C avec 1 test unitaire qui montre qu'un edge navigate produit une empty action list.
|
||||
|
||||
```python
|
||||
# tests/unit/test_coords_consumption_gap.py — ajout proposé
|
||||
|
||||
def test_gap_c_navigate_edge_produces_empty_actions():
|
||||
"""Gap C: _edge_to_normalized_actions returns [] for navigate edge.
|
||||
|
||||
Prove: navigate is in _ALLOWED_ACTION_TYPES but has no branch
|
||||
in _edge_to_normalized_actions → falls into else → empty list.
|
||||
"""
|
||||
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
|
||||
|
||||
# Minimal mock edge with navigate action type
|
||||
edge = MockEdge(
|
||||
edge_id="e1",
|
||||
from_node="start",
|
||||
to_node="login",
|
||||
action=MockAction(
|
||||
type="navigate",
|
||||
target=None,
|
||||
parameters={"action": "login"},
|
||||
),
|
||||
)
|
||||
result = _edge_to_normalized_actions(edge, {})
|
||||
|
||||
# GAP: navigate edge produces zero actions
|
||||
assert result == [], f"Expected empty list, got {result}"
|
||||
# This proves the handler in api_stream.py is unreachable
|
||||
```
|
||||
|
||||
**Note**: Ce test est un **red flag** — il doit FAIL quand le gap est résolu (navigate branch ajoutée → result ≠ []). Il sert de guardrail : si quelqu'un câble navigate sans résoudre les gaps A+B, le test rouge continue à signaler le problème.
|
||||
|
||||
---
|
||||
|
||||
## Decision Required from Dom
|
||||
|
||||
**⚠️ PAS DE CÂBLAGE SANS GO DOM**
|
||||
|
||||
Ce design note documente les gaps et propose des options. La décision appartient à Dom :
|
||||
|
||||
1. **Option préférée** : 1 (compiler injection) ou 2 (YAML templates) ?
|
||||
2. **Timeline** : implémenter maintenant (POC phase) ou post-POC ?
|
||||
3. **Scope** : navigate login only, ou general coords template system ?
|
||||
4. **Test rouge** : ajouter le test gap C maintenant (documentation) ou attendre GO ?
|
||||
|
||||
---
|
||||
|
||||
## Appendix — Code References
|
||||
|
||||
| Fichier | Lignes | Rôle |
|
||||
|---------|--------|------|
|
||||
| `replay_engine.py:44` | `_ALLOWED_ACTION_TYPES` includes "navigate" | Allowlist |
|
||||
| `replay_engine.py:1806-1955` | `_edge_to_normalized_actions` — no navigate branch | Gap C |
|
||||
| `replay_engine.py:1843-1846` | mouse_click bakes literal x_pct/y_pct | Gap A |
|
||||
| `core/navigation/__init__.py:43-113` | `_handle_navigate_action` — writes coords to variables | Gap B (write) |
|
||||
| `core/navigation/action_resolver.py:47-62` | `NavigateCoords` dataclass definition | Data model |
|
||||
| `api_stream.py:4459-4463` | navigate handler dispatch | Wired but unreachable |
|
||||
| `tests/unit/test_coords_consumption_gap.py` | 10 tests documenting write-only gap | Evidence |
|
||||
|
||||
---
|
||||
|
||||
*Qwen — design note, pas wiring. GO Dom required.*
|
||||
59
docs/DESIGN_OVMF_AUTOREPAIR_VM_2026-06-20.md
Normal file
59
docs/DESIGN_OVMF_AUTOREPAIR_VM_2026-06-20.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Design — Auto-réparation OVMF de la VM Léa (gap G2, reprise non-assistée)
|
||||
|
||||
- `Auteur`: Claude (infra)
|
||||
- `Date`: 2026-06-20 ~03:15 CEST
|
||||
- `Statut`: **PROPOSITION / design read-only** — à appliquer après revue Dom (garde-fou : aucun changement service prod sans Dom).
|
||||
- `Référence`: post-mortem `docs/POSTMORTEM_PANNE_SECTEUR_DGX_2026-06-20.md` (gap G2).
|
||||
|
||||
## 1. Problème
|
||||
|
||||
Une coupure brutale corrompt `OVMF_VARS.fd` (NVRAM UEFI) → la VM boucle dans TianoCore/Windows Boot Manager. Le 2026-06-20, blocage 02:07→02:18 jusqu'à intervention manuelle de Codex (restore OVMF connu-bon + TPM frais). En clinique sans technicien, ce blocage serait **permanent**.
|
||||
|
||||
## 2. Pourquoi le service ne s'auto-répare pas aujourd'hui
|
||||
|
||||
`~/.config/systemd/user/win11-arm-lea.service` :
|
||||
- `Restart=on-failure` — **inopérant** : en boucle TianoCore, QEMU **ne sort pas** (process « running » à 99 % CPU). Aucun échec → aucun restart.
|
||||
- `ExecStartPre` efface `tpm2-00.permall` (TPM frais à chaque boot) **mais pas `OVMF_VARS.fd`** → un OVMF corrompu **survit aux restarts** → boucle permanente.
|
||||
|
||||
## 3. Détecteur fiable
|
||||
|
||||
Le **guest agent QEMU** (`windows-11-arm-lea-agent.sock`) ne répond **que** si Windows a réellement booté. En boucle firmware, il ne répond jamais. Signature d'échec = *pas de réponse guest-agent après N min* **+** *CPU QEMU élevé*. (Plus robuste qu'une analyse framebuffer ; v1 suffisante.)
|
||||
|
||||
## 4. Design proposé (2 briques + garde-fous)
|
||||
|
||||
### Brique A — Snapshot « known-good » après boot sain
|
||||
Watchdog compagnon (lancé en `ExecStartPost=... &` ou service apparié `vm-health-watchdog.service`) :
|
||||
1. Fenêtre de boot (0→~6 min), poll guest-agent toutes les 30 s (`guest-ping` via socket agent).
|
||||
2. **Guest-agent répond** → boot sain : copie atomique `OVMF_VARS.fd` → `OVMF_VARS.fd.known-good`, écrit sentinel `boot-ok`, log horodaté. C'est le point de restauration.
|
||||
|
||||
### Brique B — Détection boucle + restauration auto
|
||||
Si à T+6 min le guest-agent **ne répond toujours pas** ET CPU QEMU > 80 % (signature boucle) :
|
||||
1. Écrit sentinel `boot-failed`.
|
||||
2. Archive l'OVMF suspect : `OVMF_VARS.fd` → `OVMF_VARS.fd.failed-<ts>` (convention déjà utilisée par Codex).
|
||||
3. Restaure `OVMF_VARS.fd.known-good` → `OVMF_VARS.fd`.
|
||||
4. Arrête le QEMU en boucle firmware. **Sûr** : aucun OS n'a booté (guest-agent jamais monté) → pas de risque de corruption Windows (≠ règle « jamais kill un Windows booté », qui ne s'applique pas ici).
|
||||
5. systemd relance (`Restart=on-failure` se déclenche enfin) avec le bon OVMF.
|
||||
|
||||
### Garde-fous (anti-mauvais comportement)
|
||||
- **Pas de known-good** (1er boot, jamais eu de boot sain) → ne PAS restaurer ; log + alerte, comportement actuel conservé.
|
||||
- **Compteur d'essais** : max 2 auto-restaurations consécutives (sentinel compteur). Au-delà → stop + alerte (évite la boucle restore→échec→restore si le known-good est lui aussi mauvais).
|
||||
- **Faux positifs** : Windows peut booter lentement → fenêtre 6–8 min + double critère (guest-agent ET CPU). Réglable.
|
||||
- **TPM** : on garde l'effacement `tpm2-00.permall` existant (évite le hang TPM) ; l'auto-réparation OVMF est complémentaire.
|
||||
- **Idempotence** : nettoyage des sentinels en début de cycle.
|
||||
|
||||
## 5. Points d'intégration (à valider Dom avant écriture)
|
||||
|
||||
- `win11-arm-lea.service` : ajouter `ExecStartPre` de garde (si `boot-failed` + known-good → restaurer avant lancement) et `ExecStartPost` qui lance le watchdog.
|
||||
- Nouveau script `vm-health-watchdog.sh` (briques A+B) dans `~/quickemu-win11-arm-lea/`.
|
||||
- Optionnel : `vm-health-watchdog.service` (PartOf=win11-arm-lea.service) plutôt qu'un `&`, pour un cycle de vie propre.
|
||||
|
||||
## 6. Plan de test (sans risque, sur la VM labo)
|
||||
|
||||
1. Boot sain → vérifier création `OVMF_VARS.fd.known-good` + sentinel `boot-ok`.
|
||||
2. Simuler corruption (copier l'`OVMF_VARS.fd.failed-powercut-20260620-021854` archivé sur le live) → vérifier détection à T+6 min, archivage, restauration, restart, boot sain.
|
||||
3. Vérifier le compteur d'essais (corrompre aussi le known-good) → stop + alerte, pas de boucle infinie.
|
||||
4. Mesurer le temps total de reprise auto (cible < 10 min sans intervention).
|
||||
|
||||
## 7. Décision attendue (Dom)
|
||||
|
||||
GO/NO-GO sur l'écriture + le réglage de la fenêtre (6 vs 8 min) et du mécanisme d'alerte (log seul ? message coordination ? mail ?). Application supervisée, un changement / un test, après ton réveil.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user