Compare commits
18 Commits
203dc00d53
...
c7b0649716
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b0649716 | ||
|
|
2bfcfa4535 | ||
|
|
b808e48b1f | ||
|
|
78ee962918 | ||
|
|
c8a3618e27 | ||
|
|
9ca277a63f | ||
|
|
8c7b6e5696 | ||
|
|
af4ffa189a | ||
|
|
42f571d496 | ||
|
|
36737cfe9d | ||
|
|
93ef93e563 | ||
|
|
376e4a88b3 | ||
|
|
bb4ed2a75d | ||
|
|
f7b8cddd2b | ||
|
|
a9a99953dd | ||
|
|
aee64f54b1 | ||
|
|
c77844fa9a | ||
|
|
013fe071a2 |
@@ -30,7 +30,9 @@ DASHBOARD_PORT=5001
|
||||
CLIP_MODEL=ViT-B-32
|
||||
CLIP_PRETRAINED=openai
|
||||
CLIP_DEVICE=cpu # cpu or cuda
|
||||
VLM_MODEL=qwen3-vl:8b
|
||||
RPA_VLM_MODEL=gemma4:latest # gemma4:latest (défaut), qwen3-vl:8b, ui-tars (fallback)
|
||||
VLM_MODEL=gemma4:latest # alias de compatibilité
|
||||
# VLM_ALLOW_CLOUD=false # true pour activer les APIs cloud en fallback (OpenAI, Gemini, Anthropic)
|
||||
VLM_ENDPOINT=http://localhost:11434
|
||||
OWL_MODEL=google/owlv2-base-patch16-ensemble
|
||||
OWL_CONFIDENCE_THRESHOLD=0.1
|
||||
|
||||
207
.gitea/workflows/security-audit.yml
Normal file
207
.gitea/workflows/security-audit.yml
Normal file
@@ -0,0 +1,207 @@
|
||||
# ------------------------------------------------------------------
|
||||
# Audit sécurité — bandit + pip-audit + scan secrets
|
||||
# ------------------------------------------------------------------
|
||||
# Jamais bloquant : on reporte les warnings, on ne casse pas la CI.
|
||||
# Utile pour détecter les dérives progressives (nouveaux CVE, secrets
|
||||
# oubliés dans un commit, patterns risqués).
|
||||
#
|
||||
# Fréquence : à chaque push sur main + hebdo (cron).
|
||||
# ------------------------------------------------------------------
|
||||
name: security-audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
# Tous les lundis à 6h UTC (8h Paris hiver, 7h Paris été).
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------------
|
||||
# Job 1 — bandit (bonnes pratiques sécu Python)
|
||||
# ----------------------------------------------------------------
|
||||
bandit:
|
||||
name: Bandit (scan statique)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Installation bandit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "bandit[toml]==1.7.10"
|
||||
|
||||
- name: Scan bandit sur core/
|
||||
run: |
|
||||
# -ll : niveau LOW minimum (remonte tout)
|
||||
# -ii : confiance LOW minimum
|
||||
# --skip B101 : on ignore les asserts (usuels en tests/validation)
|
||||
bandit -r core/ \
|
||||
--skip B101,B404,B603 \
|
||||
--format txt \
|
||||
--exit-zero \
|
||||
--output bandit-report.txt
|
||||
echo "=== RAPPORT BANDIT ==="
|
||||
cat bandit-report.txt
|
||||
|
||||
- name: Upload rapport bandit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bandit-report
|
||||
path: bandit-report.txt
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 2 — pip-audit (CVE sur requirements)
|
||||
# ----------------------------------------------------------------
|
||||
pip-audit:
|
||||
name: pip-audit (CVE dépendances)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Installation pip-audit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "pip-audit==2.7.3"
|
||||
|
||||
- name: Audit CVE sur requirements-ci.txt
|
||||
run: |
|
||||
if [ -f requirements-ci.txt ]; then
|
||||
pip-audit -r requirements-ci.txt \
|
||||
--format json \
|
||||
--output pip-audit-ci.json \
|
||||
--progress-spinner off \
|
||||
--disable-pip || echo "::warning::CVE détectées dans requirements-ci.txt"
|
||||
echo "=== RAPPORT pip-audit (CI) ==="
|
||||
cat pip-audit-ci.json || true
|
||||
else
|
||||
echo "::notice::requirements-ci.txt absent — skip"
|
||||
fi
|
||||
|
||||
- name: Audit CVE sur requirements.txt (best-effort)
|
||||
run: |
|
||||
# Timeout généreux car requirements.txt est massif (torch, CUDA).
|
||||
timeout 120 pip-audit -r requirements.txt \
|
||||
--format json \
|
||||
--output pip-audit-full.json \
|
||||
--progress-spinner off \
|
||||
--disable-pip 2>&1 | head -200 || \
|
||||
echo "::warning::pip-audit sur requirements.txt a timeout ou échoué (non bloquant)"
|
||||
|
||||
- name: Upload rapports pip-audit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pip-audit-reports
|
||||
path: |
|
||||
pip-audit-ci.json
|
||||
pip-audit-full.json
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 3 — Scan secrets en clair (grep simple)
|
||||
# ----------------------------------------------------------------
|
||||
# Patterns recherchés : clés API Anthropic (sk-ant-), OpenAI (sk-),
|
||||
# Google (AIzaSy), AWS (AKIA), tokens Hugging Face (hf_).
|
||||
# Ne cherche QUE dans les fichiers trackés (pas .env, pas .venv).
|
||||
# ----------------------------------------------------------------
|
||||
secrets-scan:
|
||||
name: Scan secrets (grep)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout (historique complet)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan patterns de secrets
|
||||
run: |
|
||||
# Chemins exclus : venvs, caches, data, htmlcov, models.
|
||||
EXCLUDES='--exclude-dir=.venv --exclude-dir=venv_v3 --exclude-dir=.git \
|
||||
--exclude-dir=node_modules --exclude-dir=htmlcov --exclude-dir=models \
|
||||
--exclude-dir=data --exclude-dir=__pycache__ --exclude-dir=.pytest_cache \
|
||||
--exclude=*.lock --exclude=*.log --exclude=*.md'
|
||||
|
||||
echo "=== Recherche de secrets potentiels ==="
|
||||
FOUND=0
|
||||
|
||||
# Anthropic
|
||||
if grep -rnI $EXCLUDES -E 'sk-ant-[a-zA-Z0-9_-]{20,}' . 2>/dev/null; then
|
||||
echo "::warning::Clé Anthropic potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# OpenAI
|
||||
if grep -rnI $EXCLUDES -E 'sk-proj-[a-zA-Z0-9_-]{20,}|sk-[a-zA-Z0-9]{40,}' . 2>/dev/null; then
|
||||
echo "::warning::Clé OpenAI potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Google Cloud / API Keys
|
||||
if grep -rnI $EXCLUDES -E 'AIzaSy[a-zA-Z0-9_-]{33}' . 2>/dev/null; then
|
||||
echo "::warning::Clé Google API potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# AWS
|
||||
if grep -rnI $EXCLUDES -E 'AKIA[0-9A-Z]{16}' . 2>/dev/null; then
|
||||
echo "::warning::Clé AWS potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Hugging Face
|
||||
if grep -rnI $EXCLUDES -E 'hf_[a-zA-Z0-9]{30,}' . 2>/dev/null; then
|
||||
echo "::warning::Token Hugging Face potentiel détecté"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Mots-clés suspects à côté d'assignations
|
||||
if grep -rnI $EXCLUDES -E '(password|passwd|secret|api_key|apikey|token)\s*=\s*["\x27][a-zA-Z0-9_\-!@#\$%]{12,}["\x27]' . 2>/dev/null \
|
||||
| grep -viE '(example|dummy|placeholder|test|fake|xxx|changeme|\$\{)' 2>/dev/null; then
|
||||
echo "::warning::Assignation suspecte d'un secret détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "Aucun secret détecté par les patterns de base."
|
||||
else
|
||||
echo ""
|
||||
echo "::notice::Vérifier manuellement les occurrences ci-dessus."
|
||||
echo "::notice::Si faux positif : ajouter le fichier aux exclusions ou reformater."
|
||||
fi
|
||||
|
||||
# Toujours succès (job non bloquant).
|
||||
exit 0
|
||||
199
.gitea/workflows/tests.yml
Normal file
199
.gitea/workflows/tests.yml
Normal file
@@ -0,0 +1,199 @@
|
||||
# ------------------------------------------------------------------
|
||||
# CI principale — Tests unitaires + lint léger
|
||||
# ------------------------------------------------------------------
|
||||
# Déclenchement : push / pull_request sur n'importe quelle branche.
|
||||
# Objectif : feedback rapide (< 3 min) sans GPU ni Ollama.
|
||||
# Runner : self-hosted (label "ubuntu-latest" ou équivalent).
|
||||
#
|
||||
# Les tests marqués `slow`, `gpu`, `integration`, `performance`,
|
||||
# `visual` et `smoke` sont exclus volontairement — ils nécessitent
|
||||
# CUDA, Ollama, ou des captures d'écran réelles.
|
||||
# ------------------------------------------------------------------
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
# Permet à une nouvelle exécution d'annuler les précédentes
|
||||
# sur la même branche (évite l'engorgement du runner local).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Empêche l'import accidentel de torch/CUDA pendant la CI.
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
PIP_NO_PYTHON_VERSION_WARNING: "1"
|
||||
# Les modules d'exécution lisent parfois ces vars ; valeurs neutres en CI.
|
||||
RPA_VISION_CI: "1"
|
||||
RPA_AUTH_VAULT_PATH: "/tmp/ci_vault.enc"
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------------
|
||||
# Job 1 — Lint (ruff + black --check)
|
||||
# ----------------------------------------------------------------
|
||||
# Non-bloquant : si ruff/black ne sont pas installables, on log
|
||||
# un warning et on continue. L'objectif ici est d'alerter, pas de
|
||||
# casser la CI pour des espaces en trop.
|
||||
# ----------------------------------------------------------------
|
||||
lint:
|
||||
name: Lint (ruff + black)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout du code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Installation des linters
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "ruff==0.6.9" "black==23.12.1" || {
|
||||
echo "::warning::Impossible d'installer ruff/black — job ignoré"
|
||||
exit 0
|
||||
}
|
||||
|
||||
- name: Ruff (lint rapide)
|
||||
run: |
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
# Ruff : on limite aux erreurs critiques (E9, F63, F7, F82) pour
|
||||
# éviter le bruit. Dom peut durcir progressivement.
|
||||
ruff check --select=E9,F63,F7,F82 --output-format=github \
|
||||
core/ agent_v0/ tests/ || {
|
||||
echo "::warning::Ruff a trouvé des erreurs critiques"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo "::warning::ruff indisponible — skip"
|
||||
fi
|
||||
|
||||
- name: Black (format check)
|
||||
run: |
|
||||
if command -v black >/dev/null 2>&1; then
|
||||
# --check : ne modifie pas, signale juste.
|
||||
black --check --diff core/ agent_v0/ tests/ || {
|
||||
echo "::warning::Black suggère un reformatage — non bloquant"
|
||||
exit 0
|
||||
}
|
||||
else
|
||||
echo "::warning::black indisponible — skip"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 2 — Tests unitaires
|
||||
# ----------------------------------------------------------------
|
||||
# Exclut tous les marqueurs lourds. Utilise requirements-ci.txt
|
||||
# pour éviter torch/CUDA (économie ~3 Go + ~2 min).
|
||||
# ----------------------------------------------------------------
|
||||
unit-tests:
|
||||
name: Tests unitaires (sans GPU)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout du code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
requirements-ci.txt
|
||||
requirements.txt
|
||||
|
||||
- name: Installation des dépendances CI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements-ci.txt ]; then
|
||||
echo "Utilisation de requirements-ci.txt (léger, sans torch)"
|
||||
pip install -r requirements-ci.txt
|
||||
else
|
||||
echo "::warning::requirements-ci.txt absent — fallback requirements.txt (lourd)"
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
- name: Vérification imports critiques
|
||||
run: |
|
||||
python -c "import pytest; print(f'pytest {pytest.__version__}')"
|
||||
python -c "import sys; sys.path.insert(0, '.'); import core; print('core OK')" || {
|
||||
echo "::error::Impossible d'importer core.*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Tests unitaires (hors slow/gpu/integration)
|
||||
run: |
|
||||
python -m pytest tests/unit/ \
|
||||
-m "not slow and not gpu and not integration and not performance and not visual" \
|
||||
--tb=short \
|
||||
--strict-markers \
|
||||
-q \
|
||||
--maxfail=10 \
|
||||
-o cache_dir=/tmp/.pytest_cache_ci
|
||||
|
||||
- name: Upload logs si échec
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: |
|
||||
/tmp/.pytest_cache_ci
|
||||
logs/
|
||||
retention-days: 3
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 3 — Tests sécurité (bloquant)
|
||||
# ----------------------------------------------------------------
|
||||
# Les tests `test_security_*` valident des invariants critiques
|
||||
# (évaluation sûre, sérialisation signée). Aucune régression tolérée.
|
||||
# ----------------------------------------------------------------
|
||||
security-tests:
|
||||
name: Tests sécurité (critique)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: [unit-tests]
|
||||
|
||||
steps:
|
||||
- name: Checkout du code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
requirements-ci.txt
|
||||
requirements.txt
|
||||
|
||||
- name: Installation des dépendances CI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements-ci.txt ]; then
|
||||
pip install -r requirements-ci.txt
|
||||
else
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
- name: Tests sécurité (test_security_*)
|
||||
run: |
|
||||
python -m pytest tests/unit/test_security_*.py \
|
||||
--tb=long \
|
||||
--strict-markers \
|
||||
-v \
|
||||
-o cache_dir=/tmp/.pytest_cache_ci_sec
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -83,3 +83,30 @@ backups/
|
||||
# === Legacy / Triage ===
|
||||
_a_trier/
|
||||
archives/
|
||||
|
||||
# === Claude Code — worktrees et données locales ===
|
||||
# Worktrees générés par la CLI Claude Code lors d'exécutions d'agents
|
||||
# parallèles. Peuvent atteindre plusieurs centaines de Mo chacun.
|
||||
# Ne jamais committer — gérer via `git worktree list` / `git worktree remove`.
|
||||
.claude/
|
||||
.kiro/
|
||||
.mcp.json
|
||||
.snapshots/
|
||||
|
||||
# === Données runtime (sessions, learning, buffer, config local) ===
|
||||
data/
|
||||
.hypothesis/
|
||||
.deps_installed
|
||||
# Buffers SQLite locaux (streamer, cache)
|
||||
**/buffer/
|
||||
**/pending_events.db
|
||||
# Databases applicatives (instance Flask)
|
||||
**/instance/*.db
|
||||
**/instance/*.sqlite
|
||||
**/instance/*.sqlite3
|
||||
# Caches et index locaux
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -21,7 +21,12 @@ ollama serve
|
||||
### 3. Télécharger le modèle VLM
|
||||
|
||||
```bash
|
||||
ollama pull qwen3-vl:8b
|
||||
# Modèle par défaut du projet (voir .env.example)
|
||||
ollama pull gemma4:latest
|
||||
|
||||
# Alternatives supportées
|
||||
# ollama pull qwen3-vl:8b
|
||||
# ollama pull 0000/ui-tars-1.5-7b-q8_0:7b # grounder visuel
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
330
README.md
330
README.md
@@ -1,207 +1,203 @@
|
||||
# RPA Vision V3 - 100% Vision-Based Workflow Automation
|
||||
# RPA Vision V3 — Automatisation basée sur la compréhension visuelle des interfaces
|
||||
|
||||
## 📊 Status
|
||||
> ⚠️ **Projet en phase POC** — voir [`docs/STATUS.md`](docs/STATUS.md) pour l'état
|
||||
> réel par module. Certaines briques sont opérationnelles bout en bout,
|
||||
> d'autres sont en cours de stabilisation. Ce dépôt n'est pas production-ready.
|
||||
|
||||
🚀 **PRODUCTION-READY** - Phase 12 Complete (77% System Completion) ✅
|
||||
*Dernière mise à jour : 14 avril 2026*
|
||||
|
||||
**Latest Update**: 14 Décembre 2024
|
||||
- ✅ **10/13 Phases Complétées** - Système mature et fonctionnel
|
||||
- ✅ **Performance Exceptionnelle** - 500-6250x plus rapide que requis
|
||||
- ✅ **Architecture Entreprise** - 148k+ lignes, 19 modules, 6 specs complètes
|
||||
- ✅ **Innovations Techniques** - Self-healing, Multi-modal, GPU management
|
||||
- 📊 **Audit Complet** - [Rapport détaillé](AUDIT_COMPLET_SYSTEME_RPA_VISION_V3.md)
|
||||
## Intention
|
||||
|
||||
**Quick Test**: `bash test_clip.sh`
|
||||
Automatiser des workflows métier par **compréhension sémantique de l'écran**
|
||||
plutôt que par coordonnées de clic fixes. Le système observe l'utilisateur,
|
||||
reconstruit un graphe d'états de l'interface, et cherche à rejouer la
|
||||
procédure en reconnaissant visuellement les éléments cibles — y compris
|
||||
quand l'UI change légèrement.
|
||||
|
||||
## 🎯 Vision
|
||||
Terrain cible principal : postes hospitaliers (Citrix, applications métier
|
||||
web et desktop). Contrainte forte : **100 % local**, pas d'appel à un LLM
|
||||
cloud dans le pipeline par défaut.
|
||||
|
||||
RPA basé sur la **compréhension sémantique** des interfaces, pas sur des coordonnées de clics.
|
||||
|
||||
Le système apprend des workflows en observant l'utilisateur et les automatise de manière robuste grâce à une architecture en 5 couches.
|
||||
|
||||
## 🏗️ Architecture en 5 Couches
|
||||
## Architecture en couches
|
||||
|
||||
```
|
||||
RawSession (Couche 0)
|
||||
RawSession (couche 0) — capture événements + screenshots
|
||||
↓
|
||||
ScreenState (Couche 1) - 4 niveaux d'abstraction
|
||||
ScreenState (couche 1) — états d'écran à plusieurs niveaux d'abstraction
|
||||
↓
|
||||
UIElement Detection (Couche 2) - Types + Rôles sémantiques
|
||||
UIElement (couche 2) — détection sémantique (cascade OCR + templates + VLM)
|
||||
↓
|
||||
State Embedding (Couche 3) - Fusion multi-modale
|
||||
State Embedding (couche 3) — fusion multi-modale + index FAISS
|
||||
↓
|
||||
Workflow Graph (Couche 4) - Nodes + Edges + Learning States
|
||||
Workflow Graph (couche 4) — nœuds, transitions, résolution de cibles
|
||||
```
|
||||
|
||||
## 📁 Structure
|
||||
## État des fonctionnalités (synthèse)
|
||||
|
||||
```
|
||||
rpa_vision_v3/
|
||||
├── core/
|
||||
│ ├── models/ # Couches 0-4 : Structures de données
|
||||
│ ├── capture/ # Couche 0 : Capture événements + screenshots
|
||||
│ ├── detection/ # Couche 2 : Détection UI sémantique
|
||||
│ ├── embedding/ # Couche 3 : Fusion multi-modale + FAISS
|
||||
│ ├── graph/ # Couche 4 : Construction + Matching + Exécution
|
||||
│ └── persistence/ # Sauvegarde/Chargement
|
||||
├── data/
|
||||
│ ├── sessions/ # RawSessions
|
||||
│ ├── screen_states/ # ScreenStates
|
||||
│ ├── embeddings/ # Vecteurs .npy
|
||||
│ ├── faiss_index/ # Index FAISS
|
||||
│ └── workflows/ # Workflow Graphs
|
||||
└── tests/ # Tests unitaires + intégration
|
||||
```
|
||||
Le détail par module est dans [`docs/STATUS.md`](docs/STATUS.md).
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
**Opérationnel**
|
||||
- Capture Windows (Agent V1) + streaming vers serveur Linux
|
||||
- Stockage des sessions brutes (screenshots + événements)
|
||||
- Streaming server FastAPI, sessions en mémoire
|
||||
- Build du package Windows (`deploy/build_package.sh`)
|
||||
|
||||
**Alpha (fonctionnel sur un cas de référence, encore peu généralisé)**
|
||||
- Détection UI par cascade VLM + OCR + templates
|
||||
- Construction de workflow graph depuis une session
|
||||
- Replay E2E supervisé — premier succès sur Notepad le 13 avril 2026
|
||||
- Mode apprentissage : pause et demande d'aide humaine quand la résolution échoue
|
||||
- Embeddings CLIP + index FAISS
|
||||
- Module auth (Fernet + TOTP), federation (LearningPack)
|
||||
- Web Dashboard, Agent Chat
|
||||
|
||||
**En cours**
|
||||
- Visual Workflow Builder (VWB) — bugs DB runtime connus
|
||||
- Self-healing / recovery global
|
||||
- Analytics / reporting
|
||||
- Worker de compilation sessions → ExecutionPlan
|
||||
- Tests E2E multi-applications
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Le pipeline de replay est validé sur un nombre très restreint d'applications.
|
||||
- `TargetMemoryStore` (apprentissage Phase 1) est câblé mais sa base reste
|
||||
vide tant qu'un replay complet n'a pas été cristallisé.
|
||||
- Certaines asymétries entre chemins stricts et legacy dans le serveur de
|
||||
streaming peuvent provoquer des arrêts au lieu de pauses d'apprentissage.
|
||||
- VWB n'est pas encore stable en écriture ; un outil dédié plus simple est
|
||||
envisagé.
|
||||
|
||||
## Démarrage
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Python 3.10 à 3.12
|
||||
- [Ollama](https://ollama.ai) installé et démarré localement
|
||||
- Recommandé : GPU NVIDIA pour l'inférence VLM
|
||||
- Windows 10/11 uniquement pour le client Agent V1
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# 1. Installer Ollama
|
||||
curl -fsSL https://ollama.ai/install.sh | sh # Linux
|
||||
# ou
|
||||
brew install ollama # macOS
|
||||
|
||||
# 2. Démarrer Ollama
|
||||
ollama serve
|
||||
|
||||
# 3. Télécharger le modèle VLM
|
||||
ollama pull qwen3-vl:8b
|
||||
|
||||
# 4. Installer dépendances Python
|
||||
# 1) Cloner puis créer le venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2) Démarrer Ollama et récupérer le modèle VLM par défaut
|
||||
ollama serve &
|
||||
ollama pull gemma4:latest # défaut du projet
|
||||
# Alternatives supportées :
|
||||
# ollama pull qwen3-vl:8b
|
||||
# ollama pull 0000/ui-tars-1.5-7b-q8_0:7b # grounder visuel
|
||||
|
||||
# 3) Copier et ajuster la configuration
|
||||
cp .env.example .env
|
||||
# éditer .env pour vérifier RPA_VLM_MODEL, VLM_ENDPOINT, ports, etc.
|
||||
```
|
||||
|
||||
### Test Rapide
|
||||
### Lancer les services
|
||||
|
||||
Tous les services sont pilotés par `svc.sh` (source de vérité des ports :
|
||||
`services.conf`).
|
||||
|
||||
```bash
|
||||
# Diagnostic système
|
||||
python3 rpa_vision_v3/examples/diagnostic_vlm.py
|
||||
|
||||
# Test de détection
|
||||
./rpa_vision_v3/test_quick.sh
|
||||
./svc.sh status # État de tous les services
|
||||
./svc.sh start # Tout démarrer
|
||||
./svc.sh start streaming # Streaming server uniquement (port 5005)
|
||||
./svc.sh restart api # Redémarrer l'API (port 8000)
|
||||
./svc.sh stop # Tout arrêter
|
||||
```
|
||||
|
||||
### Utilisation - Détection UI
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| 8000 | API Server (upload / traitement core) |
|
||||
| 5001 | Web Dashboard |
|
||||
| 5002 | VWB Backend (Flask) |
|
||||
| 5003 | Monitoring |
|
||||
| 5004 | Agent Chat |
|
||||
| 5005 | Streaming Server (Agent V1 → pipeline core) |
|
||||
| 5006 | Session Cleaner |
|
||||
| 5099 | Worker de compilation (optionnel) |
|
||||
| 3002 | VWB Frontend (Vite/React) |
|
||||
|
||||
```python
|
||||
from rpa_vision_v3.core.detection import create_detector
|
||||
### Client Windows (Agent V1)
|
||||
|
||||
# Créer le détecteur
|
||||
detector = create_detector()
|
||||
|
||||
# Détecter les éléments UI
|
||||
elements = detector.detect("screenshot.png")
|
||||
|
||||
# Utiliser les résultats
|
||||
for elem in elements:
|
||||
print(f"{elem.type:15s} | {elem.role:20s} | {elem.label}")
|
||||
```
|
||||
|
||||
### Utilisation - Workflow (Phase 4 - À venir)
|
||||
|
||||
```python
|
||||
from rpa_vision_v3.core.models import RawSession, ScreenState, Workflow
|
||||
from rpa_vision_v3.core.graph import GraphBuilder, NodeMatcher
|
||||
|
||||
# 1. Capturer une session
|
||||
session = RawSession(...)
|
||||
# ... capturer événements et screenshots
|
||||
|
||||
# 2. Construire workflow automatiquement
|
||||
builder = GraphBuilder(...)
|
||||
workflow = builder.build_from_session(session)
|
||||
|
||||
# 3. Matcher état actuel
|
||||
matcher = NodeMatcher(...)
|
||||
current_state = ScreenState(...)
|
||||
match = matcher.match(current_state, workflow)
|
||||
|
||||
# 4. Exécuter action
|
||||
if match:
|
||||
edge = workflow.get_outgoing_edges(match.node.node_id)[0]
|
||||
executor.execute_edge(edge, current_state)
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Guides Principaux
|
||||
- **Quick Start** : `QUICK_START.md` - Démarrage rapide
|
||||
- **Prochaines Étapes** : `NEXT_STEPS.md` - Roadmap et Phase 4
|
||||
- **Phase 3 Complète** : `PHASE3_COMPLETE.md` - Résumé Phase 3
|
||||
|
||||
### Documentation Technique
|
||||
- **Spec complète** : `.kiro/specs/workflow-graph-implementation/`
|
||||
- **Architecture** : `docs/reference/ARCHITECTURE_VISION_COMPLETE.md`
|
||||
- **Détection Hybride** : `HYBRID_DETECTION_SUMMARY.md`
|
||||
- **Intégration Ollama** : `docs/OLLAMA_INTEGRATION.md`
|
||||
|
||||
## 🎓 Concepts Clés
|
||||
|
||||
### RPA 100% Vision
|
||||
|
||||
- ❌ Pas de coordonnées (x, y) fixes
|
||||
- ✅ Rôles sémantiques (primary_action, form_input, etc.)
|
||||
- ✅ Matching par similarité visuelle et textuelle
|
||||
- ✅ Robuste aux changements d'UI
|
||||
|
||||
### Apprentissage Progressif
|
||||
|
||||
```
|
||||
OBSERVATION (5+ exécutions)
|
||||
↓
|
||||
COACHING (10+ assistances, succès >90%)
|
||||
↓
|
||||
AUTO_CANDIDATE (20+ exécutions, succès >95%)
|
||||
↓
|
||||
AUTO_CONFIRMÉ (validation utilisateur)
|
||||
```
|
||||
|
||||
### State Embedding
|
||||
|
||||
Fusion multi-modale :
|
||||
- 50% Image (screenshot complet)
|
||||
- 30% Texte (texte détecté)
|
||||
- 10% Titre (fenêtre)
|
||||
- 10% UI (éléments détectés)
|
||||
|
||||
## 🧪 Tests
|
||||
Le client capture souris, clavier et écran sur le poste Windows et envoie
|
||||
les données au streaming server Linux.
|
||||
|
||||
```bash
|
||||
# Tests unitaires
|
||||
pytest tests/unit/
|
||||
|
||||
# Tests d'intégration
|
||||
pytest tests/integration/
|
||||
|
||||
# Tests de performance
|
||||
pytest tests/performance/ --benchmark-only
|
||||
# Build du package Windows depuis le repo Linux
|
||||
./deploy/build_package.sh
|
||||
# produit deploy/Lea_v<version>.zip
|
||||
```
|
||||
|
||||
## 📈 Roadmap - 77% Complété (10/13 Phases)
|
||||
Voir [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) pour la maintenance du dépôt
|
||||
(worktrees, build, services).
|
||||
|
||||
### ✅ **Phases Complétées**
|
||||
- [x] **Phase 1-2** : Fondations + Embeddings FAISS ✅
|
||||
- [x] **Phase 4-6** : Détection UI + Workflow Graphs + Action Execution ✅
|
||||
- [x] **Phase 7-8** : Learning System + Training System ✅
|
||||
- [x] **Phase 10-12** : GPU Management + Performance + Monitoring ✅
|
||||
## Arborescence du dépôt
|
||||
|
||||
### 🎯 **Phases Restantes**
|
||||
- [ ] **Phase 3** : Checkpoint Final (tests storage)
|
||||
- [ ] **Phase 9** : Visual Workflow Builder (90% → 100%)
|
||||
- [ ] **Phase 13** : Tests End-to-End + Documentation finale
|
||||
```
|
||||
rpa_vision_v3/
|
||||
├── agent_v0/ # Agent V1 (client Windows) + serveur de streaming
|
||||
│ ├── agent_v1/ # Source de l'agent (capture, UI tray, exécution)
|
||||
│ └── server_v1/ # FastAPI streaming + processeurs
|
||||
├── core/ # Pipeline core
|
||||
│ ├── detection/ # Cascade VLM + OCR + templates
|
||||
│ ├── embedding/ # CLIP + FAISS
|
||||
│ ├── graph/ # Construction / matching de workflow graphs
|
||||
│ ├── execution/ # Résolution de cibles, actions LLM
|
||||
│ ├── learning/ # TargetMemoryStore (apprentissage)
|
||||
│ ├── auth/ # Vault Fernet + TOTP
|
||||
│ └── federation/ # Export/import de LearningPacks
|
||||
├── visual_workflow_builder/ # VWB (backend Flask + frontend React Vite)
|
||||
├── web_dashboard/ # Dashboard Flask + SocketIO
|
||||
├── agent_chat/ # Interface conversationnelle + planner
|
||||
├── deploy/ # Scripts de build et unités systemd
|
||||
├── data/ # Sessions, embeddings, index FAISS, apprentissage
|
||||
├── docs/ # Documentation technique
|
||||
├── tests/ # pytest (unit, integration, e2e)
|
||||
├── services.conf # Source de vérité des ports
|
||||
├── svc.sh # Orchestrateur des services
|
||||
└── run.sh # Démarrage tout-en-un (legacy, préférer svc.sh)
|
||||
```
|
||||
|
||||
### 🚀 **Composants Production-Ready**
|
||||
- **Agent V0** : Capture cross-platform + Encryption ✅
|
||||
- **Server API** : Processing pipeline + Web dashboard ✅
|
||||
- **Analytics System** : Monitoring + Insights + Reporting ✅
|
||||
- **Self-Healing** : Automatic adaptation + Recovery ✅
|
||||
## Tests
|
||||
|
||||
## 🤝 Contribution
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
|
||||
Voir `.kiro/specs/workflow-graph-implementation/tasks.md` pour les tâches en cours.
|
||||
# Tests rapides (hors marqueur slow)
|
||||
pytest -m "not slow" -q
|
||||
|
||||
## 📄 Licence
|
||||
# Tests d'intégration (streaming, pipeline)
|
||||
pytest tests/integration/ -q
|
||||
|
||||
Propriétaire - Tous droits réservés
|
||||
# Tests E2E
|
||||
pytest tests/test_pipeline_e2e.py -q
|
||||
```
|
||||
|
||||
Quelques tests legacy sont connus comme cassés — voir la mémoire projet et
|
||||
`docs/` pour la liste.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`docs/STATUS.md`](docs/STATUS.md) — état réel par module
|
||||
- [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) — tâches d'administration (worktrees, build)
|
||||
- [`docs/VISION_RPA_INTELLIGENT.md`](docs/VISION_RPA_INTELLIGENT.md) — cahier des charges
|
||||
- [`docs/PLAN_ACTEUR_V1.md`](docs/PLAN_ACTEUR_V1.md) — architecture 3 niveaux (Macro / Méso / Micro)
|
||||
- [`docs/CONFORMITE_AI_ACT.md`](docs/CONFORMITE_AI_ACT.md) — journalisation, floutage, rétention
|
||||
|
||||
## Concepts clés
|
||||
|
||||
- **RPA 100 % vision** : pas de coordonnées fixes ; l'agent localise un
|
||||
élément par ce qu'il voit (label + contexte visuel), pas par `x,y`.
|
||||
- **Apprentissage progressif** : mode shadow → assisté → autonome, validé
|
||||
par supervision humaine sur les échecs.
|
||||
- **LLM 100 % local** : Ollama sur la machine. Aucun appel cloud dans le
|
||||
pipeline par défaut (cf. feedback projet `feedback_local_only.md`).
|
||||
|
||||
## Licence
|
||||
|
||||
Propriétaire — tous droits réservés.
|
||||
|
||||
@@ -20,6 +20,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Forcer l'import de config AVANT pynput/mss pour garantir que le
|
||||
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
|
||||
@@ -88,6 +89,11 @@ class ActionExecutorV1:
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
# Gestionnaire de notifications toast (pour les messages utilisateur)
|
||||
self._notification_manager = None
|
||||
# Drapeau sécurité : positionné quand on détecte un dialogue système
|
||||
# (UAC, CredUI, SmartScreen…). Lu par le caller pour signaler une
|
||||
# pause supervisée au serveur (`paused_need_help`).
|
||||
# Cf. core/system_dialog_guard.py
|
||||
self._system_dialog_pause: Optional[Dict[str, Any]] = None
|
||||
# Log de la resolution physique pour le diagnostic DPI
|
||||
self._log_screen_info()
|
||||
|
||||
@@ -537,6 +543,11 @@ class ActionExecutorV1:
|
||||
"visual_resolved": False,
|
||||
}
|
||||
|
||||
# Réinitialiser le drapeau dialogue système à chaque action
|
||||
# (sinon une détection lors d'une action précédente ferait bail-out
|
||||
# immédiat sur toutes les suivantes).
|
||||
self._system_dialog_pause = None
|
||||
|
||||
# ── Bloc conditionnel : skip si le dialogue n'est pas apparu ──
|
||||
# Les actions marquées conditional_on_window ne s'exécutent que
|
||||
# si la fenêtre attendue est effectivement présente. Sinon → skip.
|
||||
@@ -594,6 +605,23 @@ class ActionExecutorV1:
|
||||
f"{int(action.get('y_pct', 0) * height)})"
|
||||
)
|
||||
|
||||
# ── SÉCURITÉ : check proactif AVANT toute action ──
|
||||
# Si un UAC / CredUI / SmartScreen est déjà à l'écran (apparu
|
||||
# spontanément entre deux actions), on pause IMMÉDIATEMENT
|
||||
# sans rien tenter. Clic / type / key_combo : tous bloqués.
|
||||
# Cf. core/system_dialog_guard.py
|
||||
if action_type in ("click", "type", "key_combo", "double_click", "right_click"):
|
||||
if self._check_and_pause_on_system_dialog(context=f"pre_action_{action_type}"):
|
||||
pause_info = self._system_dialog_pause or {}
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
# Resolution visuelle des coordonnees si demande
|
||||
x_pct = action.get("x_pct", 0.0)
|
||||
y_pct = action.get("y_pct", 0.0)
|
||||
@@ -737,6 +765,27 @@ class ActionExecutorV1:
|
||||
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")
|
||||
|
||||
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
|
||||
# Avant de suivre les coordonnées du serveur (VLM-based,
|
||||
# donc faillible) ou de rappeler le VLM local, on
|
||||
# vérifie que la popup n'est PAS un UAC/CredUI/SmartScreen.
|
||||
if self._check_and_pause_on_system_dialog(
|
||||
context="observer_popup"
|
||||
):
|
||||
# Dialogue système → on remonte la pause au replay.
|
||||
# On renvoie le résultat immédiatement pour que le
|
||||
# serveur passe en paused_need_help.
|
||||
pause_info = self._system_dialog_pause or {}
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
if popup_coords:
|
||||
real_x = int(popup_coords["x_pct"] * width)
|
||||
real_y = int(popup_coords["y_pct"] * height)
|
||||
@@ -745,7 +794,20 @@ class ActionExecutorV1:
|
||||
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
|
||||
else:
|
||||
# Pas de coordonnées → fallback sur handle_popup_vlm classique
|
||||
# (qui re-vérifie aussi system_dialog en interne)
|
||||
self._handle_popup_vlm()
|
||||
# Si _handle_popup_vlm a détecté un dialogue système,
|
||||
# on remonte la pause au replay.
|
||||
if self._system_dialog_pause:
|
||||
pause_info = self._system_dialog_pause
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
elif obs_state == "unexpected":
|
||||
# État inattendu (pas la bonne page/écran)
|
||||
@@ -840,6 +902,24 @@ class ActionExecutorV1:
|
||||
f"({policy_decision.reason})"
|
||||
)
|
||||
|
||||
# ── SÉCURITÉ : si Policy a détecté un dialogue système
|
||||
# pendant son _try_close_popup, on remonte la pause au
|
||||
# serveur SANS tenter aucune action supplémentaire.
|
||||
if self._system_dialog_pause:
|
||||
pause_info = self._system_dialog_pause
|
||||
logger.critical(
|
||||
f"[POLICY] Dialogue système détecté par popup handler "
|
||||
f"({pause_info.get('category')}) — pause supervisée"
|
||||
)
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"system_dialog:{pause_info.get('category', 'unknown')}"
|
||||
)
|
||||
result["system_dialog"] = pause_info
|
||||
result["needs_human"] = True
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
return result
|
||||
|
||||
if policy_decision.decision == Decision.RETRY:
|
||||
resolved2 = self._resolve_target_visual(
|
||||
server_url, target_spec, x_pct, y_pct, width, height
|
||||
@@ -1771,6 +1851,9 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
"target_spec": result.get("target_spec"),
|
||||
# Correction humaine (mode apprentissage supervisé)
|
||||
"correction": result.get("correction"),
|
||||
# Sécurité : dialogue système critique détecté (UAC, CredUI, SmartScreen)
|
||||
"system_dialog": result.get("system_dialog"),
|
||||
"needs_human": result.get("needs_human"),
|
||||
}
|
||||
try:
|
||||
resp2 = requests.post(
|
||||
@@ -1796,6 +1879,129 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Garde-fou sécurité : dialogues système Windows (UAC, CredUI, SmartScreen)
|
||||
# =========================================================================
|
||||
|
||||
def _check_and_pause_on_system_dialog(self, context: str = "") -> bool:
|
||||
"""Détecter un dialogue système critique et positionner la pause.
|
||||
|
||||
Si un dialogue UAC, CredUI, SmartScreen (etc.) est actif, on :
|
||||
- N'appelle JAMAIS le VLM sur l'image (évite de lui faire suggérer "Oui")
|
||||
- Ne clique JAMAIS automatiquement
|
||||
- Positionne `self._system_dialog_pause` pour que le caller signale
|
||||
une pause supervisée au serveur
|
||||
- Notifie l'utilisateur via systray
|
||||
- Log l'événement pour audit
|
||||
|
||||
Args:
|
||||
context: Chaîne d'origine pour les logs (ex: "handle_popup_vlm",
|
||||
"observer_popup_click").
|
||||
|
||||
Returns:
|
||||
True si un dialogue système a été détecté (le caller doit
|
||||
stopper toute action automatique). False sinon.
|
||||
"""
|
||||
try:
|
||||
from .system_dialog_guard import detect_current_system_dialog
|
||||
detection = detect_current_system_dialog()
|
||||
except Exception as e:
|
||||
# Fix P0-D : fail-closed (principe "faux positif tolérable,
|
||||
# faux négatif catastrophique"). Si la détection échoue, on ne
|
||||
# peut PAS affirmer que l'écran est sûr — on pause par précaution
|
||||
# et on demande à l'humain. Un UAC non détecté à cause d'un bug
|
||||
# de détection = vecteur d'attaque ransomware.
|
||||
logger.critical(
|
||||
f"[SYS-DIALOG] Erreur détection dialogue système "
|
||||
f"(context={context}) : {e} — PAUSE SUPERVISÉE par précaution "
|
||||
f"(fail-closed : impossible de garantir l'absence de dialogue "
|
||||
f"système critique)"
|
||||
)
|
||||
print(
|
||||
f" [SÉCURITÉ] Vérification du garde-fou système a échoué "
|
||||
f"— pause supervisée par précaution ({type(e).__name__})"
|
||||
)
|
||||
# Positionner le flag de pause avec une catégorie dédiée pour que
|
||||
# le caller (execute_replay_action) remonte "paused_need_help".
|
||||
self._system_dialog_pause = {
|
||||
"category": "unknown_check_failed",
|
||||
"matched_signal": "exception",
|
||||
"matched_value": type(e).__name__,
|
||||
"reason": f"system_dialog_guard détection exception: {e}",
|
||||
"context": context,
|
||||
}
|
||||
# Notification utilisateur best-effort.
|
||||
try:
|
||||
notifier = self.notifier
|
||||
msg = (
|
||||
"Vérification du garde-fou système a échoué — "
|
||||
"pause supervisée par précaution. Léa ne clique pas."
|
||||
)
|
||||
if hasattr(notifier, "notify"):
|
||||
notifier.notify(
|
||||
title="Léa — sécurité",
|
||||
message=msg,
|
||||
timeout=10,
|
||||
)
|
||||
elif hasattr(notifier, "error"):
|
||||
notifier.error(msg)
|
||||
except Exception as notify_err:
|
||||
logger.debug(f"[SYS-DIALOG] Notification échouée : {notify_err}")
|
||||
return True
|
||||
|
||||
if not detection.is_system_dialog:
|
||||
return False
|
||||
|
||||
# Audit log : TOUJOURS tracer, même si la pause est redondante.
|
||||
logger.critical(
|
||||
f"[SYS-DIALOG] REFUS D'INTERACTION — {detection.category} "
|
||||
f"détecté via {detection.matched_signal}='{detection.matched_value}' "
|
||||
f"(context={context}). Pause supervisée demandée."
|
||||
)
|
||||
print(
|
||||
f" [SÉCURITÉ] Dialogue système détecté : {detection.category} "
|
||||
f"— Léa NE CLIQUE PAS, intervention humaine requise"
|
||||
)
|
||||
|
||||
# Positionner le flag pour le caller (execute_replay_action)
|
||||
self._system_dialog_pause = {
|
||||
"category": detection.category,
|
||||
"matched_signal": detection.matched_signal,
|
||||
"matched_value": detection.matched_value,
|
||||
"reason": detection.reason,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
# Notification systray (best-effort, ne jamais planter dessus)
|
||||
try:
|
||||
cat_fr = {
|
||||
"uac_consent": "élévation de privilèges (UAC)",
|
||||
"windows_credential_prompt": "demande de mot de passe Windows",
|
||||
"smartscreen": "alerte SmartScreen",
|
||||
"windows_defender": "alerte Windows Defender",
|
||||
"driver_install": "installation de pilote",
|
||||
"security_toast": "notification de sécurité",
|
||||
"unknown_system_dialog": "dialogue système inconnu",
|
||||
}.get(detection.category, detection.category)
|
||||
msg = (
|
||||
f"Dialogue système détecté ({cat_fr}) — "
|
||||
f"intervention humaine requise. Léa ne clique pas."
|
||||
)
|
||||
# On essaie d'abord un formateur explicite ; sinon fallback error
|
||||
notifier = self.notifier
|
||||
if hasattr(notifier, "notify"):
|
||||
notifier.notify(
|
||||
title="Léa — sécurité",
|
||||
message=msg,
|
||||
timeout=10,
|
||||
)
|
||||
elif hasattr(notifier, "error"):
|
||||
notifier.error(msg)
|
||||
except Exception as e:
|
||||
logger.debug(f"[SYS-DIALOG] Notification échouée : {e}")
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Gestion intelligente des popups imprévues (VLM)
|
||||
# =========================================================================
|
||||
@@ -1817,9 +2023,22 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
Une seule tentative par action (pas de boucle infinie).
|
||||
|
||||
**SÉCURITÉ** : avant toute interaction, on détecte les dialogues
|
||||
système Windows critiques (UAC, CredUI, SmartScreen). Si un tel
|
||||
dialogue est actif → pause supervisée immédiate, pas de VLM, pas
|
||||
de clic automatique. Cf. system_dialog_guard.py.
|
||||
|
||||
Returns:
|
||||
True si une popup a été gérée (fermée), False sinon.
|
||||
False aussi en cas de dialogue système → le caller doit traiter
|
||||
`self._system_dialog_pause` pour signaler la pause au serveur.
|
||||
"""
|
||||
# ── SÉCURITÉ : refus absolu de cliquer sur un dialogue système ──
|
||||
# Un UAC / CredUI / SmartScreen ne doit JAMAIS recevoir de clic
|
||||
# automatique. On détecte AVANT le VLM (coût minimal ~20ms UIA).
|
||||
if self._check_and_pause_on_system_dialog(context="handle_popup_vlm"):
|
||||
return False
|
||||
|
||||
# Capturer le screenshot actuel (résolution native pour template matching)
|
||||
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
|
||||
if not screenshot_b64:
|
||||
|
||||
@@ -85,6 +85,10 @@ class PolicyEngine:
|
||||
2. Si retry déjà fait → demander à l'acteur gemma4
|
||||
3. Selon gemma4 : SKIP, ABORT, ou SUPERVISE
|
||||
|
||||
**SÉCURITÉ** : si, pendant l'étape 1, le handler popup détecte un
|
||||
dialogue système Windows (UAC, CredUI, SmartScreen…), on bascule
|
||||
immédiatement en SUPERVISE. Cf. system_dialog_guard.py.
|
||||
|
||||
Args:
|
||||
action: L'action qui a échoué
|
||||
target_spec: La cible non trouvée
|
||||
@@ -96,6 +100,22 @@ class PolicyEngine:
|
||||
# ── Étape 1 : Tentative de fermeture popup (premier essai) ──
|
||||
if retry_count == 0:
|
||||
popup_handled = self._try_close_popup()
|
||||
|
||||
# Si le popup handler a détecté un dialogue système, on
|
||||
# bascule immédiatement en SUPERVISE — pas de retry, pas de
|
||||
# gemma4 : on rend la main à l'humain.
|
||||
if getattr(self._executor, "_system_dialog_pause", None):
|
||||
sd = self._executor._system_dialog_pause
|
||||
return PolicyDecision(
|
||||
decision=Decision.SUPERVISE,
|
||||
reason=(
|
||||
f"Dialogue système détecté ({sd.get('category', '?')}) — "
|
||||
f"refus d'interaction automatique"
|
||||
),
|
||||
action_taken="system_dialog_blocked",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
if popup_handled:
|
||||
return PolicyDecision(
|
||||
decision=Decision.RETRY,
|
||||
|
||||
448
agent_v0/agent_v1/core/system_dialog_guard.py
Normal file
448
agent_v0/agent_v1/core/system_dialog_guard.py
Normal file
@@ -0,0 +1,448 @@
|
||||
# agent_v1/core/system_dialog_guard.py
|
||||
"""
|
||||
Garde-fou sécurité : détection des dialogues système Windows critiques.
|
||||
|
||||
==============================================================================
|
||||
POURQUOI ?
|
||||
==============================================================================
|
||||
|
||||
Pendant un replay, si un dialogue UAC, CredUI (mot de passe Windows),
|
||||
SmartScreen ou une notification de sécurité Windows apparaît, Léa pourrait
|
||||
demander au VLM "quel bouton cliquer" et recevoir "Oui" en réponse.
|
||||
|
||||
→ **Léa cliquerait OUI sur une élévation UAC** → vecteur d'attaque ransomware.
|
||||
|
||||
Ce module fournit la détection de ces dialogues pour que l'exécuteur
|
||||
**ne clique JAMAIS dessus automatiquement**. La décision est renvoyée à
|
||||
l'humain (pause supervisée).
|
||||
|
||||
==============================================================================
|
||||
PRINCIPE
|
||||
==============================================================================
|
||||
|
||||
- **Faux positif tolérable** : on préfère pauser pour rien plutôt que cliquer
|
||||
sur un UAC.
|
||||
- **Faux négatif catastrophique** : mieux vaut être trop prudent.
|
||||
- **Multi-signal** : titre, ClassName UIA, nom de processus, parent_path.
|
||||
Un seul signal suffit à bloquer.
|
||||
- **Compatible Citrix** : les dialogues UAC d'un client Citrix apparaissent
|
||||
aussi dans la VM distante — la détection par classe UIA fonctionne.
|
||||
|
||||
==============================================================================
|
||||
PATTERNS DE DÉTECTION (ordre de criticité décroissant)
|
||||
==============================================================================
|
||||
|
||||
1. UAC Consent (élévation de privilèges)
|
||||
- ClassName : `$$$Secure UAP Dummy Window Class$$$`
|
||||
- Process : `consent.exe`
|
||||
- Titre : "Contrôle de compte d'utilisateur", "User Account Control"
|
||||
|
||||
2. CredUI (prompt mot de passe Windows)
|
||||
- ClassName : `Credential Dialog Xaml Host`
|
||||
- Process : `credentialuibroker.exe`, `credui.exe`
|
||||
- Titre : "Sécurité Windows", "Windows Security"
|
||||
|
||||
3. SmartScreen (protection contre applications inconnues)
|
||||
- Process : `smartscreen.exe`
|
||||
- Titre : "Windows a protégé votre ordinateur", "Windows protected your PC"
|
||||
|
||||
4. Windows Defender / Security Center
|
||||
- Process : `securityhealthhost.exe`, `msmpeng.exe`
|
||||
- Titre : "Sécurité Windows", "Windows Defender"
|
||||
|
||||
5. Signatures pilotes / driver install
|
||||
- Titre : "Installer ce pilote", "Driver signature"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Catégories de dialogues système (pour logging + messages)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SystemDialogCategory:
|
||||
"""Catégories de dialogues système à bloquer absolument."""
|
||||
UAC = "uac_consent" # Élévation de privilèges
|
||||
CREDUI = "windows_credential_prompt" # Prompt de mot de passe
|
||||
SMARTSCREEN = "smartscreen" # Protection SmartScreen
|
||||
DEFENDER = "windows_defender" # Alerte Windows Defender
|
||||
DRIVER = "driver_install" # Installation pilote signé
|
||||
SECURITY_TOAST = "security_toast" # Toast de sécurité Windows
|
||||
UNKNOWN_DIALOG = "unknown_system_dialog" # Dialogue #32770 sans app connue
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemDialogDetection:
|
||||
"""Résultat d'une analyse de dialogue système."""
|
||||
is_system_dialog: bool
|
||||
category: str = "" # Valeur de SystemDialogCategory
|
||||
matched_signal: str = "" # Ex: "class_name=Consent.exe"
|
||||
matched_value: str = "" # La valeur qui a matché
|
||||
reason: str = "" # Explication lisible
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"is_system_dialog": self.is_system_dialog,
|
||||
"category": self.category,
|
||||
"matched_signal": self.matched_signal,
|
||||
"matched_value": self.matched_value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signatures de détection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# ClassName UIA (casse préservée — Windows exposées telle quelle par UIA).
|
||||
# Utilisées telles quelles puis en minuscules pour matcher avec souplesse.
|
||||
_CLASS_NAMES_SYSTEM = {
|
||||
# UAC Consent
|
||||
"$$$Secure UAP Dummy Window Class$$$": SystemDialogCategory.UAC,
|
||||
"Credential Dialog Xaml Host": SystemDialogCategory.CREDUI,
|
||||
# Windows Credential UI ancien nom
|
||||
"CredentialDialogXamlHost": SystemDialogCategory.CREDUI,
|
||||
}
|
||||
|
||||
# Nom de processus (comparaison insensible à la casse, .exe normalisé)
|
||||
_PROCESS_NAMES_SYSTEM = {
|
||||
"consent.exe": SystemDialogCategory.UAC,
|
||||
"credentialuibroker.exe": SystemDialogCategory.CREDUI,
|
||||
"credui.exe": SystemDialogCategory.CREDUI,
|
||||
"credwiz.exe": SystemDialogCategory.CREDUI,
|
||||
"smartscreen.exe": SystemDialogCategory.SMARTSCREEN,
|
||||
"securityhealthhost.exe": SystemDialogCategory.DEFENDER,
|
||||
"securityhealthui.exe": SystemDialogCategory.DEFENDER,
|
||||
"securityhealthsystray.exe": SystemDialogCategory.DEFENDER,
|
||||
"msmpeng.exe": SystemDialogCategory.DEFENDER,
|
||||
"windowsdefender.exe": SystemDialogCategory.DEFENDER,
|
||||
"msiexec.exe": SystemDialogCategory.DRIVER, # prompts pilotes signés
|
||||
"drvinst.exe": SystemDialogCategory.DRIVER,
|
||||
}
|
||||
|
||||
# Motifs titre (insensibles à la casse, regex avec word boundaries)
|
||||
# On ne matche pas les titres génériques trop larges pour limiter les faux
|
||||
# positifs sur OSIRIS/OBSIUS/MEDSPHERE.
|
||||
_TITLE_PATTERNS_SYSTEM: Tuple[Tuple[re.Pattern, str], ...] = (
|
||||
# UAC
|
||||
(re.compile(r"contr[oô]le\s+de\s+compte\s+d'?utilisateur", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"\buser\s+account\s+control\b", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"voulez-vous\s+autoriser\s+cette\s+application", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
(re.compile(r"do\s+you\s+want\s+to\s+allow\s+this\s+app", re.IGNORECASE),
|
||||
SystemDialogCategory.UAC),
|
||||
|
||||
# CredUI / Sécurité Windows
|
||||
(re.compile(r"\bs[eé]curit[eé]\s+windows\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"\bwindows\s+security\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"entrer\s+les\s+informations\s+d'?identification", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"enter\s+(?:your\s+)?credentials?", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"connectez-vous\s+[aà]\s+votre\s+compte", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
(re.compile(r"\bsign\s+in\s+to\s+your\s+account\b", re.IGNORECASE),
|
||||
SystemDialogCategory.CREDUI),
|
||||
|
||||
# SmartScreen
|
||||
(re.compile(r"windows\s+a\s+prot[eé]g[eé]", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"windows\s+protected\s+your\s+pc", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\bsmartscreen\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\b[eé]diteur\s+inconnu\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
(re.compile(r"\bunknown\s+publisher\b", re.IGNORECASE),
|
||||
SystemDialogCategory.SMARTSCREEN),
|
||||
|
||||
# Windows Defender
|
||||
(re.compile(r"windows\s+defender", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
(re.compile(r"menace\s+d[eé]tect[eé]e", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
(re.compile(r"threat\s+detected", re.IGNORECASE),
|
||||
SystemDialogCategory.DEFENDER),
|
||||
|
||||
# Driver
|
||||
(re.compile(r"installer\s+ce\s+pilote", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
(re.compile(r"install\s+this\s+driver", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
(re.compile(r"signature\s+num[eé]rique\s+du\s+pilote", re.IGNORECASE),
|
||||
SystemDialogCategory.DRIVER),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fonctions de détection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _normalize_process(name: str) -> str:
|
||||
"""Normaliser un nom de processus pour comparaison."""
|
||||
if not name:
|
||||
return ""
|
||||
name = name.strip().lower()
|
||||
# Enlever le chemin éventuel
|
||||
if "\\" in name or "/" in name:
|
||||
name = name.replace("\\", "/").split("/")[-1]
|
||||
# Assurer suffixe .exe pour matcher le dictionnaire
|
||||
if not name.endswith(".exe") and name:
|
||||
# Les process_name peuvent venir sans .exe (psutil) — on ajoute
|
||||
# pour avoir une clé uniforme
|
||||
name_with_exe = name + ".exe"
|
||||
if name_with_exe in _PROCESS_NAMES_SYSTEM:
|
||||
return name_with_exe
|
||||
return name
|
||||
|
||||
|
||||
def _check_class_name(class_name: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un ClassName UIA matche un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_class, reason) si match, None sinon.
|
||||
"""
|
||||
if not class_name:
|
||||
return None
|
||||
|
||||
# Match exact
|
||||
if class_name in _CLASS_NAMES_SYSTEM:
|
||||
cat = _CLASS_NAMES_SYSTEM[class_name]
|
||||
return (cat, class_name, f"ClassName UIA '{class_name}' = dialogue système {cat}")
|
||||
|
||||
# Match insensible à la casse + normalisation espaces
|
||||
cn_norm = class_name.strip()
|
||||
for known, cat in _CLASS_NAMES_SYSTEM.items():
|
||||
if cn_norm.lower() == known.lower():
|
||||
return (cat, class_name, f"ClassName UIA ~= '{known}' ({cat})")
|
||||
|
||||
# Détection souple UAC (il existe quelques variantes de la classe secure)
|
||||
if "secure uap" in class_name.lower() or "uap dummy" in class_name.lower():
|
||||
return (SystemDialogCategory.UAC, class_name,
|
||||
f"ClassName '{class_name}' contient 'Secure UAP' → UAC")
|
||||
|
||||
# Credential XAML Host
|
||||
if "credential" in class_name.lower() and "xaml" in class_name.lower():
|
||||
return (SystemDialogCategory.CREDUI, class_name,
|
||||
f"ClassName '{class_name}' contient Credential+Xaml → CredUI")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_process_name(process_name: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un nom de processus est un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_process, reason) si match, None sinon.
|
||||
"""
|
||||
if not process_name:
|
||||
return None
|
||||
|
||||
norm = _normalize_process(process_name)
|
||||
if norm in _PROCESS_NAMES_SYSTEM:
|
||||
cat = _PROCESS_NAMES_SYSTEM[norm]
|
||||
return (cat, process_name, f"Processus '{norm}' = {cat}")
|
||||
return None
|
||||
|
||||
|
||||
def _check_title(title: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""Vérifier si un titre de fenêtre matche un dialogue système.
|
||||
|
||||
Returns:
|
||||
(category, matched_pattern, reason) si match, None sinon.
|
||||
"""
|
||||
if not title:
|
||||
return None
|
||||
|
||||
for pattern, cat in _TITLE_PATTERNS_SYSTEM:
|
||||
m = pattern.search(title)
|
||||
if m:
|
||||
return (cat, m.group(0),
|
||||
f"Titre '{title[:60]}' matche '{pattern.pattern}' → {cat}")
|
||||
return None
|
||||
|
||||
|
||||
def is_system_dialog(
|
||||
uia_snapshot: Optional[Dict[str, Any]] = None,
|
||||
window_info: Optional[Dict[str, Any]] = None,
|
||||
) -> SystemDialogDetection:
|
||||
"""Déterminer si la fenêtre active est un dialogue système critique.
|
||||
|
||||
La détection combine plusieurs signaux — **un seul suffit à bloquer**.
|
||||
On préfère un faux positif (pause inutile) à un faux négatif (clic UAC).
|
||||
|
||||
Args:
|
||||
uia_snapshot: Dict avec champs `class_name`, `process_name`,
|
||||
`parent_path`, `name`. Peut être None si UIA indisponible.
|
||||
window_info: Dict avec champs `title`, `app_name`. Peut être None.
|
||||
|
||||
Returns:
|
||||
SystemDialogDetection avec is_system_dialog=True si un dialogue
|
||||
système est détecté.
|
||||
|
||||
Exemples::
|
||||
|
||||
det = is_system_dialog(window_info={"title": "User Account Control"})
|
||||
assert det.is_system_dialog # UAC détecté
|
||||
|
||||
det = is_system_dialog(uia_snapshot={"class_name": "$$$Secure UAP Dummy Window Class$$$"})
|
||||
assert det.is_system_dialog # UAC via ClassName
|
||||
|
||||
det = is_system_dialog(window_info={"title": "OSIRIS - Patient Dupont"})
|
||||
assert not det.is_system_dialog # Application métier → OK
|
||||
"""
|
||||
# ── Signal 1 : ClassName UIA ──
|
||||
if uia_snapshot:
|
||||
cn = uia_snapshot.get("class_name", "") or ""
|
||||
r = _check_class_name(cn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="class_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# Explorer aussi les parents (le champ cliqué peut être un bouton
|
||||
# interne dont la ClassName est "Button", mais le root de la fenêtre
|
||||
# est le Consent.exe).
|
||||
for parent in uia_snapshot.get("parent_path", []) or []:
|
||||
p_cn = parent.get("class_name", "") or ""
|
||||
r = _check_class_name(p_cn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="parent_class_name",
|
||||
matched_value=matched,
|
||||
reason=f"Parent : {reason}",
|
||||
)
|
||||
|
||||
# ── Signal 2 : Process name ──
|
||||
if uia_snapshot:
|
||||
pn = uia_snapshot.get("process_name", "") or ""
|
||||
r = _check_process_name(pn)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="process_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if window_info:
|
||||
app = window_info.get("app_name", "") or ""
|
||||
r = _check_process_name(app)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="app_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# ── Signal 3 : Titre de fenêtre ──
|
||||
if window_info:
|
||||
title = window_info.get("title", "") or ""
|
||||
r = _check_title(title)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="window_title",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if uia_snapshot:
|
||||
# Certains dialogues système remontent leur titre dans uia.name
|
||||
uia_name = uia_snapshot.get("name", "") or ""
|
||||
r = _check_title(uia_name)
|
||||
if r:
|
||||
cat, matched, reason = r
|
||||
return SystemDialogDetection(
|
||||
is_system_dialog=True,
|
||||
category=cat,
|
||||
matched_signal="uia_name",
|
||||
matched_value=matched,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return SystemDialogDetection(is_system_dialog=False)
|
||||
|
||||
|
||||
def detect_current_system_dialog() -> SystemDialogDetection:
|
||||
"""Analyser l'écran actuel et détecter un dialogue système.
|
||||
|
||||
Helper autonome qui interroge à la fois `get_active_window_info()` et
|
||||
le helper UIA (si dispo) pour obtenir la détection la plus fiable.
|
||||
|
||||
Returns:
|
||||
SystemDialogDetection. Si un signal matche, is_system_dialog=True.
|
||||
Si rien n'est disponible (Linux, UIA absent), is_system_dialog=False
|
||||
mais le caller peut encore fallback sur une analyse par titre.
|
||||
"""
|
||||
window_info: Optional[Dict[str, Any]] = None
|
||||
uia_snapshot: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Fenêtre active (cross-platform)
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
window_info = get_active_window_info()
|
||||
except Exception as e: # pragma: no cover — best-effort
|
||||
logger.debug(f"[SYS-DIALOG] window_info indisponible : {e}")
|
||||
|
||||
# UIA local (Windows uniquement, via lea_uia.exe)
|
||||
try:
|
||||
from .uia_helper import get_shared_helper
|
||||
helper = get_shared_helper()
|
||||
if helper.available:
|
||||
# On capture l'élément focalisé (root = fenêtre active)
|
||||
element = helper.capture_focused(max_depth=2)
|
||||
if element is not None:
|
||||
uia_snapshot = element.to_dict()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug(f"[SYS-DIALOG] UIA indisponible : {e}")
|
||||
|
||||
detection = is_system_dialog(
|
||||
uia_snapshot=uia_snapshot, window_info=window_info,
|
||||
)
|
||||
|
||||
if detection.is_system_dialog:
|
||||
logger.warning(
|
||||
f"[SYS-DIALOG] BLOCAGE — dialogue système détecté "
|
||||
f"[{detection.category}] via {detection.matched_signal}='{detection.matched_value}' "
|
||||
f"— {detection.reason}"
|
||||
)
|
||||
return detection
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SystemDialogCategory",
|
||||
"SystemDialogDetection",
|
||||
"is_system_dialog",
|
||||
"detect_current_system_dialog",
|
||||
]
|
||||
380
agent_v0/agent_v1/network/persistent_buffer.py
Normal file
380
agent_v0/agent_v1/network/persistent_buffer.py
Normal file
@@ -0,0 +1,380 @@
|
||||
# agent_v1/network/persistent_buffer.py
|
||||
"""
|
||||
Buffer persistant SQLite pour les événements/images qui n'ont pas pu être envoyés.
|
||||
|
||||
Résout le bloquant AI Act Article 12 : en cas de coupure serveur ou de queue pleine,
|
||||
les événements prioritaires (click, key, action, screenshot) sont persistés sur disque
|
||||
au lieu d'être silencieusement perdus. Ils sont rejoués à la reconnexion.
|
||||
|
||||
Caractéristiques :
|
||||
- SQLite fichier unique (agent_v1/buffer/pending_events.db), thread-safe
|
||||
- Async : les écritures se font depuis un thread daemon, jamais bloquant
|
||||
- Quota : compteur d'attempts par item, abandon après MAX_ATTEMPTS
|
||||
- Robustesse : un fichier corrompu est renommé et recréé vide
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nombre max de tentatives avant abandon définitif d'un item
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
# Taille max du buffer en items pour éviter une explosion disque
|
||||
# (typiquement : 1000 events + 1000 images = quelques Mo de SQLite)
|
||||
MAX_BUFFER_ITEMS = 2000
|
||||
|
||||
|
||||
class PersistentBuffer:
|
||||
"""Buffer SQLite pour événements/images en attente d'envoi.
|
||||
|
||||
Deux tables :
|
||||
- pending_events (id, session_id, payload_json, attempts, created_at)
|
||||
- pending_images (id, session_id, shot_id, image_path, attempts, created_at)
|
||||
|
||||
Usage :
|
||||
buf = PersistentBuffer(base_dir / "buffer")
|
||||
buf.add_event(session_id, event_dict) # persiste un event
|
||||
buf.add_image(session_id, image_path, shot_id) # persiste une image
|
||||
for row in buf.drain_events(): # itère sur les events
|
||||
if envoyer(row): buf.delete_event(row["id"])
|
||||
else: buf.mark_attempt(row["id"], "event")
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_dir: Path):
|
||||
self.buffer_dir = Path(buffer_dir)
|
||||
self.buffer_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.db_path = self.buffer_dir / "pending_events.db"
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Initialisation / gestion corruption
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _init_db(self):
|
||||
"""Crée les tables si elles n'existent pas.
|
||||
|
||||
En cas de fichier corrompu, on le renomme en .corrupted et on recrée
|
||||
un buffer vide. On préfère perdre un buffer non lisible plutôt que
|
||||
de crasher l'agent au démarrage.
|
||||
"""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pending_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pending_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
shot_id TEXT NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_events_created "
|
||||
"ON pending_events(created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_images_created "
|
||||
"ON pending_images(created_at)"
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.warning(
|
||||
f"Buffer SQLite corrompu ({e}) — renommage en .corrupted "
|
||||
f"et recréation d'un buffer vide"
|
||||
)
|
||||
try:
|
||||
corrupted = self.db_path.with_suffix(
|
||||
f".corrupted.{int(time.time())}"
|
||||
)
|
||||
os.rename(self.db_path, corrupted)
|
||||
except OSError:
|
||||
# Si le rename échoue, on tente la suppression directe
|
||||
try:
|
||||
os.remove(self.db_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Nouvelle tentative (table vide)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pending_events ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"session_id TEXT NOT NULL, payload TEXT NOT NULL, "
|
||||
"attempts INTEGER NOT NULL DEFAULT 0, "
|
||||
"created_at REAL NOT NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pending_images ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"session_id TEXT NOT NULL, shot_id TEXT NOT NULL, "
|
||||
"image_path TEXT NOT NULL, "
|
||||
"attempts INTEGER NOT NULL DEFAULT 0, "
|
||||
"created_at REAL NOT NULL)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
"""Connexion SQLite en mode WAL (meilleure concurrence)."""
|
||||
conn = sqlite3.connect(
|
||||
str(self.db_path),
|
||||
timeout=5.0,
|
||||
check_same_thread=False,
|
||||
isolation_level=None, # autocommit — on gère les transactions
|
||||
)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
except sqlite3.DatabaseError:
|
||||
pass
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Écriture — persiste un item
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def add_event(self, session_id: str, event: dict) -> bool:
|
||||
"""Persiste un événement. Retourne True si écrit, False sinon.
|
||||
|
||||
Si le buffer dépasse MAX_BUFFER_ITEMS, on drop l'insertion (plutôt
|
||||
que saturer le disque). On log un warning au premier dépassement.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM pending_events"
|
||||
).fetchone()[0]
|
||||
if count >= MAX_BUFFER_ITEMS:
|
||||
logger.warning(
|
||||
f"Buffer persistant saturé ({count} events) "
|
||||
f"— event droppé"
|
||||
)
|
||||
return False
|
||||
conn.execute(
|
||||
"INSERT INTO pending_events "
|
||||
"(session_id, payload, attempts, created_at) "
|
||||
"VALUES (?, ?, 0, ?)",
|
||||
(session_id, json.dumps(event), time.time()),
|
||||
)
|
||||
return True
|
||||
except (sqlite3.DatabaseError, TypeError, ValueError) as e:
|
||||
logger.error(f"Buffer add_event échoué : {e}")
|
||||
return False
|
||||
|
||||
def add_image(
|
||||
self, session_id: str, image_path: str, shot_id: str
|
||||
) -> bool:
|
||||
"""Persiste une référence image (chemin fichier + shot_id).
|
||||
|
||||
On ne stocke PAS les bytes de l'image (risque de faire gonfler la DB) :
|
||||
uniquement le chemin. Donc l'image doit rester présente sur disque
|
||||
tant qu'elle n'a pas été envoyée avec succès au serveur.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM pending_images"
|
||||
).fetchone()[0]
|
||||
if count >= MAX_BUFFER_ITEMS:
|
||||
logger.warning(
|
||||
f"Buffer persistant saturé ({count} images) "
|
||||
f"— image droppée"
|
||||
)
|
||||
return False
|
||||
conn.execute(
|
||||
"INSERT INTO pending_images "
|
||||
"(session_id, shot_id, image_path, attempts, created_at) "
|
||||
"VALUES (?, ?, ?, 0, ?)",
|
||||
(session_id, shot_id, image_path, time.time()),
|
||||
)
|
||||
return True
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer add_image échoué : {e}")
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Lecture — drain dans l'ordre chronologique
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def drain_events(self, limit: int = 100) -> list:
|
||||
"""Retourne les events en attente, triés par date de création."""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, payload, attempts "
|
||||
"FROM pending_events "
|
||||
"ORDER BY created_at ASC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer drain_events échoué : {e}")
|
||||
return []
|
||||
|
||||
def drain_images(self, limit: int = 50) -> list:
|
||||
"""Retourne les images en attente, triées par date de création."""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, shot_id, image_path, attempts "
|
||||
"FROM pending_images "
|
||||
"ORDER BY created_at ASC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer drain_images échoué : {e}")
|
||||
return []
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Marquage — succès, échec, abandon
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def delete_event(self, row_id: int):
|
||||
"""Supprime un event après envoi réussi."""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM pending_events WHERE id = ?", (row_id,)
|
||||
)
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer delete_event échoué : {e}")
|
||||
|
||||
def delete_image(self, row_id: int):
|
||||
"""Supprime une image après envoi réussi."""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM pending_images WHERE id = ?", (row_id,)
|
||||
)
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer delete_image échoué : {e}")
|
||||
|
||||
def increment_attempts(self, row_id: int, kind: str) -> int:
|
||||
"""Incrémente le compteur d'attempts. Retourne la nouvelle valeur.
|
||||
|
||||
kind : "event" ou "image"
|
||||
"""
|
||||
table = "pending_events" if kind == "event" else "pending_images"
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET attempts = attempts + 1 "
|
||||
"WHERE id = ?",
|
||||
(row_id,),
|
||||
)
|
||||
row = conn.execute(
|
||||
f"SELECT attempts FROM {table} WHERE id = ?", (row_id,)
|
||||
).fetchone()
|
||||
return int(row["attempts"]) if row else MAX_ATTEMPTS
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer increment_attempts échoué : {e}")
|
||||
return MAX_ATTEMPTS
|
||||
|
||||
def abandon_exceeded(self) -> int:
|
||||
"""Supprime les items ayant dépassé MAX_ATTEMPTS.
|
||||
|
||||
Un item abandonné est logué en erreur (trace AI Act) puis supprimé.
|
||||
Retourne le nombre d'items abandonnés.
|
||||
"""
|
||||
abandoned = 0
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
# Events abandonnés
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, payload FROM pending_events "
|
||||
"WHERE attempts >= ?",
|
||||
(MAX_ATTEMPTS,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
event_type = json.loads(r["payload"]).get(
|
||||
"type", "?"
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
event_type = "?"
|
||||
logger.error(
|
||||
f"Buffer : event abandonné après {MAX_ATTEMPTS} "
|
||||
f"tentatives — session={r['session_id']} "
|
||||
f"type={event_type}"
|
||||
)
|
||||
abandoned += 1
|
||||
conn.execute(
|
||||
"DELETE FROM pending_events WHERE attempts >= ?",
|
||||
(MAX_ATTEMPTS,),
|
||||
)
|
||||
|
||||
# Images abandonnées
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, shot_id FROM pending_images "
|
||||
"WHERE attempts >= ?",
|
||||
(MAX_ATTEMPTS,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
logger.error(
|
||||
f"Buffer : image abandonnée après {MAX_ATTEMPTS} "
|
||||
f"tentatives — session={r['session_id']} "
|
||||
f"shot_id={r['shot_id']}"
|
||||
)
|
||||
abandoned += 1
|
||||
conn.execute(
|
||||
"DELETE FROM pending_images WHERE attempts >= ?",
|
||||
(MAX_ATTEMPTS,),
|
||||
)
|
||||
except sqlite3.DatabaseError as e:
|
||||
logger.error(f"Buffer abandon_exceeded échoué : {e}")
|
||||
return abandoned
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Introspection
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def counts(self) -> dict:
|
||||
"""Retourne (events_count, images_count) pour diagnostic."""
|
||||
with self._lock:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
ev = conn.execute(
|
||||
"SELECT COUNT(*) FROM pending_events"
|
||||
).fetchone()[0]
|
||||
im = conn.execute(
|
||||
"SELECT COUNT(*) FROM pending_images"
|
||||
).fetchone()[0]
|
||||
return {"events": ev, "images": im}
|
||||
except sqlite3.DatabaseError:
|
||||
return {"events": 0, "images": 0}
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
c = self.counts()
|
||||
return c["events"] == 0 and c["images"] == 0
|
||||
@@ -14,10 +14,19 @@ Robustesse (P0-2) :
|
||||
- Health-check périodique (30s) pour recovery du flag _server_available
|
||||
- Compression JPEG qualité 85 pour les images (réduction ~5-10x)
|
||||
- Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine
|
||||
|
||||
Conformité AI Act (Article 12 — journalisation automatique) :
|
||||
- Purge après ACK : les screenshots locaux sont supprimés après HTTP 200
|
||||
du serveur (par défaut). Le serveur devient la source de vérité.
|
||||
- Buffer persistant : les events/images prioritaires non envoyés sont
|
||||
persistés dans un SQLite local (agent_v1/buffer/pending_events.db)
|
||||
et rejoués au démarrage et à la reconnexion.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
@@ -25,7 +34,18 @@ import time
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from ..config import API_TOKEN, STREAMING_ENDPOINT
|
||||
from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT
|
||||
from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer
|
||||
|
||||
|
||||
# Fix P0-E : résultat d'envoi d'image trivaleur (succès / échec réseau / fichier
|
||||
# disparu). On ne doit PAS considérer un FileNotFoundError comme un succès
|
||||
# HTTP 200 — sinon le buffer SQLite supprime l'entrée alors que le serveur n'a
|
||||
# jamais reçu l'image (perte silencieuse).
|
||||
class ImageSendResult(enum.Enum):
|
||||
OK = "ok" # HTTP 200, serveur a accusé réception
|
||||
FAILED = "failed" # Erreur réseau/serveur récupérable (retry OK)
|
||||
FILE_GONE = "file_gone" # Fichier local introuvable (abandon, pas retry)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +65,20 @@ QUEUE_MAX_SIZE = 100
|
||||
# Types d'événements à ne jamais dropper
|
||||
PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"}
|
||||
|
||||
# Purge locale après ACK serveur (Partie A de l'audit)
|
||||
# Activé par défaut : le serveur conserve déjà les screenshots 180 jours
|
||||
# (conformité AI Act Article 12). Désactivable via RPA_PURGE_AFTER_ACK=0
|
||||
# pour debugging local.
|
||||
PURGE_AFTER_ACK = os.environ.get("RPA_PURGE_AFTER_ACK", "1").lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
|
||||
# Chemin du buffer persistant (Partie B de l'audit)
|
||||
BUFFER_DIR = BASE_DIR / "buffer"
|
||||
|
||||
# Intervalle entre deux tentatives de drain du buffer (secondes)
|
||||
BUFFER_DRAIN_INTERVAL_S = 15
|
||||
|
||||
|
||||
class TraceStreamer:
|
||||
def __init__(self, session_id: str, machine_id: str = "default"):
|
||||
@@ -54,8 +88,20 @@ class TraceStreamer:
|
||||
self.running = False
|
||||
self._thread = None
|
||||
self._health_thread = None
|
||||
self._drain_thread = None
|
||||
self._server_available = True # Désactivé après trop d'échecs
|
||||
|
||||
# Buffer persistant — partagé entre sessions (survit au redémarrage)
|
||||
# Initialisé paresseusement pour ne pas payer le coût SQLite en dehors
|
||||
# d'un streaming actif.
|
||||
self._buffer: PersistentBuffer | None = None
|
||||
|
||||
def _get_buffer(self) -> PersistentBuffer:
|
||||
"""Retourne le buffer persistant, en l'initialisant au besoin."""
|
||||
if self._buffer is None:
|
||||
self._buffer = PersistentBuffer(BUFFER_DIR)
|
||||
return self._buffer
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers() -> dict:
|
||||
"""Headers d'authentification Bearer pour les requêtes API."""
|
||||
@@ -75,6 +121,11 @@ class TraceStreamer:
|
||||
target=self._health_check_loop, daemon=True
|
||||
)
|
||||
self._health_thread.start()
|
||||
# Thread de drain du buffer persistant (rejoue les items en attente)
|
||||
self._drain_thread = threading.Thread(
|
||||
target=self._buffer_drain_loop, daemon=True
|
||||
)
|
||||
self._drain_thread.start()
|
||||
logger.info(f"Streamer pour {self.session_id} démarré")
|
||||
|
||||
def stop(self):
|
||||
@@ -99,6 +150,9 @@ class TraceStreamer:
|
||||
if self._health_thread:
|
||||
self._health_thread.join(timeout=2.0)
|
||||
|
||||
if self._drain_thread:
|
||||
self._drain_thread.join(timeout=2.0)
|
||||
|
||||
self._finalize_session()
|
||||
logger.info(f"Streamer pour {self.session_id} arrêté")
|
||||
|
||||
@@ -126,11 +180,21 @@ class TraceStreamer:
|
||||
|
||||
Quand la queue est pleine :
|
||||
- Les événements prioritaires (click, key, action, screenshot) sont
|
||||
ajoutés en bloquant brièvement (0.5s)
|
||||
- Les heartbeat sont silencieusement droppés
|
||||
ajoutés en bloquant brièvement (0.5s). Si toujours pleine → persistés
|
||||
dans le buffer SQLite pour rejeu ultérieur.
|
||||
- Les heartbeat sont silencieusement droppés.
|
||||
- Si le serveur est marqué indisponible, on persiste immédiatement les
|
||||
items prioritaires (évite de remplir la queue inutilement).
|
||||
"""
|
||||
is_priority = self._is_priority_item(item_type, data)
|
||||
|
||||
# Serveur indisponible + item prioritaire → on persiste directement
|
||||
# sans polluer la queue RAM (qui ne sera jamais vidée tant que le
|
||||
# serveur est down).
|
||||
if is_priority and not self._server_available:
|
||||
self._persist_to_buffer(item_type, data)
|
||||
return
|
||||
|
||||
try:
|
||||
self.queue.put_nowait((item_type, data))
|
||||
except queue.Full:
|
||||
@@ -139,9 +203,17 @@ class TraceStreamer:
|
||||
try:
|
||||
self.queue.put((item_type, data), timeout=0.5)
|
||||
except queue.Full:
|
||||
# Persistance disque (ne JAMAIS dropper un prioritaire)
|
||||
persisted = self._persist_to_buffer(item_type, data)
|
||||
if persisted:
|
||||
logger.warning(
|
||||
f"Queue pleine — événement prioritaire droppé "
|
||||
f"(type={item_type})"
|
||||
f"Queue pleine — événement prioritaire persisté "
|
||||
f"sur disque (type={item_type})"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Queue pleine ET buffer saturé — événement "
|
||||
f"prioritaire perdu (type={item_type})"
|
||||
)
|
||||
else:
|
||||
# Heartbeat ou événement non-critique : on drop silencieusement
|
||||
@@ -163,6 +235,23 @@ class TraceStreamer:
|
||||
return event_type in PRIORITY_EVENT_TYPES
|
||||
return False
|
||||
|
||||
def _persist_to_buffer(self, item_type: str, data) -> bool:
|
||||
"""Persiste un item dans le buffer SQLite. Retourne True si OK.
|
||||
|
||||
Utilisé quand la queue est pleine ou le serveur indisponible.
|
||||
"""
|
||||
try:
|
||||
buf = self._get_buffer()
|
||||
if item_type == "event" and isinstance(data, dict):
|
||||
return buf.add_event(self.session_id, data)
|
||||
if item_type == "image":
|
||||
path, shot_id = data
|
||||
return buf.add_image(self.session_id, path, shot_id)
|
||||
except Exception as e:
|
||||
# On n'arrête jamais l'agent si le buffer échoue
|
||||
logger.error(f"Persistance buffer échouée : {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Boucle d'envoi
|
||||
# =========================================================================
|
||||
@@ -174,16 +263,36 @@ class TraceStreamer:
|
||||
try:
|
||||
item_type, data = self.queue.get(timeout=0.5)
|
||||
success = False
|
||||
is_file_gone = False
|
||||
if item_type == "event":
|
||||
success = self._send_with_retry(self._send_event, data)
|
||||
elif item_type == "image":
|
||||
success = self._send_with_retry(self._send_image, *data)
|
||||
result = self._send_with_retry(self._send_image, *data)
|
||||
# Fix P0-E : distinguer FILE_GONE du vrai succès HTTP.
|
||||
if result is ImageSendResult.OK:
|
||||
success = True
|
||||
elif result is ImageSendResult.FILE_GONE:
|
||||
# Fichier disparu : pas de retry, pas de persistance
|
||||
# (on ne peut plus le renvoyer). On considère l'item
|
||||
# comme traité sans comptabiliser un succès réseau.
|
||||
is_file_gone = True
|
||||
success = False
|
||||
else:
|
||||
success = False
|
||||
self.queue.task_done()
|
||||
|
||||
if success:
|
||||
consecutive_failures = 0
|
||||
elif is_file_gone:
|
||||
# Fichier introuvable — déjà logué ERROR dans _send_image.
|
||||
# On ne persiste PAS dans le buffer (retry voué à échouer).
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
# Après 3 retries infructueux, si l'item est prioritaire,
|
||||
# on le persiste pour ne pas le perdre définitivement.
|
||||
if self._is_priority_item(item_type, data):
|
||||
self._persist_to_buffer(item_type, data)
|
||||
if consecutive_failures >= 10:
|
||||
logger.warning(
|
||||
"10 échecs consécutifs — serveur marqué indisponible"
|
||||
@@ -200,15 +309,22 @@ class TraceStreamer:
|
||||
# Retry avec backoff exponentiel
|
||||
# =========================================================================
|
||||
|
||||
def _send_with_retry(self, send_fn, *args) -> bool:
|
||||
def _send_with_retry(self, send_fn, *args):
|
||||
"""Tente l'envoi avec retry et backoff exponentiel.
|
||||
|
||||
3 tentatives max avec délais de 1s, 2s, 4s entre chaque.
|
||||
Retourne True si l'envoi a réussi, False sinon.
|
||||
Retourne :
|
||||
- True / ImageSendResult.OK si l'envoi a réussi
|
||||
- ImageSendResult.FILE_GONE (images uniquement) — pas de retry
|
||||
- False / ImageSendResult.FAILED sinon
|
||||
"""
|
||||
# Première tentative (sans délai)
|
||||
if send_fn(*args):
|
||||
return True
|
||||
first = send_fn(*args)
|
||||
if first is ImageSendResult.OK or first is True:
|
||||
return first
|
||||
# Fix P0-E : FILE_GONE → pas de retry, l'erreur est permanente.
|
||||
if first is ImageSendResult.FILE_GONE:
|
||||
return first
|
||||
|
||||
# Retries avec backoff
|
||||
for attempt, delay in enumerate(RETRY_DELAYS, start=1):
|
||||
@@ -219,9 +335,13 @@ class TraceStreamer:
|
||||
f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
if send_fn(*args):
|
||||
result = send_fn(*args)
|
||||
if result is ImageSendResult.OK or result is True:
|
||||
logger.debug(f"Retry {attempt} réussi")
|
||||
return True
|
||||
return result
|
||||
# FILE_GONE pendant un retry — idem, on arrête
|
||||
if result is ImageSendResult.FILE_GONE:
|
||||
return result
|
||||
|
||||
logger.debug(f"Envoi échoué après {MAX_RETRIES} retries")
|
||||
return False
|
||||
@@ -260,6 +380,115 @@ class TraceStreamer:
|
||||
except Exception:
|
||||
logger.debug("Health-check échoué — serveur toujours indisponible")
|
||||
|
||||
# =========================================================================
|
||||
# Drain du buffer persistant (Partie B)
|
||||
# =========================================================================
|
||||
|
||||
def _buffer_drain_loop(self):
|
||||
"""Rejoue les items persistés en arrière-plan.
|
||||
|
||||
Tourne tant que self.running. Essaie de drainer le buffer toutes les
|
||||
BUFFER_DRAIN_INTERVAL_S secondes, mais seulement si :
|
||||
- le serveur est disponible,
|
||||
- il y a effectivement des items en attente.
|
||||
|
||||
Au premier passage (démarrage agent), on draine immédiatement pour
|
||||
rejouer tout ce qui a été persisté lors de la session précédente.
|
||||
"""
|
||||
# Au démarrage : drain immédiat (pas d'attente)
|
||||
first_pass = True
|
||||
while self.running:
|
||||
if not first_pass:
|
||||
time.sleep(BUFFER_DRAIN_INTERVAL_S)
|
||||
if not self.running:
|
||||
break
|
||||
first_pass = False
|
||||
|
||||
if not self._server_available:
|
||||
continue
|
||||
|
||||
try:
|
||||
buf = self._get_buffer()
|
||||
# Abandonner d'abord les items exceeded (évite de les retenter)
|
||||
abandoned = buf.abandon_exceeded()
|
||||
if abandoned:
|
||||
logger.warning(
|
||||
f"Buffer : {abandoned} items abandonnés "
|
||||
f"après {MAX_ATTEMPTS} tentatives"
|
||||
)
|
||||
|
||||
counts = buf.counts()
|
||||
if counts["events"] == 0 and counts["images"] == 0:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Buffer drain : {counts['events']} events, "
|
||||
f"{counts['images']} images en attente — rejeu"
|
||||
)
|
||||
self._drain_buffer_once(buf)
|
||||
except Exception as e:
|
||||
logger.error(f"Buffer drain loop échoué : {e}")
|
||||
|
||||
def _drain_buffer_once(self, buf: PersistentBuffer):
|
||||
"""Une passe de drain : envoie ce qui peut l'être, incrémente le reste.
|
||||
|
||||
On arrête dès qu'un envoi échoue (serveur probablement down).
|
||||
"""
|
||||
# Events d'abord (plus légers, priorité métier AI Act)
|
||||
for row in buf.drain_events(limit=50):
|
||||
if not self._server_available:
|
||||
return
|
||||
try:
|
||||
import json as _json
|
||||
event = _json.loads(row["payload"])
|
||||
except (ValueError, TypeError):
|
||||
logger.error(
|
||||
f"Buffer : payload event #{row['id']} corrompu, suppression"
|
||||
)
|
||||
buf.delete_event(row["id"])
|
||||
continue
|
||||
if self._send_event(event):
|
||||
buf.delete_event(row["id"])
|
||||
else:
|
||||
buf.increment_attempts(row["id"], "event")
|
||||
# Serveur répond mal — on arrête la passe
|
||||
return
|
||||
|
||||
# Puis images
|
||||
for row in buf.drain_images(limit=20):
|
||||
if not self._server_available:
|
||||
return
|
||||
image_path = row["image_path"]
|
||||
shot_id = row["shot_id"]
|
||||
if not os.path.exists(image_path):
|
||||
# Fichier local disparu (purge, clean-up) — on abandonne.
|
||||
# Fix P0-E : log ERROR (pas warning) — c'est une perte de donnée.
|
||||
logger.error(
|
||||
f"Buffer : image #{row['id']} introuvable sur disque "
|
||||
f"({image_path}) — entrée abandonnée (le serveur n'a "
|
||||
f"jamais reçu cette image, session={row['session_id']}, "
|
||||
f"shot={shot_id})"
|
||||
)
|
||||
buf.delete_image(row["id"])
|
||||
continue
|
||||
result = self._send_image(image_path, shot_id)
|
||||
if result is ImageSendResult.OK or result is True:
|
||||
buf.delete_image(row["id"])
|
||||
elif result is ImageSendResult.FILE_GONE:
|
||||
# Fix P0-E : fichier disparu pendant l'envoi.
|
||||
# Ce n'est PAS un succès HTTP — ne pas considérer comme tel.
|
||||
# On supprime néanmoins l'entrée (retry voué à échouer)
|
||||
# mais avec un log ERROR explicite.
|
||||
logger.error(
|
||||
f"Buffer : image #{row['id']} disparue pendant l'envoi "
|
||||
f"({image_path}) — entrée abandonnée, pas de retry "
|
||||
f"(session={row['session_id']}, shot={shot_id})"
|
||||
)
|
||||
buf.delete_image(row["id"])
|
||||
else:
|
||||
buf.increment_attempts(row["id"], "image")
|
||||
return
|
||||
|
||||
# =========================================================================
|
||||
# Compression JPEG
|
||||
# =========================================================================
|
||||
@@ -287,6 +516,34 @@ class TraceStreamer:
|
||||
logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}")
|
||||
return None, None, None
|
||||
|
||||
# =========================================================================
|
||||
# Purge locale après ACK (Partie A)
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _purge_local_image(path: str):
|
||||
"""Supprime un screenshot local après ACK 200 du serveur.
|
||||
|
||||
Ne crashe JAMAIS si le fichier est verrouillé (cas Windows) ou
|
||||
déjà supprimé : on log en debug et on continue. L'auto-cleanup
|
||||
de SessionStorage repassera plus tard.
|
||||
"""
|
||||
if not PURGE_AFTER_ACK:
|
||||
return
|
||||
try:
|
||||
os.remove(path)
|
||||
logger.debug(f"Screenshot local purgé après ACK : {path}")
|
||||
except FileNotFoundError:
|
||||
# Déjà supprimé ou chemin invalide — silencieux
|
||||
pass
|
||||
except PermissionError as e:
|
||||
# Windows verrouille parfois les fichiers (antivirus, indexation...)
|
||||
logger.debug(
|
||||
f"Purge différée (fichier verrouillé) : {path} — {e}"
|
||||
)
|
||||
except OSError as e:
|
||||
logger.debug(f"Purge échouée : {path} — {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Envois HTTP
|
||||
# =========================================================================
|
||||
@@ -337,7 +594,7 @@ class TraceStreamer:
|
||||
else:
|
||||
logger.warning(f"Finalisation échouée: {resp.status_code}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Finalisation échouée: {e}")
|
||||
logger.warning(f"Finalisation échouée: {e}")
|
||||
|
||||
def _send_event(self, event: dict) -> bool:
|
||||
"""Envoyer un événement au serveur (avec identifiant machine)."""
|
||||
@@ -361,14 +618,23 @@ class TraceStreamer:
|
||||
logger.debug(f"Streaming Event échoué: {e}")
|
||||
return False
|
||||
|
||||
def _send_image(self, path: str, shot_id: str) -> bool:
|
||||
def _send_image(self, path: str, shot_id: str):
|
||||
"""Envoyer un screenshot au serveur, compressé en JPEG.
|
||||
|
||||
Utilise un context manager pour le fallback PNG afin d'éviter
|
||||
les fuites de descripteurs de fichier.
|
||||
|
||||
Partie A (purge après ACK) : en cas de HTTP 200 confirmé, le fichier
|
||||
local est supprimé (le serveur devient la source de vérité).
|
||||
|
||||
Fix P0-E : retourne `ImageSendResult` (OK / FAILED / FILE_GONE).
|
||||
Les appelants historiques qui attendaient un bool continuent de
|
||||
fonctionner grâce à la truthiness du enum (OK → True, reste → False),
|
||||
MAIS le drain du buffer doit désormais discriminer FILE_GONE pour
|
||||
ne pas confondre "fichier disparu" avec "envoyé avec succès".
|
||||
"""
|
||||
if not self._server_available:
|
||||
return False
|
||||
return ImageSendResult.FAILED
|
||||
try:
|
||||
# Tenter la compression JPEG (réduction ~5-10x vs PNG)
|
||||
jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path)
|
||||
@@ -391,7 +657,10 @@ class TraceStreamer:
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
return resp.ok
|
||||
if resp.ok:
|
||||
self._purge_local_image(path)
|
||||
return ImageSendResult.OK
|
||||
return ImageSendResult.FAILED
|
||||
else:
|
||||
# Fallback : envoi PNG original avec context manager
|
||||
with open(path, "rb") as f:
|
||||
@@ -405,7 +674,20 @@ class TraceStreamer:
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
return resp.ok
|
||||
if resp.ok:
|
||||
self._purge_local_image(path)
|
||||
return ImageSendResult.OK
|
||||
return ImageSendResult.FAILED
|
||||
except FileNotFoundError:
|
||||
# Fix P0-E : fichier local disparu. On NE doit PAS considérer ça
|
||||
# comme un succès HTTP 200. Le serveur n'a rien reçu. On signale
|
||||
# `FILE_GONE` pour que le drain du buffer supprime l'entrée
|
||||
# (pas de retry possible) tout en loguant ERROR (pas debug).
|
||||
logger.error(
|
||||
f"Image {shot_id} introuvable sur disque ({path}) — "
|
||||
f"abandon (serveur n'a rien reçu)"
|
||||
)
|
||||
return ImageSendResult.FILE_GONE
|
||||
except Exception as e:
|
||||
logger.debug(f"Streaming Image échoué: {e}")
|
||||
return False
|
||||
return ImageSendResult.FAILED
|
||||
|
||||
@@ -3,15 +3,25 @@ Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande
|
||||
et les operations fichiers.
|
||||
|
||||
Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
|
||||
Bind par defaut sur 127.0.0.1 (configurable via RPA_CAPTURE_BIND).
|
||||
Endpoints :
|
||||
GET /capture -> screenshot frais en base64 (JPEG)
|
||||
GET /health -> {"status": "ok"}
|
||||
GET /health -> {"status": "ok"} (pas d'auth — sonde liveness)
|
||||
POST /file-action -> operations fichiers (list, create, move, copy, sort)
|
||||
|
||||
Securite :
|
||||
- Authentification Bearer obligatoire (RPA_API_TOKEN) pour /capture et
|
||||
/file-action. Sans token configure, ces endpoints sont desactives.
|
||||
- Les tentatives non authentifiees sont loguees (WARNING) avec l'IP source.
|
||||
- Bind defaut localhost. Pour exposer sur le LAN (cas VWB backend qui
|
||||
appelle l'agent a distance), definir explicitement
|
||||
RPA_CAPTURE_BIND=0.0.0.0. L'auth reste alors la seule protection.
|
||||
"""
|
||||
import threading
|
||||
import logging
|
||||
import json
|
||||
import base64
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
@@ -20,6 +30,17 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
|
||||
# Bind par defaut sur localhost — defense en profondeur.
|
||||
# Pour le deploiement VWB (backend Linux -> agent Windows), definir
|
||||
# RPA_CAPTURE_BIND=0.0.0.0 explicitement. L'auth par token reste requise.
|
||||
CAPTURE_BIND = os.environ.get("RPA_CAPTURE_BIND", "127.0.0.1")
|
||||
|
||||
# Token d'authentification (partage avec le streaming). Doit etre defini pour
|
||||
# que /capture et /file-action soient accessibles.
|
||||
CAPTURE_TOKEN = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
# Endpoints ouverts (pas d'auth requise — sondes techniques uniquement)
|
||||
_PUBLIC_PATHS = {"/health"}
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
|
||||
@@ -33,6 +54,8 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/capture":
|
||||
if not self._check_auth():
|
||||
return
|
||||
self._handle_capture()
|
||||
elif self.path == "/health":
|
||||
self._send_json(200, {"status": "ok"})
|
||||
@@ -41,10 +64,56 @@ class CaptureHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/file-action":
|
||||
if not self._check_auth():
|
||||
return
|
||||
self._handle_file_action()
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_auth(self) -> bool:
|
||||
"""Valide le Bearer token. Renvoie 401/503 si invalide.
|
||||
|
||||
- Si aucun token n'est configure cote serveur (RPA_API_TOKEN vide),
|
||||
on refuse toutes les requetes sensibles (503) — fail-closed.
|
||||
- Sinon, on compare en temps constant via hmac.compare_digest.
|
||||
- Les tentatives echouees sont loguees avec l'IP source.
|
||||
"""
|
||||
# Autoriser les endpoints publics
|
||||
if self.path in _PUBLIC_PATHS:
|
||||
return True
|
||||
|
||||
peer = self.client_address[0] if self.client_address else "?"
|
||||
|
||||
if not CAPTURE_TOKEN:
|
||||
logger.error(
|
||||
"Refus %s depuis %s : RPA_API_TOKEN non configure "
|
||||
"(capture server en mode fail-closed)",
|
||||
self.path, peer,
|
||||
)
|
||||
self._send_json(503, {
|
||||
"error": "capture server non configure (token manquant)",
|
||||
})
|
||||
return False
|
||||
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
token = ""
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[len("Bearer "):].strip()
|
||||
|
||||
if not token or not hmac.compare_digest(token, CAPTURE_TOKEN):
|
||||
logger.warning(
|
||||
"Tentative d'acces non autorisee a %s depuis %s "
|
||||
"(token %s)",
|
||||
self.path, peer,
|
||||
"absent" if not token else "invalide",
|
||||
)
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""Gestion CORS preflight."""
|
||||
self.send_response(200)
|
||||
@@ -351,21 +420,46 @@ class _FileActionHandlerLocal:
|
||||
class CaptureServer:
|
||||
"""Serveur de capture d'ecran en temps reel (thread daemon)."""
|
||||
|
||||
def __init__(self, port: int = CAPTURE_PORT):
|
||||
def __init__(self, port: int = CAPTURE_PORT, bind: str = CAPTURE_BIND):
|
||||
self._port = port
|
||||
self._bind = bind
|
||||
self._server: HTTPServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
"""Demarre le serveur dans un thread daemon."""
|
||||
"""Demarre le serveur dans un thread daemon.
|
||||
|
||||
Avertit si le serveur est expose sur le LAN sans token configure.
|
||||
"""
|
||||
# Defense en profondeur : refus de demarrer si expose LAN sans auth
|
||||
exposed_lan = self._bind not in ("127.0.0.1", "localhost", "::1")
|
||||
if exposed_lan and not CAPTURE_TOKEN:
|
||||
logger.error(
|
||||
"REFUS demarrage capture server : bind=%s (LAN) sans "
|
||||
"RPA_API_TOKEN. Definir le token ou RPA_CAPTURE_BIND=127.0.0.1.",
|
||||
self._bind,
|
||||
)
|
||||
print(
|
||||
f"[CAPTURE] REFUS demarrage : bind={self._bind} sans token. "
|
||||
f"Definir RPA_API_TOKEN ou RPA_CAPTURE_BIND=127.0.0.1."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self._server = HTTPServer(("0.0.0.0", self._port), CaptureHandler)
|
||||
self._server = HTTPServer((self._bind, self._port), CaptureHandler)
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.serve_forever, daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"Capture server demarre sur le port {self._port}")
|
||||
print(f"[CAPTURE] Serveur de capture demarre sur le port {self._port}")
|
||||
auth_mode = "token requis" if CAPTURE_TOKEN else "token absent (fail-closed)"
|
||||
logger.info(
|
||||
"Capture server demarre sur %s:%s (%s)",
|
||||
self._bind, self._port, auth_mode,
|
||||
)
|
||||
print(
|
||||
f"[CAPTURE] Serveur de capture demarre sur "
|
||||
f"{self._bind}:{self._port} ({auth_mode})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Impossible de demarrer le capture server : {e}")
|
||||
print(f"[CAPTURE] ERREUR demarrage : {e}")
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
"""
|
||||
deploy_windows.py — Script de packaging du client Windows pour Agent V1.
|
||||
|
||||
⚠️ OBSOLÈTE (avril 2026)
|
||||
Le build officiel du package Windows passe par ``deploy/build_package.sh``
|
||||
(à la racine du repo) qui lit directement ``agent_v0/agent_v1/`` et évite
|
||||
les clones intermédiaires. Ce script est conservé pour référence mais son
|
||||
manifeste ``FILE_MANIFEST`` est incomplet : il n'inclut pas
|
||||
``system_dialog_guard.py``, ``persistent_buffer.py``, ``recovery.py``,
|
||||
``uia_helper.py``, ``grounding.py``, ``policy.py``,
|
||||
``vision/blur_sensitive.py``, ``vision/system_info.py``,
|
||||
``ui/chat_window.py``, ``ui/capture_server.py``, ``ui/shared_state.py``.
|
||||
Ne PAS l'utiliser pour un packaging réel.
|
||||
|
||||
Copie uniquement les fichiers nécessaires au fonctionnement de l'agent
|
||||
sur le PC cible (Windows), sans le serveur ni les dépendances lourdes.
|
||||
|
||||
|
||||
@@ -71,9 +71,17 @@ class LeaServerClient:
|
||||
self._chat_port = chat_port
|
||||
self._stream_port = stream_port
|
||||
|
||||
self._chat_base = f"http://{self._host}:{self._chat_port}"
|
||||
# En prod, la base URL passe par le reverse proxy HTTPS
|
||||
# (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est
|
||||
# definie on l'utilise telle quelle, sinon on reconstruit http://host:port.
|
||||
server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
|
||||
if server_url:
|
||||
self._stream_base = server_url
|
||||
else:
|
||||
self._stream_base = f"http://{self._host}:{self._stream_port}"
|
||||
|
||||
self._chat_base = f"http://{self._host}:{self._chat_port}"
|
||||
|
||||
# Etat de connexion
|
||||
self._connected = False
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
296
agent_v0/server_v1/agent_registry.py
Normal file
296
agent_v0/server_v1/agent_registry.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# agent_v0/server_v1/agent_registry.py
|
||||
"""
|
||||
Registre des agents Lea enrolles sur le parc.
|
||||
|
||||
Alimente par les endpoints /api/v1/agents/enroll et /api/v1/agents/uninstall
|
||||
que l'installeur Inno Setup (`deploy/installer/Lea.iss`) appelle a
|
||||
l'installation et a la desinstallation sur chaque poste collaborateur.
|
||||
|
||||
Stockage : SQLite simple, cohabite avec rpa_data.db dans data/databases/.
|
||||
Aucune dependance GPU/LLM — ce module doit rester leger (juste sqlite3 +
|
||||
stdlib) pour pouvoir etre importe par le serveur HTTP.
|
||||
|
||||
Schema de la table `enrolled_agents` :
|
||||
id INTEGER PK AUTOINCREMENT
|
||||
machine_id TEXT UNIQUE NOT NULL — identifiant genere par l'installeur
|
||||
user_name TEXT — nom affichage collaborateur
|
||||
user_email TEXT
|
||||
user_id TEXT — identifiant metier (ex: AIVA-001)
|
||||
hostname TEXT
|
||||
os_info TEXT
|
||||
version TEXT — version du client Lea
|
||||
status TEXT DEFAULT 'active' — 'active' | 'uninstalled'
|
||||
enrolled_at TEXT NOT NULL — ISO 8601 UTC
|
||||
last_seen_at TEXT — ISO 8601 UTC (heartbeat / stream)
|
||||
uninstalled_at TEXT
|
||||
uninstall_reason TEXT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Verrou global : SQLite tolere plusieurs threads mais on serialise
|
||||
# les ecritures pour eviter les races sur _init_db + upserts concurrents.
|
||||
_DB_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
"""Horodatage ISO 8601 UTC (compatible toutes les autres tables)."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
"""Gestion CRUD des agents enrolles (SQLite)."""
|
||||
|
||||
def __init__(self, db_path: str | Path = "data/databases/rpa_data.db"):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Infra SQLite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
# check_same_thread=False : on protege nous-memes via _DB_LOCK,
|
||||
# indispensable car FastAPI appelle les endpoints sur threads
|
||||
# differents (thread pool).
|
||||
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Cree la table et ses index si absents (idempotent)."""
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS enrolled_agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
machine_id TEXT NOT NULL UNIQUE,
|
||||
user_name TEXT,
|
||||
user_email TEXT,
|
||||
user_id TEXT,
|
||||
hostname TEXT,
|
||||
os_info TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
enrolled_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
uninstalled_at TEXT,
|
||||
uninstall_reason TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_status "
|
||||
"ON enrolled_agents(status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine "
|
||||
"ON enrolled_agents(machine_id)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lecture
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, machine_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Recupere un agent par machine_id (ou None)."""
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_by_status(self, status: str) -> List[Dict[str, Any]]:
|
||||
"""Liste les agents par statut ('active' | 'uninstalled')."""
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE status = ? "
|
||||
"ORDER BY enrolled_at DESC",
|
||||
(status,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def count_by_status(self, status: str) -> int:
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM enrolled_agents WHERE status = ?",
|
||||
(status,),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ecriture
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enroll(
|
||||
self,
|
||||
*,
|
||||
machine_id: str,
|
||||
user_name: str | None = None,
|
||||
user_email: str | None = None,
|
||||
user_id: str | None = None,
|
||||
hostname: str | None = None,
|
||||
os_info: str | None = None,
|
||||
version: str | None = None,
|
||||
allow_reactivate: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Enregistre un nouvel agent ou reactive un agent desinstalle.
|
||||
|
||||
Returns:
|
||||
dict avec clefs {"created": bool, "reactivated": bool, "agent": row}
|
||||
|
||||
Raises:
|
||||
ValueError: si machine_id est vide.
|
||||
AgentAlreadyEnrolledError: si deja actif (status=active).
|
||||
"""
|
||||
if not machine_id or not machine_id.strip():
|
||||
raise ValueError("machine_id est obligatoire")
|
||||
machine_id = machine_id.strip()
|
||||
|
||||
now = _utc_now_iso()
|
||||
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
|
||||
if existing is not None:
|
||||
if existing["status"] == "active":
|
||||
# Deja enrolle et actif -> conflit explicit
|
||||
raise AgentAlreadyEnrolledError(dict(existing))
|
||||
|
||||
# Agent desinstalle : reactivation si autorise (defaut)
|
||||
if not allow_reactivate:
|
||||
raise AgentAlreadyEnrolledError(dict(existing))
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE enrolled_agents
|
||||
SET user_name = COALESCE(?, user_name),
|
||||
user_email = COALESCE(?, user_email),
|
||||
user_id = COALESCE(?, user_id),
|
||||
hostname = COALESCE(?, hostname),
|
||||
os_info = COALESCE(?, os_info),
|
||||
version = COALESCE(?, version),
|
||||
status = 'active',
|
||||
enrolled_at = ?,
|
||||
last_seen_at = ?,
|
||||
uninstalled_at = NULL,
|
||||
uninstall_reason = NULL
|
||||
WHERE machine_id = ?
|
||||
""",
|
||||
(
|
||||
user_name, user_email, user_id,
|
||||
hostname, os_info, version,
|
||||
now, now, machine_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return {"created": False, "reactivated": True, "agent": dict(row)}
|
||||
|
||||
# Nouvelle inscription
|
||||
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', ?, ?)
|
||||
""",
|
||||
(
|
||||
machine_id, user_name, user_email, user_id,
|
||||
hostname, os_info, version,
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return {"created": True, "reactivated": False, "agent": dict(row)}
|
||||
|
||||
def uninstall(
|
||||
self,
|
||||
*,
|
||||
machine_id: str,
|
||||
reason: str | None = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Marque un agent comme desinstalle (soft delete).
|
||||
|
||||
Returns:
|
||||
Le row mis a jour, ou None si l'agent n'existe pas.
|
||||
"""
|
||||
if not machine_id or not machine_id.strip():
|
||||
raise ValueError("machine_id est obligatoire")
|
||||
machine_id = machine_id.strip()
|
||||
|
||||
now = _utc_now_iso()
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE enrolled_agents
|
||||
SET status = 'uninstalled',
|
||||
uninstalled_at = ?,
|
||||
uninstall_reason = ?
|
||||
WHERE machine_id = ?
|
||||
""",
|
||||
(now, reason, machine_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
|
||||
(machine_id,),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
def touch_last_seen(self, machine_id: str) -> None:
|
||||
"""Met a jour last_seen_at (appel depuis le stream / heartbeat).
|
||||
|
||||
Silencieux si l'agent est inconnu (evite les erreurs sur vieux clients).
|
||||
"""
|
||||
if not machine_id:
|
||||
return
|
||||
now = _utc_now_iso()
|
||||
with _DB_LOCK, self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE enrolled_agents SET last_seen_at = ? WHERE machine_id = ?",
|
||||
(now, machine_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class AgentAlreadyEnrolledError(Exception):
|
||||
"""Levee si on tente d'enrouler une machine deja active."""
|
||||
|
||||
def __init__(self, existing_row: Dict[str, Any]):
|
||||
self.existing = existing_row
|
||||
super().__init__(
|
||||
f"machine_id={existing_row.get('machine_id')} deja enrole "
|
||||
f"(status={existing_row.get('status')})"
|
||||
)
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
|
||||
from .worker_stream import StreamWorker
|
||||
from .execution_plan_runner import (
|
||||
@@ -37,6 +38,13 @@ from .execution_plan_runner import (
|
||||
inject_plan_into_queue,
|
||||
)
|
||||
|
||||
# Pipeline d'anonymisation PII (OCR + NER côté serveur).
|
||||
# Import paresseux : on ne charge pas docTR tant qu'aucune image n'est reçue.
|
||||
try:
|
||||
from core.anonymisation import blur_pii_on_image as _blur_pii_on_image
|
||||
except ImportError:
|
||||
_blur_pii_on_image = None
|
||||
|
||||
# Instance globale du vérificateur de replay (comparaison screenshots avant/après)
|
||||
_replay_verifier = ReplayVerifier()
|
||||
_replay_learner = ReplayLearner()
|
||||
@@ -82,25 +90,77 @@ logger = logging.getLogger("api_stream")
|
||||
# =========================================================================
|
||||
# Authentification par token Bearer (sécurité HIGH)
|
||||
# =========================================================================
|
||||
# Le token est lu depuis l'environnement ou généré au démarrage.
|
||||
# Le token est lu depuis l'environnement obligatoirement.
|
||||
# Tous les endpoints requièrent le header Authorization: Bearer <token>,
|
||||
# sauf /health, /docs et /openapi.json (publics).
|
||||
API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32))
|
||||
#
|
||||
# Fail-closed P0-C :
|
||||
# - En production (défaut), RPA_API_TOKEN DOIT être défini.
|
||||
# - Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true
|
||||
# Dans ce mode, aucun token n'est requis et l'API log un WARNING au boot.
|
||||
# - Sans token ET sans RPA_AUTH_DISABLED=true → arrêt immédiat du process
|
||||
# (sys.exit 1) avec message fatal clair. On NE génère PLUS de token
|
||||
# aléatoire en silence : cela cassait tous les agents clients sans bruit.
|
||||
_AUTH_DISABLED = os.environ.get("RPA_AUTH_DISABLED", "").lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
_API_TOKEN_ENV = os.environ.get("RPA_API_TOKEN", "").strip()
|
||||
|
||||
if _AUTH_DISABLED:
|
||||
# Mode dev explicite : on tolère l'absence de token mais on log très fort.
|
||||
logger.warning(
|
||||
"[SÉCURITÉ] RPA_AUTH_DISABLED=true — authentification Bearer DÉSACTIVÉE. "
|
||||
"NE JAMAIS utiliser cette configuration en production. Tous les "
|
||||
"endpoints sont accessibles sans token."
|
||||
)
|
||||
API_TOKEN = _API_TOKEN_ENV or secrets.token_hex(32)
|
||||
elif not _API_TOKEN_ENV:
|
||||
# Fail-closed : pas de génération silencieuse. On arrête le serveur.
|
||||
_FATAL_MSG = (
|
||||
"[SÉCURITÉ] FATAL — RPA_API_TOKEN est absent ou vide. "
|
||||
"Refus de démarrer le serveur de streaming : générer un token "
|
||||
"aléatoire interne casserait tous les agents clients qui utilisent "
|
||||
"le token persistant (.env.local). "
|
||||
"Pour fixer : définir RPA_API_TOKEN=<32 hex chars> dans l'environnement. "
|
||||
"Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true."
|
||||
)
|
||||
logger.critical(_FATAL_MSG)
|
||||
print(_FATAL_MSG, flush=True)
|
||||
# Utiliser sys.exit pour un arrêt propre (raise RuntimeError est accroché
|
||||
# par uvicorn sur Python 3.11, sys.exit remonte BaseException).
|
||||
import sys as _sys
|
||||
_sys.exit(1)
|
||||
else:
|
||||
API_TOKEN = _API_TOKEN_ENV
|
||||
# Log non-sensible : 8 premiers caractères seulement pour aider au diagnostic.
|
||||
logger.info(
|
||||
f"[SÉCURITÉ] Token API chargé (8 premiers caractères : "
|
||||
f"{API_TOKEN[:8]}…) — auth Bearer obligatoire"
|
||||
)
|
||||
|
||||
# Endpoints publics (pas besoin de token)
|
||||
# En production, /docs et /redoc sont désactivés (voir ci-dessous)
|
||||
# Paths publics : pas de token requis
|
||||
# /replay/next est public car l'agent Rust legacy n'envoie pas de token
|
||||
# et c'est un endpoint read-only (polling, pas d'écriture)
|
||||
#
|
||||
# Fix P0-B : /api/v1/traces/stream/image RETIRÉ de la liste publique.
|
||||
# L'upload d'image écrit sur disque + déclenche du travail VLM : exiger
|
||||
# un token Bearer. Tous les agents V1 déployés envoient déjà le token
|
||||
# (cf. agent_v0/agent_v1/network/streamer.py:_auth_headers).
|
||||
_PUBLIC_PATHS = {
|
||||
"/health", "/docs", "/openapi.json", "/redoc",
|
||||
"/api/v1/traces/stream/replay/next",
|
||||
"/api/v1/traces/stream/image",
|
||||
}
|
||||
|
||||
|
||||
async def _verify_token(request: Request):
|
||||
"""Middleware de vérification du token API Bearer."""
|
||||
"""Middleware de vérification du token API Bearer.
|
||||
|
||||
Bypass si RPA_AUTH_DISABLED=true (mode dev local uniquement).
|
||||
"""
|
||||
if _AUTH_DISABLED:
|
||||
return
|
||||
if request.url.path in _PUBLIC_PATHS:
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
@@ -281,6 +341,14 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
|
||||
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
|
||||
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
|
||||
|
||||
# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db)
|
||||
# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests.
|
||||
_AGENTS_DB_PATH = os.environ.get(
|
||||
"RPA_AGENTS_DB_PATH",
|
||||
str(ROOT_DIR / "data" / "databases" / "rpa_data.db"),
|
||||
)
|
||||
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Flush garanti à l'arrêt — signal handler + atexit (ceinture et bretelles)
|
||||
@@ -490,6 +558,12 @@ class ReplayResultReport(BaseModel):
|
||||
target_spec: Optional[Dict[str, Any]] = None # Spec complete de la cible
|
||||
# Correction humaine (mode apprentissage supervisé)
|
||||
correction: Optional[Dict[str, Any]] = None # {x_pct, y_pct, uia_snapshot, crop_b64}
|
||||
# Sécurité : signalement d'un dialogue système critique détecté
|
||||
# (UAC, CredUI, SmartScreen...). Quand ce champ est présent, l'agent
|
||||
# refuse toute interaction et le serveur bascule en paused_need_help.
|
||||
# Cf. agent_v1/core/system_dialog_guard.py
|
||||
system_dialog: Optional[Dict[str, Any]] = None # {category, matched_signal, matched_value, reason, context}
|
||||
needs_human: Optional[bool] = None
|
||||
|
||||
|
||||
class ErrorCallbackConfig(BaseModel):
|
||||
@@ -498,6 +572,28 @@ class ErrorCallbackConfig(BaseModel):
|
||||
callback_url: str # URL à appeler en cas d'erreur non-récupérable
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent Fleet — enrollment / desinstallation
|
||||
# Consommes par l'installeur Lea.iss (voir deploy/installer/)
|
||||
# -------------------------------------------------------------------------
|
||||
class AgentEnrollRequest(BaseModel):
|
||||
"""Enregistrement d'un nouveau poste lors de l'installation Lea."""
|
||||
machine_id: str
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
os_info: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
class AgentUninstallRequest(BaseModel):
|
||||
"""Notification de desinstallation d'un poste."""
|
||||
machine_id: str
|
||||
# reason = user_uninstall | admin_revoke | machine_retired (libre)
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
# Thread de nettoyage périodique des replays terminés et sessions expirées
|
||||
_cleanup_thread: Optional[threading.Thread] = None
|
||||
_cleanup_running = False
|
||||
@@ -837,6 +933,40 @@ _som_enrichment_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="som_enrich",
|
||||
)
|
||||
|
||||
# ThreadPool dédié à l'anonymisation PII (OCR + NER).
|
||||
# Activable via RPA_PII_BLUR_SERVER (default : true). 1 worker suffit, le
|
||||
# pipeline est rapide (<2 s par screenshot) et le blur peut prendre du retard
|
||||
# sur la capture sans bloquer ni le replay ni le grounding (ils utilisent le
|
||||
# fichier _full.png brut).
|
||||
_PII_BLUR_ENABLED = os.environ.get("RPA_PII_BLUR_SERVER", "true").lower() in ("true", "1", "yes")
|
||||
_pii_blur_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="pii_blur",
|
||||
)
|
||||
|
||||
|
||||
def _produce_blurred_version(raw_path: str, shot_id: str) -> None:
|
||||
"""Exécute (en thread) le pipeline de blur PII sur un screenshot brut.
|
||||
|
||||
Écrit `<stem>_blurred.png` à côté du fichier brut pour l'affichage
|
||||
dashboard/cleaner. Le fichier brut `<stem>.png` reste intact pour le
|
||||
grounding, le replay et l'entraînement.
|
||||
"""
|
||||
if _blur_pii_on_image is None:
|
||||
return
|
||||
try:
|
||||
raw = Path(raw_path)
|
||||
out = raw.with_name(f"{raw.stem}_blurred{raw.suffix or '.png'}")
|
||||
# Évite de retraiter si déjà floutée (robustesse aux doubles réceptions)
|
||||
if out.exists() and out.stat().st_mtime >= raw.stat().st_mtime:
|
||||
return
|
||||
result = _blur_pii_on_image(raw, out)
|
||||
logger.debug(
|
||||
"pii_blur : %s → %d PII (%.0fms, ner=%s)",
|
||||
shot_id, result.count, result.elapsed_ms, result.ner_engine,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("pii_blur : échec sur %s (%s)", shot_id, e)
|
||||
|
||||
# Clics en attente d'enrichissement (le screenshot n'est pas encore arrivé)
|
||||
# Clé : (session_id, screenshot_id) → dict avec les infos nécessaires
|
||||
_pending_click_enrichments: Dict[tuple, Dict[str, Any]] = {}
|
||||
@@ -1163,6 +1293,20 @@ async def stream_image(
|
||||
|
||||
file_path_str = str(file_path)
|
||||
|
||||
# Anonymisation PII côté serveur (OCR + NER + blur ciblé).
|
||||
# On ne floute QUE les screenshots affichés dans le dashboard / cleaner :
|
||||
# shot_XXXX_full (screenshots d'action) et heartbeats (vue live).
|
||||
# Les crops, focus, window sont utilisés pour le grounding/template — pas
|
||||
# d'affichage humain direct donc pas besoin de version floutée.
|
||||
# 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.
|
||||
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_")
|
||||
):
|
||||
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
|
||||
|
||||
# Crops : traitement léger (pas d'analyse ScreenAnalyzer)
|
||||
if "_crop" in shot_id:
|
||||
result = worker.process_crop_direct(session_id, shot_id, file_path_str)
|
||||
@@ -3212,6 +3356,92 @@ async def report_action_result(report: ReplayResultReport):
|
||||
replay_state["completed_actions"] += 1
|
||||
replay_state["current_action_index"] += 1
|
||||
|
||||
elif not report.success and (report.system_dialog or (report.error or "").startswith("system_dialog:")):
|
||||
# ── SÉCURITÉ : dialogue système Windows détecté (UAC / CredUI / SmartScreen) ──
|
||||
# L'agent REFUSE de cliquer automatiquement sur ces dialogues.
|
||||
# On bascule immédiatement en paused_need_help — l'humain doit
|
||||
# valider manuellement (saisir mdp, autoriser l'élévation…).
|
||||
# Cf. agent_v1/core/system_dialog_guard.py
|
||||
_sys_info = report.system_dialog or {}
|
||||
_sys_category = (
|
||||
_sys_info.get("category")
|
||||
or (report.error or "system_dialog:unknown").split(":", 1)[-1]
|
||||
)
|
||||
_sys_reason = _sys_info.get("reason", "")
|
||||
_tspec_sys = (original_action or {}).get("target_spec") or report.target_spec or {}
|
||||
|
||||
# Message utilisateur adapté à la catégorie
|
||||
_cat_messages = {
|
||||
"uac_consent": (
|
||||
"Une demande d'élévation de privilèges (UAC) est apparue. "
|
||||
"Je ne clique jamais automatiquement dessus — merci de valider "
|
||||
"ou refuser toi-même, puis relance-moi."
|
||||
),
|
||||
"windows_credential_prompt": (
|
||||
"Windows me demande un mot de passe / identifiants. "
|
||||
"Merci de remplir toi-même, puis relance-moi."
|
||||
),
|
||||
"smartscreen": (
|
||||
"SmartScreen a bloqué l'application. "
|
||||
"Merci de vérifier et débloquer manuellement si légitime."
|
||||
),
|
||||
"windows_defender": (
|
||||
"Windows Defender signale une alerte. "
|
||||
"Merci de vérifier manuellement."
|
||||
),
|
||||
"driver_install": (
|
||||
"Une installation de pilote est demandée. "
|
||||
"Merci de valider manuellement."
|
||||
),
|
||||
}
|
||||
_pause_msg_sys = _cat_messages.get(
|
||||
_sys_category,
|
||||
"Un dialogue système Windows est apparu. "
|
||||
"Je ne clique pas automatiquement dessus — merci de gérer manuellement."
|
||||
)
|
||||
|
||||
replay_state["status"] = "paused_need_help"
|
||||
replay_state["failed_action"] = {
|
||||
"action_id": action_id,
|
||||
"type": (original_action or {}).get("type", "unknown"),
|
||||
"target_description": f"Dialogue système : {_sys_category}",
|
||||
"screenshot_b64": screenshot_after or report.screenshot,
|
||||
"target_spec": _tspec_sys,
|
||||
"reason": "system_dialog",
|
||||
"system_dialog": _sys_info,
|
||||
"error_detail": _sys_reason or (report.error or ""),
|
||||
}
|
||||
replay_state["pause_message"] = _pause_msg_sys
|
||||
error_entry = {
|
||||
"action_id": action_id,
|
||||
"error": f"system_dialog:{_sys_category}",
|
||||
"retry_count": retry_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
replay_state["error_log"].append(error_entry)
|
||||
logger.critical(
|
||||
f"[SECURITE] Replay PAUSE supervisee (dialogue systeme) : "
|
||||
f"{action_id} — categorie={_sys_category} — "
|
||||
f"signal={_sys_info.get('matched_signal', '?')}='{_sys_info.get('matched_value', '?')}' "
|
||||
f"— reason={_sys_reason}"
|
||||
)
|
||||
try:
|
||||
log_replay_failure(
|
||||
replay_id=replay_state["replay_id"],
|
||||
action_id=action_id,
|
||||
target_spec=_tspec_sys,
|
||||
screenshot_b64=screenshot_after or report.screenshot,
|
||||
error=f"system_dialog:{_sys_category}",
|
||||
extra={
|
||||
"system_dialog": _sys_info,
|
||||
"category": _sys_category,
|
||||
"matched_signal": _sys_info.get("matched_signal", ""),
|
||||
"matched_value": _sys_info.get("matched_value", ""),
|
||||
},
|
||||
)
|
||||
except Exception as _log_exc:
|
||||
logger.debug("log_replay_failure skip (system_dialog): %s", _log_exc)
|
||||
|
||||
elif not report.success and agent_warning == "wrong_window":
|
||||
# L'agent a détecté en pré-vérification que la fenêtre active
|
||||
# n'est pas celle attendue. Même philosophie que no_screen_change :
|
||||
@@ -4495,6 +4725,149 @@ async def list_chat_sessions():
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fleet management — enrollment des postes collaborateurs
|
||||
# Consommes par deploy/installer/Lea.iss et deploy/installer/uninstall_lea.ps1
|
||||
# =========================================================================
|
||||
|
||||
def _agent_row_public(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Projette un row de la table enrolled_agents pour l'API publique.
|
||||
|
||||
On ne renvoie PAS l'id SQL interne : machine_id est l'identifiant public.
|
||||
"""
|
||||
return {
|
||||
"machine_id": row.get("machine_id"),
|
||||
"user_name": row.get("user_name"),
|
||||
"user_email": row.get("user_email"),
|
||||
"user_id": row.get("user_id"),
|
||||
"hostname": row.get("hostname"),
|
||||
"os_info": row.get("os_info"),
|
||||
"version": row.get("version"),
|
||||
"status": row.get("status"),
|
||||
"enrolled_at": row.get("enrolled_at"),
|
||||
"last_seen_at": row.get("last_seen_at"),
|
||||
"uninstalled_at": row.get("uninstalled_at"),
|
||||
"uninstall_reason": row.get("uninstall_reason"),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/agents/enroll", status_code=201)
|
||||
async def agents_enroll(request: AgentEnrollRequest):
|
||||
"""Enregistre un nouveau poste collaborateur (appele par l'installeur).
|
||||
|
||||
Comportement :
|
||||
- machine_id unique et obligatoire.
|
||||
- Si deja enrole et actif -> 409 Conflict (avec infos de l'enrollement existant).
|
||||
- Si deja enrole mais desinstalle -> reactive automatiquement (return 201 + reactivated=True).
|
||||
- Token Bearer global obligatoire (un seul token partage entre tous les postes).
|
||||
Une phase 2 pourra emettre un token par poste si besoin.
|
||||
"""
|
||||
machine_id = (request.machine_id or "").strip()
|
||||
if not machine_id:
|
||||
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
|
||||
|
||||
try:
|
||||
result = agent_registry.enroll(
|
||||
machine_id=machine_id,
|
||||
user_name=request.user_name,
|
||||
user_email=request.user_email,
|
||||
user_id=request.user_id,
|
||||
hostname=request.hostname,
|
||||
os_info=request.os_info,
|
||||
version=request.version,
|
||||
)
|
||||
except AgentAlreadyEnrolledError as exc:
|
||||
existing = _agent_row_public(exc.existing)
|
||||
logger.warning(
|
||||
f"[FLEET] Tentative de reenrollement machine_id={machine_id} "
|
||||
f"(deja actif depuis {existing.get('enrolled_at')})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "already_enrolled",
|
||||
"message": "machine_id deja enrole et actif",
|
||||
"existing": existing,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
agent = _agent_row_public(result["agent"])
|
||||
event_kind = "reactivated" if result["reactivated"] else "created"
|
||||
logger.info(
|
||||
f"[FLEET] Agent enrole ({event_kind}) : machine_id={machine_id} "
|
||||
f"user={request.user_name!r} hostname={request.hostname!r} "
|
||||
f"version={request.version!r}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "enrolled",
|
||||
"created": result["created"],
|
||||
"reactivated": result["reactivated"],
|
||||
"machine_id": machine_id,
|
||||
# Phase 1 : on renvoie le token global pour que le client puisse
|
||||
# verifier qu'il est bien aligne avec le serveur. Phase 2 pourra
|
||||
# emettre un token par poste (issued_token != API_TOKEN global).
|
||||
"api_token": API_TOKEN,
|
||||
"agent": agent,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/agents/uninstall")
|
||||
async def agents_uninstall(request: AgentUninstallRequest):
|
||||
"""Marque un poste comme desinstalle (soft delete, garde l'historique).
|
||||
|
||||
Appele par deploy/installer/uninstall_lea.ps1 en best-effort. Si le
|
||||
machine_id est inconnu -> 404 (le client l'ignore silencieusement).
|
||||
"""
|
||||
machine_id = (request.machine_id or "").strip()
|
||||
if not machine_id:
|
||||
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
|
||||
|
||||
reason = (request.reason or "").strip() or None
|
||||
|
||||
try:
|
||||
row = agent_registry.uninstall(machine_id=machine_id, reason=reason)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
if row is None:
|
||||
logger.warning(
|
||||
f"[FLEET] Desinstallation d'un machine_id inconnu : {machine_id}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"machine_id={machine_id} introuvable dans le registre",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[FLEET] Agent desinstalle : machine_id={machine_id} reason={reason!r}"
|
||||
)
|
||||
return {
|
||||
"status": "uninstalled",
|
||||
"machine_id": machine_id,
|
||||
"agent": _agent_row_public(row),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/fleet")
|
||||
async def agents_fleet():
|
||||
"""Liste les agents enroles, separes par statut (active / uninstalled).
|
||||
|
||||
Futur dashboard fleet : synthese des postes deployes + ceux disparus.
|
||||
"""
|
||||
active_rows = agent_registry.list_by_status("active")
|
||||
uninstalled_rows = agent_registry.list_by_status("uninstalled")
|
||||
|
||||
return {
|
||||
"active": [_agent_row_public(r) for r in active_rows],
|
||||
"uninstalled": [_agent_row_public(r) for r in uninstalled_rows],
|
||||
"total_active": len(active_rows),
|
||||
"total_uninstalled": len(uninstalled_rows),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ class LiveSessionState:
|
||||
class LiveSessionManager:
|
||||
"""Gère les sessions live en mémoire côté serveur avec persistance disque."""
|
||||
|
||||
def __init__(self, persist_dir: str = "data/streaming_sessions"):
|
||||
def __init__(self, persist_dir: str = "data/streaming_sessions",
|
||||
live_sessions_dir: Optional[str] = None):
|
||||
self._sessions: Dict[str, LiveSessionState] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._persist_dir = Path(persist_dir)
|
||||
@@ -74,11 +75,16 @@ class LiveSessionManager:
|
||||
self._persist_counter = 0 # Compteur pour limiter la fréquence de persistance
|
||||
self._persist_interval = 10 # Persister toutes les N modifications
|
||||
|
||||
# Dossier des sessions live (JSONL + screenshots)
|
||||
self._live_sessions_dir = Path(live_sessions_dir) if live_sessions_dir else None
|
||||
|
||||
# Charger les sessions persistées au démarrage
|
||||
self._load_persisted_sessions()
|
||||
# Reconstruire les sessions depuis les live_events.jsonl sur disque
|
||||
self._discover_sessions_from_disk()
|
||||
|
||||
def _load_persisted_sessions(self):
|
||||
"""Charger les sessions sauvegardées au démarrage."""
|
||||
"""Charger les sessions sauvegardées au démarrage (JSON state files)."""
|
||||
count = 0
|
||||
for session_file in sorted(self._persist_dir.glob("sess_*.json")):
|
||||
try:
|
||||
@@ -92,6 +98,66 @@ class LiveSessionManager:
|
||||
if count:
|
||||
logger.info(f"{count} session(s) restaurée(s) depuis {self._persist_dir}")
|
||||
|
||||
def _discover_sessions_from_disk(self):
|
||||
"""Découvrir les sessions depuis les live_events.jsonl sur disque.
|
||||
|
||||
Reconstruit les sessions manquantes du session_manager en scannant :
|
||||
- live_sessions/sess_*/live_events.jsonl (sessions racine)
|
||||
- live_sessions/{machine_id}/sess_*/live_events.jsonl (multi-machine)
|
||||
|
||||
Ne touche pas aux sessions déjà chargées depuis le JSON persist.
|
||||
"""
|
||||
if self._live_sessions_dir is None:
|
||||
return
|
||||
live_dir = self._live_sessions_dir
|
||||
if not live_dir.exists():
|
||||
return
|
||||
|
||||
discovered = 0
|
||||
for jsonl_file in sorted(live_dir.glob("**/live_events.jsonl")):
|
||||
session_dir = jsonl_file.parent
|
||||
session_id = session_dir.name
|
||||
if not session_id.startswith("sess_"):
|
||||
continue
|
||||
if session_id in self._sessions:
|
||||
continue
|
||||
|
||||
# Déduire le machine_id depuis le chemin parent
|
||||
parent_name = session_dir.parent.name
|
||||
if parent_name == live_dir.name:
|
||||
machine_id = "default"
|
||||
else:
|
||||
machine_id = parent_name
|
||||
|
||||
# Compter events et screenshots
|
||||
events_count = 0
|
||||
try:
|
||||
with open(jsonl_file, 'r', encoding='utf-8') as f:
|
||||
for _ in f:
|
||||
events_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
shots_dir = session_dir / "shots"
|
||||
shots_count = len(list(shots_dir.glob("shot_*_full.png"))) if shots_dir.exists() else 0
|
||||
|
||||
# Créer la session en mémoire
|
||||
session = LiveSessionState(
|
||||
session_id=session_id,
|
||||
machine_id=machine_id,
|
||||
finalized=False,
|
||||
)
|
||||
# Stocker le nombre d'events/shots dans les métadonnées
|
||||
session.shot_paths = {f"shot_{i:04d}": "" for i in range(shots_count)}
|
||||
self._sessions[session_id] = session
|
||||
discovered += 1
|
||||
|
||||
if discovered:
|
||||
logger.info(
|
||||
f"{discovered} session(s) découverte(s) depuis {live_dir} "
|
||||
f"(total: {len(self._sessions)} sessions en mémoire)"
|
||||
)
|
||||
|
||||
def _persist_session(self, session_id: str):
|
||||
"""Sauvegarder une session sur disque (appelé périodiquement)."""
|
||||
session = self._sessions.get(session_id)
|
||||
@@ -102,7 +168,7 @@ class LiveSessionManager:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(session.to_dict(), f, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.debug(f"Erreur persistance session {session_id}: {e}")
|
||||
logger.warning(f"Erreur persistance session {session_id}: {e}")
|
||||
|
||||
def _maybe_persist(self, session_id: str):
|
||||
"""Persister si le compteur atteint l'intervalle."""
|
||||
@@ -180,6 +246,17 @@ class LiveSessionManager:
|
||||
if meta_val is not None:
|
||||
info[meta_key] = meta_val
|
||||
session.last_window_info = info
|
||||
# Exploiter window_capture (envoyé par l'agent avec la capture fenêtre)
|
||||
# pour enrichir last_window_info avec le titre précis de la fenêtre cliquée
|
||||
window_capture = event_data.get("window_capture")
|
||||
if window_capture and isinstance(window_capture, dict):
|
||||
wc_title = window_capture.get("title", "").strip()
|
||||
wc_app = window_capture.get("app_name", "").strip()
|
||||
if wc_title:
|
||||
session.last_window_info["title"] = wc_title
|
||||
if wc_app:
|
||||
session.last_window_info["app_name"] = wc_app
|
||||
|
||||
# Accumuler les titres/apps pour le nommage automatique
|
||||
title = session.last_window_info.get("title", "").strip()
|
||||
app_name = session.last_window_info.get("app_name", "").strip()
|
||||
@@ -221,18 +298,41 @@ class LiveSessionManager:
|
||||
import socket
|
||||
|
||||
# Construire les événements au format RawSession
|
||||
# Important : copier TOUTES les données de l'événement (pos, text, keys, button...)
|
||||
# car Event.from_dict() met tout sauf t/type/window/screenshot_id dans event.data,
|
||||
# et le GraphBuilder utilise event.data pour construire les actions.
|
||||
events = []
|
||||
for evt in session.events:
|
||||
# Extraire window info (plusieurs formats possibles)
|
||||
window_raw = evt.get("window")
|
||||
if isinstance(window_raw, dict):
|
||||
window_info = {
|
||||
"title": window_raw.get("title", session.last_window_info.get("title", "")),
|
||||
"app_name": window_raw.get("app_name", session.last_window_info.get("app_name", "unknown")),
|
||||
}
|
||||
else:
|
||||
window_info = {
|
||||
"title": evt.get("window_title", session.last_window_info.get("title", "")),
|
||||
"app_name": evt.get("app_name", session.last_window_info.get("app_name", "unknown")),
|
||||
}
|
||||
events.append({
|
||||
|
||||
raw_event = {
|
||||
"t": evt.get("timestamp", 0),
|
||||
"type": evt.get("type", "unknown"),
|
||||
"window": window_info,
|
||||
"screenshot_id": evt.get("screenshot_id"),
|
||||
})
|
||||
}
|
||||
|
||||
# Copier les données spécifiques au type d'événement
|
||||
# (pos, button, text, keys, etc.) — indispensable pour le replay
|
||||
_skip_keys = {"type", "timestamp", "window", "window_title",
|
||||
"app_name", "screenshot_id", "machine_id",
|
||||
"screen_metadata", "vision_info"}
|
||||
for key, value in evt.items():
|
||||
if key not in _skip_keys and key not in raw_event:
|
||||
raw_event[key] = value
|
||||
|
||||
events.append(raw_event)
|
||||
|
||||
# Construire les screenshots au format RawSession
|
||||
screenshots = []
|
||||
|
||||
@@ -248,7 +248,14 @@ def memory_record_success(
|
||||
try:
|
||||
from core.learning.target_memory_store import TargetFingerprint
|
||||
|
||||
# Stripper les préfixes "memory_" empilés pour ne garder que
|
||||
# la méthode de résolution originale (ex: template_matching).
|
||||
# Sans ça, le cycle lookup → record → lookup empile "memory_"
|
||||
# indéfiniment : memory_memory_memory_template_matching.
|
||||
method_clean = method or "v4_unknown"
|
||||
while method_clean.startswith("memory_"):
|
||||
method_clean = method_clean[len("memory_"):]
|
||||
method_clean = method_clean or "v4_unknown"
|
||||
fingerprint = TargetFingerprint(
|
||||
element_id=f"v4_{method_clean}",
|
||||
bbox=(x_pct, y_pct, 0.0, 0.0),
|
||||
|
||||
@@ -76,6 +76,15 @@ class StepMetrics:
|
||||
confidence_score: float
|
||||
retry_count: int = 0
|
||||
error_details: Optional[str] = None
|
||||
# C1 — Instrumentation vision-aware (ExecutionLoop)
|
||||
# Ces champs proviennent de `StepResult` (core/execution/execution_loop.py).
|
||||
# Tous optionnels avec valeurs par défaut pour rétrocompatibilité.
|
||||
ocr_ms: float = 0.0 # Temps OCR sur ce step
|
||||
ui_ms: float = 0.0 # Temps détection UI sur ce step
|
||||
analyze_ms: float = 0.0 # Temps analyse ScreenState (OCR + UI + reste)
|
||||
total_ms: float = 0.0 # Temps total du step (alias duration_ms)
|
||||
cache_hit: bool = False # True si ScreenState vient du cache perceptuel
|
||||
degraded: bool = False # True si mode dégradé (timeout analyse)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
@@ -92,7 +101,13 @@ class StepMetrics:
|
||||
'status': self.status,
|
||||
'confidence_score': self.confidence_score,
|
||||
'retry_count': self.retry_count,
|
||||
'error_details': self.error_details
|
||||
'error_details': self.error_details,
|
||||
'ocr_ms': self.ocr_ms,
|
||||
'ui_ms': self.ui_ms,
|
||||
'analyze_ms': self.analyze_ms,
|
||||
'total_ms': self.total_ms,
|
||||
'cache_hit': self.cache_hit,
|
||||
'degraded': self.degraded,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -111,7 +126,13 @@ class StepMetrics:
|
||||
status=data['status'],
|
||||
confidence_score=data['confidence_score'],
|
||||
retry_count=data.get('retry_count', 0),
|
||||
error_details=data.get('error_details')
|
||||
error_details=data.get('error_details'),
|
||||
ocr_ms=float(data.get('ocr_ms') or 0.0),
|
||||
ui_ms=float(data.get('ui_ms') or 0.0),
|
||||
analyze_ms=float(data.get('analyze_ms') or 0.0),
|
||||
total_ms=float(data.get('total_ms') or 0.0),
|
||||
cache_hit=bool(data.get('cache_hit') or False),
|
||||
degraded=bool(data.get('degraded') or False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Integration of analytics with ExecutionLoop."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
from ..analytics_system import get_analytics_system
|
||||
@@ -14,17 +14,35 @@ logger = logging.getLogger(__name__)
|
||||
class AnalyticsExecutionIntegration:
|
||||
"""Integrate analytics collection with workflow execution."""
|
||||
|
||||
def __init__(self, enabled: bool = True):
|
||||
def __init__(self, analytics_system: Any = True, enabled: Optional[bool] = None):
|
||||
"""
|
||||
Initialize analytics integration.
|
||||
|
||||
Args:
|
||||
enabled: Whether analytics collection is enabled
|
||||
"""
|
||||
self.enabled = enabled
|
||||
self.analytics = None
|
||||
Accepte deux formes d'appel pour la rétrocompatibilité :
|
||||
- ``AnalyticsExecutionIntegration(enabled=True)`` → auto-load du système
|
||||
- ``AnalyticsExecutionIntegration(analytics_system_instance)`` →
|
||||
utilise l'instance fournie (utilisé par ExecutionLoop)
|
||||
|
||||
if enabled:
|
||||
Args:
|
||||
analytics_system: Instance d'AnalyticsSystem pré-construite, ou
|
||||
True/False pour activer/désactiver (legacy).
|
||||
enabled: Legacy — si défini, prime sur analytics_system.
|
||||
"""
|
||||
# Détection de la forme d'appel
|
||||
if enabled is not None:
|
||||
# Appel legacy explicite: AnalyticsExecutionIntegration(enabled=...)
|
||||
self.enabled = bool(enabled)
|
||||
self.analytics = None
|
||||
elif isinstance(analytics_system, bool):
|
||||
# Appel legacy: AnalyticsExecutionIntegration(True/False)
|
||||
self.enabled = analytics_system
|
||||
self.analytics = None
|
||||
else:
|
||||
# Nouvelle forme: instance injectée
|
||||
self.enabled = analytics_system is not None
|
||||
self.analytics = analytics_system
|
||||
|
||||
if self.enabled and self.analytics is None:
|
||||
try:
|
||||
self.analytics = get_analytics_system()
|
||||
logger.info("Analytics integration enabled")
|
||||
@@ -36,18 +54,21 @@ class AnalyticsExecutionIntegration:
|
||||
self,
|
||||
workflow_id: str,
|
||||
execution_id: Optional[str] = None,
|
||||
total_steps: int = 0
|
||||
total_steps: int = 0,
|
||||
mode: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Called when workflow execution starts.
|
||||
Appelé au démarrage d'une exécution de workflow.
|
||||
|
||||
Args:
|
||||
workflow_id: Workflow identifier
|
||||
execution_id: Execution identifier (generated if None)
|
||||
total_steps: Total number of steps
|
||||
workflow_id: Identifiant du workflow
|
||||
execution_id: Identifiant d'exécution (généré si None)
|
||||
total_steps: Nombre total d'étapes prévues
|
||||
mode: Mode d'exécution (OBSERVATION / COACHING / SUPERVISED /
|
||||
AUTOMATIC). Propagé en contexte pour MetricsCollector.
|
||||
|
||||
Returns:
|
||||
Execution ID
|
||||
Identifiant d'exécution (celui fourni ou nouvellement généré).
|
||||
"""
|
||||
if not self.enabled or not self.analytics:
|
||||
return execution_id or str(uuid.uuid4())
|
||||
@@ -56,11 +77,21 @@ class AnalyticsExecutionIntegration:
|
||||
execution_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
# Start real-time tracking
|
||||
# Démarrage du tracking temps réel
|
||||
self.analytics.realtime_analytics.track_execution(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
total_steps=total_steps
|
||||
total_steps=total_steps,
|
||||
)
|
||||
|
||||
# Ouverture de l'ExecutionMetrics côté collector (état "running").
|
||||
# Cela permet à `on_execution_complete` d'appeler
|
||||
# `record_execution_complete` qui clôture proprement.
|
||||
context = {"mode": mode} if mode else {}
|
||||
self.analytics.metrics_collector.record_execution_start(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
context=context,
|
||||
)
|
||||
|
||||
logger.debug(f"Started tracking execution: {execution_id}")
|
||||
@@ -101,108 +132,247 @@ class AnalyticsExecutionIntegration:
|
||||
execution_id: str,
|
||||
workflow_id: str,
|
||||
node_id: str,
|
||||
action_type: str,
|
||||
started_at: datetime,
|
||||
completed_at: datetime,
|
||||
duration: float,
|
||||
*,
|
||||
duration_ms: float,
|
||||
success: bool,
|
||||
error_message: Optional[str] = None
|
||||
action_type: str = "",
|
||||
started_at: Optional[datetime] = None,
|
||||
completed_at: Optional[datetime] = None,
|
||||
error_message: Optional[str] = None,
|
||||
confidence: float = 0.0,
|
||||
target_element: str = "",
|
||||
retry_count: int = 0,
|
||||
ocr_ms: float = 0.0,
|
||||
ui_ms: float = 0.0,
|
||||
analyze_ms: float = 0.0,
|
||||
total_ms: float = 0.0,
|
||||
cache_hit: bool = False,
|
||||
degraded: bool = False,
|
||||
step_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Called when a step completes.
|
||||
Appelé à la fin d'un step.
|
||||
|
||||
Contrat normalisé (Lot A — avril 2026) : ``duration_ms`` est
|
||||
obligatoire et en millisecondes. Plus de rétrocompat silencieuse
|
||||
sur ``duration`` en secondes.
|
||||
|
||||
Args:
|
||||
execution_id: Execution identifier
|
||||
workflow_id: Workflow identifier
|
||||
node_id: Node identifier
|
||||
action_type: Type of action
|
||||
started_at: Start timestamp
|
||||
completed_at: Completion timestamp
|
||||
duration: Duration in seconds
|
||||
success: Whether step succeeded
|
||||
error_message: Error message if failed
|
||||
execution_id: Identifiant d'exécution
|
||||
workflow_id: Identifiant du workflow
|
||||
node_id: Identifiant du node
|
||||
duration_ms: Durée du step en millisecondes (obligatoire)
|
||||
success: Vrai si le step a réussi
|
||||
action_type: Type d'action (``click``, ``type``, …)
|
||||
started_at: Timestamp de début (déduit de duration_ms si None)
|
||||
completed_at: Timestamp de fin (``now()`` si None)
|
||||
error_message: Message d'erreur si ``success=False``
|
||||
confidence: Score de matching [0, 1]
|
||||
target_element: Élément ciblé (optionnel)
|
||||
retry_count: Nombre de retries
|
||||
ocr_ms: Temps OCR (C1)
|
||||
ui_ms: Temps détection UI (C1)
|
||||
analyze_ms: Temps analyse ScreenState (C1)
|
||||
total_ms: Temps total du step (C1, alias duration_ms)
|
||||
cache_hit: ScreenState depuis cache perceptuel (C1)
|
||||
degraded: Mode dégradé activé (C1)
|
||||
step_id: ID unique du step (généré si None)
|
||||
"""
|
||||
if not self.enabled or not self.analytics:
|
||||
return
|
||||
|
||||
try:
|
||||
# Record step metrics
|
||||
duration_ms_final = float(duration_ms)
|
||||
|
||||
# Normaliser les timestamps
|
||||
if completed_at is None:
|
||||
completed_at = datetime.now()
|
||||
if started_at is None:
|
||||
started_at = completed_at - timedelta(milliseconds=duration_ms_final)
|
||||
|
||||
step_metrics = StepMetrics(
|
||||
step_id=step_id or f"{execution_id}:{node_id}:{completed_at.isoformat()}",
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
node_id=node_id,
|
||||
action_type=action_type,
|
||||
action_type=action_type or "unknown",
|
||||
target_element=target_element,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
duration=duration,
|
||||
success=success,
|
||||
error_message=error_message
|
||||
duration_ms=duration_ms_final,
|
||||
status="completed" if success else "failed",
|
||||
confidence_score=float(confidence),
|
||||
retry_count=retry_count,
|
||||
error_details=error_message,
|
||||
# C1 — vision-aware
|
||||
ocr_ms=float(ocr_ms or 0.0),
|
||||
ui_ms=float(ui_ms or 0.0),
|
||||
analyze_ms=float(analyze_ms or 0.0),
|
||||
total_ms=float(total_ms or duration_ms_final),
|
||||
cache_hit=bool(cache_hit),
|
||||
degraded=bool(degraded),
|
||||
)
|
||||
|
||||
self.analytics.metrics_collector.record_step(step_metrics)
|
||||
|
||||
# Update real-time tracking
|
||||
# Tracking temps réel
|
||||
try:
|
||||
self.analytics.realtime_analytics.record_step_complete(
|
||||
execution_id=execution_id,
|
||||
success=success
|
||||
success=success,
|
||||
)
|
||||
except Exception as rt_err:
|
||||
logger.debug(f"Realtime tracking skipped: {rt_err}")
|
||||
|
||||
logger.debug(f"Recorded step: {node_id} ({'success' if success else 'failed'})")
|
||||
logger.debug(
|
||||
f"Recorded step: {node_id} "
|
||||
f"({'success' if success else 'failed'}, "
|
||||
f"analyze_ms={analyze_ms:.0f}, cache_hit={cache_hit}, "
|
||||
f"degraded={degraded})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording step completion: {e}")
|
||||
|
||||
def on_step_result(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_id: str,
|
||||
step_result: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Raccourci C1 — enregistre un `StepResult` complet.
|
||||
|
||||
Évite aux appelants d'extraire manuellement les champs vision-aware.
|
||||
Utilisé par ExecutionLoop pour pousser StepResult au système analytics.
|
||||
|
||||
Args:
|
||||
execution_id: Identifiant d'exécution
|
||||
workflow_id: Identifiant de workflow
|
||||
step_result: Instance de `core.execution.execution_loop.StepResult`
|
||||
"""
|
||||
if not self.enabled or not self.analytics:
|
||||
return
|
||||
|
||||
action_type = "unknown"
|
||||
try:
|
||||
if getattr(step_result, "action_result", None) is not None:
|
||||
ar = step_result.action_result
|
||||
# ExecutionResult.action est optionnel selon la branche
|
||||
action_type = (
|
||||
getattr(ar, "action_type", None)
|
||||
or getattr(ar, "action", None)
|
||||
or "unknown"
|
||||
)
|
||||
except Exception:
|
||||
action_type = "unknown"
|
||||
|
||||
self.on_step_complete(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
node_id=getattr(step_result, "node_id", "unknown"),
|
||||
action_type=str(action_type),
|
||||
success=bool(getattr(step_result, "success", False)),
|
||||
error_message=None
|
||||
if getattr(step_result, "success", False)
|
||||
else getattr(step_result, "message", None),
|
||||
duration_ms=float(getattr(step_result, "duration_ms", 0.0) or 0.0),
|
||||
confidence=float(getattr(step_result, "match_confidence", 0.0) or 0.0),
|
||||
ocr_ms=float(getattr(step_result, "ocr_ms", 0.0) or 0.0),
|
||||
ui_ms=float(getattr(step_result, "ui_ms", 0.0) or 0.0),
|
||||
analyze_ms=float(getattr(step_result, "analyze_ms", 0.0) or 0.0),
|
||||
total_ms=float(getattr(step_result, "total_ms", 0.0) or 0.0),
|
||||
cache_hit=bool(getattr(step_result, "cache_hit", False)),
|
||||
degraded=bool(getattr(step_result, "degraded", False)),
|
||||
)
|
||||
|
||||
def on_execution_complete(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_id: str,
|
||||
started_at: datetime,
|
||||
completed_at: datetime,
|
||||
duration: float,
|
||||
*,
|
||||
duration_ms: float,
|
||||
status: str,
|
||||
error_message: Optional[str] = None,
|
||||
steps_total: Optional[int] = None,
|
||||
steps_completed: int = 0,
|
||||
steps_failed: int = 0
|
||||
steps_failed: int = 0,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Called when workflow execution completes.
|
||||
Appelé à la fin d'une exécution de workflow.
|
||||
|
||||
Contrat normalisé (Lot A — avril 2026) :
|
||||
- ``duration_ms`` en millisecondes, toujours. Plus de rétrocompat
|
||||
silencieuse sur ``duration`` en secondes.
|
||||
- ``status`` est une chaîne libre (``"completed"``, ``"failed"``,
|
||||
``"stopped"``, ``"timeout"``, …). L'appelant décide.
|
||||
- ``steps_total`` / ``steps_completed`` / ``steps_failed`` : noms
|
||||
alignés sur le dataclass ``ExecutionMetrics``. Si ``steps_total``
|
||||
n'est pas fourni, on le déduit par somme.
|
||||
|
||||
Args:
|
||||
execution_id: Execution identifier
|
||||
workflow_id: Workflow identifier
|
||||
started_at: Start timestamp
|
||||
completed_at: Completion timestamp
|
||||
duration: Duration in seconds
|
||||
status: Final status (success, failed, timeout)
|
||||
error_message: Error message if failed
|
||||
steps_completed: Number of steps completed
|
||||
steps_failed: Number of steps failed
|
||||
execution_id: Identifiant d'exécution
|
||||
workflow_id: Identifiant du workflow
|
||||
duration_ms: Durée totale en millisecondes
|
||||
status: Statut final (``"completed"`` / ``"failed"`` / ``"stopped"``)
|
||||
steps_total: Nombre total de steps exécutés (tous statuts confondus)
|
||||
steps_completed: Nombre de steps réussis
|
||||
steps_failed: Nombre de steps en échec
|
||||
error_message: Message d'erreur si ``status != "completed"``
|
||||
"""
|
||||
if not self.enabled or not self.analytics:
|
||||
return
|
||||
|
||||
# steps_total dérivé si non fourni explicitement
|
||||
if steps_total is None:
|
||||
steps_total = int(steps_completed) + int(steps_failed)
|
||||
|
||||
try:
|
||||
# Record execution metrics
|
||||
collector = self.analytics.metrics_collector
|
||||
|
||||
# record_execution_complete clôture proprement un ExecutionMetrics
|
||||
# ouvert par record_execution_start (chemin nominal via
|
||||
# on_execution_start). Si l'état n'est pas présent (tests, legacy),
|
||||
# on pousse un ExecutionMetrics synthétique directement.
|
||||
completed_at = datetime.now()
|
||||
started_at = completed_at - timedelta(milliseconds=float(duration_ms))
|
||||
|
||||
active = getattr(collector, "_active_executions", None)
|
||||
if active is not None and execution_id in active:
|
||||
collector.record_execution_complete(
|
||||
execution_id=execution_id,
|
||||
status=status,
|
||||
steps_total=int(steps_total),
|
||||
steps_completed=int(steps_completed),
|
||||
steps_failed=int(steps_failed),
|
||||
error_message=error_message,
|
||||
)
|
||||
else:
|
||||
# Fallback explicite : on construit directement un ExecutionMetrics
|
||||
# aligné sur le dataclass (duration_ms, status, steps_*).
|
||||
execution_metrics = ExecutionMetrics(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
duration=duration,
|
||||
duration_ms=float(duration_ms),
|
||||
status=status,
|
||||
steps_total=int(steps_total),
|
||||
steps_completed=int(steps_completed),
|
||||
steps_failed=int(steps_failed),
|
||||
error_message=error_message,
|
||||
steps_completed=steps_completed,
|
||||
steps_failed=steps_failed
|
||||
)
|
||||
# Le collector n'expose pas record_execution(...) : on pousse
|
||||
# dans le buffer protégé par lock pour rester cohérent.
|
||||
with collector._lock:
|
||||
collector._buffer.append(execution_metrics)
|
||||
|
||||
self.analytics.metrics_collector.record_execution(execution_metrics)
|
||||
# Flush pour garantir la persistance immédiate
|
||||
collector.flush()
|
||||
|
||||
# Flush to ensure persistence
|
||||
self.analytics.metrics_collector.flush()
|
||||
|
||||
# Complete real-time tracking
|
||||
# Clôture du tracking temps réel
|
||||
self.analytics.realtime_analytics.complete_execution(
|
||||
execution_id=execution_id,
|
||||
status=status
|
||||
status=status,
|
||||
)
|
||||
|
||||
logger.info(f"Recorded execution: {execution_id} ({status})")
|
||||
@@ -216,39 +386,54 @@ class AnalyticsExecutionIntegration:
|
||||
node_id: str,
|
||||
strategy: str,
|
||||
success: bool,
|
||||
duration: float
|
||||
duration_ms: float,
|
||||
) -> None:
|
||||
"""
|
||||
Called when self-healing attempts recovery.
|
||||
Appelé quand le self-healing tente une récupération.
|
||||
|
||||
Contrat normalisé (Lot A — avril 2026) : ``duration_ms`` en
|
||||
millisecondes, cohérent avec ``on_execution_complete`` et
|
||||
``on_step_complete``. Le StepMetrics construit respecte strictement
|
||||
le dataclass (``status``, ``duration_ms``, ``error_details``,
|
||||
``confidence_score``, ``target_element``, ``step_id``).
|
||||
|
||||
Args:
|
||||
execution_id: Execution identifier
|
||||
workflow_id: Workflow identifier
|
||||
node_id: Node identifier
|
||||
strategy: Recovery strategy used
|
||||
success: Whether recovery succeeded
|
||||
duration: Recovery duration
|
||||
execution_id: Identifiant d'exécution
|
||||
workflow_id: Identifiant du workflow
|
||||
node_id: Node où la récupération est tentée
|
||||
strategy: Stratégie de récupération employée
|
||||
success: Vrai si la récupération a réussi
|
||||
duration_ms: Durée de la tentative en millisecondes
|
||||
"""
|
||||
if not self.enabled or not self.analytics:
|
||||
return
|
||||
|
||||
try:
|
||||
# Record as a special step metric
|
||||
now = datetime.now()
|
||||
started_at = now - timedelta(milliseconds=float(duration_ms))
|
||||
|
||||
recovery_metrics = StepMetrics(
|
||||
step_id=f"{execution_id}:{node_id}:recovery:{now.isoformat()}",
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
node_id=f"{node_id}_recovery",
|
||||
action_type=f"recovery_{strategy}",
|
||||
started_at=datetime.now(),
|
||||
completed_at=datetime.now(),
|
||||
duration=duration,
|
||||
success=success,
|
||||
error_message=None if success else f"Recovery failed: {strategy}"
|
||||
target_element="",
|
||||
started_at=started_at,
|
||||
completed_at=now,
|
||||
duration_ms=float(duration_ms),
|
||||
status="completed" if success else "failed",
|
||||
confidence_score=0.0,
|
||||
retry_count=0,
|
||||
error_details=None if success else f"Recovery failed: {strategy}",
|
||||
)
|
||||
|
||||
self.analytics.metrics_collector.record_step(recovery_metrics)
|
||||
|
||||
logger.debug(f"Recorded recovery: {strategy} ({'success' if success else 'failed'})")
|
||||
logger.debug(
|
||||
f"Recorded recovery: {strategy} "
|
||||
f"({'success' if success else 'failed'})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording recovery attempt: {e}")
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ class TimeSeriesStore:
|
||||
ON execution_metrics(started_at);
|
||||
|
||||
-- Step metrics table
|
||||
-- Les colonnes ocr_ms, ui_ms, analyze_ms, total_ms, cache_hit, degraded
|
||||
-- proviennent de l'instrumentation vision-aware (C1) de ExecutionLoop.
|
||||
CREATE TABLE IF NOT EXISTS step_metrics (
|
||||
step_id TEXT PRIMARY KEY,
|
||||
execution_id TEXT NOT NULL,
|
||||
@@ -56,6 +58,12 @@ class TimeSeriesStore:
|
||||
confidence_score REAL,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
error_details TEXT,
|
||||
ocr_ms REAL DEFAULT 0.0,
|
||||
ui_ms REAL DEFAULT 0.0,
|
||||
analyze_ms REAL DEFAULT 0.0,
|
||||
total_ms REAL DEFAULT 0.0,
|
||||
cache_hit INTEGER DEFAULT 0,
|
||||
degraded INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (execution_id) REFERENCES execution_metrics(execution_id)
|
||||
);
|
||||
|
||||
@@ -101,12 +109,41 @@ class TimeSeriesStore:
|
||||
|
||||
logger.info(f"TimeSeriesStore initialized at {self.db_path}")
|
||||
|
||||
# Colonnes ajoutées ultérieurement — appliquées via ALTER TABLE si absentes.
|
||||
# (C1 — instrumentation vision-aware, avril 2026)
|
||||
_STEP_METRICS_MIGRATIONS = [
|
||||
("ocr_ms", "REAL DEFAULT 0.0"),
|
||||
("ui_ms", "REAL DEFAULT 0.0"),
|
||||
("analyze_ms", "REAL DEFAULT 0.0"),
|
||||
("total_ms", "REAL DEFAULT 0.0"),
|
||||
("cache_hit", "INTEGER DEFAULT 0"),
|
||||
("degraded", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
|
||||
def _init_database(self) -> None:
|
||||
"""Initialize database schema."""
|
||||
"""Initialize database schema and apply lightweight migrations."""
|
||||
with self._get_connection() as conn:
|
||||
conn.executescript(self.SCHEMA)
|
||||
self._migrate_step_metrics(conn)
|
||||
conn.commit()
|
||||
|
||||
def _migrate_step_metrics(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ajoute les colonnes C1 sur une base `step_metrics` pré-existante."""
|
||||
cursor = conn.execute("PRAGMA table_info(step_metrics)")
|
||||
existing = {row[1] for row in cursor.fetchall()}
|
||||
for column, ddl in self._STEP_METRICS_MIGRATIONS:
|
||||
if column not in existing:
|
||||
try:
|
||||
conn.execute(
|
||||
f"ALTER TABLE step_metrics ADD COLUMN {column} {ddl}"
|
||||
)
|
||||
logger.info(
|
||||
f"Migration step_metrics: ajout colonne {column}"
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
# Collision bénigne (colonne déjà ajoutée par un autre process)
|
||||
logger.debug(f"Migration colonne {column} ignorée: {e}")
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(self):
|
||||
"""Get database connection context manager."""
|
||||
@@ -164,13 +201,14 @@ class TimeSeriesStore:
|
||||
))
|
||||
|
||||
def _write_step_metric(self, conn: sqlite3.Connection, metric: StepMetrics) -> None:
|
||||
"""Write step metric."""
|
||||
"""Write step metric (inclut les champs vision-aware C1)."""
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO step_metrics
|
||||
(step_id, execution_id, workflow_id, node_id, action_type, target_element,
|
||||
started_at, completed_at, duration_ms, status, confidence_score,
|
||||
retry_count, error_details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
retry_count, error_details,
|
||||
ocr_ms, ui_ms, analyze_ms, total_ms, cache_hit, degraded)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
metric.step_id,
|
||||
metric.execution_id,
|
||||
@@ -184,7 +222,13 @@ class TimeSeriesStore:
|
||||
metric.status,
|
||||
metric.confidence_score,
|
||||
metric.retry_count,
|
||||
metric.error_details
|
||||
metric.error_details,
|
||||
getattr(metric, 'ocr_ms', 0.0),
|
||||
getattr(metric, 'ui_ms', 0.0),
|
||||
getattr(metric, 'analyze_ms', 0.0),
|
||||
getattr(metric, 'total_ms', 0.0),
|
||||
1 if getattr(metric, 'cache_hit', False) else 0,
|
||||
1 if getattr(metric, 'degraded', False) else 0,
|
||||
))
|
||||
|
||||
def _write_resource_metric(self, conn: sqlite3.Connection, metric: ResourceMetrics) -> None:
|
||||
|
||||
31
core/anonymisation/__init__.py
Normal file
31
core/anonymisation/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# core/anonymisation/__init__.py
|
||||
"""Module de floutage ciblé des PII côté serveur.
|
||||
|
||||
Remplace l'ancien blur client-side (`agent_v0/agent_v1/vision/blur_sensitive.py`)
|
||||
qui floutait toutes les zones de texte claires, cassant les codes CIM, les
|
||||
montants PMSI et les boutons.
|
||||
|
||||
Stratégie :
|
||||
1. OCR (docTR) sur le screenshot → texte + bounding boxes
|
||||
2. NER (EDS-NLP si disponible, sinon regex) → détection des PII
|
||||
3. Filtrage : ne conserver que PERSON / LOCATION / PHONE / NIR / EMAIL
|
||||
4. Blur gaussien uniquement sur les bbox des PII filtrées
|
||||
|
||||
Usage :
|
||||
from core.anonymisation import blur_pii_on_image
|
||||
blurred_path = blur_pii_on_image("shot_0001_full.png")
|
||||
"""
|
||||
|
||||
from .pii_blur import (
|
||||
PIIBlurResult,
|
||||
PIIEntity,
|
||||
PIIBlurrer,
|
||||
blur_pii_on_image,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PIIBlurResult",
|
||||
"PIIEntity",
|
||||
"PIIBlurrer",
|
||||
"blur_pii_on_image",
|
||||
]
|
||||
650
core/anonymisation/pii_blur.py
Normal file
650
core/anonymisation/pii_blur.py
Normal file
@@ -0,0 +1,650 @@
|
||||
# core/anonymisation/pii_blur.py
|
||||
"""Floutage ciblé des PII côté serveur (Personal Identifiable Information).
|
||||
|
||||
Contexte
|
||||
--------
|
||||
L'ancien blur côté client (`agent_v0/agent_v1/vision/blur_sensitive.py`) était
|
||||
trop agressif : il floutait TOUTES les zones blanches avec texte, ce qui
|
||||
détruisait les codes CIM-10, les montants PMSI, les boutons et rendait les
|
||||
screenshots inutilisables pour le replay ou le grounding VLM. De plus,
|
||||
`opencv-python` n'était pas listé dans les dépendances de l'agent, donc le blur
|
||||
échouait silencieusement en production.
|
||||
|
||||
Stratégie retenue (avril 2026)
|
||||
------------------------------
|
||||
1. Agent = zéro blur → envoie les screenshots bruts via TLS.
|
||||
2. Serveur = OCR (docTR) + NER (EDS-NLP avec fallback regex).
|
||||
3. On floute UNIQUEMENT les entités :
|
||||
- PERSON → noms, prénoms
|
||||
- LOCATION → adresses, villes
|
||||
- PHONE → numéros de téléphone
|
||||
- NIR → numéro de sécurité sociale
|
||||
- EMAIL → adresses électroniques
|
||||
Et on préserve :
|
||||
- codes CIM-10 / CCAM
|
||||
- montants (1250€, 31,50 €)
|
||||
- dates (pas PII au sens RGPD santé)
|
||||
- identifiants techniques (shot_0001, session IDs…)
|
||||
4. Deux fichiers sont stockés :
|
||||
- `shot_XXXX_full.png` → version brute (accès restreint)
|
||||
- `shot_XXXX_full_blurred.png` → version pour affichage
|
||||
|
||||
Performance
|
||||
-----------
|
||||
Objectif : < 2 s par screenshot sur RTX 5070.
|
||||
docTR (db_mobilenet_v3_large + crnn_mobilenet_v3_large) : ~800 ms CPU, ~300 ms GPU.
|
||||
EDS-NLP pipeline minimal : ~100 ms pour un texte d'écran typique.
|
||||
Fallback regex : < 10 ms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Types
|
||||
# =============================================================================
|
||||
|
||||
# Type d'entité PII reconnu. Aligné sur les labels EDS-NLP (`nlp.pipes.eds`)
|
||||
# et enrichi par nos propres patterns regex.
|
||||
PII_LABELS = frozenset({
|
||||
"PERSON", # noms de patient, médecin
|
||||
"LOCATION", # adresses, ville, code postal
|
||||
"ADDRESS", # alias de LOCATION (certains pipelines le produisent)
|
||||
"PHONE", # téléphone
|
||||
"NIR", # numéro sécu FR (15 chiffres)
|
||||
"SECURITY_NUMBER", # alias de NIR
|
||||
"EMAIL", # adresse email
|
||||
})
|
||||
|
||||
# Motifs qu'on NE DOIT PAS flouter même s'ils ressemblent à des PII :
|
||||
# - codes CIM-10 : 1 lettre + 2 chiffres + optionnellement .xx
|
||||
# - codes CCAM : 4 lettres + 3 chiffres
|
||||
# - montants (€, euros)
|
||||
# - dates format fr (dd/mm/yyyy, dd-mm-yy)
|
||||
# - identifiants techniques (ex: shot_0001, session_xxxxx)
|
||||
_RE_ICD10 = re.compile(r"\b[A-Z]\d{2}(\.\d{1,3})?\b")
|
||||
_RE_CCAM = re.compile(r"\b[A-Z]{4}\d{3}\b")
|
||||
_RE_MONEY = re.compile(r"\b\d{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{1,2})?\s?€\b", re.IGNORECASE)
|
||||
_RE_DATE = re.compile(r"\b(0?[1-9]|[12]\d|3[01])[/.-](0?[1-9]|1[0-2])[/.-](\d{2}|\d{4})\b")
|
||||
_RE_TECH_ID = re.compile(r"\b(?:shot|session|sess|frame|trace|req|msg)_[\w-]+\b", re.IGNORECASE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entités PII
|
||||
# =============================================================================
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PIIEntity:
|
||||
"""Une entité PII détectée dans un screenshot."""
|
||||
label: str # PERSON, LOCATION, PHONE, NIR, EMAIL
|
||||
text: str # Texte brut détecté
|
||||
bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2) en pixels
|
||||
confidence: float = 1.0 # Score NER (1.0 si regex)
|
||||
source: str = "ner" # "ner" (EDS-NLP) ou "regex"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIIBlurResult:
|
||||
"""Résultat du pipeline de blur."""
|
||||
raw_path: Path
|
||||
blurred_path: Path
|
||||
entities: List[PIIEntity] = field(default_factory=list)
|
||||
elapsed_ms: float = 0.0
|
||||
ocr_ms: float = 0.0
|
||||
ner_ms: float = 0.0
|
||||
blur_ms: float = 0.0
|
||||
ocr_engine: str = "doctr"
|
||||
ner_engine: str = "regex" # ou "edsnlp"
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.entities)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fallback NER par regex (utilisé si EDS-NLP indisponible)
|
||||
# =============================================================================
|
||||
|
||||
# Précaution : on ne marque comme PHONE que des suites contiguës de 10 chiffres
|
||||
# (FR) ou un format international. Les codes à 3-4 chiffres sont ignorés.
|
||||
_RE_PHONE = re.compile(
|
||||
r"\b(?:(?:\+?33|0)\s?[1-9])(?:[\s.-]?\d{2}){4}\b"
|
||||
)
|
||||
_RE_NIR = re.compile(
|
||||
r"\b[12]\s?\d{2}\s?(?:0[1-9]|1[0-2]|20)\s?(?:\d{2}|2A|2B)\s?\d{3}\s?\d{3}(?:\s?\d{2})?\b"
|
||||
)
|
||||
_RE_EMAIL = re.compile(
|
||||
r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE
|
||||
)
|
||||
# Nom : Prénom Nom (au moins 2 majuscules initiales). Attrape aussi
|
||||
# "Mme Dupont", "M. Martin", "Dr. Bernard".
|
||||
# On utilise [^\S\n] (whitespace SANS newline) pour empêcher le match de sauter
|
||||
# de ligne — les lignes sont typiquement des champs distincts dans une UI métier.
|
||||
_RE_PERSON = re.compile(
|
||||
r"\b(?:M\.?|Mme|Mlle|Dr\.?|Pr\.?|Prof\.?)[^\S\n]+"
|
||||
r"[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç\-]+"
|
||||
r"(?:[^\S\n]+[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç\-]+)?"
|
||||
)
|
||||
# Adresse : "12 rue de la Paix", "3, avenue Victor Hugo"
|
||||
# Même principe : on empêche le matching de franchir les sauts de ligne.
|
||||
_RE_ADDRESS = re.compile(
|
||||
r"\b\d{1,4}(?:[^\S\n]?(?:bis|ter|quater))?[,\s]+(?:rue|avenue|av\.?|bd|boulevard|"
|
||||
r"allée|all\.?|place|impasse|chemin|route|rte\.?|quai|cours|voie|passage)"
|
||||
r"[^\S\n]+(?:de[^\S\n]+|du[^\S\n]+|des[^\S\n]+|la[^\S\n]+|le[^\S\n]+|les[^\S\n]+|l'|de[^\S\n]+la[^\S\n]+|d')?"
|
||||
r"[A-Za-zÀ-ÿ\-' ]{2,40}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _regex_find_pii(text: str) -> List[Tuple[str, int, int]]:
|
||||
"""Retourne une liste de (label, offset_debut, offset_fin) par regex.
|
||||
|
||||
Les motifs "techniques" (codes CIM, montants, dates) sont explicitement
|
||||
exclus même si un autre regex les attrape.
|
||||
"""
|
||||
# 1. Collecter toutes les plages à NE PAS flouter
|
||||
protected: List[Tuple[int, int]] = []
|
||||
for rx in (_RE_ICD10, _RE_CCAM, _RE_MONEY, _RE_DATE, _RE_TECH_ID):
|
||||
for m in rx.finditer(text):
|
||||
protected.append(m.span())
|
||||
|
||||
def _is_protected(start: int, end: int) -> bool:
|
||||
for p_start, p_end in protected:
|
||||
# recouvrement non nul
|
||||
if start < p_end and end > p_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
hits: List[Tuple[str, int, int]] = []
|
||||
for label, rx in (
|
||||
("NIR", _RE_NIR),
|
||||
("EMAIL", _RE_EMAIL),
|
||||
("PHONE", _RE_PHONE),
|
||||
("PERSON", _RE_PERSON),
|
||||
("LOCATION", _RE_ADDRESS),
|
||||
):
|
||||
for m in rx.finditer(text):
|
||||
if _is_protected(m.start(), m.end()):
|
||||
continue
|
||||
hits.append((label, m.start(), m.end()))
|
||||
return hits
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NER via EDS-NLP (optionnel)
|
||||
# =============================================================================
|
||||
|
||||
_edsnlp_pipeline = None
|
||||
|
||||
|
||||
def _get_edsnlp_pipeline():
|
||||
"""Charge une pipeline EDS-NLP si le module est disponible.
|
||||
|
||||
Retourne None si EDS-NLP n'est pas installé — le pipeline retombera
|
||||
alors sur le NER regex.
|
||||
"""
|
||||
global _edsnlp_pipeline
|
||||
if _edsnlp_pipeline is not None:
|
||||
return _edsnlp_pipeline
|
||||
try:
|
||||
import edsnlp # type: ignore
|
||||
except ImportError:
|
||||
logger.info(
|
||||
"EDS-NLP non installé — fallback regex utilisé pour la détection PII. "
|
||||
"Pour activer EDS-NLP : pip install edsnlp"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
nlp = edsnlp.blank("eds")
|
||||
nlp.add_pipe("eds.sentences")
|
||||
nlp.add_pipe("eds.normalizer")
|
||||
# Les composants disponibles dépendent de la version installée.
|
||||
# On les ajoute en try/except pour rester résilient.
|
||||
for pipe_name in ("eds.names", "eds.dates", "eds.addresses"):
|
||||
try:
|
||||
nlp.add_pipe(pipe_name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("EDS-NLP : composant %s indisponible (%s)", pipe_name, e)
|
||||
_edsnlp_pipeline = nlp
|
||||
logger.info("EDS-NLP : pipeline chargée")
|
||||
return _edsnlp_pipeline
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("EDS-NLP non utilisable (%s) — fallback regex", e)
|
||||
return None
|
||||
|
||||
|
||||
def _edsnlp_find_pii(text: str, nlp) -> List[Tuple[str, int, int]]:
|
||||
"""Utilise EDS-NLP pour trouver des entités PII.
|
||||
|
||||
Les labels EDS-NLP sont mappés vers nos labels canoniques.
|
||||
"""
|
||||
try:
|
||||
doc = nlp(text)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("EDS-NLP : échec sur texte de %d chars (%s)", len(text), e)
|
||||
return []
|
||||
|
||||
mapping = {
|
||||
"person": "PERSON",
|
||||
"name": "PERSON",
|
||||
"patient": "PERSON",
|
||||
"doctor": "PERSON",
|
||||
"location": "LOCATION",
|
||||
"address": "LOCATION",
|
||||
"city": "LOCATION",
|
||||
}
|
||||
hits: List[Tuple[str, int, int]] = []
|
||||
for ent in getattr(doc, "ents", []):
|
||||
raw_label = str(getattr(ent, "label_", "")).lower()
|
||||
mapped = mapping.get(raw_label)
|
||||
if mapped is None:
|
||||
# On accepte aussi si le label EDS-NLP est déjà l'un de nos labels
|
||||
upper = raw_label.upper()
|
||||
if upper in PII_LABELS:
|
||||
mapped = upper
|
||||
if mapped:
|
||||
hits.append((mapped, ent.start_char, ent.end_char))
|
||||
return hits
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OCR avec bounding boxes par mot (docTR)
|
||||
# =============================================================================
|
||||
|
||||
_ocr_predictor = None
|
||||
|
||||
|
||||
def _get_ocr_predictor():
|
||||
"""Charge un prédicteur docTR léger (mobilenet) pour l'OCR rapide."""
|
||||
global _ocr_predictor
|
||||
if _ocr_predictor is not None:
|
||||
return _ocr_predictor
|
||||
from doctr.models import ocr_predictor # type: ignore
|
||||
_ocr_predictor = ocr_predictor(
|
||||
det_arch="db_mobilenet_v3_large",
|
||||
reco_arch="crnn_mobilenet_v3_large",
|
||||
pretrained=True,
|
||||
)
|
||||
# GPU si disponible
|
||||
try:
|
||||
import torch # type: ignore
|
||||
if torch.cuda.is_available():
|
||||
_ocr_predictor = _ocr_predictor.cuda()
|
||||
logger.info("pii_blur : docTR chargé sur CUDA")
|
||||
else:
|
||||
logger.info("pii_blur : docTR chargé sur CPU")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.info("pii_blur : docTR chargé (device indéterminé)")
|
||||
return _ocr_predictor
|
||||
|
||||
|
||||
def _doctr_ocr(image_path: Path) -> Tuple[List[dict], int, int]:
|
||||
"""Exécute docTR et retourne une liste de mots avec leurs bbox pixel.
|
||||
|
||||
Retour : (words, width, height) où words = [{text, x1, y1, x2, y2}, ...]
|
||||
"""
|
||||
from doctr.io import DocumentFile # type: ignore
|
||||
from PIL import Image
|
||||
|
||||
predictor = _get_ocr_predictor()
|
||||
doc = DocumentFile.from_images([str(image_path)])
|
||||
result = predictor(doc)
|
||||
|
||||
# Les coords sont normalisées (0..1). On les remappe vers la taille réelle.
|
||||
with Image.open(image_path) as img:
|
||||
W, H = img.size
|
||||
|
||||
words: List[dict] = []
|
||||
line_counter = 0
|
||||
for page in result.pages:
|
||||
for block in page.blocks:
|
||||
for line in block.lines:
|
||||
for word in line.words:
|
||||
text = word.value
|
||||
if not text or not text.strip():
|
||||
continue
|
||||
(nx1, ny1), (nx2, ny2) = word.geometry
|
||||
x1 = max(0, int(nx1 * W))
|
||||
y1 = max(0, int(ny1 * H))
|
||||
x2 = min(W, int(nx2 * W))
|
||||
y2 = min(H, int(ny2 * H))
|
||||
words.append({
|
||||
"text": text,
|
||||
"x1": x1, "y1": y1, "x2": x2, "y2": y2,
|
||||
"line": line_counter,
|
||||
})
|
||||
line_counter += 1
|
||||
return words, W, H
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pipeline principal
|
||||
# =============================================================================
|
||||
|
||||
class PIIBlurrer:
|
||||
"""Pipeline réutilisable (garde les modèles en mémoire entre appels).
|
||||
|
||||
Exemple :
|
||||
blurrer = PIIBlurrer()
|
||||
res = blurrer.blur_image("shot_0001_full.png")
|
||||
print(res.count, res.elapsed_ms)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
blur_kernel: Tuple[int, int] = (31, 31),
|
||||
blur_sigma: float = 15.0,
|
||||
bbox_padding: int = 2,
|
||||
use_edsnlp: bool = True,
|
||||
) -> None:
|
||||
self._blur_kernel = blur_kernel
|
||||
self._blur_sigma = blur_sigma
|
||||
self._bbox_padding = bbox_padding
|
||||
self._use_edsnlp = use_edsnlp
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Point d'entrée publique
|
||||
# ------------------------------------------------------------------
|
||||
def blur_image(
|
||||
self,
|
||||
input_path: Union[str, Path],
|
||||
output_path: Optional[Union[str, Path]] = None,
|
||||
) -> PIIBlurResult:
|
||||
"""Floute les PII détectées et écrit la version floutée sur disque.
|
||||
|
||||
Args:
|
||||
input_path: Chemin vers le screenshot brut (PNG/JPG).
|
||||
output_path: Chemin de sortie. Défaut :
|
||||
`<stem>_blurred.png` à côté de l'input.
|
||||
|
||||
Returns:
|
||||
PIIBlurResult avec les timings et la liste des entités détectées.
|
||||
"""
|
||||
input_path = Path(input_path)
|
||||
if not input_path.is_file():
|
||||
raise FileNotFoundError(f"Screenshot introuvable : {input_path}")
|
||||
|
||||
if output_path is None:
|
||||
output_path = input_path.with_name(
|
||||
f"{input_path.stem}_blurred{input_path.suffix or '.png'}"
|
||||
)
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
t_start = time.perf_counter()
|
||||
|
||||
# 1. OCR
|
||||
t_ocr = time.perf_counter()
|
||||
try:
|
||||
words, W, H = _doctr_ocr(input_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("pii_blur : OCR docTR échoué (%s) — pas de blur appliqué", e)
|
||||
# On copie simplement l'original vers la version "blurred"
|
||||
_copy_file(input_path, output_path)
|
||||
return PIIBlurResult(
|
||||
raw_path=input_path,
|
||||
blurred_path=output_path,
|
||||
entities=[],
|
||||
elapsed_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
ocr_ms = (time.perf_counter() - t_ocr) * 1000
|
||||
|
||||
if not words:
|
||||
_copy_file(input_path, output_path)
|
||||
return PIIBlurResult(
|
||||
raw_path=input_path,
|
||||
blurred_path=output_path,
|
||||
entities=[],
|
||||
elapsed_ms=(time.perf_counter() - t_start) * 1000,
|
||||
ocr_ms=ocr_ms,
|
||||
)
|
||||
|
||||
# 2. Reconstituer le texte ligne par ligne en conservant la correspondance
|
||||
# (offset_char → mot) pour pouvoir repérer les bbox des entités.
|
||||
text, char_to_word = _build_text_with_map(words)
|
||||
|
||||
# 3. NER : EDS-NLP si dispo, sinon regex
|
||||
t_ner = time.perf_counter()
|
||||
ner_engine = "regex"
|
||||
entities_spans: List[Tuple[str, int, int]] = []
|
||||
if self._use_edsnlp:
|
||||
nlp = _get_edsnlp_pipeline()
|
||||
if nlp is not None:
|
||||
entities_spans = _edsnlp_find_pii(text, nlp)
|
||||
ner_engine = "edsnlp"
|
||||
# Toujours compléter avec le regex (EDS-NLP ne couvre pas tous les PII
|
||||
# fréquents : email, NIR, téléphone français).
|
||||
entities_spans.extend(_regex_find_pii(text))
|
||||
ner_ms = (time.perf_counter() - t_ner) * 1000
|
||||
|
||||
# Dédupliquer et normaliser
|
||||
entities_spans = _merge_spans(entities_spans)
|
||||
|
||||
# 4. Convertir (label, start, end) → PIIEntity(label, text, bbox pixel)
|
||||
pii_entities: List[PIIEntity] = []
|
||||
for label, start, end in entities_spans:
|
||||
if label not in PII_LABELS:
|
||||
continue
|
||||
bbox = _spans_to_bbox(start, end, char_to_word, words, self._bbox_padding, W, H)
|
||||
if bbox is None:
|
||||
continue
|
||||
pii_entities.append(PIIEntity(
|
||||
label=label,
|
||||
text=text[start:end],
|
||||
bbox=bbox,
|
||||
confidence=1.0,
|
||||
source=("ner" if ner_engine == "edsnlp" else "regex"),
|
||||
))
|
||||
|
||||
# 5. Appliquer le blur gaussien sur les bbox
|
||||
t_blur = time.perf_counter()
|
||||
_apply_blur(input_path, output_path, pii_entities,
|
||||
kernel=self._blur_kernel, sigma=self._blur_sigma)
|
||||
blur_ms = (time.perf_counter() - t_blur) * 1000
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t_start) * 1000
|
||||
if pii_entities:
|
||||
logger.info(
|
||||
"pii_blur : %d PII floutés sur %s (%.0fms : ocr=%.0f ner=%.0f blur=%.0f, ner=%s)",
|
||||
len(pii_entities), input_path.name, elapsed_ms,
|
||||
ocr_ms, ner_ms, blur_ms, ner_engine,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"pii_blur : aucune PII détectée dans %s (%.0fms)",
|
||||
input_path.name, elapsed_ms,
|
||||
)
|
||||
|
||||
return PIIBlurResult(
|
||||
raw_path=input_path,
|
||||
blurred_path=output_path,
|
||||
entities=pii_entities,
|
||||
elapsed_ms=elapsed_ms,
|
||||
ocr_ms=ocr_ms,
|
||||
ner_ms=ner_ms,
|
||||
blur_ms=blur_ms,
|
||||
ner_engine=ner_engine,
|
||||
)
|
||||
|
||||
|
||||
# Instance singleton (lazy)
|
||||
_default_blurrer: Optional[PIIBlurrer] = None
|
||||
|
||||
|
||||
def blur_pii_on_image(
|
||||
input_path: Union[str, Path],
|
||||
output_path: Optional[Union[str, Path]] = None,
|
||||
) -> PIIBlurResult:
|
||||
"""Helper fonctionnel : instancie un PIIBlurrer singleton et l'applique."""
|
||||
global _default_blurrer
|
||||
if _default_blurrer is None:
|
||||
_default_blurrer = PIIBlurrer()
|
||||
return _default_blurrer.blur_image(input_path, output_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers internes
|
||||
# =============================================================================
|
||||
|
||||
def _copy_file(src: Path, dst: Path) -> None:
|
||||
"""Copie bytewise (utilisé quand aucun PII n'est détecté / OCR KO)."""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(src, "rb") as f_in, open(dst, "wb") as f_out:
|
||||
f_out.write(f_in.read())
|
||||
|
||||
|
||||
def _build_text_with_map(words: Sequence[dict]) -> Tuple[str, List[int]]:
|
||||
"""Concatène les mots en texte + mappe chaque caractère vers son index de mot.
|
||||
|
||||
Quand deux mots consécutifs appartiennent à des lignes différentes (champ
|
||||
`line` dans le dict), on insère un `\n` au lieu d'un espace. Cela empêche
|
||||
les regex gloutons (PERSON, LOCATION…) de matcher à travers des lignes
|
||||
logiques, qui sont typiquement des champs distincts dans une UI métier.
|
||||
|
||||
Returns:
|
||||
text : str concaténé (mots séparés par un espace ou un \n)
|
||||
char_to_word : list[int] len == len(text), char_to_word[i] = index du mot
|
||||
(ou -1 pour les séparateurs).
|
||||
"""
|
||||
parts: List[str] = []
|
||||
char_to_word: List[int] = []
|
||||
prev_line: Optional[int] = None
|
||||
for i, w in enumerate(words):
|
||||
cur_line = w.get("line")
|
||||
if i > 0:
|
||||
if prev_line is not None and cur_line is not None and cur_line != prev_line:
|
||||
sep = "\n"
|
||||
else:
|
||||
sep = " "
|
||||
parts.append(sep)
|
||||
char_to_word.append(-1)
|
||||
txt = w["text"]
|
||||
parts.append(txt)
|
||||
char_to_word.extend([i] * len(txt))
|
||||
prev_line = cur_line
|
||||
return "".join(parts), char_to_word
|
||||
|
||||
|
||||
def _spans_to_bbox(
|
||||
start: int,
|
||||
end: int,
|
||||
char_to_word: Sequence[int],
|
||||
words: Sequence[dict],
|
||||
padding: int,
|
||||
image_w: int,
|
||||
image_h: int,
|
||||
) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""Convertit une plage [start, end[ dans le texte en bbox englobant les mots."""
|
||||
if end <= start or start >= len(char_to_word):
|
||||
return None
|
||||
word_ids = set()
|
||||
for i in range(start, min(end, len(char_to_word))):
|
||||
wid = char_to_word[i]
|
||||
if wid >= 0:
|
||||
word_ids.add(wid)
|
||||
if not word_ids:
|
||||
return None
|
||||
xs1, ys1, xs2, ys2 = [], [], [], []
|
||||
for wid in word_ids:
|
||||
w = words[wid]
|
||||
xs1.append(w["x1"]); ys1.append(w["y1"])
|
||||
xs2.append(w["x2"]); ys2.append(w["y2"])
|
||||
x1 = max(0, min(xs1) - padding)
|
||||
y1 = max(0, min(ys1) - padding)
|
||||
x2 = min(image_w, max(xs2) + padding)
|
||||
y2 = min(image_h, max(ys2) + padding)
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
return None
|
||||
return (x1, y1, x2, y2)
|
||||
|
||||
|
||||
def _merge_spans(
|
||||
spans: Sequence[Tuple[str, int, int]],
|
||||
) -> List[Tuple[str, int, int]]:
|
||||
"""Déduplique et fusionne les plages qui se chevauchent sur un même label.
|
||||
|
||||
En cas de conflit inter-labels, on garde celui qui couvre le plus large.
|
||||
"""
|
||||
if not spans:
|
||||
return []
|
||||
# Trier par start puis par -width (le plus long d'abord pour les ties)
|
||||
sorted_spans = sorted(spans, key=lambda s: (s[1], -(s[2] - s[1])))
|
||||
merged: List[Tuple[str, int, int]] = []
|
||||
for label, s, e in sorted_spans:
|
||||
if not merged:
|
||||
merged.append((label, s, e))
|
||||
continue
|
||||
last_label, ls, le = merged[-1]
|
||||
if s < le: # chevauchement
|
||||
# On garde l'étendue fusionnée avec le label du plus large
|
||||
new_start = min(ls, s)
|
||||
new_end = max(le, e)
|
||||
new_label = last_label if (le - ls) >= (e - s) else label
|
||||
merged[-1] = (new_label, new_start, new_end)
|
||||
else:
|
||||
merged.append((label, s, e))
|
||||
return merged
|
||||
|
||||
|
||||
def _apply_blur(
|
||||
src: Path,
|
||||
dst: Path,
|
||||
entities: Sequence[PIIEntity],
|
||||
kernel: Tuple[int, int],
|
||||
sigma: float,
|
||||
) -> None:
|
||||
"""Applique un flou gaussien sur les bbox des entités et écrit l'image."""
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(src) as img:
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
if not entities:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(dst, format="PNG", optimize=True)
|
||||
return
|
||||
|
||||
# On privilégie OpenCV s'il est disponible (plus rapide),
|
||||
# sinon on utilise PIL ImageFilter.GaussianBlur.
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
import numpy as np # type: ignore
|
||||
arr = np.array(img)
|
||||
bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
|
||||
for ent in entities:
|
||||
x1, y1, x2, y2 = ent.bbox
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
continue
|
||||
roi = bgr[y1:y2, x1:x2]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
k = (max(3, kernel[0] | 1), max(3, kernel[1] | 1)) # impair
|
||||
bgr[y1:y2, x1:x2] = cv2.GaussianBlur(roi, k, sigma)
|
||||
out = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
img = Image.fromarray(out)
|
||||
except ImportError:
|
||||
from PIL import ImageFilter
|
||||
radius = max(sigma / 2, 4.0)
|
||||
for ent in entities:
|
||||
x1, y1, x2, y2 = ent.bbox
|
||||
region = img.crop((x1, y1, x2, y2))
|
||||
if region.size[0] == 0 or region.size[1] == 0:
|
||||
continue
|
||||
blurred = region.filter(ImageFilter.GaussianBlur(radius=radius))
|
||||
img.paste(blurred, (x1, y1))
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(dst, format="PNG", optimize=True)
|
||||
@@ -68,7 +68,7 @@ class SystemConfig:
|
||||
clip_model: str = "ViT-B-32"
|
||||
clip_pretrained: str = "openai"
|
||||
clip_device: str = "cpu"
|
||||
vlm_model: str = "qwen3-vl:8b"
|
||||
vlm_model: str = "gemma4:latest"
|
||||
vlm_endpoint: str = "http://localhost:11434"
|
||||
owl_model: str = "google/owlv2-base-patch16-ensemble"
|
||||
owl_confidence_threshold: float = 0.1
|
||||
@@ -211,7 +211,7 @@ class ConfigurationManager:
|
||||
clip_model=os.getenv("CLIP_MODEL", "ViT-B-32"),
|
||||
clip_pretrained=os.getenv("CLIP_PRETRAINED", "openai"),
|
||||
clip_device=os.getenv("CLIP_DEVICE", "cpu"),
|
||||
vlm_model=os.getenv("VLM_MODEL", "qwen3-vl:8b"),
|
||||
vlm_model=os.getenv("RPA_VLM_MODEL", os.getenv("VLM_MODEL", "gemma4:latest")),
|
||||
vlm_endpoint=os.getenv("VLM_ENDPOINT", "http://localhost:11434"),
|
||||
owl_model=os.getenv("OWL_MODEL", "google/owlv2-base-patch16-ensemble"),
|
||||
owl_confidence_threshold=float(os.getenv("OWL_CONFIDENCE_THRESHOLD", "0.1")),
|
||||
@@ -435,7 +435,7 @@ class ModelConfig:
|
||||
clip_model: str = "ViT-B-32"
|
||||
clip_pretrained: str = "openai"
|
||||
clip_device: str = "cpu"
|
||||
vlm_model: str = "qwen3-vl:8b"
|
||||
vlm_model: str = "gemma4:latest"
|
||||
vlm_endpoint: str = "http://localhost:11434"
|
||||
owl_model: str = "google/owlv2-base-patch16-ensemble"
|
||||
owl_confidence_threshold: float = 0.1
|
||||
@@ -510,7 +510,7 @@ class FAISSConfig:
|
||||
class GPUResourceConfig:
|
||||
"""Configuration for GPU resource management - DEPRECATED: Use SystemConfig instead"""
|
||||
ollama_endpoint: str = "http://localhost:11434"
|
||||
vlm_model: str = "qwen3-vl:8b"
|
||||
vlm_model: str = "gemma4:latest"
|
||||
clip_model: str = "ViT-B-32"
|
||||
idle_timeout_seconds: int = 300
|
||||
vram_threshold_for_clip_gpu_mb: int = 1024
|
||||
@@ -599,7 +599,7 @@ UPLOADS_PATH=data/training/uploads
|
||||
CLIP_MODEL=ViT-B-32
|
||||
CLIP_PRETRAINED=openai
|
||||
CLIP_DEVICE=cpu
|
||||
VLM_MODEL=qwen3-vl:8b
|
||||
VLM_MODEL=gemma4:latest
|
||||
VLM_ENDPOINT=http://localhost:11434
|
||||
OWL_MODEL=google/owlv2-base-patch16-ensemble
|
||||
OWL_CONFIDENCE_THRESHOLD=0.1
|
||||
|
||||
@@ -25,7 +25,7 @@ class OllamaClient:
|
||||
|
||||
def __init__(self,
|
||||
endpoint: str = "http://localhost:11434",
|
||||
model: str = "qwen3-vl:8b",
|
||||
model: str = None,
|
||||
timeout: int = 180):
|
||||
"""
|
||||
Initialiser le client Ollama
|
||||
@@ -36,7 +36,12 @@ class OllamaClient:
|
||||
timeout: Timeout en secondes
|
||||
"""
|
||||
self.endpoint = endpoint.rstrip('/')
|
||||
# Résolution du modèle : paramètre explicite > config centralisée
|
||||
if model is not None:
|
||||
self.model = model
|
||||
else:
|
||||
from core.detection.vlm_config import get_vlm_model
|
||||
self.model = get_vlm_model(endpoint=self.endpoint)
|
||||
self.timeout = timeout
|
||||
self._check_connection()
|
||||
|
||||
@@ -126,7 +131,12 @@ class OllamaClient:
|
||||
messages.append(user_message)
|
||||
|
||||
# Déterminer si le modèle est un modèle thinking (qwen3)
|
||||
is_thinking_model = "qwen3" in self.model.lower()
|
||||
# Les modèles non-thinking (gemma4, qwen2.5vl) n'ont pas besoin
|
||||
# du workaround prefill et supportent le rôle system natif.
|
||||
from core.detection.vlm_config import is_thinking_model as _is_thinking
|
||||
from core.detection.vlm_config import needs_think_false as _needs_think_false
|
||||
is_thinking_model = _is_thinking(self.model)
|
||||
requires_think_false = _needs_think_false(self.model)
|
||||
|
||||
# WORKAROUND Ollama 0.18.x : think=false est ignoré par le
|
||||
# renderer qwen3-vl-thinking. On utilise un assistant prefill
|
||||
@@ -168,9 +178,9 @@ class OllamaClient:
|
||||
}
|
||||
}
|
||||
|
||||
# Garder think=false au cas où une future version d'Ollama le
|
||||
# corrige — le prefill reste le mécanisme principal
|
||||
if is_thinking_model:
|
||||
# think=false : requis pour qwen3 (prefill reste le mécanisme
|
||||
# principal) ET pour gemma4 (sinon tokens vides sur Ollama >=0.20)
|
||||
if is_thinking_model or requires_think_false:
|
||||
payload["think"] = False
|
||||
|
||||
if force_json:
|
||||
@@ -575,7 +585,7 @@ Your answer:"""
|
||||
# Fonctions utilitaires
|
||||
# ============================================================================
|
||||
|
||||
def create_ollama_client(model: str = "qwen3-vl:8b",
|
||||
def create_ollama_client(model: str = None,
|
||||
endpoint: str = "http://localhost:11434") -> OllamaClient:
|
||||
"""
|
||||
Créer un client Ollama
|
||||
|
||||
@@ -72,9 +72,9 @@ class BoundingBox:
|
||||
class DetectionConfig:
|
||||
"""Configuration de la détection UI hybride"""
|
||||
# VLM — modèle configurable via variable d'environnement RPA_VLM_MODEL
|
||||
# Production (local) : "qwen3-vl:8b" — GPU local, pas de réseau
|
||||
# Tests (cloud) : "qwen3-vl:235b-cloud" — pas de GPU, plus lent mais libère la VRAM
|
||||
vlm_model: str = os.environ.get("RPA_VLM_MODEL", "qwen3-vl:8b")
|
||||
# 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_endpoint: str = "http://localhost:11434"
|
||||
use_vlm_classification: bool = True # Utiliser VLM pour classifier
|
||||
|
||||
@@ -865,7 +865,7 @@ JSON array: [{{"id":0,"type":"...","role":"...","text":"..."}}]"""
|
||||
# ============================================================================
|
||||
|
||||
def create_detector(
|
||||
vlm_model: str = "qwen3-vl:8b",
|
||||
vlm_model: str = None,
|
||||
confidence_threshold: float = 0.7,
|
||||
use_vlm: bool = True
|
||||
) -> UIDetector:
|
||||
@@ -873,13 +873,16 @@ def create_detector(
|
||||
Créer un détecteur avec configuration personnalisée
|
||||
|
||||
Args:
|
||||
vlm_model: Modèle VLM à utiliser
|
||||
vlm_model: Modèle VLM à utiliser (None = résolution automatique via vlm_config)
|
||||
confidence_threshold: Seuil de confiance
|
||||
use_vlm: Utiliser le VLM pour la classification
|
||||
|
||||
Returns:
|
||||
UIDetector configuré
|
||||
"""
|
||||
if vlm_model is None:
|
||||
from core.detection.vlm_config import get_vlm_model
|
||||
vlm_model = get_vlm_model()
|
||||
config = DetectionConfig(
|
||||
vlm_model=vlm_model,
|
||||
confidence_threshold=confidence_threshold,
|
||||
|
||||
194
core/detection/vlm_config.py
Normal file
194
core/detection/vlm_config.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Configuration centralisée du modèle VLM (Vision-Language Model).
|
||||
|
||||
Point unique de configuration pour le modèle VLM utilisé dans tout le pipeline.
|
||||
Gère la variable d'environnement RPA_VLM_MODEL avec fallback automatique
|
||||
si le modèle configuré n'est pas disponible dans Ollama.
|
||||
|
||||
Ordre de résolution du modèle :
|
||||
1. Variable d'env RPA_VLM_MODEL (prioritaire)
|
||||
2. Variable d'env VLM_MODEL (compatibilité)
|
||||
3. Modèle par défaut : gemma4:latest
|
||||
|
||||
Fallback automatique :
|
||||
Si le modèle choisi n'est pas trouvé dans Ollama, on essaie les
|
||||
modèles de fallback dans l'ordre (FALLBACK_VLM_MODELS).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
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)
|
||||
DEFAULT_VLM_MODEL = "gemma4:latest"
|
||||
|
||||
# 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"]
|
||||
|
||||
# Endpoint Ollama par défaut
|
||||
DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
|
||||
|
||||
# Cache du modèle résolu (évite de requêter Ollama à chaque appel)
|
||||
_resolved_model: Optional[str] = None
|
||||
_resolved_model_checked = False
|
||||
|
||||
|
||||
def get_vlm_model(
|
||||
endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
|
||||
force_check: bool = False,
|
||||
) -> str:
|
||||
"""Retourne le nom du modèle VLM à utiliser, avec fallback automatique.
|
||||
|
||||
Vérifie la disponibilité du modèle dans Ollama au premier appel,
|
||||
puis cache le résultat pour les appels suivants.
|
||||
|
||||
Args:
|
||||
endpoint: URL de l'API Ollama
|
||||
force_check: Forcer une nouvelle vérification (ignorer le cache)
|
||||
|
||||
Returns:
|
||||
Nom du modèle VLM disponible (ex: "gemma4:latest")
|
||||
"""
|
||||
global _resolved_model, _resolved_model_checked
|
||||
|
||||
if _resolved_model_checked and not force_check:
|
||||
return _resolved_model
|
||||
|
||||
# Lire le modèle configuré depuis l'environnement
|
||||
configured = (
|
||||
os.environ.get("RPA_VLM_MODEL")
|
||||
or os.environ.get("VLM_MODEL")
|
||||
or DEFAULT_VLM_MODEL
|
||||
)
|
||||
|
||||
# Vérifier la disponibilité dans Ollama
|
||||
available = _list_ollama_models(endpoint)
|
||||
|
||||
if available is None:
|
||||
# Ollama non joignable — utiliser le modèle configuré sans vérification
|
||||
logger.warning(
|
||||
"Ollama non joignable (%s) — utilisation de '%s' sans vérification",
|
||||
endpoint, configured,
|
||||
)
|
||||
_resolved_model = configured
|
||||
_resolved_model_checked = True
|
||||
return _resolved_model
|
||||
|
||||
# Vérifier si le modèle configuré est disponible
|
||||
if _model_available(configured, available):
|
||||
logger.info("VLM model: %s (configuré, disponible)", configured)
|
||||
_resolved_model = configured
|
||||
_resolved_model_checked = True
|
||||
return _resolved_model
|
||||
|
||||
# Fallback : essayer les modèles alternatifs
|
||||
logger.warning(
|
||||
"Modèle VLM '%s' non trouvé dans Ollama. Recherche d'un fallback...",
|
||||
configured,
|
||||
)
|
||||
|
||||
# Construire la liste de fallback complète
|
||||
fallback_candidates = [DEFAULT_VLM_MODEL] + FALLBACK_VLM_MODELS
|
||||
for candidate in fallback_candidates:
|
||||
if candidate == configured:
|
||||
continue # Déjà testé
|
||||
if _model_available(candidate, available):
|
||||
logger.info(
|
||||
"VLM model: %s (fallback, '%s' non disponible)",
|
||||
candidate, configured,
|
||||
)
|
||||
_resolved_model = candidate
|
||||
_resolved_model_checked = True
|
||||
return _resolved_model
|
||||
|
||||
# Aucun fallback trouvé — utiliser le modèle configuré quand même
|
||||
# (Ollama le téléchargera peut-être au premier appel)
|
||||
logger.warning(
|
||||
"Aucun modèle VLM trouvé dans Ollama. "
|
||||
"Modèles disponibles : %s. Utilisation de '%s' par défaut.",
|
||||
[m for m in available if "vl" in m.lower() or "gemma" in m.lower()],
|
||||
configured,
|
||||
)
|
||||
_resolved_model = configured
|
||||
_resolved_model_checked = True
|
||||
return _resolved_model
|
||||
|
||||
|
||||
def reset_vlm_model_cache():
|
||||
"""Réinitialiser le cache du modèle résolu.
|
||||
|
||||
Utile après un changement de configuration ou un pull de modèle.
|
||||
"""
|
||||
global _resolved_model, _resolved_model_checked
|
||||
_resolved_model = None
|
||||
_resolved_model_checked = False
|
||||
|
||||
|
||||
def is_thinking_model(model_name: str) -> bool:
|
||||
"""Détermine si un modèle est un modèle 'thinking' (qwen3).
|
||||
|
||||
Les modèles thinking nécessitent un assistant prefill pour éviter
|
||||
le mode réflexion interne qui peut durer >180s avec des images.
|
||||
|
||||
Args:
|
||||
model_name: Nom du modèle (ex: "qwen3-vl:8b", "gemma4:e4b")
|
||||
|
||||
Returns:
|
||||
True si le modèle est de type thinking (nécessite prefill workaround)
|
||||
"""
|
||||
return "qwen3" in model_name.lower()
|
||||
|
||||
|
||||
def needs_think_false(model_name: str) -> bool:
|
||||
"""Détermine si un modèle nécessite think=false dans le payload.
|
||||
|
||||
Sur Ollama >=0.20, gemma4 produit des tokens vides si think n'est pas
|
||||
explicitement désactivé. Ce flag doit être envoyé dans le payload chat.
|
||||
|
||||
Args:
|
||||
model_name: Nom du modèle (ex: "gemma4:latest", "gemma4:e4b")
|
||||
|
||||
Returns:
|
||||
True si le modèle nécessite think=false
|
||||
"""
|
||||
return "gemma4" in model_name.lower()
|
||||
|
||||
|
||||
def _list_ollama_models(endpoint: str) -> Optional[List[str]]:
|
||||
"""Lister les modèles disponibles dans Ollama.
|
||||
|
||||
Returns:
|
||||
Liste des noms de modèles, ou None si Ollama n'est pas joignable.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f"{endpoint}/api/tags", timeout=5)
|
||||
if resp.status_code == 200:
|
||||
models = resp.json().get("models", [])
|
||||
return [m["name"] for m in models]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _model_available(model_name: str, available_models: List[str]) -> bool:
|
||||
"""Vérifie si un modèle est disponible dans la liste Ollama.
|
||||
|
||||
Supporte la correspondance exacte et le match sans tag de version
|
||||
(ex: "gemma4:e4b" match "gemma4:e4b" ou "gemma4:e4b-q4_0").
|
||||
"""
|
||||
# Match exact
|
||||
if model_name in available_models:
|
||||
return True
|
||||
|
||||
# Match par préfixe (sans tag) — "gemma4:e4b" match "gemma4:e4b"
|
||||
base_name = model_name.split(":")[0] if ":" in model_name else model_name
|
||||
for m in available_models:
|
||||
if m.startswith(base_name + ":"):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -11,7 +11,12 @@ from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from core.security.signed_serializer import (
|
||||
SignatureVerificationError,
|
||||
load_signed,
|
||||
save_signed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -500,21 +505,23 @@ class FAISSManager:
|
||||
# Sauvegarder index FAISS
|
||||
faiss.write_index(index_to_save, str(index_path))
|
||||
|
||||
# Sauvegarder métadonnées
|
||||
# Sauvegarder métadonnées (JSON signé HMAC — cf. core.security.signed_serializer)
|
||||
metadata = {
|
||||
"dimensions": self.dimensions,
|
||||
"index_type": self.index_type,
|
||||
"metric": self.metric,
|
||||
"next_id": self.next_id,
|
||||
"metadata_store": self.metadata_store,
|
||||
# Les clés dict sont des int côté Python ; on les sérialise en str
|
||||
# puis on les reconvertit au chargement. JSON n'autorise pas de
|
||||
# clés non-string.
|
||||
"metadata_store": {str(k): v for k, v in self.metadata_store.items()},
|
||||
"nlist": self.nlist,
|
||||
"nprobe": self.nprobe,
|
||||
"is_trained": self.is_trained,
|
||||
"auto_optimize": self.auto_optimize
|
||||
"auto_optimize": self.auto_optimize,
|
||||
}
|
||||
|
||||
with open(metadata_path, 'wb') as f:
|
||||
pickle.dump(metadata, f)
|
||||
save_signed(metadata_path, metadata)
|
||||
|
||||
@classmethod
|
||||
def load(cls, index_path: Path, metadata_path: Path, use_gpu: bool = False) -> 'FAISSManager':
|
||||
@@ -529,11 +536,22 @@ class FAISSManager:
|
||||
Returns:
|
||||
FAISSManager chargé
|
||||
"""
|
||||
# Charger métadonnées
|
||||
with open(metadata_path, 'rb') as f:
|
||||
metadata = pickle.load(f)
|
||||
# Charger métadonnées (JSON signé ; fallback legacy pickle avec migration).
|
||||
try:
|
||||
metadata = load_signed(metadata_path)
|
||||
except SignatureVerificationError:
|
||||
logger.error(
|
||||
"Signature HMAC invalide pour %s — refus de chargement.",
|
||||
metadata_path,
|
||||
)
|
||||
raise
|
||||
|
||||
# Créer instance
|
||||
# Reconvertir les clés int du metadata_store (JSON force des clés str).
|
||||
if isinstance(metadata.get("metadata_store"), dict):
|
||||
metadata["metadata_store"] = {
|
||||
int(k) if isinstance(k, str) and k.lstrip("-").isdigit() else k: v
|
||||
for k, v in metadata["metadata_store"].items()
|
||||
}
|
||||
manager = cls(
|
||||
dimensions=metadata["dimensions"],
|
||||
index_type=metadata["index_type"],
|
||||
|
||||
@@ -525,11 +525,25 @@ class DAGExecutor:
|
||||
True/False selon le résultat de la condition
|
||||
"""
|
||||
condition = action.get("condition", "True")
|
||||
# Contexte d'évaluation sécurisé : uniquement les résultats
|
||||
# Contexte d'évaluation sécurisé : uniquement les résultats.
|
||||
# NB : on utilise un évaluateur AST restreint (pas d'eval/exec),
|
||||
# seuls literals, comparaisons, booléens et indexations sont permis.
|
||||
eval_context = {"results": dict(self._results)}
|
||||
|
||||
# Import local pour éviter une dépendance circulaire au chargement.
|
||||
from core.execution.safe_condition_evaluator import (
|
||||
UnsafeExpressionError,
|
||||
safe_eval_condition,
|
||||
)
|
||||
|
||||
try:
|
||||
result = bool(eval(condition, {"__builtins__": {}}, eval_context))
|
||||
result = bool(safe_eval_condition(condition, eval_context))
|
||||
except UnsafeExpressionError as exc:
|
||||
logger.error(
|
||||
"Condition refusée pour '%s' (expression non sûre) : %s",
|
||||
step.step_id, exc,
|
||||
)
|
||||
result = False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Erreur d'évaluation de condition pour '%s' : %s",
|
||||
|
||||
@@ -151,6 +151,13 @@ class StepResult:
|
||||
duration_ms: float
|
||||
message: str
|
||||
screenshot_path: Optional[str] = None
|
||||
# C1 — Instrumentation vision-aware
|
||||
ocr_ms: float = 0.0 # Temps OCR du ScreenState de ce step
|
||||
ui_ms: float = 0.0 # Temps détection UI de ce step
|
||||
total_ms: float = 0.0 # Temps total (alias de duration_ms pour cohérence)
|
||||
analyze_ms: float = 0.0 # Temps total analyse ScreenState (OCR + UI + reste)
|
||||
cache_hit: bool = False # True si ScreenState vient du cache
|
||||
degraded: bool = False # True si mode dégradé activé (timeout analyse)
|
||||
|
||||
|
||||
class ExecutionLoop:
|
||||
@@ -175,7 +182,13 @@ class ExecutionLoop:
|
||||
capture_interval_ms: int = 500,
|
||||
max_no_match_retries: int = 5,
|
||||
confirmation_callback: Optional[Callable[[str, Dict], bool]] = None,
|
||||
coaching_callback: Optional[Callable[[str, Dict], "CoachingResponse"]] = None
|
||||
coaching_callback: Optional[Callable[[str, Dict], "CoachingResponse"]] = None,
|
||||
screen_analyzer: Optional[Any] = None,
|
||||
screen_state_cache: Optional[Any] = None,
|
||||
enable_ui_detection: bool = True,
|
||||
enable_ocr: bool = True,
|
||||
analyze_timeout_ms: int = 8000,
|
||||
window_info_provider: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
|
||||
):
|
||||
"""
|
||||
Initialiser la boucle d'exécution.
|
||||
@@ -188,6 +201,15 @@ class ExecutionLoop:
|
||||
max_no_match_retries: Nombre max de tentatives si pas de match
|
||||
confirmation_callback: Callback pour demander confirmation (SUPERVISED)
|
||||
coaching_callback: Callback pour décisions coaching (COACHING)
|
||||
screen_analyzer: ScreenAnalyzer pour construire un ScreenState enrichi
|
||||
(lazy init via singleton si None)
|
||||
screen_state_cache: Cache perceptuel (lazy init via singleton si None)
|
||||
enable_ui_detection: Active la détection UI (True par défaut, flag d'urgence)
|
||||
enable_ocr: Active l'OCR (True par défaut)
|
||||
analyze_timeout_ms: Timeout soft pour l'analyse d'un ScreenState.
|
||||
Au-delà, on active le mode dégradé pour les steps suivants.
|
||||
window_info_provider: Callable renvoyant un dict window_info. Si None,
|
||||
on tente `screen_capturer.get_active_window()`.
|
||||
"""
|
||||
self.pipeline = pipeline
|
||||
self.action_executor = action_executor or ActionExecutor()
|
||||
@@ -204,6 +226,27 @@ class ExecutionLoop:
|
||||
self.confirmation_callback = confirmation_callback
|
||||
self.coaching_callback = coaching_callback
|
||||
|
||||
# C1 — Vision-aware execution
|
||||
self._screen_analyzer = screen_analyzer # lazy init si None
|
||||
self._screen_state_cache = screen_state_cache # lazy init si None
|
||||
self.enable_ui_detection = enable_ui_detection
|
||||
self.enable_ocr = enable_ocr
|
||||
self.analyze_timeout_ms = analyze_timeout_ms
|
||||
self._window_info_provider = window_info_provider
|
||||
# Mode dégradé déclenché par un timeout analyse — persiste tant qu'un
|
||||
# probe n'a pas démontré la récupération (voir ci-dessous).
|
||||
self._degraded_mode = False
|
||||
# Auto-rétablissement : compteur de steps rapides consécutifs.
|
||||
# Si l'analyse tourne vite (< analyze_timeout_ms / 2) pendant
|
||||
# _fast_steps_recovery_threshold steps → on quitte le mode dégradé.
|
||||
self._successive_fast_steps = 0
|
||||
self._fast_steps_recovery_threshold = 3
|
||||
# En mode dégradé, on retente l'analyse tous les _probe_interval steps
|
||||
# pour détecter la récupération (les autres steps restent en stub pour
|
||||
# éviter de re-saturer le GPU). 10 par défaut = ~5s à 500ms/step.
|
||||
self._probe_interval = 10
|
||||
self._degraded_step_counter = 0
|
||||
|
||||
# État interne
|
||||
self.state = ExecutionState.IDLE
|
||||
self.context: Optional[ExecutionContext] = None
|
||||
@@ -464,15 +507,15 @@ class ExecutionLoop:
|
||||
})
|
||||
|
||||
# Notify Analytics about step completion
|
||||
# C1 — transmet tous les champs vision-aware (ocr_ms, ui_ms,
|
||||
# analyze_ms, cache_hit, degraded) au système analytics via
|
||||
# on_step_result qui accepte un StepResult complet.
|
||||
if self._analytics_integration and step_result:
|
||||
try:
|
||||
self._analytics_integration.on_step_complete(
|
||||
workflow_id=self.context.workflow_id,
|
||||
self._analytics_integration.on_step_result(
|
||||
execution_id=self.context.execution_id,
|
||||
step_id=step_result.node_id,
|
||||
success=step_result.success,
|
||||
duration_ms=step_result.duration_ms,
|
||||
confidence=step_result.match_confidence
|
||||
workflow_id=self.context.workflow_id,
|
||||
step_result=step_result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Analytics step notification failed: {e}")
|
||||
@@ -505,10 +548,32 @@ class ExecutionLoop:
|
||||
self._notify_state_change(ExecutionState.STOPPED)
|
||||
|
||||
# Notify Analytics about execution completion
|
||||
# Contrat normalisé (Lot A) : duration_ms + status explicite
|
||||
# au lieu du booléen success + duration ambigu.
|
||||
if self._analytics_integration and self.context:
|
||||
try:
|
||||
success = self.state == ExecutionState.COMPLETED
|
||||
duration_ms = (datetime.now() - self.context.started_at).total_seconds() * 1000
|
||||
duration_ms = (
|
||||
datetime.now() - self.context.started_at
|
||||
).total_seconds() * 1000
|
||||
|
||||
# Mapping ExecutionState → status analytics
|
||||
if self.state == ExecutionState.COMPLETED:
|
||||
status = "completed"
|
||||
elif self.state == ExecutionState.FAILED:
|
||||
status = "failed"
|
||||
elif self.state == ExecutionState.STOPPED:
|
||||
status = "stopped"
|
||||
elif self.state == ExecutionState.PAUSED:
|
||||
# Pause non résolue à la sortie = blocage non récupéré
|
||||
status = "blocked"
|
||||
else:
|
||||
status = self.state.value
|
||||
|
||||
error_message = (
|
||||
None
|
||||
if status == "completed"
|
||||
else f"Execution ended in state: {self.state.value}"
|
||||
)
|
||||
|
||||
# Stop resource monitoring
|
||||
self._analytics_integration.stop_resource_monitoring(
|
||||
@@ -518,12 +583,12 @@ class ExecutionLoop:
|
||||
self._analytics_integration.on_execution_complete(
|
||||
workflow_id=self.context.workflow_id,
|
||||
execution_id=self.context.execution_id,
|
||||
success=success,
|
||||
duration_ms=duration_ms,
|
||||
steps_executed=self.context.steps_executed,
|
||||
steps_succeeded=self.context.steps_succeeded,
|
||||
status=status,
|
||||
steps_total=self.context.steps_executed,
|
||||
steps_completed=self.context.steps_succeeded,
|
||||
steps_failed=self.context.steps_failed,
|
||||
error_message=None if success else f"Execution ended in state: {self.state.value}"
|
||||
error_message=error_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Analytics completion notification failed: {e}")
|
||||
@@ -547,10 +612,23 @@ class ExecutionLoop:
|
||||
|
||||
self.context.last_screenshot_path = screenshot_path
|
||||
|
||||
# 1bis. Construire un ScreenState enrichi (C1) — avec cache perceptuel
|
||||
screen_state, timings = self._build_screen_state(screenshot_path)
|
||||
logger.debug(
|
||||
f"[Step] ScreenState analyze={timings['analyze_ms']:.0f}ms "
|
||||
f"ocr={timings['ocr_ms']:.0f}ms ui={timings['ui_ms']:.0f}ms "
|
||||
f"cache_hit={timings['cache_hit']} degraded={timings['degraded']}"
|
||||
)
|
||||
|
||||
# 2. Identifier l'état actuel (matching)
|
||||
match = self.pipeline.match_current_state(
|
||||
screenshot_path,
|
||||
workflow_id=self.context.workflow_id
|
||||
#
|
||||
# Lot E — on consomme le ScreenState enrichi déjà construit en 1bis
|
||||
# (avec ui_elements, detected_text, window_title réels) au lieu de
|
||||
# laisser le pipeline reconstruire un stub avec window_title="Unknown".
|
||||
# Premier vrai matching context-aware.
|
||||
match = self.pipeline.match_current_state_from_state(
|
||||
screen_state,
|
||||
workflow_id=self.context.workflow_id,
|
||||
)
|
||||
|
||||
if not match:
|
||||
@@ -564,25 +642,98 @@ class ExecutionLoop:
|
||||
|
||||
logger.info(f"Matched node: {current_node_id} (confidence: {confidence:.3f})")
|
||||
|
||||
# 3. Obtenir la prochaine action
|
||||
# 3. Obtenir la prochaine action (C3 : sélection d'edge robuste)
|
||||
#
|
||||
# Lot A — contrat dict avec status explicite :
|
||||
# "terminal" → fin légitime du workflow (success=True)
|
||||
# "blocked" → pause supervisée (plus JAMAIS traité comme un succès
|
||||
# pour ne pas déclencher un faux _is_workflow_complete)
|
||||
# "selected" → action à exécuter
|
||||
#
|
||||
# Lot B — on propage la confidence du match courant (source_similarity)
|
||||
# pour que l'EdgeScorer puisse vérifier la précondition
|
||||
# `min_source_similarity` de chaque edge. Sans cette propagation, la
|
||||
# contrainte était silencieusement désactivée (hardcodé à 1.0).
|
||||
next_action = self.pipeline.get_next_action(
|
||||
self.context.workflow_id,
|
||||
current_node_id
|
||||
current_node_id,
|
||||
screen_state=screen_state,
|
||||
source_similarity=confidence,
|
||||
)
|
||||
|
||||
if not next_action:
|
||||
# Pas d'action suivante = fin du workflow ou node terminal
|
||||
# Rétrocompat défensive : si un pipeline legacy renvoie None ou un dict
|
||||
# sans status, on considère ça comme un blocage (safe default).
|
||||
if not isinstance(next_action, dict) or "status" not in next_action:
|
||||
logger.error(
|
||||
"get_next_action a renvoyé un résultat sans status "
|
||||
f"(legacy?). Valeur reçue: {next_action!r}"
|
||||
)
|
||||
next_action = {"status": "blocked", "reason": "legacy_none_return"}
|
||||
|
||||
action_status = next_action.get("status")
|
||||
|
||||
if action_status == "terminal":
|
||||
# Fin légitime : aucun outgoing_edge sur le node courant
|
||||
total_ms = (time.time() - start_time) * 1000
|
||||
return StepResult(
|
||||
success=True,
|
||||
node_id=current_node_id,
|
||||
edge_id=None,
|
||||
action_result=None,
|
||||
match_confidence=confidence,
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
message="No next action (terminal node)",
|
||||
screenshot_path=screenshot_path
|
||||
duration_ms=total_ms,
|
||||
message="Workflow terminated (terminal node)",
|
||||
screenshot_path=screenshot_path,
|
||||
ocr_ms=timings["ocr_ms"],
|
||||
ui_ms=timings["ui_ms"],
|
||||
analyze_ms=timings["analyze_ms"],
|
||||
total_ms=total_ms,
|
||||
cache_hit=timings["cache_hit"],
|
||||
degraded=timings["degraded"],
|
||||
)
|
||||
|
||||
if action_status == "blocked":
|
||||
# Blocage : des edges existent mais aucun n'est valide.
|
||||
# On déclenche une pause supervisée (paused_need_help) et on
|
||||
# remonte l'erreur. On ne retourne PAS success=True.
|
||||
reason = next_action.get("reason", "unknown")
|
||||
logger.warning(
|
||||
f"ExecutionLoop bloqué sur {current_node_id}: {reason} "
|
||||
f"→ pause supervisée demandée"
|
||||
)
|
||||
# On bascule en PAUSED et on arme _pause_requested pour que la
|
||||
# boucle principale attende un resume() humain.
|
||||
self.state = ExecutionState.PAUSED
|
||||
self._pause_requested = True
|
||||
self._notify_state_change(ExecutionState.PAUSED)
|
||||
if self._on_error:
|
||||
try:
|
||||
self._on_error(
|
||||
"blocked",
|
||||
Exception(f"No valid edge from {current_node_id}: {reason}"),
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logger.debug(f"on_error callback failed: {cb_err}")
|
||||
|
||||
total_ms = (time.time() - start_time) * 1000
|
||||
return StepResult(
|
||||
success=False,
|
||||
node_id=current_node_id,
|
||||
edge_id=None,
|
||||
action_result=None,
|
||||
match_confidence=confidence,
|
||||
duration_ms=total_ms,
|
||||
message=f"Blocked: {reason}",
|
||||
screenshot_path=screenshot_path,
|
||||
ocr_ms=timings["ocr_ms"],
|
||||
ui_ms=timings["ui_ms"],
|
||||
analyze_ms=timings["analyze_ms"],
|
||||
total_ms=total_ms,
|
||||
cache_hit=timings["cache_hit"],
|
||||
degraded=timings["degraded"],
|
||||
)
|
||||
|
||||
# À partir d'ici, on est forcément en status="selected"
|
||||
edge_id = next_action["edge_id"]
|
||||
self.context.current_edge_id = edge_id
|
||||
|
||||
@@ -604,7 +755,7 @@ class ExecutionLoop:
|
||||
if coaching_response.decision == CoachingDecision.ACCEPT:
|
||||
# Utilisateur accepte : exécuter l'action suggérée
|
||||
self._coaching_stats['accepted'] += 1
|
||||
action_result = self._execute_action(next_action)
|
||||
action_result = self._execute_action(next_action, screen_state=screen_state)
|
||||
self._record_coaching_feedback(
|
||||
next_action, coaching_response, action_result, success=True
|
||||
)
|
||||
@@ -615,15 +766,22 @@ class ExecutionLoop:
|
||||
self._record_coaching_feedback(
|
||||
next_action, coaching_response, None, success=False
|
||||
)
|
||||
total_ms = (time.time() - start_time) * 1000
|
||||
return StepResult(
|
||||
success=False,
|
||||
node_id=current_node_id,
|
||||
edge_id=edge_id,
|
||||
action_result=None,
|
||||
match_confidence=confidence,
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
duration_ms=total_ms,
|
||||
message="Action rejected by user in COACHING mode",
|
||||
screenshot_path=screenshot_path
|
||||
screenshot_path=screenshot_path,
|
||||
ocr_ms=timings["ocr_ms"],
|
||||
ui_ms=timings["ui_ms"],
|
||||
analyze_ms=timings["analyze_ms"],
|
||||
total_ms=total_ms,
|
||||
cache_hit=timings["cache_hit"],
|
||||
degraded=timings["degraded"],
|
||||
)
|
||||
|
||||
elif coaching_response.decision == CoachingDecision.CORRECT:
|
||||
@@ -632,7 +790,7 @@ class ExecutionLoop:
|
||||
corrected_action = self._apply_coaching_correction(
|
||||
next_action, coaching_response.correction
|
||||
)
|
||||
action_result = self._execute_action(corrected_action)
|
||||
action_result = self._execute_action(corrected_action, screen_state=screen_state)
|
||||
self._record_coaching_feedback(
|
||||
next_action, coaching_response, action_result,
|
||||
success=action_result.status == ExecutionStatus.SUCCESS if action_result else False
|
||||
@@ -658,23 +816,30 @@ class ExecutionLoop:
|
||||
# Mode supervisé : demander confirmation
|
||||
if not self._request_confirmation(next_action):
|
||||
logger.info("Action rejected by user")
|
||||
total_ms = (time.time() - start_time) * 1000
|
||||
return StepResult(
|
||||
success=False,
|
||||
node_id=current_node_id,
|
||||
edge_id=edge_id,
|
||||
action_result=None,
|
||||
match_confidence=confidence,
|
||||
duration_ms=(time.time() - start_time) * 1000,
|
||||
duration_ms=total_ms,
|
||||
message="Action rejected by user",
|
||||
screenshot_path=screenshot_path
|
||||
screenshot_path=screenshot_path,
|
||||
ocr_ms=timings["ocr_ms"],
|
||||
ui_ms=timings["ui_ms"],
|
||||
analyze_ms=timings["analyze_ms"],
|
||||
total_ms=total_ms,
|
||||
cache_hit=timings["cache_hit"],
|
||||
degraded=timings["degraded"],
|
||||
)
|
||||
|
||||
# Exécuter l'action
|
||||
action_result = self._execute_action(next_action)
|
||||
action_result = self._execute_action(next_action, screen_state=screen_state)
|
||||
|
||||
elif self.context.mode == ExecutionMode.AUTOMATIC:
|
||||
# Mode automatique : exécuter directement
|
||||
action_result = self._execute_action(next_action)
|
||||
action_result = self._execute_action(next_action, screen_state=screen_state)
|
||||
|
||||
# 5. Mettre à jour les compteurs
|
||||
self.context.steps_executed += 1
|
||||
@@ -693,7 +858,13 @@ class ExecutionLoop:
|
||||
match_confidence=confidence,
|
||||
duration_ms=duration_ms,
|
||||
message=action_result.message if action_result else "Observed",
|
||||
screenshot_path=screenshot_path
|
||||
screenshot_path=screenshot_path,
|
||||
ocr_ms=timings["ocr_ms"],
|
||||
ui_ms=timings["ui_ms"],
|
||||
analyze_ms=timings["analyze_ms"],
|
||||
total_ms=duration_ms,
|
||||
cache_hit=timings["cache_hit"],
|
||||
degraded=timings["degraded"],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -718,8 +889,18 @@ class ExecutionLoop:
|
||||
logger.error(f"Screen capture failed: {e}")
|
||||
return None
|
||||
|
||||
def _execute_action(self, action_info: Dict[str, Any]) -> ExecutionResult:
|
||||
"""Exécuter une action via l'ActionExecutor."""
|
||||
def _execute_action(
|
||||
self,
|
||||
action_info: Dict[str, Any],
|
||||
screen_state: Optional[Any] = None,
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Exécuter une action via l'ActionExecutor.
|
||||
|
||||
Args:
|
||||
action_info: dict action {edge_id, action, target_node, ...}
|
||||
screen_state: ScreenState enrichi (si None, fallback stub minimal)
|
||||
"""
|
||||
try:
|
||||
# Charger le workflow et l'edge
|
||||
workflow = self.pipeline.load_workflow(self.context.workflow_id)
|
||||
@@ -732,36 +913,10 @@ class ExecutionLoop:
|
||||
duration_ms=0
|
||||
)
|
||||
|
||||
# Créer un ScreenState minimal pour l'exécution
|
||||
from core.models.screen_state import (
|
||||
ScreenState, WindowContext, RawLevel, PerceptionLevel,
|
||||
ContextLevel, EmbeddingRef
|
||||
)
|
||||
|
||||
screen_state = ScreenState(
|
||||
screen_state_id=f"exec_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id=self.context.execution_id,
|
||||
window=WindowContext(
|
||||
app_name="unknown",
|
||||
window_title="Unknown",
|
||||
screen_resolution=[1920, 1080],
|
||||
workspace="main"
|
||||
),
|
||||
raw=RawLevel(
|
||||
screenshot_path=self.context.last_screenshot_path or "",
|
||||
capture_method="execution",
|
||||
file_size_bytes=0
|
||||
),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="", vector_id="", dimensions=512),
|
||||
detected_text=[],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[]
|
||||
)
|
||||
# Utiliser le ScreenState enrichi fourni par le loop ; fallback minimal
|
||||
# uniquement si on n'en a pas (legacy, tests).
|
||||
if screen_state is None:
|
||||
screen_state = self._build_stub_screen_state()
|
||||
|
||||
# Exécuter l'action
|
||||
result = self.action_executor.execute_edge(
|
||||
@@ -782,6 +937,286 @@ class ExecutionLoop:
|
||||
error=e
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# C1 — Construction du ScreenState (vision-aware)
|
||||
# =========================================================================
|
||||
|
||||
def _get_screen_analyzer(self):
|
||||
"""
|
||||
Récupérer le ScreenAnalyzer (singleton partagé, lazy).
|
||||
|
||||
Retourne None si indisponible (import error, etc.) — le loop
|
||||
bascule alors en fallback stub.
|
||||
|
||||
Note Lot C : on ne passe plus `session_id` au singleton. Le session_id
|
||||
est désormais un paramètre d'appel de `analyze()`, pour éviter que deux
|
||||
ExecutionLoop partageant le même analyzer se marchent dessus.
|
||||
"""
|
||||
if self._screen_analyzer is not None:
|
||||
return self._screen_analyzer
|
||||
try:
|
||||
from core.pipeline import get_screen_analyzer
|
||||
self._screen_analyzer = get_screen_analyzer()
|
||||
return self._screen_analyzer
|
||||
except Exception as e:
|
||||
logger.warning(f"ScreenAnalyzer indisponible: {e}")
|
||||
return None
|
||||
|
||||
def _get_screen_state_cache(self):
|
||||
"""Récupérer le cache de ScreenState (singleton partagé, lazy)."""
|
||||
if self._screen_state_cache is not None:
|
||||
return self._screen_state_cache
|
||||
try:
|
||||
from core.pipeline import get_screen_state_cache
|
||||
self._screen_state_cache = get_screen_state_cache()
|
||||
return self._screen_state_cache
|
||||
except Exception as e:
|
||||
logger.warning(f"ScreenStateCache indisponible: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_window_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Récupérer les infos de la fenêtre active.
|
||||
|
||||
Ordre de préférence :
|
||||
1. `window_info_provider` fourni au constructeur
|
||||
2. `screen_capturer.get_active_window()`
|
||||
3. None → ScreenAnalyzer utilisera les valeurs par défaut
|
||||
"""
|
||||
if self._window_info_provider is not None:
|
||||
try:
|
||||
return self._window_info_provider()
|
||||
except Exception as e:
|
||||
logger.debug(f"window_info_provider failed: {e}")
|
||||
|
||||
try:
|
||||
raw = self.screen_capturer.get_active_window()
|
||||
if raw:
|
||||
# Normaliser vers le format attendu par ScreenAnalyzer
|
||||
return {
|
||||
"title": raw.get("title", "Unknown"),
|
||||
"app_name": raw.get("app", "unknown"),
|
||||
"window_bounds": [
|
||||
raw.get("x", 0),
|
||||
raw.get("y", 0),
|
||||
raw.get("width", 0),
|
||||
raw.get("height", 0),
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"get_active_window failed: {e}")
|
||||
return None
|
||||
|
||||
def _build_screen_state(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
) -> tuple:
|
||||
"""
|
||||
Construire un ScreenState enrichi depuis un screenshot.
|
||||
|
||||
Logique :
|
||||
- Si enable_ui_detection=False ET enable_ocr=False → stub
|
||||
- Si analyseur indisponible → stub
|
||||
- Sinon : cache.get_or_compute(analyzer.analyze)
|
||||
- Timeout soft : si l'analyse dépasse `analyze_timeout_ms`, on log
|
||||
un warning et on active le mode dégradé pour les prochains steps.
|
||||
|
||||
Returns:
|
||||
(screen_state, timings_dict)
|
||||
timings_dict: {
|
||||
"analyze_ms", "ocr_ms", "ui_ms", "cache_hit", "degraded"
|
||||
}
|
||||
"""
|
||||
timings = {
|
||||
"analyze_ms": 0.0,
|
||||
"ocr_ms": 0.0,
|
||||
"ui_ms": 0.0,
|
||||
"cache_hit": False,
|
||||
"degraded": False,
|
||||
}
|
||||
|
||||
# Mode "tout désactivé" (flag d'urgence) → stub
|
||||
if not self.enable_ui_detection and not self.enable_ocr:
|
||||
timings["degraded"] = True
|
||||
return self._build_stub_screen_state(screenshot_path), timings
|
||||
|
||||
analyzer = self._get_screen_analyzer()
|
||||
if analyzer is None:
|
||||
timings["degraded"] = True
|
||||
return self._build_stub_screen_state(screenshot_path), timings
|
||||
|
||||
# Mode dégradé : on reste sur stub, sauf "probe" périodique qui teste
|
||||
# si le GPU est redevenu performant. Si oui, on accumule les steps
|
||||
# rapides ; après _fast_steps_recovery_threshold probes rapides
|
||||
# consécutifs on retourne en mode complet.
|
||||
if self._degraded_mode:
|
||||
self._degraded_step_counter += 1
|
||||
if self._degraded_step_counter < self._probe_interval:
|
||||
timings["degraded"] = True
|
||||
return self._build_stub_screen_state(screenshot_path), timings
|
||||
# Sinon on tente un probe réel ci-dessous
|
||||
self._degraded_step_counter = 0
|
||||
|
||||
cache = self._get_screen_state_cache()
|
||||
|
||||
# Invalidation proactive : si l'écran a massivement changé depuis
|
||||
# la dernière entrée du cache, on purge. Le TTL seul (2s) laisserait
|
||||
# passer des entrées obsolètes sur des changements rapides (popup, nav).
|
||||
if cache is not None:
|
||||
try:
|
||||
cache.invalidate_if_changed(screenshot_path, threshold=0.3)
|
||||
except Exception as e:
|
||||
logger.debug(f"invalidate_if_changed a échoué: {e}")
|
||||
|
||||
window_info = self._resolve_window_info()
|
||||
|
||||
# Fonction de calcul (cache miss)
|
||||
# Les flags runtime (enable_ocr, enable_ui_detection) et le session_id
|
||||
# sont passés en kwargs-only à analyze() : AUCUNE mutation de l'analyseur
|
||||
# singleton (Lot C — thread-safety, deux ExecutionLoop peuvent partager
|
||||
# le même analyzer sans se contaminer).
|
||||
execution_id = self.context.execution_id if self.context else ""
|
||||
|
||||
def compute(path: str):
|
||||
t_start = time.time()
|
||||
state = analyzer.analyze(
|
||||
path,
|
||||
window_info=window_info,
|
||||
enable_ocr=self.enable_ocr,
|
||||
enable_ui_detection=self.enable_ui_detection,
|
||||
session_id=execution_id,
|
||||
)
|
||||
elapsed = (time.time() - t_start) * 1000
|
||||
# Annoter le temps dans les métadonnées
|
||||
if hasattr(state, "metadata"):
|
||||
state.metadata["analyze_ms"] = elapsed
|
||||
return state
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
if cache is not None:
|
||||
# Lot D — clé composite context-aware : deux contextes
|
||||
# différents partageant le même screenshot n'entrent plus
|
||||
# en collision. Le workflow_id isole les replays par workflow,
|
||||
# les flags différencient les modes d'analyse (OCR on/off,
|
||||
# UI on/off), et le (window_title, app_name) distingue deux
|
||||
# applications qui présenteraient un rendu visuel similaire.
|
||||
ctx_window_title = (window_info or {}).get("title", "") or ""
|
||||
ctx_app_name = (window_info or {}).get("app_name", "") or ""
|
||||
ctx_workflow_id = (
|
||||
self.context.workflow_id if self.context else ""
|
||||
)
|
||||
state, cache_hit, _ = cache.get_or_compute(
|
||||
screenshot_path,
|
||||
compute,
|
||||
window_title=ctx_window_title,
|
||||
app_name=ctx_app_name,
|
||||
enable_ocr=self.enable_ocr,
|
||||
enable_ui_detection=self.enable_ui_detection,
|
||||
workflow_id=ctx_workflow_id,
|
||||
)
|
||||
else:
|
||||
state = compute(screenshot_path)
|
||||
cache_hit = False
|
||||
except Exception as e:
|
||||
logger.warning(f"ScreenState build failed: {e} — fallback stub")
|
||||
timings["degraded"] = True
|
||||
return self._build_stub_screen_state(screenshot_path), timings
|
||||
|
||||
analyze_ms = (time.time() - t0) * 1000
|
||||
timings["analyze_ms"] = analyze_ms
|
||||
timings["cache_hit"] = cache_hit
|
||||
|
||||
# Décomposer OCR vs UI si possible (métadonnées)
|
||||
meta = getattr(state, "metadata", {}) or {}
|
||||
timings["ocr_ms"] = float(meta.get("ocr_ms", 0.0))
|
||||
timings["ui_ms"] = float(meta.get("ui_ms", 0.0))
|
||||
|
||||
# Timeout soft : activer le mode dégradé si > seuil
|
||||
# (cache_hit ignoré : un hit ne prouve rien sur la santé du GPU)
|
||||
if analyze_ms > self.analyze_timeout_ms and not cache_hit:
|
||||
logger.warning(
|
||||
f"ScreenState analysis slow: {analyze_ms:.0f}ms > "
|
||||
f"{self.analyze_timeout_ms}ms → activation mode dégradé"
|
||||
)
|
||||
self._degraded_mode = True
|
||||
self._successive_fast_steps = 0
|
||||
timings["degraded"] = True
|
||||
else:
|
||||
# Step "rapide" : incrémenter le compteur si < timeout / 2.
|
||||
# On ignore les cache hits (pas représentatifs de la perf GPU).
|
||||
fast_threshold_ms = self.analyze_timeout_ms / 2
|
||||
if not cache_hit and analyze_ms < fast_threshold_ms:
|
||||
self._successive_fast_steps += 1
|
||||
|
||||
# Auto-rétablissement : si on était en dégradé et qu'on a
|
||||
# enchaîné assez de steps rapides → retour en mode complet.
|
||||
if (
|
||||
self._degraded_mode
|
||||
and self._successive_fast_steps
|
||||
>= self._fast_steps_recovery_threshold
|
||||
):
|
||||
logger.info(
|
||||
"Mode complet restauré après %d steps rapides "
|
||||
"(dernier analyze_ms=%.0fms < seuil=%.0fms)",
|
||||
self._successive_fast_steps,
|
||||
analyze_ms,
|
||||
fast_threshold_ms,
|
||||
)
|
||||
self._degraded_mode = False
|
||||
self._successive_fast_steps = 0
|
||||
elif not cache_hit:
|
||||
# Step ni lent ni rapide (entre timeout/2 et timeout) : reset
|
||||
self._successive_fast_steps = 0
|
||||
|
||||
# On propage l'état dégradé courant dans les timings (utile pour le
|
||||
# StepResult : tant qu'on n'a pas récupéré assez de steps rapides,
|
||||
# on continue à signaler "degraded=True").
|
||||
timings["degraded"] = self._degraded_mode
|
||||
|
||||
return state, timings
|
||||
|
||||
def _build_stub_screen_state(self, screenshot_path: Optional[str] = None):
|
||||
"""
|
||||
Construire un ScreenState minimal (fallback legacy).
|
||||
|
||||
Utilisé quand l'analyseur est indisponible ou que tous les flags
|
||||
de détection sont désactivés (flag d'urgence).
|
||||
"""
|
||||
from core.models.screen_state import (
|
||||
ScreenState, WindowContext, RawLevel, PerceptionLevel,
|
||||
ContextLevel, EmbeddingRef
|
||||
)
|
||||
|
||||
path = screenshot_path or (
|
||||
self.context.last_screenshot_path if self.context else ""
|
||||
) or ""
|
||||
|
||||
return ScreenState(
|
||||
screen_state_id=f"exec_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id=self.context.execution_id if self.context else "stub",
|
||||
window=WindowContext(
|
||||
app_name="unknown",
|
||||
window_title="Unknown",
|
||||
screen_resolution=[1920, 1080],
|
||||
workspace="main",
|
||||
),
|
||||
raw=RawLevel(
|
||||
screenshot_path=path,
|
||||
capture_method="execution",
|
||||
file_size_bytes=0,
|
||||
),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="", vector_id="", dimensions=512),
|
||||
detected_text=[],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
def _request_confirmation(self, action_info: Dict[str, Any]) -> bool:
|
||||
"""Demander confirmation à l'utilisateur."""
|
||||
if self.confirmation_callback:
|
||||
|
||||
228
core/execution/safe_condition_evaluator.py
Normal file
228
core/execution/safe_condition_evaluator.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Évaluateur de conditions sécurisé pour le DAGExecutor.
|
||||
|
||||
Remplace `eval()` (vulnérable à l'exécution de code arbitraire) par un
|
||||
parseur AST restreint :
|
||||
|
||||
- Seuls les noeuds AST nécessaires sont autorisés (literals, comparaisons,
|
||||
booléens, indexations, accès attribut limité, arithmétique simple).
|
||||
- Les appels de fonction sont interdits.
|
||||
- Les accès à des attributs « dunder » (`__class__`, `__import__`, etc.)
|
||||
sont systématiquement refusés pour éviter les évasions classiques.
|
||||
- Le contexte d'évaluation est fourni explicitement par l'appelant ;
|
||||
aucun builtins n'est exposé.
|
||||
|
||||
Usage typique :
|
||||
>>> evaluator = SafeConditionEvaluator()
|
||||
>>> evaluator.evaluate("results['step_1']['score'] >= 0.8",
|
||||
... {"results": {"step_1": {"score": 0.92}}})
|
||||
True
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
from typing import Any, Callable, Dict, Mapping
|
||||
|
||||
|
||||
class UnsafeExpressionError(ValueError):
|
||||
"""Levée lorsqu'une expression contient un noeud AST interdit."""
|
||||
|
||||
|
||||
# Opérateurs arithmétiques & de comparaison autorisés.
|
||||
_BIN_OPS: Dict[type, Callable[[Any, Any], Any]] = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
}
|
||||
|
||||
_BOOL_OPS: Dict[type, Callable[[Any, Any], Any]] = {
|
||||
ast.And: lambda a, b: a and b,
|
||||
ast.Or: lambda a, b: a or b,
|
||||
}
|
||||
|
||||
_UNARY_OPS: Dict[type, Callable[[Any], Any]] = {
|
||||
ast.Not: operator.not_,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
_CMP_OPS: Dict[type, Callable[[Any, Any], bool]] = {
|
||||
ast.Eq: operator.eq,
|
||||
ast.NotEq: operator.ne,
|
||||
ast.Lt: operator.lt,
|
||||
ast.LtE: operator.le,
|
||||
ast.Gt: operator.gt,
|
||||
ast.GtE: operator.ge,
|
||||
ast.In: lambda a, b: a in b,
|
||||
ast.NotIn: lambda a, b: a not in b,
|
||||
ast.Is: operator.is_,
|
||||
ast.IsNot: operator.is_not,
|
||||
}
|
||||
|
||||
|
||||
class SafeConditionEvaluator:
|
||||
"""Évalue une expression de condition via un parseur AST restreint."""
|
||||
|
||||
# Longueur max — stoppe les expressions pathologiques très tôt.
|
||||
MAX_EXPRESSION_LENGTH = 1024
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
expression: str,
|
||||
context: Mapping[str, Any],
|
||||
) -> Any:
|
||||
if not isinstance(expression, str):
|
||||
raise UnsafeExpressionError(
|
||||
"L'expression doit être une chaîne de caractères."
|
||||
)
|
||||
if len(expression) > self.MAX_EXPRESSION_LENGTH:
|
||||
raise UnsafeExpressionError(
|
||||
"Expression trop longue (> 1024 caractères)."
|
||||
)
|
||||
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise UnsafeExpressionError(
|
||||
f"Syntaxe d'expression invalide : {exc}"
|
||||
) from exc
|
||||
|
||||
return self._eval_node(tree.body, context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch AST
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _eval_node(self, node: ast.AST, context: Mapping[str, Any]) -> Any:
|
||||
# Littéraux (Constant remplace Num/Str/Bytes/NameConstant depuis 3.8)
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
# Variables : uniquement celles présentes dans `context`.
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in context:
|
||||
raise UnsafeExpressionError(
|
||||
f"Variable '{node.id}' non autorisée."
|
||||
)
|
||||
return context[node.id]
|
||||
|
||||
# Accès attribut — interdit tout attribut dunder.
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr.startswith("_"):
|
||||
raise UnsafeExpressionError(
|
||||
f"Accès à l'attribut privé '{node.attr}' interdit."
|
||||
)
|
||||
value = self._eval_node(node.value, context)
|
||||
return getattr(value, node.attr)
|
||||
|
||||
# Indexation (results['step_1']).
|
||||
if isinstance(node, ast.Subscript):
|
||||
value = self._eval_node(node.value, context)
|
||||
# Python < 3.9 utilise ast.Index, >= 3.9 utilise directement un
|
||||
# noeud. On gère les deux cas.
|
||||
slice_node = node.slice
|
||||
if isinstance(slice_node, ast.Index): # type: ignore[attr-defined]
|
||||
slice_value = self._eval_node(
|
||||
slice_node.value, context # type: ignore[attr-defined]
|
||||
)
|
||||
else:
|
||||
slice_value = self._eval_node(slice_node, context)
|
||||
return value[slice_value]
|
||||
|
||||
# Comparaisons chaînées (a < b <= c).
|
||||
if isinstance(node, ast.Compare):
|
||||
left = self._eval_node(node.left, context)
|
||||
for op_node, comparator in zip(node.ops, node.comparators):
|
||||
op_cls = type(op_node)
|
||||
if op_cls not in _CMP_OPS:
|
||||
raise UnsafeExpressionError(
|
||||
f"Opérateur de comparaison '{op_cls.__name__}' interdit."
|
||||
)
|
||||
right = self._eval_node(comparator, context)
|
||||
if not _CMP_OPS[op_cls](left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
|
||||
# Booléen (and / or) — short-circuit manuel.
|
||||
if isinstance(node, ast.BoolOp):
|
||||
op_cls = type(node.op)
|
||||
if op_cls not in _BOOL_OPS:
|
||||
raise UnsafeExpressionError(
|
||||
f"Opérateur booléen '{op_cls.__name__}' interdit."
|
||||
)
|
||||
if isinstance(node.op, ast.And):
|
||||
result: Any = True
|
||||
for sub in node.values:
|
||||
result = self._eval_node(sub, context)
|
||||
if not result:
|
||||
return result
|
||||
return result
|
||||
# Or
|
||||
result = False
|
||||
for sub in node.values:
|
||||
result = self._eval_node(sub, context)
|
||||
if result:
|
||||
return result
|
||||
return result
|
||||
|
||||
# Unaires (-x, not x)
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
op_cls = type(node.op)
|
||||
if op_cls not in _UNARY_OPS:
|
||||
raise UnsafeExpressionError(
|
||||
f"Opérateur unaire '{op_cls.__name__}' interdit."
|
||||
)
|
||||
return _UNARY_OPS[op_cls](self._eval_node(node.operand, context))
|
||||
|
||||
# Binaires (+, -, *, /, %, **, //)
|
||||
if isinstance(node, ast.BinOp):
|
||||
op_cls = type(node.op)
|
||||
if op_cls not in _BIN_OPS:
|
||||
raise UnsafeExpressionError(
|
||||
f"Opérateur binaire '{op_cls.__name__}' interdit."
|
||||
)
|
||||
left = self._eval_node(node.left, context)
|
||||
right = self._eval_node(node.right, context)
|
||||
return _BIN_OPS[op_cls](left, right)
|
||||
|
||||
# Literals composites
|
||||
if isinstance(node, ast.Tuple):
|
||||
return tuple(self._eval_node(e, context) for e in node.elts)
|
||||
if isinstance(node, ast.List):
|
||||
return [self._eval_node(e, context) for e in node.elts]
|
||||
if isinstance(node, ast.Set):
|
||||
return {self._eval_node(e, context) for e in node.elts}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
self._eval_node(k, context) if k is not None else None:
|
||||
self._eval_node(v, context)
|
||||
for k, v in zip(node.keys, node.values)
|
||||
}
|
||||
|
||||
# Tout le reste (Call, Lambda, Comprehensions, Import, etc.) est
|
||||
# refusé explicitement.
|
||||
raise UnsafeExpressionError(
|
||||
f"Noeud AST '{type(node).__name__}' interdit dans les conditions."
|
||||
)
|
||||
|
||||
|
||||
def safe_eval_condition(
|
||||
expression: str,
|
||||
context: Mapping[str, Any],
|
||||
) -> Any:
|
||||
"""Helper fonctionnel : évalue `expression` avec le contexte donné."""
|
||||
return SafeConditionEvaluator().evaluate(expression, context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SafeConditionEvaluator",
|
||||
"UnsafeExpressionError",
|
||||
"safe_eval_condition",
|
||||
]
|
||||
@@ -2,7 +2,140 @@
|
||||
Pipeline module - Orchestration du flux RPA Vision V3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from .workflow_pipeline import WorkflowPipeline, create_pipeline
|
||||
from .screen_analyzer import ScreenAnalyzer
|
||||
from .screen_state_cache import ScreenStateCache, compute_perceptual_hash
|
||||
from .edge_scorer import EdgeScorer, EdgeScore
|
||||
|
||||
__all__ = ["WorkflowPipeline", "create_pipeline", "ScreenAnalyzer"]
|
||||
__all__ = [
|
||||
"WorkflowPipeline",
|
||||
"create_pipeline",
|
||||
"ScreenAnalyzer",
|
||||
"ScreenStateCache",
|
||||
"compute_perceptual_hash",
|
||||
"EdgeScorer",
|
||||
"EdgeScore",
|
||||
"get_screen_analyzer",
|
||||
"reset_screen_analyzer",
|
||||
"get_screen_state_cache",
|
||||
"reset_screen_state_cache",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton ScreenAnalyzer
|
||||
# =============================================================================
|
||||
#
|
||||
# Une seule instance est partagée entre ExecutionLoop, GraphBuilder et
|
||||
# stream_processor pour éviter le double chargement GPU (UIDetector + CLIP
|
||||
# = 6-10 Go VRAM, plafond 12 Go sur RTX 5070).
|
||||
#
|
||||
# Thread-safe : protégé par un lock.
|
||||
#
|
||||
# IMPORTANT (Lot C — avril 2026) :
|
||||
# Ce singleton ne porte plus AUCUN contexte d'exécution. Il détient
|
||||
# uniquement les ressources lourdes (modèles OCR, UIDetector, CLIP).
|
||||
# • Les flags runtime (`enable_ocr`, `enable_ui_detection`) et l'identité
|
||||
# de session (`session_id`) se passent en kwargs-only à `analyze()`,
|
||||
# jamais en mutant l'instance. Voir `ScreenAnalyzer.analyze()`.
|
||||
# • L'argument `session_id` de `get_screen_analyzer()` ne sert QUE de
|
||||
# valeur par défaut historique, ignorée après la première création.
|
||||
# À terme, prévoir sa suppression.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_SCREEN_ANALYZER_SINGLETON: Optional[ScreenAnalyzer] = None
|
||||
_SCREEN_ANALYZER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_screen_analyzer(
|
||||
ui_detector=None,
|
||||
ocr_engine: Optional[str] = None,
|
||||
session_id: str = "",
|
||||
force_new: bool = False,
|
||||
) -> ScreenAnalyzer:
|
||||
"""
|
||||
Récupérer l'instance partagée de ScreenAnalyzer.
|
||||
|
||||
Création à la première demande (lazy). Les appels ultérieurs retournent
|
||||
la même instance, quels que soient les arguments (sauf `force_new=True`).
|
||||
|
||||
Args:
|
||||
ui_detector: UIDetector optionnel (utilisé seulement à la 1ère création)
|
||||
ocr_engine: Moteur OCR ("doctr", "tesseract", None=auto)
|
||||
session_id: ID de session pour la 1ère création
|
||||
force_new: Forcer la création d'une nouvelle instance (tests)
|
||||
|
||||
Returns:
|
||||
Instance partagée de ScreenAnalyzer
|
||||
"""
|
||||
global _SCREEN_ANALYZER_SINGLETON
|
||||
|
||||
if force_new:
|
||||
with _SCREEN_ANALYZER_LOCK:
|
||||
_SCREEN_ANALYZER_SINGLETON = ScreenAnalyzer(
|
||||
ui_detector=ui_detector,
|
||||
ocr_engine=ocr_engine,
|
||||
session_id=session_id,
|
||||
)
|
||||
return _SCREEN_ANALYZER_SINGLETON
|
||||
|
||||
if _SCREEN_ANALYZER_SINGLETON is not None:
|
||||
return _SCREEN_ANALYZER_SINGLETON
|
||||
|
||||
with _SCREEN_ANALYZER_LOCK:
|
||||
# Double-check locking
|
||||
if _SCREEN_ANALYZER_SINGLETON is None:
|
||||
_SCREEN_ANALYZER_SINGLETON = ScreenAnalyzer(
|
||||
ui_detector=ui_detector,
|
||||
ocr_engine=ocr_engine,
|
||||
session_id=session_id,
|
||||
)
|
||||
return _SCREEN_ANALYZER_SINGLETON
|
||||
|
||||
|
||||
def reset_screen_analyzer() -> None:
|
||||
"""Réinitialiser le singleton (tests uniquement)."""
|
||||
global _SCREEN_ANALYZER_SINGLETON
|
||||
with _SCREEN_ANALYZER_LOCK:
|
||||
_SCREEN_ANALYZER_SINGLETON = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton ScreenStateCache (partagé)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_SCREEN_STATE_CACHE_SINGLETON: Optional[ScreenStateCache] = None
|
||||
_SCREEN_STATE_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_screen_state_cache(
|
||||
ttl_seconds: float = 2.0,
|
||||
max_entries: int = 16,
|
||||
) -> ScreenStateCache:
|
||||
"""
|
||||
Retourne le cache de ScreenState partagé (créé à la 1ère demande).
|
||||
"""
|
||||
global _SCREEN_STATE_CACHE_SINGLETON
|
||||
if _SCREEN_STATE_CACHE_SINGLETON is not None:
|
||||
return _SCREEN_STATE_CACHE_SINGLETON
|
||||
with _SCREEN_STATE_CACHE_LOCK:
|
||||
if _SCREEN_STATE_CACHE_SINGLETON is None:
|
||||
_SCREEN_STATE_CACHE_SINGLETON = ScreenStateCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
return _SCREEN_STATE_CACHE_SINGLETON
|
||||
|
||||
|
||||
def reset_screen_state_cache() -> None:
|
||||
"""Réinitialiser le cache partagé (tests uniquement)."""
|
||||
global _SCREEN_STATE_CACHE_SINGLETON
|
||||
with _SCREEN_STATE_CACHE_LOCK:
|
||||
_SCREEN_STATE_CACHE_SINGLETON = None
|
||||
|
||||
380
core/pipeline/edge_scorer.py
Normal file
380
core/pipeline/edge_scorer.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
EdgeScorer — Sélection robuste d'un edge parmi plusieurs candidats.
|
||||
|
||||
Au lieu de prendre "le premier edge sortant" (comportement legacy),
|
||||
ce module :
|
||||
|
||||
1. Applique un **filtre dur** : rejette les edges dont les `pre_conditions`
|
||||
(EdgeConstraints) échouent étant donné le ScreenState courant.
|
||||
2. Applique un **ranking léger** : score composite
|
||||
- `stats.success_rate` (pondéré fort)
|
||||
- match du `target_spec` (présence d'un UI element compatible)
|
||||
- récence (dernière exécution réussie)
|
||||
3. Retourne le meilleur edge, ou `None` si aucun ne passe le filtre.
|
||||
|
||||
API principale :
|
||||
>>> scorer = EdgeScorer()
|
||||
>>> edge = scorer.select_best(edges, screen_state=state)
|
||||
|
||||
Les scores individuels sont exposés via `score_edge()` pour les tests
|
||||
et la télémétrie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from core.models.screen_state import ScreenState
|
||||
from core.models.workflow_graph import WorkflowEdge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Résultat de scoring (utile pour la télémétrie / debug)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class EdgeScore:
|
||||
"""Résultat détaillé du scoring d'un edge."""
|
||||
|
||||
edge: WorkflowEdge
|
||||
total: float
|
||||
success_rate: float
|
||||
target_match: float
|
||||
recency: float
|
||||
passed_preconditions: bool
|
||||
precondition_reason: str = "OK"
|
||||
|
||||
def __lt__(self, other: "EdgeScore") -> bool:
|
||||
# Utilisé par sorted() : plus grand score = meilleur
|
||||
return self.total < other.total
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Scorer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class EdgeScorer:
|
||||
"""
|
||||
Sélectionne le meilleur edge sortant étant donné un ScreenState.
|
||||
|
||||
Les poids par défaut peuvent être ajustés à la construction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
weight_success_rate: float = 0.55,
|
||||
weight_target_match: float = 0.35,
|
||||
weight_recency: float = 0.10,
|
||||
default_success_rate: float = 0.5,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
weight_success_rate: poids du `edge.stats.success_rate`
|
||||
weight_target_match: poids du match `target_spec` / `ui_elements`
|
||||
weight_recency: poids de la récence de la dernière exécution
|
||||
default_success_rate: valeur quand l'edge n'a jamais été exécuté
|
||||
"""
|
||||
total = weight_success_rate + weight_target_match + weight_recency
|
||||
if total <= 0:
|
||||
raise ValueError("La somme des poids doit être > 0")
|
||||
# Normalisation silencieuse
|
||||
self.w_success = weight_success_rate / total
|
||||
self.w_target = weight_target_match / total
|
||||
self.w_recency = weight_recency / total
|
||||
self.default_success_rate = default_success_rate
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API publique
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def select_best(
|
||||
self,
|
||||
edges: Sequence[WorkflowEdge],
|
||||
screen_state: Optional[ScreenState] = None,
|
||||
strategy: str = "best",
|
||||
source_similarity: float = 1.0,
|
||||
) -> Optional[WorkflowEdge]:
|
||||
"""
|
||||
Sélectionne le meilleur edge.
|
||||
|
||||
Args:
|
||||
edges: Liste des edges candidats (généralement les sortants d'un node)
|
||||
screen_state: État courant pour évaluer pre_conditions et target_spec
|
||||
strategy: "best" (défaut, score complet) ou "first" (legacy, premier edge)
|
||||
source_similarity: confiance du matching qui a identifié le node
|
||||
source courant (valeur propagée depuis `match_current_state`).
|
||||
Utilisée pour évaluer la précondition ``min_source_similarity``
|
||||
de chaque edge. Défaut à ``1.0`` pour compat avec les appelants
|
||||
qui ne la fournissent pas encore.
|
||||
|
||||
Returns:
|
||||
Meilleur edge ou None si aucun ne passe les pre_conditions
|
||||
"""
|
||||
if not edges:
|
||||
return None
|
||||
|
||||
if strategy == "first":
|
||||
# Comportement legacy — retourne le premier edge quoi qu'il arrive
|
||||
return edges[0]
|
||||
|
||||
scores = self.rank(
|
||||
edges, screen_state=screen_state, source_similarity=source_similarity
|
||||
)
|
||||
|
||||
# Filtrer ceux qui ont passé les pre_conditions
|
||||
valid = [s for s in scores if s.passed_preconditions]
|
||||
if not valid:
|
||||
# Aucun edge valide → log pour debug, retourner None
|
||||
reasons = "; ".join(
|
||||
f"{s.edge.edge_id}: {s.precondition_reason}" for s in scores[:5]
|
||||
)
|
||||
logger.warning(
|
||||
f"[EdgeScorer] Aucun edge valide parmi {len(edges)} candidats. "
|
||||
f"Raisons: {reasons}"
|
||||
)
|
||||
return None
|
||||
|
||||
best = valid[0].edge # déjà trié par score décroissant
|
||||
logger.debug(
|
||||
f"[EdgeScorer] Sélection {best.edge_id} "
|
||||
f"(score={valid[0].total:.3f}, parmi {len(valid)} valides)"
|
||||
)
|
||||
return best
|
||||
|
||||
def rank(
|
||||
self,
|
||||
edges: Sequence[WorkflowEdge],
|
||||
screen_state: Optional[ScreenState] = None,
|
||||
source_similarity: float = 1.0,
|
||||
) -> List[EdgeScore]:
|
||||
"""
|
||||
Retourne la liste des edges triés par score décroissant,
|
||||
avec le détail pour chaque edge.
|
||||
|
||||
Tiebreak : `success_rate` le plus haut.
|
||||
|
||||
Args:
|
||||
edges: edges candidats
|
||||
screen_state: état courant (pour pre_conditions + target_match)
|
||||
source_similarity: confiance du match courant, propagée aux
|
||||
pre_conditions pour vérifier ``min_source_similarity``
|
||||
"""
|
||||
scored = [
|
||||
self.score_edge(edge, screen_state, source_similarity=source_similarity)
|
||||
for edge in edges
|
||||
]
|
||||
# Tri : score total décroissant, puis success_rate décroissant
|
||||
scored.sort(key=lambda s: (s.total, s.success_rate), reverse=True)
|
||||
return scored
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scoring par edge
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def score_edge(
|
||||
self,
|
||||
edge: WorkflowEdge,
|
||||
screen_state: Optional[ScreenState] = None,
|
||||
source_similarity: float = 1.0,
|
||||
) -> EdgeScore:
|
||||
"""
|
||||
Calcule le score d'un edge.
|
||||
|
||||
Les pre_conditions sont évaluées ici mais servent uniquement de filtre
|
||||
dur (le score total reste calculé, mais `passed_preconditions` est à False).
|
||||
|
||||
Args:
|
||||
edge: edge à scorer
|
||||
screen_state: état courant (fenêtre, textes, ui_elements)
|
||||
source_similarity: confiance du matching courant, injectée dans
|
||||
``EdgeConstraints.check_preconditions`` pour évaluer
|
||||
``min_source_similarity``.
|
||||
"""
|
||||
# 1. Pre-conditions : filtre dur
|
||||
passed, reason = self._check_preconditions(
|
||||
edge, screen_state, source_similarity=source_similarity
|
||||
)
|
||||
|
||||
# 2. Success rate (dépend des stats existantes)
|
||||
success_rate = self._score_success_rate(edge)
|
||||
|
||||
# 3. Target match (UI element présent ?)
|
||||
target_match = self._score_target_match(edge, screen_state)
|
||||
|
||||
# 4. Récence
|
||||
recency = self._score_recency(edge)
|
||||
|
||||
total = (
|
||||
self.w_success * success_rate
|
||||
+ self.w_target * target_match
|
||||
+ self.w_recency * recency
|
||||
)
|
||||
|
||||
return EdgeScore(
|
||||
edge=edge,
|
||||
total=total,
|
||||
success_rate=success_rate,
|
||||
target_match=target_match,
|
||||
recency=recency,
|
||||
passed_preconditions=passed,
|
||||
precondition_reason=reason,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Composantes du score
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _check_preconditions(
|
||||
self,
|
||||
edge: WorkflowEdge,
|
||||
screen_state: Optional[ScreenState],
|
||||
source_similarity: float = 1.0,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Vérifier les pre_conditions de l'edge.
|
||||
|
||||
Si pas de ScreenState, on ne peut rien vérifier → on laisse passer
|
||||
(mais on loggue).
|
||||
|
||||
Args:
|
||||
edge: edge à évaluer
|
||||
screen_state: état courant (None si non dispo)
|
||||
source_similarity: confiance du matching courant propagée par
|
||||
l'appelant (EdgeScorer.score_edge/rank/select_best). Elle
|
||||
alimente ``EdgeConstraints.check_preconditions`` pour rendre
|
||||
effective la contrainte ``min_source_similarity``.
|
||||
"""
|
||||
constraints = edge.constraints
|
||||
if constraints is None:
|
||||
return True, "OK (pas de contraintes)"
|
||||
|
||||
if screen_state is None:
|
||||
# Pas de ScreenState → on ne peut évaluer ni fenêtre, ni textes,
|
||||
# mais la similarité source reste vérifiable.
|
||||
try:
|
||||
ok, reason = constraints.check_preconditions(
|
||||
window_title="",
|
||||
app_name="",
|
||||
detected_texts=[],
|
||||
source_similarity=source_similarity,
|
||||
)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
except Exception as e:
|
||||
logger.warning(f"[EdgeScorer] Erreur check_preconditions: {e}")
|
||||
return True, f"Erreur ignorée: {e}"
|
||||
return True, "OK (pas de ScreenState pour évaluer)"
|
||||
|
||||
window_title = screen_state.window.window_title if screen_state.window else ""
|
||||
app_name = screen_state.window.app_name if screen_state.window else ""
|
||||
detected_texts = (
|
||||
screen_state.perception.detected_text
|
||||
if screen_state.perception
|
||||
else []
|
||||
)
|
||||
|
||||
try:
|
||||
ok, reason = constraints.check_preconditions(
|
||||
window_title=window_title,
|
||||
app_name=app_name,
|
||||
detected_texts=detected_texts,
|
||||
source_similarity=source_similarity,
|
||||
)
|
||||
return ok, reason
|
||||
except Exception as e:
|
||||
logger.warning(f"[EdgeScorer] Erreur check_preconditions: {e}")
|
||||
# En cas d'erreur, on ne bloque pas l'edge
|
||||
return True, f"Erreur ignorée: {e}"
|
||||
|
||||
def _score_success_rate(self, edge: WorkflowEdge) -> float:
|
||||
"""Score basé sur `edge.stats.success_rate`."""
|
||||
if edge.stats is None or edge.stats.execution_count == 0:
|
||||
return self.default_success_rate
|
||||
return max(0.0, min(1.0, edge.stats.success_rate))
|
||||
|
||||
def _score_target_match(
|
||||
self,
|
||||
edge: WorkflowEdge,
|
||||
screen_state: Optional[ScreenState],
|
||||
) -> float:
|
||||
"""
|
||||
Score de correspondance entre le `target_spec` de l'action et
|
||||
les `ui_elements` de l'écran courant.
|
||||
|
||||
Retourne :
|
||||
- 1.0 si un élément matche strictement (texte ou rôle)
|
||||
- 0.5 si aucun screen_state fourni (neutre, pas pénalisant)
|
||||
- 0.0 si aucun élément compatible
|
||||
"""
|
||||
if screen_state is None:
|
||||
return 0.5
|
||||
|
||||
target = edge.action.target if edge.action else None
|
||||
if target is None:
|
||||
return 0.5
|
||||
|
||||
ui_elements = screen_state.ui_elements or []
|
||||
if not ui_elements:
|
||||
# Pas d'UI détectée → on ne peut pas trancher, neutre
|
||||
return 0.5
|
||||
|
||||
target_text = (target.by_text or "").lower().strip()
|
||||
target_role = (target.by_role or "").lower().strip()
|
||||
|
||||
best = 0.0
|
||||
for el in ui_elements:
|
||||
score = 0.0
|
||||
el_label = getattr(el, "label", "") or ""
|
||||
el_role = getattr(el, "role", "") or ""
|
||||
el_type = getattr(el, "type", "") or ""
|
||||
|
||||
if target_text:
|
||||
if target_text == el_label.lower().strip():
|
||||
score = max(score, 1.0)
|
||||
elif target_text in el_label.lower():
|
||||
score = max(score, 0.8)
|
||||
|
||||
if target_role:
|
||||
if target_role == el_role.lower() or target_role == el_type.lower():
|
||||
score = max(score, 0.9)
|
||||
|
||||
if not target_text and not target_role and target.by_position:
|
||||
# Si seule la position est fournie, on considère toujours match possible
|
||||
score = 0.6
|
||||
|
||||
if score > best:
|
||||
best = score
|
||||
|
||||
# Si on n'a rien trouvé mais qu'un target est demandé → 0.0 (fort négatif)
|
||||
if best == 0.0 and (target_text or target_role):
|
||||
return 0.0
|
||||
|
||||
return best if best > 0 else 0.5
|
||||
|
||||
def _score_recency(self, edge: WorkflowEdge) -> float:
|
||||
"""
|
||||
Score de récence basé sur `edge.stats.last_executed`.
|
||||
|
||||
Échelle :
|
||||
- exécuté dans les dernières 24h : 1.0
|
||||
- exécuté dans les 7 derniers jours : 0.7
|
||||
- exécuté il y a plus longtemps : 0.3
|
||||
- jamais exécuté : 0.5 (neutre)
|
||||
"""
|
||||
if edge.stats is None or edge.stats.last_executed is None:
|
||||
return 0.5
|
||||
|
||||
delta = datetime.now() - edge.stats.last_executed
|
||||
seconds = delta.total_seconds()
|
||||
if seconds < 24 * 3600:
|
||||
return 1.0
|
||||
if seconds < 7 * 24 * 3600:
|
||||
return 0.7
|
||||
return 0.3
|
||||
@@ -9,13 +9,33 @@ Orchestre les 4 niveaux du ScreenState :
|
||||
|
||||
Ce module comble le chaînon manquant entre la capture brute (Couche 0)
|
||||
et la construction d'embeddings (Couche 3).
|
||||
|
||||
=============================================================================
|
||||
Thread-safety & partage multi-loops (Lot C — avril 2026)
|
||||
=============================================================================
|
||||
Cet analyseur peut être partagé entre plusieurs `ExecutionLoop` (singleton
|
||||
`get_screen_analyzer()`). Pour éviter la contamination croisée :
|
||||
|
||||
• `analyze()` NE MUTE JAMAIS `self._ocr`, `self._ui_detector`,
|
||||
`self._ocr_initialized`, `self._ui_detector_initialized` pour gérer les
|
||||
flags runtime (enable_ocr / enable_ui_detection). Ces flags sont par
|
||||
appel, résolus en variables locales.
|
||||
• `session_id` circule en paramètre d'appel et renseigne la metadata du
|
||||
ScreenState ; l'attribut `self.session_id` n'est qu'un défaut historique
|
||||
(rétrocompat) et n'est plus la source de vérité.
|
||||
• L'init lazy des composants lourds (OCR, UIDetector) est protégée par un
|
||||
`_init_lock` par instance pour empêcher une double initialisation
|
||||
concurrente.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -32,6 +52,44 @@ from core.models.ui_element import UIElement
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Lock d'inférence local au module : sert de fallback si le GPUResourceManager
|
||||
# n'est pas disponible (import error, tests). Partagé entre toutes les instances
|
||||
# ScreenAnalyzer du process, cohérent avec le singleton get_screen_analyzer().
|
||||
_ANALYZE_FALLBACK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _acquire_gpu_context(timeout: Optional[float] = None):
|
||||
"""
|
||||
Retourne un context manager pour sérialiser les appels GPU.
|
||||
|
||||
Préfère `GPUResourceManager.acquire_inference()` si disponible (coordination
|
||||
globale), sinon bascule sur un lock threading local au module.
|
||||
"""
|
||||
try:
|
||||
from core.gpu import get_gpu_resource_manager
|
||||
|
||||
manager = get_gpu_resource_manager()
|
||||
return manager.acquire_inference(timeout=timeout)
|
||||
except Exception as e: # pragma: no cover - fallback defensif
|
||||
logger.debug(f"GPUResourceManager indisponible, fallback lock local: {e}")
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _fallback():
|
||||
if timeout is None:
|
||||
_ANALYZE_FALLBACK_LOCK.acquire()
|
||||
yield True
|
||||
_ANALYZE_FALLBACK_LOCK.release()
|
||||
else:
|
||||
got = _ANALYZE_FALLBACK_LOCK.acquire(timeout=timeout)
|
||||
try:
|
||||
yield got
|
||||
finally:
|
||||
if got:
|
||||
_ANALYZE_FALLBACK_LOCK.release()
|
||||
|
||||
return _fallback()
|
||||
|
||||
|
||||
class ScreenAnalyzer:
|
||||
"""
|
||||
Construit un ScreenState complet (4 niveaux) depuis un screenshot.
|
||||
@@ -44,6 +102,14 @@ class ScreenAnalyzer:
|
||||
>>> state = analyzer.analyze("/path/to/screenshot.png")
|
||||
>>> print(state.perception.detected_text)
|
||||
>>> print(len(state.ui_elements))
|
||||
|
||||
Runtime overrides (kwargs-only) sur analyze() :
|
||||
>>> state = analyzer.analyze(
|
||||
... path,
|
||||
... enable_ocr=False, # bypass OCR pour cet appel
|
||||
... enable_ui_detection=False, # bypass UIDetector
|
||||
... session_id="session_42", # session par appel
|
||||
... )
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -56,18 +122,27 @@ class ScreenAnalyzer:
|
||||
Args:
|
||||
ui_detector: Instance de UIDetector (créé si None)
|
||||
ocr_engine: Moteur OCR à utiliser ("doctr", "tesseract", None=auto)
|
||||
session_id: ID de la session en cours
|
||||
session_id: ID de session par défaut (rétrocompat ; préférer passer
|
||||
`session_id` en kwarg de `analyze()` pour chaque appel).
|
||||
"""
|
||||
self._ui_detector = ui_detector
|
||||
self._ocr_engine_name = ocr_engine
|
||||
self._ocr = None
|
||||
# Session par défaut (rétrocompat). La source de vérité est désormais
|
||||
# le paramètre `session_id` de `analyze()`.
|
||||
self.session_id = session_id
|
||||
# Compteur d'états — protégé par _state_lock pour être safe en parallèle.
|
||||
self._state_counter = 0
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
# Initialisation lazy pour éviter les imports lourds au démarrage
|
||||
# Initialisation lazy pour éviter les imports lourds au démarrage.
|
||||
self._ui_detector_initialized = ui_detector is not None
|
||||
self._ocr_initialized = False
|
||||
|
||||
# Lock dédié à l'init lazy : empêche deux threads d'initialiser
|
||||
# simultanément OCR ou UIDetector (double chargement GPU).
|
||||
self._init_lock = threading.Lock()
|
||||
|
||||
# =========================================================================
|
||||
# API publique
|
||||
# =========================================================================
|
||||
@@ -77,28 +152,85 @@ class ScreenAnalyzer:
|
||||
screenshot_path: str,
|
||||
window_info: Optional[Dict[str, Any]] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
enable_ocr: bool = True,
|
||||
enable_ui_detection: bool = True,
|
||||
session_id: str = "",
|
||||
) -> ScreenState:
|
||||
"""
|
||||
Analyser un screenshot et construire un ScreenState complet.
|
||||
|
||||
Les flags `enable_ocr`, `enable_ui_detection` et `session_id` sont
|
||||
**par appel, kwargs-only**, pour ne pas polluer l'état partagé du
|
||||
singleton quand plusieurs `ExecutionLoop` se partagent l'analyseur.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le fichier image
|
||||
window_info: Infos fenêtre active {"title": ..., "app_name": ...}
|
||||
context: Contexte métier optionnel
|
||||
enable_ocr: Active l'OCR pour cet appel (True par défaut).
|
||||
False → `detected_text=[]`, aucune init d'OCR déclenchée.
|
||||
enable_ui_detection: Active la détection UI pour cet appel
|
||||
(True par défaut). False → `ui_elements=[]`.
|
||||
session_id: ID de session pour cet appel. Si vide, on retombe sur
|
||||
`self.session_id` (rétrocompat). Cette valeur est propagée
|
||||
dans `ScreenState.session_id` et `metadata["session_id"]`.
|
||||
|
||||
Returns:
|
||||
ScreenState avec les 4 niveaux remplis
|
||||
ScreenState avec les 4 niveaux remplis.
|
||||
"""
|
||||
screenshot_path = str(screenshot_path)
|
||||
|
||||
# Résolution de la session : priorité au kwarg, fallback sur l'état
|
||||
# interne (legacy). Variable locale uniquement — pas de mutation.
|
||||
effective_session_id = session_id or self.session_id
|
||||
|
||||
# Compteur incrémenté sous lock pour identifiants uniques même en
|
||||
# parallèle. C'est la seule mutation tolérée : elle n'impacte pas le
|
||||
# comportement OCR/UI.
|
||||
with self._state_lock:
|
||||
self._state_counter += 1
|
||||
state_counter = self._state_counter
|
||||
|
||||
state_id = f"{self.session_id}_state_{self._state_counter:04d}" if self.session_id else f"state_{self._state_counter:04d}"
|
||||
state_id = (
|
||||
f"{effective_session_id}_state_{state_counter:04d}"
|
||||
if effective_session_id
|
||||
else f"state_{state_counter:04d}"
|
||||
)
|
||||
|
||||
# Niveau 1 : Raw
|
||||
# Niveau 1 : Raw (léger, hors lock GPU)
|
||||
raw = self._build_raw_level(screenshot_path)
|
||||
|
||||
# Niveau 2 : Perception (OCR)
|
||||
detected_text = self._extract_text(screenshot_path)
|
||||
# Résolution locale des instances OCR / UIDetector selon les flags.
|
||||
# Aucune mutation de self ici : on décide simplement ce qu'on utilise.
|
||||
ocr_instance = self._resolve_ocr_instance(enable_ocr=enable_ocr)
|
||||
ui_detector_instance = self._resolve_ui_detector_instance(
|
||||
enable_ui_detection=enable_ui_detection
|
||||
)
|
||||
|
||||
# Niveaux 2 et 3 : OCR + détection UI sont les étapes lourdes en GPU.
|
||||
# On sérialise via GPUResourceManager.acquire_inference() pour éviter
|
||||
# que ExecutionLoop et stream_processor saturent simultanément la VRAM
|
||||
# sur RTX 5070 (12 Go). Timeout généreux : un appel peut prendre 15-20s.
|
||||
with _acquire_gpu_context(timeout=60.0) as acquired:
|
||||
if not acquired:
|
||||
logger.warning(
|
||||
"Timeout en attendant le lock GPU pour ScreenAnalyzer.analyze() "
|
||||
"→ exécution sans sérialisation (risque saturation VRAM)"
|
||||
)
|
||||
|
||||
# Niveau 2 : Perception (OCR) — mesure du temps OCR
|
||||
ocr_t0 = time.time()
|
||||
detected_text = self._extract_text_with(ocr_instance, screenshot_path)
|
||||
ocr_ms = (time.time() - ocr_t0) * 1000
|
||||
|
||||
# Niveau 3 : UI Elements — mesure du temps détection
|
||||
ui_t0 = time.time()
|
||||
ui_elements = self._detect_ui_elements_with(
|
||||
ui_detector_instance, screenshot_path, window_info
|
||||
)
|
||||
ui_ms = (time.time() - ui_t0) * 1000
|
||||
|
||||
perception = PerceptionLevel(
|
||||
embedding=EmbeddingRef(
|
||||
provider="openclip_ViT-B-32",
|
||||
@@ -106,13 +238,10 @@ class ScreenAnalyzer:
|
||||
dimensions=512,
|
||||
),
|
||||
detected_text=detected_text,
|
||||
text_detection_method=self._get_ocr_method_name(),
|
||||
text_detection_method=self._get_ocr_method_name(ocr_instance),
|
||||
confidence_avg=0.85 if detected_text else 0.0,
|
||||
)
|
||||
|
||||
# Niveau 3 : UI Elements
|
||||
ui_elements = self._detect_ui_elements(screenshot_path, window_info)
|
||||
|
||||
# Niveau 4 : Contexte
|
||||
window_ctx = self._build_window_context(window_info)
|
||||
context_level = self._build_context_level(context)
|
||||
@@ -120,22 +249,28 @@ class ScreenAnalyzer:
|
||||
state = ScreenState(
|
||||
screen_state_id=state_id,
|
||||
timestamp=datetime.now(),
|
||||
session_id=self.session_id,
|
||||
session_id=effective_session_id,
|
||||
window=window_ctx,
|
||||
raw=raw,
|
||||
perception=perception,
|
||||
context=context_level,
|
||||
metadata={
|
||||
"analyzer_version": "1.0",
|
||||
"analyzer_version": "1.1",
|
||||
"session_id": effective_session_id,
|
||||
"ui_elements_count": len(ui_elements),
|
||||
"text_regions_count": len(detected_text),
|
||||
"ocr_ms": ocr_ms,
|
||||
"ui_ms": ui_ms,
|
||||
"ocr_enabled": enable_ocr,
|
||||
"ui_detection_enabled": enable_ui_detection,
|
||||
},
|
||||
ui_elements=ui_elements,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"ScreenState {state_id} construit: "
|
||||
f"{len(ui_elements)} éléments UI, {len(detected_text)} textes détectés"
|
||||
f"{len(ui_elements)} éléments UI, {len(detected_text)} textes détectés "
|
||||
f"(ocr={enable_ocr}, ui={enable_ui_detection})"
|
||||
)
|
||||
return state
|
||||
|
||||
@@ -145,11 +280,16 @@ class ScreenAnalyzer:
|
||||
save_dir: str = "data/screens",
|
||||
window_info: Optional[Dict[str, Any]] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
enable_ocr: bool = True,
|
||||
enable_ui_detection: bool = True,
|
||||
session_id: str = "",
|
||||
) -> ScreenState:
|
||||
"""
|
||||
Analyser une PIL Image (utile quand on a déjà l'image en mémoire).
|
||||
|
||||
Sauvegarde l'image sur disque puis appelle analyze().
|
||||
Sauvegarde l'image sur disque puis appelle analyze(). Les flags
|
||||
runtime sont propagés à `analyze()` en kwargs-only.
|
||||
"""
|
||||
save_path = Path(save_dir)
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -159,7 +299,49 @@ class ScreenAnalyzer:
|
||||
filepath = save_path / filename
|
||||
|
||||
image.save(str(filepath))
|
||||
return self.analyze(str(filepath), window_info=window_info, context=context)
|
||||
return self.analyze(
|
||||
str(filepath),
|
||||
window_info=window_info,
|
||||
context=context,
|
||||
enable_ocr=enable_ocr,
|
||||
enable_ui_detection=enable_ui_detection,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Résolution des instances OCR / UI selon les flags d'appel
|
||||
# =========================================================================
|
||||
|
||||
def _resolve_ocr_instance(self, *, enable_ocr: bool):
|
||||
"""
|
||||
Retourner l'instance OCR à utiliser pour cet appel.
|
||||
|
||||
- `enable_ocr=False` → None (pas d'init, pas d'appel OCR)
|
||||
- sinon → init lazy sous lock si nécessaire, puis retour de `self._ocr`
|
||||
|
||||
Ne mute `self._ocr` / `self._ocr_initialized` QUE pendant l'init lazy
|
||||
réelle, jamais pour bypasser l'OCR d'un appel.
|
||||
"""
|
||||
if not enable_ocr:
|
||||
return None
|
||||
if not self._ocr_initialized:
|
||||
with self._init_lock:
|
||||
# Double-check : un autre thread a pu initialiser entretemps.
|
||||
if not self._ocr_initialized:
|
||||
self._ensure_ocr_locked()
|
||||
return self._ocr
|
||||
|
||||
def _resolve_ui_detector_instance(self, *, enable_ui_detection: bool):
|
||||
"""
|
||||
Retourner l'instance UIDetector pour cet appel (idem _resolve_ocr_instance).
|
||||
"""
|
||||
if not enable_ui_detection:
|
||||
return None
|
||||
if not self._ui_detector_initialized:
|
||||
with self._init_lock:
|
||||
if not self._ui_detector_initialized:
|
||||
self._ensure_ui_detector_locked()
|
||||
return self._ui_detector
|
||||
|
||||
# =========================================================================
|
||||
# Niveau 1 : Raw
|
||||
@@ -182,23 +364,24 @@ class ScreenAnalyzer:
|
||||
# Niveau 2 : Perception — OCR
|
||||
# =========================================================================
|
||||
|
||||
def _extract_text(self, screenshot_path: str) -> List[str]:
|
||||
"""Extraire le texte d'un screenshot via OCR."""
|
||||
self._ensure_ocr()
|
||||
|
||||
if self._ocr is None:
|
||||
def _extract_text_with(self, ocr_callable, screenshot_path: str) -> List[str]:
|
||||
"""Extraire le texte via un callable OCR donné (peut être None)."""
|
||||
if ocr_callable is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
return self._ocr(screenshot_path)
|
||||
return ocr_callable(screenshot_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"OCR échoué: {e}")
|
||||
return []
|
||||
|
||||
def _ensure_ocr(self) -> None:
|
||||
"""Initialiser le moteur OCR (lazy)."""
|
||||
if self._ocr_initialized:
|
||||
return
|
||||
def _ensure_ocr_locked(self) -> None:
|
||||
"""
|
||||
Initialiser le moteur OCR (appelé sous `self._init_lock`).
|
||||
|
||||
Ne doit PAS être appelé hors de `_resolve_ocr_instance()`.
|
||||
"""
|
||||
# Mutation intentionnelle : on installe l'instance OCR réelle.
|
||||
# Protégée par le lock d'init (pas le lock GPU).
|
||||
self._ocr_initialized = True
|
||||
|
||||
engine = self._ocr_engine_name
|
||||
@@ -257,8 +440,9 @@ class ScreenAnalyzer:
|
||||
|
||||
return ocr_func
|
||||
|
||||
def _get_ocr_method_name(self) -> str:
|
||||
if self._ocr is None:
|
||||
def _get_ocr_method_name(self, ocr_instance=None) -> str:
|
||||
"""Nom du moteur OCR effectivement utilisé pour cet appel."""
|
||||
if ocr_instance is None:
|
||||
return "none"
|
||||
if self._ocr_engine_name:
|
||||
return self._ocr_engine_name
|
||||
@@ -268,19 +452,18 @@ class ScreenAnalyzer:
|
||||
# Niveau 3 : UI Elements
|
||||
# =========================================================================
|
||||
|
||||
def _detect_ui_elements(
|
||||
def _detect_ui_elements_with(
|
||||
self,
|
||||
ui_detector,
|
||||
screenshot_path: str,
|
||||
window_info: Optional[Dict[str, Any]] = None,
|
||||
) -> List[UIElement]:
|
||||
"""Détecter les éléments UI dans le screenshot."""
|
||||
self._ensure_ui_detector()
|
||||
|
||||
if self._ui_detector is None:
|
||||
"""Détecter les éléments UI via un détecteur donné (peut être None)."""
|
||||
if ui_detector is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
elements = self._ui_detector.detect(
|
||||
elements = ui_detector.detect(
|
||||
screenshot_path, window_context=window_info
|
||||
)
|
||||
return elements
|
||||
@@ -288,10 +471,10 @@ class ScreenAnalyzer:
|
||||
logger.warning(f"Détection UI échouée: {e}")
|
||||
return []
|
||||
|
||||
def _ensure_ui_detector(self) -> None:
|
||||
"""Initialiser le UIDetector (lazy)."""
|
||||
if self._ui_detector_initialized:
|
||||
return
|
||||
def _ensure_ui_detector_locked(self) -> None:
|
||||
"""
|
||||
Initialiser le UIDetector (appelé sous `self._init_lock`).
|
||||
"""
|
||||
self._ui_detector_initialized = True
|
||||
|
||||
try:
|
||||
|
||||
409
core/pipeline/screen_state_cache.py
Normal file
409
core/pipeline/screen_state_cache.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
ScreenStateCache — Cache perceptuel de ScreenState (context-aware).
|
||||
|
||||
Objectif : éviter de réanalyser un screenshot identique (5-15s VLM/OCR)
|
||||
à chaque step de la boucle d'exécution.
|
||||
|
||||
Principe (Lot D — avril 2026) :
|
||||
- Clé = composite de 6 éléments pour éviter les collisions silencieuses
|
||||
entre contextes différents partageant un même screenshot :
|
||||
1. phash (dhash 8x8 du screenshot) — calculé en ~2-5ms
|
||||
2. window_title (titre fenêtre active)
|
||||
3. app_name (nom process actif)
|
||||
4. enable_ocr (flag runtime)
|
||||
5. enable_ui_detection (flag runtime)
|
||||
6. workflow_id (isolation inter-workflows)
|
||||
- TTL par défaut : 2 secondes (configurable)
|
||||
- Invalidation explicite possible (par clé composite ou globale)
|
||||
- invalidate_if_changed reste piloté par le phash seul (détection de
|
||||
changement visuel majeur, indépendant du contexte)
|
||||
- Thread-safe (lock interne)
|
||||
|
||||
API principale :
|
||||
>>> cache = ScreenStateCache(ttl_seconds=2.0)
|
||||
>>> state, hit, ms = cache.get_or_compute(
|
||||
... screenshot_path, compute_fn,
|
||||
... window_title="App", app_name="app.exe",
|
||||
... enable_ocr=True, enable_ui_detection=True,
|
||||
... workflow_id="wf_123",
|
||||
... )
|
||||
|
||||
La fonction `compute_fn` prend le chemin du screenshot et doit retourner
|
||||
un `ScreenState`. Elle n'est appelée qu'en cache miss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from core.models.screen_state import ScreenState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hash perceptuel (dhash simple, sans dépendance imagehash)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _hamming_distance_hex(a: str, b: str) -> int:
|
||||
"""
|
||||
Distance de Hamming entre deux chaînes hexadécimales de même longueur.
|
||||
|
||||
Retourne le nombre de bits qui diffèrent entre les deux hashes.
|
||||
Si les longueurs diffèrent, on pad à droite par des zéros.
|
||||
"""
|
||||
if len(a) != len(b):
|
||||
max_len = max(len(a), len(b))
|
||||
a = a.ljust(max_len, "0")
|
||||
b = b.ljust(max_len, "0")
|
||||
try:
|
||||
xor = int(a, 16) ^ int(b, 16)
|
||||
return bin(xor).count("1")
|
||||
except ValueError:
|
||||
# Fallback : comparaison caractère à caractère
|
||||
return sum(1 for ca, cb in zip(a, b) if ca != cb) * 4
|
||||
|
||||
|
||||
def compute_perceptual_hash(screenshot_path: str, size: int = 8) -> str:
|
||||
"""
|
||||
Calculer un dhash (difference hash) pour un screenshot.
|
||||
|
||||
Algorithme :
|
||||
1. Convertir en niveaux de gris
|
||||
2. Redimensionner à (size+1) x size
|
||||
3. Comparer chaque pixel avec son voisin de droite (dhash)
|
||||
4. Retourner un hash hexadécimal de size*size bits
|
||||
|
||||
Robuste aux petites variations (curseur, blink, compression).
|
||||
Coût typique : 2-5 ms sur un 1920x1080.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le fichier image
|
||||
size: Taille du hash (8 = 64 bits, défaut)
|
||||
|
||||
Returns:
|
||||
Chaîne hexadécimale (size*size/4 caractères)
|
||||
"""
|
||||
try:
|
||||
img = Image.open(screenshot_path)
|
||||
img = img.convert("L").resize((size + 1, size), Image.LANCZOS)
|
||||
pixels = list(img.getdata())
|
||||
|
||||
# dhash : comparer chaque pixel avec celui de droite
|
||||
bits = []
|
||||
for row in range(size):
|
||||
for col in range(size):
|
||||
left = pixels[row * (size + 1) + col]
|
||||
right = pixels[row * (size + 1) + col + 1]
|
||||
bits.append(1 if left > right else 0)
|
||||
|
||||
# Convertir en hex
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | bit
|
||||
return format(value, f"0{size * size // 4}x")
|
||||
except Exception as e:
|
||||
logger.warning(f"Hash perceptuel échoué pour {screenshot_path}: {e}")
|
||||
# Fallback : hash du contenu brut
|
||||
try:
|
||||
data = Path(screenshot_path).read_bytes()
|
||||
return hashlib.md5(data).hexdigest()[:16]
|
||||
except Exception:
|
||||
return f"unhashable_{int(time.time() * 1000)}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Clé composite (Lot D)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _make_cache_key(
|
||||
phash: str,
|
||||
window_title: str,
|
||||
app_name: str,
|
||||
enable_ocr: bool,
|
||||
enable_ui_detection: bool,
|
||||
workflow_id: str,
|
||||
) -> str:
|
||||
"""
|
||||
Construire une clé composite stable pour le cache.
|
||||
|
||||
Combine les 6 dimensions du contexte d'exécution dans une chaîne
|
||||
hexadécimale (md5 tronqué à 16 caractères), préfixée par le phash pour
|
||||
conserver une lisibilité minimale en debug (log : `aabb…|ctx=1234…`).
|
||||
|
||||
NB : On hash plutôt que concaténer brut pour :
|
||||
- Borner la taille de la clé même si window_title est long
|
||||
- Éviter les collisions triviales (séparateur présent dans un titre)
|
||||
- Rendre la clé opaque (pas de PII en clair dans les logs de cache)
|
||||
|
||||
Args:
|
||||
phash: Hash perceptuel du screenshot (dhash 8x8)
|
||||
window_title: Titre de la fenêtre active (str)
|
||||
app_name: Nom du process actif (str)
|
||||
enable_ocr: Flag runtime OCR (bool)
|
||||
enable_ui_detection: Flag runtime détection UI (bool)
|
||||
workflow_id: ID du workflow en cours (str, "" pour legacy)
|
||||
|
||||
Returns:
|
||||
Clé composite `{phash}|{ctx_hash}` où ctx_hash = md5(16)
|
||||
"""
|
||||
# Sérialisation déterministe ; `|` comme séparateur interne puisque hashé.
|
||||
ctx_repr = (
|
||||
f"{window_title or ''}\x1f"
|
||||
f"{app_name or ''}\x1f"
|
||||
f"{int(bool(enable_ocr))}\x1f"
|
||||
f"{int(bool(enable_ui_detection))}\x1f"
|
||||
f"{workflow_id or ''}"
|
||||
)
|
||||
ctx_hash = hashlib.md5(ctx_repr.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{phash}|{ctx_hash}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
state: ScreenState
|
||||
created_at: float
|
||||
phash: str # phash seul (utilisé par invalidate_if_changed)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cache
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ScreenStateCache:
|
||||
"""
|
||||
Cache de ScreenState avec TTL et clé composite context-aware.
|
||||
|
||||
Thread-safe. Utilise un lock interne pour les opérations get/set.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = 2.0, max_entries: int = 16):
|
||||
"""
|
||||
Args:
|
||||
ttl_seconds: Durée de vie d'une entrée (en secondes)
|
||||
max_entries: Nombre max d'entrées avant éviction LRU simple
|
||||
"""
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
# Clé = composite (_make_cache_key), valeur = _CacheEntry
|
||||
self._store: dict[str, _CacheEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Métriques simples (utile pour le debug / logs)
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
self.invalidations = 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API bas niveau (par clé composite)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get(self, composite_key: str) -> Optional[ScreenState]:
|
||||
"""Retourne l'entrée pour cette clé composite si encore valide."""
|
||||
with self._lock:
|
||||
entry = self._store.get(composite_key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry.created_at > self.ttl_seconds:
|
||||
# Expiré
|
||||
self._store.pop(composite_key, None)
|
||||
return None
|
||||
return entry.state
|
||||
|
||||
def _set(self, composite_key: str, phash: str, state: ScreenState) -> None:
|
||||
"""Enregistre un état pour cette clé composite."""
|
||||
with self._lock:
|
||||
# Éviction simple : si plein, virer l'entrée la plus ancienne
|
||||
if (
|
||||
len(self._store) >= self.max_entries
|
||||
and composite_key not in self._store
|
||||
):
|
||||
oldest_key = min(
|
||||
self._store, key=lambda k: self._store[k].created_at
|
||||
)
|
||||
self._store.pop(oldest_key, None)
|
||||
|
||||
self._store[composite_key] = _CacheEntry(
|
||||
state=state,
|
||||
created_at=time.time(),
|
||||
phash=phash,
|
||||
)
|
||||
|
||||
def invalidate(self, composite_key: Optional[str] = None) -> None:
|
||||
"""
|
||||
Invalider une entrée ou tout le cache.
|
||||
|
||||
Args:
|
||||
composite_key: Clé à invalider. Si None, vide tout le cache.
|
||||
"""
|
||||
with self._lock:
|
||||
if composite_key is None:
|
||||
self._store.clear()
|
||||
else:
|
||||
self._store.pop(composite_key, None)
|
||||
self.invalidations += 1
|
||||
|
||||
def invalidate_if_changed(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
threshold: float = 0.3,
|
||||
) -> bool:
|
||||
"""
|
||||
Invalider le cache si l'écran a suffisamment changé.
|
||||
|
||||
Compare le dhash du screenshot courant avec le phash (seul) de chaque
|
||||
entrée du cache. La décision est volontairement indépendante du reste
|
||||
de la clé composite : un changement visuel majeur rend toutes les
|
||||
entrées obsolètes, quel que soit le contexte.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin du screenshot courant
|
||||
threshold: Proportion de bits qui doivent différer (0.0-1.0).
|
||||
0.3 = 30% (~19 bits sur 64) = changement significatif.
|
||||
|
||||
Returns:
|
||||
True si le cache a été invalidé, False sinon.
|
||||
"""
|
||||
if not self._store:
|
||||
return False
|
||||
|
||||
current_phash = compute_perceptual_hash(screenshot_path)
|
||||
|
||||
# Bits totaux : 64 pour un dhash 8x8 standard. On déduit via la
|
||||
# longueur hexa du hash courant pour rester générique.
|
||||
total_bits = len(current_phash) * 4
|
||||
if total_bits == 0:
|
||||
return False
|
||||
|
||||
threshold_bits = threshold * total_bits
|
||||
|
||||
with self._lock:
|
||||
if not self._store:
|
||||
return False
|
||||
|
||||
# Distance de Hamming minimale avec les phashes des entrées
|
||||
# (on regarde entry.phash, pas la clé composite).
|
||||
min_distance = None
|
||||
for entry in self._store.values():
|
||||
distance = _hamming_distance_hex(current_phash, entry.phash)
|
||||
if min_distance is None or distance < min_distance:
|
||||
min_distance = distance
|
||||
|
||||
if min_distance is not None and min_distance > threshold_bits:
|
||||
size_before = len(self._store)
|
||||
self._store.clear()
|
||||
self.invalidations += 1
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] invalidate_if_changed: "
|
||||
f"distance={min_distance}/{total_bits} > "
|
||||
f"threshold={threshold_bits:.1f} → {size_before} entrées purgées"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API haut niveau (context-aware)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_or_compute(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
compute_fn: Callable[[str], ScreenState],
|
||||
*,
|
||||
window_title: str = "",
|
||||
app_name: str = "",
|
||||
enable_ocr: bool = True,
|
||||
enable_ui_detection: bool = True,
|
||||
workflow_id: str = "",
|
||||
force_refresh: bool = False,
|
||||
) -> Tuple[ScreenState, bool, float]:
|
||||
"""
|
||||
Récupérer ou calculer le ScreenState pour un screenshot + contexte.
|
||||
|
||||
Clé de cache = composite(phash, window_title, app_name, enable_ocr,
|
||||
enable_ui_detection, workflow_id). Deux contextes différents partageant
|
||||
le même screenshot n'entrent PAS en collision.
|
||||
|
||||
Rétrocompatibilité : tous les kwargs de contexte ont une valeur par
|
||||
défaut. Un caller legacy qui n'a pas encore été adapté partagera la
|
||||
même entrée de cache qu'un autre caller legacy (comportement antérieur).
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin du screenshot
|
||||
compute_fn: Fonction qui construit un ScreenState si cache miss
|
||||
window_title: Titre de la fenêtre active (contexte visuel)
|
||||
app_name: Nom du process actif (contexte applicatif)
|
||||
enable_ocr: Flag runtime — différencie états avec/sans OCR
|
||||
enable_ui_detection: Flag runtime — différencie états avec/sans UI
|
||||
workflow_id: ID du workflow — isolation inter-workflows
|
||||
force_refresh: Ignorer le cache et recalculer
|
||||
|
||||
Returns:
|
||||
Tuple (state, cache_hit, elapsed_ms)
|
||||
"""
|
||||
t0 = time.time()
|
||||
phash = compute_perceptual_hash(screenshot_path)
|
||||
composite_key = _make_cache_key(
|
||||
phash=phash,
|
||||
window_title=window_title,
|
||||
app_name=app_name,
|
||||
enable_ocr=enable_ocr,
|
||||
enable_ui_detection=enable_ui_detection,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
|
||||
if not force_refresh:
|
||||
cached = self._get(composite_key)
|
||||
if cached is not None:
|
||||
self.hits += 1
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] HIT key={composite_key[:24]}… "
|
||||
f"({elapsed_ms:.1f}ms)"
|
||||
)
|
||||
return cached, True, elapsed_ms
|
||||
|
||||
# Cache miss → calcul complet
|
||||
self.misses += 1
|
||||
state = compute_fn(screenshot_path)
|
||||
self._set(composite_key, phash, state)
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
logger.debug(
|
||||
f"[ScreenStateCache] MISS key={composite_key[:24]}… "
|
||||
f"({elapsed_ms:.1f}ms)"
|
||||
)
|
||||
return state, False, elapsed_ms
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Retourne les métriques du cache."""
|
||||
with self._lock:
|
||||
total = self.hits + self.misses
|
||||
return {
|
||||
"hits": self.hits,
|
||||
"misses": self.misses,
|
||||
"invalidations": self.invalidations,
|
||||
"hit_rate": self.hits / total if total > 0 else 0.0,
|
||||
"size": len(self._store),
|
||||
"max_entries": self.max_entries,
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
}
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._store)
|
||||
@@ -355,87 +355,177 @@ class WorkflowPipeline:
|
||||
# Mode MATCHING : Reconnaissance de l'état actuel
|
||||
# =========================================================================
|
||||
|
||||
def match_current_state(
|
||||
def match_current_state_from_state(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
screen_state: ScreenState,
|
||||
workflow_id: Optional[str] = None,
|
||||
window_title: Optional[str] = None
|
||||
*,
|
||||
min_similarity: float = 0.5,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Identifier dans quel node se trouve l'écran actuel.
|
||||
Matcher un ``ScreenState`` enrichi contre les nodes d'un workflow.
|
||||
|
||||
Lot E — premier vrai matching context-aware. Cette méthode consomme
|
||||
directement le ``ScreenState`` déjà construit par ``ExecutionLoop``
|
||||
(avec ``window_title``, ``detected_text`` et ``ui_elements``
|
||||
renseignés par le ``ScreenAnalyzer``) au lieu de reconstruire un
|
||||
stub vide avec ``window_title="Unknown"``.
|
||||
|
||||
Stratégie :
|
||||
1. Si le ``HierarchicalMatcher`` est disponible ET que le workflow
|
||||
cible est chargeable, on privilégie le matching multi-niveau
|
||||
(fenêtre → région → élément) qui exploite pleinement les
|
||||
``ui_elements`` et le ``window_title``.
|
||||
2. Sinon on retombe sur le matching par embedding via FAISS
|
||||
(même logique que l'ancien ``match_current_state``, mais avec
|
||||
le ``ScreenState`` fourni, pas un stub).
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le screenshot actuel
|
||||
workflow_id: ID du workflow à matcher (tous si None)
|
||||
window_title: Titre de fenêtre pour contexte
|
||||
screen_state: ``ScreenState`` complet (ui_elements + detected_text
|
||||
+ window_info) construit en amont par l'``ExecutionLoop``.
|
||||
workflow_id: ID du workflow cible (tous si None).
|
||||
min_similarity: seuil minimum de confidence pour considérer un
|
||||
match valide. Conserve la sémantique historique (0.5 pour
|
||||
le hiérarchique, 0.85 pour le FAISS fallback).
|
||||
|
||||
Returns:
|
||||
Dict avec node_id, workflow_id, confidence, ou None si pas de match
|
||||
Dict avec ``node_id``, ``workflow_id``, ``confidence`` (+ détails
|
||||
du matching hiérarchique si applicable), ou ``None`` si aucun
|
||||
match ne dépasse le seuil.
|
||||
"""
|
||||
logger.debug(f"Matching screenshot: {screenshot_path}")
|
||||
|
||||
# Créer un ScreenState temporaire
|
||||
from core.models.screen_state import (
|
||||
WindowContext, RawLevel, PerceptionLevel, ContextLevel, EmbeddingRef
|
||||
logger.debug(
|
||||
"Matching ScreenState (app=%s, title=%s, ui_elements=%d, "
|
||||
"detected_text=%d)",
|
||||
screen_state.window.app_name,
|
||||
screen_state.window.window_title,
|
||||
len(screen_state.ui_elements),
|
||||
len(screen_state.perception.detected_text),
|
||||
)
|
||||
|
||||
screenshot_path = Path(screenshot_path)
|
||||
|
||||
window = WindowContext(
|
||||
app_name="unknown",
|
||||
window_title=window_title or "Unknown",
|
||||
screen_resolution=[1920, 1080],
|
||||
workspace="main"
|
||||
# --- Stratégie 1 : matching hiérarchique si workflow disponible ---
|
||||
if workflow_id:
|
||||
workflow = self.load_workflow(workflow_id)
|
||||
if workflow is not None and getattr(workflow, "nodes", None):
|
||||
try:
|
||||
hier_result = self._match_hierarchical_from_state(
|
||||
screen_state=screen_state,
|
||||
workflow=workflow,
|
||||
workflow_id=workflow_id,
|
||||
min_similarity=min_similarity,
|
||||
)
|
||||
if hier_result is not None:
|
||||
return hier_result
|
||||
except Exception as exc:
|
||||
# Ne jamais casser le matching sur une erreur du
|
||||
# matcher hiérarchique : on retombe sur FAISS.
|
||||
logger.debug(
|
||||
f"Hierarchical matching failed, fallback FAISS: {exc}"
|
||||
)
|
||||
|
||||
raw = RawLevel(
|
||||
screenshot_path=str(screenshot_path),
|
||||
capture_method="manual",
|
||||
file_size_bytes=screenshot_path.stat().st_size if screenshot_path.exists() else 0
|
||||
# --- Stratégie 2 : fallback embedding + FAISS ---
|
||||
return self._match_via_faiss(
|
||||
screen_state=screen_state,
|
||||
workflow_id=workflow_id,
|
||||
min_similarity=min_similarity,
|
||||
)
|
||||
|
||||
perception = PerceptionLevel(
|
||||
embedding=EmbeddingRef(
|
||||
provider="openclip_ViT-B-32",
|
||||
vector_id="temp",
|
||||
dimensions=512
|
||||
),
|
||||
detected_text=[],
|
||||
text_detection_method="pending",
|
||||
confidence_avg=0.0
|
||||
def _match_hierarchical_from_state(
|
||||
self,
|
||||
screen_state: ScreenState,
|
||||
workflow: Workflow,
|
||||
workflow_id: str,
|
||||
min_similarity: float,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Déléguer le matching au ``HierarchicalMatcher`` en extrayant
|
||||
``window_info``, ``detected_elements`` et le screenshot à partir du
|
||||
``ScreenState`` fourni. Factorise la logique de ``match_hierarchical``
|
||||
sans re-ouvrir l'image si ce n'est pas nécessaire.
|
||||
"""
|
||||
# Reconstruire window_info à partir du ScreenState (pas "Unknown")
|
||||
window_info = {
|
||||
"title": screen_state.window.window_title,
|
||||
"app_name": screen_state.window.app_name,
|
||||
"window_title": screen_state.window.window_title,
|
||||
}
|
||||
detected_elements = list(screen_state.ui_elements)
|
||||
|
||||
# Ouvrir le screenshot si nécessaire (le matcher peut en avoir besoin
|
||||
# pour du matching au niveau région). Si le chemin n'existe pas, on
|
||||
# passe None et laisse le matcher travailler avec window + elements.
|
||||
screenshot = None
|
||||
path = screen_state.raw.screenshot_path
|
||||
if path:
|
||||
try:
|
||||
from PIL import Image
|
||||
screenshot = Image.open(path)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Screenshot unavailable for hierarchical match: {exc}")
|
||||
|
||||
# Contexte temporel par workflow
|
||||
if workflow_id not in self._temporal_context:
|
||||
self._temporal_context[workflow_id] = TemporalContext()
|
||||
temporal_context = self._temporal_context[workflow_id]
|
||||
|
||||
result: MatchResult = self.hierarchical_matcher.match(
|
||||
screenshot=screenshot,
|
||||
workflow=workflow,
|
||||
window_info=window_info,
|
||||
detected_elements=detected_elements,
|
||||
temporal_context=temporal_context,
|
||||
)
|
||||
|
||||
context = ContextLevel(
|
||||
current_workflow_candidate=workflow_id,
|
||||
workflow_step=None,
|
||||
user_id="matcher",
|
||||
tags=[],
|
||||
business_variables={}
|
||||
if result.confidence < min_similarity:
|
||||
logger.debug(
|
||||
f"Hierarchical match below threshold: {result.confidence:.3f} "
|
||||
f"(min={min_similarity})"
|
||||
)
|
||||
return None
|
||||
|
||||
current_state = ScreenState(
|
||||
screen_state_id=f"match_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id="matching",
|
||||
window=window,
|
||||
raw=raw,
|
||||
perception=perception,
|
||||
context=context,
|
||||
ui_elements=[]
|
||||
)
|
||||
# Mémoriser le match pour le boost temporel suivant
|
||||
temporal_context.add_match(result.node_id, result.confidence)
|
||||
|
||||
# Calculer embedding
|
||||
state_embedding = self.embedding_builder.build(current_state)
|
||||
return {
|
||||
"node_id": result.node_id,
|
||||
"workflow_id": workflow_id,
|
||||
"confidence": result.confidence,
|
||||
"window_confidence": result.window_confidence,
|
||||
"region_confidence": result.region_confidence,
|
||||
"element_confidence": result.element_confidence,
|
||||
"temporal_boost": result.temporal_boost,
|
||||
"matched_variant": result.matched_variant,
|
||||
"alternatives": [
|
||||
{"node_id": alt.node_id, "confidence": alt.confidence}
|
||||
for alt in result.alternatives
|
||||
],
|
||||
"match_time_ms": result.match_time_ms,
|
||||
"match_type": "hierarchical",
|
||||
}
|
||||
|
||||
def _match_via_faiss(
|
||||
self,
|
||||
screen_state: ScreenState,
|
||||
workflow_id: Optional[str],
|
||||
min_similarity: float,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fallback embedding + recherche FAISS. On réutilise le ``ScreenState``
|
||||
fourni (donc ses ``ui_elements`` et son ``window_title`` réels)
|
||||
au lieu d'en recréer un stub.
|
||||
"""
|
||||
# Le seuil FAISS historique était 0.85. On l'honore comme plancher
|
||||
# par défaut mais on respecte un ``min_similarity`` plus permissif
|
||||
# si l'appelant en fournit un (hiérarchique pouvant déjà avoir échoué).
|
||||
threshold = max(min_similarity, 0.85)
|
||||
|
||||
state_embedding = self.embedding_builder.build(screen_state)
|
||||
query_vector = state_embedding.get_vector()
|
||||
|
||||
# Rechercher dans FAISS
|
||||
results = self.faiss_manager.search(query_vector, k=5)
|
||||
|
||||
if not results:
|
||||
logger.debug("No match found in FAISS")
|
||||
return None
|
||||
|
||||
# Filtrer par workflow si spécifié
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
result_workflow_id = metadata.get("workflow_id")
|
||||
@@ -444,17 +534,136 @@ class WorkflowPipeline:
|
||||
continue
|
||||
|
||||
similarity = result.get("similarity", 0)
|
||||
if similarity >= 0.85: # Seuil de matching
|
||||
if similarity >= threshold:
|
||||
return {
|
||||
"node_id": metadata.get("node_id"),
|
||||
"workflow_id": result_workflow_id,
|
||||
"confidence": similarity,
|
||||
"state_embedding_id": state_embedding.embedding_id
|
||||
"state_embedding_id": state_embedding.embedding_id,
|
||||
"match_type": "faiss",
|
||||
}
|
||||
|
||||
logger.debug(f"Best match below threshold: {results[0].get('similarity', 0):.3f}")
|
||||
logger.debug(
|
||||
f"Best FAISS match below threshold: "
|
||||
f"{results[0].get('similarity', 0):.3f} (min={threshold})"
|
||||
)
|
||||
return None
|
||||
|
||||
def match_current_state(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
workflow_id: Optional[str] = None,
|
||||
window_title: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Identifier dans quel node se trouve l'écran actuel (API legacy).
|
||||
|
||||
Lot E — cette méthode est désormais un **wrapper** de rétrocompat :
|
||||
elle construit un ``ScreenState`` enrichi via ``ScreenAnalyzer``
|
||||
(au lieu d'un stub avec ``window_title="Unknown"``) puis délègue
|
||||
à ``match_current_state_from_state``. Garantit la compat pour les
|
||||
callers externes qui ne manipulent que le chemin du screenshot.
|
||||
|
||||
Args:
|
||||
screenshot_path: Chemin vers le screenshot actuel.
|
||||
workflow_id: ID du workflow à matcher (tous si None).
|
||||
window_title: Titre de fenêtre pour contexte (utilisé comme
|
||||
hint si le ScreenAnalyzer n'est pas disponible).
|
||||
|
||||
Returns:
|
||||
Dict avec ``node_id``, ``workflow_id``, ``confidence``, ou
|
||||
``None`` si pas de match.
|
||||
"""
|
||||
logger.debug(f"Matching screenshot: {screenshot_path}")
|
||||
|
||||
# Construire un ScreenState enrichi via le ScreenAnalyzer partagé.
|
||||
screen_state = self._build_screen_state_for_matching(
|
||||
screenshot_path=screenshot_path,
|
||||
workflow_id=workflow_id,
|
||||
window_title=window_title,
|
||||
)
|
||||
|
||||
return self.match_current_state_from_state(
|
||||
screen_state=screen_state,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
|
||||
def _build_screen_state_for_matching(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
workflow_id: Optional[str],
|
||||
window_title: Optional[str],
|
||||
) -> ScreenState:
|
||||
"""
|
||||
Construire un ``ScreenState`` pour l'API legacy ``match_current_state``.
|
||||
|
||||
Tente d'utiliser le ``ScreenAnalyzer`` partagé ; en cas d'échec,
|
||||
retombe sur un stub minimaliste (équivalent fonctionnel de l'ancien
|
||||
comportement, mais clairement isolé ici).
|
||||
"""
|
||||
from core.models.screen_state import (
|
||||
WindowContext, RawLevel, PerceptionLevel, ContextLevel, EmbeddingRef
|
||||
)
|
||||
|
||||
path = Path(screenshot_path)
|
||||
|
||||
# Tentative 1 : ScreenAnalyzer partagé (résultat enrichi)
|
||||
try:
|
||||
from core.pipeline import get_screen_analyzer
|
||||
analyzer = get_screen_analyzer()
|
||||
if analyzer is not None:
|
||||
window_info = None
|
||||
if window_title:
|
||||
window_info = {"title": window_title, "app_name": "unknown"}
|
||||
return analyzer.analyze(
|
||||
str(path),
|
||||
window_info=window_info,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"ScreenAnalyzer unavailable in match_current_state wrapper: {exc}"
|
||||
)
|
||||
|
||||
# Tentative 2 : stub minimal (comportement legacy d'urgence)
|
||||
window = WindowContext(
|
||||
app_name="unknown",
|
||||
window_title=window_title or "Unknown",
|
||||
screen_resolution=[1920, 1080],
|
||||
workspace="main",
|
||||
)
|
||||
raw = RawLevel(
|
||||
screenshot_path=str(path),
|
||||
capture_method="manual",
|
||||
file_size_bytes=path.stat().st_size if path.exists() else 0,
|
||||
)
|
||||
perception = PerceptionLevel(
|
||||
embedding=EmbeddingRef(
|
||||
provider="openclip_ViT-B-32",
|
||||
vector_id="temp",
|
||||
dimensions=512,
|
||||
),
|
||||
detected_text=[],
|
||||
text_detection_method="pending",
|
||||
confidence_avg=0.0,
|
||||
)
|
||||
context = ContextLevel(
|
||||
current_workflow_candidate=workflow_id,
|
||||
workflow_step=None,
|
||||
user_id="matcher",
|
||||
tags=[],
|
||||
business_variables={},
|
||||
)
|
||||
return ScreenState(
|
||||
screen_state_id=f"match_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id="matching",
|
||||
window=window,
|
||||
raw=raw,
|
||||
perception=perception,
|
||||
context=context,
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
def match_hierarchical(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
@@ -548,17 +757,56 @@ class WorkflowPipeline:
|
||||
def get_next_action(
|
||||
self,
|
||||
workflow_id: str,
|
||||
current_node_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
current_node_id: str,
|
||||
screen_state: Optional[ScreenState] = None,
|
||||
strategy: str = "best",
|
||||
source_similarity: float = 1.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Obtenir la prochaine action à exécuter.
|
||||
|
||||
Contrat normalisé (Lot A — avril 2026) : retourne **toujours** un
|
||||
dict avec une clé ``status`` non-ambiguë. Le ``None`` ambigu qui
|
||||
confondait "workflow terminé" et "aucun edge valide" a été
|
||||
supprimé : l'appelant (ExecutionLoop) peut désormais distinguer
|
||||
ces cas pour déclencher une pause supervisée plutôt qu'une fin
|
||||
de workflow faux-positive.
|
||||
|
||||
Sélection d'edge (C3) :
|
||||
- Filtre dur sur ``pre_conditions`` (EdgeConstraints)
|
||||
- Ranking par score composite (success_rate, target_match, recency)
|
||||
- Tiebreak : success_rate le plus haut
|
||||
|
||||
Args:
|
||||
workflow_id: ID du workflow
|
||||
current_node_id: ID du node actuel
|
||||
screen_state: État courant, requis pour évaluer les
|
||||
``pre_conditions`` et le match ``target_spec``. Si None,
|
||||
fallback sur la logique sans filtre de contraintes.
|
||||
strategy: ``"best"`` (défaut, scoring complet) ou ``"first"``
|
||||
(mode legacy, premier edge sans tri)
|
||||
source_similarity: confiance du matching (``match_current_state``)
|
||||
qui a identifié ``current_node_id``. Propagée à l'EdgeScorer
|
||||
pour activer la précondition ``min_source_similarity`` des
|
||||
edges. Défaut ``1.0`` pour compat avec les appelants qui
|
||||
ne la fournissent pas encore (Lot B — avril 2026).
|
||||
|
||||
Returns:
|
||||
Dict avec action, target_node, confidence, ou None
|
||||
Dict avec l'une des formes suivantes :
|
||||
|
||||
- ``{"status": "selected", "edge_id": str, "action": dict,
|
||||
"target_node": str, "confidence": float, "score": float}``
|
||||
→ edge sélectionné, l'ExecutionLoop doit l'exécuter.
|
||||
|
||||
- ``{"status": "terminal"}`` → le node courant n'a pas
|
||||
d'outgoing_edge (fin légitime de workflow).
|
||||
|
||||
- ``{"status": "blocked", "reason": str}`` → il existe des
|
||||
outgoing_edges mais aucun ne satisfait les conditions
|
||||
(``reason="no_valid_edge"``), ou le workflow est introuvable
|
||||
(``reason="workflow_not_found"``). L'ExecutionLoop doit
|
||||
déclencher une pause supervisée et ne **jamais** traiter
|
||||
ce cas comme un succès.
|
||||
"""
|
||||
workflow = self._workflows.get(workflow_id)
|
||||
if not workflow:
|
||||
@@ -569,23 +817,44 @@ class WorkflowPipeline:
|
||||
self._workflows[workflow_id] = workflow
|
||||
else:
|
||||
logger.error(f"Workflow not found: {workflow_id}")
|
||||
return None
|
||||
return {"status": "blocked", "reason": "workflow_not_found"}
|
||||
|
||||
# Trouver les edges sortants du node actuel
|
||||
outgoing_edges = workflow.get_outgoing_edges(current_node_id)
|
||||
|
||||
if not outgoing_edges:
|
||||
# Aucun outgoing_edge = fin légitime du workflow
|
||||
logger.info(f"No outgoing edges from node {current_node_id}")
|
||||
return None
|
||||
return {"status": "terminal"}
|
||||
|
||||
# Pour l'instant, prendre le premier edge (TODO: logique de sélection)
|
||||
edge = outgoing_edges[0]
|
||||
# Sélection robuste via EdgeScorer (C3)
|
||||
from core.pipeline.edge_scorer import EdgeScorer
|
||||
|
||||
scorer = EdgeScorer()
|
||||
edge = scorer.select_best(
|
||||
outgoing_edges,
|
||||
screen_state=screen_state,
|
||||
strategy=strategy,
|
||||
source_similarity=source_similarity,
|
||||
)
|
||||
|
||||
if edge is None:
|
||||
# Il y avait des candidats mais aucun n'a passé les filtres.
|
||||
# On NE retourne PAS "terminal" : l'ExecutionLoop doit traiter
|
||||
# ce cas comme un blocage et demander de l'aide.
|
||||
logger.warning(
|
||||
f"No valid edge from {current_node_id} "
|
||||
f"({len(outgoing_edges)} candidates rejected)"
|
||||
)
|
||||
return {"status": "blocked", "reason": "no_valid_edge"}
|
||||
|
||||
return {
|
||||
"status": "selected",
|
||||
"edge_id": edge.edge_id,
|
||||
"action": edge.action.to_dict(),
|
||||
"target_node": edge.to_node,
|
||||
"confidence": edge.stats.success_rate if edge.stats else 1.0
|
||||
"confidence": edge.stats.success_rate if edge.stats else 1.0,
|
||||
"score": edge.stats.success_rate if edge.stats else 1.0,
|
||||
}
|
||||
|
||||
def should_execute_automatically(self, workflow_id: str) -> bool:
|
||||
@@ -759,10 +1028,11 @@ class WorkflowPipeline:
|
||||
current_node_id = match_result["node_id"]
|
||||
logger.info(f"Matched current state to node: {current_node_id} (confidence: {match_result['confidence']:.3f})")
|
||||
|
||||
# 2. Obtenir la prochaine action
|
||||
# 2. Obtenir la prochaine action (contrat dict avec status explicite)
|
||||
action_info = self.get_next_action(workflow_id, current_node_id)
|
||||
action_status = action_info.get("status")
|
||||
|
||||
if not action_info:
|
||||
if action_status == "terminal":
|
||||
return {
|
||||
"execution_id": execution_id,
|
||||
"workflow_id": workflow_id,
|
||||
@@ -771,7 +1041,19 @@ class WorkflowPipeline:
|
||||
"message": "Workflow completed - no more actions",
|
||||
"current_node": current_node_id,
|
||||
"execution_time_ms": (datetime.now() - start_time).total_seconds() * 1000,
|
||||
"correlation_id": execution_id
|
||||
"correlation_id": execution_id,
|
||||
}
|
||||
|
||||
if action_status == "blocked":
|
||||
return {
|
||||
"execution_id": execution_id,
|
||||
"workflow_id": workflow_id,
|
||||
"success": False,
|
||||
"step_type": "action_selection",
|
||||
"error": f"No valid edge: {action_info.get('reason', 'unknown')}",
|
||||
"current_node": current_node_id,
|
||||
"execution_time_ms": (datetime.now() - start_time).total_seconds() * 1000,
|
||||
"correlation_id": execution_id,
|
||||
}
|
||||
|
||||
logger.info(f"Next action: {action_info['action']['type']} -> {action_info['target_node']}")
|
||||
|
||||
@@ -125,18 +125,19 @@ class WorkflowPipelineEnhanced:
|
||||
current_node_id = match_result["node_id"]
|
||||
logger.info(f"Matched current state to node: {current_node_id} (confidence: {match_result['confidence']:.3f})")
|
||||
|
||||
# 2. Obtenir la prochaine action
|
||||
# 2. Obtenir la prochaine action (contrat dict avec status explicite)
|
||||
action_info = self.get_next_action(workflow_id, current_node_id)
|
||||
action_status = action_info.get("status")
|
||||
|
||||
if not action_info:
|
||||
# Workflow terminé
|
||||
if action_status == "terminal":
|
||||
# Workflow terminé (aucun outgoing_edge = fin légitime)
|
||||
performance_metrics.total_execution_time_ms = (datetime.now() - start_time).total_seconds() * 1000
|
||||
|
||||
result = WorkflowExecutionResult.workflow_complete(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
current_node=current_node_id,
|
||||
performance_metrics=performance_metrics
|
||||
performance_metrics=performance_metrics,
|
||||
)
|
||||
result.correlation_id = correlation_id
|
||||
result.match_result = match_result
|
||||
@@ -144,6 +145,27 @@ class WorkflowPipelineEnhanced:
|
||||
logger.info(f"Workflow {workflow_id} completed at node {current_node_id}")
|
||||
return result
|
||||
|
||||
if action_status == "blocked":
|
||||
# Des edges existent mais aucun ne passe les filtres :
|
||||
# c'est un blocage, pas une fin de workflow.
|
||||
performance_metrics.total_execution_time_ms = (datetime.now() - start_time).total_seconds() * 1000
|
||||
|
||||
result = WorkflowExecutionResult.error(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
error_message=f"No valid edge: {action_info.get('reason', 'unknown')}",
|
||||
step_type="action_selection",
|
||||
current_node=current_node_id,
|
||||
performance_metrics=performance_metrics,
|
||||
)
|
||||
result.correlation_id = correlation_id
|
||||
|
||||
logger.warning(
|
||||
f"Workflow {workflow_id} blocked at node {current_node_id}: "
|
||||
f"{action_info.get('reason')}"
|
||||
)
|
||||
return result
|
||||
|
||||
logger.info(f"Next action: {action_info['action']['type']} -> {action_info['target_node']}")
|
||||
|
||||
# 3. Charger le workflow pour obtenir l'edge complet
|
||||
|
||||
308
core/security/signed_serializer.py
Normal file
308
core/security/signed_serializer.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Sérialiseur signé — RPA Vision V3
|
||||
|
||||
Remplace les usages de `pickle.load` (vulnérables à la désérialisation arbitraire
|
||||
de code) par une sérialisation JSON signée via HMAC-SHA256.
|
||||
|
||||
Principes :
|
||||
- Les données sont sérialisées en JSON (avec support des types numpy / datetime
|
||||
via un encodeur custom).
|
||||
- Une signature HMAC-SHA256 est calculée sur le JSON avec une clé secrète
|
||||
dérivée de `RPA_SIGNING_KEY` (ou, à défaut, de `TOKEN_SECRET_KEY`).
|
||||
- À la lecture, la signature est vérifiée AVANT tout parsing applicatif.
|
||||
- Rétrocompatibilité : un fallback `pickle.load` est disponible pour migrer
|
||||
les anciens fichiers. Il logue un WARNING et doit être suivi d'une
|
||||
ré-écriture en JSON signé.
|
||||
|
||||
ATTENTION : n'utiliser le fallback pickle que sur des fichiers dont la source
|
||||
est réputée sûre (locale + protégée). Le fallback est désactivable via la
|
||||
variable d'environnement `RPA_ALLOW_PICKLE_FALLBACK=0`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Clé de signature
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
_SIGNATURE_ALGO = "sha256"
|
||||
_SIGNATURE_HEADER = b"RPA_SIGNED_V1\n" # Marqueur de format signé
|
||||
|
||||
|
||||
def _resolve_signing_key() -> bytes:
|
||||
"""Récupère la clé de signature HMAC.
|
||||
|
||||
Ordre de priorité :
|
||||
1. RPA_SIGNING_KEY (dédiée à la signature de fichiers)
|
||||
2. TOKEN_SECRET_KEY (clé déjà utilisée pour signer les tokens API)
|
||||
3. Clé dérivée en dev (avec WARNING)
|
||||
|
||||
La clé dev est stable pour une même machine (dérivée du hostname + path)
|
||||
afin que les lectures/écritures locales restent cohérentes en l'absence
|
||||
de configuration, tout en refusant de valider des fichiers produits
|
||||
ailleurs.
|
||||
"""
|
||||
explicit = os.getenv("RPA_SIGNING_KEY", "").strip()
|
||||
if explicit:
|
||||
return explicit.encode("utf-8")
|
||||
|
||||
fallback = os.getenv("TOKEN_SECRET_KEY", "").strip()
|
||||
if fallback:
|
||||
return fallback.encode("utf-8")
|
||||
|
||||
# Clé dev dérivée : non cryptographiquement sûre, juste pour éviter des
|
||||
# erreurs en dev local. On loggue explicitement.
|
||||
logger.warning(
|
||||
"RPA_SIGNING_KEY et TOKEN_SECRET_KEY non définis — "
|
||||
"utilisation d'une clé dérivée locale. "
|
||||
"Définir RPA_SIGNING_KEY en production."
|
||||
)
|
||||
seed = f"rpa-vision-v3::{os.uname().nodename}::dev-signing" # type: ignore[attr-defined]
|
||||
return hashlib.sha256(seed.encode("utf-8")).digest()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Encodage JSON étendu (numpy, datetime, Path, bytes)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class _RPAJSONEncoder(json.JSONEncoder):
|
||||
"""Encodeur JSON supportant numpy / datetime / Path / bytes."""
|
||||
|
||||
def default(self, obj: Any) -> Any: # noqa: D401 - API json standard
|
||||
if isinstance(obj, np.ndarray):
|
||||
return {
|
||||
"__type__": "ndarray",
|
||||
"dtype": str(obj.dtype),
|
||||
"shape": list(obj.shape),
|
||||
"data": base64.b64encode(obj.tobytes()).decode("ascii"),
|
||||
}
|
||||
if isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
if isinstance(obj, (np.floating,)):
|
||||
return float(obj)
|
||||
if isinstance(obj, (np.bool_,)):
|
||||
return bool(obj)
|
||||
if isinstance(obj, datetime):
|
||||
return {"__type__": "datetime", "iso": obj.isoformat()}
|
||||
if isinstance(obj, timedelta):
|
||||
return {"__type__": "timedelta", "seconds": obj.total_seconds()}
|
||||
if isinstance(obj, Path):
|
||||
return {"__type__": "path", "value": str(obj)}
|
||||
if isinstance(obj, bytes):
|
||||
return {
|
||||
"__type__": "bytes",
|
||||
"data": base64.b64encode(obj).decode("ascii"),
|
||||
}
|
||||
if isinstance(obj, set):
|
||||
return {"__type__": "set", "items": list(obj)}
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
def _json_object_hook(obj: Any) -> Any:
|
||||
"""Reconstruit les types étendus depuis le JSON."""
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
tag = obj.get("__type__")
|
||||
if tag is None:
|
||||
return obj
|
||||
if tag == "ndarray":
|
||||
raw = base64.b64decode(obj["data"])
|
||||
arr = np.frombuffer(raw, dtype=np.dtype(obj["dtype"]))
|
||||
return arr.reshape(obj["shape"]).copy()
|
||||
if tag == "datetime":
|
||||
return datetime.fromisoformat(obj["iso"])
|
||||
if tag == "timedelta":
|
||||
return timedelta(seconds=float(obj["seconds"]))
|
||||
if tag == "path":
|
||||
return Path(obj["value"])
|
||||
if tag == "bytes":
|
||||
return base64.b64decode(obj["data"])
|
||||
if tag == "set":
|
||||
return set(obj.get("items", []))
|
||||
return obj
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Erreurs dédiées
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class SignedSerializerError(Exception):
|
||||
"""Erreur de base du module."""
|
||||
|
||||
|
||||
class SignatureVerificationError(SignedSerializerError):
|
||||
"""Signature HMAC invalide : le fichier a été altéré ou forgé."""
|
||||
|
||||
|
||||
class UnsupportedFormatError(SignedSerializerError):
|
||||
"""Le fichier n'est ni au format signé, ni reconnu comme pickle legacy."""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API publique
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _compute_hmac(payload: bytes, key: bytes) -> str:
|
||||
return hmac.new(key, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def dumps_signed(data: Any, key: Optional[bytes] = None) -> bytes:
|
||||
"""Sérialise `data` en JSON signé HMAC-SHA256.
|
||||
|
||||
Format binaire retourné :
|
||||
b"RPA_SIGNED_V1\n" + utf8(json({"hmac": "<hex>", "payload": <data>}))
|
||||
|
||||
Le HMAC couvre le JSON canonique de `payload` (keys triées,
|
||||
séparateurs compacts) pour qu'un même objet produise toujours la
|
||||
même signature.
|
||||
"""
|
||||
signing_key = key if key is not None else _resolve_signing_key()
|
||||
payload_json = json.dumps(
|
||||
data,
|
||||
cls=_RPAJSONEncoder,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
signature = _compute_hmac(payload_json, signing_key)
|
||||
envelope = {"hmac": signature, "payload_b64": base64.b64encode(payload_json).decode("ascii")}
|
||||
body = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return _SIGNATURE_HEADER + body
|
||||
|
||||
|
||||
def loads_signed(raw: bytes, key: Optional[bytes] = None) -> Any:
|
||||
"""Désérialise un blob produit par `dumps_signed` après vérification HMAC."""
|
||||
if not raw.startswith(_SIGNATURE_HEADER):
|
||||
raise UnsupportedFormatError("Marqueur RPA_SIGNED_V1 absent.")
|
||||
signing_key = key if key is not None else _resolve_signing_key()
|
||||
body = raw[len(_SIGNATURE_HEADER):]
|
||||
try:
|
||||
envelope = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SignedSerializerError(f"Enveloppe JSON invalide : {exc}") from exc
|
||||
|
||||
if not isinstance(envelope, dict):
|
||||
raise SignedSerializerError("Enveloppe inattendue.")
|
||||
signature = envelope.get("hmac")
|
||||
payload_b64 = envelope.get("payload_b64")
|
||||
if not isinstance(signature, str) or not isinstance(payload_b64, str):
|
||||
raise SignedSerializerError("Enveloppe mal formée (hmac / payload_b64).")
|
||||
|
||||
try:
|
||||
payload_bytes = base64.b64decode(payload_b64.encode("ascii"), validate=True)
|
||||
except Exception as exc: # noqa: BLE001 - base64 peut lever plusieurs erreurs
|
||||
raise SignedSerializerError(f"Payload base64 invalide : {exc}") from exc
|
||||
|
||||
expected = _compute_hmac(payload_bytes, signing_key)
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise SignatureVerificationError(
|
||||
"Signature HMAC invalide — fichier altéré ou clé différente."
|
||||
)
|
||||
|
||||
return json.loads(payload_bytes.decode("utf-8"), object_hook=_json_object_hook)
|
||||
|
||||
|
||||
def _pickle_fallback_allowed() -> bool:
|
||||
return os.getenv("RPA_ALLOW_PICKLE_FALLBACK", "1") != "0"
|
||||
|
||||
|
||||
def save_signed(path: Union[str, Path], data: Any, key: Optional[bytes] = None) -> None:
|
||||
"""Écrit `data` sur disque dans le format JSON signé."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
blob = dumps_signed(data, key=key)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "wb") as fp:
|
||||
fp.write(blob)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_signed(
|
||||
path: Union[str, Path],
|
||||
*,
|
||||
allow_pickle_fallback: bool = True,
|
||||
migrate_on_fallback: bool = True,
|
||||
pickle_loader: Optional[Callable[[io.BufferedReader], Any]] = None,
|
||||
key: Optional[bytes] = None,
|
||||
) -> Any:
|
||||
"""Charge un fichier sauvegardé par `save_signed`.
|
||||
|
||||
Si le fichier n'est pas au format signé, et si `allow_pickle_fallback`
|
||||
est vrai (ET `RPA_ALLOW_PICKLE_FALLBACK != "0"`), tente un
|
||||
`pickle.load()` pour migrer les anciens fichiers. Dans ce cas, un
|
||||
WARNING est émis et le fichier est ré-écrit en JSON signé si
|
||||
`migrate_on_fallback` vaut True.
|
||||
|
||||
Args:
|
||||
path: Chemin du fichier
|
||||
allow_pickle_fallback: Activer la compat legacy
|
||||
migrate_on_fallback: Ré-écrire en JSON signé après fallback
|
||||
pickle_loader: Callable alternatif (pour tests / restricted unpickler)
|
||||
key: Clé HMAC explicite (sinon dérivée de l'environnement)
|
||||
|
||||
Raises:
|
||||
SignatureVerificationError: HMAC invalide (fichier altéré)
|
||||
UnsupportedFormatError: format inconnu et fallback désactivé
|
||||
"""
|
||||
path = Path(path)
|
||||
with open(path, "rb") as fp:
|
||||
raw = fp.read()
|
||||
|
||||
if raw.startswith(_SIGNATURE_HEADER):
|
||||
return loads_signed(raw, key=key)
|
||||
|
||||
if not allow_pickle_fallback or not _pickle_fallback_allowed():
|
||||
raise UnsupportedFormatError(
|
||||
f"{path} n'est pas au format signé et le fallback pickle est désactivé."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Chargement legacy pickle pour %s — ce format est obsolète et "
|
||||
"sera ré-écrit en JSON signé. Voir docs/SECURITY.md.",
|
||||
path,
|
||||
)
|
||||
|
||||
# Par défaut on refuse tout type non documenté dans ce fichier à risque :
|
||||
# utilisateur peut fournir un `pickle_loader` custom (ex: Unpickler
|
||||
# restreint). On log l'ouverture pour la traçabilité.
|
||||
loader = pickle_loader or (lambda f: pickle.load(f)) # noqa: S301 - usage legacy
|
||||
with open(path, "rb") as fp:
|
||||
data = loader(fp)
|
||||
|
||||
if migrate_on_fallback:
|
||||
try:
|
||||
save_signed(path, data, key=key)
|
||||
logger.info("Fichier %s migré en JSON signé.", path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Migration JSON signé échouée pour %s : %s", path, exc
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SignedSerializerError",
|
||||
"SignatureVerificationError",
|
||||
"UnsupportedFormatError",
|
||||
"dumps_signed",
|
||||
"loads_signed",
|
||||
"save_signed",
|
||||
"load_signed",
|
||||
]
|
||||
@@ -26,11 +26,15 @@ from PIL import Image
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import pickle
|
||||
import os
|
||||
|
||||
from core.models import BBox
|
||||
from core.embedding.fusion_engine import FusionEngine
|
||||
from core.security.signed_serializer import (
|
||||
SignatureVerificationError,
|
||||
load_signed,
|
||||
save_signed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -521,29 +525,74 @@ class VisualEmbeddingManager:
|
||||
|
||||
logger.debug(f"Éviction de {num_to_remove} entrées du cache")
|
||||
|
||||
def _entry_to_dict(self, entry: "EmbeddingCacheEntry") -> Dict[str, Any]:
|
||||
"""Convertit une entrée du cache en dict JSON-serialisable."""
|
||||
return {
|
||||
"embedding": entry.embedding, # numpy → encodé par signed_serializer
|
||||
"signature": entry.signature,
|
||||
"created_at": entry.created_at,
|
||||
"access_count": entry.access_count,
|
||||
"last_accessed": entry.last_accessed,
|
||||
}
|
||||
|
||||
def _dict_to_entry(self, data: Any) -> Optional["EmbeddingCacheEntry"]:
|
||||
"""Reconstruit une EmbeddingCacheEntry depuis un dict (format JSON)
|
||||
ou depuis un objet déjà typé (fallback pickle legacy).
|
||||
Retourne None si la donnée n'est pas exploitable.
|
||||
"""
|
||||
if isinstance(data, EmbeddingCacheEntry):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
return EmbeddingCacheEntry(
|
||||
embedding=np.asarray(data["embedding"]),
|
||||
signature=data["signature"],
|
||||
created_at=data["created_at"],
|
||||
access_count=int(data.get("access_count", 0)),
|
||||
last_accessed=data.get("last_accessed"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.warning(f"Entrée de cache invalide ignorée: {exc}")
|
||||
return None
|
||||
|
||||
def _load_persistent_cache(self):
|
||||
"""Charge le cache persistant depuis le disque"""
|
||||
"""Charge le cache persistant depuis le disque (JSON signé HMAC,
|
||||
fallback pickle legacy avec migration automatique)."""
|
||||
if not self.cache_persistence_path or not os.path.exists(self.cache_persistence_path):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.cache_persistence_path, 'rb') as f:
|
||||
cached_data = pickle.load(f)
|
||||
cached_data = load_signed(self.cache_persistence_path)
|
||||
except SignatureVerificationError:
|
||||
logger.error(
|
||||
"Cache persistant %s altéré (HMAC invalide) — ignoré.",
|
||||
self.cache_persistence_path,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur lors du chargement du cache persistant: {e}")
|
||||
return
|
||||
|
||||
if not isinstance(cached_data, dict):
|
||||
logger.warning("Format de cache inattendu — ignoré.")
|
||||
return
|
||||
|
||||
# Filtrer les entrées trop anciennes (plus de 24h)
|
||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||
|
||||
for signature, entry in cached_data.items():
|
||||
loaded = 0
|
||||
for signature, raw in cached_data.items():
|
||||
entry = self._dict_to_entry(raw)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.created_at > cutoff_time:
|
||||
self._embedding_cache[signature] = entry
|
||||
loaded += 1
|
||||
|
||||
logger.info(f"Cache persistant chargé: {len(self._embedding_cache)} entrées")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur lors du chargement du cache persistant: {e}")
|
||||
logger.info(f"Cache persistant chargé: {loaded} entrées")
|
||||
|
||||
def _save_persistent_cache(self):
|
||||
"""Sauvegarde le cache sur disque"""
|
||||
"""Sauvegarde le cache sur disque en JSON signé HMAC."""
|
||||
if not self.cache_persistence_path:
|
||||
return
|
||||
|
||||
@@ -552,9 +601,12 @@ class VisualEmbeddingManager:
|
||||
os.makedirs(os.path.dirname(self.cache_persistence_path), exist_ok=True)
|
||||
|
||||
with self._cache_lock:
|
||||
with open(self.cache_persistence_path, 'wb') as f:
|
||||
pickle.dump(dict(self._embedding_cache), f)
|
||||
serializable = {
|
||||
signature: self._entry_to_dict(entry)
|
||||
for signature, entry in self._embedding_cache.items()
|
||||
}
|
||||
|
||||
save_signed(self.cache_persistence_path, serializable)
|
||||
logger.debug("Cache persistant sauvegardé")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -14,8 +14,9 @@ import asyncio
|
||||
import logging
|
||||
import json
|
||||
import base64
|
||||
import pickle
|
||||
import gzip
|
||||
import pickle # noqa: S403 - usage legacy restreint au fallback de migration
|
||||
import io
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
@@ -24,6 +25,12 @@ import numpy as np
|
||||
|
||||
from core.visual.visual_target_manager import VisualTarget, VisualTargetManager
|
||||
from core.visual.screenshot_validation_manager import ScreenshotValidationManager, ValidationResult
|
||||
from core.security.signed_serializer import (
|
||||
SignatureVerificationError,
|
||||
UnsupportedFormatError,
|
||||
dumps_signed,
|
||||
loads_signed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -435,7 +442,7 @@ class VisualPersistenceManager:
|
||||
return None
|
||||
|
||||
async def _serialize_workflow_data(self, workflow_data: VisualWorkflowData) -> bytes:
|
||||
"""Sérialise les données d'un workflow"""
|
||||
"""Sérialise les données d'un workflow en JSON signé HMAC."""
|
||||
# Convertir en dictionnaire
|
||||
data_dict = asdict(workflow_data)
|
||||
|
||||
@@ -456,13 +463,28 @@ class VisualPersistenceManager:
|
||||
]
|
||||
data_dict['validation_history'] = serialized_history
|
||||
|
||||
# Convertir en bytes
|
||||
return pickle.dumps(data_dict)
|
||||
# JSON signé HMAC (cf. core.security.signed_serializer)
|
||||
return dumps_signed(data_dict)
|
||||
|
||||
async def _deserialize_workflow_data(self, data: bytes) -> VisualWorkflowData:
|
||||
"""Désérialise les données d'un workflow"""
|
||||
# Désérialiser le dictionnaire
|
||||
data_dict = pickle.loads(data)
|
||||
"""Désérialise les données d'un workflow (JSON signé HMAC ;
|
||||
fallback pickle legacy avec WARNING pour migrer les anciens fichiers)."""
|
||||
try:
|
||||
data_dict = loads_signed(data)
|
||||
except SignatureVerificationError:
|
||||
# Fichier altéré ou clé différente : on refuse sans fallback.
|
||||
logger.error("Workflow visuel : signature HMAC invalide — refus.")
|
||||
raise
|
||||
except UnsupportedFormatError:
|
||||
# Ancien format pickle : fallback explicite et bruyant.
|
||||
import os
|
||||
if os.getenv("RPA_ALLOW_PICKLE_FALLBACK", "1") == "0":
|
||||
raise
|
||||
logger.warning(
|
||||
"Workflow visuel au format pickle legacy — lecture de compat, "
|
||||
"ré-écrire en JSON signé dès que possible."
|
||||
)
|
||||
data_dict = pickle.loads(data) # noqa: S301 - fallback legacy
|
||||
|
||||
# Reconstruire les objets
|
||||
workflow_data = VisualWorkflowData(
|
||||
|
||||
@@ -146,8 +146,14 @@ REQUIRED_FILES=(
|
||||
"agent_v1/core/__init__.py"
|
||||
"agent_v1/core/captor.py"
|
||||
"agent_v1/core/executor.py"
|
||||
"agent_v1/core/grounding.py"
|
||||
"agent_v1/core/policy.py"
|
||||
"agent_v1/core/recovery.py"
|
||||
"agent_v1/core/system_dialog_guard.py"
|
||||
"agent_v1/core/uia_helper.py"
|
||||
"agent_v1/network/__init__.py"
|
||||
"agent_v1/network/streamer.py"
|
||||
"agent_v1/network/persistent_buffer.py"
|
||||
"agent_v1/session/__init__.py"
|
||||
"agent_v1/session/storage.py"
|
||||
"agent_v1/ui/__init__.py"
|
||||
@@ -156,6 +162,8 @@ REQUIRED_FILES=(
|
||||
"agent_v1/ui/chat_window.py"
|
||||
"agent_v1/ui/capture_server.py"
|
||||
"agent_v1/ui/notifications.py"
|
||||
"agent_v1/ui/activity_panel.py"
|
||||
"agent_v1/ui/messages.py"
|
||||
"agent_v1/vision/__init__.py"
|
||||
"agent_v1/vision/capturer.py"
|
||||
"agent_v1/vision/blur_sensitive.py"
|
||||
|
||||
61
deploy/installer/LICENSE.txt
Normal file
61
deploy/installer/LICENSE.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
============================================================
|
||||
Lea - Conditions Generales d'Utilisation
|
||||
============================================================
|
||||
|
||||
Version 1.0 — Avril 2026
|
||||
Editeur : AIVANOV
|
||||
|
||||
1. OBJET
|
||||
--------
|
||||
Lea est un logiciel d'assistance intelligente destine a automatiser
|
||||
des taches repetitives sur poste de travail Windows, pour le compte
|
||||
de son employeur (AIVANOV et ses clients autorises).
|
||||
|
||||
2. NATURE DES DONNEES COLLECTEES
|
||||
--------------------------------
|
||||
Lors de son utilisation, Lea capture :
|
||||
- Des captures d'ecran du poste de travail
|
||||
- Les evenements clavier et souris
|
||||
- Les metadonnees systeme (nom de la machine, processus actifs)
|
||||
|
||||
Les donnees sensibles (mots de passe, numeros de securite sociale,
|
||||
informations de cartes bancaires) sont automatiquement floutees
|
||||
avant transmission au serveur, sauf desactivation explicite par
|
||||
l'administrateur (parametre RPA_BLUR_SENSITIVE=false).
|
||||
|
||||
3. TRANSMISSION ET STOCKAGE
|
||||
---------------------------
|
||||
Les donnees sont transmises via HTTPS chiffre a un serveur central
|
||||
gere par AIVANOV. Elles sont conservees 180 jours minimum pour des
|
||||
raisons de conformite, puis purgees automatiquement.
|
||||
|
||||
4. SYSTEME D'IA (AI ACT - ARTICLE 50)
|
||||
-------------------------------------
|
||||
Lea utilise des modeles d'intelligence artificielle pour comprendre
|
||||
et automatiser les taches. Conformement a l'Article 50 du Reglement
|
||||
europeen sur l'Intelligence Artificielle, l'utilisateur est informe
|
||||
qu'il interagit avec un systeme d'IA.
|
||||
|
||||
5. CONTROLE PAR L'UTILISATEUR
|
||||
-----------------------------
|
||||
L'utilisateur peut a tout moment :
|
||||
- Arreter l'enregistrement (clic droit sur icone > C'est termine)
|
||||
- Declencher un arret d'urgence (clic droit > ARRET D'URGENCE)
|
||||
- Quitter Lea completement (clic droit > Quitter Lea)
|
||||
- Desinstaller Lea via le panneau de configuration Windows
|
||||
|
||||
6. RESPONSABILITE
|
||||
-----------------
|
||||
L'utilisateur s'engage a ne pas utiliser Lea sur des donnees qu'il
|
||||
n'est pas autorise a traiter dans le cadre de ses fonctions.
|
||||
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
|
||||
|
||||
============================================================
|
||||
En cliquant sur "J'accepte", vous confirmez avoir pris connaissance
|
||||
de ces conditions et les accepter.
|
||||
============================================================
|
||||
554
deploy/installer/Lea.iss
Normal file
554
deploy/installer/Lea.iss
Normal file
@@ -0,0 +1,554 @@
|
||||
; ============================================================
|
||||
; Lea.iss — Script Inno Setup pour l'installeur Lea
|
||||
; ------------------------------------------------------------
|
||||
; Compile avec Inno Setup 6.2+ (ISCC.exe Lea.iss)
|
||||
;
|
||||
; Ce script produit Lea-Setup-v{VERSION}.exe dans ..\releases\
|
||||
;
|
||||
; Fonctions principales :
|
||||
; - Page de bienvenue + licence (CGU)
|
||||
; - Page custom d'enrollment (nom, email, ID AIVANOV, URL, token)
|
||||
; - Generation d'un machine_id unique par poste
|
||||
; - Generation automatique de config.txt
|
||||
; - Installation silencieuse de Python 3.12 embedded (optionnelle)
|
||||
; - Raccourci demarrage automatique (checkbox)
|
||||
; - Installation silencieuse : /VERYSILENT /CONFIG=path\to\config.txt
|
||||
; - Desinstallation propre (kill process, cleanup, export logs)
|
||||
;
|
||||
; Pre-requis staging :
|
||||
; Le dossier ..\build\installer_staging\ doit contenir :
|
||||
; - Le package Lea complet (agent_v1/, lea_ui/, run_agent_v1.py, Lea.bat, ...)
|
||||
; - Optionnel : python-3.12-embed\ (runtime Python embedded pre-configure)
|
||||
; build_installer.sh s'occupe de preparer ce staging.
|
||||
; ============================================================
|
||||
|
||||
#define MyAppName "Lea"
|
||||
#define MyAppVersion "1.0.0"
|
||||
#define MyAppPublisher "AIVANOV"
|
||||
#define MyAppURL "https://lea.labs.laurinebazin.design"
|
||||
#define MyAppExeName "Lea.bat"
|
||||
#define MyAppDescription "Lea - Assistante IA pour l'automatisation"
|
||||
|
||||
; Chemin du staging (peut etre surcharge via ISCC /DSourceDir=...)
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "..\build\installer_staging"
|
||||
#endif
|
||||
|
||||
; Chemin de sortie des installeurs
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "..\releases"
|
||||
#endif
|
||||
|
||||
; Activer le bundle Python embedded si present dans le staging
|
||||
#define PythonEmbedDir "python-3.12-embed"
|
||||
|
||||
[Setup]
|
||||
AppId={{B3F9A1E2-5C4D-4E7F-9A1B-2C3D4E5F6789}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=Lea-Setup-v{#MyAppVersion}
|
||||
; Compression correcte (pas trop aggressive pour que l'install reste rapide)
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
; Support HiDPI
|
||||
WizardStyle=modern
|
||||
; Langue FR par defaut
|
||||
ShowLanguageDialog=no
|
||||
; Autorise l'install en mode user si pas admin (bascule sur LOCALAPPDATA)
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
; Icone de l'installeur (decommenter si disponible)
|
||||
; SetupIconFile=lea.ico
|
||||
; Uninstall
|
||||
UninstallDisplayName={#MyAppName} {#MyAppVersion}
|
||||
; UninstallDisplayIcon={app}\lea.ico ; decommenter quand l'icone sera fournie
|
||||
; Architecture : 64-bit uniquement (Windows 10+ / 11)
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
; Version minimale Windows : 10
|
||||
MinVersion=10.0
|
||||
; Informations legales
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
VersionInfoCompany={#MyAppPublisher}
|
||||
VersionInfoDescription={#MyAppDescription}
|
||||
VersionInfoCopyright=Copyright (C) 2026 {#MyAppPublisher}
|
||||
; Licence CGU affichee avant le choix du repertoire
|
||||
LicenseFile=LICENSE.txt
|
||||
|
||||
[Languages]
|
||||
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 : 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)
|
||||
Source: "{#SourceDir}\{#PythonEmbedDir}\*"; \
|
||||
DestDir: "{app}\python-embed"; \
|
||||
Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist; \
|
||||
Components: pythonembed
|
||||
|
||||
; 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
|
||||
|
||||
; Licence CGU (affichee dans la page licence ET conservee dans {app})
|
||||
Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme
|
||||
|
||||
; Template de config pour installation silencieuse (reference)
|
||||
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
|
||||
|
||||
[Tasks]
|
||||
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
|
||||
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; \
|
||||
WorkingDir: "{app}"; Components: 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
|
||||
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
|
||||
|
||||
; Lancer Lea a la fin de l'installation (optionnel)
|
||||
Filename: "{app}\{#MyAppExeName}"; \
|
||||
Description: "Lancer {#MyAppName} maintenant"; \
|
||||
Flags: postinstall skipifsilent nowait shellexec
|
||||
|
||||
[UninstallRun]
|
||||
; Tuer le process via PID du lock avant suppression des fichiers
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\uninstall_lea.ps1"" -AppDir ""{app}"""; \
|
||||
RunOnceId: "KillLeaProcess"; \
|
||||
Flags: runhidden waituntilterminated
|
||||
|
||||
[UninstallDelete]
|
||||
Type: filesandordirs; Name: "{app}\.venv"
|
||||
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}\machine_id.txt"
|
||||
|
||||
; ============================================================
|
||||
; Code Pascal : pages custom + generation config.txt + helpers
|
||||
; ============================================================
|
||||
[Code]
|
||||
const
|
||||
SERVER_URL_DEFAULT = 'https://lea.labs.laurinebazin.design/api/v1';
|
||||
SERVER_HOST_DEFAULT = 'lea.labs.laurinebazin.design';
|
||||
DEFAULT_TOKEN = '86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab';
|
||||
|
||||
var
|
||||
EnrollmentPage: TInputQueryWizardPage;
|
||||
TokenPage: TInputQueryWizardPage;
|
||||
MachineIdValue: string;
|
||||
ConfigFilePath: string;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Helper : ajoute des guillemets autour d'une chaine
|
||||
// --------------------------------------------------------------------
|
||||
function AddQuotes(const S: string): string;
|
||||
begin
|
||||
Result := '"' + S + '"';
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Wrapper CreateGUIDString (via PowerShell, fallback par defaut)
|
||||
// --------------------------------------------------------------------
|
||||
function CreateGUIDString(var Guid: string): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
TmpFile: string;
|
||||
Lines: TArrayOfString;
|
||||
begin
|
||||
Result := False;
|
||||
TmpFile := ExpandConstant('{tmp}\guid.txt');
|
||||
// powershell : genere un GUID
|
||||
if Exec('powershell.exe',
|
||||
'-NoProfile -Command "[guid]::NewGuid().ToString() | Out-File -Encoding ASCII ' + AddQuotes(TmpFile) + '"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
if LoadStringsFromFile(TmpFile, Lines) and (GetArrayLength(Lines) > 0) then
|
||||
begin
|
||||
Guid := Trim(Lines[0]);
|
||||
Result := Length(Guid) > 0;
|
||||
end;
|
||||
DeleteFile(TmpFile);
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Recupere le hostname de la machine
|
||||
// --------------------------------------------------------------------
|
||||
function GetComputerNameString(): string;
|
||||
var
|
||||
Buffer: string;
|
||||
begin
|
||||
Buffer := ExpandConstant('{computername}');
|
||||
if Length(Buffer) = 0 then
|
||||
Buffer := 'unknown-host';
|
||||
Result := Buffer;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Genere un identifiant machine unique : UUID4 + hostname hashe
|
||||
// --------------------------------------------------------------------
|
||||
function GenerateMachineId(): string;
|
||||
var
|
||||
Guid: string;
|
||||
Hostname: string;
|
||||
I: Integer;
|
||||
Hash: Cardinal;
|
||||
begin
|
||||
// Essaye d'utiliser le GUID genere par Windows (via PowerShell)
|
||||
Guid := '';
|
||||
if CreateGUIDString(Guid) then
|
||||
Result := LowerCase(StringChange(StringChange(StringChange(Guid, '{', ''), '}', ''), '-', ''))
|
||||
else
|
||||
Result := IntToStr(GetTickCount);
|
||||
|
||||
// Ajoute un hash du hostname pour stabilite
|
||||
Hostname := GetComputerNameString();
|
||||
Hash := 0;
|
||||
for I := 1 to Length(Hostname) do
|
||||
Hash := (Hash * 31 + Ord(Hostname[I])) and $FFFFFFFF;
|
||||
|
||||
Result := Copy(Result, 1, 16) + '-' + Format('%08x', [Hash]);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Charge une configuration depuis /CONFIG=path (installation silencieuse)
|
||||
// Format du fichier : NOM=valeur, une ligne par parametre
|
||||
// Cles attendues : USER_NAME, USER_EMAIL, USER_ID, SERVER_URL, API_TOKEN
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadConfigFromCommandLine(); forward;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Initialisation : cree les pages custom d'enrollment
|
||||
// --------------------------------------------------------------------
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
// Page 1 : informations collaborateur
|
||||
EnrollmentPage := CreateInputQueryPage(wpSelectTasks,
|
||||
'Identification du collaborateur',
|
||||
'Veuillez renseigner vos informations pour l''enrollment',
|
||||
'Ces informations sont envoyees au serveur Lea pour identifier votre poste. ' +
|
||||
'Elles sont stockees de maniere securisee et ne sont jamais partagees avec des tiers.');
|
||||
|
||||
EnrollmentPage.Add('Nom et prenom :', False);
|
||||
EnrollmentPage.Add('Email professionnel :', False);
|
||||
EnrollmentPage.Add('ID interne AIVANOV (optionnel) :', False);
|
||||
|
||||
EnrollmentPage.Values[0] := '';
|
||||
EnrollmentPage.Values[1] := '';
|
||||
EnrollmentPage.Values[2] := '';
|
||||
|
||||
// Page 2 : configuration serveur (URL + token)
|
||||
TokenPage := CreateInputQueryPage(EnrollmentPage.ID,
|
||||
'Connexion au serveur Lea',
|
||||
'Configuration de la connexion au serveur central',
|
||||
'L''URL du serveur est pre-remplie par defaut. Le token d''authentification ' +
|
||||
'vous est fourni par votre administrateur AIVANOV. Laissez la valeur par defaut ' +
|
||||
'si vous ne savez pas quoi mettre.');
|
||||
|
||||
TokenPage.Add('URL du serveur (avec /api/v1) :', False);
|
||||
TokenPage.Add('Token d''authentification :', False);
|
||||
|
||||
TokenPage.Values[0] := SERVER_URL_DEFAULT;
|
||||
TokenPage.Values[1] := DEFAULT_TOKEN;
|
||||
|
||||
// Si un fichier /CONFIG= est passe en ligne de commande, pre-remplir
|
||||
LoadConfigFromCommandLine();
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Implementation de LoadConfigFromCommandLine (declare en forward ci-dessus)
|
||||
// --------------------------------------------------------------------
|
||||
procedure LoadConfigFromCommandLine();
|
||||
var
|
||||
ConfigParam: string;
|
||||
Lines: TArrayOfString;
|
||||
I: Integer;
|
||||
Line, Key, Value: string;
|
||||
EqPos: Integer;
|
||||
begin
|
||||
ConfigParam := ExpandConstant('{param:CONFIG}');
|
||||
if Length(ConfigParam) = 0 then Exit;
|
||||
if not FileExists(ConfigParam) then Exit;
|
||||
|
||||
if not LoadStringsFromFile(ConfigParam, Lines) then Exit;
|
||||
|
||||
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 = 'USER_NAME' then EnrollmentPage.Values[0] := Value
|
||||
else if Key = 'USER_EMAIL' then EnrollmentPage.Values[1] := Value
|
||||
else if Key = 'USER_ID' then EnrollmentPage.Values[2] := Value
|
||||
else if Key = 'SERVER_URL' then TokenPage.Values[0] := Value
|
||||
else if Key = 'API_TOKEN' then TokenPage.Values[1] := Value;
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Validation des pages custom (Nom/Email obligatoires, token non vide)
|
||||
// --------------------------------------------------------------------
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
var
|
||||
Email: string;
|
||||
begin
|
||||
Result := True;
|
||||
|
||||
if CurPageID = EnrollmentPage.ID then
|
||||
begin
|
||||
if Length(Trim(EnrollmentPage.Values[0])) = 0 then
|
||||
begin
|
||||
MsgBox('Le nom est obligatoire.', mbError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Email := Trim(EnrollmentPage.Values[1]);
|
||||
if (Length(Email) = 0) or (Pos('@', Email) = 0) then
|
||||
begin
|
||||
MsgBox('Un email valide est obligatoire.', mbError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = TokenPage.ID then
|
||||
begin
|
||||
if Length(Trim(TokenPage.Values[0])) = 0 then
|
||||
begin
|
||||
MsgBox('L''URL du serveur est obligatoire.', mbError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
if Length(Trim(TokenPage.Values[1])) < 16 then
|
||||
begin
|
||||
if MsgBox('Le token parait court (< 16 caracteres). Continuer quand meme ?',
|
||||
mbConfirmation, MB_YESNO) = IDNO then
|
||||
begin
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Ecrit config.txt genere dans le dossier d'installation
|
||||
// --------------------------------------------------------------------
|
||||
procedure WriteGeneratedConfig();
|
||||
var
|
||||
Config: string;
|
||||
ServerUrl, ServerHost, Token: string;
|
||||
UserName, UserEmail, UserId: string;
|
||||
SlashPos: Integer;
|
||||
begin
|
||||
ConfigFilePath := ExpandConstant('{app}\config.txt');
|
||||
|
||||
ServerUrl := Trim(TokenPage.Values[0]);
|
||||
Token := Trim(TokenPage.Values[1]);
|
||||
UserName := Trim(EnrollmentPage.Values[0]);
|
||||
UserEmail := Trim(EnrollmentPage.Values[1]);
|
||||
UserId := Trim(EnrollmentPage.Values[2]);
|
||||
|
||||
// Derive ServerHost depuis ServerUrl : https://host/api/v1 -> host
|
||||
ServerHost := ServerUrl;
|
||||
ServerHost := StringChange(ServerHost, 'https://', '');
|
||||
ServerHost := StringChange(ServerHost, 'http://', '');
|
||||
SlashPos := Pos('/', ServerHost);
|
||||
if SlashPos > 0 then
|
||||
ServerHost := Copy(ServerHost, 1, SlashPos - 1);
|
||||
|
||||
Config :=
|
||||
'# ============================================================' + #13#10 +
|
||||
'# Configuration Lea (genere par l''installeur)' + #13#10 +
|
||||
'# ============================================================' + #13#10 +
|
||||
'# Genere le ' + GetDateTimeString('yyyy-mm-dd hh:nn:ss', '-', ':') + #13#10 +
|
||||
'# Installe par : ' + UserName + ' <' + UserEmail + '>' + #13#10 +
|
||||
'# ID interne : ' + UserId + #13#10 +
|
||||
'# Machine ID : ' + MachineIdValue + #13#10 +
|
||||
'# ============================================================' + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Adresse du serveur Lea (URL complete avec /api/v1)' + #13#10 +
|
||||
'RPA_SERVER_URL=' + ServerUrl + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Cle d''authentification (fournie par l''administrateur)' + #13#10 +
|
||||
'RPA_API_TOKEN=' + Token + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Nom du serveur (sans https://, sans /api/v1)' + #13#10 +
|
||||
'RPA_SERVER_HOST=' + ServerHost + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Identifiant unique de cette machine (genere a l''install)' + #13#10 +
|
||||
'RPA_MACHINE_ID=' + MachineIdValue + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Informations collaborateur (utilisees pour l''audit cote serveur)' + #13#10 +
|
||||
'RPA_USER_NAME=' + UserName + #13#10 +
|
||||
'RPA_USER_EMAIL=' + UserEmail + #13#10;
|
||||
|
||||
if Length(UserId) > 0 then
|
||||
Config := Config + 'RPA_USER_ID=' + UserId + #13#10;
|
||||
|
||||
Config := Config + '' + #13#10 +
|
||||
'# ============================================================' + #13#10 +
|
||||
'# Parametres avances (ne pas modifier sauf indication)' + #13#10 +
|
||||
'# ============================================================' + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Flouter les zones de texte dans les captures (securite donnees)' + #13#10 +
|
||||
'RPA_BLUR_SENSITIVE=true' + #13#10 +
|
||||
'' + #13#10 +
|
||||
'# Duree de conservation des logs en jours (minimum 180 pour conformite)' + #13#10 +
|
||||
'RPA_LOG_RETENTION_DAYS=180' + #13#10;
|
||||
|
||||
if not SaveStringToFile(ConfigFilePath, Config, False) then
|
||||
MsgBox('Echec de l''ecriture de config.txt dans ' + ConfigFilePath, mbError, MB_OK);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Ecrit le machine_id.txt (identifiant du poste)
|
||||
// --------------------------------------------------------------------
|
||||
procedure WriteMachineId();
|
||||
var
|
||||
MachineIdFile: string;
|
||||
begin
|
||||
MachineIdFile := ExpandConstant('{app}\machine_id.txt');
|
||||
if not SaveStringToFile(MachineIdFile, MachineIdValue, False) then
|
||||
MsgBox('Echec de l''ecriture de machine_id.txt', mbError, MB_OK);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Notifie le serveur de l'enrollment (best-effort, non bloquant)
|
||||
// POST vers {SERVER_URL}/agents/enroll avec les infos collaborateur
|
||||
// --------------------------------------------------------------------
|
||||
procedure NotifyServerEnrollment();
|
||||
var
|
||||
ResultCode: Integer;
|
||||
PsScript: string;
|
||||
PsFile: string;
|
||||
ServerUrl, Token: string;
|
||||
begin
|
||||
ServerUrl := Trim(TokenPage.Values[0]);
|
||||
Token := Trim(TokenPage.Values[1]);
|
||||
|
||||
PsFile := ExpandConstant('{tmp}\enroll.ps1');
|
||||
PsScript :=
|
||||
'$ErrorActionPreference = ''SilentlyContinue''' + #13#10 +
|
||||
'$body = @{' + #13#10 +
|
||||
' machine_id = ''' + MachineIdValue + '''' + #13#10 +
|
||||
' hostname = $env:COMPUTERNAME' + #13#10 +
|
||||
' user_name = ''' + EnrollmentPage.Values[0] + '''' + #13#10 +
|
||||
' user_email = ''' + EnrollmentPage.Values[1] + '''' + #13#10 +
|
||||
' user_id = ''' + EnrollmentPage.Values[2] + '''' + #13#10 +
|
||||
' agent_version = ''' + '{#MyAppVersion}' + '''' + #13#10 +
|
||||
'} | ConvertTo-Json' + #13#10 +
|
||||
'try {' + #13#10 +
|
||||
' Invoke-RestMethod -Uri ''' + ServerUrl + '/agents/enroll'' ' +
|
||||
'-Method POST -Body $body -ContentType ''application/json'' ' +
|
||||
'-Headers @{ Authorization = ''Bearer ' + Token + ''' } -TimeoutSec 10 | Out-Null' + #13#10 +
|
||||
'} catch { exit 0 }' + #13#10;
|
||||
|
||||
SaveStringToFile(PsFile, PsScript, False);
|
||||
Exec('powershell.exe',
|
||||
'-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(PsFile),
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
DeleteFile(PsFile);
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Hook : actions apres copie des fichiers (ssPostInstall)
|
||||
// --------------------------------------------------------------------
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
// Genere le machine_id AVANT la copie des fichiers
|
||||
MachineIdValue := GenerateMachineId();
|
||||
end;
|
||||
|
||||
if CurStep = ssPostInstall then
|
||||
begin
|
||||
// Ecrit config.txt et machine_id.txt
|
||||
WriteGeneratedConfig();
|
||||
WriteMachineId();
|
||||
// Notifie le serveur (best-effort)
|
||||
NotifyServerEnrollment();
|
||||
end;
|
||||
end;
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Desinstallation : proposer d'exporter les logs avant suppression
|
||||
// --------------------------------------------------------------------
|
||||
function InitializeUninstall(): Boolean;
|
||||
var
|
||||
LogDir, ExportDir: string;
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := True;
|
||||
LogDir := ExpandConstant('{app}\agent_v1\logs');
|
||||
|
||||
if DirExists(LogDir) then
|
||||
begin
|
||||
if MsgBox('Voulez-vous exporter les logs de Lea avant la desinstallation ?' + #13#10 +
|
||||
'(les logs seront copies dans votre dossier Documents)',
|
||||
mbConfirmation, MB_YESNO) = IDYES then
|
||||
begin
|
||||
ExportDir := ExpandConstant('{userdocs}\Lea_logs_export');
|
||||
ForceDirectories(ExportDir);
|
||||
Exec('powershell.exe',
|
||||
'-NoProfile -Command "Copy-Item -Path ' + AddQuotes(LogDir + '\*') +
|
||||
' -Destination ' + AddQuotes(ExportDir) + ' -Recurse -Force"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
MsgBox('Logs exportes dans : ' + ExportDir, mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
227
deploy/installer/README.md
Normal file
227
deploy/installer/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Installeur Lea (Inno Setup)
|
||||
|
||||
Installeur Windows professionnel pour Lea, remplacant le ZIP + `install.bat` artisanal.
|
||||
|
||||
## Resume
|
||||
|
||||
Produit `Lea-Setup-v1.0.0.exe` dans `deploy/releases/`.
|
||||
|
||||
Caracteristiques :
|
||||
- Interface francaise, moderne (style wizard Windows 10/11)
|
||||
- Page custom d'enrollment (nom, email, ID interne, URL serveur, token)
|
||||
- Generation automatique de `machine_id` unique (GUID + hash hostname)
|
||||
- `config.txt` genere a partir des donnees saisies
|
||||
- Option bundle Python 3.12 embedded (postes sans droits admin)
|
||||
- Raccourci demarrage automatique (`shell:startup`) optionnel
|
||||
- Notification serveur a l'install / desinstall (best-effort)
|
||||
- Installation silencieuse : `/VERYSILENT /CONFIG=enroll.txt`
|
||||
- Desinstallation propre : kill process, cleanup, export logs
|
||||
|
||||
## Pre-requis pour compiler
|
||||
|
||||
### Inno Setup 6.2+
|
||||
|
||||
Telecharger depuis [jrsoftware.org](https://jrsoftware.org/isinfo.php) et installer
|
||||
`innosetup-6.x.exe`. Le compilateur `ISCC.exe` doit etre accessible.
|
||||
|
||||
### Alternative Linux : Wine
|
||||
|
||||
```bash
|
||||
# Installation
|
||||
winetricks innosetup
|
||||
# Ou : telecharger innosetup-6.x.exe et lancer : wine innosetup-6.x.exe
|
||||
|
||||
# Verifier
|
||||
ls "$HOME/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe"
|
||||
```
|
||||
|
||||
Le script `build_installer.sh` detecte automatiquement Wine si present.
|
||||
|
||||
## Build local
|
||||
|
||||
### Build complet (staging + compilation)
|
||||
|
||||
```bash
|
||||
cd rpa_vision_v3
|
||||
./deploy/installer/build_installer.sh
|
||||
```
|
||||
|
||||
Produit `deploy/releases/Lea-Setup-v1.0.0.exe`.
|
||||
|
||||
### Build staging uniquement (sans ISCC)
|
||||
|
||||
```bash
|
||||
./deploy/installer/build_installer.sh --stage-only
|
||||
```
|
||||
|
||||
Prepare `deploy/build/installer_staging/` puis affiche la commande ISCC a executer
|
||||
sur Windows.
|
||||
|
||||
### Nettoyer avant
|
||||
|
||||
```bash
|
||||
./deploy/installer/build_installer.sh --clean
|
||||
```
|
||||
|
||||
## Build sur Windows (recommande pour production)
|
||||
|
||||
1. Copier le dossier `deploy/` sur le PC Windows
|
||||
2. Ouvrir `deploy/installer/Lea.iss` dans Inno Setup Compiler
|
||||
3. `Build > Compile` (ou F9)
|
||||
4. Recuperer `deploy/releases/Lea-Setup-v1.0.0.exe`
|
||||
|
||||
## Python 3.12 embedded (optionnel)
|
||||
|
||||
Pour bundler Python directement dans l'installeur (evite d'exiger que les postes
|
||||
aient Python installe) :
|
||||
|
||||
```bash
|
||||
# Sur Linux
|
||||
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/
|
||||
```
|
||||
|
||||
Le staging copie automatiquement ce dossier si present. Le composant
|
||||
"pythonembed" devient alors selectionnable dans l'installeur.
|
||||
|
||||
Le script `configure_embed.ps1` :
|
||||
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`
|
||||
|
||||
## Installation silencieuse (deploiement de masse)
|
||||
|
||||
Pour deployer sans interaction utilisateur (GPO, SCCM, script PowerShell) :
|
||||
|
||||
1. Preparer un fichier `enroll.txt` par poste (ou un commun) :
|
||||
|
||||
```
|
||||
USER_NAME=Jean Dupont
|
||||
USER_EMAIL=jean.dupont@aivanov.com
|
||||
USER_ID=EMP-00123
|
||||
SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
|
||||
API_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
2. Lancer l'installeur :
|
||||
|
||||
```cmd
|
||||
Lea-Setup-v1.0.0.exe /VERYSILENT /CONFIG=C:\temp\enroll.txt /DIR="C:\Lea"
|
||||
```
|
||||
|
||||
Parametres Inno Setup utiles :
|
||||
- `/VERYSILENT` : aucune UI
|
||||
- `/SILENT` : barre de progression seulement
|
||||
- `/DIR="..."` : dossier d'installation
|
||||
- `/LOG="install.log"` : log d'installation
|
||||
- `/TASKS="startmenuicon,autostart"` : composants a installer (voir `[Tasks]` et `[Components]`)
|
||||
- `/CONFIG=path` : fichier d'enrollment (custom, specifique a Lea)
|
||||
|
||||
## Signature du .exe (SmartScreen)
|
||||
|
||||
Sans signature, Windows SmartScreen affiche un avertissement rouge ("Cet editeur
|
||||
est inconnu"). Pour eviter cela, signer l'installeur avec un certificat
|
||||
code-signing.
|
||||
|
||||
### Options de certificat
|
||||
|
||||
1. **Certificat OV (Organization Validation)** : ~200-400 EUR/an
|
||||
- Sectigo, DigiCert, GlobalSign
|
||||
- SmartScreen apprend la reputation progressivement (~30 installations)
|
||||
- Livre sur token USB FIPS depuis 2023
|
||||
|
||||
2. **Certificat EV (Extended Validation)** : ~400-700 EUR/an
|
||||
- Reputation SmartScreen immediate (pas d'avertissement des la 1ere install)
|
||||
- Strict : obligatoirement sur token USB
|
||||
|
||||
### Signature manuelle (avec signtool.exe du Windows SDK)
|
||||
|
||||
```cmd
|
||||
signtool sign ^
|
||||
/tr http://timestamp.sectigo.com ^
|
||||
/td sha256 ^
|
||||
/fd sha256 ^
|
||||
/a ^
|
||||
"deploy\releases\Lea-Setup-v1.0.0.exe"
|
||||
|
||||
signtool verify /pa /v "deploy\releases\Lea-Setup-v1.0.0.exe"
|
||||
```
|
||||
|
||||
### Signature automatique dans Inno Setup
|
||||
|
||||
Ajouter dans `Lea.iss` apres `[Setup]` :
|
||||
|
||||
```
|
||||
SignTool=signtool $f
|
||||
```
|
||||
|
||||
Et declarer le signtool via `ISCC.exe /Ssigntool=...` au build.
|
||||
|
||||
### Solution interne (certif AIVANOV)
|
||||
|
||||
Si AIVANOV a deja un certificat code-signing, le token USB + mot de passe
|
||||
suffisent. Sinon, Sectigo OV est un bon choix d'entree de gamme.
|
||||
|
||||
## Structure du dossier installer/
|
||||
|
||||
```
|
||||
deploy/installer/
|
||||
├── Lea.iss # Script Inno Setup principal
|
||||
├── build_installer.sh # Helper bash (staging + ISCC)
|
||||
├── uninstall_lea.ps1 # Script de desinstallation propre
|
||||
├── configure_embed.ps1 # Configuration Python embedded
|
||||
├── config_template.txt # Modele config pour /VERYSILENT /CONFIG=
|
||||
├── LICENSE.txt # CGU affichees dans la page licence
|
||||
└── README.md # Ce fichier
|
||||
```
|
||||
|
||||
## Test de l'installeur
|
||||
|
||||
1. **Machine de test Windows 11** (VM ou PC physique, idealement sans Python)
|
||||
2. Copier `Lea-Setup-v1.0.0.exe` sur la machine
|
||||
3. Double-cliquer : verifier que l'enrollment s'affiche en francais
|
||||
4. Tester l'installation (avec et sans Python deja installe)
|
||||
5. Verifier le fichier `C:\Program Files\Lea\config.txt` genere
|
||||
6. Verifier le raccourci `shell:startup` (si option cochee)
|
||||
7. Lancer Lea, verifier la connexion au serveur
|
||||
8. Tester la desinstallation depuis "Ajout/suppression de programmes"
|
||||
|
||||
### Test automatise (PowerShell, sur la VM)
|
||||
|
||||
```powershell
|
||||
# Installation silencieuse
|
||||
$cfg = "C:\temp\enroll.txt"
|
||||
@"
|
||||
USER_NAME=Test Automatique
|
||||
USER_EMAIL=test@aivanov.com
|
||||
"@ | Out-File -Encoding ASCII $cfg
|
||||
|
||||
.\Lea-Setup-v1.0.0.exe /VERYSILENT /CONFIG=$cfg /LOG="C:\temp\install.log"
|
||||
|
||||
# Verifications
|
||||
Test-Path "C:\Program Files\Lea\config.txt"
|
||||
Get-Content "C:\Program Files\Lea\machine_id.txt"
|
||||
|
||||
# Desinstallation silencieuse
|
||||
$uninst = Get-WmiObject Win32_Product | Where-Object { $_.Name -like "Lea*" }
|
||||
$uninst.Uninstall()
|
||||
```
|
||||
|
||||
## Notes et limites connues
|
||||
|
||||
- **Endpoint serveur `/agents/enroll` et `/agents/uninstall` :** pas encore
|
||||
implemente cote serveur (avril 2026). L'installeur envoie la requete en
|
||||
best-effort, une erreur est silencieusement ignoree. A implementer dans
|
||||
`agent_v0/server_v1/api_stream.py` quand necessaire.
|
||||
- **Python embedded :** le patch `python312._pth` + pip bootstrap fonctionne mais
|
||||
augmente la taille de l'installeur (~25 MB). A reserver aux postes sans
|
||||
Python.
|
||||
- **Code signing :** indispensable pour deploiement hopital/client. Prevoir le
|
||||
budget certificat (400-700 EUR/an) dans la roadmap commerciale.
|
||||
|
||||
## Historique
|
||||
|
||||
- v1.0.0 (2026-04-13) : Premiere version de l'installeur Inno Setup.
|
||||
220
deploy/installer/build_installer.sh
Executable file
220
deploy/installer/build_installer.sh
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# build_installer.sh — Prepare le staging et invoque ISCC
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
# Ce script :
|
||||
# 1. Invoque build_package.sh pour generer le package classique
|
||||
# 2. Copie le package dans deploy/build/installer_staging/
|
||||
# 3. Copie les helpers de l'installeur (uninstall, licence)
|
||||
# 4. Appelle Inno Setup (ISCC.exe) si disponible
|
||||
# (sinon, affiche les instructions pour compiler sous Windows)
|
||||
#
|
||||
# Usage :
|
||||
# ./deploy/installer/build_installer.sh # Build complet
|
||||
# ./deploy/installer/build_installer.sh --stage-only # Prepare le staging uniquement
|
||||
# ./deploy/installer/build_installer.sh --clean # Nettoyer avant
|
||||
#
|
||||
# Pre-requis :
|
||||
# - bash, rsync, zip (pour le package de base)
|
||||
# - Inno Setup 6.2+ installe (Windows ou Wine) pour compiler
|
||||
#
|
||||
# Sur Linux, ISCC.exe peut etre execute via Wine :
|
||||
# wine "/home/dom/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe" Lea.iss
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PROJECT_ROOT="$(dirname "$DEPLOY_DIR")"
|
||||
|
||||
STAGING_DIR="$DEPLOY_DIR/build/installer_staging"
|
||||
RELEASES_DIR="$DEPLOY_DIR/releases"
|
||||
BASE_BUILD_DIR="$DEPLOY_DIR/build/Lea"
|
||||
|
||||
# Recupere la version depuis config.py
|
||||
VERSION=$(grep -oP 'AGENT_VERSION\s*=\s*"([^"]+)"' "$PROJECT_ROOT/agent_v0/agent_v1/config.py" | grep -oP '"[^"]+"' | tr -d '"' || echo "1.0.0")
|
||||
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} Build installeur Inno Setup Lea v${VERSION}${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Parsing des arguments
|
||||
# ---------------------------------------------------------------
|
||||
STAGE_ONLY=0
|
||||
CLEAN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--stage-only) STAGE_ONLY=1 ;;
|
||||
--clean) CLEAN=1 ;;
|
||||
*) echo "Argument inconnu : $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Clean optionnel
|
||||
# ---------------------------------------------------------------
|
||||
if [[ $CLEAN -eq 1 ]]; then
|
||||
echo -e "${YELLOW}[0/5] Nettoyage des anciens builds...${NC}"
|
||||
rm -rf "$STAGING_DIR"
|
||||
rm -rf "$BASE_BUILD_DIR"
|
||||
rm -f "$RELEASES_DIR"/Lea-Setup-*.exe
|
||||
echo " OK"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Build du package de base (reutilise build_package.sh)
|
||||
# ---------------------------------------------------------------
|
||||
echo "[1/5] Build du package de base..."
|
||||
if [[ ! -d "$BASE_BUILD_DIR" ]] || [[ $CLEAN -eq 1 ]]; then
|
||||
bash "$DEPLOY_DIR/build_package.sh" >/dev/null
|
||||
fi
|
||||
if [[ ! -d "$BASE_BUILD_DIR" ]]; then
|
||||
echo -e "${RED} Erreur : $BASE_BUILD_DIR n'a pas ete cree par build_package.sh${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo " Package de base pret : $BASE_BUILD_DIR"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Copie vers staging
|
||||
# ---------------------------------------------------------------
|
||||
echo "[2/5] Preparation du staging installeur..."
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
|
||||
# Copie tout sauf config.txt (genere par l'installeur) et install.bat
|
||||
# install.bat est conserve mais sera appele en mode silencieux par ISS
|
||||
rsync -a \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.venv' \
|
||||
--exclude='sessions/' \
|
||||
--exclude='logs/' \
|
||||
"$BASE_BUILD_DIR/" \
|
||||
"$STAGING_DIR/"
|
||||
|
||||
# On supprime le config.txt du staging : c'est l'installeur qui le generera
|
||||
rm -f "$STAGING_DIR/config.txt"
|
||||
|
||||
echo " Staging : $STAGING_DIR"
|
||||
echo " Fichiers : $(find "$STAGING_DIR" -type f | wc -l)"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Copie des helpers installeur (uninstall, licence, etc.)
|
||||
# ---------------------------------------------------------------
|
||||
echo "[3/5] Copie des helpers installeur..."
|
||||
cp "$SCRIPT_DIR/uninstall_lea.ps1" "$STAGING_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/configure_embed.ps1" "$STAGING_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/LICENSE.txt" "$STAGING_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/config_template.txt" "$STAGING_DIR/config_template.txt" 2>/dev/null || true
|
||||
echo " Helpers copies"
|
||||
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'"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Stage-only : on s'arrete ici
|
||||
# ---------------------------------------------------------------
|
||||
if [[ $STAGE_ONLY -eq 1 ]]; then
|
||||
echo -e "${GREEN} Staging pret. Utiliser ISCC pour compiler :${NC}"
|
||||
echo " ISCC.exe \"$SCRIPT_DIR/Lea.iss\""
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Compilation avec ISCC (si disponible)
|
||||
# ---------------------------------------------------------------
|
||||
echo "[5/5] Compilation Inno Setup..."
|
||||
|
||||
# Chercher ISCC : natif Linux (rare), Wine, ou WSL
|
||||
ISCC_BIN=""
|
||||
if command -v iscc >/dev/null 2>&1; then
|
||||
ISCC_BIN="iscc"
|
||||
elif command -v ISCC.exe >/dev/null 2>&1; then
|
||||
ISCC_BIN="ISCC.exe"
|
||||
elif command -v wine >/dev/null 2>&1; then
|
||||
# Chemins Wine courants
|
||||
for path in \
|
||||
"$HOME/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe" \
|
||||
"$HOME/.wine/drive_c/Program Files/Inno Setup 6/ISCC.exe"; do
|
||||
if [[ -f "$path" ]]; then
|
||||
ISCC_BIN="wine \"$path\""
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$ISCC_BIN" ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW} ISCC (Inno Setup Compiler) introuvable.${NC}"
|
||||
echo ""
|
||||
echo " Le staging est pret dans : $STAGING_DIR"
|
||||
echo ""
|
||||
echo " Pour compiler l'installeur, deux options :"
|
||||
echo ""
|
||||
echo " 1) Sur un PC Windows avec Inno Setup 6 installe :"
|
||||
echo " - Copier le dossier deploy/ sur le PC"
|
||||
echo " - Ouvrir deploy/installer/Lea.iss dans Inno Setup"
|
||||
echo " - Cliquer 'Compile' (F9)"
|
||||
echo " - Recuperer deploy/releases/Lea-Setup-v${VERSION}.exe"
|
||||
echo ""
|
||||
echo " 2) Sur Linux avec Wine :"
|
||||
echo " - winetricks innosetup (ou installer le .exe manuellement)"
|
||||
echo " - wine \"\$HOME/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe\" \\"
|
||||
echo " \"$SCRIPT_DIR/Lea.iss\""
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " ISCC trouve : $ISCC_BIN"
|
||||
eval "$ISCC_BIN \"$SCRIPT_DIR/Lea.iss\""
|
||||
|
||||
# Verification du resultat
|
||||
OUTPUT_EXE="$RELEASES_DIR/Lea-Setup-v${VERSION}.exe"
|
||||
if [[ -f "$OUTPUT_EXE" ]]; then
|
||||
EXE_SIZE=$(du -h "$OUTPUT_EXE" | cut -f1)
|
||||
echo ""
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} Installeur produit !${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo ""
|
||||
echo " Fichier : $OUTPUT_EXE"
|
||||
echo " Taille : $EXE_SIZE"
|
||||
echo ""
|
||||
echo " Deploiement :"
|
||||
echo " - Signer le .exe avec un certificat code-signing (voir README.md)"
|
||||
echo " - Publier sur : https://lea.labs.laurinebazin.design/downloads/"
|
||||
echo " - Installation silencieuse : Lea-Setup-v${VERSION}.exe /VERYSILENT /CONFIG=enroll.txt"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${RED} Erreur : $OUTPUT_EXE n'a pas ete produit${NC}"
|
||||
exit 1
|
||||
fi
|
||||
27
deploy/installer/config_template.txt
Normal file
27
deploy/installer/config_template.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
# ============================================================
|
||||
# config_template.txt — Modele pour installation silencieuse
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
# Ce fichier est utilise en mode /VERYSILENT pour pre-remplir
|
||||
# les valeurs d'enrollment sans interface graphique.
|
||||
#
|
||||
# Usage :
|
||||
# Lea-Setup-v1.0.0.exe /VERYSILENT /CONFIG=enroll.txt
|
||||
#
|
||||
# L'installeur lit ce fichier au demarrage et remplit les pages
|
||||
# custom (nom, email, ID, URL, token) automatiquement.
|
||||
#
|
||||
# Toutes les cles ci-dessous sont optionnelles. Si une cle est
|
||||
# absente, la valeur par defaut de l'installeur est utilisee.
|
||||
#
|
||||
# Format : CLE=valeur, une ligne par parametre, # = commentaire.
|
||||
# ============================================================
|
||||
|
||||
# Identite du collaborateur (obligatoires sauf USER_ID)
|
||||
USER_NAME=Prenom Nom
|
||||
USER_EMAIL=prenom.nom@aivanov.com
|
||||
USER_ID=
|
||||
|
||||
# Connexion serveur (valeurs par defaut deja pre-remplies)
|
||||
SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
|
||||
API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
|
||||
112
deploy/installer/configure_embed.ps1
Normal file
112
deploy/installer/configure_embed.ps1
Normal file
@@ -0,0 +1,112 @@
|
||||
# ============================================================
|
||||
# configure_embed.ps1 — Configure le runtime Python embedded
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
# Quand le composant 'pythonembed' est installe, on a :
|
||||
# <AppDir>\python-embed\ <-- runtime Python 3.12 embedded
|
||||
#
|
||||
# Ce script :
|
||||
# 1. Active l'import des packages (patch de python312._pth)
|
||||
# 2. Installe pip dans le runtime embedded
|
||||
# 3. Installe les dependances requirements_agent.txt
|
||||
# 4. Reecrit Lea.bat pour pointer sur python-embed\pythonw.exe
|
||||
#
|
||||
# Doit etre execute avec le CWD = <AppDir>
|
||||
# ============================================================
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$AppDir = Get-Location
|
||||
$EmbedDir = Join-Path $AppDir "python-embed"
|
||||
$PythonExe = Join-Path $EmbedDir "python.exe"
|
||||
|
||||
if (-not (Test-Path $PythonExe)) {
|
||||
Write-Host "Python embedded introuvable, abandon."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Configuration de Python embedded..."
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Decommenter la ligne 'import site' dans python312._pth
|
||||
# (necessaire pour que pip puisse fonctionner)
|
||||
# ---------------------------------------------------------------
|
||||
$PthFile = Get-ChildItem -Path $EmbedDir -Filter "python*._pth" | Select-Object -First 1
|
||||
if ($PthFile) {
|
||||
$Content = Get-Content $PthFile.FullName
|
||||
$NewContent = $Content -replace '^#import site', 'import site'
|
||||
Set-Content -Path $PthFile.FullName -Value $NewContent
|
||||
Write-Host " python._pth patche (import site active)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Installer pip (bootstrap via get-pip.py)
|
||||
# ---------------------------------------------------------------
|
||||
$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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Reecrire Lea.bat pour utiliser python-embed
|
||||
# ---------------------------------------------------------------
|
||||
$LeaBat = Join-Path $AppDir "Lea.bat"
|
||||
$NewLeaBat = @"
|
||||
@echo off
|
||||
chcp 65001 >nul 2>&1
|
||||
title Lea - Assistante IA
|
||||
cd /d "%~dp0"
|
||||
|
||||
if exist "lea_agent.lock" (
|
||||
for /f "usebackq tokens=* delims=" %%i in ("lea_agent.lock") do (
|
||||
taskkill /F /PID %%i >nul 2>&1
|
||||
)
|
||||
del /f /q "lea_agent.lock" >nul 2>&1
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Demarrage de Lea (runtime embedded)...
|
||||
echo.
|
||||
|
||||
start "" /b "%~dp0python-embed\pythonw.exe" run_agent_v1.py
|
||||
|
||||
timeout /t 3 >nul
|
||||
set "LEA_ALIVE=0"
|
||||
if exist "lea_agent.lock" (
|
||||
for /f "usebackq tokens=* delims=" %%i in ("lea_agent.lock") do (
|
||||
tasklist /FI "PID eq %%i" /NH 2>nul | findstr /I "pythonw" >nul && set "LEA_ALIVE=1"
|
||||
)
|
||||
)
|
||||
if "%LEA_ALIVE%"=="0" (
|
||||
echo.
|
||||
echo Lea n'a pas demarre correctement. Affichage des erreurs :
|
||||
echo.
|
||||
"%~dp0python-embed\python.exe" run_agent_v1.py
|
||||
pause
|
||||
)
|
||||
"@
|
||||
|
||||
Set-Content -Path $LeaBat -Value $NewLeaBat -Encoding ASCII
|
||||
Write-Host " Lea.bat reecrit pour runtime embedded"
|
||||
|
||||
Write-Host "Configuration terminee."
|
||||
exit 0
|
||||
99
deploy/installer/uninstall_lea.ps1
Normal file
99
deploy/installer/uninstall_lea.ps1
Normal file
@@ -0,0 +1,99 @@
|
||||
# ============================================================
|
||||
# uninstall_lea.ps1 — Script de desinstallation propre de Lea
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
# Appele par Inno Setup via [UninstallRun] AVANT la suppression
|
||||
# des fichiers. Roles :
|
||||
#
|
||||
# 1. Tuer proprement le process Lea (via PID du lock)
|
||||
# 2. Nettoyer shell:startup (supprimer le raccourci auto-start)
|
||||
# 3. Notifier le serveur de la desinstallation (best-effort)
|
||||
# 4. Supprimer le lock file
|
||||
#
|
||||
# Usage (par Inno Setup) :
|
||||
# powershell.exe -NoProfile -ExecutionPolicy Bypass \
|
||||
# -File uninstall_lea.ps1 -AppDir "C:\Program Files\Lea"
|
||||
# ============================================================
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Write-Host "Desinstallation de Lea en cours..."
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Tuer le process via PID du lock file
|
||||
# ---------------------------------------------------------------
|
||||
$LockFile = Join-Path $AppDir "lea_agent.lock"
|
||||
if (Test-Path $LockFile) {
|
||||
try {
|
||||
$Pid = (Get-Content $LockFile -ErrorAction Stop | Select-Object -First 1).Trim()
|
||||
if ($Pid -match '^\d+$') {
|
||||
Write-Host " Arret du process Lea (PID $Pid)..."
|
||||
Stop-Process -Id ([int]$Pid) -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
} catch {
|
||||
Write-Host " Lock file illisible (ignore)."
|
||||
}
|
||||
Remove-Item $LockFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Nettoyer shell:startup (peut ne pas exister si composant
|
||||
# non installe, on le supprime silencieusement dans tous les cas)
|
||||
# ---------------------------------------------------------------
|
||||
$StartupDir = [Environment]::GetFolderPath('Startup')
|
||||
$StartupShortcut = Join-Path $StartupDir "Lea.lnk"
|
||||
if (Test-Path $StartupShortcut) {
|
||||
Write-Host " Suppression du raccourci auto-start..."
|
||||
Remove-Item $StartupShortcut -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Notifier le serveur de la desinstallation (best-effort)
|
||||
# ---------------------------------------------------------------
|
||||
$ConfigFile = Join-Path $AppDir "config.txt"
|
||||
$MachineIdFile = Join-Path $AppDir "machine_id.txt"
|
||||
if ((Test-Path $ConfigFile) -and (Test-Path $MachineIdFile)) {
|
||||
try {
|
||||
$ConfigLines = Get-Content $ConfigFile
|
||||
$ServerUrl = ($ConfigLines | Where-Object { $_ -match '^RPA_SERVER_URL=' } | Select-Object -First 1) -replace '^RPA_SERVER_URL=', ''
|
||||
$Token = ($ConfigLines | Where-Object { $_ -match '^RPA_API_TOKEN=' } | Select-Object -First 1) -replace '^RPA_API_TOKEN=', ''
|
||||
$MachineId = (Get-Content $MachineIdFile -ErrorAction Stop | Select-Object -First 1).Trim()
|
||||
|
||||
if ($ServerUrl -and $Token -and $MachineId) {
|
||||
Write-Host " Notification du serveur..."
|
||||
$Body = @{
|
||||
machine_id = $MachineId
|
||||
hostname = $env:COMPUTERNAME
|
||||
event = 'uninstall'
|
||||
timestamp = (Get-Date -Format "o")
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "$ServerUrl/agents/uninstall" `
|
||||
-Method POST `
|
||||
-Body $Body `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ Authorization = "Bearer $Token" } `
|
||||
-TimeoutSec 5 `
|
||||
-ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
} catch {
|
||||
# Best-effort : on ignore toute erreur reseau/auth
|
||||
Write-Host " Notification serveur echouee (ignore)."
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Supprimer les fichiers restants verrouilles eventuellement
|
||||
# ---------------------------------------------------------------
|
||||
Start-Sleep -Seconds 1
|
||||
Get-ChildItem -Path $AppDir -Filter "*.pyc" -Recurse -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "Desinstallation : pre-traitement termine."
|
||||
exit 0
|
||||
@@ -8,12 +8,17 @@ title Lea - Assistante IA
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: Fermer les anciennes instances de Lea
|
||||
:: Fermer l'ancienne instance de Lea (UNIQUEMENT via le PID du lock)
|
||||
:: NE JAMAIS tuer tous les pythonw.exe/python.exe du poste :
|
||||
:: cela tuerait Jupyter, Spyder, Anaconda, scripts metier, etc.
|
||||
:: ---------------------------------------------------------------
|
||||
taskkill /F /IM pythonw.exe >nul 2>&1
|
||||
taskkill /F /IM python.exe >nul 2>&1
|
||||
taskkill /F /IM rpa-agent.exe >nul 2>&1
|
||||
timeout /t 2 >nul
|
||||
if exist "lea_agent.lock" (
|
||||
for /f "usebackq tokens=* delims=" %%i in ("lea_agent.lock") do (
|
||||
taskkill /F /PID %%i >nul 2>&1
|
||||
)
|
||||
del /f /q "lea_agent.lock" >nul 2>&1
|
||||
timeout /t 2 >nul
|
||||
)
|
||||
|
||||
:: ---------------------------------------------------------------
|
||||
:: Verifier que l'installation a ete faite
|
||||
@@ -53,10 +58,15 @@ echo.
|
||||
|
||||
start "" /b .venv\Scripts\pythonw.exe run_agent_v1.py
|
||||
|
||||
:: Attendre 3s puis verifier que Lea tourne
|
||||
:: Attendre 3s puis verifier que Lea tourne (via le PID du lock)
|
||||
timeout /t 3 >nul
|
||||
tasklist /FI "IMAGENAME eq pythonw.exe" /NH 2>nul | findstr /I "pythonw" >nul
|
||||
if errorlevel 1 (
|
||||
set "LEA_ALIVE=0"
|
||||
if exist "lea_agent.lock" (
|
||||
for /f "usebackq tokens=* delims=" %%i in ("lea_agent.lock") do (
|
||||
tasklist /FI "PID eq %%i" /NH 2>nul | findstr /I "pythonw" >nul && set "LEA_ALIVE=1"
|
||||
)
|
||||
)
|
||||
if "%LEA_ALIVE%"=="0" (
|
||||
echo.
|
||||
echo Lea n'a pas demarre correctement.
|
||||
echo Tentative avec affichage des erreurs...
|
||||
|
||||
@@ -23,9 +23,21 @@ RPA_SERVER_HOST=lea.labs.laurinebazin.design
|
||||
# Parametres avances (ne pas modifier sauf indication)
|
||||
# ============================================================
|
||||
|
||||
# Flouter les zones de texte dans les captures (securite donnees)
|
||||
# Mettre false uniquement pour le developpement/tests
|
||||
RPA_BLUR_SENSITIVE=true
|
||||
# Flouter les zones de texte dans les captures cote CLIENT.
|
||||
#
|
||||
# DEPUIS AVRIL 2026 : LE BLUR CLIENT EST DESACTIVE PAR DEFAUT.
|
||||
# Le floutage des donnees sensibles (noms, adresses, telephones, NIR, email)
|
||||
# est desormais effectue cote SERVEUR via EDS-NLP + OCR dans le module
|
||||
# core/anonymisation/pii_blur.py.
|
||||
#
|
||||
# Avantages du blur server-side :
|
||||
# - Cible precisement les PII (PERSON/LOCATION/PHONE/NIR/EMAIL)
|
||||
# - Ne casse plus les codes CIM, montants PMSI, identifiants techniques
|
||||
# - Deux versions stockees : _raw (entrainement) + _blurred (affichage)
|
||||
#
|
||||
# Ne remettre a 'true' que si un deploiement specifique l'exige explicitement
|
||||
# (ex : reseau non chiffre entre agent et serveur).
|
||||
RPA_BLUR_SENSITIVE=false
|
||||
|
||||
# Duree de conservation des logs en jours (minimum 180 pour conformite)
|
||||
RPA_LOG_RETENTION_DAYS=180
|
||||
|
||||
42
deploy/systemd/rpa-session-cleaner.service
Normal file
42
deploy/systemd/rpa-session-cleaner.service
Normal file
@@ -0,0 +1,42 @@
|
||||
[Unit]
|
||||
Description=RPA Vision V3 - Session Cleaner (port 5006)
|
||||
Documentation=https://lea.labs.laurinebazin.design
|
||||
After=network-online.target rpa-streaming.service
|
||||
Wants=network-online.target
|
||||
Requires=rpa-streaming.service
|
||||
PartOf=rpa-vision.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# ---- Runtime ----
|
||||
User=dom
|
||||
Group=dom
|
||||
WorkingDirectory=/home/dom/ai/rpa_vision_v3
|
||||
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
Environment="RPA_SERVICE_NAME=rpa-session-cleaner"
|
||||
|
||||
# Lancement du session cleaner (dépend du streaming server port 5005)
|
||||
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 tools/session_cleaner.py
|
||||
|
||||
# ---- Resilience ----
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=15
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
||||
# ---- Hardening ----
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Logs -> journald
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=rpa-session-cleaner
|
||||
|
||||
[Install]
|
||||
WantedBy=rpa-vision.target
|
||||
7
deploy/systemd/rpa-vision.target
Normal file
7
deploy/systemd/rpa-vision.target
Normal file
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=RPA Vision V3 - Tous les services
|
||||
After=network-online.target
|
||||
Wants=rpa-streaming.service rpa-vision-v3-api.service rpa-vision-v3-dashboard.service rpa-vision-v3-worker.service rpa-session-cleaner.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
227
docs/CI_SETUP.md
Normal file
227
docs/CI_SETUP.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# CI Setup — Gitea Actions pour RPA Vision V3
|
||||
|
||||
> **Statut** : CI activée le 15 avril 2026. Runner `dom-local-runner` (systemd) enregistré.
|
||||
|
||||
Ce document décrit la CI minimale mise en place sur `gitea.localhost:3100`
|
||||
pour prévenir les régressions silencieuses sur `main` et les PR.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Deux workflows Gitea Actions (syntaxe compatible GitHub Actions) :
|
||||
|
||||
| Workflow | Fichier | Déclencheur | Bloquant |
|
||||
|----------------------------------|---------------------------------------|------------------------------|----------|
|
||||
| Tests | `.gitea/workflows/tests.yml` | push + PR (toutes branches) | Oui (unit + security) |
|
||||
| Audit sécurité | `.gitea/workflows/security-audit.yml` | push main + cron hebdo | Non |
|
||||
|
||||
### Jobs du workflow `tests`
|
||||
|
||||
1. **lint** (non bloquant) — `ruff` + `black --check` sur `core/`, `agent_v0/`, `tests/`.
|
||||
2. **unit-tests** (bloquant) — `pytest tests/unit/` avec `-m "not slow and not gpu and not integration and not performance and not visual"`.
|
||||
3. **security-tests** (bloquant) — `pytest tests/unit/test_security_*.py` en mode verbose. Dépend de `unit-tests`.
|
||||
|
||||
### Jobs du workflow `security-audit`
|
||||
|
||||
1. **bandit** — scan statique sur `core/` (asserts ignorés).
|
||||
2. **pip-audit** — détection CVE sur `requirements-ci.txt` et `requirements.txt`.
|
||||
3. **secrets-scan** — `grep` pour patterns `sk-ant-`, `sk-proj-`, `AIzaSy`, `AKIA`, `hf_`.
|
||||
|
||||
Aucun de ces jobs ne casse la CI — ils produisent des artefacts consultables.
|
||||
|
||||
## Activation de Gitea Actions
|
||||
|
||||
Gitea Actions n'est pas actif par défaut. Deux étapes :
|
||||
|
||||
### 1. Activer Actions dans Gitea
|
||||
|
||||
Sur `http://localhost:3100`, éditer `/home/dom/Install_base/docker-compose.yml`
|
||||
(ou le `app.ini` monté dans le conteneur Gitea) et ajouter :
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
DEFAULT_ACTIONS_URL = https://github.com
|
||||
```
|
||||
|
||||
Puis redémarrer Gitea :
|
||||
|
||||
```bash
|
||||
cd /home/dom/Install_base
|
||||
docker compose restart gitea
|
||||
```
|
||||
|
||||
Vérifier : dans l'UI Gitea → `Site Administration` → `Configuration Summary`
|
||||
→ la section `[actions]` doit afficher `enabled: true`.
|
||||
|
||||
Côté dépôt : `Settings` → `Advanced Settings` → cocher **"Enable Repository Actions"**.
|
||||
|
||||
### 2. Installer et enregistrer un runner local
|
||||
|
||||
Gitea a besoin d'un `act_runner` (fork de nektos/act) pour exécuter les jobs.
|
||||
|
||||
```bash
|
||||
# Téléchargement du runner (Linux amd64)
|
||||
cd /home/dom/Install_base
|
||||
mkdir -p gitea_runner && cd gitea_runner
|
||||
wget https://dl.gitea.com/act_runner/0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner
|
||||
chmod +x act_runner
|
||||
|
||||
# Génération de la config
|
||||
./act_runner generate-config > config.yaml
|
||||
|
||||
# Récupération du token d'enregistrement
|
||||
# Site Administration → Actions → Runners → Create new Runner
|
||||
# (ou pour un runner par-dépôt : Settings du dépôt → Actions → Runners)
|
||||
|
||||
# Enregistrement (interactif)
|
||||
./act_runner register --no-interactive \
|
||||
--instance http://localhost:3100 \
|
||||
--token <TOKEN_COPIE_DEPUIS_GITEA> \
|
||||
--name "runner-local-cpu" \
|
||||
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
|
||||
|
||||
# Lancement en daemon
|
||||
nohup ./act_runner daemon --config config.yaml > runner.log 2>&1 &
|
||||
```
|
||||
|
||||
Pour persister au reboot : créer un service systemd
|
||||
(cf. `~/ai/rpa_vision_v3/deploy/systemd/` pour un modèle).
|
||||
|
||||
**Note** : le label `ubuntu-latest` pointe sur une image Docker légère
|
||||
(`catthehacker/ubuntu:act-22.04`, ~300 Mo) qui suffit pour nos jobs Python.
|
||||
|
||||
### 3. Premier test
|
||||
|
||||
```bash
|
||||
cd /home/dom/ai/rpa_vision_v3
|
||||
# Modification triviale
|
||||
echo "" >> README.md
|
||||
git add README.md
|
||||
git commit -m "chore: trigger CI"
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
Dans l'UI Gitea → onglet `Actions` du dépôt, le workflow doit apparaître
|
||||
et passer en ~2 minutes.
|
||||
|
||||
## Lancer les tests localement avant push
|
||||
|
||||
Identique à la CI :
|
||||
|
||||
```bash
|
||||
cd /home/dom/ai/rpa_vision_v3
|
||||
source .venv/bin/activate
|
||||
|
||||
# Tests unitaires (hors slow/gpu/integration) — ~60s
|
||||
pytest tests/unit/ -m "not slow and not gpu and not integration and not performance and not visual" -q
|
||||
|
||||
# Tests sécurité seulement — ~5s
|
||||
pytest tests/unit/test_security_*.py -v
|
||||
|
||||
# Lint (si installé)
|
||||
ruff check --select=E9,F63,F7,F82 core/ agent_v0/ tests/
|
||||
black --check core/ agent_v0/ tests/
|
||||
```
|
||||
|
||||
Ou via Makefile :
|
||||
|
||||
```bash
|
||||
make test-fast # équivalent à "not slow"
|
||||
make check # validate-imports + test-fast
|
||||
```
|
||||
|
||||
## Désactiver temporairement la CI (merge urgent)
|
||||
|
||||
Trois options, de la plus propre à la plus brutale :
|
||||
|
||||
### Option 1 — Skip via message de commit (recommandé)
|
||||
|
||||
Préfixer le message avec `[skip ci]` ou `[ci skip]` :
|
||||
|
||||
```bash
|
||||
git commit -m "fix: hotfix prod [skip ci]"
|
||||
```
|
||||
|
||||
Gitea Actions respecte cette convention.
|
||||
|
||||
### Option 2 — Désactiver le workflow côté dépôt
|
||||
|
||||
Dans l'UI Gitea → dépôt → `Actions` → sélectionner le workflow → bouton
|
||||
**"Disable workflow"**. Réactivable au même endroit.
|
||||
|
||||
### Option 3 — Renommer le fichier
|
||||
|
||||
```bash
|
||||
mv .gitea/workflows/tests.yml .gitea/workflows/tests.yml.disabled
|
||||
git commit -am "chore: disable CI temporarily"
|
||||
```
|
||||
|
||||
Ne **jamais** supprimer le fichier — ça rend le rollback pénible.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- **Pas de tests `slow` / `gpu` / `integration`** en CI. Ces tests nécessitent
|
||||
CUDA, Ollama (port 11434), ou des captures d'écran réelles. Ils doivent
|
||||
être lancés manuellement sur la machine de dev avant un tag de release.
|
||||
- **Pas de tests E2E `smoke`** (`tests/smoke/`) — nécessitent le serveur
|
||||
complet (ports 5005, 5001, 5002, 3002).
|
||||
- **Pas de tests `visual`** (`tests/visual/`) — nécessitent le serveur GPU.
|
||||
- **Runner unique** : tant qu'il n'y a qu'un `act_runner` enregistré,
|
||||
les jobs s'exécutent en série. Acceptable pour < 10 builds/jour.
|
||||
- **Pas de `torch` en CI** : si un test unitaire importe `torch` directement
|
||||
(sans lazy import), il échouera. Convention : les imports GPU doivent
|
||||
être dans `try/except ImportError` + marqueur `@pytest.mark.gpu`.
|
||||
- **`requirements-ci.txt` à resynchroniser** : quand une dépendance est
|
||||
ajoutée à `requirements.txt` et utilisée par un test unitaire, penser
|
||||
à l'ajouter aussi à `requirements-ci.txt`.
|
||||
|
||||
## Temps d'exécution estimé
|
||||
|
||||
| Job | Cold (sans cache pip) | Warm (cache pip) |
|
||||
|-----------------|----------------------|------------------|
|
||||
| lint | ~40s | ~15s |
|
||||
| unit-tests | ~2m30 | ~1m15 |
|
||||
| security-tests | ~1m | ~30s |
|
||||
| **Total CI** | **~3m** | **~1m30** |
|
||||
|
||||
Le cache pip est géré automatiquement par `actions/setup-python@v5`
|
||||
via la clé `requirements-ci.txt` + `requirements.txt`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Le workflow ne se déclenche pas
|
||||
|
||||
1. Vérifier que `[actions]` est actif dans `app.ini` Gitea.
|
||||
2. Vérifier que le runner est bien enregistré : `Site Administration` → `Actions` → `Runners`.
|
||||
3. Le runner doit être `Online` (point vert).
|
||||
4. Le dépôt doit avoir Actions activées dans ses paramètres.
|
||||
|
||||
### Erreur "No runner available"
|
||||
|
||||
Le runner est stoppé ou a un label incompatible. Relancer :
|
||||
|
||||
```bash
|
||||
cd /home/dom/Install_base/gitea_runner
|
||||
ps aux | grep act_runner # vérifier s'il tourne
|
||||
tail -f runner.log # voir les erreurs
|
||||
```
|
||||
|
||||
### Timeout sur `pip install`
|
||||
|
||||
`requirements.txt` contient torch + CUDA (~3 Go). Si la CI tombe sur
|
||||
`requirements.txt` au lieu de `requirements-ci.txt`, vérifier que le
|
||||
fichier léger est bien committé à la racine du repo.
|
||||
|
||||
### Tests passent en local mais échouent en CI
|
||||
|
||||
Diff le plus fréquent :
|
||||
- Variables d'environnement (`.env.local` absent en CI → tester avec `unset` en local).
|
||||
- Ports déjà pris par `svc.sh` en local mais libres en CI (→ OK).
|
||||
- Paths absolus hardcodés (`/home/dom/...`) → utiliser `pathlib` + fixtures.
|
||||
|
||||
## Évolutions possibles
|
||||
|
||||
- Ajouter un job `type-check` avec `mypy core/` (actuellement dans `requirements.txt` mais pas en CI — choix délibéré : trop lent et 200+ erreurs à nettoyer d'abord).
|
||||
- Ajouter un job `coverage` avec seuil minimum (ex: 60%).
|
||||
- Brancher les résultats sur un badge README via `gitea-actions-status`.
|
||||
- Pour les PR : bloquer le merge tant que `unit-tests` + `security-tests` ne passent pas (réglable dans `Settings` → `Branches` → `Branch protection rules`).
|
||||
107
docs/DEV_SETUP.md
Normal file
107
docs/DEV_SETUP.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# DEV_SETUP — Guide développeur
|
||||
|
||||
Ce document recense les tâches d'administration du dépôt qui ne sont pas couvertes
|
||||
par `README.md` (destiné aux utilisateurs) mais nécessaires au quotidien.
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [Environnement Python](#environnement-python)
|
||||
- [Services locaux](#services-locaux)
|
||||
- [Worktrees Claude Code](#worktrees-claude-code)
|
||||
- [Build du package Windows](#build-du-package-windows)
|
||||
|
||||
---
|
||||
|
||||
## Environnement Python
|
||||
|
||||
- Venv du projet : `.venv/` (à la racine du repo)
|
||||
- Python supporté : 3.10 à 3.12
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Services locaux
|
||||
|
||||
Utiliser `./svc.sh` pour piloter tous les services. La carte des ports est
|
||||
dans `services.conf`.
|
||||
|
||||
```bash
|
||||
./svc.sh status # État de tous les services
|
||||
./svc.sh start streaming # Démarrer le serveur Agent V1 (port 5005)
|
||||
./svc.sh restart api # Redémarrer l'API (port 8000)
|
||||
./svc.sh stop # Tout arrêter
|
||||
```
|
||||
|
||||
## Worktrees Claude Code
|
||||
|
||||
La CLI Claude Code peut créer des worktrees git dans `.claude/worktrees/` pour
|
||||
exécuter des agents parallèles sur des branches isolées. Ces dossiers peuvent
|
||||
occuper plusieurs centaines de Mo chacun et polluer les grep.
|
||||
|
||||
### Vérifier l'état des worktrees
|
||||
|
||||
```bash
|
||||
# Worktrees actifs vs branches git
|
||||
git worktree list
|
||||
git branch | grep worktree
|
||||
|
||||
# Espace disque consommé
|
||||
du -sh .claude/worktrees/* 2>/dev/null
|
||||
```
|
||||
|
||||
### Supprimer un worktree proprement
|
||||
|
||||
```bash
|
||||
# 1) Retirer l'entrée git (libère le lock dans .git/worktrees/)
|
||||
git worktree remove .claude/worktrees/agent-<hash>
|
||||
|
||||
# 2) Si le dossier persiste (worktree orphelin), forcer le retrait
|
||||
git worktree remove --force .claude/worktrees/agent-<hash>
|
||||
|
||||
# 3) Supprimer les branches worktree abandonnées
|
||||
git branch -D worktree-agent-<hash>
|
||||
```
|
||||
|
||||
### Nettoyage global
|
||||
|
||||
```bash
|
||||
# Supprimer TOUS les worktrees et leurs branches associées
|
||||
for wt in .claude/worktrees/*/; do
|
||||
hash=$(basename "$wt")
|
||||
git worktree remove --force "$wt" 2>/dev/null
|
||||
done
|
||||
git branch | grep worktree-agent- | xargs -r git branch -D
|
||||
git worktree prune -v
|
||||
|
||||
# Nettoyer les branches orphelines (worktree supprimé mais branche subsiste)
|
||||
git branch | grep worktree-agent- | xargs -r git branch -D
|
||||
```
|
||||
|
||||
Le dossier `.claude/` est gitignoré — il ne sera jamais committé.
|
||||
|
||||
## Build du package Windows
|
||||
|
||||
Le package de déploiement pour le PC Windows des utilisateurs est généré par
|
||||
`deploy/build_package.sh`. Il embarque `agent_v0/agent_v1/` directement (pas
|
||||
de staging intermédiaire).
|
||||
|
||||
```bash
|
||||
./deploy/build_package.sh # Build standard
|
||||
./deploy/build_package.sh --clean # Nettoyer avant de builder
|
||||
```
|
||||
|
||||
Le script vérifie la présence de tous les fichiers Python requis via la liste
|
||||
`REQUIRED_FILES`. Si vous ajoutez un nouveau module Python critique côté agent
|
||||
(ex: dans `agent_v1/core/` ou `agent_v1/network/`), **ajoutez-le à
|
||||
`REQUIRED_FILES`** pour qu'un fichier manquant fasse échouer le build plutôt
|
||||
que de produire un zip incomplet.
|
||||
|
||||
### Note historique : `agent_v0/deploy/windows_client/`
|
||||
|
||||
Ce dossier a été créé par `agent_v0/deploy_windows.py` comme staging de build
|
||||
et s'est désynchronisé. Il a été supprimé en avril 2026 — le build officiel
|
||||
passe désormais par `deploy/build_package.sh` qui lit directement
|
||||
`agent_v0/agent_v1/`.
|
||||
112
docs/STATUS.md
Normal file
112
docs/STATUS.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# STATUS — État réel du projet RPA Vision V3
|
||||
|
||||
> Dernière mise à jour : 14 avril 2026
|
||||
>
|
||||
> Ce document remplace les affirmations marketing du README historique.
|
||||
> Il décrit l'état réel des modules, sans embellissement.
|
||||
|
||||
## Positionnement
|
||||
|
||||
**POC avancé** — certaines briques sont fonctionnelles de bout en bout
|
||||
(capture, streaming, premier replay E2E sur Notepad), d'autres sont en cours
|
||||
de stabilisation ou à l'état d'ébauche. Le projet n'est pas « production-ready ».
|
||||
|
||||
Les fonctionnalités ci-dessous sont documentées sans minimiser les limites.
|
||||
|
||||
## Légende
|
||||
|
||||
- **opérationnel** : testé, utilisé régulièrement, pas de régression récente connue
|
||||
- **alpha** : branché et fonctionnel sur un cas d'usage de référence, manque
|
||||
de recul sur la généralisation
|
||||
- **en cours** : en développement actif, comportement instable
|
||||
- **non démarré** : planifié, pas encore de code significatif
|
||||
|
||||
## Vue d'ensemble par module
|
||||
|
||||
| Module / fonctionnalité | État | Commentaire |
|
||||
|---|---|---|
|
||||
| Capture d'écran + événements (Agent V1 Windows) | opérationnel | `agent_v0/agent_v1/` — systray, streaming vers serveur |
|
||||
| Streaming server (`agent_v0/server_v1/`) | opérationnel | FastAPI port 5005, sessions en mémoire |
|
||||
| Stockage sessions (`RawSession`) | opérationnel | JSON + screenshots, rotation manuelle |
|
||||
| Détection UI (`core/detection/`) | alpha | Cascade VLM + OCR + templates, sensible au modèle choisi |
|
||||
| Embedding & FAISS (`core/embedding/`) | alpha | CLIP ViT-B/32 + index Flat, pas testé à grande échelle |
|
||||
| Workflow Graph (`core/graph/`) | alpha | Construction depuis sessions, matching heuristique |
|
||||
| Replay E2E (`agent_v0/server_v1/api_stream.py`) | alpha | Premier succès le 13 avril 2026 sur Notepad, asymétries strict/legacy connues |
|
||||
| Mode apprentissage supervisé | alpha | Pause sur échec répété, demande d'intervention humaine |
|
||||
| TargetMemoryStore (Phase 1 apprentissage) | alpha | Schéma SQLite en place, DB vide jusqu'au premier replay complet |
|
||||
| Grounding visuel (UI-TARS, gemma4, qwen3-vl) | alpha | Switch de modèle via `.env` (`RPA_VLM_MODEL`) |
|
||||
| SomEngine (YOLO + docTR + VLM) | alpha | Intégré, dormant dans la cascade par défaut |
|
||||
| Web Dashboard (port 5001) | alpha | Flask + SocketIO, fonctionnel mais non durci |
|
||||
| Visual Workflow Builder (VWB, ports 5002 + 3002) | en cours | Catalogue d'actions, UI React. Bugs DB runtime connus |
|
||||
| Agent Chat (port 5004) | alpha | Planner autonome, basé LLM local |
|
||||
| Module auth (`core/auth/`) | alpha | Vault Fernet + TOTP, CLI seul, pas d'intégration UI |
|
||||
| Federation (`core/federation/`) | alpha | Export/import de LearningPacks, pas de test terrain |
|
||||
| GPU Resource Manager (`core/gpu/`) | alpha | Gestion Ollama + warmup modèles, code utilisé mais peu testé |
|
||||
| Self-healing / recovery | en cours | Heuristiques présentes, comportement global non stabilisé |
|
||||
| Analytics / reporting | en cours | Prototype, pas de frontend finalisé |
|
||||
| Tests end-to-end | en cours | 1 replay E2E réussi, 56 tests d'intégration verts hors cas connus |
|
||||
| Deploy Windows (`deploy/build_package.sh`) | opérationnel | Produit `Lea_v<version>.zip`, vérification des fichiers requis |
|
||||
| Conformité AI Act (journalisation, floutage, rétention logs) | alpha | Mécanismes en place, audit formel non fait |
|
||||
|
||||
## Limites connues (non exploitables comme failles)
|
||||
|
||||
- Plusieurs copies parallèles du code agent ont existé (source, staging
|
||||
Windows, worktrees) avec risque de divergence. Le staging Windows obsolète
|
||||
a été supprimé ; le build officiel passe par `deploy/build_package.sh`.
|
||||
- La base `data/learning/target_memory.db` reste vide tant qu'un replay
|
||||
complet n'a pas été cristallisé — l'apprentissage est câblé mais pas
|
||||
encore éprouvé.
|
||||
- Certaines asymétries entre chemins « strict » et « legacy » dans
|
||||
`api_stream.py` peuvent faire retomber une erreur en mode strict vers
|
||||
le retry+stop legacy au lieu de la pause d'apprentissage.
|
||||
- Le worker de compilation sessions → `ExecutionPlan` (port 5099) n'est pas
|
||||
lancé par défaut — les sessions enregistrées ne sont pas compilées
|
||||
automatiquement.
|
||||
- Le VWB présente des bugs en écriture DB identifiés et documentés.
|
||||
- La détection VLM est sensible au choix de modèle ; le défaut est
|
||||
`gemma4:latest` (cf. `.env.example`).
|
||||
|
||||
## Modèles utilisés
|
||||
|
||||
Définis dans `.env` (voir `.env.example`) :
|
||||
|
||||
| Variable | Valeur par défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `RPA_VLM_MODEL` | `gemma4:latest` | Modèle VLM principal (Ollama) |
|
||||
| `VLM_MODEL` | `gemma4:latest` | Alias de compatibilité |
|
||||
| `CLIP_MODEL` | `ViT-B-32` | Embeddings visuels |
|
||||
| `CLIP_PRETRAINED` | `openai` | Poids pré-entraînés |
|
||||
| `VLM_ENDPOINT` | `http://localhost:11434` | Ollama local |
|
||||
|
||||
Modèles alternatifs testés : `qwen3-vl:8b`, `ui-tars` (grounding direct).
|
||||
Aucun appel cloud par défaut — tout passe par Ollama local.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **OS cible serveur** : Linux (Ubuntu 24.04 testé)
|
||||
- **GPU recommandé** : NVIDIA (ex. RTX 5070) pour l'inférence VLM locale
|
||||
- **OS cible client** : Windows 10/11 (Agent V1)
|
||||
- **Python** : 3.10 à 3.12
|
||||
- **Ollama** : service local obligatoire
|
||||
|
||||
## Ports utilisés (source : `services.conf`)
|
||||
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| 8000 | API Server (core upload) |
|
||||
| 5001 | Web Dashboard |
|
||||
| 5002 | VWB Backend (Flask) |
|
||||
| 5003 | Monitoring |
|
||||
| 5004 | Agent Chat |
|
||||
| 5005 | Streaming Server (Agent V1) |
|
||||
| 5006 | Session Cleaner |
|
||||
| 5099 | Worker de compilation (optionnel) |
|
||||
| 3002 | VWB Frontend (Vite/React) |
|
||||
|
||||
## Prochaines étapes prioritaires
|
||||
|
||||
1. Stabiliser le replay E2E sur 3 applications métier différentes
|
||||
2. Alimenter `TargetMemoryStore` via des replays réussis réels
|
||||
3. Harmoniser les branches `strict` / `legacy` dans `api_stream.py`
|
||||
4. Durcir VWB ou pivoter vers un outil dédié plus simple
|
||||
5. Activer le worker de compilation sessions → ExecutionPlan
|
||||
109
requirements-ci.txt
Normal file
109
requirements-ci.txt
Normal file
@@ -0,0 +1,109 @@
|
||||
# ------------------------------------------------------------------
|
||||
# requirements-ci.txt — Dépendances pour la CI (tests unitaires)
|
||||
# ------------------------------------------------------------------
|
||||
# Objectif : installer le minimum pour que `pytest tests/unit/`
|
||||
# passe sans GPU, sans Ollama, sans torch, sans FAISS GPU.
|
||||
#
|
||||
# Les tests lourds (torch, transformers, CLIP, FAISS GPU, doctr,
|
||||
# Ollama) sont marqués `slow`, `gpu` ou `integration` et exclus
|
||||
# via `-m "not slow and not gpu and not integration"`.
|
||||
#
|
||||
# Versions alignées sur requirements.txt pour éviter les surprises
|
||||
# lors du runtime local, mais allégées (CPU-only, headless).
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# --- Runtime core ---
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
python-dotenv==1.0.0
|
||||
PyYAML==6.0.1
|
||||
click==8.3.1
|
||||
typing_extensions==4.15.0
|
||||
annotated-types==0.7.0
|
||||
|
||||
# --- Web frameworks (utilisés par les tests API/dashboard) ---
|
||||
fastapi==0.128.0
|
||||
starlette==0.50.0
|
||||
uvicorn==0.40.0
|
||||
Flask==3.0.0
|
||||
Flask-Caching==2.1.0
|
||||
Flask-Cors==4.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Werkzeug==3.1.5
|
||||
Jinja2==3.1.6
|
||||
itsdangerous==2.2.0
|
||||
blinker==1.9.0
|
||||
|
||||
# --- DB (tests auth/audit/extraction) ---
|
||||
SQLAlchemy==2.0.23
|
||||
alembic==1.18.4
|
||||
|
||||
# --- HTTP clients ---
|
||||
httpx==0.28.1
|
||||
requests==2.32.5
|
||||
urllib3==2.6.3
|
||||
certifi==2026.1.4
|
||||
idna==3.11
|
||||
charset-normalizer==3.4.4
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
anyio==4.12.1
|
||||
sniffio==1.3.1; python_version >= "3.7"
|
||||
|
||||
# --- Sécurité (test_security_*, auth vault, TOTP) ---
|
||||
cryptography==46.0.3
|
||||
cffi==2.0.0
|
||||
pycparser==2.23
|
||||
|
||||
# --- Images (opencv-python-headless au lieu de opencv-python pour CI) ---
|
||||
pillow==12.1.0
|
||||
opencv-python-headless==4.12.0.88
|
||||
numpy==2.2.6
|
||||
|
||||
# --- Pytest et plugins ---
|
||||
pytest==9.0.2
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==4.1.0
|
||||
pytest-flask==1.3.0
|
||||
pytest-mock==3.12.0
|
||||
iniconfig==2.3.0
|
||||
pluggy==1.6.0
|
||||
packaging==25.0
|
||||
|
||||
# --- Couverture ---
|
||||
coverage==7.13.1
|
||||
|
||||
# --- Utilitaires divers (imports indirects fréquents) ---
|
||||
python-dateutil==2.8.2
|
||||
six==1.17.0
|
||||
attrs==25.4.0
|
||||
jsonschema==4.20.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
referencing==0.37.0
|
||||
rpds-py==0.30.0
|
||||
RapidFuzz==3.14.3
|
||||
regex==2025.11.3
|
||||
python-multipart==0.0.21
|
||||
validators==0.35.0
|
||||
prometheus_client==0.23.1
|
||||
psutil==7.2.1
|
||||
filelock==3.20.3
|
||||
tqdm==4.67.1
|
||||
|
||||
# --- Hypothesis (property tests, si inclus plus tard) ---
|
||||
hypothesis==6.92.1
|
||||
sortedcontainers==2.4.0
|
||||
|
||||
# --- NOTES ---
|
||||
# Volontairement absents :
|
||||
# - torch / torchvision / triton / nvidia-* → GPU, hors CI
|
||||
# - transformers / accelerate / tokenizers → chargent torch
|
||||
# - open_clip_torch / timm → idem
|
||||
# - faiss-cpu → binaire lourd (~90 Mo),
|
||||
# utilisé uniquement en
|
||||
# tests `slow` / `integration`
|
||||
# - ollama → nécessite serveur Ollama
|
||||
# - python-doctr / pypdfium2 → OCR, tests `slow`
|
||||
# - pynput / pyautogui / mss / PyQt5 → GUI / simulation I/O
|
||||
# - python-socketio / Flask-SocketIO → WS, tests intégration
|
||||
# - eds-nlp / spacy → modèles NLP hors CI
|
||||
@@ -9,6 +9,7 @@
|
||||
# 5003 - Monitoring (métriques système)
|
||||
# 5004 - Agent Chat (interface conversationnelle)
|
||||
# 5005 - Streaming Server (Agent V1 → core pipeline)
|
||||
# 5006 - Session Cleaner (nettoyage sessions avant replay)
|
||||
# 3002 - VWB Frontend (Vite/React)
|
||||
#
|
||||
|
||||
@@ -20,3 +21,4 @@ agent-chat|5004|agent_chat/app.py|optional
|
||||
streaming|5005|agent_v0/server_v1/api_stream.py|optional
|
||||
worker|5099|agent_v0/server_v1/run_worker.py|optional
|
||||
vwb-frontend|3002|cd visual_workflow_builder/frontend_v4 && npm run dev|required
|
||||
session-cleaner|5006|tools/session_cleaner.py|optional
|
||||
|
||||
24
svc.sh
24
svc.sh
@@ -56,6 +56,7 @@ declare -A PORTS=(
|
||||
[streaming]=5005
|
||||
[worker]=5099
|
||||
[vwb-frontend]=3002
|
||||
[session-cleaner]=5006
|
||||
)
|
||||
|
||||
# Mapping nom court -> nom service systemd
|
||||
@@ -66,13 +67,14 @@ declare -A SYSTEMD_UNITS=(
|
||||
[streaming]="rpa-streaming.service"
|
||||
[worker]="rpa-worker.service"
|
||||
[vwb-frontend]="rpa-vwb-frontend.service"
|
||||
[session-cleaner]="rpa-session-cleaner.service"
|
||||
)
|
||||
|
||||
# Services gérés par systemd (ceux qui ont un .service)
|
||||
SYSTEMD_SERVICES="streaming worker agent-chat dashboard vwb-backend vwb-frontend"
|
||||
SYSTEMD_SERVICES="streaming worker agent-chat dashboard vwb-backend vwb-frontend session-cleaner"
|
||||
|
||||
# Tous les services connus
|
||||
ALL_SERVICES="api dashboard vwb-backend monitoring agent-chat streaming worker vwb-frontend"
|
||||
ALL_SERVICES="api dashboard vwb-backend monitoring agent-chat streaming worker vwb-frontend session-cleaner"
|
||||
|
||||
declare -A COMMANDS=(
|
||||
[api]="$VENV_DIR/bin/python3 server/api_upload.py"
|
||||
@@ -83,14 +85,15 @@ declare -A COMMANDS=(
|
||||
[streaming]="$VENV_DIR/bin/python3 -m agent_v0.server_v1.api_stream"
|
||||
[worker]="$VENV_DIR/bin/python3 -m agent_v0.server_v1.run_worker"
|
||||
[vwb-frontend]="cd $SCRIPT_DIR/visual_workflow_builder/frontend_v4 && npm run dev"
|
||||
[session-cleaner]="$VENV_DIR/bin/python3 tools/session_cleaner.py"
|
||||
)
|
||||
|
||||
# Groupes de services
|
||||
declare -A SVC_GROUPS=(
|
||||
[vwb]="vwb-backend vwb-frontend"
|
||||
[all]="api dashboard vwb-backend vwb-frontend"
|
||||
[full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming worker"
|
||||
[boot]="streaming worker agent-chat dashboard vwb-backend vwb-frontend"
|
||||
[full]="api dashboard vwb-backend vwb-frontend monitoring agent-chat streaming worker session-cleaner"
|
||||
[boot]="streaming worker agent-chat dashboard vwb-backend vwb-frontend session-cleaner"
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
@@ -353,7 +356,7 @@ do_install() {
|
||||
|
||||
# Vérifier que les fichiers existent
|
||||
local missing=false
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-vision.target; do
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-session-cleaner.service rpa-vision.target; do
|
||||
if [ -f "$SYSTEMD_DIR/$unit" ]; then
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
else
|
||||
@@ -397,7 +400,7 @@ do_enable() {
|
||||
echo -e "${CYAN}${BOLD}Activation du demarrage automatique au boot...${NC}"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable rpa-vision.target
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-session-cleaner.service; do
|
||||
systemctl --user enable "$unit" 2>/dev/null
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
done
|
||||
@@ -408,7 +411,7 @@ do_enable() {
|
||||
do_disable() {
|
||||
echo -e "${YELLOW}${BOLD}Desactivation du demarrage automatique...${NC}"
|
||||
systemctl --user disable rpa-vision.target 2>/dev/null || true
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service; do
|
||||
for unit in rpa-streaming.service rpa-worker.service rpa-agent-chat.service rpa-dashboard.service rpa-vwb-backend.service rpa-vwb-frontend.service rpa-session-cleaner.service; do
|
||||
systemctl --user disable "$unit" 2>/dev/null || true
|
||||
echo -e " ${GREEN}OK${NC} $unit"
|
||||
done
|
||||
@@ -438,11 +441,12 @@ show_help() {
|
||||
echo " dashboard Web Dashboard (port 5001)"
|
||||
echo " vwb-backend VWB Backend Flask (port 5002)"
|
||||
echo " vwb-frontend VWB Frontend Vite (port 3002)"
|
||||
echo " session-cleaner Session Cleaner (port 5006)"
|
||||
echo " api API Server (port 8000) [legacy uniquement]"
|
||||
echo " monitoring Monitoring (port 5003) [legacy uniquement]"
|
||||
echo ""
|
||||
echo -e "${BOLD}Groupes:${NC}"
|
||||
echo " boot Services systemd (streaming, worker, chat, dashboard, vwb)"
|
||||
echo " boot Services systemd (streaming, worker, chat, dashboard, vwb, session-cleaner)"
|
||||
echo " vwb VWB backend + frontend"
|
||||
echo " all Core (api, dashboard, vwb)"
|
||||
echo " full Tous les services"
|
||||
@@ -451,8 +455,8 @@ show_help() {
|
||||
echo " --legacy Forcer le mode legacy (PID files au lieu de systemd)"
|
||||
echo ""
|
||||
echo -e "${BOLD}Exemples:${NC}"
|
||||
echo " $0 start boot # Demarrer les 5 services systemd"
|
||||
echo " $0 stop boot # Arreter les 5 services systemd"
|
||||
echo " $0 start boot # Demarrer les services systemd"
|
||||
echo " $0 stop boot # Arreter les services systemd"
|
||||
echo " $0 restart streaming # Redemarrer le streaming server"
|
||||
echo " $0 logs streaming -f # Suivre les logs du streaming"
|
||||
echo " $0 status # Voir l'etat de tout"
|
||||
|
||||
333
tests/integration/test_agents_enroll_api.py
Normal file
333
tests/integration/test_agents_enroll_api.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Tests d'integration pour les endpoints /api/v1/agents/* (fleet management).
|
||||
|
||||
Couvre :
|
||||
- POST /api/v1/agents/enroll (201, 409 duplicate, 401 sans token,
|
||||
reenrollement apres uninstall)
|
||||
- POST /api/v1/agents/uninstall (200, 404 inconnu)
|
||||
- GET /api/v1/agents/fleet (listing actif / desinstalle)
|
||||
|
||||
Le module `agent_v0.server_v1.api_stream` applique un fail-closed a
|
||||
l'import si RPA_API_TOKEN est absent : la fixture `_ensure_api_token`
|
||||
garantit que l'env est defini AVANT tout import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Racine du projet pour les imports locaux (meme pattern que les autres
|
||||
# tests d'integration)
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
_TEST_API_TOKEN = "test_token_fleet_endpoints_0123456789abcdef"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agents_client(monkeypatch, tmp_path):
|
||||
"""Client FastAPI de test avec un AgentRegistry isole sur disque.
|
||||
|
||||
Remplace le `agent_registry` global par une instance pointant sur une
|
||||
DB temporaire, pour ne pas polluer la vraie rpa_data.db du workspace.
|
||||
"""
|
||||
# Garantir que le module peut s'importer (RPA_API_TOKEN sinon sys.exit 1)
|
||||
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
||||
monkeypatch.setenv(
|
||||
"RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db")
|
||||
)
|
||||
|
||||
# Import tardif apres config de l'env
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||
|
||||
# Aligner le token attendu par le middleware Bearer avec notre token de test
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
||||
|
||||
# Substituer le registre global par une instance dediee au test
|
||||
original_registry = api_stream.agent_registry
|
||||
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
|
||||
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
|
||||
|
||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
yield client, _TEST_API_TOKEN, test_registry
|
||||
|
||||
# Restauration
|
||||
monkeypatch.setattr(api_stream, "agent_registry", original_registry)
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/agents/enroll
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enroll_new_agent_returns_201(agents_client):
|
||||
client, token, _ = agents_client
|
||||
payload = {
|
||||
"machine_id": "aivanov-jdoe-a3f2b718",
|
||||
"user_name": "Jean Doe",
|
||||
"user_email": "jdoe@aivanov.fr",
|
||||
"user_id": "AIVA-001",
|
||||
"hostname": "DESKTOP-ABC123",
|
||||
"os_info": "Windows 11",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||
)
|
||||
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["status"] == "enrolled"
|
||||
assert data["created"] is True
|
||||
assert data["reactivated"] is False
|
||||
assert data["machine_id"] == "aivanov-jdoe-a3f2b718"
|
||||
# Phase 1 : token global renvoye pour confirmation
|
||||
assert data["api_token"] == token
|
||||
agent = data["agent"]
|
||||
assert agent["user_name"] == "Jean Doe"
|
||||
assert agent["hostname"] == "DESKTOP-ABC123"
|
||||
assert agent["status"] == "active"
|
||||
assert agent["enrolled_at"]
|
||||
assert agent["uninstalled_at"] is None
|
||||
|
||||
|
||||
def test_enroll_duplicate_returns_409(agents_client):
|
||||
client, token, _ = agents_client
|
||||
payload = {
|
||||
"machine_id": "dup-machine-001",
|
||||
"user_name": "Alice",
|
||||
"hostname": "PC-ALICE",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
first = client.post(
|
||||
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
# Reenrollement sur machine encore active -> 409
|
||||
second = client.post(
|
||||
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||
)
|
||||
assert second.status_code == 409, second.text
|
||||
body = second.json()
|
||||
# FastAPI enveloppe notre detail dans "detail"
|
||||
detail = body["detail"]
|
||||
assert detail["error"] == "already_enrolled"
|
||||
assert detail["existing"]["machine_id"] == "dup-machine-001"
|
||||
|
||||
|
||||
def test_enroll_without_token_returns_401(agents_client):
|
||||
client, _, _ = agents_client
|
||||
payload = {"machine_id": "no-auth-001"}
|
||||
resp = client.post("/api/v1/agents/enroll", json=payload)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_enroll_with_wrong_token_returns_401(agents_client):
|
||||
client, _, _ = agents_client
|
||||
payload = {"machine_id": "bad-token-001"}
|
||||
resp = client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json=payload,
|
||||
headers={"Authorization": "Bearer WRONG_TOKEN"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_enroll_missing_machine_id_returns_422(agents_client):
|
||||
"""Pydantic renvoie 422 si machine_id est absent (validation automatique)."""
|
||||
client, token, _ = agents_client
|
||||
resp = client.post(
|
||||
"/api/v1/agents/enroll", json={}, headers=_auth_headers(token)
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_enroll_blank_machine_id_returns_400(agents_client):
|
||||
"""Un machine_id vide (whitespace) est rejete avec un 400 explicite."""
|
||||
client, token, _ = agents_client
|
||||
resp = client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={"machine_id": " "},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/agents/uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uninstall_existing_returns_200_and_soft_deletes(agents_client):
|
||||
client, token, registry = agents_client
|
||||
|
||||
# Preparer un agent actif
|
||||
client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={
|
||||
"machine_id": "uninst-001",
|
||||
"user_name": "Bob",
|
||||
"hostname": "PC-BOB",
|
||||
},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "uninst-001", "reason": "user_uninstall"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["status"] == "uninstalled"
|
||||
assert data["machine_id"] == "uninst-001"
|
||||
assert data["agent"]["status"] == "uninstalled"
|
||||
assert data["agent"]["uninstall_reason"] == "user_uninstall"
|
||||
assert data["agent"]["uninstalled_at"]
|
||||
|
||||
# Verifier en base : pas de suppression physique (soft delete)
|
||||
row = registry.get("uninst-001")
|
||||
assert row is not None
|
||||
assert row["status"] == "uninstalled"
|
||||
|
||||
|
||||
def test_uninstall_unknown_returns_404(agents_client):
|
||||
client, token, _ = agents_client
|
||||
resp = client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "never-seen-001", "reason": "admin_revoke"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_uninstall_without_token_returns_401(agents_client):
|
||||
client, _, _ = agents_client
|
||||
resp = client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "anything"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reenrollement apres uninstall = reactivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reenroll_after_uninstall_reactivates(agents_client):
|
||||
client, token, _ = agents_client
|
||||
|
||||
client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={
|
||||
"machine_id": "reenroll-001",
|
||||
"user_name": "Carol",
|
||||
"hostname": "PC-CAROL",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "reenroll-001", "reason": "user_uninstall"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
# Nouvelle installation -> reactivation OK (meme machine_id, maj des champs)
|
||||
resp = client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={
|
||||
"machine_id": "reenroll-001",
|
||||
"user_name": "Carol Durand",
|
||||
"hostname": "PC-CAROL",
|
||||
"version": "1.1.0",
|
||||
},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["created"] is False
|
||||
assert data["reactivated"] is True
|
||||
agent = data["agent"]
|
||||
assert agent["status"] == "active"
|
||||
assert agent["uninstalled_at"] is None
|
||||
assert agent["uninstall_reason"] is None
|
||||
# Les champs ont bien ete mis a jour
|
||||
assert agent["user_name"] == "Carol Durand"
|
||||
assert agent["version"] == "1.1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/agents/fleet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fleet_lists_active_and_uninstalled(agents_client):
|
||||
client, token, _ = agents_client
|
||||
|
||||
# 2 agents actifs + 1 desinstalle
|
||||
for mid in ("fleet-a", "fleet-b"):
|
||||
client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={"machine_id": mid, "user_name": mid, "hostname": mid.upper()},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
client.post(
|
||||
"/api/v1/agents/enroll",
|
||||
json={"machine_id": "fleet-c", "user_name": "Cleo"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/agents/uninstall",
|
||||
json={"machine_id": "fleet-c", "reason": "machine_retired"},
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
|
||||
resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["total_active"] == 2
|
||||
assert data["total_uninstalled"] == 1
|
||||
|
||||
active_ids = {a["machine_id"] for a in data["active"]}
|
||||
assert active_ids == {"fleet-a", "fleet-b"}
|
||||
|
||||
uninstalled_ids = {a["machine_id"] for a in data["uninstalled"]}
|
||||
assert uninstalled_ids == {"fleet-c"}
|
||||
assert data["uninstalled"][0]["uninstall_reason"] == "machine_retired"
|
||||
|
||||
|
||||
def test_fleet_empty(agents_client):
|
||||
client, token, _ = agents_client
|
||||
resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {
|
||||
"active": [],
|
||||
"uninstalled": [],
|
||||
"total_active": 0,
|
||||
"total_uninstalled": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_fleet_without_token_returns_401(agents_client):
|
||||
client, _, _ = agents_client
|
||||
resp = client.get("/api/v1/agents/fleet")
|
||||
assert resp.status_code == 401
|
||||
@@ -184,8 +184,12 @@ class TestImagePayloadFormat:
|
||||
"""Le serveur distingue full/crop par '_crop' dans le shot_id."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
fake_img = tmp_path / "crop.png"
|
||||
fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
# Dans le monde réel, full et crop sont deux fichiers distincts
|
||||
# (la purge après ACK supprime le premier avant que le second parte).
|
||||
fake_full = tmp_path / "full.png"
|
||||
fake_full.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
fake_crop = tmp_path / "crop.png"
|
||||
fake_crop.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
@@ -194,9 +198,9 @@ class TestImagePayloadFormat:
|
||||
streamer._server_available = True
|
||||
|
||||
# Full screenshot
|
||||
streamer._send_image(str(fake_img), "shot_0001_full")
|
||||
streamer._send_image(str(fake_full), "shot_0001_full")
|
||||
# Crop screenshot
|
||||
streamer._send_image(str(fake_img), "shot_0001_crop")
|
||||
streamer._send_image(str(fake_crop), "shot_0001_crop")
|
||||
|
||||
img_calls = [
|
||||
c for c in mock_req.post.call_args_list
|
||||
|
||||
@@ -6,6 +6,7 @@ Sans GPU/modèles lourds (mocks pour ScreenAnalyzer et CLIP).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -457,6 +458,27 @@ class TestStreamProcessorListMethods:
|
||||
class TestAPIEndpoints:
|
||||
"""Tests pour les endpoints GET sessions et workflows."""
|
||||
|
||||
# Token de test fixe utilisé pour tous les tests d'API.
|
||||
# Doit être défini AVANT le premier import de agent_v0.server_v1.api_stream
|
||||
# car le module fail-closed (sys.exit 1) si RPA_API_TOKEN est absent.
|
||||
_TEST_API_TOKEN = "test_token_for_api_endpoints_0123456789abcdef"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_api_token(self, monkeypatch):
|
||||
"""Garantit que RPA_API_TOKEN est défini avant l'import de api_stream.
|
||||
|
||||
Le module agent_v0.server_v1.api_stream applique un fail-closed P0-C
|
||||
(sys.exit 1) à l'import si RPA_API_TOKEN est absent. On force donc
|
||||
une valeur de test ici avant tout import lazy dans la fixture client.
|
||||
"""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
|
||||
# Si api_stream est déjà chargé dans sys.modules avec un autre token
|
||||
# (par ex. depuis un précédent test), on aligne sa valeur API_TOKEN
|
||||
# pour que les requêtes Bearer du test passent l'auth.
|
||||
api_stream_mod = sys.modules.get("agent_v0.server_v1.api_stream")
|
||||
if api_stream_mod is not None:
|
||||
monkeypatch.setattr(api_stream_mod, "API_TOKEN", self._TEST_API_TOKEN)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, temp_dir):
|
||||
"""Client de test FastAPI."""
|
||||
|
||||
378
tests/integration/test_streamer_buffer_and_purge.py
Normal file
378
tests/integration/test_streamer_buffer_and_purge.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Tests pour les fonctionnalités Partie A (purge après ACK) et Partie B
|
||||
(buffer persistant) du TraceStreamer — bloquants audit AI Act.
|
||||
|
||||
Aucun réseau : on mocke requests.post.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_png(path: Path, size: int = 100) -> Path:
|
||||
"""Crée un PNG minimal (header + padding) valide pour open()."""
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * size)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_buffer(tmp_path, monkeypatch):
|
||||
"""Isole le buffer persistant dans un tmp_path par test.
|
||||
|
||||
Le buffer est normalement partagé (BASE_DIR / "buffer"). On pointe
|
||||
vers un chemin jetable pour éviter la pollution croisée entre tests.
|
||||
"""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
buffer_dir = tmp_path / "buffer"
|
||||
monkeypatch.setattr(streamer_mod, "BUFFER_DIR", buffer_dir)
|
||||
return buffer_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partie A — Purge après ACK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPurgeAfterAck:
|
||||
"""Partie A : les screenshots locaux sont supprimés après HTTP 200."""
|
||||
|
||||
def test_image_purged_after_ack(self, tmp_path, isolated_buffer):
|
||||
"""Après HTTP 200, le fichier image local doit être supprimé."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
img_path = _make_png(tmp_path / "to_purge.png")
|
||||
assert img_path.exists()
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = TraceStreamer("sess_purge_001")
|
||||
streamer._server_available = True
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert result is ImageSendResult.OK
|
||||
assert not img_path.exists(), "Fichier local doit être supprimé après ACK"
|
||||
|
||||
def test_image_not_purged_if_server_rejects(self, tmp_path, isolated_buffer):
|
||||
"""Si le serveur répond 500, le fichier local est conservé."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
img_path = _make_png(tmp_path / "keep_me.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
|
||||
streamer = TraceStreamer("sess_purge_002")
|
||||
streamer._server_available = True
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert result is ImageSendResult.FAILED
|
||||
assert img_path.exists(), "Fichier doit rester si le serveur rejette"
|
||||
|
||||
def test_purge_disabled_via_env(
|
||||
self, tmp_path, isolated_buffer, monkeypatch
|
||||
):
|
||||
"""RPA_PURGE_AFTER_ACK=0 désactive la purge."""
|
||||
# On patche PURGE_AFTER_ACK directement (lu au module load)
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
monkeypatch.setattr(streamer_mod, "PURGE_AFTER_ACK", False)
|
||||
|
||||
img_path = _make_png(tmp_path / "keep.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = streamer_mod.TraceStreamer("sess_purge_003")
|
||||
streamer._server_available = True
|
||||
streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert img_path.exists(), "Purge doit être désactivée"
|
||||
|
||||
def test_purge_does_not_crash_on_locked_file(
|
||||
self, tmp_path, isolated_buffer, monkeypatch
|
||||
):
|
||||
"""Si os.remove échoue (fichier verrouillé), pas de crash."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
img_path = _make_png(tmp_path / "locked.png")
|
||||
|
||||
def _raise_permission(*_args, **_kwargs):
|
||||
raise PermissionError("Fichier verrouillé (simulé)")
|
||||
|
||||
monkeypatch.setattr(streamer_mod.os, "remove", _raise_permission)
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = streamer_mod.TraceStreamer("sess_purge_004")
|
||||
streamer._server_available = True
|
||||
# Ne doit PAS lever
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
assert result is ImageSendResult.OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partie B — Buffer persistant SQLite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistentBuffer:
|
||||
"""Partie B : persistance disque des events/images non envoyés."""
|
||||
|
||||
def test_priority_event_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un event prioritaire est persisté si le serveur est indisponible."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_001")
|
||||
streamer._server_available = False
|
||||
|
||||
streamer.push_event({"type": "click", "pos": [100, 200]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
counts = buf.counts()
|
||||
assert counts["events"] == 1, "Click doit être persisté"
|
||||
|
||||
def test_heartbeat_not_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un heartbeat (non prioritaire) n'est PAS persisté."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_002")
|
||||
streamer._server_available = False
|
||||
|
||||
# La queue n'est pas pleine, donc le heartbeat va dans la queue RAM
|
||||
streamer.push_event({"type": "heartbeat", "image": "/tmp/h.png"})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
# Heartbeat reste dans la queue RAM (pas prioritaire → pas persisté)
|
||||
assert buf.counts()["events"] == 0
|
||||
|
||||
def test_image_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Une image est persistée si le serveur est indisponible."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
img = _make_png(tmp_path / "img.png")
|
||||
|
||||
streamer = TraceStreamer("sess_buf_003")
|
||||
streamer._server_available = False
|
||||
|
||||
streamer.push_image(str(img), "shot_001")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
def test_buffer_persists_when_queue_full(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Quand la queue RAM est pleine, un event prioritaire va en SQLite."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
# Monkeypatch la taille max de queue pour forcer le débordement vite
|
||||
streamer = streamer_mod.TraceStreamer("sess_buf_004")
|
||||
streamer._server_available = True
|
||||
# Remplir artificiellement la queue
|
||||
import queue as _q
|
||||
|
||||
# Remplir jusqu'à être full
|
||||
while True:
|
||||
try:
|
||||
streamer.queue.put_nowait(("event", {"type": "noise"}))
|
||||
except _q.Full:
|
||||
break
|
||||
|
||||
# Maintenant queue pleine — un click doit aller en SQLite
|
||||
streamer.push_event({"type": "click", "pos": [1, 2]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] >= 1
|
||||
|
||||
def test_drain_replays_events_when_server_recovers(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Le drain rejoue les events persistés quand le serveur revient."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_005")
|
||||
# Persister un event pendant que le serveur est down
|
||||
streamer._server_available = False
|
||||
streamer.push_event({"type": "click", "pos": [50, 50]})
|
||||
|
||||
assert streamer._get_buffer().counts()["events"] == 1
|
||||
|
||||
# Serveur revient — on simule un drain manuel
|
||||
streamer._server_available = True
|
||||
with patch(
|
||||
"agent_v0.agent_v1.network.streamer.requests"
|
||||
) as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(streamer._get_buffer())
|
||||
|
||||
# L'event doit être envoyé ET supprimé du buffer
|
||||
event_calls = [
|
||||
c for c in mock_req.post.call_args_list if "/event" in str(c)
|
||||
]
|
||||
assert len(event_calls) == 1
|
||||
assert streamer._get_buffer().counts()["events"] == 0
|
||||
|
||||
def test_drain_increments_attempts_on_failure(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Si le drain échoue, attempts est incrémenté (pas de suppression)."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_006")
|
||||
streamer._server_available = False
|
||||
streamer.push_event({"type": "click"})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] == 1
|
||||
|
||||
# Simule un envoi qui échoue (500)
|
||||
streamer._server_available = True
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
# L'event reste dans le buffer avec attempts=1
|
||||
rows = buf.drain_events()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["attempts"] == 1
|
||||
|
||||
def test_event_abandoned_after_max_attempts(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Après MAX_ATTEMPTS, un event est abandonné (supprimé + log error)."""
|
||||
from agent_v0.agent_v1.network.persistent_buffer import (
|
||||
MAX_ATTEMPTS,
|
||||
PersistentBuffer,
|
||||
)
|
||||
|
||||
buf = PersistentBuffer(tmp_path / "buf")
|
||||
buf.add_event("sess_aband", {"type": "click"})
|
||||
|
||||
# Incrémenter attempts jusqu'au max
|
||||
rows = buf.drain_events()
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
buf.increment_attempts(rows[0]["id"], "event")
|
||||
|
||||
abandoned = buf.abandon_exceeded()
|
||||
assert abandoned == 1
|
||||
assert buf.counts()["events"] == 0
|
||||
|
||||
def test_buffer_survives_corrupted_db(self, tmp_path):
|
||||
"""Un fichier DB corrompu est renommé et un nouveau est créé."""
|
||||
from agent_v0.agent_v1.network.persistent_buffer import (
|
||||
PersistentBuffer,
|
||||
)
|
||||
|
||||
buffer_dir = tmp_path / "buf"
|
||||
buffer_dir.mkdir()
|
||||
# Créer un fichier "DB" corrompu
|
||||
db_path = buffer_dir / "pending_events.db"
|
||||
db_path.write_bytes(b"this is not a valid sqlite db file\x00\x01")
|
||||
|
||||
# Ne doit pas crasher
|
||||
buf = PersistentBuffer(buffer_dir)
|
||||
|
||||
# Le buffer doit être utilisable
|
||||
assert buf.add_event("sess_recover", {"type": "click"}) is True
|
||||
assert buf.counts()["events"] == 1
|
||||
|
||||
def test_drain_skips_image_with_missing_file(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Si le fichier image a disparu, on supprime l'entrée du buffer."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_missing")
|
||||
streamer._server_available = False
|
||||
# Persister une image vers un chemin qui n'existe pas
|
||||
streamer.push_image("/tmp/does_not_exist_xyz.png", "shot_missing")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
# Drain : l'entrée doit être supprimée (fichier introuvable)
|
||||
streamer._server_available = True
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
assert buf.counts()["images"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scénarios complets (reprise, coupure réseau)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
"""Scénarios de bout en bout pour valider la reprise après incident."""
|
||||
|
||||
def test_scenario_server_offline_then_recover(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Scénario : serveur offline → events bufferisés → serveur revient
|
||||
→ drain automatique → buffer vide."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_scenario_001")
|
||||
|
||||
# 1) Serveur offline au démarrage
|
||||
streamer._server_available = False
|
||||
|
||||
# 2) L'utilisateur clique 5 fois
|
||||
for i in range(5):
|
||||
streamer.push_event({"type": "click", "pos": [i, i]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] == 5, "5 clicks doivent être persistés"
|
||||
|
||||
# 3) Le serveur revient
|
||||
streamer._server_available = True
|
||||
|
||||
# 4) Drain manuel (équivalent boucle)
|
||||
with patch(
|
||||
"agent_v0.agent_v1.network.streamer.requests"
|
||||
) as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
# 5) Tous les events ont été envoyés dans l'ordre
|
||||
event_calls = [
|
||||
c for c in mock_req.post.call_args_list if "/event" in str(c)
|
||||
]
|
||||
assert len(event_calls) == 5
|
||||
# Vérifier l'ordre (positions croissantes)
|
||||
positions = [
|
||||
c[1]["json"]["event"]["pos"][0] for c in event_calls
|
||||
]
|
||||
assert positions == [0, 1, 2, 3, 4]
|
||||
|
||||
assert buf.counts()["events"] == 0
|
||||
214
tests/integration/test_streamer_file_gone_p0e.py
Normal file
214
tests/integration/test_streamer_file_gone_p0e.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Tests du Fix P0-E : FileNotFoundError dans _send_image n'est pas un succès.
|
||||
|
||||
Avant : un fichier image disparu retournait `True` (succès logique) — donc
|
||||
le buffer SQLite supprimait l'entrée alors que le serveur n'avait jamais
|
||||
reçu l'image. Perte silencieuse, contradiction avec la sémantique
|
||||
"succès = HTTP 200".
|
||||
|
||||
Après : retourne `ImageSendResult.FILE_GONE` distinct de `OK`. Le drain
|
||||
du buffer supprime l'entrée mais avec un log ERROR explicite (pas de retry,
|
||||
pas de confusion avec un succès réseau).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_buffer(tmp_path, monkeypatch):
|
||||
"""Isole le buffer persistant dans un tmp_path par test."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
buffer_dir = tmp_path / "buffer"
|
||||
monkeypatch.setattr(streamer_mod, "BUFFER_DIR", buffer_dir)
|
||||
return buffer_dir
|
||||
|
||||
|
||||
class TestImageSendResultEnum:
|
||||
"""Vérifier l'existence et le contrat de l'enum ImageSendResult."""
|
||||
|
||||
def test_enum_has_three_values(self):
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
assert ImageSendResult.OK.value == "ok"
|
||||
assert ImageSendResult.FAILED.value == "failed"
|
||||
assert ImageSendResult.FILE_GONE.value == "file_gone"
|
||||
|
||||
def test_enum_values_distinct(self):
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
assert ImageSendResult.OK is not ImageSendResult.FAILED
|
||||
assert ImageSendResult.OK is not ImageSendResult.FILE_GONE
|
||||
assert ImageSendResult.FAILED is not ImageSendResult.FILE_GONE
|
||||
|
||||
|
||||
class TestSendImageReturnsFileGone:
|
||||
"""_send_image doit retourner FILE_GONE si le fichier n'existe pas."""
|
||||
|
||||
def test_missing_file_returns_file_gone(self, tmp_path, isolated_buffer):
|
||||
"""Fichier inexistant → FILE_GONE (pas OK, pas FAILED)."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_001")
|
||||
streamer._server_available = True
|
||||
|
||||
# On NE crée pas le fichier
|
||||
missing_path = str(tmp_path / "i_do_not_exist.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests"):
|
||||
result = streamer._send_image(missing_path, "shot_lost")
|
||||
|
||||
assert result is ImageSendResult.FILE_GONE, (
|
||||
f"Attendu FILE_GONE, reçu {result}"
|
||||
)
|
||||
|
||||
def test_file_gone_is_not_truthy_for_legacy_callers(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un caller legacy qui fait `if result:` ne doit PAS interpréter
|
||||
FILE_GONE comme un succès."""
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
# FILE_GONE est un membre d'enum non vide → en Python il est truthy
|
||||
# par défaut. C'est pour ça qu'on ne peut PAS se contenter du test
|
||||
# bool(result) pour distinguer succès/échec : il faut comparer is OK.
|
||||
# Ce test documente le contrat : les callers DOIVENT comparer is OK.
|
||||
result = ImageSendResult.FILE_GONE
|
||||
assert result is not ImageSendResult.OK
|
||||
assert result is not True
|
||||
|
||||
|
||||
class TestDrainHandlesFileGone:
|
||||
"""Le drain du buffer doit supprimer l'entrée FILE_GONE avec log ERROR."""
|
||||
|
||||
def test_drain_removes_buffer_entry_for_missing_file(
|
||||
self, tmp_path, isolated_buffer, caplog
|
||||
):
|
||||
"""Si le fichier disparait entre la persistance et le drain :
|
||||
- L'entrée est supprimée du buffer (pas de retry infini)
|
||||
- Un log ERROR signale la perte
|
||||
"""
|
||||
import logging
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_drain")
|
||||
streamer._server_available = False
|
||||
|
||||
# Persister une image vers un chemin inexistant
|
||||
ghost_path = str(tmp_path / "ghost.png")
|
||||
streamer.push_image(ghost_path, "shot_ghost")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
# Drain avec serveur dispo : doit détecter l'absence et abandonner
|
||||
streamer._server_available = True
|
||||
with caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"):
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests"):
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
assert buf.counts()["images"] == 0, (
|
||||
"L'entrée doit être supprimée (retry voué à échouer)"
|
||||
)
|
||||
|
||||
# Vérifier qu'un log ERROR a été émis (pas seulement un warning)
|
||||
error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(error_logs) >= 1, (
|
||||
"Un log ERROR doit signaler que le serveur n'a rien reçu"
|
||||
)
|
||||
assert any(
|
||||
"abandonnée" in r.getMessage() or "introuvable" in r.getMessage()
|
||||
or "abandonnée" in r.getMessage().lower()
|
||||
for r in error_logs
|
||||
)
|
||||
|
||||
def test_send_image_file_disappears_during_send(
|
||||
self, tmp_path, isolated_buffer, caplog
|
||||
):
|
||||
"""Cas tordu : le fichier existe au moment de drain_images mais
|
||||
disparait pendant _send_image (race condition disque).
|
||||
|
||||
On simule en patchant _compress_image_to_jpeg pour lever
|
||||
FileNotFoundError.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
# Fichier existant initialement
|
||||
img_path = tmp_path / "race.png"
|
||||
img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_race")
|
||||
streamer._server_available = True
|
||||
|
||||
# Forcer FileNotFoundError dans le pipeline d'envoi (compression
|
||||
# tente d'ouvrir le fichier — qui aura "disparu" entre temps).
|
||||
def _gone(_path):
|
||||
raise FileNotFoundError(f"race condition: {_path}")
|
||||
|
||||
with patch.object(streamer, "_compress_image_to_jpeg", _gone), \
|
||||
patch("agent_v0.agent_v1.network.streamer.requests"), \
|
||||
caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"):
|
||||
result = streamer._send_image(str(img_path), "shot_race")
|
||||
|
||||
assert result is ImageSendResult.FILE_GONE, (
|
||||
"FileNotFoundError pendant la compression → FILE_GONE"
|
||||
)
|
||||
# Log ERROR (pas debug comme avant)
|
||||
error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(error_logs) >= 1
|
||||
|
||||
|
||||
class TestStreamLoopHandlesFileGone:
|
||||
"""La boucle d'envoi ne doit PAS persister une entrée FILE_GONE."""
|
||||
|
||||
def test_file_gone_not_persisted_to_buffer(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Quand _send_image retourne FILE_GONE, on ne réécrit pas dans
|
||||
le buffer (sinon boucle infinie : add → drain → file_gone → add…)."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_loop")
|
||||
streamer._server_available = True
|
||||
|
||||
# Mock _send_with_retry pour retourner FILE_GONE directement
|
||||
with patch.object(
|
||||
streamer, "_send_with_retry", return_value=ImageSendResult.FILE_GONE
|
||||
):
|
||||
# Mettre une image dans la queue
|
||||
streamer.queue.put(("image", ("/tmp/whatever.png", "shot_x")))
|
||||
# Lancer une seule itération de la boucle (en simulant)
|
||||
try:
|
||||
item_type, data = streamer.queue.get(timeout=0.1)
|
||||
# Reproduire la logique du _stream_loop
|
||||
result = streamer._send_with_retry(
|
||||
streamer._send_image, *data
|
||||
)
|
||||
assert result is ImageSendResult.FILE_GONE
|
||||
# Le caller (stream_loop) doit identifier FILE_GONE comme
|
||||
# "ne pas persister" → on vérifie que le buffer reste vide
|
||||
buf = streamer._get_buffer()
|
||||
# Avant le fix : l'item aurait été persisté car "consecutive_failures += 1"
|
||||
# et "if priority_item: persist()". Avec le fix, on saute.
|
||||
assert buf.counts()["images"] == 0
|
||||
finally:
|
||||
streamer.queue.task_done()
|
||||
@@ -96,12 +96,14 @@ class TestWorkflowPipelineEnhanced:
|
||||
"confidence": 0.92
|
||||
}
|
||||
|
||||
# Mock de l'action suivante
|
||||
# Mock de l'action suivante (contrat dict normalisé Lot A)
|
||||
mock_workflow_pipeline.get_next_action.return_value = {
|
||||
"status": "selected",
|
||||
"edge_id": "edge_1",
|
||||
"action": {"type": "click", "target": "button"},
|
||||
"target_node": "node_2",
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"score": 0.95,
|
||||
}
|
||||
|
||||
# Mock du workflow
|
||||
@@ -242,7 +244,8 @@ class TestWorkflowPipelineEnhanced:
|
||||
}
|
||||
|
||||
# Mock de l'action suivante (pas d'action = workflow terminé)
|
||||
mock_workflow_pipeline.get_next_action.return_value = None
|
||||
# Contrat dict normalisé Lot A : status="terminal" pour fin légitime
|
||||
mock_workflow_pipeline.get_next_action.return_value = {"status": "terminal"}
|
||||
|
||||
# Créer l'instance enhanced
|
||||
enhanced = WorkflowPipelineEnhanced()
|
||||
@@ -347,12 +350,14 @@ class TestWorkflowPipelineEnhanced:
|
||||
"confidence": 0.92
|
||||
}
|
||||
|
||||
# Mock de l'action suivante
|
||||
# Mock de l'action suivante (contrat dict normalisé Lot A)
|
||||
mock_workflow_pipeline.get_next_action.return_value = {
|
||||
"status": "selected",
|
||||
"edge_id": "edge_1",
|
||||
"action": {"type": "click", "target": "button"},
|
||||
"target_node": "node_2",
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"score": 0.95,
|
||||
}
|
||||
|
||||
# Mock du workflow
|
||||
|
||||
520
tests/unit/test_analytics_vision_metrics.py
Normal file
520
tests/unit/test_analytics_vision_metrics.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Tests unitaires pour la remontée des champs vision-aware (C1) vers analytics.
|
||||
|
||||
Couvre :
|
||||
- StepMetrics.to_dict / from_dict avec les nouveaux champs
|
||||
- AnalyticsExecutionIntegration.on_step_result passe bien les champs
|
||||
- Persistance SQLite (schema + migration) des colonnes C1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics.collection.metrics_collector import StepMetrics
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# StepMetrics : sérialisation des champs C1
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_step_metrics(**overrides) -> StepMetrics:
|
||||
base = dict(
|
||||
step_id="s1",
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
node_id="n1",
|
||||
action_type="click",
|
||||
target_element="",
|
||||
started_at=datetime(2026, 4, 13, 10, 0, 0),
|
||||
completed_at=datetime(2026, 4, 13, 10, 0, 1),
|
||||
duration_ms=1000.0,
|
||||
status="completed",
|
||||
confidence_score=0.9,
|
||||
retry_count=0,
|
||||
error_details=None,
|
||||
)
|
||||
base.update(overrides)
|
||||
return StepMetrics(**base)
|
||||
|
||||
|
||||
class TestStepMetricsVisionFields:
|
||||
def test_default_vision_fields(self):
|
||||
m = _make_step_metrics()
|
||||
assert m.ocr_ms == 0.0
|
||||
assert m.ui_ms == 0.0
|
||||
assert m.analyze_ms == 0.0
|
||||
assert m.total_ms == 0.0
|
||||
assert m.cache_hit is False
|
||||
assert m.degraded is False
|
||||
|
||||
def test_to_dict_includes_vision_fields(self):
|
||||
m = _make_step_metrics(
|
||||
ocr_ms=120.5,
|
||||
ui_ms=45.0,
|
||||
analyze_ms=200.0,
|
||||
total_ms=1050.0,
|
||||
cache_hit=True,
|
||||
degraded=True,
|
||||
)
|
||||
d = m.to_dict()
|
||||
assert d["ocr_ms"] == 120.5
|
||||
assert d["ui_ms"] == 45.0
|
||||
assert d["analyze_ms"] == 200.0
|
||||
assert d["total_ms"] == 1050.0
|
||||
assert d["cache_hit"] is True
|
||||
assert d["degraded"] is True
|
||||
|
||||
def test_from_dict_roundtrip(self):
|
||||
original = _make_step_metrics(
|
||||
ocr_ms=10.0, ui_ms=20.0, analyze_ms=30.0,
|
||||
total_ms=100.0, cache_hit=True, degraded=False,
|
||||
)
|
||||
restored = StepMetrics.from_dict(original.to_dict())
|
||||
assert restored.ocr_ms == 10.0
|
||||
assert restored.ui_ms == 20.0
|
||||
assert restored.analyze_ms == 30.0
|
||||
assert restored.total_ms == 100.0
|
||||
assert restored.cache_hit is True
|
||||
assert restored.degraded is False
|
||||
|
||||
def test_from_dict_missing_vision_fields_defaults_to_zero(self):
|
||||
"""Rétrocompatibilité : un dict sans champs C1 doit produire 0/False."""
|
||||
restored = StepMetrics.from_dict({
|
||||
'step_id': 's1',
|
||||
'execution_id': 'e1',
|
||||
'workflow_id': 'w1',
|
||||
'node_id': 'n1',
|
||||
'action_type': 'click',
|
||||
'target_element': '',
|
||||
'started_at': datetime.now().isoformat(),
|
||||
'completed_at': datetime.now().isoformat(),
|
||||
'duration_ms': 100.0,
|
||||
'status': 'completed',
|
||||
'confidence_score': 0.5,
|
||||
})
|
||||
assert restored.ocr_ms == 0.0
|
||||
assert restored.cache_hit is False
|
||||
assert restored.degraded is False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AnalyticsExecutionIntegration.on_step_result
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeStepResult:
|
||||
"""Stand-in minimal pour core.execution.execution_loop.StepResult."""
|
||||
def __init__(self, **kw):
|
||||
self.success = kw.get("success", True)
|
||||
self.node_id = kw.get("node_id", "n1")
|
||||
self.edge_id = kw.get("edge_id", None)
|
||||
self.action_result = kw.get("action_result", None)
|
||||
self.match_confidence = kw.get("match_confidence", 0.9)
|
||||
self.duration_ms = kw.get("duration_ms", 100.0)
|
||||
self.message = kw.get("message", "")
|
||||
self.ocr_ms = kw.get("ocr_ms", 0.0)
|
||||
self.ui_ms = kw.get("ui_ms", 0.0)
|
||||
self.analyze_ms = kw.get("analyze_ms", 0.0)
|
||||
self.total_ms = kw.get("total_ms", 0.0)
|
||||
self.cache_hit = kw.get("cache_hit", False)
|
||||
self.degraded = kw.get("degraded", False)
|
||||
|
||||
|
||||
class TestAnalyticsOnStepResult:
|
||||
def test_on_step_result_passes_vision_fields(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
# Analytics system mocké
|
||||
fake_system = MagicMock()
|
||||
integration = AnalyticsExecutionIntegration(fake_system)
|
||||
|
||||
step = _FakeStepResult(
|
||||
node_id="node_click",
|
||||
success=True,
|
||||
match_confidence=0.87,
|
||||
duration_ms=1234.0,
|
||||
ocr_ms=111.0,
|
||||
ui_ms=222.0,
|
||||
analyze_ms=333.0,
|
||||
total_ms=1234.0,
|
||||
cache_hit=True,
|
||||
degraded=False,
|
||||
)
|
||||
|
||||
integration.on_step_result(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
step_result=step,
|
||||
)
|
||||
|
||||
# Vérifie qu'un StepMetrics avec les bons champs a été enregistré
|
||||
record_calls = fake_system.metrics_collector.record_step.call_args_list
|
||||
assert len(record_calls) == 1
|
||||
recorded: StepMetrics = record_calls[0].args[0]
|
||||
assert isinstance(recorded, StepMetrics)
|
||||
assert recorded.node_id == "node_click"
|
||||
assert recorded.workflow_id == "wf1"
|
||||
assert recorded.execution_id == "exec1"
|
||||
assert recorded.confidence_score == 0.87
|
||||
assert recorded.duration_ms == 1234.0
|
||||
assert recorded.ocr_ms == 111.0
|
||||
assert recorded.ui_ms == 222.0
|
||||
assert recorded.analyze_ms == 333.0
|
||||
assert recorded.total_ms == 1234.0
|
||||
assert recorded.cache_hit is True
|
||||
assert recorded.degraded is False
|
||||
assert recorded.status == "completed"
|
||||
|
||||
def test_on_step_result_failed_step(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
fake_system = MagicMock()
|
||||
integration = AnalyticsExecutionIntegration(fake_system)
|
||||
|
||||
step = _FakeStepResult(
|
||||
success=False,
|
||||
message="Click failed",
|
||||
degraded=True,
|
||||
)
|
||||
|
||||
integration.on_step_result("e1", "w1", step)
|
||||
|
||||
recorded: StepMetrics = fake_system.metrics_collector.record_step.call_args.args[0]
|
||||
assert recorded.status == "failed"
|
||||
assert recorded.error_details == "Click failed"
|
||||
assert recorded.degraded is True
|
||||
|
||||
def test_on_step_result_disabled_integration_is_noop(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
integration = AnalyticsExecutionIntegration(None) # désactivé
|
||||
assert integration.enabled is False
|
||||
|
||||
step = _FakeStepResult()
|
||||
# Ne doit rien faire ni lever d'exception
|
||||
integration.on_step_result("e1", "w1", step)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AnalyticsExecutionIntegration.on_execution_complete (Lot A — avril 2026)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalyticsOnExecutionComplete:
|
||||
"""Contrat normalisé : duration_ms (ms) + status (str), pas de magie."""
|
||||
|
||||
def _make_integration(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
fake_system = MagicMock()
|
||||
# Pas d'execution active : l'intégration doit emprunter le fallback
|
||||
# "ExecutionMetrics synthétique pushé dans _buffer".
|
||||
fake_system.metrics_collector._active_executions = {}
|
||||
fake_system.metrics_collector._lock = MagicMock()
|
||||
fake_system.metrics_collector._lock.__enter__ = MagicMock(
|
||||
return_value=None
|
||||
)
|
||||
fake_system.metrics_collector._lock.__exit__ = MagicMock(
|
||||
return_value=None
|
||||
)
|
||||
fake_system.metrics_collector._buffer = []
|
||||
return AnalyticsExecutionIntegration(fake_system), fake_system
|
||||
|
||||
def test_fallback_builds_execution_metrics_with_correct_fields(self):
|
||||
"""Sans record_execution_start préalable, on construit un
|
||||
ExecutionMetrics synthétique avec les bons noms de champs."""
|
||||
from core.analytics.collection.metrics_collector import ExecutionMetrics
|
||||
|
||||
integration, fake_system = self._make_integration()
|
||||
|
||||
integration.on_execution_complete(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
duration_ms=1500.0,
|
||||
status="completed",
|
||||
steps_total=3,
|
||||
steps_completed=3,
|
||||
steps_failed=0,
|
||||
)
|
||||
|
||||
# Un ExecutionMetrics a été pushé dans le buffer
|
||||
buffer = fake_system.metrics_collector._buffer
|
||||
assert len(buffer) == 1
|
||||
metric: ExecutionMetrics = buffer[0]
|
||||
assert isinstance(metric, ExecutionMetrics)
|
||||
assert metric.execution_id == "exec1"
|
||||
assert metric.workflow_id == "wf1"
|
||||
assert metric.duration_ms == 1500.0
|
||||
assert metric.status == "completed"
|
||||
assert metric.steps_total == 3
|
||||
assert metric.steps_completed == 3
|
||||
assert metric.steps_failed == 0
|
||||
# started_at / completed_at sont cohérents
|
||||
delta_ms = (
|
||||
metric.completed_at - metric.started_at
|
||||
).total_seconds() * 1000
|
||||
assert abs(delta_ms - 1500.0) < 1.0
|
||||
|
||||
def test_uses_record_execution_complete_if_active(self):
|
||||
"""Si l'execution a été ouverte via on_execution_start, on délègue
|
||||
à record_execution_complete (chemin nominal)."""
|
||||
integration, fake_system = self._make_integration()
|
||||
# Simuler une execution active
|
||||
fake_system.metrics_collector._active_executions = {"exec1": object()}
|
||||
|
||||
integration.on_execution_complete(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
duration_ms=800.0,
|
||||
status="failed",
|
||||
steps_total=2,
|
||||
steps_completed=1,
|
||||
steps_failed=1,
|
||||
error_message="timeout",
|
||||
)
|
||||
|
||||
call = fake_system.metrics_collector.record_execution_complete.call_args
|
||||
assert call is not None
|
||||
kwargs = call.kwargs
|
||||
assert kwargs["execution_id"] == "exec1"
|
||||
assert kwargs["status"] == "failed"
|
||||
assert kwargs["steps_total"] == 2
|
||||
assert kwargs["steps_completed"] == 1
|
||||
assert kwargs["steps_failed"] == 1
|
||||
assert kwargs["error_message"] == "timeout"
|
||||
|
||||
def test_steps_total_derived_when_not_provided(self):
|
||||
"""steps_total déduit par somme si absent, pas d'erreur silencieuse."""
|
||||
integration, fake_system = self._make_integration()
|
||||
|
||||
integration.on_execution_complete(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
duration_ms=500.0,
|
||||
status="completed",
|
||||
steps_completed=2,
|
||||
steps_failed=1,
|
||||
)
|
||||
|
||||
metric = fake_system.metrics_collector._buffer[0]
|
||||
assert metric.steps_total == 3 # 2 + 1
|
||||
|
||||
def test_disabled_integration_is_noop(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
integration = AnalyticsExecutionIntegration(None)
|
||||
assert integration.enabled is False
|
||||
|
||||
# Ne doit rien faire ni lever d'exception
|
||||
integration.on_execution_complete(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
duration_ms=100.0,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
def test_realtime_complete_called(self):
|
||||
"""Le tracking temps réel est clos avec le bon status."""
|
||||
integration, fake_system = self._make_integration()
|
||||
|
||||
integration.on_execution_complete(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
duration_ms=100.0,
|
||||
status="stopped",
|
||||
)
|
||||
|
||||
fake_system.realtime_analytics.complete_execution.assert_called_once_with(
|
||||
execution_id="exec1",
|
||||
status="stopped",
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AnalyticsExecutionIntegration.on_recovery_attempt (Lot A — avril 2026)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalyticsOnRecoveryAttempt:
|
||||
"""Contrat normalisé : StepMetrics construit avec les vrais champs."""
|
||||
|
||||
def test_success_recovery_builds_valid_step_metrics(self):
|
||||
from core.analytics.collection.metrics_collector import StepMetrics
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
fake_system = MagicMock()
|
||||
integration = AnalyticsExecutionIntegration(fake_system)
|
||||
|
||||
integration.on_recovery_attempt(
|
||||
execution_id="exec1",
|
||||
workflow_id="wf1",
|
||||
node_id="node_click",
|
||||
strategy="retry_with_delay",
|
||||
success=True,
|
||||
duration_ms=250.0,
|
||||
)
|
||||
|
||||
call = fake_system.metrics_collector.record_step.call_args
|
||||
assert call is not None
|
||||
recorded: StepMetrics = call.args[0]
|
||||
assert isinstance(recorded, StepMetrics)
|
||||
assert recorded.execution_id == "exec1"
|
||||
assert recorded.workflow_id == "wf1"
|
||||
assert recorded.node_id == "node_click_recovery"
|
||||
assert recorded.action_type == "recovery_retry_with_delay"
|
||||
assert recorded.duration_ms == 250.0
|
||||
assert recorded.status == "completed"
|
||||
assert recorded.error_details is None
|
||||
# Champs obligatoires du dataclass
|
||||
assert recorded.step_id # non vide
|
||||
assert recorded.target_element == ""
|
||||
assert recorded.confidence_score == 0.0
|
||||
|
||||
def test_failed_recovery_sets_status_and_error_details(self):
|
||||
from core.analytics.collection.metrics_collector import StepMetrics
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
fake_system = MagicMock()
|
||||
integration = AnalyticsExecutionIntegration(fake_system)
|
||||
|
||||
integration.on_recovery_attempt(
|
||||
execution_id="e1",
|
||||
workflow_id="w1",
|
||||
node_id="n1",
|
||||
strategy="fallback_to_parent",
|
||||
success=False,
|
||||
duration_ms=80.0,
|
||||
)
|
||||
|
||||
recorded: StepMetrics = (
|
||||
fake_system.metrics_collector.record_step.call_args.args[0]
|
||||
)
|
||||
assert recorded.status == "failed"
|
||||
assert recorded.error_details == "Recovery failed: fallback_to_parent"
|
||||
assert recorded.duration_ms == 80.0
|
||||
|
||||
def test_disabled_integration_is_noop(self):
|
||||
from core.analytics.integration.execution_integration import (
|
||||
AnalyticsExecutionIntegration,
|
||||
)
|
||||
|
||||
integration = AnalyticsExecutionIntegration(None)
|
||||
integration.on_recovery_attempt(
|
||||
execution_id="e1",
|
||||
workflow_id="w1",
|
||||
node_id="n1",
|
||||
strategy="x",
|
||||
success=True,
|
||||
duration_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Persistance SQLite : schema + migration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTimeSeriesStoreSchema:
|
||||
def test_new_store_has_vision_columns(self, tmp_path):
|
||||
from core.analytics.storage.timeseries_store import TimeSeriesStore
|
||||
|
||||
store = TimeSeriesStore(tmp_path)
|
||||
with sqlite3.connect(str(store.db_path)) as conn:
|
||||
cols = {row[1] for row in conn.execute(
|
||||
"PRAGMA table_info(step_metrics)"
|
||||
)}
|
||||
# Colonnes legacy
|
||||
assert "duration_ms" in cols
|
||||
assert "confidence_score" in cols
|
||||
# Colonnes C1
|
||||
assert "ocr_ms" in cols
|
||||
assert "ui_ms" in cols
|
||||
assert "analyze_ms" in cols
|
||||
assert "total_ms" in cols
|
||||
assert "cache_hit" in cols
|
||||
assert "degraded" in cols
|
||||
|
||||
def test_migration_adds_missing_columns(self, tmp_path):
|
||||
"""Base pré-existante sans les colonnes C1 — la migration doit les ajouter."""
|
||||
from core.analytics.storage.timeseries_store import TimeSeriesStore
|
||||
|
||||
# Créer une base "legacy" manuellement, sans les nouvelles colonnes
|
||||
storage_dir = tmp_path / "legacy"
|
||||
storage_dir.mkdir()
|
||||
legacy_db = storage_dir / "timeseries.db"
|
||||
with sqlite3.connect(str(legacy_db)) as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE step_metrics (
|
||||
step_id TEXT PRIMARY KEY,
|
||||
execution_id TEXT NOT NULL,
|
||||
workflow_id TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
target_element TEXT,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP NOT NULL,
|
||||
duration_ms REAL NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
confidence_score REAL,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
error_details TEXT
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Instancier TimeSeriesStore → doit migrer
|
||||
_ = TimeSeriesStore(storage_dir)
|
||||
|
||||
with sqlite3.connect(str(legacy_db)) as conn:
|
||||
cols = {row[1] for row in conn.execute(
|
||||
"PRAGMA table_info(step_metrics)"
|
||||
)}
|
||||
assert "ocr_ms" in cols
|
||||
assert "cache_hit" in cols
|
||||
assert "degraded" in cols
|
||||
|
||||
def test_write_and_read_vision_metrics(self, tmp_path):
|
||||
from core.analytics.storage.timeseries_store import TimeSeriesStore
|
||||
|
||||
store = TimeSeriesStore(tmp_path)
|
||||
metric = _make_step_metrics(
|
||||
ocr_ms=50.0, ui_ms=60.0, analyze_ms=110.0,
|
||||
total_ms=500.0, cache_hit=True, degraded=True,
|
||||
)
|
||||
store.write_metrics([metric])
|
||||
|
||||
with sqlite3.connect(str(store.db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT * FROM step_metrics WHERE step_id = ?", (metric.step_id,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["ocr_ms"] == 50.0
|
||||
assert row["ui_ms"] == 60.0
|
||||
assert row["analyze_ms"] == 110.0
|
||||
assert row["total_ms"] == 500.0
|
||||
# SQLite stocke les bool comme INTEGER
|
||||
assert row["cache_hit"] == 1
|
||||
assert row["degraded"] == 1
|
||||
171
tests/unit/test_api_stream_auth_p0bc.py
Normal file
171
tests/unit/test_api_stream_auth_p0bc.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Tests des Fix P0-B et P0-C sur agent_v0/server_v1/api_stream.py.
|
||||
|
||||
P0-B : /api/v1/traces/stream/image n'est PLUS dans _PUBLIC_PATHS.
|
||||
L'upload d'image exige désormais un Bearer token.
|
||||
|
||||
P0-C : Si RPA_API_TOKEN est absent ET RPA_AUTH_DISABLED ≠ true,
|
||||
le module DOIT refuser de se charger (sys.exit 1).
|
||||
En mode dev (RPA_AUTH_DISABLED=true), pas de crash mais log warning.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
def _reload_api_stream():
|
||||
"""Recharge le module api_stream pour appliquer les nouvelles env vars."""
|
||||
mod_name = "agent_v0.server_v1.api_stream"
|
||||
if mod_name in sys.modules:
|
||||
del sys.modules[mod_name]
|
||||
return importlib.import_module(mod_name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix P0-B : /image n'est plus public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImageEndpointNotPublic:
|
||||
"""Fix P0-B : /api/v1/traces/stream/image exige un Bearer token."""
|
||||
|
||||
def test_image_path_removed_from_public_paths(self, monkeypatch):
|
||||
"""Vérifier que la constante _PUBLIC_PATHS ne contient plus /image."""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "deadbeef" * 4)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
mod = _reload_api_stream()
|
||||
assert "/api/v1/traces/stream/image" not in mod._PUBLIC_PATHS, (
|
||||
"L'endpoint d'upload d'image NE doit PAS être public — il accepte "
|
||||
"des bytes arbitraires et déclenche du travail VLM côté serveur."
|
||||
)
|
||||
|
||||
def test_health_still_public(self, monkeypatch):
|
||||
"""/health reste public (monitoring)."""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "deadbeef" * 4)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
mod = _reload_api_stream()
|
||||
assert "/health" in mod._PUBLIC_PATHS
|
||||
|
||||
def test_replay_next_still_public(self, monkeypatch):
|
||||
"""/replay/next reste public (legacy agent Rust polling)."""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "deadbeef" * 4)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
mod = _reload_api_stream()
|
||||
assert "/api/v1/traces/stream/replay/next" in mod._PUBLIC_PATHS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix P0-C : fail-closed si pas de token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFailClosedTokenP0C:
|
||||
"""Fix P0-C : RPA_API_TOKEN absent → sys.exit (pas de génération silencieuse)."""
|
||||
|
||||
def test_no_token_no_disable_exits(self, monkeypatch):
|
||||
"""Sans RPA_API_TOKEN ET sans RPA_AUTH_DISABLED → SystemExit(1)."""
|
||||
monkeypatch.delenv("RPA_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_reload_api_stream()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_empty_token_no_disable_exits(self, monkeypatch):
|
||||
"""Token explicitement vide → SystemExit (pas généré aléatoirement)."""
|
||||
monkeypatch.setenv("RPA_API_TOKEN", " ") # whitespace, strippé
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_reload_api_stream()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_no_token_with_disable_succeeds(self, monkeypatch):
|
||||
"""Sans token MAIS RPA_AUTH_DISABLED=true → chargement OK (mode dev)."""
|
||||
monkeypatch.delenv("RPA_API_TOKEN", raising=False)
|
||||
monkeypatch.setenv("RPA_AUTH_DISABLED", "true")
|
||||
# Doit pas crash
|
||||
mod = _reload_api_stream()
|
||||
assert mod._AUTH_DISABLED is True
|
||||
# API_TOKEN existe toujours (généré pour cohérence interne, jamais utilisé)
|
||||
assert mod.API_TOKEN, "Un token interne est toujours défini en mode dev"
|
||||
|
||||
def test_token_present_logs_prefix(self, monkeypatch, caplog):
|
||||
"""Avec un token valide, le module log les 8 premiers caractères."""
|
||||
import logging
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "abcdef0123456789" * 2)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
with caplog.at_level(logging.INFO, logger="api_stream"):
|
||||
mod = _reload_api_stream()
|
||||
# Le log INFO contient le préfixe (8 premiers chars)
|
||||
assert mod.API_TOKEN == "abcdef0123456789" * 2
|
||||
# Au moins une trace contient "abcdef01" (préfixe)
|
||||
log_text = " ".join(r.getMessage() for r in caplog.records)
|
||||
assert "abcdef01" in log_text or "Token API chargé" in log_text
|
||||
|
||||
def test_verify_token_bypass_when_disabled(self, monkeypatch):
|
||||
"""Mode dev : _verify_token doit laisser passer sans header."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
monkeypatch.delenv("RPA_API_TOKEN", raising=False)
|
||||
monkeypatch.setenv("RPA_AUTH_DISABLED", "true")
|
||||
mod = _reload_api_stream()
|
||||
|
||||
# Forger une requête sans header sur un endpoint normalement protégé
|
||||
req = MagicMock()
|
||||
req.url.path = "/api/v1/traces/stream/event"
|
||||
req.headers = {}
|
||||
# Ne doit pas raise
|
||||
asyncio.get_event_loop().run_until_complete(mod._verify_token(req))
|
||||
|
||||
def test_verify_token_rejects_missing_header(self, monkeypatch):
|
||||
"""Auth activée : pas de header → HTTPException 401."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import HTTPException
|
||||
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "validtoken" * 4)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
mod = _reload_api_stream()
|
||||
|
||||
req = MagicMock()
|
||||
req.url.path = "/api/v1/traces/stream/image" # Désormais protégé (P0-B)
|
||||
req.headers = {}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.get_event_loop().run_until_complete(mod._verify_token(req))
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_verify_token_rejects_image_without_bearer(self, monkeypatch):
|
||||
"""P0-B + P0-C : POST /image sans token → 401 (l'endpoint n'est plus public)."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import HTTPException
|
||||
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "validtoken" * 4)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
mod = _reload_api_stream()
|
||||
|
||||
req = MagicMock()
|
||||
req.url.path = "/api/v1/traces/stream/image"
|
||||
req.headers = {"Authorization": "Bearer wrong-token"}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.get_event_loop().run_until_complete(mod._verify_token(req))
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup(monkeypatch):
|
||||
"""Nettoie l'environnement entre les tests pour éviter la pollution."""
|
||||
yield
|
||||
# Recharger avec un token bidon pour ne pas casser les autres suites
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "cleanup-token" * 3)
|
||||
monkeypatch.delenv("RPA_AUTH_DISABLED", raising=False)
|
||||
try:
|
||||
_reload_api_stream()
|
||||
except SystemExit:
|
||||
pass
|
||||
160
tests/unit/test_dashboard_auth_p0a.py
Normal file
160
tests/unit/test_dashboard_auth_p0a.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Tests du Fix P0-A : authentification HTTP Basic sur le dashboard Flask (port 5001).
|
||||
|
||||
Avant ce fix, 71 endpoints étaient exposés sans auth. Le middleware
|
||||
`_dashboard_basic_auth_middleware` ajoute un challenge 401 sur toutes les
|
||||
routes HTTP sauf les healthchecks publics.
|
||||
|
||||
Contrôles :
|
||||
- Sans Authorization header → 401 avec WWW-Authenticate
|
||||
- Avec mauvais credentials → 401
|
||||
- Avec bons credentials → passage normal (200)
|
||||
- /health, /healthz, /api/health restent publics (monitoring externe)
|
||||
- Mode TESTING sans DASHBOARD_AUTH_ENABLED → bypass (compat tests existants)
|
||||
- DASHBOARD_AUTH_DISABLED=true → bypass global (dev local)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ajouter le répertoire racine au path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_enabled_client(monkeypatch):
|
||||
"""Client Flask avec l'auth activée (TESTING + DASHBOARD_AUTH_ENABLED).
|
||||
|
||||
On recharge le module pour forcer la relecture des variables d'env.
|
||||
"""
|
||||
monkeypatch.setenv("DASHBOARD_USER", "lea")
|
||||
monkeypatch.setenv("DASHBOARD_PASSWORD", "secret-test-pwd")
|
||||
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
|
||||
|
||||
# Recharger le module pour que les constantes soient relues
|
||||
if "web_dashboard.app" in sys.modules:
|
||||
importlib.reload(sys.modules["web_dashboard.app"])
|
||||
from web_dashboard.app import app
|
||||
|
||||
app.config["TESTING"] = True
|
||||
app.config["DASHBOARD_AUTH_ENABLED"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_disabled_client(monkeypatch):
|
||||
"""Client Flask avec l'auth désactivée (bypass global)."""
|
||||
monkeypatch.setenv("DASHBOARD_AUTH_DISABLED", "true")
|
||||
if "web_dashboard.app" in sys.modules:
|
||||
importlib.reload(sys.modules["web_dashboard.app"])
|
||||
from web_dashboard.app import app
|
||||
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _basic_auth_header(user: str, password: str) -> str:
|
||||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return f"Basic {token}"
|
||||
|
||||
|
||||
class TestDashboardAuthP0A:
|
||||
"""Fix P0-A : auth HTTP Basic obligatoire sur le dashboard."""
|
||||
|
||||
def test_no_auth_header_returns_401(self, auth_enabled_client):
|
||||
"""Sans header Authorization → 401 + challenge WWW-Authenticate."""
|
||||
resp = auth_enabled_client.get("/api/system/status")
|
||||
assert resp.status_code == 401
|
||||
assert "WWW-Authenticate" in resp.headers
|
||||
assert "Basic" in resp.headers["WWW-Authenticate"]
|
||||
|
||||
def test_wrong_password_returns_401(self, auth_enabled_client):
|
||||
"""Mauvais mot de passe → 401."""
|
||||
resp = auth_enabled_client.get(
|
||||
"/api/system/status",
|
||||
headers={"Authorization": _basic_auth_header("lea", "wrong")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_wrong_user_returns_401(self, auth_enabled_client):
|
||||
"""Mauvais utilisateur → 401."""
|
||||
resp = auth_enabled_client.get(
|
||||
"/api/system/status",
|
||||
headers={"Authorization": _basic_auth_header("intruder", "secret-test-pwd")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_malformed_header_returns_401(self, auth_enabled_client):
|
||||
"""Header mal formé (pas de Basic) → 401."""
|
||||
resp = auth_enabled_client.get(
|
||||
"/api/system/status",
|
||||
headers={"Authorization": "Bearer tototoken"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_valid_credentials_pass(self, auth_enabled_client):
|
||||
"""Bons credentials → 200."""
|
||||
resp = auth_enabled_client.get(
|
||||
"/api/system/status",
|
||||
headers={"Authorization": _basic_auth_header("lea", "secret-test-pwd")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_healthz_public(self, auth_enabled_client):
|
||||
"""/healthz reste public (systemd healthcheck)."""
|
||||
resp = auth_enabled_client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_health_public(self, auth_enabled_client):
|
||||
"""/health reste public (monitoring externe)."""
|
||||
resp = auth_enabled_client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_health_public(self, auth_enabled_client):
|
||||
"""/api/health reste public (NPM reverse proxy)."""
|
||||
resp = auth_enabled_client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_auth_disabled_bypass(self, auth_disabled_client):
|
||||
"""DASHBOARD_AUTH_DISABLED=true → pas d'auth requise."""
|
||||
resp = auth_disabled_client.get("/api/system/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_config_endpoint_requires_auth(self, auth_enabled_client):
|
||||
"""L'endpoint sensible /api/config exige l'auth."""
|
||||
resp = auth_enabled_client.get("/api/config")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_services_endpoint_requires_auth(self, auth_enabled_client):
|
||||
"""L'endpoint sensible /api/services exige l'auth."""
|
||||
resp = auth_enabled_client.get("/api/services")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_services_start_all_requires_auth(self, auth_enabled_client):
|
||||
"""Un endpoint POST destructeur exige l'auth."""
|
||||
resp = auth_enabled_client.post("/api/services/start-all")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_index_page_requires_auth(self, auth_enabled_client):
|
||||
"""Même la page HTML d'accueil exige l'auth (pas de leak côté public)."""
|
||||
resp = auth_enabled_client.get("/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_module(monkeypatch):
|
||||
"""Recharge web_dashboard.app après chaque test pour que les autres
|
||||
tests (TestDashboardRoutes sans auth explicite) continuent de passer."""
|
||||
yield
|
||||
monkeypatch.delenv("DASHBOARD_AUTH_DISABLED", raising=False)
|
||||
monkeypatch.delenv("DASHBOARD_USER", raising=False)
|
||||
monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
|
||||
if "web_dashboard.app" in sys.modules:
|
||||
importlib.reload(sys.modules["web_dashboard.app"])
|
||||
337
tests/unit/test_edge_scorer.py
Normal file
337
tests/unit/test_edge_scorer.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Tests unitaires de l'EdgeScorer (C3).
|
||||
|
||||
Couvre :
|
||||
- Filtre dur : pre_conditions échouent → edge rejeté
|
||||
- Ranking : edge avec success_rate le plus élevé gagne
|
||||
- Tiebreak sur success_rate
|
||||
- Retour None si aucun edge valide
|
||||
- Target match via ui_elements
|
||||
- Mode legacy strategy="first"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.models.ui_element import UIElement, UIElementEmbeddings, VisualFeatures
|
||||
from core.models.base_models import BBox
|
||||
from core.models.workflow_graph import (
|
||||
Action,
|
||||
EdgeConstraints,
|
||||
EdgeStats,
|
||||
PostConditions,
|
||||
TargetSpec,
|
||||
WorkflowEdge,
|
||||
)
|
||||
from core.pipeline.edge_scorer import EdgeScorer
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_edge(
|
||||
edge_id: str,
|
||||
by_text: str | None = None,
|
||||
by_role: str | None = None,
|
||||
success_rate: float | None = None,
|
||||
execution_count: int = 0,
|
||||
last_executed: datetime | None = None,
|
||||
required_window_title: str | None = None,
|
||||
required_app_name: str | None = None,
|
||||
min_source_similarity: float = 0.80,
|
||||
) -> WorkflowEdge:
|
||||
stats = EdgeStats()
|
||||
if success_rate is not None and execution_count > 0:
|
||||
stats.execution_count = execution_count
|
||||
stats.success_count = int(round(success_rate * execution_count))
|
||||
stats.failure_count = execution_count - stats.success_count
|
||||
stats.last_executed = last_executed
|
||||
|
||||
target = TargetSpec(by_text=by_text, by_role=by_role)
|
||||
action = Action(type="mouse_click", target=target)
|
||||
constraints = EdgeConstraints(
|
||||
required_window_title=required_window_title or "",
|
||||
required_app_name=required_app_name or "",
|
||||
min_source_similarity=min_source_similarity,
|
||||
)
|
||||
|
||||
return WorkflowEdge(
|
||||
edge_id=edge_id,
|
||||
from_node="n1",
|
||||
to_node="n2",
|
||||
action=action,
|
||||
constraints=constraints,
|
||||
post_conditions=PostConditions(),
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
|
||||
def _make_ui_element(
|
||||
element_id: str, label: str, role: str = "button", type_: str = "button"
|
||||
) -> UIElement:
|
||||
return UIElement(
|
||||
element_id=element_id,
|
||||
type=type_,
|
||||
role=role,
|
||||
bbox=BBox(x=0, y=0, width=100, height=30),
|
||||
center=(50, 15),
|
||||
label=label,
|
||||
label_confidence=0.9,
|
||||
embeddings=UIElementEmbeddings(),
|
||||
visual_features=VisualFeatures(
|
||||
dominant_color="#000",
|
||||
has_icon=False,
|
||||
shape="rectangle",
|
||||
size_category="medium",
|
||||
),
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
|
||||
def _make_state(
|
||||
window_title: str = "Firefox",
|
||||
app_name: str = "firefox",
|
||||
detected_text: list[str] | None = None,
|
||||
ui_elements: list[UIElement] | None = None,
|
||||
) -> ScreenState:
|
||||
return ScreenState(
|
||||
screen_state_id="s1",
|
||||
timestamp=datetime.now(),
|
||||
session_id="sess",
|
||||
window=WindowContext(
|
||||
app_name=app_name,
|
||||
window_title=window_title,
|
||||
screen_resolution=[1920, 1080],
|
||||
),
|
||||
raw=RawLevel(screenshot_path="", capture_method="t", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=detected_text or [],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=ui_elements or [],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeScorerBasic:
|
||||
|
||||
def test_returns_none_on_empty(self):
|
||||
assert EdgeScorer().select_best([]) is None
|
||||
|
||||
def test_single_edge_returned_when_no_constraints(self):
|
||||
edge = _make_edge("e1")
|
||||
state = _make_state()
|
||||
assert EdgeScorer().select_best([edge], screen_state=state) == edge
|
||||
|
||||
def test_strategy_first_returns_first_edge(self):
|
||||
e1 = _make_edge("e1", success_rate=0.1, execution_count=10)
|
||||
e2 = _make_edge("e2", success_rate=0.9, execution_count=10)
|
||||
state = _make_state()
|
||||
result = EdgeScorer().select_best(
|
||||
[e1, e2], screen_state=state, strategy="first"
|
||||
)
|
||||
assert result.edge_id == "e1"
|
||||
|
||||
|
||||
class TestEdgeScorerFilter:
|
||||
|
||||
def test_rejects_edge_with_wrong_window(self):
|
||||
"""Un edge exigeant un titre de fenêtre différent doit être rejeté."""
|
||||
e1 = _make_edge("e1", required_window_title="Chrome")
|
||||
state = _make_state(window_title="Firefox")
|
||||
result = EdgeScorer().select_best([e1], screen_state=state)
|
||||
assert result is None
|
||||
|
||||
def test_rejects_edge_with_wrong_app(self):
|
||||
e1 = _make_edge("e1", required_app_name="chrome")
|
||||
state = _make_state(app_name="firefox")
|
||||
result = EdgeScorer().select_best([e1], screen_state=state)
|
||||
assert result is None
|
||||
|
||||
def test_keeps_valid_edge_when_one_rejected(self):
|
||||
"""Cas simple : 2 edges, un seul valide."""
|
||||
e_bad = _make_edge("e_bad", required_window_title="NopeApp")
|
||||
e_ok = _make_edge("e_ok", required_window_title="Firefox")
|
||||
state = _make_state(window_title="Firefox Browser")
|
||||
result = EdgeScorer().select_best([e_bad, e_ok], screen_state=state)
|
||||
assert result is not None
|
||||
assert result.edge_id == "e_ok"
|
||||
|
||||
|
||||
class TestEdgeScorerRanking:
|
||||
|
||||
def test_higher_success_rate_wins(self):
|
||||
"""Cas : 2 edges valides, celui avec meilleur success_rate gagne."""
|
||||
e_low = _make_edge("e_low", success_rate=0.20, execution_count=20)
|
||||
e_high = _make_edge("e_high", success_rate=0.95, execution_count=20)
|
||||
state = _make_state()
|
||||
result = EdgeScorer().select_best([e_low, e_high], screen_state=state)
|
||||
assert result.edge_id == "e_high"
|
||||
|
||||
def test_rank_returns_sorted_by_score(self):
|
||||
e1 = _make_edge("e1", success_rate=0.3, execution_count=10)
|
||||
e2 = _make_edge("e2", success_rate=0.9, execution_count=10)
|
||||
e3 = _make_edge("e3", success_rate=0.6, execution_count=10)
|
||||
state = _make_state()
|
||||
ranked = EdgeScorer().rank([e1, e2, e3], screen_state=state)
|
||||
ids = [s.edge.edge_id for s in ranked]
|
||||
assert ids == ["e2", "e3", "e1"]
|
||||
|
||||
def test_target_match_boost(self):
|
||||
"""Un edge qui match un UI element gagne face à un sans match."""
|
||||
e_match = _make_edge("e_match", by_text="Submit")
|
||||
e_no_match = _make_edge("e_no_match", by_text="DoesNotExist")
|
||||
ui = _make_ui_element("btn1", label="Submit")
|
||||
state = _make_state(ui_elements=[ui])
|
||||
|
||||
ranked = EdgeScorer().rank([e_no_match, e_match], screen_state=state)
|
||||
assert ranked[0].edge.edge_id == "e_match"
|
||||
assert ranked[0].target_match > ranked[1].target_match
|
||||
|
||||
def test_recency_bonus_for_recent_execution(self):
|
||||
recent = _make_edge(
|
||||
"recent",
|
||||
success_rate=0.5,
|
||||
execution_count=10,
|
||||
last_executed=datetime.now() - timedelta(hours=1),
|
||||
)
|
||||
old = _make_edge(
|
||||
"old",
|
||||
success_rate=0.5,
|
||||
execution_count=10,
|
||||
last_executed=datetime.now() - timedelta(days=30),
|
||||
)
|
||||
scorer = EdgeScorer()
|
||||
state = _make_state()
|
||||
ranked = scorer.rank([old, recent], screen_state=state)
|
||||
# Même success_rate, récence tranche → recent gagne
|
||||
assert ranked[0].edge.edge_id == "recent"
|
||||
|
||||
|
||||
class TestEdgeScorerNoValidEdge:
|
||||
|
||||
def test_all_edges_rejected_returns_none(self):
|
||||
e1 = _make_edge("e1", required_window_title="AppA")
|
||||
e2 = _make_edge("e2", required_window_title="AppB")
|
||||
state = _make_state(window_title="AppC")
|
||||
assert EdgeScorer().select_best([e1, e2], screen_state=state) is None
|
||||
|
||||
def test_no_screen_state_does_not_filter(self):
|
||||
"""Sans ScreenState, on ne peut pas évaluer les pre_conditions → laisser passer."""
|
||||
e1 = _make_edge("e1", required_window_title="StrictApp")
|
||||
result = EdgeScorer().select_best([e1], screen_state=None)
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestEdgeScorerSourceSimilarity:
|
||||
"""Lot B — la contrainte `min_source_similarity` redevient effective."""
|
||||
|
||||
def test_min_source_similarity_pass(self):
|
||||
"""Edge accepté lorsque source_similarity >= min_source_similarity."""
|
||||
edge = _make_edge("e1", min_source_similarity=0.80)
|
||||
state = _make_state()
|
||||
result = EdgeScorer().select_best(
|
||||
[edge], screen_state=state, source_similarity=0.90
|
||||
)
|
||||
assert result is not None
|
||||
assert result.edge_id == "e1"
|
||||
|
||||
def test_min_source_similarity_fail(self):
|
||||
"""Edge rejeté lorsque source_similarity < min_source_similarity.
|
||||
|
||||
Ce test démontre concrètement que le filtre n'est plus désactivé
|
||||
silencieusement (avant Lot B il recevait toujours 1.0 hardcodé).
|
||||
"""
|
||||
edge = _make_edge("e1", min_source_similarity=0.80)
|
||||
state = _make_state()
|
||||
result = EdgeScorer().select_best(
|
||||
[edge], screen_state=state, source_similarity=0.50
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_min_source_similarity_default_is_pass_through(self):
|
||||
"""Défaut source_similarity=1.0 → aucun edge n'est rejeté pour ce motif."""
|
||||
edge = _make_edge("e1", min_source_similarity=0.99)
|
||||
state = _make_state()
|
||||
# Pas de source_similarity fournie → défaut 1.0 → edge accepté
|
||||
result = EdgeScorer().select_best([edge], screen_state=state)
|
||||
assert result is not None
|
||||
|
||||
def test_tiebreak_unchanged_with_similarity(self):
|
||||
"""Avec similarité OK des deux côtés, le tiebreak sur success_rate
|
||||
reste identique (pas de régression du comportement existant)."""
|
||||
e_low = _make_edge(
|
||||
"e_low",
|
||||
success_rate=0.20,
|
||||
execution_count=20,
|
||||
min_source_similarity=0.70,
|
||||
)
|
||||
e_high = _make_edge(
|
||||
"e_high",
|
||||
success_rate=0.95,
|
||||
execution_count=20,
|
||||
min_source_similarity=0.70,
|
||||
)
|
||||
state = _make_state()
|
||||
ranked = EdgeScorer().rank(
|
||||
[e_low, e_high], screen_state=state, source_similarity=0.85
|
||||
)
|
||||
# Les deux passent le filtre, e_high gagne au success_rate
|
||||
assert ranked[0].edge.edge_id == "e_high"
|
||||
assert ranked[0].passed_preconditions is True
|
||||
assert ranked[1].passed_preconditions is True
|
||||
|
||||
def test_similarity_filters_before_ranking(self):
|
||||
"""Entre 2 edges, celui dont min_source_similarity est violée est rejeté
|
||||
même s'il a un meilleur success_rate."""
|
||||
e_strict_high = _make_edge(
|
||||
"e_strict_high",
|
||||
success_rate=0.95,
|
||||
execution_count=20,
|
||||
min_source_similarity=0.90,
|
||||
)
|
||||
e_loose_low = _make_edge(
|
||||
"e_loose_low",
|
||||
success_rate=0.30,
|
||||
execution_count=20,
|
||||
min_source_similarity=0.50,
|
||||
)
|
||||
state = _make_state()
|
||||
# Source similarity 0.70 → e_strict_high rejeté, e_loose_low accepté
|
||||
result = EdgeScorer().select_best(
|
||||
[e_strict_high, e_loose_low],
|
||||
screen_state=state,
|
||||
source_similarity=0.70,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.edge_id == "e_loose_low"
|
||||
|
||||
def test_score_edge_exposes_precondition_reason(self):
|
||||
"""Pour la télémétrie : la raison d'échec mentionne la similarité."""
|
||||
edge = _make_edge("e1", min_source_similarity=0.80)
|
||||
state = _make_state()
|
||||
score = EdgeScorer().score_edge(
|
||||
edge, screen_state=state, source_similarity=0.40
|
||||
)
|
||||
assert score.passed_preconditions is False
|
||||
assert "imilarité" in score.precondition_reason or "imilarite" in score.precondition_reason
|
||||
678
tests/unit/test_execution_loop_vision_aware.py
Normal file
678
tests/unit/test_execution_loop_vision_aware.py
Normal file
@@ -0,0 +1,678 @@
|
||||
"""
|
||||
Tests unitaires de l'intégration vision-aware dans ExecutionLoop (C1).
|
||||
|
||||
Couvre :
|
||||
- Construction d'un ScreenState enrichi via ScreenAnalyzer
|
||||
- Cache hit évite un second appel à analyzer.analyze
|
||||
- Timeout → mode dégradé persistant
|
||||
- enable_ui_detection=False + enable_ocr=False → fallback stub
|
||||
- StepResult contient bien les champs temps (ocr_ms, ui_ms, analyze_ms, cache_hit, degraded)
|
||||
- Singleton get_screen_analyzer partage bien l'instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core.execution.execution_loop import ExecutionContext, ExecutionLoop, ExecutionMode, StepResult
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.pipeline import (
|
||||
get_screen_analyzer,
|
||||
get_screen_state_cache,
|
||||
reset_screen_analyzer,
|
||||
reset_screen_state_cache,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
"""Réinitialiser les singletons entre chaque test."""
|
||||
reset_screen_analyzer()
|
||||
reset_screen_state_cache()
|
||||
yield
|
||||
reset_screen_analyzer()
|
||||
reset_screen_state_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def screenshot(tmp_path):
|
||||
path = tmp_path / "shot.png"
|
||||
Image.new("RGB", (320, 240), color=(128, 128, 128)).save(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _make_state(session_id: str = "s1") -> ScreenState:
|
||||
return ScreenState(
|
||||
screen_state_id="sid",
|
||||
timestamp=datetime.now(),
|
||||
session_id=session_id,
|
||||
window=WindowContext(
|
||||
app_name="app", window_title="Title", screen_resolution=[1920, 1080]
|
||||
),
|
||||
raw=RawLevel(screenshot_path="", capture_method="test", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=["hello"],
|
||||
text_detection_method="test",
|
||||
confidence_avg=0.9,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
metadata={"ocr_ms": 123.0, "ui_ms": 45.0},
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(screen_analyzer=None, **kwargs) -> ExecutionLoop:
|
||||
pipeline = MagicMock()
|
||||
# Mocker load_workflow pour éviter dépendance FS
|
||||
pipeline.load_workflow.return_value = None
|
||||
loop = ExecutionLoop(
|
||||
pipeline=pipeline,
|
||||
action_executor=MagicMock(),
|
||||
screen_capturer=MagicMock(),
|
||||
screen_analyzer=screen_analyzer,
|
||||
**kwargs,
|
||||
)
|
||||
loop.context = ExecutionContext(
|
||||
workflow_id="wf1",
|
||||
execution_id="exec1",
|
||||
mode=ExecutionMode.AUTOMATIC,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
return loop
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVisionAwareBuild:
|
||||
|
||||
def test_build_screen_state_uses_analyzer(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer)
|
||||
state, timings = loop._build_screen_state(screenshot)
|
||||
|
||||
assert analyzer.analyze.called
|
||||
assert state.session_id == "s1"
|
||||
assert timings["cache_hit"] is False
|
||||
assert timings["ocr_ms"] == 123.0
|
||||
assert timings["ui_ms"] == 45.0
|
||||
assert timings["degraded"] is False
|
||||
|
||||
def test_build_screen_state_cache_hit_on_second_call(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer)
|
||||
loop._build_screen_state(screenshot)
|
||||
loop._build_screen_state(screenshot)
|
||||
|
||||
# Un seul appel à analyze grâce au cache
|
||||
assert analyzer.analyze.call_count == 1
|
||||
|
||||
def test_disabled_ui_and_ocr_returns_stub(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
loop = _make_loop(
|
||||
screen_analyzer=analyzer,
|
||||
enable_ui_detection=False,
|
||||
enable_ocr=False,
|
||||
)
|
||||
state, timings = loop._build_screen_state(screenshot)
|
||||
|
||||
# analyze ne doit PAS avoir été appelé
|
||||
analyzer.analyze.assert_not_called()
|
||||
assert timings["degraded"] is True
|
||||
assert state.perception.detected_text == []
|
||||
assert state.ui_elements == []
|
||||
|
||||
def test_timeout_activates_degraded_mode(self, screenshot):
|
||||
"""Si l'analyse dépasse analyze_timeout_ms, le loop bascule en dégradé."""
|
||||
analyzer = MagicMock()
|
||||
|
||||
def slow_analyze(*_args, **_kw):
|
||||
time.sleep(0.15)
|
||||
return _make_state()
|
||||
|
||||
analyzer.analyze.side_effect = slow_analyze
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer, analyze_timeout_ms=50)
|
||||
# Premier appel → mesure timeout et active dégradé
|
||||
_, timings1 = loop._build_screen_state(screenshot)
|
||||
assert timings1["degraded"] is True
|
||||
assert loop._degraded_mode is True
|
||||
|
||||
# Deuxième appel (autre screenshot pour éviter cache) → stub direct
|
||||
img2 = Path(screenshot).parent / "other.png"
|
||||
Image.new("RGB", (320, 240), color=(1, 2, 3)).save(str(img2))
|
||||
_, timings2 = loop._build_screen_state(str(img2))
|
||||
assert timings2["degraded"] is True
|
||||
# analyzer.analyze n'a pas été appelé une 2ème fois
|
||||
assert analyzer.analyze.call_count == 1
|
||||
|
||||
def test_analyzer_unavailable_returns_stub(self, screenshot):
|
||||
"""Si get_screen_analyzer() renvoie None, fallback stub."""
|
||||
loop = _make_loop(screen_analyzer=None)
|
||||
# Forcer _get_screen_analyzer à retourner None
|
||||
with patch.object(loop, "_get_screen_analyzer", return_value=None):
|
||||
state, timings = loop._build_screen_state(screenshot)
|
||||
assert timings["degraded"] is True
|
||||
assert state.ui_elements == []
|
||||
|
||||
def test_stub_when_all_flags_off(self, screenshot):
|
||||
loop = _make_loop(enable_ui_detection=False, enable_ocr=False)
|
||||
state, timings = loop._build_screen_state(screenshot)
|
||||
assert state.window.window_title == "Unknown"
|
||||
assert timings["degraded"] is True
|
||||
|
||||
|
||||
class TestWindowInfoProvider:
|
||||
|
||||
def test_window_info_provider_is_used(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
provider = lambda: {"title": "Chrome", "app_name": "chrome"}
|
||||
loop = _make_loop(screen_analyzer=analyzer, window_info_provider=provider)
|
||||
loop._build_screen_state(screenshot)
|
||||
|
||||
# Vérifier que window_info a bien été passé à analyze
|
||||
call_kwargs = analyzer.analyze.call_args.kwargs
|
||||
assert call_kwargs.get("window_info") == {"title": "Chrome", "app_name": "chrome"}
|
||||
|
||||
def test_falls_back_to_screen_capturer(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer)
|
||||
loop.screen_capturer.get_active_window.return_value = {
|
||||
"title": "Firefox",
|
||||
"app": "firefox",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
}
|
||||
loop._build_screen_state(screenshot)
|
||||
|
||||
call_kwargs = analyzer.analyze.call_args.kwargs
|
||||
wi = call_kwargs.get("window_info")
|
||||
assert wi is not None
|
||||
assert wi["title"] == "Firefox"
|
||||
assert wi["app_name"] == "firefox"
|
||||
|
||||
|
||||
class TestDegradedModeRecovery:
|
||||
"""Tâche 2 — Auto-rétablissement du mode dégradé après steps rapides."""
|
||||
|
||||
def test_fast_steps_counter_resets_on_degradation(self, screenshot):
|
||||
"""Dépassement du timeout → active dégradé + reset compteur."""
|
||||
analyzer = MagicMock()
|
||||
|
||||
def slow_analyze(*_args, **_kw):
|
||||
time.sleep(0.15)
|
||||
return _make_state()
|
||||
|
||||
analyzer.analyze.side_effect = slow_analyze
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer, analyze_timeout_ms=50)
|
||||
loop._successive_fast_steps = 2 # état fictif avant le timeout
|
||||
|
||||
_, timings = loop._build_screen_state(screenshot)
|
||||
|
||||
assert loop._degraded_mode is True
|
||||
assert loop._successive_fast_steps == 0
|
||||
assert timings["degraded"] is True
|
||||
|
||||
def test_recovery_after_three_fast_probes(self, tmp_path):
|
||||
"""Après 3 probes rapides consécutifs, retour en mode complet."""
|
||||
import random
|
||||
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
# Timeout 1000ms → fast_threshold = 500ms ; MagicMock = instant (<<500ms).
|
||||
loop = _make_loop(screen_analyzer=analyzer, analyze_timeout_ms=1000)
|
||||
# Simuler un état dégradé préexistant
|
||||
loop._degraded_mode = True
|
||||
loop._successive_fast_steps = 0
|
||||
loop._degraded_step_counter = 0
|
||||
# Probe immédiat à chaque appel
|
||||
loop._probe_interval = 1
|
||||
|
||||
# 3 probes rapides sur 3 screenshots avec dhash différents.
|
||||
# Une image unie a toujours un dhash 0...0 → on génère du bruit.
|
||||
for i in range(3):
|
||||
random.seed(i + 1)
|
||||
img = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
v = random.randint(0, 255)
|
||||
img.putpixel((x, y), (v, v, v))
|
||||
path = tmp_path / f"shot_{i}.png"
|
||||
img.save(str(path))
|
||||
_, timings = loop._build_screen_state(str(path))
|
||||
|
||||
assert loop._degraded_mode is False, "Devrait être sorti du mode dégradé"
|
||||
assert loop._successive_fast_steps == 0 # Reset après récupération
|
||||
|
||||
def test_slow_probe_keeps_degraded(self, tmp_path):
|
||||
"""Un probe lent en mode dégradé garde _degraded_mode=True."""
|
||||
analyzer = MagicMock()
|
||||
|
||||
def slow_analyze(*_args, **_kw):
|
||||
time.sleep(0.15)
|
||||
return _make_state()
|
||||
|
||||
analyzer.analyze.side_effect = slow_analyze
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer, analyze_timeout_ms=50)
|
||||
loop._degraded_mode = True
|
||||
loop._successive_fast_steps = 2
|
||||
loop._degraded_step_counter = 0
|
||||
loop._probe_interval = 1
|
||||
|
||||
path = tmp_path / "slow.png"
|
||||
Image.new("RGB", (320, 240), color=(80, 80, 80)).save(str(path))
|
||||
_, timings = loop._build_screen_state(str(path))
|
||||
|
||||
assert loop._degraded_mode is True
|
||||
assert loop._successive_fast_steps == 0 # Reset au slow
|
||||
assert timings["degraded"] is True
|
||||
|
||||
def test_probe_interval_respected_in_degraded(self, screenshot):
|
||||
"""En dégradé, on ne fait probe que tous les _probe_interval steps."""
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer, analyze_timeout_ms=1000)
|
||||
loop._degraded_mode = True
|
||||
loop._probe_interval = 5
|
||||
|
||||
# 4 appels successifs → aucun probe (stub direct)
|
||||
for _ in range(4):
|
||||
_, timings = loop._build_screen_state(screenshot)
|
||||
assert timings["degraded"] is True
|
||||
assert analyzer.analyze.call_count == 0
|
||||
|
||||
|
||||
class TestStepResultFields:
|
||||
|
||||
def test_step_result_has_new_timing_fields(self):
|
||||
r = StepResult(
|
||||
success=True,
|
||||
node_id="n1",
|
||||
edge_id=None,
|
||||
action_result=None,
|
||||
match_confidence=0.9,
|
||||
duration_ms=10.0,
|
||||
message="test",
|
||||
)
|
||||
assert r.ocr_ms == 0.0
|
||||
assert r.ui_ms == 0.0
|
||||
assert r.analyze_ms == 0.0
|
||||
assert r.total_ms == 0.0
|
||||
assert r.cache_hit is False
|
||||
assert r.degraded is False
|
||||
|
||||
|
||||
class TestExecuteStepBlockedContract:
|
||||
"""Lot A — contrat dict get_next_action dans ExecutionLoop._execute_step."""
|
||||
|
||||
def _setup_loop_with_match(self, next_action_return, screenshot):
|
||||
"""Crée une ExecutionLoop avec un pipeline mocké qui renvoie
|
||||
``next_action_return`` à get_next_action, et un
|
||||
``match_current_state_from_state`` qui matche toujours (Lot E — le
|
||||
chemin d'exécution utilise la nouvelle API context-aware)."""
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
loop = _make_loop(screen_analyzer=analyzer)
|
||||
# Nouveau chemin Lot E : match_current_state_from_state retourne un match valide
|
||||
loop.pipeline.match_current_state_from_state.return_value = {
|
||||
"node_id": "n1",
|
||||
"workflow_id": "wf1",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
loop.pipeline.get_next_action.return_value = next_action_return
|
||||
|
||||
# Mock _capture_screen pour éviter le vrai capture
|
||||
loop._capture_screen = lambda: screenshot
|
||||
|
||||
return loop
|
||||
|
||||
def test_blocked_triggers_paused_state(self, screenshot):
|
||||
"""status="blocked" → PAUSED + success=False + on_error appelé."""
|
||||
loop = self._setup_loop_with_match(
|
||||
next_action_return={"status": "blocked", "reason": "no_valid_edge"},
|
||||
screenshot=screenshot,
|
||||
)
|
||||
|
||||
errors_seen = []
|
||||
loop.on_error(lambda src, exc: errors_seen.append((src, exc)))
|
||||
|
||||
result = loop._execute_step()
|
||||
|
||||
assert result is not None
|
||||
assert result.success is False
|
||||
assert result.edge_id is None
|
||||
assert "Blocked" in result.message
|
||||
assert loop.state.value == "paused"
|
||||
# Callback on_error a bien été notifié
|
||||
assert len(errors_seen) == 1
|
||||
assert errors_seen[0][0] == "blocked"
|
||||
|
||||
def test_terminal_succeeds_without_edge(self, screenshot):
|
||||
"""status="terminal" → success=True + message "terminated"."""
|
||||
loop = self._setup_loop_with_match(
|
||||
next_action_return={"status": "terminal"},
|
||||
screenshot=screenshot,
|
||||
)
|
||||
|
||||
result = loop._execute_step()
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.edge_id is None
|
||||
assert "terminated" in result.message.lower()
|
||||
# PAS passé en PAUSED (workflow terminé légitimement)
|
||||
assert loop.state.value != "paused"
|
||||
|
||||
def test_legacy_none_treated_as_blocked(self, screenshot):
|
||||
"""Rétrocompat défensive : si un pipeline legacy renvoie None,
|
||||
on considère ça comme un blocage (safe default)."""
|
||||
loop = self._setup_loop_with_match(
|
||||
next_action_return=None,
|
||||
screenshot=screenshot,
|
||||
)
|
||||
|
||||
result = loop._execute_step()
|
||||
assert result is not None
|
||||
assert result.success is False
|
||||
assert loop.state.value == "paused"
|
||||
|
||||
def test_selected_continues_execution(self, screenshot):
|
||||
"""status="selected" → chemin nominal, tente d'exécuter l'edge."""
|
||||
loop = self._setup_loop_with_match(
|
||||
next_action_return={
|
||||
"status": "selected",
|
||||
"edge_id": "e1",
|
||||
"action": {"type": "click", "target": {}},
|
||||
"target_node": "n2",
|
||||
"confidence": 0.9,
|
||||
"score": 0.9,
|
||||
},
|
||||
screenshot=screenshot,
|
||||
)
|
||||
# Mode OBSERVATION pour ne rien exécuter réellement
|
||||
loop.context.mode = ExecutionMode.OBSERVATION
|
||||
|
||||
result = loop._execute_step()
|
||||
assert result is not None
|
||||
# Pas de PAUSED déclenché
|
||||
assert loop.state.value != "paused"
|
||||
# edge_id bien propagé
|
||||
assert result.edge_id == "e1"
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
|
||||
def test_get_screen_analyzer_returns_same_instance(self):
|
||||
a1 = get_screen_analyzer()
|
||||
a2 = get_screen_analyzer()
|
||||
assert a1 is a2
|
||||
|
||||
def test_force_new_creates_new_instance(self):
|
||||
a1 = get_screen_analyzer()
|
||||
a2 = get_screen_analyzer(force_new=True)
|
||||
assert a1 is not a2
|
||||
|
||||
def test_get_screen_state_cache_returns_same_instance(self):
|
||||
c1 = get_screen_state_cache()
|
||||
c2 = get_screen_state_cache()
|
||||
assert c1 is c2
|
||||
|
||||
|
||||
class TestAnalyzerIsolationBetweenLoops:
|
||||
"""
|
||||
Lot C — Deux ExecutionLoop partageant le même ScreenAnalyzer ne doivent
|
||||
PAS se contaminer mutuellement.
|
||||
|
||||
Règle : `analyze()` ne mute jamais `_ocr`, `_ui_detector`,
|
||||
`_ocr_initialized`, `_ui_detector_initialized` pour gérer les flags runtime.
|
||||
Les flags (`enable_ocr`, `enable_ui_detection`) et `session_id` circulent
|
||||
en kwargs d'appel, pas via l'état du singleton.
|
||||
"""
|
||||
|
||||
def _make_distinct_image(self, path, seed: int):
|
||||
"""Image avec dhash unique (random noise) pour éviter les cache hits."""
|
||||
import random
|
||||
random.seed(seed)
|
||||
img = Image.new("RGB", (128, 128))
|
||||
for y in range(128):
|
||||
for x in range(128):
|
||||
v = random.randint(0, 255)
|
||||
img.putpixel((x, y), (v, v, v))
|
||||
img.save(str(path))
|
||||
return str(path)
|
||||
|
||||
def test_two_loops_share_analyzer_no_contamination(self, tmp_path):
|
||||
"""Deux loops, le premier avec enable_ocr=False, le second avec
|
||||
enable_ocr=True → l'état interne du singleton doit être intact
|
||||
après l'appel du premier loop (pas de self._ocr=None)."""
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
|
||||
analyzer = ScreenAnalyzer()
|
||||
|
||||
# Installer un OCR + UIDetector factices ET marqués "initialisés" pour
|
||||
# empêcher l'init lazy réelle pendant le test.
|
||||
sentinel_ocr = lambda path: ["texte_sentinelle"]
|
||||
sentinel_detector = MagicMock()
|
||||
sentinel_detector.detect.return_value = []
|
||||
|
||||
analyzer._ocr = sentinel_ocr
|
||||
analyzer._ocr_initialized = True
|
||||
analyzer._ui_detector = sentinel_detector
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
# Deux screenshots avec dhash distincts (random noise)
|
||||
img_a = self._make_distinct_image(tmp_path / "shot_a.png", seed=1)
|
||||
img_b = self._make_distinct_image(tmp_path / "shot_b.png", seed=2)
|
||||
|
||||
# Premier loop : OCR désactivé
|
||||
loop_a = _make_loop(screen_analyzer=analyzer, enable_ocr=False)
|
||||
state_a, _ = loop_a._build_screen_state(img_a)
|
||||
|
||||
# Vérifier l'isolation : l'analyseur est INCHANGÉ.
|
||||
assert analyzer._ocr is sentinel_ocr, (
|
||||
"analyze(enable_ocr=False) NE DOIT PAS muter self._ocr"
|
||||
)
|
||||
assert analyzer._ocr_initialized is True
|
||||
assert analyzer._ui_detector is sentinel_detector
|
||||
assert analyzer._ui_detector_initialized is True
|
||||
# Pour le loop A, OCR bypass → detected_text vide
|
||||
assert state_a.perception.detected_text == []
|
||||
|
||||
# Deuxième loop : OCR activé
|
||||
loop_b = _make_loop(screen_analyzer=analyzer, enable_ocr=True)
|
||||
state_b, _ = loop_b._build_screen_state(img_b)
|
||||
|
||||
# L'analyseur est toujours intact
|
||||
assert analyzer._ocr is sentinel_ocr
|
||||
# Et le loop B a bien bénéficié de l'OCR
|
||||
assert state_b.perception.detected_text == ["texte_sentinelle"]
|
||||
|
||||
def test_session_id_is_per_call_not_singleton(self, tmp_path):
|
||||
"""Deux appels avec session_id différent → chaque ScreenState porte
|
||||
le bon session_id, et le singleton ne garde pas de session résiduelle."""
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
|
||||
# On patche _ensure_*_locked pour éviter l'init réelle.
|
||||
analyzer = ScreenAnalyzer()
|
||||
analyzer._ocr = None
|
||||
analyzer._ocr_initialized = True
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
img1 = tmp_path / "s1.png"
|
||||
img2 = tmp_path / "s2.png"
|
||||
Image.new("RGB", (100, 100), color=(1, 2, 3)).save(str(img1))
|
||||
Image.new("RGB", (100, 100), color=(4, 5, 6)).save(str(img2))
|
||||
|
||||
s1 = analyzer.analyze(str(img1), session_id="session_alpha")
|
||||
s2 = analyzer.analyze(str(img2), session_id="session_beta")
|
||||
|
||||
assert s1.session_id == "session_alpha"
|
||||
assert s2.session_id == "session_beta"
|
||||
assert s1.metadata.get("session_id") == "session_alpha"
|
||||
assert s2.metadata.get("session_id") == "session_beta"
|
||||
# Le state_id doit refléter chaque session, pas la "dernière vue" du singleton
|
||||
assert s1.screen_state_id.startswith("session_alpha_")
|
||||
assert s2.screen_state_id.startswith("session_beta_")
|
||||
|
||||
def test_analyze_flags_override_without_mutation(self, tmp_path):
|
||||
"""enable_ui_detection=False → ui_elements=[] dans le résultat,
|
||||
mais analyzer._ui_detector reste initialisé (pas de mutation)."""
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
|
||||
analyzer = ScreenAnalyzer()
|
||||
sentinel_detector = MagicMock()
|
||||
sentinel_detector.detect.return_value = [MagicMock()] # 1 élément factice
|
||||
analyzer._ui_detector = sentinel_detector
|
||||
analyzer._ui_detector_initialized = True
|
||||
analyzer._ocr = lambda p: []
|
||||
analyzer._ocr_initialized = True
|
||||
|
||||
img = tmp_path / "shot.png"
|
||||
Image.new("RGB", (100, 100), color=(10, 20, 30)).save(str(img))
|
||||
|
||||
state = analyzer.analyze(str(img), enable_ui_detection=False)
|
||||
|
||||
# ui_elements vide puisque détection désactivée pour cet appel
|
||||
assert state.ui_elements == []
|
||||
# Mais le détecteur du singleton est intact
|
||||
assert analyzer._ui_detector is sentinel_detector
|
||||
assert analyzer._ui_detector_initialized is True
|
||||
# Le détecteur n'a PAS été appelé
|
||||
sentinel_detector.detect.assert_not_called()
|
||||
|
||||
|
||||
class TestCacheContextAwareFromLoop:
|
||||
"""Lot D — Deux ExecutionLoop qui partagent le même ScreenStateCache
|
||||
mais s'exécutent dans des workflows différents NE DOIVENT PAS partager
|
||||
leurs entrées de cache : la clé composite inclut `workflow_id`.
|
||||
"""
|
||||
|
||||
def test_two_loops_different_workflow_different_cache(self, tmp_path):
|
||||
"""Même screenshot + même analyseur + workflow_id différent → 2 miss.
|
||||
|
||||
Le compute_fn sous-jacent (analyzer.analyze) doit être appelé pour
|
||||
chaque loop : pas de contamination inter-workflows.
|
||||
"""
|
||||
from core.pipeline import get_screen_state_cache
|
||||
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
|
||||
# Un même cache partagé (singleton) entre les deux loops.
|
||||
shared_cache = get_screen_state_cache()
|
||||
|
||||
# Image commune (dhash identique)
|
||||
img = tmp_path / "common.png"
|
||||
Image.new("RGB", (320, 240), color=(77, 77, 77)).save(str(img))
|
||||
|
||||
# Loop A → workflow "wf_A"
|
||||
loop_a = _make_loop(
|
||||
screen_analyzer=analyzer,
|
||||
screen_state_cache=shared_cache,
|
||||
)
|
||||
loop_a.context.workflow_id = "wf_A"
|
||||
loop_a._build_screen_state(str(img))
|
||||
assert analyzer.analyze.call_count == 1
|
||||
|
||||
# Loop B → workflow "wf_B" (même cache, même image, contexte différent)
|
||||
loop_b = _make_loop(
|
||||
screen_analyzer=analyzer,
|
||||
screen_state_cache=shared_cache,
|
||||
)
|
||||
loop_b.context.workflow_id = "wf_B"
|
||||
loop_b._build_screen_state(str(img))
|
||||
|
||||
# Pas de collision : analyzer.analyze a bien été appelé une 2ème fois.
|
||||
assert analyzer.analyze.call_count == 2
|
||||
|
||||
# Une 3ème exécution du loop A (même workflow_id, même screenshot)
|
||||
# doit par contre frapper le cache.
|
||||
loop_a._build_screen_state(str(img))
|
||||
assert analyzer.analyze.call_count == 2 # Pas de nouvel appel
|
||||
|
||||
|
||||
class TestExecutionLoopUsesMatchFromState:
|
||||
"""
|
||||
Lot E — ExecutionLoop._execute_step doit appeler
|
||||
``pipeline.match_current_state_from_state`` avec le ScreenState enrichi,
|
||||
et NON plus l'API legacy ``match_current_state(screenshot_path, ...)``.
|
||||
"""
|
||||
|
||||
def _make_loop_with_analyzer(self, screenshot):
|
||||
analyzer = MagicMock()
|
||||
analyzer.analyze.return_value = _make_state()
|
||||
loop = _make_loop(screen_analyzer=analyzer)
|
||||
loop._capture_screen = lambda: screenshot
|
||||
return loop
|
||||
|
||||
def test_execution_loop_calls_match_from_state(self, screenshot):
|
||||
"""_execute_step doit appeler match_current_state_from_state, pas
|
||||
l'ancienne API."""
|
||||
loop = self._make_loop_with_analyzer(screenshot)
|
||||
loop.pipeline.match_current_state_from_state.return_value = {
|
||||
"node_id": "n1",
|
||||
"workflow_id": "wf1",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
loop.pipeline.get_next_action.return_value = {"status": "terminal"}
|
||||
|
||||
loop._execute_step()
|
||||
|
||||
# La nouvelle API a été appelée
|
||||
assert loop.pipeline.match_current_state_from_state.called
|
||||
# L'ancienne API n'a PAS été appelée
|
||||
loop.pipeline.match_current_state.assert_not_called()
|
||||
|
||||
def test_execution_loop_passes_enriched_screen_state(self, screenshot):
|
||||
"""Le ScreenState passé à match_current_state_from_state doit être le
|
||||
résultat enrichi du ScreenAnalyzer (avec detected_text + title réel),
|
||||
pas un stub."""
|
||||
loop = self._make_loop_with_analyzer(screenshot)
|
||||
loop.pipeline.match_current_state_from_state.return_value = None
|
||||
|
||||
loop._execute_step()
|
||||
|
||||
call_args = loop.pipeline.match_current_state_from_state.call_args
|
||||
passed_state = call_args.args[0]
|
||||
# Le state vient de _make_state() → detected_text=["hello"], title="Title"
|
||||
assert passed_state.perception.detected_text == ["hello"]
|
||||
assert passed_state.window.window_title == "Title"
|
||||
# Et le workflow_id est bien propagé
|
||||
assert call_args.kwargs.get("workflow_id") == "wf1"
|
||||
298
tests/unit/test_pii_blur.py
Normal file
298
tests/unit/test_pii_blur.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# tests/unit/test_pii_blur.py
|
||||
"""Tests du pipeline d'anonymisation server-side (core.anonymisation.pii_blur).
|
||||
|
||||
On couvre :
|
||||
1. Logique regex : PERSON / ADDRESS / PHONE / NIR / EMAIL sont détectés ;
|
||||
codes CIM, CCAM, montants, dates et IDs techniques sont protégés.
|
||||
2. Fusion de spans qui se chevauchent.
|
||||
3. Pipeline complet sur une image synthétique PIL (sans OCR — on patche `_doctr_ocr`)
|
||||
avec assertions sur les pixels : les zones PII sont floutées, les autres non.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
# Éviter de charger docTR pendant les tests rapides
|
||||
os.environ.setdefault("RPA_PII_BLUR_SERVER", "true")
|
||||
|
||||
|
||||
# --- Import du module sous test --------------------------------------------
|
||||
from core.anonymisation import pii_blur as mod # noqa: E402
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests regex — pas besoin d'image
|
||||
# ===========================================================================
|
||||
|
||||
class TestRegexPII:
|
||||
def test_detect_person_with_title(self):
|
||||
hits = mod._regex_find_pii("Patient : M. Dupont Jean, né le 12/03/1965")
|
||||
labels = {h[0] for h in hits}
|
||||
assert "PERSON" in labels
|
||||
|
||||
def test_detect_email(self):
|
||||
hits = mod._regex_find_pii("Contact : jean.dupont@hopital.fr")
|
||||
labels = {h[0] for h in hits}
|
||||
assert "EMAIL" in labels
|
||||
|
||||
def test_detect_phone_fr(self):
|
||||
hits = mod._regex_find_pii("Tél : 06 12 34 56 78")
|
||||
labels = {h[0] for h in hits}
|
||||
assert "PHONE" in labels
|
||||
|
||||
def test_detect_nir(self):
|
||||
hits = mod._regex_find_pii("NIR : 2 85 03 75 115 120 42")
|
||||
labels = {h[0] for h in hits}
|
||||
assert "NIR" in labels
|
||||
|
||||
def test_detect_address(self):
|
||||
hits = mod._regex_find_pii("Adresse : 12 rue de la Paix, Paris")
|
||||
labels = {h[0] for h in hits}
|
||||
assert "LOCATION" in labels
|
||||
|
||||
# --- Négatifs : ces motifs NE DOIVENT PAS être détectés --------------
|
||||
def test_icd10_not_flagged(self):
|
||||
hits = mod._regex_find_pii("Code CIM : F32.1 (épisode dépressif)")
|
||||
assert not hits, f"CIM ne doit pas être floué, hits={hits}"
|
||||
|
||||
def test_ccam_not_flagged(self):
|
||||
hits = mod._regex_find_pii("Acte CCAM : DEQP003")
|
||||
assert not hits
|
||||
|
||||
def test_money_not_flagged(self):
|
||||
hits = mod._regex_find_pii("Montant facturé : 1250,50 €")
|
||||
assert not hits
|
||||
|
||||
def test_date_not_flagged(self):
|
||||
hits = mod._regex_find_pii("Séjour du 01/03/2026 au 15/03/2026")
|
||||
assert not hits
|
||||
|
||||
def test_tech_id_not_flagged(self):
|
||||
hits = mod._regex_find_pii("Fichier : shot_0007_full.png session_abc123")
|
||||
assert not hits
|
||||
|
||||
# --- Mélange réaliste -------------------------------------------------
|
||||
def test_realistic_hospital_text(self):
|
||||
text = (
|
||||
"Patient : M. Dupont Jean - NIR : 1 85 03 75 115 120 42 "
|
||||
"- Tél : 06 12 34 56 78 - Adresse : 12 rue de la Paix "
|
||||
"- Code CIM : F32.1 - Montant : 1250,50 € "
|
||||
"- Séjour du 01/03/2026 - Email : jean@test.fr"
|
||||
)
|
||||
hits = mod._regex_find_pii(text)
|
||||
labels = {h[0] for h in hits}
|
||||
assert "PERSON" in labels
|
||||
assert "NIR" in labels
|
||||
assert "PHONE" in labels
|
||||
assert "LOCATION" in labels
|
||||
assert "EMAIL" in labels
|
||||
|
||||
# Vérifier qu'aucun hit ne couvre F32.1, 1250,50 €, 01/03/2026
|
||||
protected_strings = ("F32.1", "1250,50", "01/03/2026")
|
||||
for label, s, e in hits:
|
||||
span = text[s:e]
|
||||
for prot in protected_strings:
|
||||
assert prot not in span, f"{label} '{span}' couvre {prot}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests de fusion de spans
|
||||
# ===========================================================================
|
||||
|
||||
class TestMergeSpans:
|
||||
def test_non_overlapping_preserved(self):
|
||||
spans = [("PERSON", 0, 5), ("EMAIL", 20, 30)]
|
||||
assert mod._merge_spans(spans) == spans
|
||||
|
||||
def test_overlap_kept_widest_label(self):
|
||||
spans = [("PERSON", 0, 10), ("LOCATION", 5, 20)]
|
||||
merged = mod._merge_spans(spans)
|
||||
assert len(merged) == 1
|
||||
label, s, e = merged[0]
|
||||
assert (s, e) == (0, 20)
|
||||
# le plus large est LOCATION (15 chars) > PERSON (10)
|
||||
assert label == "LOCATION"
|
||||
|
||||
def test_identical_spans_dedup(self):
|
||||
spans = [("EMAIL", 3, 9), ("EMAIL", 3, 9)]
|
||||
assert len(mod._merge_spans(spans)) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests pipeline complet avec OCR mocké
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def synthetic_screenshot(tmp_path: Path) -> Path:
|
||||
"""Génère une image synthétique avec 4 lignes de texte aux positions connues."""
|
||||
W, H = 900, 300
|
||||
img = Image.new("RGB", (W, H), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 22)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Lignes (y, text) — on reproduit le test demandé par l'utilisateur
|
||||
lines = [
|
||||
(20, "Nom : Dupont"),
|
||||
(70, "Code CIM : F32.1"),
|
||||
(120, "Adresse : 12 rue de la Paix"),
|
||||
(170, "Montant : 1250€"),
|
||||
]
|
||||
for y, t in lines:
|
||||
draw.text((20, y), t, fill="black", font=font)
|
||||
|
||||
path = tmp_path / "synth.png"
|
||||
img.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def _fake_doctr_ocr(image_path: Path):
|
||||
"""Mock docTR : retourne des bbox word-level connues pour le screenshot synthétique.
|
||||
|
||||
On utilise des bbox approximatives correspondant à la disposition dans le fixture.
|
||||
"""
|
||||
# Format : liste de mots par ligne. Les x sont progressifs pour simuler
|
||||
# la largeur rendue. On reste volontairement grossier (le blur tolère).
|
||||
words = []
|
||||
|
||||
line_idx = [0]
|
||||
|
||||
def line(y, word_defs):
|
||||
x = 20
|
||||
for text, w in word_defs:
|
||||
words.append({
|
||||
"text": text, "x1": x, "y1": y, "x2": x + w, "y2": y + 30,
|
||||
"line": line_idx[0],
|
||||
})
|
||||
x += w + 8
|
||||
line_idx[0] += 1
|
||||
|
||||
# "Nom : Dupont" → ciblé PERSON (via "M. Dupont" ? non, on n'a pas M.) →
|
||||
# on ajoute le titre "M." pour déclencher le regex PERSON.
|
||||
line(20, [("M.", 30), ("Dupont", 90)])
|
||||
# "Code CIM : F32.1" → doit NE PAS flouter
|
||||
line(70, [("Code", 60), ("CIM", 50), (":", 10), ("F32.1", 80)])
|
||||
# "Adresse : 12 rue de la Paix" → LOCATION
|
||||
line(120, [("Adresse", 90), (":", 10), ("12", 30), ("rue", 40), ("de", 30),
|
||||
("la", 30), ("Paix", 60)])
|
||||
# "Montant : 1250€" → NE PAS flouter
|
||||
line(170, [("Montant", 90), (":", 10), ("1250€", 80)])
|
||||
|
||||
return words, 900, 300
|
||||
|
||||
|
||||
def _pixel_variance(img: Image.Image, bbox) -> float:
|
||||
"""Variance moyenne par canal dans une ROI — proxy pour « y a-t-il du détail ».
|
||||
|
||||
Une zone floutée a une variance très basse ; une zone nette a plus de détail.
|
||||
"""
|
||||
import statistics
|
||||
x1, y1, x2, y2 = bbox
|
||||
crop = img.crop((x1, y1, x2, y2)).convert("RGB")
|
||||
pixels = list(crop.getdata())
|
||||
if len(pixels) < 2:
|
||||
return 0.0
|
||||
rs = [p[0] for p in pixels]
|
||||
gs = [p[1] for p in pixels]
|
||||
bs = [p[2] for p in pixels]
|
||||
return (statistics.pvariance(rs) + statistics.pvariance(gs) + statistics.pvariance(bs)) / 3
|
||||
|
||||
|
||||
class TestPIIBlurrerPipeline:
|
||||
def test_blur_only_pii_regions(self, tmp_path, synthetic_screenshot):
|
||||
with patch.object(mod, "_doctr_ocr", side_effect=_fake_doctr_ocr):
|
||||
blurrer = mod.PIIBlurrer(use_edsnlp=False)
|
||||
out = tmp_path / "synth_blurred.png"
|
||||
result = blurrer.blur_image(synthetic_screenshot, out)
|
||||
|
||||
# Assertions globales
|
||||
assert result.count >= 2, (
|
||||
f"Attendu au moins 2 PII (PERSON + LOCATION), reçu {result.count} : "
|
||||
f"{[e.label for e in result.entities]}"
|
||||
)
|
||||
labels = {e.label for e in result.entities}
|
||||
assert "PERSON" in labels
|
||||
assert "LOCATION" in labels
|
||||
# F32.1 ne doit PAS être parmi les entités floutées
|
||||
assert not any("F32.1" in e.text for e in result.entities)
|
||||
# 1250 ne doit PAS être parmi les entités floutées
|
||||
assert not any("1250" in e.text for e in result.entities)
|
||||
|
||||
# Vérification visuelle : la ligne CIM (y=70..100) doit rester nette,
|
||||
# la ligne Adresse (y=120..150) doit être floutée.
|
||||
original = Image.open(synthetic_screenshot)
|
||||
blurred = Image.open(out)
|
||||
|
||||
# Ligne CIM : doit contenir du texte net (variance haute sur la zone)
|
||||
cim_bbox = (20, 68, 280, 105)
|
||||
var_orig_cim = _pixel_variance(original, cim_bbox)
|
||||
var_blur_cim = _pixel_variance(blurred, cim_bbox)
|
||||
# Tolérance : la zone CIM doit rester AU MOINS à 60% de la variance d'origine
|
||||
assert var_blur_cim >= 0.5 * var_orig_cim, (
|
||||
f"Code CIM a été flouté ! var_orig={var_orig_cim:.1f}, "
|
||||
f"var_blur={var_blur_cim:.1f}"
|
||||
)
|
||||
|
||||
# Ligne Adresse : la variance doit chuter (flou applique un lissage)
|
||||
addr_bbox = (20, 118, 400, 155)
|
||||
var_orig_addr = _pixel_variance(original, addr_bbox)
|
||||
var_blur_addr = _pixel_variance(blurred, addr_bbox)
|
||||
assert var_blur_addr < var_orig_addr * 0.85, (
|
||||
f"Adresse pas suffisamment floutée : var_orig={var_orig_addr:.1f}, "
|
||||
f"var_blur={var_blur_addr:.1f}"
|
||||
)
|
||||
|
||||
def test_no_pii_copies_file(self, tmp_path):
|
||||
"""Si aucun PII n'est détecté, le fichier est copié tel quel."""
|
||||
img = Image.new("RGB", (400, 100), "white")
|
||||
p = tmp_path / "clean.png"
|
||||
img.save(p)
|
||||
|
||||
def fake_clean_ocr(path):
|
||||
return (
|
||||
[{"text": "Bonjour", "x1": 10, "y1": 10, "x2": 100, "y2": 40},
|
||||
{"text": "monde", "x1": 110, "y1": 10, "x2": 200, "y2": 40}],
|
||||
400, 100,
|
||||
)
|
||||
|
||||
with patch.object(mod, "_doctr_ocr", side_effect=fake_clean_ocr):
|
||||
res = mod.PIIBlurrer(use_edsnlp=False).blur_image(p, tmp_path / "clean_out.png")
|
||||
|
||||
assert res.count == 0
|
||||
assert (tmp_path / "clean_out.png").is_file()
|
||||
|
||||
def test_ocr_failure_falls_back_to_copy(self, tmp_path):
|
||||
"""Si docTR plante, on copie l'original en version 'blurred' (failsafe)."""
|
||||
img = Image.new("RGB", (100, 100), "white")
|
||||
p = tmp_path / "fail.png"
|
||||
img.save(p)
|
||||
|
||||
def boom(path):
|
||||
raise RuntimeError("OCR indispo")
|
||||
|
||||
with patch.object(mod, "_doctr_ocr", side_effect=boom):
|
||||
res = mod.PIIBlurrer(use_edsnlp=False).blur_image(p, tmp_path / "fail_out.png")
|
||||
|
||||
assert res.count == 0
|
||||
assert (tmp_path / "fail_out.png").is_file()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Sanity check helper
|
||||
# ===========================================================================
|
||||
|
||||
def test_pii_labels_contains_expected():
|
||||
assert "PERSON" in mod.PII_LABELS
|
||||
assert "LOCATION" in mod.PII_LABELS
|
||||
assert "EMAIL" in mod.PII_LABELS
|
||||
assert "NIR" in mod.PII_LABELS
|
||||
assert "PHONE" in mod.PII_LABELS
|
||||
@@ -122,6 +122,7 @@ class TestPolicyEngine:
|
||||
def _make_engine(self):
|
||||
from agent_v0.agent_v1.core.policy import PolicyEngine
|
||||
executor = MagicMock()
|
||||
executor._system_dialog_pause = None
|
||||
return PolicyEngine(executor), executor
|
||||
|
||||
def test_premier_essai_popup_fermee_retry(self):
|
||||
|
||||
185
tests/unit/test_screen_analyzer.py
Normal file
185
tests/unit/test_screen_analyzer.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests unitaires de `ScreenAnalyzer` (Lot C — thread-safety).
|
||||
|
||||
Couvre :
|
||||
- Les flags runtime sont kwargs-only (enable_ocr, enable_ui_detection, session_id)
|
||||
- L'init lazy (OCR + UIDetector) est protégée par un lock → pas de double init
|
||||
- `analyze()` ne mute jamais `_ocr*` / `_ui_detector*` pour gérer les flags
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def screenshot(tmp_path):
|
||||
path = tmp_path / "shot.png"
|
||||
Image.new("RGB", (64, 64), color=(100, 100, 100)).save(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API — kwargs-only
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalyzeKwargsOnly:
|
||||
"""Les flags runtime doivent être passés en kwargs-only, jamais positionnels."""
|
||||
|
||||
def test_analyze_kwargs_only_accept(self, screenshot):
|
||||
"""L'appel nominal avec kwargs fonctionne."""
|
||||
analyzer = ScreenAnalyzer()
|
||||
# Empêcher l'init réelle
|
||||
analyzer._ocr = None
|
||||
analyzer._ocr_initialized = True
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
state = analyzer.analyze(
|
||||
screenshot,
|
||||
enable_ocr=False,
|
||||
enable_ui_detection=False,
|
||||
session_id="s_kwargs",
|
||||
)
|
||||
assert state.session_id == "s_kwargs"
|
||||
assert state.perception.detected_text == []
|
||||
assert state.ui_elements == []
|
||||
|
||||
def test_analyze_rejects_positional_flags(self, screenshot):
|
||||
"""Passer enable_ocr en position 4 (après window_info, context) → TypeError."""
|
||||
analyzer = ScreenAnalyzer()
|
||||
analyzer._ocr = None
|
||||
analyzer._ocr_initialized = True
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
# Signature : analyze(self, screenshot_path, window_info=None, context=None,
|
||||
# *, enable_ocr=..., enable_ui_detection=..., session_id=...)
|
||||
# Un 4e argument positionnel doit être rejeté.
|
||||
with pytest.raises(TypeError):
|
||||
analyzer.analyze(screenshot, None, None, False) # noqa: E501 (flag positionnel interdit)
|
||||
|
||||
def test_analyze_session_id_propagates_to_state(self, screenshot):
|
||||
"""session_id passé en kwarg remplit ScreenState.session_id et metadata."""
|
||||
analyzer = ScreenAnalyzer(session_id="default_session")
|
||||
analyzer._ocr = None
|
||||
analyzer._ocr_initialized = True
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
# kwarg explicite → prioritaire
|
||||
state_call = analyzer.analyze(screenshot, session_id="explicit_session")
|
||||
assert state_call.session_id == "explicit_session"
|
||||
assert state_call.metadata["session_id"] == "explicit_session"
|
||||
|
||||
# kwarg vide → fallback sur la valeur d'instance (rétrocompat)
|
||||
state_default = analyzer.analyze(screenshot)
|
||||
assert state_default.session_id == "default_session"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lazy init sous lock
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLazyInitUnderLock:
|
||||
"""L'init lazy (OCR / UIDetector) ne doit jamais se faire en double."""
|
||||
|
||||
def test_analyze_lazy_init_under_lock(self, screenshot):
|
||||
"""Init concurrente → une seule création de l'OCR."""
|
||||
analyzer = ScreenAnalyzer()
|
||||
|
||||
# Simuler un init OCR coûteux : compte les appels, renvoie un OCR factice.
|
||||
init_count = {"n": 0}
|
||||
|
||||
def fake_ensure_ocr_locked():
|
||||
# Ne marcher qu'une fois : mimer _ensure_ocr_locked qui s'auto-verrouille.
|
||||
init_count["n"] += 1
|
||||
time.sleep(0.05) # laisser la concurrence s'exprimer
|
||||
analyzer._ocr = lambda p: ["ok"]
|
||||
analyzer._ocr_initialized = True
|
||||
|
||||
analyzer._ensure_ocr_locked = fake_ensure_ocr_locked # type: ignore[assignment]
|
||||
# UIDetector déjà "prêt" (pas None → détection évitée via mock)
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
# N threads lancent analyze() simultanément
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
s = analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=False)
|
||||
results.append(s)
|
||||
except Exception as e: # pragma: no cover
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not errors, f"Erreurs dans les threads: {errors}"
|
||||
assert len(results) == 8
|
||||
# UNE seule init OCR malgré 8 appels concurrents
|
||||
assert init_count["n"] == 1, (
|
||||
f"Init OCR exécutée {init_count['n']} fois — doit être 1 sous lock"
|
||||
)
|
||||
|
||||
def test_analyze_no_mutation_for_flag_bypass(self, screenshot):
|
||||
"""enable_ocr=False NE DOIT PAS muter self._ocr ni _ocr_initialized."""
|
||||
analyzer = ScreenAnalyzer()
|
||||
# État "frais" : rien d'initialisé
|
||||
assert analyzer._ocr is None
|
||||
assert analyzer._ocr_initialized is False
|
||||
assert analyzer._ui_detector is None
|
||||
assert analyzer._ui_detector_initialized is False
|
||||
|
||||
analyzer.analyze(screenshot, enable_ocr=False, enable_ui_detection=False)
|
||||
|
||||
# L'état interne doit être strictement inchangé : aucune init n'a été
|
||||
# déclenchée puisque les deux flags étaient à False.
|
||||
assert analyzer._ocr is None
|
||||
assert analyzer._ocr_initialized is False
|
||||
assert analyzer._ui_detector is None
|
||||
assert analyzer._ui_detector_initialized is False
|
||||
|
||||
def test_analyze_lazy_init_only_when_requested(self, screenshot):
|
||||
"""enable_ocr=True sur instance fraîche → init déclenchée.
|
||||
enable_ocr=False sur instance fraîche → pas d'init."""
|
||||
analyzer = ScreenAnalyzer()
|
||||
calls = {"ocr": 0, "ui": 0}
|
||||
|
||||
def fake_ocr_init():
|
||||
calls["ocr"] += 1
|
||||
analyzer._ocr = lambda p: []
|
||||
analyzer._ocr_initialized = True
|
||||
|
||||
def fake_ui_init():
|
||||
calls["ui"] += 1
|
||||
analyzer._ui_detector = None
|
||||
analyzer._ui_detector_initialized = True
|
||||
|
||||
analyzer._ensure_ocr_locked = fake_ocr_init # type: ignore[assignment]
|
||||
analyzer._ensure_ui_detector_locked = fake_ui_init # type: ignore[assignment]
|
||||
|
||||
# Appel 1 : seul OCR demandé
|
||||
analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=False)
|
||||
assert calls["ocr"] == 1
|
||||
assert calls["ui"] == 0
|
||||
|
||||
# Appel 2 : maintenant UI demandée
|
||||
analyzer.analyze(screenshot, enable_ocr=True, enable_ui_detection=True)
|
||||
assert calls["ocr"] == 1 # déjà initialisé, pas de réinit
|
||||
assert calls["ui"] == 1
|
||||
449
tests/unit/test_screen_state_cache.py
Normal file
449
tests/unit/test_screen_state_cache.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Tests unitaires du ScreenStateCache.
|
||||
|
||||
Couvre :
|
||||
- Hash perceptuel (déterministe, stable sur même image, différent sur autres)
|
||||
- Cache hit / miss
|
||||
- TTL (expiration)
|
||||
- Invalidation explicite
|
||||
- Éviction LRU
|
||||
- Thread-safety basique
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.pipeline.screen_state_cache import (
|
||||
ScreenStateCache,
|
||||
compute_perceptual_hash,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_screenshot(tmp_path: Path, color: tuple, name: str = "shot.png") -> str:
|
||||
img = Image.new("RGB", (320, 240), color=color)
|
||||
path = tmp_path / name
|
||||
img.save(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _make_state(session_id: str = "s1") -> ScreenState:
|
||||
return ScreenState(
|
||||
screen_state_id=f"state_{datetime.now().strftime('%H%M%S%f')}",
|
||||
timestamp=datetime.now(),
|
||||
session_id=session_id,
|
||||
window=WindowContext(
|
||||
app_name="app", window_title="Title", screen_resolution=[1920, 1080]
|
||||
),
|
||||
raw=RawLevel(screenshot_path="", capture_method="test", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=[],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hash perceptuel
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerceptualHash:
|
||||
|
||||
def test_deterministic_for_same_image(self, tmp_path):
|
||||
path = _make_screenshot(tmp_path, (255, 0, 0))
|
||||
h1 = compute_perceptual_hash(path)
|
||||
h2 = compute_perceptual_hash(path)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 16 # 8*8 bits = 64 bits = 16 hex chars
|
||||
|
||||
def test_differs_across_images(self, tmp_path):
|
||||
path_red = _make_screenshot(tmp_path, (255, 0, 0), "red.png")
|
||||
path_blue = _make_screenshot(tmp_path, (0, 0, 255), "blue.png")
|
||||
# Note : deux images unies ont le même dhash (toutes différences nulles)
|
||||
# On doit utiliser des images avec un vrai gradient pour différer.
|
||||
grad_red = Image.new("RGB", (320, 240))
|
||||
for x in range(320):
|
||||
for y in range(240):
|
||||
grad_red.putpixel((x, y), (x % 256, 0, 0))
|
||||
grad_path = tmp_path / "grad_red.png"
|
||||
grad_red.save(str(grad_path))
|
||||
|
||||
h_red = compute_perceptual_hash(path_red)
|
||||
h_grad = compute_perceptual_hash(str(grad_path))
|
||||
assert h_red != h_grad
|
||||
|
||||
def test_robust_to_missing_file(self, tmp_path):
|
||||
# Chemin inexistant → fallback mais pas de crash
|
||||
h = compute_perceptual_hash(str(tmp_path / "does_not_exist.png"))
|
||||
assert isinstance(h, str)
|
||||
assert len(h) > 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cache
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScreenStateCache:
|
||||
|
||||
def test_get_or_compute_cache_miss_then_hit(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (100, 100, 100))
|
||||
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
s1, hit1, _ = cache.get_or_compute(path, compute)
|
||||
s2, hit2, _ = cache.get_or_compute(path, compute)
|
||||
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
assert s1 is s2 # Même objet retourné
|
||||
|
||||
def test_ttl_expiration(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=0.1)
|
||||
path = _make_screenshot(tmp_path, (50, 50, 50))
|
||||
|
||||
def compute(_):
|
||||
return _make_state()
|
||||
|
||||
cache.get_or_compute(path, compute)
|
||||
time.sleep(0.15)
|
||||
_, hit, _ = cache.get_or_compute(path, compute)
|
||||
assert hit is False # Expiré
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (10, 10, 10))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path, lambda _: _make_state(), force_refresh=True
|
||||
)
|
||||
assert hit is False
|
||||
|
||||
def test_invalidate_all(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (200, 200, 200))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
cache.invalidate()
|
||||
_, hit, _ = cache.get_or_compute(path, lambda _: _make_state())
|
||||
assert hit is False
|
||||
|
||||
def test_eviction_lru(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0, max_entries=2)
|
||||
# Créer 3 images différentes (gradients différents pour hashes différents)
|
||||
paths = []
|
||||
for i, intensity in enumerate([30, 120, 220]):
|
||||
img = Image.new("RGB", (320, 240))
|
||||
for x in range(320):
|
||||
for y in range(240):
|
||||
img.putpixel((x, y), ((x + intensity) % 256, intensity, 0))
|
||||
p = tmp_path / f"grad_{i}.png"
|
||||
img.save(str(p))
|
||||
paths.append(str(p))
|
||||
|
||||
def compute(_):
|
||||
return _make_state()
|
||||
|
||||
cache.get_or_compute(paths[0], compute)
|
||||
time.sleep(0.01)
|
||||
cache.get_or_compute(paths[1], compute)
|
||||
time.sleep(0.01)
|
||||
cache.get_or_compute(paths[2], compute)
|
||||
# Le 1er doit avoir été évincé
|
||||
assert len(cache) == 2
|
||||
|
||||
def test_stats(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (77, 77, 77))
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
stats = cache.stats()
|
||||
assert stats["hits"] == 1
|
||||
assert stats["misses"] == 1
|
||||
assert stats["hit_rate"] == 0.5
|
||||
|
||||
def test_invalidate_if_changed_purges_on_big_change(self, tmp_path):
|
||||
"""Un screenshot très différent doit invalider tout le cache."""
|
||||
import random
|
||||
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
# Image 1 : gradient doux
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), (y, y, y))
|
||||
p1 = tmp_path / "v.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
# Image 2 : bruit aléatoire (structure radicalement différente)
|
||||
random.seed(42)
|
||||
img2 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
v = random.randint(0, 255)
|
||||
img2.putpixel((x, y), (v, v, v))
|
||||
p2 = tmp_path / "noise.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
cache.get_or_compute(str(p1), lambda _: _make_state())
|
||||
assert len(cache) == 1
|
||||
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is True
|
||||
assert len(cache) == 0
|
||||
|
||||
def test_invalidate_if_changed_keeps_cache_on_small_change(self, tmp_path):
|
||||
"""Un screenshot très proche ne doit PAS invalider le cache."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
# Même gradient avec un léger bruit
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), ((x + y) % 256, 0, 0))
|
||||
p1 = tmp_path / "a.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
img2 = img1.copy()
|
||||
# Bruit léger : changer seulement quelques pixels
|
||||
for i in range(5):
|
||||
img2.putpixel((i, 0), (255, 255, 255))
|
||||
p2 = tmp_path / "b.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
cache.get_or_compute(str(p1), lambda _: _make_state())
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is False
|
||||
assert len(cache) == 1
|
||||
|
||||
def test_invalidate_if_changed_empty_cache_is_noop(self, tmp_path):
|
||||
"""Sur cache vide, invalidate_if_changed ne doit rien faire."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
p = _make_screenshot(tmp_path, (100, 100, 100))
|
||||
purged = cache.invalidate_if_changed(p, threshold=0.3)
|
||||
assert purged is False
|
||||
|
||||
def test_thread_safety(self, tmp_path):
|
||||
"""Lecture/écriture concurrentes ne doivent pas crasher."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (64, 64, 64))
|
||||
errors = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for _ in range(20):
|
||||
cache.get_or_compute(path, lambda _: _make_state())
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Clé composite context-aware (Lot D)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheContextAware:
|
||||
"""Lot D — Le cache ne doit jamais hit entre deux contextes différents.
|
||||
|
||||
La clé composite combine 6 éléments : phash, window_title, app_name,
|
||||
enable_ocr, enable_ui_detection, workflow_id. Toute variation sur une
|
||||
de ces dimensions doit produire un cache miss, même si le screenshot
|
||||
(donc le phash) est strictement identique.
|
||||
"""
|
||||
|
||||
def test_same_image_different_window_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (60, 60, 60))
|
||||
|
||||
_, hit_a, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Chrome",
|
||||
app_name="chrome.exe",
|
||||
workflow_id="wf1",
|
||||
)
|
||||
_, hit_b, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Firefox", # Diffère
|
||||
app_name="chrome.exe",
|
||||
workflow_id="wf1",
|
||||
)
|
||||
assert hit_a is False
|
||||
assert hit_b is False # Contexte fenêtre différent → miss
|
||||
|
||||
def test_same_image_different_app_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (90, 90, 90))
|
||||
|
||||
cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Doc.pdf",
|
||||
app_name="acrobat.exe",
|
||||
)
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
window_title="Doc.pdf",
|
||||
app_name="sumatra.exe", # Diffère
|
||||
)
|
||||
assert hit is False # app_name différent → miss
|
||||
|
||||
def test_same_image_different_flags_miss(self, tmp_path):
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (120, 120, 120))
|
||||
|
||||
# Run 1 : OCR actif
|
||||
cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=True,
|
||||
)
|
||||
# Run 2 : OCR désactivé → clé différente
|
||||
_, hit_ocr_off, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=False,
|
||||
enable_ui_detection=True,
|
||||
)
|
||||
# Run 3 : UI désactivé → encore une autre clé
|
||||
_, hit_ui_off, _ = cache.get_or_compute(
|
||||
path,
|
||||
lambda _: _make_state(),
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=False,
|
||||
)
|
||||
assert hit_ocr_off is False
|
||||
assert hit_ui_off is False
|
||||
|
||||
def test_same_image_different_workflow_miss(self, tmp_path):
|
||||
"""Isolation stricte inter-workflows : replay wf1 ≠ replay wf2."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (33, 77, 200))
|
||||
|
||||
cache.get_or_compute(
|
||||
path, lambda _: _make_state(), workflow_id="wf_alpha"
|
||||
)
|
||||
_, hit, _ = cache.get_or_compute(
|
||||
path, lambda _: _make_state(), workflow_id="wf_beta"
|
||||
)
|
||||
assert hit is False
|
||||
|
||||
def test_same_image_same_context_hit(self, tmp_path):
|
||||
"""Tout identique → hit (comportement cache nominal)."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (42, 42, 42))
|
||||
|
||||
kwargs = dict(
|
||||
window_title="Notepad",
|
||||
app_name="notepad.exe",
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=True,
|
||||
workflow_id="wf_stable",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
_, hit1, _ = cache.get_or_compute(path, compute, **kwargs)
|
||||
_, hit2, _ = cache.get_or_compute(path, compute, **kwargs)
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_default_context_is_stable(self, tmp_path):
|
||||
"""Rétrocompat : deux callers sans kwargs de contexte partagent
|
||||
la même entrée de cache (ancien comportement préservé)."""
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
path = _make_screenshot(tmp_path, (11, 22, 33))
|
||||
|
||||
calls = []
|
||||
|
||||
def compute(p):
|
||||
calls.append(p)
|
||||
return _make_state()
|
||||
|
||||
# Deux appels sans kwargs → doivent partager la même clé
|
||||
_, hit1, _ = cache.get_or_compute(path, compute)
|
||||
_, hit2, _ = cache.get_or_compute(path, compute)
|
||||
assert hit1 is False
|
||||
assert hit2 is True
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_invalidate_if_changed_ignores_context(self, tmp_path):
|
||||
"""invalidate_if_changed regarde le phash seul, pas la clé composite.
|
||||
Un changement visuel majeur purge toutes les entrées, quel que soit
|
||||
leur contexte (workflow, flags, fenêtre)."""
|
||||
import random
|
||||
|
||||
cache = ScreenStateCache(ttl_seconds=10.0)
|
||||
|
||||
# Deux entrées dans des contextes différents MAIS pour la même image.
|
||||
img1 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
img1.putpixel((x, y), (y, y, y))
|
||||
p1 = tmp_path / "orig.png"
|
||||
img1.save(str(p1))
|
||||
|
||||
cache.get_or_compute(
|
||||
str(p1), lambda _: _make_state(), workflow_id="wf1"
|
||||
)
|
||||
cache.get_or_compute(
|
||||
str(p1), lambda _: _make_state(), workflow_id="wf2"
|
||||
)
|
||||
assert len(cache) == 2
|
||||
|
||||
# Nouveau screenshot radicalement différent → doit tout purger.
|
||||
random.seed(42)
|
||||
img2 = Image.new("RGB", (320, 240))
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
v = random.randint(0, 255)
|
||||
img2.putpixel((x, y), (v, v, v))
|
||||
p2 = tmp_path / "noise.png"
|
||||
img2.save(str(p2))
|
||||
|
||||
purged = cache.invalidate_if_changed(str(p2), threshold=0.3)
|
||||
assert purged is True
|
||||
assert len(cache) == 0
|
||||
179
tests/unit/test_security_safe_condition.py
Normal file
179
tests/unit/test_security_safe_condition.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Tests de sécurité : évaluateur de conditions AST restreint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.execution.safe_condition_evaluator import (
|
||||
SafeConditionEvaluator,
|
||||
UnsafeExpressionError,
|
||||
safe_eval_condition,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cas valides — expressions que les workflows doivent pouvoir évaluer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidExpressions:
|
||||
def test_literal_true(self):
|
||||
assert safe_eval_condition("True", {}) is True
|
||||
|
||||
def test_literal_false(self):
|
||||
assert safe_eval_condition("False", {}) is False
|
||||
|
||||
def test_numeric_comparison(self):
|
||||
assert safe_eval_condition("1 < 2", {}) is True
|
||||
assert safe_eval_condition("2 < 1", {}) is False
|
||||
|
||||
def test_chained_comparison(self):
|
||||
assert safe_eval_condition("1 < 2 < 3", {}) is True
|
||||
assert safe_eval_condition("1 < 3 < 2", {}) is False
|
||||
|
||||
def test_variable_access(self):
|
||||
assert safe_eval_condition("x > 5", {"x": 10}) is True
|
||||
|
||||
def test_subscript_dict(self):
|
||||
ctx = {"results": {"step_1": {"score": 0.9}}}
|
||||
assert safe_eval_condition(
|
||||
"results['step_1']['score'] >= 0.8", ctx
|
||||
) is True
|
||||
|
||||
def test_boolean_and(self):
|
||||
assert safe_eval_condition("True and False", {}) is False
|
||||
assert safe_eval_condition("True and True", {}) is True
|
||||
|
||||
def test_boolean_or(self):
|
||||
assert safe_eval_condition("False or True", {}) is True
|
||||
|
||||
def test_not_operator(self):
|
||||
assert safe_eval_condition("not False", {}) is True
|
||||
|
||||
def test_arithmetic(self):
|
||||
assert safe_eval_condition("(a + b) * 2 > 10", {"a": 3, "b": 4}) is True
|
||||
|
||||
def test_in_operator(self):
|
||||
assert safe_eval_condition("'ok' in status", {"status": ["ok", "done"]}) is True
|
||||
|
||||
def test_list_literal(self):
|
||||
assert safe_eval_condition("x in [1, 2, 3]", {"x": 2}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cas malveillants — tentatives d'injection / RCE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaliciousExpressions:
|
||||
"""Toutes ces expressions DOIVENT lever UnsafeExpressionError."""
|
||||
|
||||
def test_rejects_import(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("__import__('os').system('echo pwn')", {})
|
||||
|
||||
def test_rejects_function_call(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("print('hello')", {"print": print})
|
||||
|
||||
def test_rejects_eval(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("eval('1+1')", {})
|
||||
|
||||
def test_rejects_exec(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("exec('x=1')", {})
|
||||
|
||||
def test_rejects_dunder_attribute(self):
|
||||
# Classique : remonter à __builtins__ via __class__.__mro__
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("x.__class__", {"x": "abc"})
|
||||
|
||||
def test_rejects_dunder_subclasses(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition(
|
||||
"x.__class__.__mro__[-1].__subclasses__()",
|
||||
{"x": []},
|
||||
)
|
||||
|
||||
def test_rejects_undefined_variable(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("secret > 0", {})
|
||||
|
||||
def test_rejects_lambda(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("(lambda: 42)()", {})
|
||||
|
||||
def test_rejects_list_comprehension(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("[x for x in range(3)]", {})
|
||||
|
||||
def test_rejects_generator(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("(x for x in [1])", {})
|
||||
|
||||
def test_rejects_walrus(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("(x := 1)", {})
|
||||
|
||||
def test_rejects_ifexp(self):
|
||||
# IfExp (conditional) non autorisé par défaut — si besoin ajouter plus tard.
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("1 if True else 2", {})
|
||||
|
||||
def test_rejects_starred(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("[*x]", {"x": [1, 2]})
|
||||
|
||||
def test_rejects_attribute_call_chain(self):
|
||||
# Même si 'dict' est fourni dans le contexte, on n'autorise pas les
|
||||
# appels de méthode.
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition(
|
||||
"results.keys()", {"results": {"a": 1}}
|
||||
)
|
||||
|
||||
def test_rejects_huge_expression(self):
|
||||
big = "0+" * 1000 + "0"
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition(big, {})
|
||||
|
||||
def test_rejects_syntax_error(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition("1 + ", {})
|
||||
|
||||
def test_rejects_non_string(self):
|
||||
with pytest.raises(UnsafeExpressionError):
|
||||
safe_eval_condition(12345, {}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intégration avec DAGExecutor : le step condition doit refuser l'injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDAGExecutorIntegration:
|
||||
def test_condition_step_refuses_malicious_payload(self):
|
||||
"""Un workflow injectant __import__ dans 'condition' doit être refusé
|
||||
silencieusement (result = False) sans exécuter le code."""
|
||||
from core.execution.dag_executor import DAGExecutor, WorkflowStep, StepType
|
||||
|
||||
executor = DAGExecutor()
|
||||
step = WorkflowStep(
|
||||
step_id="malicious",
|
||||
step_type=StepType.CONDITION,
|
||||
action={"condition": "__import__('os').system('echo PWNED')"},
|
||||
)
|
||||
# Accès direct à la méthode privée pour isoler le comportement.
|
||||
result = executor._execute_condition_step(step, step.action)
|
||||
assert result is False
|
||||
|
||||
def test_condition_step_accepts_safe_expression(self):
|
||||
from core.execution.dag_executor import DAGExecutor, WorkflowStep, StepType
|
||||
|
||||
executor = DAGExecutor()
|
||||
executor._results["step_prev"] = {"ok": True}
|
||||
step = WorkflowStep(
|
||||
step_id="cond",
|
||||
step_type=StepType.CONDITION,
|
||||
action={"condition": "results['step_prev']['ok']"},
|
||||
)
|
||||
result = executor._execute_condition_step(step, step.action)
|
||||
assert result is True
|
||||
239
tests/unit/test_security_signed_serializer.py
Normal file
239
tests/unit/test_security_signed_serializer.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Tests de sécurité : sérialiseur JSON signé HMAC.
|
||||
|
||||
Couvre :
|
||||
- round-trip JSON signé
|
||||
- rejet d'un fichier altéré
|
||||
- fallback pickle legacy + migration
|
||||
- intégration FAISSManager (lecture / rejet HMAC)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.security.signed_serializer import (
|
||||
SignatureVerificationError,
|
||||
UnsupportedFormatError,
|
||||
dumps_signed,
|
||||
load_signed,
|
||||
loads_signed,
|
||||
save_signed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signing_key(monkeypatch):
|
||||
"""Force une clé de signature stable pour les tests."""
|
||||
monkeypatch.setenv("RPA_SIGNING_KEY", "test-signing-key-for-unit-tests-only")
|
||||
monkeypatch.setenv("RPA_ALLOW_PICKLE_FALLBACK", "1")
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip et types étendus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoundTrip:
|
||||
def test_primitive_types(self, tmp_path: Path):
|
||||
payload = {"a": 1, "b": "texte", "c": [1, 2, 3], "d": None}
|
||||
path = tmp_path / "data.json.signed"
|
||||
save_signed(path, payload)
|
||||
assert load_signed(path) == payload
|
||||
|
||||
def test_numpy_roundtrip(self, tmp_path: Path):
|
||||
arr = np.arange(12, dtype=np.float32).reshape(3, 4)
|
||||
path = tmp_path / "arr.json.signed"
|
||||
save_signed(path, {"embedding": arr})
|
||||
loaded = load_signed(path)
|
||||
assert isinstance(loaded["embedding"], np.ndarray)
|
||||
assert loaded["embedding"].shape == (3, 4)
|
||||
assert loaded["embedding"].dtype == np.float32
|
||||
np.testing.assert_array_equal(loaded["embedding"], arr)
|
||||
|
||||
def test_datetime_roundtrip(self, tmp_path: Path):
|
||||
now = datetime(2026, 4, 13, 10, 0, 0)
|
||||
path = tmp_path / "dt.json.signed"
|
||||
save_signed(path, {"created_at": now})
|
||||
loaded = load_signed(path)
|
||||
assert loaded["created_at"] == now
|
||||
|
||||
def test_bytes_payload(self):
|
||||
raw = dumps_signed({"blob": b"\x00\x01\x02"})
|
||||
out = loads_signed(raw)
|
||||
assert out["blob"] == b"\x00\x01\x02"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rejet d'un fichier altéré
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTampering:
|
||||
def test_rejects_tampered_payload(self, tmp_path: Path):
|
||||
path = tmp_path / "f.signed"
|
||||
save_signed(path, {"score": 0.5})
|
||||
|
||||
raw = path.read_bytes()
|
||||
# Altérer un caractère quelque part dans le payload base64.
|
||||
idx = raw.rfind(b'"payload_b64":"') + len(b'"payload_b64":"')
|
||||
tampered = raw[:idx] + (b"X" if raw[idx:idx + 1] != b"X" else b"Y") + raw[idx + 1:]
|
||||
path.write_bytes(tampered)
|
||||
|
||||
with pytest.raises((SignatureVerificationError, Exception)):
|
||||
load_signed(path)
|
||||
|
||||
def test_rejects_tampered_hmac(self, tmp_path: Path):
|
||||
path = tmp_path / "f.signed"
|
||||
save_signed(path, {"score": 0.5})
|
||||
|
||||
raw = path.read_bytes()
|
||||
tampered = raw.replace(b'"hmac":"', b'"hmac":"0')
|
||||
path.write_bytes(tampered)
|
||||
|
||||
with pytest.raises(SignatureVerificationError):
|
||||
load_signed(path)
|
||||
|
||||
def test_rejects_wrong_key(self, tmp_path: Path, monkeypatch):
|
||||
path = tmp_path / "f.signed"
|
||||
save_signed(path, {"score": 0.5})
|
||||
|
||||
# Changer la clé : la vérification doit échouer.
|
||||
monkeypatch.setenv("RPA_SIGNING_KEY", "other-key")
|
||||
with pytest.raises(SignatureVerificationError):
|
||||
load_signed(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback pickle + migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPickleFallback:
|
||||
def test_pickle_fallback_loads_and_migrates(self, tmp_path: Path):
|
||||
# Écrire un vieux fichier pickle (format legacy).
|
||||
path = tmp_path / "legacy.pkl"
|
||||
payload = {"score": 0.42, "label": "legacy"}
|
||||
with open(path, "wb") as fp:
|
||||
pickle.dump(payload, fp)
|
||||
|
||||
# Chargement : doit réussir ET migrer le fichier en signé.
|
||||
loaded = load_signed(path, allow_pickle_fallback=True, migrate_on_fallback=True)
|
||||
assert loaded == payload
|
||||
|
||||
# Le fichier doit maintenant être au format signé.
|
||||
new_raw = path.read_bytes()
|
||||
assert new_raw.startswith(b"RPA_SIGNED_V1\n")
|
||||
|
||||
# Et relisable via le format signé.
|
||||
loaded2 = load_signed(path)
|
||||
assert loaded2 == payload
|
||||
|
||||
def test_pickle_fallback_disabled(self, tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_ALLOW_PICKLE_FALLBACK", "0")
|
||||
path = tmp_path / "legacy.pkl"
|
||||
with open(path, "wb") as fp:
|
||||
pickle.dump({"x": 1}, fp)
|
||||
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
load_signed(path)
|
||||
|
||||
def test_pickle_fallback_explicit_off(self, tmp_path: Path):
|
||||
path = tmp_path / "legacy.pkl"
|
||||
with open(path, "wb") as fp:
|
||||
pickle.dump({"x": 1}, fp)
|
||||
|
||||
with pytest.raises(UnsupportedFormatError):
|
||||
load_signed(path, allow_pickle_fallback=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intégration FAISSManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytest.importorskip("faiss", reason="FAISS non installé.")
|
||||
|
||||
|
||||
class TestFAISSManagerSignedMetadata:
|
||||
def test_save_and_load_roundtrip(self, tmp_path: Path):
|
||||
from core.embedding.faiss_manager import FAISSManager
|
||||
|
||||
manager = FAISSManager(dimensions=8, index_type="Flat", metric="cosine")
|
||||
vec = np.random.rand(8).astype(np.float32)
|
||||
manager.add_embedding("emb_1", vec, metadata={"label": "target"})
|
||||
|
||||
index_path = tmp_path / "index.bin"
|
||||
meta_path = tmp_path / "meta.signed"
|
||||
manager.save(index_path, meta_path)
|
||||
|
||||
# Le fichier métadonnées doit être signé.
|
||||
raw = meta_path.read_bytes()
|
||||
assert raw.startswith(b"RPA_SIGNED_V1\n")
|
||||
|
||||
# Recharger.
|
||||
reloaded = FAISSManager.load(index_path, meta_path)
|
||||
assert reloaded.dimensions == 8
|
||||
assert reloaded.next_id == 1
|
||||
assert 0 in reloaded.metadata_store
|
||||
assert reloaded.metadata_store[0]["embedding_id"] == "emb_1"
|
||||
|
||||
def test_load_refuses_tampered_metadata(self, tmp_path: Path):
|
||||
from core.embedding.faiss_manager import FAISSManager
|
||||
|
||||
manager = FAISSManager(dimensions=4, index_type="Flat", metric="cosine")
|
||||
manager.add_embedding("e", np.ones(4, dtype=np.float32), metadata={})
|
||||
|
||||
index_path = tmp_path / "index.bin"
|
||||
meta_path = tmp_path / "meta.signed"
|
||||
manager.save(index_path, meta_path)
|
||||
|
||||
# Altérer la signature du fichier.
|
||||
raw = meta_path.read_bytes()
|
||||
meta_path.write_bytes(raw.replace(b'"hmac":"', b'"hmac":"0'))
|
||||
|
||||
with pytest.raises(SignatureVerificationError):
|
||||
FAISSManager.load(index_path, meta_path)
|
||||
|
||||
def test_load_migrates_legacy_pickle(self, tmp_path: Path):
|
||||
"""Un fichier métadonnées pickle legacy doit être migré."""
|
||||
from core.embedding.faiss_manager import FAISSManager
|
||||
import faiss
|
||||
|
||||
# Construire manuellement un fichier legacy (comme l'ancienne version).
|
||||
manager = FAISSManager(dimensions=4, index_type="Flat", metric="cosine")
|
||||
vec = np.ones(4, dtype=np.float32)
|
||||
manager.add_embedding("legacy_emb", vec, metadata={"tag": "old"})
|
||||
|
||||
index_path = tmp_path / "index.bin"
|
||||
meta_path = tmp_path / "meta.pkl"
|
||||
|
||||
# Écrire l'index FAISS normalement...
|
||||
index_to_save = manager.index
|
||||
faiss.write_index(index_to_save, str(index_path))
|
||||
|
||||
# ...mais les métadonnées en pickle brut (format pré-correctif).
|
||||
legacy = {
|
||||
"dimensions": 4,
|
||||
"index_type": "Flat",
|
||||
"metric": "cosine",
|
||||
"next_id": manager.next_id,
|
||||
"metadata_store": manager.metadata_store,
|
||||
"nlist": None,
|
||||
"nprobe": 8,
|
||||
"is_trained": True,
|
||||
"auto_optimize": True,
|
||||
}
|
||||
with open(meta_path, "wb") as fp:
|
||||
pickle.dump(legacy, fp)
|
||||
|
||||
# Chargement : doit réussir + migrer vers format signé.
|
||||
reloaded = FAISSManager.load(index_path, meta_path)
|
||||
assert reloaded.dimensions == 4
|
||||
assert reloaded.next_id == 1
|
||||
|
||||
# Le fichier a été ré-écrit en signé.
|
||||
assert meta_path.read_bytes().startswith(b"RPA_SIGNED_V1\n")
|
||||
391
tests/unit/test_system_dialog_guard.py
Normal file
391
tests/unit/test_system_dialog_guard.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# tests/unit/test_system_dialog_guard.py
|
||||
"""
|
||||
Tests du garde-fou sécurité : détection des dialogues système Windows critiques.
|
||||
|
||||
Objectif : garantir que Léa REFUSE de cliquer automatiquement sur un
|
||||
UAC / CredUI / SmartScreen (vecteur d'attaque ransomware).
|
||||
|
||||
Philosophie :
|
||||
- Faux positif tolérable (pause pour rien).
|
||||
- Faux négatif catastrophique (clic UAC).
|
||||
- Les tests privilégient la sécurité : tout dialogue suspect DOIT matcher.
|
||||
|
||||
Cf. agent_v0/agent_v1/core/system_dialog_guard.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_v0.agent_v1.core.system_dialog_guard import (
|
||||
SystemDialogCategory,
|
||||
SystemDialogDetection,
|
||||
is_system_dialog,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UAC (Contrôle de compte d'utilisateur) — le danger le plus grave
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestUACDetection:
|
||||
"""Un UAC qui n'est PAS détecté = vecteur d'attaque ransomware."""
|
||||
|
||||
def test_uac_via_class_name_exact(self):
|
||||
"""ClassName UIA du Consent.exe."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "$$$Secure UAP Dummy Window Class$$$"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
assert d.matched_signal == "class_name"
|
||||
|
||||
def test_uac_via_class_name_case_variation(self):
|
||||
"""Robustesse à la casse (UIA peut varier)."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "$$$SECURE UAP DUMMY WINDOW CLASS$$$"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_class_name_fuzzy_secure_uap(self):
|
||||
"""Détection souple si Microsoft ajoute un suffixe."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "Secure UAP Dummy"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_process_consent_exe(self):
|
||||
"""Le process consent.exe signe un UAC, peu importe la classe."""
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "consent.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_process_consent_exe_with_path(self):
|
||||
"""Chemin complet doit être normalisé."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"process_name": r"C:\Windows\System32\consent.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_app_name_in_window_info(self):
|
||||
"""psutil.name() (app_name côté agent) doit être reconnu."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Administrateur", "app_name": "consent.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_fr(self):
|
||||
"""Titre français officiel."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Contrôle de compte d'utilisateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_en(self):
|
||||
"""Titre anglais."""
|
||||
d = is_system_dialog(window_info={"title": "User Account Control"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_question_fr(self):
|
||||
"""Phrase caractéristique du prompt UAC."""
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": (
|
||||
"Voulez-vous autoriser cette application à apporter "
|
||||
"des modifications à votre appareil ?"
|
||||
)
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_question_en(self):
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": "Do you want to allow this app to make changes to your device?"
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_parent_path_button_focused(self):
|
||||
"""Cas critique : le focus est sur le bouton 'Oui' de l'UAC.
|
||||
|
||||
Le ClassName du Button sera "Button", mais le parent est bien
|
||||
le Consent.exe. La détection DOIT matcher sur parent_path.
|
||||
"""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={
|
||||
"class_name": "Button",
|
||||
"name": "Oui",
|
||||
"control_type": "Button",
|
||||
"parent_path": [
|
||||
{"class_name": "$$$Secure UAP Dummy Window Class$$$", "name": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
assert "parent" in d.matched_signal.lower() or d.matched_signal == "parent_class_name"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CredUI (prompt mot de passe Windows)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCredUIDetection:
|
||||
"""Les prompts de mot de passe ne doivent JAMAIS recevoir de frappe auto."""
|
||||
|
||||
def test_credui_via_class_name(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "Credential Dialog Xaml Host"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_variant_no_space(self):
|
||||
"""Variante sans espaces."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "CredentialDialogXamlHost"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_process_credentialuibroker(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"process_name": "CredentialUIBroker.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_process_credui(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "credui.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_fr(self):
|
||||
d = is_system_dialog(window_info={"title": "Sécurité Windows"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_en(self):
|
||||
d = is_system_dialog(window_info={"title": "Windows Security"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_enter_creds(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Connectez-vous à votre compte"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SmartScreen
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSmartScreenDetection:
|
||||
|
||||
def test_smartscreen_via_process(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "smartscreen.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_via_title_fr(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Windows a protégé votre ordinateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_via_title_en(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Windows protected your PC"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_unknown_publisher(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Éditeur inconnu — Voulez-vous continuer ?"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Autres dialogues système
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOtherSystemDialogs:
|
||||
|
||||
def test_defender_via_process(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "MsMpEng.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.DEFENDER
|
||||
|
||||
def test_defender_threat_detected(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Menace détectée — Windows Defender"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
# Le regex "windows defender" gagne (listé en premier dans les patterns)
|
||||
assert d.category == SystemDialogCategory.DEFENDER
|
||||
|
||||
def test_driver_install(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Installer ce pilote logiciel ?"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.DRIVER
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FAUX POSITIFS À ÉVITER — les apps métier doivent passer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBusinessAppsPassThrough:
|
||||
"""Vérifier que les apps métier (OSIRIS, OBSIUS, MEDSPHERE, etc.)
|
||||
ne sont jamais confondues avec des dialogues système.
|
||||
|
||||
Un faux positif ici = workflow métier cassé.
|
||||
"""
|
||||
|
||||
def test_osiris_main_window(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "OSIRIS - Patient Dupont Jean", "app_name": "osiris.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_osiris_confirmation_dialog(self):
|
||||
"""Un dialogue #32770 OSIRIS métier (confirmation sauvegarde) DOIT passer."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={
|
||||
"class_name": "#32770",
|
||||
"name": "Confirmation",
|
||||
"process_name": "osiris.exe",
|
||||
},
|
||||
window_info={"title": "Confirmation", "app_name": "osiris.exe"},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_obsius_main_window(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "OBSIUS - Consultation", "app_name": "obsius.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_medsphere(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "MEDSPHERE v4.2", "app_name": "medsphere.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_chrome_with_security_word_in_page(self):
|
||||
"""Chrome avec 'sécurité' dans le titre de page = OK si app_name=chrome.exe.
|
||||
|
||||
NOTE : on accepte ici un faux positif théorique car 'Sécurité Windows'
|
||||
est matché par le regex titre. Un navigateur affichant cette exacte
|
||||
phrase en titre est possible. Compte tenu de l'asymétrie (clic UAC =
|
||||
catastrophe), on ACCEPTE cette pause supervisée de fait.
|
||||
"""
|
||||
# Cas neutre : titre Chrome sans 'Sécurité Windows'
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": "Google Chrome - Recherche Google",
|
||||
"app_name": "chrome.exe",
|
||||
}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_excel_file(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Classeur1 - Excel", "app_name": "EXCEL.EXE"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_notepad(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Sans titre - Bloc-notes", "app_name": "notepad.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cas limites — robustesse
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
def test_no_input_returns_false(self):
|
||||
"""Aucune info disponible = pas de blocage (fail-open)."""
|
||||
d = is_system_dialog()
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_empty_strings(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "", "process_name": "", "name": ""},
|
||||
window_info={"title": "", "app_name": ""},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_none_values(self):
|
||||
"""Les champs à None ne doivent pas planter."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": None, "process_name": None},
|
||||
window_info={"title": None, "app_name": None},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_detection_to_dict_serializable(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "User Account Control"}
|
||||
)
|
||||
data = d.to_dict()
|
||||
assert data["is_system_dialog"] is True
|
||||
assert data["category"] == SystemDialogCategory.UAC
|
||||
assert data["reason"]
|
||||
|
||||
def test_unicode_title_fr_accents(self):
|
||||
"""Les accents ne cassent pas la détection."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Contrôle de compte d'utilisateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
|
||||
def test_whitespace_title(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": " Windows Security "}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Intégration avec detect_current_system_dialog
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_detect_current_system_dialog_no_exception_linux():
|
||||
"""Sur Linux, UIA indispo mais l'appel ne doit jamais planter."""
|
||||
from agent_v0.agent_v1.core.system_dialog_guard import detect_current_system_dialog
|
||||
detection = detect_current_system_dialog()
|
||||
# On ne valide pas le résultat (dépend de la fenêtre active) —
|
||||
# juste qu'il n'y a pas d'exception.
|
||||
assert isinstance(detection, SystemDialogDetection)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
148
tests/unit/test_uac_guard_fail_closed_p0d.py
Normal file
148
tests/unit/test_uac_guard_fail_closed_p0d.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Tests du Fix P0-D : le garde-fou de dialogue système doit fail-closed.
|
||||
|
||||
Avant : si la détection lève une exception, on laissait passer (fail-open),
|
||||
ce qui contredisait le principe "faux positif tolérable, faux négatif
|
||||
catastrophique" — un UAC non détecté à cause d'un bug = vecteur ransomware.
|
||||
|
||||
Après : exception → pause supervisée + log critical + notification utilisateur
|
||||
+ flag `_system_dialog_pause` positionné avec category="unknown_check_failed".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor():
|
||||
"""Crée un ActionExecutorV1 sans déclencher l'init mss/pynput lourd.
|
||||
|
||||
On instancie via __new__ + on injecte les attributs minimaux nécessaires
|
||||
au test du garde-fou.
|
||||
"""
|
||||
from agent_v0.agent_v1.core.executor import ActionExecutorV1
|
||||
|
||||
exe = ActionExecutorV1.__new__(ActionExecutorV1)
|
||||
exe._system_dialog_pause = None
|
||||
exe._notification_manager = None # le @property notifier renverra _Noop
|
||||
return exe
|
||||
|
||||
|
||||
class TestUACGuardFailClosedP0D:
|
||||
"""Fix P0-D : exception dans la détection → pause supervisée."""
|
||||
|
||||
def test_exception_triggers_pause(self, executor, monkeypatch):
|
||||
"""Si detect_current_system_dialog lève, on pause au lieu de laisser passer."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise RuntimeError("UIA backend down")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
# Avant : pas de pause
|
||||
assert executor._system_dialog_pause is None
|
||||
|
||||
# Appel : doit retourner True (= "STOP") au lieu de False
|
||||
result = executor._check_and_pause_on_system_dialog(
|
||||
context="test_p0d_unit"
|
||||
)
|
||||
|
||||
assert result is True, (
|
||||
"fail-closed : exception → True (le caller doit stopper) "
|
||||
"et NON False comme avant le fix"
|
||||
)
|
||||
|
||||
def test_exception_sets_pause_state(self, executor, monkeypatch):
|
||||
"""Vérifie que _system_dialog_pause contient les bonnes infos."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise ValueError("XPath error")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
executor._check_and_pause_on_system_dialog(context="popup_handler")
|
||||
|
||||
pause = executor._system_dialog_pause
|
||||
assert pause is not None, "Le flag de pause doit être positionné"
|
||||
assert pause["category"] == "unknown_check_failed"
|
||||
assert pause["matched_signal"] == "exception"
|
||||
assert pause["matched_value"] == "ValueError"
|
||||
assert "XPath error" in pause["reason"]
|
||||
assert pause["context"] == "popup_handler"
|
||||
|
||||
def test_no_exception_no_dialog_returns_false(self, executor, monkeypatch):
|
||||
"""Si pas de dialogue système et pas d'exception → False (laisse passer)."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
class _FakeDetection:
|
||||
is_system_dialog = False
|
||||
category = None
|
||||
matched_signal = None
|
||||
matched_value = None
|
||||
reason = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
guard_mod,
|
||||
"detect_current_system_dialog",
|
||||
lambda *a, **k: _FakeDetection(),
|
||||
)
|
||||
|
||||
result = executor._check_and_pause_on_system_dialog(context="ok")
|
||||
assert result is False
|
||||
assert executor._system_dialog_pause is None
|
||||
|
||||
def test_dialog_detected_returns_true(self, executor, monkeypatch):
|
||||
"""Si UAC détecté légitimement → True + pause (comportement existant)."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
class _UACDetection:
|
||||
is_system_dialog = True
|
||||
category = "uac_consent"
|
||||
matched_signal = "class_name"
|
||||
matched_value = "$$$Secure UAP Dummy Window Class$$$"
|
||||
reason = "UAC consent prompt detected"
|
||||
|
||||
monkeypatch.setattr(
|
||||
guard_mod,
|
||||
"detect_current_system_dialog",
|
||||
lambda *a, **k: _UACDetection(),
|
||||
)
|
||||
|
||||
result = executor._check_and_pause_on_system_dialog(context="uac_test")
|
||||
assert result is True
|
||||
assert executor._system_dialog_pause is not None
|
||||
assert executor._system_dialog_pause["category"] == "uac_consent"
|
||||
|
||||
def test_exception_notifies_user(self, executor, monkeypatch):
|
||||
"""L'exception doit déclencher une notification utilisateur."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise OSError("UIA com error")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
# Spy sur la notification
|
||||
notifications = []
|
||||
|
||||
class _SpyNotifier:
|
||||
def notify(self, title=None, message=None, **kwargs):
|
||||
notifications.append((title, message))
|
||||
|
||||
executor._notification_manager = _SpyNotifier()
|
||||
|
||||
executor._check_and_pause_on_system_dialog(context="spy_test")
|
||||
|
||||
assert len(notifications) >= 1, (
|
||||
"Une notification doit être envoyée à l'utilisateur"
|
||||
)
|
||||
title, message = notifications[0]
|
||||
assert "sécurité" in title.lower() or "lea" in title.lower()
|
||||
assert "garde-fou" in message.lower() or "pause" in message.lower()
|
||||
264
tests/unit/test_workflow_pipeline_get_next_action.py
Normal file
264
tests/unit/test_workflow_pipeline_get_next_action.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Tests de la sélection robuste d'edge dans WorkflowPipeline.get_next_action (C3).
|
||||
|
||||
Vérifie que la nouvelle API utilise EdgeScorer et expose le contrat dict
|
||||
normalisé (Lot A — avril 2026) :
|
||||
- status="selected" → edge choisi
|
||||
- status="terminal" → aucun outgoing_edge (fin légitime)
|
||||
- status="blocked" → candidats rejetés (NE DOIT PAS être traité comme fin)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.models.workflow_graph import (
|
||||
Action,
|
||||
EdgeConstraints,
|
||||
EdgeStats,
|
||||
PostConditions,
|
||||
TargetSpec,
|
||||
Workflow,
|
||||
WorkflowEdge,
|
||||
WorkflowNode,
|
||||
)
|
||||
|
||||
|
||||
def _edge(
|
||||
edge_id: str,
|
||||
required_window_title: str = "",
|
||||
success_rate: float = 0.5,
|
||||
execution_count: int = 10,
|
||||
min_source_similarity: float = 0.80,
|
||||
) -> WorkflowEdge:
|
||||
stats = EdgeStats()
|
||||
if execution_count > 0:
|
||||
stats.execution_count = execution_count
|
||||
stats.success_count = int(round(success_rate * execution_count))
|
||||
stats.failure_count = execution_count - stats.success_count
|
||||
|
||||
return WorkflowEdge(
|
||||
edge_id=edge_id,
|
||||
from_node="n1",
|
||||
to_node="n2",
|
||||
action=Action(type="mouse_click", target=TargetSpec()),
|
||||
constraints=EdgeConstraints(
|
||||
required_window_title=required_window_title,
|
||||
min_source_similarity=min_source_similarity,
|
||||
),
|
||||
post_conditions=PostConditions(),
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
|
||||
def _state(window_title: str = "AppA") -> ScreenState:
|
||||
return ScreenState(
|
||||
screen_state_id="s",
|
||||
timestamp=datetime.now(),
|
||||
session_id="sess",
|
||||
window=WindowContext(
|
||||
app_name="app", window_title=window_title, screen_resolution=[1920, 1080]
|
||||
),
|
||||
raw=RawLevel(screenshot_path="", capture_method="t", file_size_bytes=0),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=[],
|
||||
text_detection_method="none",
|
||||
confidence_avg=0.0,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline_with_workflow(tmp_path):
|
||||
"""Pipeline minimal avec un workflow en mémoire (Workflow mocké).
|
||||
|
||||
On évite la construction d'un vrai Workflow (ScreenTemplate trop lourd)
|
||||
en utilisant un MagicMock configuré pour les méthodes utilisées par
|
||||
`get_next_action` : `get_outgoing_edges`.
|
||||
"""
|
||||
from core.pipeline.workflow_pipeline import WorkflowPipeline
|
||||
|
||||
# Stub pour éviter les lourds imports (mocks sur composants GPU)
|
||||
with patch.multiple(
|
||||
"core.pipeline.workflow_pipeline",
|
||||
UIDetector=MagicMock(),
|
||||
CLIPEmbedder=MagicMock(),
|
||||
StateEmbeddingBuilder=MagicMock(),
|
||||
FusionEngine=MagicMock(),
|
||||
FAISSManager=MagicMock(),
|
||||
GraphBuilder=MagicMock(),
|
||||
NodeMatcher=MagicMock(),
|
||||
HierarchicalMatcher=MagicMock(),
|
||||
LearningManager=MagicMock(),
|
||||
ActionExecutor=MagicMock(),
|
||||
TargetResolver=MagicMock(),
|
||||
ErrorHandler=MagicMock(),
|
||||
):
|
||||
pipeline = WorkflowPipeline(data_dir=str(tmp_path), use_gpu=False)
|
||||
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.workflow_id = "wf1"
|
||||
workflow.edges = []
|
||||
workflow.get_outgoing_edges = lambda node_id: [
|
||||
e for e in workflow.edges if e.from_node == node_id
|
||||
]
|
||||
pipeline._workflows["wf1"] = workflow
|
||||
return pipeline, workflow
|
||||
|
||||
|
||||
class TestGetNextActionC3:
|
||||
|
||||
def test_picks_highest_success_rate(self, pipeline_with_workflow):
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [
|
||||
_edge("low", success_rate=0.1, execution_count=20),
|
||||
_edge("high", success_rate=0.9, execution_count=20),
|
||||
]
|
||||
result = pipeline.get_next_action("wf1", "n1", screen_state=_state())
|
||||
assert result["status"] == "selected"
|
||||
assert result["edge_id"] == "high"
|
||||
|
||||
def test_filters_out_invalid_preconditions(self, pipeline_with_workflow):
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [
|
||||
_edge("bad", required_window_title="NopeApp", success_rate=0.99, execution_count=20),
|
||||
_edge("ok", success_rate=0.50, execution_count=20),
|
||||
]
|
||||
result = pipeline.get_next_action(
|
||||
"wf1", "n1", screen_state=_state(window_title="AppA")
|
||||
)
|
||||
assert result["status"] == "selected"
|
||||
assert result["edge_id"] == "ok"
|
||||
|
||||
def test_blocked_when_no_valid_edge(self, pipeline_with_workflow):
|
||||
"""Des candidats existent mais aucun ne passe les contraintes.
|
||||
|
||||
Lot A — cas critique : on NE DOIT PAS retourner "terminal" ici. Un
|
||||
blocage doit remonter explicitement pour déclencher pause supervisée.
|
||||
"""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [
|
||||
_edge("e1", required_window_title="AppB"),
|
||||
_edge("e2", required_window_title="AppC"),
|
||||
]
|
||||
result = pipeline.get_next_action(
|
||||
"wf1", "n1", screen_state=_state(window_title="AppA")
|
||||
)
|
||||
assert result["status"] == "blocked"
|
||||
assert result["reason"] == "no_valid_edge"
|
||||
|
||||
def test_strategy_first_keeps_legacy_behavior(self, pipeline_with_workflow):
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [
|
||||
_edge("e1", success_rate=0.1, execution_count=20),
|
||||
_edge("e2", success_rate=0.9, execution_count=20),
|
||||
]
|
||||
result = pipeline.get_next_action(
|
||||
"wf1", "n1", screen_state=_state(), strategy="first"
|
||||
)
|
||||
# Mode legacy : premier edge sans tri
|
||||
assert result["status"] == "selected"
|
||||
assert result["edge_id"] == "e1"
|
||||
|
||||
def test_no_screen_state_still_works(self, pipeline_with_workflow):
|
||||
"""Sans ScreenState, le scorer ne peut pas filtrer mais peut ranker."""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [
|
||||
_edge("e1", success_rate=0.1, execution_count=20),
|
||||
_edge("e2", success_rate=0.9, execution_count=20),
|
||||
]
|
||||
result = pipeline.get_next_action("wf1", "n1", screen_state=None)
|
||||
assert result["status"] == "selected"
|
||||
# Le ranking par success_rate fonctionne toujours
|
||||
assert result["edge_id"] == "e2"
|
||||
|
||||
def test_no_outgoing_edges_is_terminal(self, pipeline_with_workflow):
|
||||
"""Aucun outgoing_edge = fin légitime du workflow (status="terminal")."""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = []
|
||||
result = pipeline.get_next_action("wf1", "n1", screen_state=_state())
|
||||
assert result["status"] == "terminal"
|
||||
|
||||
def test_blocked_distinct_from_terminal(self, pipeline_with_workflow):
|
||||
"""Régression Lot A : blocked != terminal.
|
||||
|
||||
Le bug historique confondait ces deux cas. Un workflow bloqué
|
||||
apparaissait comme "terminé avec succès" côté ExecutionLoop.
|
||||
"""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
|
||||
# Cas terminal : pas d'outgoing
|
||||
wf.edges = []
|
||||
terminal = pipeline.get_next_action("wf1", "n1", screen_state=_state())
|
||||
|
||||
# Cas bloqué : outgoing présent mais rejetés
|
||||
wf.edges = [_edge("bad", required_window_title="NopeApp")]
|
||||
blocked = pipeline.get_next_action("wf1", "n1", screen_state=_state(window_title="AppA"))
|
||||
|
||||
assert terminal["status"] == "terminal"
|
||||
assert blocked["status"] == "blocked"
|
||||
# L'appelant doit pouvoir les distinguer sans ambiguïté
|
||||
assert terminal["status"] != blocked["status"]
|
||||
|
||||
def test_workflow_not_found_is_blocked(self, pipeline_with_workflow):
|
||||
"""Workflow inexistant = blocked avec reason explicite (pas silencieux)."""
|
||||
pipeline, _wf = pipeline_with_workflow
|
||||
result = pipeline.get_next_action(
|
||||
"wf_inexistant", "n1", screen_state=_state()
|
||||
)
|
||||
assert result["status"] == "blocked"
|
||||
assert result["reason"] == "workflow_not_found"
|
||||
|
||||
|
||||
class TestGetNextActionSourceSimilarity:
|
||||
"""Lot B — propagation de source_similarity jusqu'à EdgeScorer."""
|
||||
|
||||
def test_high_similarity_passes_min_source_similarity(
|
||||
self, pipeline_with_workflow
|
||||
):
|
||||
"""source_similarity élevée → edge accepté."""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [_edge("e1", min_source_similarity=0.80)]
|
||||
result = pipeline.get_next_action(
|
||||
"wf1", "n1", screen_state=_state(), source_similarity=0.95
|
||||
)
|
||||
assert result["status"] == "selected"
|
||||
assert result["edge_id"] == "e1"
|
||||
|
||||
def test_low_similarity_blocks_edge(self, pipeline_with_workflow):
|
||||
"""source_similarity < min_source_similarity → edge rejeté → blocked.
|
||||
|
||||
C'est la preuve que la précondition min_source_similarity est
|
||||
redevenue effective (Lot B). Avant ce lot, l'EdgeScorer recevait
|
||||
toujours 1.0 hardcodé et ne rejetait jamais l'edge pour ce motif.
|
||||
"""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
wf.edges = [_edge("e1", min_source_similarity=0.80)]
|
||||
result = pipeline.get_next_action(
|
||||
"wf1", "n1", screen_state=_state(), source_similarity=0.40
|
||||
)
|
||||
assert result["status"] == "blocked"
|
||||
assert result["reason"] == "no_valid_edge"
|
||||
|
||||
def test_default_source_similarity_is_one(self, pipeline_with_workflow):
|
||||
"""Sans source_similarity fourni → défaut 1.0 → pas de rejet pour
|
||||
ce motif (compat avec les call sites qui ne l'ont pas encore)."""
|
||||
pipeline, wf = pipeline_with_workflow
|
||||
# min_source_similarity très strict, mais défaut appelant = 1.0
|
||||
wf.edges = [_edge("e1", min_source_similarity=0.99)]
|
||||
result = pipeline.get_next_action("wf1", "n1", screen_state=_state())
|
||||
assert result["status"] == "selected"
|
||||
400
tests/unit/test_workflow_pipeline_match_from_state.py
Normal file
400
tests/unit/test_workflow_pipeline_match_from_state.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Tests unitaires du matching context-aware — Lot E.
|
||||
|
||||
Vérifient que ``WorkflowPipeline.match_current_state_from_state`` :
|
||||
- Consomme réellement le ``ScreenState`` fourni (window_title,
|
||||
detected_text, ui_elements) au lieu de le reconstruire en stub.
|
||||
- Ne réinvoque PAS ``ScreenAnalyzer.analyze`` (le state est déjà prêt).
|
||||
- Préfère le matching hiérarchique si un workflow est chargeable.
|
||||
- Retombe sur FAISS quand le hiérarchique n'est pas applicable.
|
||||
|
||||
Vérifient aussi que l'ancienne API ``match_current_state(screenshot_path, ...)``
|
||||
continue à fonctionner comme un **wrapper** qui invoque bien le
|
||||
``ScreenAnalyzer`` puis délègue à ``match_current_state_from_state``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core.models.screen_state import (
|
||||
ContextLevel,
|
||||
EmbeddingRef,
|
||||
PerceptionLevel,
|
||||
RawLevel,
|
||||
ScreenState,
|
||||
WindowContext,
|
||||
)
|
||||
from core.pipeline.workflow_pipeline import WorkflowPipeline
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_enriched_state(
|
||||
*,
|
||||
window_title: str = "Bloc-Notes - Sans titre",
|
||||
app_name: str = "notepad",
|
||||
detected_text=None,
|
||||
ui_elements=None,
|
||||
screenshot_path: str = "",
|
||||
) -> ScreenState:
|
||||
"""ScreenState enrichi utilisé pour simuler ce que ExecutionLoop fournit."""
|
||||
return ScreenState(
|
||||
screen_state_id="state_lot_e",
|
||||
timestamp=datetime.now(),
|
||||
session_id="sess_lot_e",
|
||||
window=WindowContext(
|
||||
app_name=app_name,
|
||||
window_title=window_title,
|
||||
screen_resolution=[1920, 1080],
|
||||
),
|
||||
raw=RawLevel(
|
||||
screenshot_path=screenshot_path,
|
||||
capture_method="test",
|
||||
file_size_bytes=0,
|
||||
),
|
||||
perception=PerceptionLevel(
|
||||
embedding=EmbeddingRef(provider="t", vector_id="v", dimensions=512),
|
||||
detected_text=detected_text if detected_text is not None else ["Fichier", "Édition"],
|
||||
text_detection_method="test",
|
||||
confidence_avg=0.9,
|
||||
),
|
||||
context=ContextLevel(),
|
||||
ui_elements=ui_elements if ui_elements is not None else [],
|
||||
)
|
||||
|
||||
|
||||
def _make_pipeline_with_mocks(tmp_path) -> WorkflowPipeline:
|
||||
"""Construit une WorkflowPipeline minimale avec composants mockés.
|
||||
|
||||
On évite d'instancier réellement CLIPEmbedder / UIDetector / VLM :
|
||||
on bypass ``__init__`` et on injecte directement les collaborateurs
|
||||
mockés. Plus rapide et plus déterministe.
|
||||
"""
|
||||
pipe = WorkflowPipeline.__new__(WorkflowPipeline)
|
||||
|
||||
# Répertoires
|
||||
pipe.data_dir = Path(tmp_path)
|
||||
pipe.workflows_dir = pipe.data_dir / "workflows"
|
||||
pipe.workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
pipe.embeddings_dir = pipe.data_dir / "embeddings"
|
||||
pipe.embeddings_dir.mkdir(parents=True, exist_ok=True)
|
||||
pipe.screenshots_dir = pipe.data_dir / "screenshots"
|
||||
pipe.screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Collaborateurs mockés
|
||||
fake_embedding = MagicMock()
|
||||
fake_embedding.get_vector.return_value = [0.0] * 8
|
||||
fake_embedding.embedding_id = "emb_test"
|
||||
|
||||
pipe.embedding_builder = MagicMock()
|
||||
pipe.embedding_builder.build.return_value = fake_embedding
|
||||
|
||||
pipe.faiss_manager = MagicMock()
|
||||
pipe.faiss_manager.search.return_value = []
|
||||
|
||||
pipe.hierarchical_matcher = MagicMock()
|
||||
|
||||
pipe.clip_embedder = MagicMock()
|
||||
pipe.fusion_engine = MagicMock()
|
||||
pipe.ui_detector = None
|
||||
pipe.vlm_client = None
|
||||
pipe.graph_builder = MagicMock()
|
||||
pipe.node_matcher = MagicMock()
|
||||
pipe.learning_manager = MagicMock()
|
||||
pipe.target_resolver = MagicMock()
|
||||
pipe.error_handler = MagicMock()
|
||||
pipe.action_executor = MagicMock()
|
||||
|
||||
pipe._workflows = {}
|
||||
pipe._temporal_context = {}
|
||||
|
||||
return pipe
|
||||
|
||||
|
||||
def _fake_hierarchical_result(
|
||||
node_id: str = "node_ok",
|
||||
confidence: float = 0.82,
|
||||
):
|
||||
"""Construit un MatchResult factice (compat avec l'API du HierarchicalMatcher)."""
|
||||
result = MagicMock()
|
||||
result.node_id = node_id
|
||||
result.confidence = confidence
|
||||
result.window_confidence = 0.9
|
||||
result.region_confidence = 0.8
|
||||
result.element_confidence = 0.85
|
||||
result.temporal_boost = 0.0
|
||||
result.matched_variant = None
|
||||
result.alternatives = []
|
||||
result.match_time_ms = 1.0
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. match_current_state_from_state — chemin hiérarchique
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchFromStateHierarchical:
|
||||
"""
|
||||
Quand un workflow est chargeable, on passe par le HierarchicalMatcher
|
||||
qui consomme window_title + ui_elements — c'est le cœur du Lot E.
|
||||
"""
|
||||
|
||||
def test_match_from_state_uses_provided_window_title(self, tmp_path):
|
||||
"""Le window_title fourni (Bloc-Notes) est transmis au matcher,
|
||||
pas un stub "Unknown"."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result()
|
||||
|
||||
state = _make_enriched_state(window_title="Bloc-Notes - Sans titre")
|
||||
result = pipe.match_current_state_from_state(state, workflow_id="wf1")
|
||||
|
||||
assert result is not None
|
||||
# Le matcher hiérarchique a été appelé avec le vrai window_title
|
||||
call_kwargs = pipe.hierarchical_matcher.match.call_args.kwargs
|
||||
window_info = call_kwargs["window_info"]
|
||||
assert window_info["title"] == "Bloc-Notes - Sans titre"
|
||||
assert window_info["window_title"] == "Bloc-Notes - Sans titre"
|
||||
# Pas de "Unknown"
|
||||
assert "Unknown" not in window_info["title"]
|
||||
|
||||
def test_match_from_state_uses_ui_elements(self, tmp_path):
|
||||
"""Les ui_elements du ScreenState sont transmis au matcher comme
|
||||
detected_elements, pas remplacés par []."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result()
|
||||
|
||||
# 3 éléments factices
|
||||
ui_elements = [MagicMock(), MagicMock(), MagicMock()]
|
||||
state = _make_enriched_state(ui_elements=ui_elements)
|
||||
pipe.match_current_state_from_state(state, workflow_id="wf1")
|
||||
|
||||
call_kwargs = pipe.hierarchical_matcher.match.call_args.kwargs
|
||||
passed_elements = call_kwargs["detected_elements"]
|
||||
assert len(passed_elements) == 3
|
||||
assert passed_elements == ui_elements
|
||||
|
||||
def test_match_from_state_uses_detected_text(self, tmp_path):
|
||||
"""Un ScreenState avec detected_text non vide doit être entièrement
|
||||
transmis (pas remplacé par un stub vide)."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result()
|
||||
|
||||
detected_text = ["Fichier", "Édition", "Affichage", "Aide"]
|
||||
state = _make_enriched_state(detected_text=detected_text)
|
||||
pipe.match_current_state_from_state(state, workflow_id="wf1")
|
||||
|
||||
# Le state lui-même n'est pas passé directement au matcher, mais il
|
||||
# ne doit pas avoir été réécrit en stub avant : on le vérifie
|
||||
# indirectement via ses propriétés conservées. Le state original
|
||||
# doit rester enrichi.
|
||||
assert state.perception.detected_text == detected_text
|
||||
assert state.perception.detected_text != []
|
||||
|
||||
def test_match_from_state_no_reconstruction(self, tmp_path):
|
||||
"""``ScreenAnalyzer.analyze`` ne doit PAS être appelé par
|
||||
``match_current_state_from_state`` — le state est déjà construit."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result()
|
||||
|
||||
state = _make_enriched_state()
|
||||
|
||||
# On patche get_screen_analyzer globalement : si la nouvelle méthode
|
||||
# invoque l'analyseur, le mock sera appelé. Attente : ZÉRO appel.
|
||||
with patch(
|
||||
"core.pipeline.get_screen_analyzer"
|
||||
) as mock_get_analyzer:
|
||||
fake_analyzer = MagicMock()
|
||||
mock_get_analyzer.return_value = fake_analyzer
|
||||
|
||||
pipe.match_current_state_from_state(state, workflow_id="wf1")
|
||||
|
||||
# get_screen_analyzer peut ou non être appelé (pas de garantie
|
||||
# forte), mais en tout cas analyze() ne doit PAS l'être.
|
||||
fake_analyzer.analyze.assert_not_called()
|
||||
|
||||
def test_match_from_state_below_threshold_returns_none(self, tmp_path):
|
||||
"""Si le hiérarchique rend une confidence < min_similarity, on
|
||||
retombe sur FAISS ; si FAISS ne trouve rien non plus, None."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result(
|
||||
confidence=0.1
|
||||
)
|
||||
pipe.faiss_manager.search.return_value = []
|
||||
|
||||
state = _make_enriched_state()
|
||||
result = pipe.match_current_state_from_state(
|
||||
state, workflow_id="wf1", min_similarity=0.5
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_match_from_state_returns_hierarchical_metadata(self, tmp_path):
|
||||
"""Le résultat doit inclure les confidences par niveau (window,
|
||||
region, element) quand on passe par le hiérarchique."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result(
|
||||
node_id="node_42", confidence=0.77
|
||||
)
|
||||
|
||||
state = _make_enriched_state()
|
||||
result = pipe.match_current_state_from_state(
|
||||
state, workflow_id="wf1", min_similarity=0.5
|
||||
)
|
||||
assert result is not None
|
||||
assert result["node_id"] == "node_42"
|
||||
assert result["confidence"] == 0.77
|
||||
assert result["workflow_id"] == "wf1"
|
||||
assert result["match_type"] == "hierarchical"
|
||||
assert "window_confidence" in result
|
||||
assert "region_confidence" in result
|
||||
assert "element_confidence" in result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. match_current_state_from_state — fallback FAISS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatchFromStateFAISSFallback:
|
||||
"""Si aucun workflow n'est chargeable, on tombe sur FAISS avec le state fourni."""
|
||||
|
||||
def test_fallback_faiss_when_no_workflow_id(self, tmp_path):
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.faiss_manager.search.return_value = [
|
||||
{
|
||||
"similarity": 0.91,
|
||||
"metadata": {"node_id": "n_faiss", "workflow_id": None},
|
||||
}
|
||||
]
|
||||
|
||||
state = _make_enriched_state()
|
||||
result = pipe.match_current_state_from_state(state, workflow_id=None)
|
||||
|
||||
# Pas de hiérarchique (pas de workflow_id)
|
||||
pipe.hierarchical_matcher.match.assert_not_called()
|
||||
# FAISS a reçu le vecteur calculé sur le state enrichi
|
||||
pipe.embedding_builder.build.assert_called_once_with(state)
|
||||
assert result is not None
|
||||
assert result["node_id"] == "n_faiss"
|
||||
assert result["match_type"] == "faiss"
|
||||
|
||||
def test_faiss_returns_none_below_threshold(self, tmp_path):
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.faiss_manager.search.return_value = [
|
||||
{
|
||||
"similarity": 0.6, # < 0.85
|
||||
"metadata": {"node_id": "n_low", "workflow_id": None},
|
||||
}
|
||||
]
|
||||
|
||||
state = _make_enriched_state()
|
||||
result = pipe.match_current_state_from_state(state, workflow_id=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. Wrapper legacy match_current_state(screenshot_path, ...)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyWrapper:
|
||||
"""
|
||||
L'ancienne API ``match_current_state(screenshot_path, ...)`` doit :
|
||||
1. Appeler ScreenAnalyzer.analyze pour enrichir le state.
|
||||
2. Déléguer à match_current_state_from_state.
|
||||
"""
|
||||
|
||||
def test_match_current_state_wrapper_calls_analyzer(self, tmp_path):
|
||||
"""Le wrapper legacy DOIT appeler ScreenAnalyzer.analyze."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
pipe.load_workflow = MagicMock(return_value=MagicMock(nodes=[MagicMock()]))
|
||||
pipe.hierarchical_matcher.match.return_value = _fake_hierarchical_result()
|
||||
|
||||
# Préparer un vrai fichier image pour le wrapper
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (64, 64), color=(100, 100, 100)).save(str(shot))
|
||||
|
||||
# Patcher l'analyseur partagé pour vérifier l'appel
|
||||
fake_analyzer = MagicMock()
|
||||
fake_analyzer.analyze.return_value = _make_enriched_state(
|
||||
window_title="Calc", screenshot_path=str(shot)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"core.pipeline.get_screen_analyzer",
|
||||
return_value=fake_analyzer,
|
||||
):
|
||||
result = pipe.match_current_state(str(shot), workflow_id="wf1")
|
||||
|
||||
# L'analyseur a été invoqué
|
||||
fake_analyzer.analyze.assert_called_once()
|
||||
# Et on a un résultat (le hiérarchique a été appelé derrière)
|
||||
assert result is not None
|
||||
assert pipe.hierarchical_matcher.match.called
|
||||
|
||||
def test_match_current_state_wrapper_delegates_to_from_state(self, tmp_path):
|
||||
"""Le wrapper délègue bien à match_current_state_from_state."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (32, 32), color=(50, 50, 50)).save(str(shot))
|
||||
|
||||
fake_analyzer = MagicMock()
|
||||
fake_analyzer.analyze.return_value = _make_enriched_state(
|
||||
screenshot_path=str(shot)
|
||||
)
|
||||
|
||||
# Espionner la nouvelle méthode (elle existe, on wrap)
|
||||
with patch(
|
||||
"core.pipeline.get_screen_analyzer",
|
||||
return_value=fake_analyzer,
|
||||
), patch.object(
|
||||
pipe,
|
||||
"match_current_state_from_state",
|
||||
return_value={"node_id": "x", "workflow_id": "wf1", "confidence": 0.9},
|
||||
) as mock_from_state:
|
||||
result = pipe.match_current_state(str(shot), workflow_id="wf1")
|
||||
|
||||
mock_from_state.assert_called_once()
|
||||
# Le state passé (args ou kwargs) est bien celui renvoyé par l'analyseur
|
||||
call = mock_from_state.call_args
|
||||
passed_state = call.args[0] if call.args else call.kwargs["screen_state"]
|
||||
assert passed_state is fake_analyzer.analyze.return_value
|
||||
assert result == {"node_id": "x", "workflow_id": "wf1", "confidence": 0.9}
|
||||
|
||||
def test_wrapper_fallback_to_stub_when_analyzer_fails(self, tmp_path):
|
||||
"""Si l'analyseur est indisponible/plante, le wrapper retombe sur un
|
||||
stub minimal pour garder la rétrocompat."""
|
||||
pipe = _make_pipeline_with_mocks(tmp_path)
|
||||
|
||||
shot = tmp_path / "shot.png"
|
||||
Image.new("RGB", (32, 32)).save(str(shot))
|
||||
|
||||
with patch(
|
||||
"core.pipeline.get_screen_analyzer",
|
||||
side_effect=RuntimeError("analyzer down"),
|
||||
), patch.object(
|
||||
pipe, "match_current_state_from_state", return_value=None
|
||||
) as mock_from_state:
|
||||
pipe.match_current_state(str(shot), workflow_id="wf1", window_title="Hint")
|
||||
|
||||
mock_from_state.assert_called_once()
|
||||
# Le state passé (args ou kwargs) est un stub (hint window_title respecté)
|
||||
call = mock_from_state.call_args
|
||||
passed_state = call.args[0] if call.args else call.kwargs["screen_state"]
|
||||
assert passed_state.window.window_title == "Hint"
|
||||
@@ -136,8 +136,18 @@ def capture_windows():
|
||||
agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
|
||||
agent_url = f'http://{agent_host}:{agent_port}/capture'
|
||||
|
||||
# Auth : l'agent exige un Bearer token (meme RPA_API_TOKEN que le streaming)
|
||||
api_token = os.environ.get('RPA_API_TOKEN', '')
|
||||
headers = {'Authorization': f'Bearer {api_token}'} if api_token else {}
|
||||
|
||||
try:
|
||||
resp = http_client.get(agent_url, timeout=10)
|
||||
resp = http_client.get(agent_url, headers=headers, timeout=10)
|
||||
if resp.status_code == 401:
|
||||
return jsonify({
|
||||
'error': 'Agent Windows : authentification refusee',
|
||||
'hint': 'Verifiez que RPA_API_TOKEN est defini et identique '
|
||||
'cote backend VWB et cote agent Windows.',
|
||||
}), 401
|
||||
if resp.ok:
|
||||
return jsonify(resp.json())
|
||||
return jsonify({
|
||||
|
||||
@@ -359,7 +359,7 @@ def _execute_db_foreach(
|
||||
|
||||
# 3. Pour chaque ligne, injecter et exécuter
|
||||
iteration_results = []
|
||||
model = executor_kwargs.get("model", "qwen3-vl:8b")
|
||||
model = executor_kwargs.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
|
||||
ollama_endpoint = executor_kwargs.get("ollama_endpoint", "http://localhost:11434")
|
||||
timeout = executor_kwargs.get("timeout", 300)
|
||||
|
||||
@@ -514,7 +514,7 @@ def execute_dag(workflow_id: str):
|
||||
|
||||
# Paramètres optionnels
|
||||
timeout = data.get("timeout", 300)
|
||||
model = data.get("model", "qwen3-vl:8b")
|
||||
model = data.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
|
||||
ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434")
|
||||
|
||||
executor_kwargs = {
|
||||
@@ -1000,21 +1000,34 @@ def execute_windows():
|
||||
file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
|
||||
if file_actions:
|
||||
# Exécuter les actions fichiers via l'agent Windows
|
||||
# Auth : Bearer token obligatoire (capture_server.py exige RPA_API_TOKEN)
|
||||
_agent_host = os.environ.get('RPA_WINDOWS_AGENT_HOST', '192.168.1.11')
|
||||
_agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
|
||||
_agent_url = f'http://{_agent_host}:{_agent_port}/file-action'
|
||||
_api_token = os.environ.get('RPA_API_TOKEN', '')
|
||||
_file_headers = {'Authorization': f'Bearer {_api_token}'} if _api_token else {}
|
||||
|
||||
file_results = []
|
||||
for fa in file_actions:
|
||||
try:
|
||||
fa_resp = req.post(
|
||||
'http://192.168.1.11:5006/file-action',
|
||||
_agent_url,
|
||||
json={
|
||||
'action': fa['type'],
|
||||
'params': fa.get('parameters', {}),
|
||||
},
|
||||
headers=_file_headers,
|
||||
timeout=30,
|
||||
)
|
||||
if fa_resp.status_code == 401:
|
||||
file_results.append({
|
||||
'error': "Agent Windows : auth refusee (verifier RPA_API_TOKEN)",
|
||||
})
|
||||
else:
|
||||
file_results.append(fa_resp.json())
|
||||
except req.ConnectionError:
|
||||
file_results.append({
|
||||
'error': "Agent Windows (port 5006) non disponible pour l'action fichier"
|
||||
'error': f"Agent Windows ({_agent_host}:{_agent_port}) non disponible pour l'action fichier"
|
||||
})
|
||||
except Exception as e:
|
||||
file_results.append({'error': str(e)})
|
||||
|
||||
@@ -227,12 +227,10 @@ class VisualWorkflowExecutor:
|
||||
self.analytics_integration.on_execution_complete(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_graph.workflow_id,
|
||||
started_at=result.start_time,
|
||||
completed_at=result.end_time,
|
||||
duration=result._calculate_duration() / 1000.0, # en secondes
|
||||
status='success',
|
||||
duration_ms=float(result._calculate_duration() or 0.0),
|
||||
status='completed',
|
||||
steps_completed=len(workflow_graph.nodes),
|
||||
steps_failed=0
|
||||
steps_failed=0,
|
||||
)
|
||||
|
||||
# Collecter les métriques Analytics pour l'UI
|
||||
@@ -265,13 +263,11 @@ class VisualWorkflowExecutor:
|
||||
self.analytics_integration.on_execution_complete(
|
||||
execution_id=execution_id,
|
||||
workflow_id=visual_workflow.workflow_id,
|
||||
started_at=result.start_time,
|
||||
completed_at=result.end_time,
|
||||
duration=result._calculate_duration() / 1000.0 if result._calculate_duration() else 0,
|
||||
duration_ms=float(result._calculate_duration() or 0.0),
|
||||
status='failed',
|
||||
error_message=str(e),
|
||||
steps_completed=0,
|
||||
steps_failed=1
|
||||
steps_failed=1,
|
||||
)
|
||||
|
||||
# Enregistrer l'échec dans le système d'apprentissage
|
||||
@@ -312,7 +308,8 @@ class VisualWorkflowExecutor:
|
||||
if result.success:
|
||||
self._log(execution_id, 'info', f'Workflow exécuté avec succès')
|
||||
|
||||
# Notifier Analytics pour chaque étape
|
||||
# Notifier Analytics pour chaque étape (contrat normalisé Lot A :
|
||||
# duration_ms en millisecondes, plus de "duration" en secondes)
|
||||
for i, step_result in enumerate(result.step_results):
|
||||
if self.analytics_integration:
|
||||
self.analytics_integration.on_step_complete(
|
||||
@@ -322,8 +319,8 @@ class VisualWorkflowExecutor:
|
||||
action_type=step_result.action_type,
|
||||
started_at=step_result.start_time,
|
||||
completed_at=step_result.end_time,
|
||||
duration=step_result.duration_seconds,
|
||||
success=step_result.success
|
||||
duration_ms=float(step_result.duration_seconds or 0.0) * 1000.0,
|
||||
success=step_result.success,
|
||||
)
|
||||
|
||||
# Notifier la progression
|
||||
@@ -383,8 +380,8 @@ class VisualWorkflowExecutor:
|
||||
action_type=getattr(node, 'action_type', 'unknown'),
|
||||
started_at=step_start_time,
|
||||
completed_at=step_end_time,
|
||||
duration=step_duration,
|
||||
success=True
|
||||
duration_ms=float(step_duration or 0.0) * 1000.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
progress = (i + 1) / total_nodes * 100
|
||||
|
||||
@@ -18,22 +18,29 @@ for path in env_paths:
|
||||
break
|
||||
|
||||
class VLMProvider:
|
||||
"""Hub de Vision Sémantique Multi-Fournisseurs (OpenAI, Gemini, Anthropic, Ollama)"""
|
||||
"""Hub de Vision Sémantique — Ollama local prioritaire, cloud opt-in.
|
||||
|
||||
Par défaut, seul Ollama local est utilisé (100% local, pas de cloud).
|
||||
Pour activer les APIs cloud en fallback, définir VLM_ALLOW_CLOUD=true
|
||||
dans l'environnement.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Clés API
|
||||
self.openai_key = os.getenv("OPENAI_API_KEY")
|
||||
self.gemini_key = os.getenv("GOOGLE_API_KEY")
|
||||
self.anthropic_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
self.deepseek_key = os.getenv("DEEPSEEK_API_KEY")
|
||||
# Cloud opt-in uniquement (VLM_ALLOW_CLOUD=true pour activer)
|
||||
self.allow_cloud = os.getenv("VLM_ALLOW_CLOUD", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# Configuration Ollama Local
|
||||
# Clés API (chargées mais pas utilisées sauf si cloud autorisé)
|
||||
self.openai_key = os.getenv("OPENAI_API_KEY") if self.allow_cloud else None
|
||||
self.gemini_key = os.getenv("GOOGLE_API_KEY") if self.allow_cloud else None
|
||||
self.anthropic_key = os.getenv("ANTHROPIC_API_KEY") if self.allow_cloud else None
|
||||
self.deepseek_key = os.getenv("DEEPSEEK_API_KEY") if self.allow_cloud else None
|
||||
|
||||
# Configuration Ollama Local (toujours prioritaire)
|
||||
self.ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
self.local_model = os.getenv("VLM_MODEL", "qwen3-vl:8b")
|
||||
self.local_model = os.getenv("RPA_VLM_MODEL", os.getenv("VLM_MODEL", "gemma4:latest"))
|
||||
|
||||
# Priorité par défaut
|
||||
self.preferred_cloud = "openai" # gpt-4o est la référence UI
|
||||
print(f"🔧 [VLM Hub] Initialisé. OpenAI: {bool(self.openai_key)}, Gemini: {bool(self.gemini_key)}, Anthropic: {bool(self.anthropic_key)}")
|
||||
cloud_status = f"OpenAI: {bool(self.openai_key)}, Gemini: {bool(self.gemini_key)}, Anthropic: {bool(self.anthropic_key)}" if self.allow_cloud else "désactivé (VLM_ALLOW_CLOUD non défini)"
|
||||
print(f"[VLM Hub] Ollama local: {self.ollama_url} ({self.local_model}), Cloud: {cloud_status}")
|
||||
|
||||
def _to_base64(self, image_input) -> str:
|
||||
"""Convertit n'importe quel input image en base64 pur"""
|
||||
@@ -51,25 +58,28 @@ class VLMProvider:
|
||||
return base64.b64encode(image_input).decode("utf-8")
|
||||
|
||||
def detect_ui_element(self, screenshot, anchor_image=None, description: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Tente de localiser l'élément en essayant les fournisseurs par ordre de qualité"""
|
||||
"""Localise l'élément — Ollama local en priorité, cloud en fallback opt-in."""
|
||||
|
||||
# 1. Tenter OpenAI (Référence Vision UI)
|
||||
# 1. Ollama local (toujours prioritaire — 100% local)
|
||||
res = self._call_ollama_local(screenshot, anchor_image, description)
|
||||
if res and res.get('found'):
|
||||
return res
|
||||
|
||||
# 2-4. Fallback cloud (uniquement si VLM_ALLOW_CLOUD=true)
|
||||
if self.allow_cloud:
|
||||
if self.openai_key:
|
||||
res = self._call_openai(screenshot, anchor_image, description)
|
||||
if res and res.get('found'): return res
|
||||
|
||||
# 2. Tenter Gemini (Excellent backup Vision)
|
||||
if self.gemini_key:
|
||||
res = self._call_gemini(screenshot, anchor_image, description)
|
||||
if res and res.get('found'): return res
|
||||
|
||||
# 3. Tenter Anthropic (Précision logique)
|
||||
if self.anthropic_key:
|
||||
res = self._call_anthropic(screenshot, anchor_image, description)
|
||||
if res and res.get('found'): return res
|
||||
|
||||
# 4. Fallback Local (Ollama) - Crucial pour le DGX Spark
|
||||
return self._call_ollama_local(screenshot, anchor_image, description)
|
||||
return res # Retourner le dernier résultat (Ollama ou cloud)
|
||||
|
||||
def _call_openai(self, screenshot, anchor_image, description):
|
||||
try:
|
||||
@@ -137,28 +147,36 @@ class VLMProvider:
|
||||
return None
|
||||
|
||||
def _call_ollama_local(self, screenshot, anchor_image, description):
|
||||
"""Appel à Ollama local (Mode DGX Spark / Offline)"""
|
||||
"""Appel a Ollama local (prioritaire — 100% local)"""
|
||||
try:
|
||||
import requests
|
||||
print(f"🏠 [Hub] Fallback Local Ollama ({self.local_model})...")
|
||||
prompt = f"Localise l'élément '{description}'. Retourne JSON: {{'found': bool, 'bbox': [ymin, xmin, ymax, xmax] (0-1000)}}"
|
||||
print(f"[Hub] Ollama local ({self.local_model})...")
|
||||
prompt = f"Localise l'element '{description}'. Retourne JSON: {{'found': bool, 'bbox': [ymin, xmin, ymax, xmax] (0-1000)}}"
|
||||
|
||||
images = [self._to_base64(screenshot)]
|
||||
if anchor_image:
|
||||
images.append(self._to_base64(anchor_image))
|
||||
|
||||
messages = [{"role": "user", "content": prompt, "images": images}]
|
||||
|
||||
payload = {
|
||||
"model": self.local_model,
|
||||
"prompt": prompt,
|
||||
"images": [self._to_base64(screenshot)],
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"format": "json"
|
||||
}
|
||||
if anchor_image:
|
||||
payload["images"].append(self._to_base64(anchor_image))
|
||||
|
||||
response = requests.post(f"{self.ollama_url}/api/generate", json=payload, timeout=60)
|
||||
# gemma4 necessite think=false (sinon tokens vides sur Ollama >=0.20)
|
||||
if "gemma4" in self.local_model.lower():
|
||||
payload["think"] = False
|
||||
|
||||
response = requests.post(f"{self.ollama_url}/api/chat", json=payload, timeout=60)
|
||||
if response.status_code == 200:
|
||||
return json.loads(response.json().get('response', '{}'))
|
||||
content = response.json().get("message", {}).get("content", "{}")
|
||||
return json.loads(content)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ [Hub] Local Ollama Error: {e}")
|
||||
print(f"[Hub] Ollama local erreur: {e}")
|
||||
return {"found": False, "error": str(e)}
|
||||
|
||||
# Instance unique
|
||||
|
||||
@@ -7,8 +7,16 @@ Fonctionnalités:
|
||||
- Gestion des workflows
|
||||
- Visualisation des sessions et screenshots
|
||||
- Graphiques de performance
|
||||
|
||||
Sécurité (Fix P0-A) :
|
||||
- HTTP Basic Auth sur tous les endpoints (middleware before_request).
|
||||
- Credentials via DASHBOARD_USER / DASHBOARD_PASSWORD.
|
||||
- Exceptions : /health, /healthz, /api/health (monitoring externe).
|
||||
- Désactivation auth en dev local : DASHBOARD_AUTH_DISABLED=true
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@@ -41,9 +49,125 @@ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fix P0-A : HTTP Basic Auth sur le dashboard (port 5001)
|
||||
# =============================================================================
|
||||
# Avant ce fix, 71 endpoints étaient exposés sans authentification.
|
||||
# On ajoute un middleware Flask (before_request) qui exige un header
|
||||
# Authorization: Basic <b64>. Les credentials sont pris dans l'environnement.
|
||||
#
|
||||
# Chemins publics (pas de challenge) : healthcheck uniquement — ils servent au
|
||||
# monitoring externe (Prometheus, systemd, k8s, NPM reverse proxy).
|
||||
#
|
||||
# Pour désactiver l'auth (dev local, tests) : DASHBOARD_AUTH_DISABLED=true.
|
||||
# Les tests unitaires définissent cette variable via un flag Flask config.
|
||||
|
||||
_DASHBOARD_USER = os.getenv("DASHBOARD_USER", "lea").strip()
|
||||
_DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip()
|
||||
_DASHBOARD_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
|
||||
# Si pas de password défini en env ET auth pas explicitement désactivée →
|
||||
# on utilise un mot de passe par défaut "safe" (long, random-ish) ET on log
|
||||
# un WARNING très visible au démarrage pour forcer Dom à le configurer
|
||||
# avant un déploiement prod. On ne veut surtout pas générer un mot de passe
|
||||
# aléatoire à chaque boot (même problème que l'API token auto-généré).
|
||||
if not _DASHBOARD_PASSWORD and not _DASHBOARD_AUTH_DISABLED:
|
||||
_DASHBOARD_PASSWORD = "changeme-dashboard-Medecin2026!"
|
||||
api_logger.warning(
|
||||
"[SÉCURITÉ] DASHBOARD_PASSWORD non défini en env — utilisation d'un "
|
||||
"mot de passe par défaut temporaire. DÉFINIR DASHBOARD_PASSWORD "
|
||||
"AVANT TOUT DÉPLOIEMENT (identifiant : DASHBOARD_USER)."
|
||||
)
|
||||
|
||||
# Paths publics (pas d'auth, pour healthchecks externes)
|
||||
_PUBLIC_DASHBOARD_PATHS = {
|
||||
"/health",
|
||||
"/healthz",
|
||||
"/api/health",
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_auth_ok(header_value: str) -> bool:
|
||||
"""Valide le header Authorization Basic. Comparaison constant-time."""
|
||||
if not header_value or not header_value.lower().startswith("basic "):
|
||||
return False
|
||||
try:
|
||||
decoded = base64.b64decode(header_value[6:].strip()).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
if ":" not in decoded:
|
||||
return False
|
||||
user, _, password = decoded.partition(":")
|
||||
# Comparaison constant-time pour éviter les timing attacks.
|
||||
user_ok = hmac.compare_digest(user, _DASHBOARD_USER)
|
||||
pwd_ok = hmac.compare_digest(password, _DASHBOARD_PASSWORD)
|
||||
return user_ok and pwd_ok
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _dashboard_basic_auth_middleware():
|
||||
"""Middleware d'auth HTTP Basic sur tous les endpoints HTTP du dashboard.
|
||||
|
||||
- Bypass complet si DASHBOARD_AUTH_DISABLED=true (dev/tests).
|
||||
- Bypass complet si app.config['TESTING'] (pytest) et qu'aucun credential
|
||||
n'est passé : les tests existants du dashboard doivent continuer de
|
||||
passer sans retoucher chaque fixture.
|
||||
- Paths dans _PUBLIC_DASHBOARD_PATHS : toujours publics (healthchecks).
|
||||
- Sinon : header Authorization: Basic <b64> obligatoire.
|
||||
|
||||
Note WebSocket : Flask-SocketIO utilise son propre canal pour le handshake.
|
||||
Le before_request ci-dessus s'applique à la requête HTTP de l'upgrade
|
||||
(compatible mode threading). Les sockets post-handshake ne passent pas par
|
||||
Flask, c'est acceptable pour un MVP (le client doit avoir passé l'auth HTTP).
|
||||
"""
|
||||
# Dev / tests : bypass total
|
||||
if _DASHBOARD_AUTH_DISABLED:
|
||||
return None
|
||||
if app.config.get("TESTING") and not app.config.get("DASHBOARD_AUTH_ENABLED"):
|
||||
return None
|
||||
|
||||
path = request.path or "/"
|
||||
if path in _PUBLIC_DASHBOARD_PATHS:
|
||||
return None
|
||||
|
||||
header_value = request.headers.get("Authorization", "")
|
||||
if _dashboard_auth_ok(header_value):
|
||||
return None
|
||||
|
||||
# Pas authentifié — challenge 401 avec WWW-Authenticate
|
||||
return Response(
|
||||
'{"error": "authentication required"}',
|
||||
status=401,
|
||||
mimetype="application/json",
|
||||
headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 Dashboard"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/healthz')
|
||||
def healthz():
|
||||
"""Healthcheck minimal (systemd/k8s)."""
|
||||
"""Healthcheck minimal (systemd/k8s). Public — pas d'auth."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@app.get('/api/health')
|
||||
def api_health():
|
||||
"""Healthcheck JSON public — pas d'auth (monitoring externe)."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
"""Healthcheck public — pas d'auth (NPM reverse proxy)."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
@@ -532,8 +656,16 @@ def get_session(session_id):
|
||||
|
||||
@app.route('/api/agent/sessions/<session_id>/screenshot/<filename>')
|
||||
def get_screenshot(session_id, filename):
|
||||
"""Récupère un screenshot."""
|
||||
"""Récupère un screenshot.
|
||||
|
||||
Par défaut, si une version floutée (PII) `<stem>_blurred.png` existe à côté
|
||||
du fichier demandé, elle est servie à la place (affichage conforme RGPD).
|
||||
Pour obtenir la version brute, passer `?raw=1` (réservé aux endpoints
|
||||
d'entraînement/grounding, à protéger par auth).
|
||||
"""
|
||||
try:
|
||||
want_raw = request.args.get('raw', '0') in ('1', 'true', 'yes')
|
||||
|
||||
# Chercher le screenshot dans tous les répertoires
|
||||
screenshot_path = None
|
||||
|
||||
@@ -566,6 +698,14 @@ def get_screenshot(session_id, filename):
|
||||
if not screenshot_path:
|
||||
return jsonify({'error': 'Screenshot non trouvé'}), 404
|
||||
|
||||
# Préférer la version floutée si dispo et si l'appelant ne demande pas le brut
|
||||
if not want_raw and "_blurred" not in screenshot_path.stem:
|
||||
blurred_candidate = screenshot_path.with_name(
|
||||
f"{screenshot_path.stem}_blurred{screenshot_path.suffix}"
|
||||
)
|
||||
if blurred_candidate.is_file():
|
||||
screenshot_path = blurred_candidate
|
||||
|
||||
return send_file(screenshot_path, mimetype='image/png')
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -1630,6 +1770,14 @@ SERVICES_CONFIG = {
|
||||
"url": "http://localhost:5005",
|
||||
"icon": "📡"
|
||||
},
|
||||
"session_cleaner": {
|
||||
"name": "Session Cleaner",
|
||||
"description": "Nettoyage de sessions avant replay (dépend du Streaming Server)",
|
||||
"port": 5006,
|
||||
"start_cmd": "cd {base} && {base}/.venv/bin/python3 tools/session_cleaner.py",
|
||||
"url": "http://localhost:5006",
|
||||
"icon": "🧹"
|
||||
},
|
||||
"web_dashboard": {
|
||||
"name": "Dashboard (ce service)",
|
||||
"description": "Panneau de contrôle RPA Vision V3",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<div class="tab" onclick="switchTab('corrections')">🔧 Corrections</div>
|
||||
<div class="tab" onclick="switchTab('learning')">🧠 Apprentissage</div>
|
||||
<div class="tab" onclick="switchTab('config')">🔧 Configuration</div>
|
||||
<div class="tab" onclick="switchTab('cleaner')">🧹 Nettoyage</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -783,6 +784,14 @@
|
||||
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'upload_api')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Session Cleaner (port 5006)</label>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<input type="text" id="cfg_session_cleaner_host" placeholder="localhost" class="config-input" style="flex:2;">
|
||||
<input type="number" id="cfg_session_cleaner_port" placeholder="5006" class="config-input" style="flex:1;">
|
||||
<button class="btn btn-small btn-primary" onclick="testConnection('service', 'session_cleaner')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -834,37 +843,6 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<!-- Section Detection -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">👁️</span> Detection Visuelle</h2>
|
||||
<div id="configDetection" class="config-section">
|
||||
<div class="config-item">
|
||||
<label>Modele OWL</label>
|
||||
<select id="cfg_detection_owl_model" class="config-input">
|
||||
<option value="google/owlv2-base-patch16-ensemble">OWLv2 Base Ensemble</option>
|
||||
<option value="google/owlv2-large-patch14-ensemble">OWLv2 Large Ensemble</option>
|
||||
<option value="google/owlvit-base-patch32">OWLViT Base</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Seuil de confiance</label>
|
||||
<input type="range" id="cfg_detection_confidence" min="0.1" max="0.9" step="0.05" value="0.3" class="config-input" oninput="document.getElementById('confValue').textContent = this.value">
|
||||
<span id="confValue" style="color:#3b82f6;margin-left:10px;">0.3</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Seuil NMS</label>
|
||||
<input type="range" id="cfg_detection_nms" min="0.1" max="0.9" step="0.05" value="0.3" class="config-input" oninput="document.getElementById('nmsValue').textContent = this.value">
|
||||
<span id="nmsValue" style="color:#3b82f6;margin-left:10px;">0.3</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>
|
||||
<input type="checkbox" id="cfg_detection_use_gpu" checked>
|
||||
Utiliser GPU (CUDA)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Base de donnees -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">💾</span> Base de Donnees</h2>
|
||||
@@ -896,9 +874,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<!-- Section Securite -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔒</span> Securite</h2>
|
||||
@@ -963,6 +939,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Nettoyage de sessions (iframe vers session_cleaner port 5006) -->
|
||||
<div id="tab-cleaner" class="tab-content">
|
||||
<div class="card" style="margin-bottom:20px;background:linear-gradient(135deg, #1e293b 0%, #0f172a 100%);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:15px;">
|
||||
<div>
|
||||
<h2 style="margin-bottom:5px;color:#e2e8f0;"><span class="icon">🧹</span> Nettoyage de sessions avant replay</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Visualisez les sessions, supprimez les clics parasites et regénérez un replay propre (Session Cleaner, port 5006)</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<button class="btn btn-primary" onclick="refreshCleanerFrame()">🔄 Recharger</button>
|
||||
<a class="btn btn-secondary" href="http://localhost:5006" target="_blank" rel="noopener">↗ Ouvrir dans un onglet</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message d'état (affiché si le service n'est pas démarré) -->
|
||||
<div id="cleanerOfflineNotice" class="card" style="display:none;margin-bottom:20px;border:1px solid #ef4444;">
|
||||
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
|
||||
<div style="font-size:48px;">⚠️</div>
|
||||
<div style="flex:1;min-width:250px;">
|
||||
<h3 style="color:#ef4444;margin-bottom:8px;">Session Cleaner non démarré</h3>
|
||||
<p style="color:#94a3b8;font-size:13px;">Le service sur le port 5006 ne répond pas. Démarrez-le pour accéder à l'interface de nettoyage.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<button class="btn btn-success" onclick="startCleanerService()" id="btnStartCleaner">▶️ Démarrer le cleaner</button>
|
||||
<button class="btn btn-secondary" onclick="switchTab('services')">🎛️ Gérer les services</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iframe vers le cleaner -->
|
||||
<div id="cleanerFrameContainer" class="card" style="padding:0;overflow:hidden;">
|
||||
<iframe
|
||||
id="cleanerFrame"
|
||||
src="about:blank"
|
||||
style="width:100%;height:85vh;min-height:800px;border:0;border-radius:12px;background:#0f172a;"
|
||||
title="Session Cleaner"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -1199,6 +1215,73 @@
|
||||
if (tabName === 'corrections') refreshCorrectionPacks();
|
||||
if (tabName === 'learning') refreshLearningStats();
|
||||
if (tabName === 'config') refreshConfig();
|
||||
if (tabName === 'cleaner') checkCleanerStatus();
|
||||
}
|
||||
|
||||
// === Session Cleaner (iframe vers port 5006) ===
|
||||
const CLEANER_URL = 'http://localhost:5006';
|
||||
|
||||
async function checkCleanerStatus() {
|
||||
const notice = document.getElementById('cleanerOfflineNotice');
|
||||
const frameContainer = document.getElementById('cleanerFrameContainer');
|
||||
const frame = document.getElementById('cleanerFrame');
|
||||
if (!notice || !frameContainer || !frame) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/services/session_cleaner/status');
|
||||
const data = await res.json();
|
||||
const running = data && data.status === 'running';
|
||||
|
||||
if (running) {
|
||||
notice.style.display = 'none';
|
||||
frameContainer.style.display = 'block';
|
||||
// Charger l'iframe seulement si ce n'est pas déjà fait
|
||||
if (frame.src === 'about:blank' || !frame.src.startsWith(CLEANER_URL)) {
|
||||
frame.src = CLEANER_URL;
|
||||
}
|
||||
} else {
|
||||
notice.style.display = 'block';
|
||||
frameContainer.style.display = 'none';
|
||||
frame.src = 'about:blank';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('checkCleanerStatus error:', err);
|
||||
notice.style.display = 'block';
|
||||
frameContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCleanerFrame() {
|
||||
const frame = document.getElementById('cleanerFrame');
|
||||
if (!frame) return;
|
||||
// Forcer un rechargement (cache busting)
|
||||
frame.src = CLEANER_URL + '?t=' + Date.now();
|
||||
}
|
||||
|
||||
async function startCleanerService() {
|
||||
const btn = document.getElementById('btnStartCleaner');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Démarrage...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/services/session_cleaner/start', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert('Erreur : ' + (data.error || 'démarrage impossible'));
|
||||
} else {
|
||||
// Laisser le temps au service de démarrer
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Erreur réseau : ' + err.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '▶️ Démarrer le cleaner';
|
||||
}
|
||||
await checkCleanerStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Update execution UI
|
||||
@@ -2852,15 +2935,7 @@
|
||||
refreshOllamaModels();
|
||||
}
|
||||
|
||||
// Detection
|
||||
if (config.detection) {
|
||||
document.getElementById('cfg_detection_owl_model').value = config.detection.owl_model || 'google/owlv2-base-patch16-ensemble';
|
||||
document.getElementById('cfg_detection_confidence').value = config.detection.confidence_threshold || 0.3;
|
||||
document.getElementById('confValue').textContent = config.detection.confidence_threshold || 0.3;
|
||||
document.getElementById('cfg_detection_nms').value = config.detection.nms_threshold || 0.3;
|
||||
document.getElementById('nmsValue').textContent = config.detection.nms_threshold || 0.3;
|
||||
document.getElementById('cfg_detection_use_gpu').checked = config.detection.use_gpu !== false;
|
||||
}
|
||||
// Detection (OWL-v2 legacy) — section UI retiree, config preservee telle quelle
|
||||
|
||||
// Database
|
||||
if (config.database) {
|
||||
@@ -2936,12 +3011,14 @@
|
||||
model: document.getElementById('cfg_vlm_model').value,
|
||||
description: 'Modele VLM pour l\'analyse visuelle'
|
||||
},
|
||||
detection: {
|
||||
owl_model: document.getElementById('cfg_detection_owl_model').value,
|
||||
confidence_threshold: parseFloat(document.getElementById('cfg_detection_confidence').value),
|
||||
nms_threshold: parseFloat(document.getElementById('cfg_detection_nms').value),
|
||||
use_gpu: document.getElementById('cfg_detection_use_gpu').checked,
|
||||
description: 'Configuration du detecteur visuel OWL-v2'
|
||||
// Detection: section UI retiree (OWL-v2 remplace par pipeline VLM).
|
||||
// On preserve la config existante pour le fallback eventuel.
|
||||
detection: currentConfig.detection || {
|
||||
owl_model: 'google/owlv2-base-patch16-ensemble',
|
||||
confidence_threshold: 0.3,
|
||||
nms_threshold: 0.3,
|
||||
use_gpu: true,
|
||||
description: 'Configuration legacy du detecteur visuel OWL-v2 (fallback)'
|
||||
},
|
||||
embedding: currentConfig.embedding || {
|
||||
model: 'clip',
|
||||
|
||||
Reference in New Issue
Block a user