Compare commits
186 Commits
203dc00d53
...
backup/pos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea4960e65 | ||
|
|
f2212e77e3 | ||
|
|
9872f4510c | ||
|
|
2eeaa806bb | ||
|
|
df5ad59330 | ||
|
|
bfbf0f9c3e | ||
|
|
ecc5a233a7 | ||
|
|
293e54b4e6 | ||
|
|
0d7bcd18ac | ||
|
|
4df1ba5779 | ||
|
|
e9702b4df9 | ||
|
|
e0b47e4518 | ||
|
|
5dc20cc85b | ||
|
|
88ed103de5 | ||
|
|
194853cebb | ||
|
|
626823d327 | ||
|
|
2e76b44ff3 | ||
|
|
731b5bcae2 | ||
|
|
8648e375fe | ||
|
|
56e869c467 | ||
|
|
f8dc3c3af4 | ||
|
|
ca81850a20 | ||
|
|
35fd6cf4c5 | ||
|
|
7847a0e829 | ||
|
|
40440f1ca0 | ||
|
|
7233df2bb9 | ||
|
|
f62fda575f | ||
|
|
22c0a2ba61 | ||
|
|
6fdedbfe9d | ||
|
|
c969f93a23 | ||
|
|
1cbec2806e | ||
|
|
864530c851 | ||
|
|
d1ebf62217 | ||
|
|
87dbe8c5ff | ||
|
|
0a02a6ec9c | ||
|
|
83be93e121 | ||
|
|
f5c33477f0 | ||
|
|
b1a3aa16f1 | ||
|
|
0bcfddbbc4 | ||
|
|
aa47172f0f | ||
|
|
65da557310 | ||
|
|
af13cd80ff | ||
|
|
7c6945171e | ||
|
|
ca0b436a61 | ||
|
|
fc01afa59c | ||
|
|
2a51a844b9 | ||
|
|
2d71e2a249 | ||
|
|
fae95c5366 | ||
|
|
6582a69d31 | ||
|
|
5543e25f9d | ||
|
|
2a07d8084b | ||
|
|
35b27ae492 | ||
|
|
b584bbabc3 | ||
|
|
8817f527e7 | ||
|
|
964856ab30 | ||
|
|
a67d896104 | ||
|
|
90c1d8036f | ||
|
|
6261002039 | ||
|
|
0e6e61f2b1 | ||
|
|
41c1250c99 | ||
|
|
2af3bc3b93 | ||
|
|
6154423a91 | ||
|
|
41eba898c0 | ||
|
|
9452e86fd1 | ||
|
|
5e31cdf666 | ||
|
|
487bcb8618 | ||
|
|
3d6868f029 | ||
|
|
f73a2a59a9 | ||
|
|
77faa03ec9 | ||
|
|
343d6fbe95 | ||
|
|
cc64439738 | ||
|
|
90007cc7c1 | ||
|
|
73cea2385e | ||
|
|
e2046837cf | ||
|
|
b30d4b6656 | ||
|
|
e4a48e78bf | ||
|
|
ea36bba5cc | ||
|
|
9da589c8c2 | ||
|
|
16ff396dbf | ||
|
|
e44fd7b328 | ||
|
|
66815b7a1a | ||
|
|
c6b695eca8 | ||
|
|
99d2083dea | ||
|
|
a718086140 | ||
|
|
c82979e72b | ||
|
|
2185c41cc1 | ||
|
|
26804eb123 | ||
|
|
d71d5df4a8 | ||
|
|
6829ad8e79 | ||
|
|
8903f35433 | ||
|
|
4ab2c15e5c | ||
|
|
eba6fea779 | ||
|
|
f04398d5a7 | ||
|
|
4ce9c47f45 | ||
|
|
9dfcdb5fb0 | ||
|
|
3efe15d2c7 | ||
|
|
9d87ed64c5 | ||
|
|
00134963e5 | ||
|
|
0ec5e2a25b | ||
|
|
0c5fffe951 | ||
|
|
5027ed9a23 | ||
|
|
6caab2c600 | ||
|
|
552e66dbf6 | ||
|
|
de1026ee2e | ||
|
|
7b50725bf8 | ||
|
|
7feef3b6a9 | ||
|
|
0b06db222d | ||
|
|
74ee0dadee | ||
|
|
0b452f975a | ||
|
|
6ab385d671 | ||
|
|
b3eab83a0f | ||
|
|
27490849a8 | ||
|
|
cebbf0809a | ||
|
|
3e227d28ad | ||
|
|
8ce63fcba2 | ||
|
|
4202431421 | ||
|
|
4923623dd4 | ||
|
|
84181cc982 | ||
|
|
7355d315a3 | ||
|
|
c50adab3a1 | ||
|
|
2fbb305f65 | ||
|
|
ff581be397 | ||
|
|
203e5cc6c1 | ||
|
|
d1b556b6cd | ||
|
|
729cd67743 | ||
|
|
73ddcdb29d | ||
|
|
14a9442343 | ||
|
|
5da4581e76 | ||
|
|
cbe8dc95d2 | ||
|
|
04a14a56b2 | ||
|
|
2290f1846b | ||
|
|
c57b40ae1d | ||
|
|
bc21b27da7 | ||
|
|
6a2248ddcd | ||
|
|
82d7b38cff | ||
|
|
6c7f88c05d | ||
|
|
447fbb2c6e | ||
|
|
623be15bfe | ||
|
|
55d5aebbd2 | ||
|
|
73b731fef8 | ||
|
|
ffd97ae9a5 | ||
|
|
d168833609 | ||
|
|
23a06a744c | ||
|
|
af4eae28b9 | ||
|
|
c198c930a1 | ||
|
|
e3efef2fe7 | ||
|
|
95fddeebb3 | ||
|
|
71523cebd3 | ||
|
|
3aa806a630 | ||
|
|
588c8f22c1 | ||
|
|
3d243d731d | ||
|
|
2431a6c9e9 | ||
|
|
969236da03 | ||
|
|
f30461b88c | ||
|
|
f34eca20f9 | ||
|
|
309dfd5287 | ||
|
|
f5a672d7b9 | ||
|
|
1acea85fa6 | ||
|
|
4f61741420 | ||
|
|
2fa864b5c7 | ||
|
|
10739c33fa | ||
|
|
39bea1b042 | ||
|
|
26b4e6d8ce | ||
|
|
4fb84b1090 | ||
|
|
7f2bc6fe97 | ||
|
|
eded968c70 | ||
|
|
53d29d9b24 | ||
|
|
690053bd57 | ||
|
|
c7b0649716 | ||
|
|
2bfcfa4535 | ||
|
|
b808e48b1f | ||
|
|
78ee962918 | ||
|
|
c8a3618e27 | ||
|
|
9ca277a63f | ||
|
|
8c7b6e5696 | ||
|
|
af4ffa189a | ||
|
|
42f571d496 | ||
|
|
36737cfe9d | ||
|
|
93ef93e563 | ||
|
|
376e4a88b3 | ||
|
|
bb4ed2a75d | ||
|
|
f7b8cddd2b | ||
|
|
a9a99953dd | ||
|
|
aee64f54b1 | ||
|
|
c77844fa9a | ||
|
|
013fe071a2 |
12
.env.example
12
.env.example
@@ -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
|
||||
@@ -44,6 +46,14 @@ LOGS_PATH=logs
|
||||
UPLOADS_PATH=data/training/uploads
|
||||
SESSIONS_PATH=data/training/sessions
|
||||
|
||||
# ============================================================================
|
||||
# Feedback Bus (Léa parle pendant exécution)
|
||||
# ============================================================================
|
||||
# Bus SocketIO unifié 'lea:*' (action_started, action_done, need_confirm, paused).
|
||||
# Désactivé par défaut. Mettre à 1 pour activer les bulles temps réel dans ChatWindow.
|
||||
# Si la connexion bus échoue, l'exécution continue normalement (fail-safe).
|
||||
LEA_FEEDBACK_BUS=0
|
||||
|
||||
# ============================================================================
|
||||
# FAISS
|
||||
# ============================================================================
|
||||
|
||||
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
|
||||
214
.gitea/workflows/tests.yml
Normal file
214
.gitea/workflows/tests.yml
Normal file
@@ -0,0 +1,214 @@
|
||||
# ------------------------------------------------------------------
|
||||
# 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"
|
||||
# api_stream.py a un fail-closed P0-C : si RPA_API_TOKEN absent, sys.exit(1)
|
||||
# au module load. On fournit un token bidon pour que les imports passent en CI.
|
||||
# (Le token n'est jamais utilisé réellement — les tests mockent les requêtes.)
|
||||
RPA_API_TOKEN: "ci_test_token_not_used_for_real_auth_just_to_pass_import_check_0123456789"
|
||||
|
||||
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 : erreurs critiques uniquement (E9 syntax, F63 invalid print,
|
||||
# F7 syntax, F82 undefined in __all__).
|
||||
# F821 (undefined name) volontairement exclu le temps de nettoyer
|
||||
# la dette technique préexistante (voir docs/STATUS.md).
|
||||
# Dossiers legacy exclus :
|
||||
# - agent_v0/deploy/windows_client/ : clone obsolète (marqué OBSOLÈTE)
|
||||
# - tests/property/ : tests cassés connus (cf. MEMORY.md)
|
||||
ruff check --select=E9,F63,F7,F82 --output-format=github \
|
||||
--exclude "agent_v0/deploy/windows_client" \
|
||||
--exclude "tests/property" \
|
||||
--exclude "tests/integration/test_visual_rpa_checkpoint.py" \
|
||||
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.
|
||||
# Dossiers legacy exclus (cohérent avec ruff).
|
||||
black --check --diff \
|
||||
--exclude "agent_v0/deploy/windows_client|tests/property" \
|
||||
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
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -83,3 +83,36 @@ 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/
|
||||
**/capture_library.json
|
||||
.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
|
||||
|
||||
# Verbatims clients (sensibles, à valider avant push)
|
||||
docs/clients/
|
||||
|
||||
.qw-baseline.log
|
||||
|
||||
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "cccc2566",
|
||||
"configHash": "0c083961",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "764a8433",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# CLAUDE.md — rpa_vision_v3
|
||||
|
||||
Ce fichier prime sur le CLAUDE.md racine (`~/ai/CLAUDE.md`) pour tout travail dans ce projet.
|
||||
|
||||
## Rôle de Claude Code sur ce projet
|
||||
|
||||
Exécutant supervisé, pas architecte. Mission : garantir la **cohérence** de chaque modification avec la vision globale du projet et le **contrat "100% vision"** (résolution UI par la vue, pas par les sélecteurs DOM/API). Quand tu touches un fichier, vérifie que tu ne casses rien ailleurs.
|
||||
|
||||
Tu n'es pas en autonomie. Dom valide avant chaque étape. Tu proposes, il décide.
|
||||
|
||||
## Priorité absolue
|
||||
|
||||
**La démo Urgence_aiva_demo doit fonctionner.** Workflow 22+ steps sur Easily Assure, patiente MOREL Catherine, audience mixte DG/DSI/médecins/DIM/TIM. Tout arbitrage technique se tranche par : "est-ce que ça rapproche ou éloigne de la démo qui tourne ?"
|
||||
|
||||
## Méthode obligatoire — non négociable
|
||||
|
||||
- **Chirurgie itérative supervisée** : une modification, un test (≤ 2 min), validation explicite de Dom avant la suivante.
|
||||
- **Pas de batch** : jamais plusieurs changements groupés sans validation intermédiaire.
|
||||
- **Rustine interdite** : tu corriges la cause, pas le symptôme. Si tu ne comprends pas la cause, tu le dis et tu arrêtes.
|
||||
- **Lire la doc avant d'agir** : code existant, `docs/`, specs. Pas de proposition basée sur des suppositions.
|
||||
- **Un commit = une intention** : message explicite, daté.
|
||||
- **Diff review systématique** sur tout code de production avant commit.
|
||||
|
||||
## Anti-patterns à proscrire
|
||||
|
||||
- Réponses longues. Si Dom dit "trop long" ou "déjà vu", tu raccourcis sans débattre.
|
||||
- Propositions structurelles avant d'avoir compris l'intention de Dom.
|
||||
- Re-proposer ce qui est déjà en place dans le code.
|
||||
- Raisonner sur un composant trouvé via grep **sans vérifier qu'il est effectivement appelé au runtime**. Le projet contient beaucoup de code écrit mais non wired.
|
||||
- Présenter la première solution qui marche. Toujours explorer 2-3 approches, présenter la meilleure avec justification.
|
||||
|
||||
## Architecture runtime réelle (à valider/raffiner avec Dom)
|
||||
|
||||
```
|
||||
[VWB frontend React :3002]
|
||||
↓ (HTTP)
|
||||
[VWB backend Flask + SQLite]
|
||||
↓ (envoi step par step)
|
||||
[agent_v1 — Linux]
|
||||
↓ (SSH vers Windows)
|
||||
[Léa — chatbot exécutant — PC Windows]
|
||||
↓
|
||||
[Easily Assure — interface cible]
|
||||
```
|
||||
|
||||
**Ollama** : sert le ou les modèles utilisés pour la résolution VLM, l'extraction texte, et la décision t2a. Sert aussi de **proxy vers cloud** pour certains appels.
|
||||
|
||||
**Cascade de résolution UI** (à confirmer composant par composant au runtime) :
|
||||
1. OCR (docTR ou EasyOCR selon module)
|
||||
2. cv2 template matching
|
||||
3. YOLO v4 grounding
|
||||
4. VLM grounding
|
||||
|
||||
**UI-DETR-1** : utilisé par VWB **au recording** pour overlays numérotés (équivalent OmniParser). `crop_hash` volontairement non persisté.
|
||||
|
||||
**Asymétrie connue, sujet ouvert post-démo** : VWB direct utilise UI-DETR-1 au runtime, le replay sur Léa ne l'utilise pas (cascade OCR/template/VLM seulement). Ne pas tenter de "fixer" cette asymétrie maintenant.
|
||||
|
||||
## ⚠️ Champs de mines — code orphelin
|
||||
|
||||
`core/` contient ~40 sous-modules. **Beaucoup ne sont pas wired au runtime actif.** Avant de raisonner sur un composant trouvé dans `core/` (coaching, healing, federation, learning, cognition, etc.) :
|
||||
|
||||
1. Vérifier qu'il est importé par un point d'entrée actif.
|
||||
2. Vérifier qu'il est effectivement appelé en runtime (traces, logs).
|
||||
3. Si doute, demander à Dom.
|
||||
|
||||
**Cas spécifique agent_v1** : suspicion de code orphelin à rebrancher. Si tu trouves un appel codé mais non exécuté en runtime (ex. appel Ollama de commentaire d'action présent dans le code mais jamais déclenché), c'est prioritaire à signaler.
|
||||
|
||||
## Debug — où regarder en premier
|
||||
|
||||
- `logs/` (racine projet) — logs runtime généraux
|
||||
- `logs/audit/` — traces d'exécution
|
||||
- `logs/healing/` — si concerne le healing
|
||||
- `data/runner_captures/` — captures d'exécution
|
||||
- `visual_workflow_builder/logs/` — logs VWB
|
||||
- `server/logs/` — logs serveur
|
||||
|
||||
**Vérifier qu'un appel Ollama se déclenche vraiment au runtime** : ne pas se fier à la présence de l'appel dans le code. Tracer effectivement (log d'entrée de fonction, requête vue côté Ollama `:11434`).
|
||||
|
||||
## Inspirations externes
|
||||
|
||||
Voir `docs/INSPIRATION_FRAMEWORKS_2026-05-10.md` pour les patterns convergents (OpenAdapt, Skyvern, OmniParser : Policy/Grounding, Safety Gate, Abstraction Ladder, Planner-Actor-Validator). Le projet est techniquement plus mature que sa documentation ne le suggère — s'inspirer des bons patterns sans complexe.
|
||||
|
||||
## Recherche d'information
|
||||
|
||||
Ta connaissance interne est datée. Pour tout sujet technique évoluant vite (modèles VLM, frameworks RPA visuels, librairies de grounding, versions d'outils), **chercher sur internet d'abord**. Privilégier les sources de moins de 6 mois.
|
||||
|
||||
## Stack
|
||||
|
||||
- Python 3.10-3.12, venv `venv_v3/`
|
||||
- Backend VWB : Flask + SQLite
|
||||
- Frontend VWB : React (port 3002), dashboard :5001, API :8000
|
||||
- LLM local : Ollama `:11434`
|
||||
- GUI legacy : PyQt5
|
||||
- Tests : pytest avec marqueurs (unit/integration/slow/smoke)
|
||||
- Langue : français (code, commentaires, logs, GUI)
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
cd ~/ai/rpa_vision_v3 && source venv_v3/bin/activate
|
||||
./run.sh --full # Écosystème complet
|
||||
./run.sh --gui # GUI PyQt5 seule
|
||||
./run.sh --test # Tests complets
|
||||
make test-fast # Tests rapides
|
||||
make check # Validation imports + tests rapides
|
||||
```
|
||||
@@ -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
|
||||
|
||||
339
README.md
339
README.md
@@ -1,207 +1,204 @@
|
||||
# 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)
|
||||
↓
|
||||
ScreenState (Couche 1) - 4 niveaux d'abstraction
|
||||
↓
|
||||
UIElement Detection (Couche 2) - Types + Rôles sémantiques
|
||||
↓
|
||||
State Embedding (Couche 3) - Fusion multi-modale
|
||||
↓
|
||||
Workflow Graph (Couche 4) - Nodes + Edges + Learning States
|
||||
RawSession (couche 0) — capture événements + screenshots
|
||||
↓
|
||||
ScreenState (couche 1) — états d'écran à plusieurs niveaux d'abstraction
|
||||
↓
|
||||
UIElement (couche 2) — détection sémantique (cascade OCR + templates + VLM)
|
||||
↓
|
||||
State Embedding (couche 3) — fusion multi-modale + index FAISS
|
||||
↓
|
||||
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/EXECUTION_LOOP_FLAGS.md`](docs/EXECUTION_LOOP_FLAGS.md) — flags C1 vision-aware (`enable_ui_detection`, `enable_ocr`, `analyze_timeout_ms`, `window_info_provider`)
|
||||
- [`docs/VISION_RPA_INTELLIGENT.md`](docs/VISION_RPA_INTELLIGENT.md) — cahier des charges
|
||||
- [`docs/PLAN_ACTEUR_V1.md`](docs/PLAN_ACTEUR_V1.md) — architecture 3 niveaux (Macro / Méso / Micro)
|
||||
- [`docs/CONFORMITE_AI_ACT.md`](docs/CONFORMITE_AI_ACT.md) — journalisation, floutage, rétention
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -125,25 +125,47 @@ 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)
|
||||
|
||||
if not action_info:
|
||||
# Workflow terminé
|
||||
action_status = action_info.get("status")
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -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,19 +442,19 @@ 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)
|
||||
|
||||
|
||||
# Traiter les types spéciaux
|
||||
data_dict['created_at'] = workflow_data.created_at.isoformat()
|
||||
|
||||
|
||||
# Sérialiser les cibles visuelles
|
||||
serialized_targets = {}
|
||||
for signature, target in workflow_data.visual_targets.items():
|
||||
serialized_targets[signature] = await self._serialize_visual_target(target)
|
||||
data_dict['visual_targets'] = serialized_targets
|
||||
|
||||
|
||||
# Sérialiser l'historique de validation
|
||||
serialized_history = {}
|
||||
for signature, history in workflow_data.validation_history.items():
|
||||
@@ -455,15 +462,30 @@ class VisualPersistenceManager:
|
||||
self._serialize_validation_result(result) for result in history
|
||||
]
|
||||
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(
|
||||
workflow_id=data_dict['workflow_id'],
|
||||
@@ -133,6 +133,28 @@ def _streaming_headers() -> dict:
|
||||
headers["Authorization"] = f"Bearer {_STREAMING_API_TOKEN}"
|
||||
return headers
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Feedback Bus — events 'lea:*' temps réel vers ChatWindow
|
||||
# ============================================================
|
||||
LEA_FEEDBACK_BUS = os.environ.get("LEA_FEEDBACK_BUS", "0").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _emit_lea(event: str, payload: Dict[str, Any]) -> None:
|
||||
"""Émet 'lea:{event}' sur le bus SocketIO. No-op silencieux si flag off ou erreur."""
|
||||
if not LEA_FEEDBACK_BUS:
|
||||
return
|
||||
try:
|
||||
socketio.emit(f"lea:{event}", payload)
|
||||
except Exception:
|
||||
logger.debug("_emit_lea silenced", exc_info=True)
|
||||
|
||||
|
||||
def _emit_dual(legacy_event: str, lea_event: str, payload: Dict[str, Any], **kwargs) -> None:
|
||||
"""Émet l'event legacy (compat dashboard) ET l'alias lea:* (ChatWindow tkinter)."""
|
||||
socketio.emit(legacy_event, payload, **kwargs)
|
||||
_emit_lea(lea_event, payload)
|
||||
|
||||
execution_status = {
|
||||
"running": False,
|
||||
"workflow": None,
|
||||
@@ -623,7 +645,7 @@ def api_execute():
|
||||
}
|
||||
|
||||
# Notifier via WebSocket
|
||||
socketio.emit('execution_started', {
|
||||
_emit_dual('execution_started', 'action_started', {
|
||||
"workflow": match.workflow_name,
|
||||
"params": all_params
|
||||
})
|
||||
@@ -1181,28 +1203,28 @@ def _execute_gesture(gesture):
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
socketio.emit('execution_completed', {
|
||||
_emit_dual('execution_completed', 'done', {
|
||||
"workflow": gesture.name,
|
||||
"success": True,
|
||||
"message": f"Geste '{gesture.name}' ({'+'.join(gesture.keys)}) envoyé",
|
||||
})
|
||||
else:
|
||||
error = resp.text[:200]
|
||||
socketio.emit('execution_completed', {
|
||||
_emit_dual('execution_completed', 'done', {
|
||||
"workflow": gesture.name,
|
||||
"success": False,
|
||||
"message": f"Erreur: {error}",
|
||||
})
|
||||
|
||||
except http_requests.ConnectionError:
|
||||
socketio.emit('execution_completed', {
|
||||
_emit_dual('execution_completed', 'done', {
|
||||
"workflow": gesture.name,
|
||||
"success": False,
|
||||
"message": "Serveur de streaming non disponible (port 5005).",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Gesture execution error: {e}")
|
||||
socketio.emit('execution_completed', {
|
||||
_emit_dual('execution_completed', 'done', {
|
||||
"workflow": gesture.name,
|
||||
"success": False,
|
||||
"message": f"Erreur: {str(e)}",
|
||||
@@ -1661,6 +1683,52 @@ def handle_copilot_abort():
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bulle paused_need_help — handlers SocketIO depuis ChatWindow (J3.5)
|
||||
# =============================================================================
|
||||
|
||||
@socketio.on('lea:replay_resume')
|
||||
def handle_lea_replay_resume(data):
|
||||
"""Bouton Continuer : relayer le resume vers le streaming server."""
|
||||
replay_id = (data or {}).get("replay_id")
|
||||
if not replay_id:
|
||||
_emit_lea("resume_acked", {"status": "error", "detail": "replay_id manquant"})
|
||||
return
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}/resume",
|
||||
headers=_streaming_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.info(f"Replay {replay_id} resume relayé OK")
|
||||
_emit_lea("resume_acked", {"replay_id": replay_id, "status": "ok"})
|
||||
else:
|
||||
detail = resp.text[:200]
|
||||
logger.warning(f"Resume échoué (HTTP {resp.status_code}): {detail}")
|
||||
_emit_lea("resume_acked", {
|
||||
"replay_id": replay_id, "status": "error",
|
||||
"http_status": resp.status_code, "detail": detail,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Resume relay error: {e}")
|
||||
_emit_lea("resume_acked", {
|
||||
"replay_id": replay_id, "status": "error", "detail": str(e),
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('lea:replay_abort')
|
||||
def handle_lea_replay_abort(data):
|
||||
"""Bouton Annuler : arrêter le polling local. Le replay côté streaming sera
|
||||
cleaned up naturellement au prochain replay (cf api_stream._replay_states stale)."""
|
||||
global execution_status
|
||||
replay_id = (data or {}).get("replay_id")
|
||||
execution_status["running"] = False
|
||||
execution_status["message"] = "Annulé par l'utilisateur"
|
||||
logger.info(f"Replay {replay_id or '?'} abort par l'utilisateur (paused bubble)")
|
||||
_emit_lea("abort_acked", {"replay_id": replay_id, "status": "ok"})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exécution de workflow
|
||||
# =============================================================================
|
||||
@@ -1730,14 +1798,20 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
|
||||
"""Suivre la progression d'un replay distant via polling."""
|
||||
import time
|
||||
|
||||
max_wait = 120 # 2 minutes max
|
||||
max_wait_running = 120 # 2 min en exécution active
|
||||
max_wait_paused = 600 # 10 min en pause supervisée (humain peut prendre son temps)
|
||||
poll_interval = 2.0
|
||||
elapsed = 0
|
||||
was_paused = False
|
||||
|
||||
while elapsed < max_wait and execution_status.get("running"):
|
||||
while execution_status.get("running"):
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
cap = max_wait_paused if was_paused else max_wait_running
|
||||
if elapsed >= cap:
|
||||
break
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{STREAMING_SERVER_URL}/api/v1/traces/stream/replay/{replay_id}",
|
||||
@@ -1753,7 +1827,26 @@ def _poll_replay_progress(replay_id: str, workflow_name: str, total_actions: int
|
||||
failed = data.get("failed_actions", 0)
|
||||
progress = int(10 + (completed / max(total_actions, 1)) * 80)
|
||||
|
||||
socketio.emit('execution_progress', {
|
||||
if status == "paused_need_help" and not was_paused:
|
||||
_emit_lea("paused", {
|
||||
"workflow": workflow_name,
|
||||
"replay_id": replay_id,
|
||||
"completed": completed,
|
||||
"total": total_actions,
|
||||
"failed_action": data.get("failed_action"),
|
||||
"reason": data.get("error") or "Action incertaine",
|
||||
})
|
||||
was_paused = True
|
||||
elapsed = 0
|
||||
elif was_paused and status != "paused_need_help":
|
||||
_emit_lea("resumed", {
|
||||
"workflow": workflow_name,
|
||||
"replay_id": replay_id,
|
||||
"status_after": status,
|
||||
})
|
||||
was_paused = False
|
||||
|
||||
_emit_dual('execution_progress', 'action_progress', {
|
||||
"progress": progress,
|
||||
"step": f"Action {completed}/{total_actions} exécutée",
|
||||
"current": completed,
|
||||
@@ -1922,7 +2015,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
|
||||
actions = _build_actions_from_workflow(match, params)
|
||||
if not actions:
|
||||
socketio.emit('copilot_complete', {
|
||||
_emit_dual('copilot_complete', 'done', {
|
||||
"workflow": workflow_name,
|
||||
"status": "error",
|
||||
"message": "Aucune action exécutable dans ce workflow.",
|
||||
@@ -1959,7 +2052,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
break
|
||||
|
||||
copilot_state["status"] = "waiting_approval"
|
||||
socketio.emit('copilot_step', {
|
||||
_emit_dual('copilot_step', 'need_confirm', {
|
||||
"workflow": workflow_name,
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
@@ -1982,7 +2075,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
|
||||
if waited >= max_wait:
|
||||
copilot_state["status"] = "aborted"
|
||||
socketio.emit('copilot_complete', {
|
||||
_emit_dual('copilot_complete', 'done', {
|
||||
"workflow": workflow_name,
|
||||
"status": "timeout",
|
||||
"message": f"Timeout : pas de réponse après {max_wait}s.",
|
||||
@@ -1999,7 +2092,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
elif decision == "skipped":
|
||||
copilot_state["skipped"] += 1
|
||||
logger.info(f"Copilot skip étape {idx + 1}/{total}")
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "skipped",
|
||||
@@ -2034,7 +2127,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
|
||||
if action_success:
|
||||
copilot_state["completed"] += 1
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "completed",
|
||||
@@ -2042,7 +2135,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
})
|
||||
else:
|
||||
copilot_state["failed"] += 1
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "failed",
|
||||
@@ -2051,7 +2144,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
else:
|
||||
error = resp.text[:200]
|
||||
copilot_state["failed"] += 1
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "failed",
|
||||
@@ -2060,7 +2153,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
|
||||
except http_requests.ConnectionError:
|
||||
copilot_state["failed"] += 1
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "failed",
|
||||
@@ -2070,7 +2163,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
except Exception as e:
|
||||
copilot_state["failed"] += 1
|
||||
logger.error(f"Copilot action error: {e}")
|
||||
socketio.emit('copilot_step_result', {
|
||||
_emit_dual('copilot_step_result', 'step_result', {
|
||||
"step_index": idx,
|
||||
"total": total,
|
||||
"status": "failed",
|
||||
@@ -2098,7 +2191,7 @@ def execute_workflow_copilot(match, params: Dict[str, Any]):
|
||||
f"Copilot terminé : {completed} réussies, "
|
||||
f"{skipped} passées, {failed} échouées sur {total} étapes."
|
||||
)
|
||||
socketio.emit('copilot_complete', {
|
||||
_emit_dual('copilot_complete', 'done', {
|
||||
"workflow": workflow_name,
|
||||
"status": "completed" if success else "partial",
|
||||
"message": message,
|
||||
@@ -2175,7 +2268,7 @@ def execute_workflow(match, params):
|
||||
execution_status["progress"] = 10
|
||||
execution_status["message"] = f"Envoyé à l'Agent V1 ({target_session})"
|
||||
|
||||
socketio.emit('execution_progress', {
|
||||
_emit_dual('execution_progress', 'action_progress', {
|
||||
"progress": 10,
|
||||
"step": f"Replay envoyé à l'Agent V1 — {total_actions} actions en attente",
|
||||
"current": 0,
|
||||
@@ -2523,7 +2616,7 @@ def update_progress(progress: int, message: str, current: int, total: int):
|
||||
execution_status["progress"] = progress
|
||||
execution_status["message"] = message
|
||||
|
||||
socketio.emit('execution_progress', {
|
||||
_emit_dual('execution_progress', 'action_progress', {
|
||||
"progress": progress,
|
||||
"step": message,
|
||||
"current": current,
|
||||
@@ -2543,13 +2636,83 @@ def finish_execution(workflow_name: str, success: bool, message: str):
|
||||
if command_history:
|
||||
command_history[-1]["status"] = "completed" if success else "failed"
|
||||
|
||||
socketio.emit('execution_completed', {
|
||||
_emit_dual('execution_completed', 'done', {
|
||||
"workflow": workflow_name,
|
||||
"success": success,
|
||||
"message": message
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Orchestration démo GHT Sud 95 — "traite N dossiers"
|
||||
# =============================================================================
|
||||
# Délégué à agent_chat.urgences_orchestrator (gemma3:1b NLP + thread orchestrateur).
|
||||
# Routes :
|
||||
# POST /api/urgences/parse — test parsing intent (debug)
|
||||
# POST /api/urgences/start — démarrer une orchestration
|
||||
# GET /api/urgences/status/<id>— état d'une orchestration
|
||||
# GET /api/urgences/list — toutes les orchestrations en mémoire
|
||||
|
||||
try:
|
||||
from agent_chat.urgences_orchestrator import (
|
||||
parse_lea_command,
|
||||
start_orchestration,
|
||||
get_orchestration,
|
||||
list_orchestrations,
|
||||
)
|
||||
_URGENCES_AVAILABLE = True
|
||||
except Exception as _e_urg:
|
||||
logger.warning("Module urgences_orchestrator indisponible : %s", _e_urg)
|
||||
_URGENCES_AVAILABLE = False
|
||||
|
||||
|
||||
@app.route('/api/urgences/parse', methods=['POST'])
|
||||
def urgences_parse():
|
||||
if not _URGENCES_AVAILABLE:
|
||||
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
|
||||
payload = request.get_json(silent=True) or {}
|
||||
text = (payload.get("text") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "champ 'text' manquant"}), 400
|
||||
intent = parse_lea_command(text)
|
||||
return jsonify(intent)
|
||||
|
||||
|
||||
@app.route('/api/urgences/start', methods=['POST'])
|
||||
def urgences_start():
|
||||
if not _URGENCES_AVAILABLE:
|
||||
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
|
||||
payload = request.get_json(silent=True) or {}
|
||||
text = (payload.get("text") or "").strip()
|
||||
session_id = payload.get("session_id") or ""
|
||||
machine_id = payload.get("machine_id") or None
|
||||
if not text:
|
||||
return jsonify({"error": "champ 'text' manquant"}), 400
|
||||
intent = parse_lea_command(text)
|
||||
if intent.get("action") != "process_patients":
|
||||
return jsonify({"intent": intent, "started": False,
|
||||
"reply": "Je n'ai pas compris la commande. Exemples : 'traite-moi 3 dossiers', 'code les 5 premiers'."})
|
||||
state = start_orchestration(intent, session_id=session_id, machine_id=machine_id)
|
||||
return jsonify({"intent": intent, "started": True, "orchestration": state.to_dict()})
|
||||
|
||||
|
||||
@app.route('/api/urgences/status/<orch_id>')
|
||||
def urgences_status(orch_id):
|
||||
if not _URGENCES_AVAILABLE:
|
||||
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
|
||||
state = get_orchestration(orch_id)
|
||||
if not state:
|
||||
return jsonify({"error": f"orchestration {orch_id} introuvable"}), 404
|
||||
return jsonify(state.to_dict())
|
||||
|
||||
|
||||
@app.route('/api/urgences/list')
|
||||
def urgences_list():
|
||||
if not _URGENCES_AVAILABLE:
|
||||
return jsonify({"error": "module urgences_orchestrator indisponible"}), 503
|
||||
return jsonify({"orchestrations": list_orchestrations()})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
@@ -49,7 +49,10 @@ try:
|
||||
from PIL import Image as PILImage
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception:
|
||||
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
|
||||
# quand X n'est pas accessible — typique d'un service systemd headless côté
|
||||
# serveur. Le serveur n'a pas besoin de pyautogui (utilisé côté client agent).
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
PILImage = None
|
||||
pyautogui = None
|
||||
@@ -147,8 +150,10 @@ class AutonomousPlanner:
|
||||
"""Initialise le client VLM pour analyse intelligente."""
|
||||
if VLM_AVAILABLE and OllamaClient:
|
||||
try:
|
||||
self._vlm_client = OllamaClient(model="qwen2.5vl:7b")
|
||||
logger.info("VLM client initialized (qwen2.5vl:7b)")
|
||||
from core.detection.vlm_config import get_vlm_model
|
||||
_planner_vlm = get_vlm_model()
|
||||
self._vlm_client = OllamaClient(model=_planner_vlm)
|
||||
logger.info("VLM client initialized (%s)", _planner_vlm)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize VLM client: {e}")
|
||||
self._vlm_client = None
|
||||
|
||||
518
agent_chat/urgences_orchestrator.py
Normal file
518
agent_chat/urgences_orchestrator.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""Orchestrateur démo GHT Sud 95 — pilotage du scénario "traite N dossiers".
|
||||
|
||||
Reçoit une commande naturelle de Léa (chat) et orchestre :
|
||||
1. Parsing intent via gemma3:1b (mini-LLM local, ~400 ms)
|
||||
2. Setup Chrome (Win+R → URL maquette → Enter) via /replay/raw
|
||||
3. extract_table sur la liste des patients (regex IPP, limit=N)
|
||||
4. Boucle : pour chaque IPP, lance le workflow "Urgence_unit" via /replay
|
||||
avec `variables={"patient_id": ipp}` pour la résolution `{{patient_id}}`
|
||||
5. Synthèse finale postée dans le chat
|
||||
|
||||
L'orchestration tourne dans un thread daemon. L'état est stocké en mémoire,
|
||||
poll-able via /api/urgences/status/<orch_id>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chargement explicite de .env.local du repo (le service systemd peut ne pas
|
||||
# voir cet env file). Cherche dans le parent de agent_chat/.
|
||||
def _load_env_local() -> None:
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env.local"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
try:
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(k, v)
|
||||
except Exception as e:
|
||||
logger.warning("Erreur chargement .env.local: %s", e)
|
||||
|
||||
|
||||
_load_env_local()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Config
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
STREAM_BASE = os.environ.get("RPA_STREAM_BASE", "http://localhost:5005")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
|
||||
NLP_MODEL = os.environ.get("LEA_NLP_MODEL", "gemma3:1b")
|
||||
RPA_API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
URGENCE_WORKFLOW_ID = os.environ.get("LEA_URGENCE_WORKFLOW_ID", "wf_urgence_unit")
|
||||
# URL LAN locale (sans Basic Auth ni HTTPS) pour éviter le prompt Windows Hello
|
||||
# de Chrome (lecteur d'empreintes digitales) qui bloque le replay automatique.
|
||||
# L'URL publique HTTPS reste disponible (https://urgence.labs.laurinebazin.design)
|
||||
# pour usage humain, mais n'est PAS utilisée par Léa pendant la démo.
|
||||
MAQUETTE_URL = os.environ.get("LEA_MAQUETTE_URL", "http://192.168.1.40:8765/index.html")
|
||||
|
||||
|
||||
|
||||
# Session de replay stable de l'agent V1. L'agent polle /replay/next sur
|
||||
# `agent_<user_id>` indépendamment des sessions d'enregistrement (sess_*).
|
||||
# user_id default côté agent V1 = "demo_user" (cf. agent_v1/main.py:62).
|
||||
AGENT_SESSION_ID = os.environ.get("LEA_AGENT_SESSION_ID", "agent_demo_user")
|
||||
|
||||
# machine_id de l'agent V1 cible. DOIT matcher self.machine_id côté agent V1
|
||||
# (sinon /replay/next ne distribue pas la queue à cette machine — le serveur
|
||||
# isole les machines pour éviter le vol cross-machine d'actions).
|
||||
# Valeur par défaut = hostname du PC Windows de démo GHT.
|
||||
AGENT_MACHINE_ID = os.environ.get("LEA_AGENT_MACHINE_ID", "DESKTOP-58D5CAC_windows")
|
||||
|
||||
# Pattern IPP : 8 chiffres, premier groupe "25" (cohort 2025), reste libre
|
||||
IPP_PATTERN = r"^25\d{6}$"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# NLP : parsing de commande naturelle via gemma3:1b
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
NLP_PROMPT = """Tu es un parseur d'intentions pour Léa, assistant RPA médical.
|
||||
Réponds UNIQUEMENT en JSON valide, sans texte avant/après, selon ce schéma :
|
||||
{"action": "process_patients" | "stop" | "unknown", "count": <int|null>, "order": "first" | "last" | "all" | "specific" | null, "ipp": "<string>" | null}
|
||||
|
||||
Règles :
|
||||
- "traite N dossiers" / "code N dossiers" / "fais les N premiers" → action=process_patients, count=N, order="first"
|
||||
- "traite tous les dossiers" → action=process_patients, count=null, order="all"
|
||||
- "traite le dossier 25003364" → action=process_patients, count=1, order="specific", ipp="25003364"
|
||||
- "stop" / "arrête" / "annule" → action=stop
|
||||
- Question ("comment", "pourquoi") → action=unknown
|
||||
- Si tu ne comprends pas → action=unknown"""
|
||||
|
||||
|
||||
def parse_lea_command(text: str, model: str = NLP_MODEL, timeout: int = 8) -> Dict[str, Any]:
|
||||
"""Parse une commande naturelle en intent structuré via gemma3:1b.
|
||||
|
||||
Fallback regex si Ollama est indisponible — pour ne pas bloquer la démo.
|
||||
Returns : dict {action, count, order, ipp} ou {action: "unknown"}.
|
||||
"""
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": NLP_PROMPT + "\n\nUtilisateur : " + text + "\n\nJSON :",
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.0, "num_predict": 120, "num_ctx": 1024},
|
||||
}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(OLLAMA_URL, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
raw = (body.get("response") or "").strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
intent = json.loads(raw)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
|
||||
logger.warning("parse_lea_command: gemma3:1b indisponible (%s), fallback regex", e)
|
||||
return _parse_fallback_regex(text)
|
||||
|
||||
# Post-processing : gemma3:1b a tendance à remplir tous les champs même
|
||||
# quand non pertinent. On nettoie :
|
||||
# - ipp ne doit être conservé que si présent LITTÉRALEMENT dans le texte source
|
||||
# (sinon le LLM hallucine un IPP plausible)
|
||||
if intent.get("ipp") and str(intent["ipp"]) not in text:
|
||||
intent["ipp"] = None
|
||||
# Si le LLM a forcé order=specific sans vrai IPP, on bascule en first
|
||||
if intent.get("order") == "specific":
|
||||
intent["order"] = "first"
|
||||
# - ipp ne doit être conservé que si order="specific" ET format IPP valide
|
||||
if intent.get("ipp") and intent.get("order") != "specific":
|
||||
intent["ipp"] = None
|
||||
if intent.get("ipp") and not re.match(r"^\d{8,10}$", str(intent["ipp"])):
|
||||
intent["ipp"] = None
|
||||
# - si count est défini ET order="all", l'humain demande "N dossiers" et
|
||||
# non "tous les dossiers" : on bascule en "first" (cohérence sémantique)
|
||||
if intent.get("count") and intent.get("order") == "all":
|
||||
intent["order"] = "first"
|
||||
return intent
|
||||
|
||||
|
||||
def _parse_fallback_regex(text: str) -> Dict[str, Any]:
|
||||
"""Fallback regex robuste si LLM HS — couvre les phrasings classiques."""
|
||||
t = text.lower()
|
||||
if any(w in t for w in ("stop", "arrête", "annule", "annuler")):
|
||||
return {"action": "stop", "count": None, "order": None, "ipp": None}
|
||||
# IPP spécifique : "traite le dossier 25003364"
|
||||
m = re.search(r"\b(25\d{6})\b", text)
|
||||
if m and any(w in t for w in ("traite", "code", "analyse")):
|
||||
return {"action": "process_patients", "count": 1, "order": "specific", "ipp": m.group(1)}
|
||||
if any(w in t for w in ("tous", "toutes")) and any(w in t for w in ("traite", "code")):
|
||||
return {"action": "process_patients", "count": None, "order": "all", "ipp": None}
|
||||
# Quantifié : "traite 3 dossiers"
|
||||
m = re.search(r"(\d+)\s*(?:premiers?\s*)?(?:dossiers?|cas|patients?)", t)
|
||||
if m and any(w in t for w in ("traite", "code", "fais", "analyse")):
|
||||
return {"action": "process_patients", "count": int(m.group(1)), "order": "first", "ipp": None}
|
||||
return {"action": "unknown", "count": None, "order": None, "ipp": None}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Helpers HTTP vers le streaming server (port 5005)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _stream_headers() -> Dict[str, str]:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if RPA_API_TOKEN:
|
||||
h["Authorization"] = f"Bearer {RPA_API_TOKEN}"
|
||||
return h
|
||||
|
||||
|
||||
def _post(path: str, body: dict, timeout: int = 30) -> dict:
|
||||
req = urllib.request.Request(
|
||||
STREAM_BASE + path,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers=_stream_headers(),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _get(path: str, timeout: int = 10) -> dict:
|
||||
req = urllib.request.Request(
|
||||
STREAM_BASE + path,
|
||||
headers=_stream_headers(),
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Orchestration : état + thread d'exécution
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class DossierResult:
|
||||
ipp: str
|
||||
decision: Optional[str] = None # "REQUALIFICATION_HOSPITALISATION" | "FORFAIT_URGENCE"
|
||||
decision_court: Optional[str] = None # "UHCD" | "Forfait Urgences"
|
||||
confiance: Optional[str] = None
|
||||
duree_passage_heures: Optional[float] = None
|
||||
concordance: Optional[bool] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestrationState:
|
||||
orch_id: str
|
||||
status: str = "starting" # starting | running | done | error | cancelled
|
||||
progress: int = 0 # 0 → count
|
||||
count: int = 0
|
||||
current_step: str = "" # "setup_chrome" | "extract_table" | "process_dossier_X" | "synthese"
|
||||
intent: Dict[str, Any] = field(default_factory=dict)
|
||||
patients: List[str] = field(default_factory=list)
|
||||
results: List[DossierResult] = field(default_factory=list)
|
||||
synthese: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
started_at: float = field(default_factory=time.time)
|
||||
finished_at: Optional[float] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"orch_id": self.orch_id,
|
||||
"status": self.status,
|
||||
"progress": self.progress,
|
||||
"count": self.count,
|
||||
"current_step": self.current_step,
|
||||
"intent": self.intent,
|
||||
"patients": self.patients,
|
||||
"results": [r.__dict__ for r in self.results],
|
||||
"synthese": self.synthese,
|
||||
"error": self.error,
|
||||
"elapsed_s": round((self.finished_at or time.time()) - self.started_at, 1),
|
||||
}
|
||||
|
||||
|
||||
# Registry global des orchestrations en cours (thread-safe via lock)
|
||||
_ORCH_REGISTRY: Dict[str, OrchestrationState] = {}
|
||||
_ORCH_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_orchestration(orch_id: str) -> Optional[OrchestrationState]:
|
||||
with _ORCH_LOCK:
|
||||
return _ORCH_REGISTRY.get(orch_id)
|
||||
|
||||
|
||||
def list_orchestrations() -> List[Dict[str, Any]]:
|
||||
with _ORCH_LOCK:
|
||||
return [s.to_dict() for s in _ORCH_REGISTRY.values()]
|
||||
|
||||
|
||||
def start_orchestration(
|
||||
intent: Dict[str, Any],
|
||||
session_id: str = "",
|
||||
machine_id: Optional[str] = None,
|
||||
) -> OrchestrationState:
|
||||
"""Lance une orchestration en thread daemon. Retourne l'état initial.
|
||||
|
||||
Args:
|
||||
intent: dict {action, count, order, ipp} (sortie de parse_lea_command)
|
||||
session_id: session de replay (default: agent_demo_user, le canal stable
|
||||
sur lequel l'agent V1 polle /replay/next)
|
||||
machine_id: machine cible (optionnel, pour multi-machines futurs)
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = AGENT_SESSION_ID
|
||||
if not machine_id:
|
||||
machine_id = AGENT_MACHINE_ID
|
||||
orch_id = "orch_" + uuid.uuid4().hex[:10]
|
||||
count = intent.get("count") or 3 # default 3 si "tous" ou "first" sans nombre
|
||||
state = OrchestrationState(
|
||||
orch_id=orch_id,
|
||||
status="starting",
|
||||
count=count,
|
||||
intent=intent,
|
||||
)
|
||||
with _ORCH_LOCK:
|
||||
_ORCH_REGISTRY[orch_id] = state
|
||||
|
||||
th = threading.Thread(
|
||||
target=_run_orchestration,
|
||||
args=(state, session_id, machine_id),
|
||||
daemon=True,
|
||||
name=f"orch-{orch_id}",
|
||||
)
|
||||
th.start()
|
||||
return state
|
||||
|
||||
|
||||
def _run_orchestration(state: OrchestrationState, session_id: str, machine_id: Optional[str]) -> None:
|
||||
"""Boucle d'orchestration exécutée dans un thread.
|
||||
|
||||
Phases :
|
||||
1. Setup Chrome (raw actions Win+R)
|
||||
2. extract_table sur liste patients
|
||||
3. Boucle workflow Urgence_unit
|
||||
4. Synthèse
|
||||
"""
|
||||
try:
|
||||
state.status = "running"
|
||||
intent = state.intent
|
||||
|
||||
# Cas "specific" : court-circuiter, juste 1 IPP
|
||||
if intent.get("order") == "specific" and intent.get("ipp"):
|
||||
state.patients = [intent["ipp"]]
|
||||
state.count = 1
|
||||
state.current_step = "process_dossier"
|
||||
_process_dossiers(state, session_id, machine_id)
|
||||
else:
|
||||
# 1. Setup Chrome → URL maquette
|
||||
state.current_step = "setup_chrome"
|
||||
_setup_chrome(session_id, machine_id)
|
||||
|
||||
# 2. Lire la liste des IPP via extract_table
|
||||
state.current_step = "extract_table"
|
||||
patients = _extract_patient_list(session_id, machine_id, limit=state.count)
|
||||
state.patients = patients
|
||||
if not patients:
|
||||
raise RuntimeError("extract_table n'a trouvé aucun IPP — vérifier que Chrome est sur index.html")
|
||||
|
||||
# 3. Pour chaque IPP : lancer workflow Urgence_unit
|
||||
_process_dossiers(state, session_id, machine_id)
|
||||
|
||||
# 4. Synthèse
|
||||
state.current_step = "synthese"
|
||||
state.synthese = _build_synthese(state)
|
||||
state.status = "done"
|
||||
except Exception as e:
|
||||
logger.exception("Orchestration %s : erreur fatale", state.orch_id)
|
||||
state.status = "error"
|
||||
state.error = str(e)
|
||||
finally:
|
||||
state.finished_at = time.time()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Phases de l'orchestration
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _setup_chrome(session_id: str, machine_id: Optional[str]) -> None:
|
||||
"""Composer "ouvrir Chrome sur l'URL maquette" via le catalogue de réflexes.
|
||||
|
||||
Léa ne fait PAS un workflow appris pour cette étape : c'est une composition
|
||||
de primitives natives (réflexes du catalogue) + une saisie texte.
|
||||
|
||||
Séquence :
|
||||
1. réflexe `sys_run` (Win+R) ← gesture_catalog
|
||||
2. type "chrome.exe <URL>" ← saisie atomique
|
||||
3. réflexe `nav_enter` (Entrée) ← gesture_catalog
|
||||
"""
|
||||
from agent_chat.gesture_catalog import get_gesture_catalog
|
||||
|
||||
catalog = get_gesture_catalog()
|
||||
show_desktop = catalog.get_by_id("win_minimize_all") # Win+D — minimise tout (Léa incl.)
|
||||
sys_run = catalog.get_by_id("sys_run")
|
||||
nav_enter = catalog.get_by_id("nav_enter")
|
||||
if sys_run is None or nav_enter is None or show_desktop is None:
|
||||
raise RuntimeError("Réflexes catalogue manquants : win_minimize_all / sys_run / nav_enter")
|
||||
|
||||
actions = [
|
||||
show_desktop.to_replay_action(), # réflexe Win+D — Léa se réduit complètement
|
||||
{
|
||||
"action_id": f"setup_wait_desktop_{uuid.uuid4().hex[:6]}",
|
||||
"type": "wait",
|
||||
"duration_ms": 400,
|
||||
"intention": "Attendre que le bureau soit affiché",
|
||||
},
|
||||
sys_run.to_replay_action(), # réflexe Win+R
|
||||
{
|
||||
"action_id": f"setup_wait_{uuid.uuid4().hex[:6]}",
|
||||
"type": "wait",
|
||||
"duration_ms": 800,
|
||||
"intention": "Attendre que la boîte Exécuter soit prête",
|
||||
},
|
||||
{
|
||||
"action_id": f"setup_typeurl_{uuid.uuid4().hex[:6]}",
|
||||
"type": "type",
|
||||
"text": f"chrome.exe {MAQUETTE_URL}",
|
||||
"intention": "Taper la commande Chrome + URL maquette",
|
||||
},
|
||||
nav_enter.to_replay_action(), # réflexe Entrée
|
||||
{
|
||||
"action_id": f"setup_wait_load_{uuid.uuid4().hex[:6]}",
|
||||
"type": "wait",
|
||||
"duration_ms": 3500,
|
||||
"intention": "Attendre le chargement de la maquette",
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"actions": actions,
|
||||
"session_id": session_id,
|
||||
"task_description": "Setup démo GHT — composition réflexes (sys_run + type + nav_enter)",
|
||||
}
|
||||
if machine_id:
|
||||
payload["machine_id"] = machine_id
|
||||
resp = _post("/api/v1/traces/stream/replay/raw", payload, timeout=20)
|
||||
replay_id = resp.get("replay_id")
|
||||
if not replay_id:
|
||||
raise RuntimeError(f"setup_chrome : pas de replay_id ({resp})")
|
||||
# Setup Chrome ≈ 13s observé (Win+D + Win+R + type URL + Enter + wait 3500ms),
|
||||
# mais le PC peut être chargé → 60s donne de la marge.
|
||||
_wait_replay_done(replay_id, timeout_s=60)
|
||||
|
||||
|
||||
def _extract_patient_list(session_id: str, machine_id: Optional[str], limit: int) -> List[str]:
|
||||
"""Lance une action extract_table seule pour lire la liste des IPP."""
|
||||
actions = [
|
||||
{
|
||||
"action_id": f"extract_table_{uuid.uuid4().hex[:6]}",
|
||||
"type": "extract_table",
|
||||
"parameters": {
|
||||
"output_var": "patients_list",
|
||||
"pattern": IPP_PATTERN,
|
||||
"limit": limit,
|
||||
},
|
||||
"intention": "Lire la liste des IPP visible à l'écran",
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"actions": actions,
|
||||
"session_id": session_id,
|
||||
"task_description": "Extraction liste patients GHT",
|
||||
}
|
||||
if machine_id:
|
||||
payload["machine_id"] = machine_id
|
||||
resp = _post("/api/v1/traces/stream/replay/raw", payload, timeout=15)
|
||||
replay_id = resp.get("replay_id")
|
||||
if not replay_id:
|
||||
raise RuntimeError(f"extract_table : pas de replay_id ({resp})")
|
||||
final = _wait_replay_done(replay_id, timeout_s=20)
|
||||
return list(final.get("variables", {}).get("patients_list") or [])
|
||||
|
||||
|
||||
def _process_dossiers(state: OrchestrationState, session_id: str, machine_id: Optional[str]) -> None:
|
||||
"""Boucle : pour chaque IPP, lance le workflow Urgence_unit."""
|
||||
for i, ipp in enumerate(state.patients):
|
||||
state.current_step = f"process_dossier_{i+1}_of_{len(state.patients)}"
|
||||
result = DossierResult(ipp=ipp)
|
||||
try:
|
||||
payload = {
|
||||
"workflow_id": URGENCE_WORKFLOW_ID,
|
||||
"session_id": session_id,
|
||||
"variables": {"patient_id": ipp},
|
||||
}
|
||||
if machine_id:
|
||||
payload["machine_id"] = machine_id
|
||||
resp = _post("/api/v1/traces/stream/replay", payload, timeout=20)
|
||||
replay_id = resp.get("replay_id")
|
||||
if not replay_id:
|
||||
raise RuntimeError(f"replay_id manquant ({resp})")
|
||||
final = _wait_replay_done(replay_id, timeout_s=180)
|
||||
t2a = final.get("variables", {}).get("t2a_result") or {}
|
||||
result.decision = t2a.get("decision")
|
||||
result.decision_court = t2a.get("decision_court")
|
||||
result.confiance = t2a.get("confiance")
|
||||
result.duree_passage_heures = t2a.get("duree_passage_heures")
|
||||
result.concordance = t2a.get("concordance")
|
||||
except Exception as e:
|
||||
result.error = str(e)
|
||||
logger.warning("Dossier %s : erreur %s", ipp, e)
|
||||
state.results.append(result)
|
||||
state.progress = i + 1
|
||||
|
||||
|
||||
def _wait_replay_done(replay_id: str, timeout_s: int = 60, poll_s: float = 1.0) -> Dict[str, Any]:
|
||||
"""Poll /replay/<id> jusqu'à status terminal."""
|
||||
deadline = time.time() + timeout_s
|
||||
last = {}
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last = _get(f"/api/v1/traces/stream/replay/{replay_id}", timeout=5)
|
||||
except Exception as e:
|
||||
logger.warning("poll replay %s : %s", replay_id, e)
|
||||
status = last.get("status", "")
|
||||
if status in ("done", "completed", "finished", "error", "cancelled", "paused_need_help"):
|
||||
return last
|
||||
time.sleep(poll_s)
|
||||
raise TimeoutError(f"replay {replay_id} non terminé après {timeout_s}s (status={last.get('status')})")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Synthèse finale
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_synthese(state: OrchestrationState) -> str:
|
||||
"""Construit le message de synthèse posté dans le chat à la fin."""
|
||||
n = len(state.results)
|
||||
if n == 0:
|
||||
return "Aucun dossier traité."
|
||||
n_uhcd = sum(1 for r in state.results if r.decision == "REQUALIFICATION_HOSPITALISATION")
|
||||
n_forfait = sum(1 for r in state.results if r.decision == "FORFAIT_URGENCE")
|
||||
n_concord = sum(1 for r in state.results if r.concordance is True)
|
||||
lines = [f"✅ Terminé. {n} dossier(s) traité(s) : {n_forfait} forfait(s) urgences, {n_uhcd} UHCD."]
|
||||
if any(r.concordance is not None for r in state.results):
|
||||
lines.append(f"Concordance vérité-terrain : {n_concord}/{n}.")
|
||||
lines.append("")
|
||||
for r in state.results:
|
||||
if r.error:
|
||||
lines.append(f" • {r.ipp} : ❌ erreur — {r.error}")
|
||||
continue
|
||||
decision_label = r.decision_court or r.decision or "—"
|
||||
conf = f"confiance {r.confiance}" if r.confiance else ""
|
||||
duree = f"{r.duree_passage_heures:.1f}h" if r.duree_passage_heures else ""
|
||||
concord_mark = ""
|
||||
if r.concordance is True:
|
||||
concord_mark = " ✓"
|
||||
elif r.concordance is False:
|
||||
concord_mark = " ⚠ écart vérité-terrain"
|
||||
details = ", ".join(x for x in (conf, duree) if x)
|
||||
lines.append(f" • {r.ipp} : {decision_label}{concord_mark}" + (f" ({details})" if details else ""))
|
||||
return "\n".join(lines)
|
||||
@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Endpoint du serveur Streaming (port 5005)
|
||||
# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée).
|
||||
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
||||
# Base sans /api/v1 — pour les routes à la racine (/health)
|
||||
SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0]
|
||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
||||
|
||||
# Host Ollama — SÉPARÉ du serveur RPA.
|
||||
# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy.
|
||||
# Défaut : localhost (exécution locale ou accès LAN direct).
|
||||
OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost")
|
||||
|
||||
# Token d'authentification API (doit correspondre au token du serveur)
|
||||
# Configurable via variable d'environnement RPA_API_TOKEN
|
||||
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
@@ -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,16 @@ 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
|
||||
# Référence à la ChatWindow Léa V1 (Tkinter) pour afficher les bulles
|
||||
# paused interactives quand le serveur signale une pause supervisée.
|
||||
# Câblée depuis main.py après instanciation des deux objets.
|
||||
# Si None (mode headless / tests), fallback sur self.notifier.
|
||||
self._chat_window_ref = None
|
||||
# Log de la resolution physique pour le diagnostic DPI
|
||||
self._log_screen_info()
|
||||
|
||||
@@ -471,9 +482,15 @@ class ActionExecutorV1:
|
||||
},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} sur POST {url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
elif resp.ok:
|
||||
data = resp.json()
|
||||
state = data.get("screen_state", "ok")
|
||||
if state != "ok":
|
||||
@@ -537,6 +554,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 +616,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)
|
||||
@@ -675,7 +714,11 @@ class ActionExecutorV1:
|
||||
f"attendu '{expected_title}' → mode apprentissage"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_wrong_window(current_title, expected_title)
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="wrong_window",
|
||||
target_description=expected_title,
|
||||
window_title=current_title,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -737,6 +780,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 +809,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 +917,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
|
||||
@@ -855,9 +950,10 @@ class ActionExecutorV1:
|
||||
# et ne trouve toujours pas. L'humain doit montrer.
|
||||
print(f" [POLICY] Retry échoué → mode apprentissage")
|
||||
try:
|
||||
self.notifier.replay_target_not_found(
|
||||
target_desc,
|
||||
target_spec.get("window_title", ""),
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="retry_failed",
|
||||
target_description=target_desc,
|
||||
window_title=target_spec.get("window_title", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -913,9 +1009,10 @@ class ActionExecutorV1:
|
||||
# passe en mode capture et enregistre ce que
|
||||
# l'humain fait (mini-workflow de correction).
|
||||
try:
|
||||
self.notifier.replay_target_not_found(
|
||||
target_desc,
|
||||
target_spec.get("window_title", ""),
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="supervise",
|
||||
target_description=target_desc,
|
||||
window_title=target_spec.get("window_title", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1051,8 +1148,9 @@ class ActionExecutorV1:
|
||||
if raw_keys:
|
||||
self._replay_raw_keys(raw_keys)
|
||||
else:
|
||||
# Fallback copier-coller (anciens enregistrements sans raw_keys)
|
||||
self._type_text(text)
|
||||
# Fallback (anciens enregistrements ou templating runtime sans raw_keys)
|
||||
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
|
||||
self._type_text(text, paste=bool(action.get("paste", False)))
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
@@ -1141,7 +1239,9 @@ class ActionExecutorV1:
|
||||
f"je demande de l'aide"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_no_screen_change(action_type)
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="no_screen_change",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1188,6 +1288,13 @@ class ActionExecutorV1:
|
||||
pass
|
||||
else:
|
||||
print(f" [OK] Changement d'ecran detecte apres {action_type}")
|
||||
# Stabilisation page avant capture screenshot_after :
|
||||
# _wait_for_screen_change retourne dès le 1er tick de diff
|
||||
# (200ms), or sur une transition de page (fade, render API)
|
||||
# le DOM n'est pas encore stable → screenshot ambigu →
|
||||
# verify_action serveur déclenche retry fantôme. Cf. démo
|
||||
# GHT 2026-05-11.
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
|
||||
time.sleep(0.5)
|
||||
@@ -1196,8 +1303,11 @@ class ActionExecutorV1:
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(f"Echec replay action {action_id} ({action_type}) : {e}")
|
||||
result["error"] = f"{type(e).__name__}: {e}"
|
||||
logger.error(
|
||||
f"Echec replay action {action_id} ({action_type}) : {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1297,7 +1407,13 @@ class ActionExecutorV1:
|
||||
|
||||
try:
|
||||
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...")
|
||||
resp = _requests.post(url, json=payload, headers=headers, timeout=30)
|
||||
resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False)
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} sur POST {url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.warning(f"Server resolve HTTP {resp.status_code}")
|
||||
return None
|
||||
@@ -1441,7 +1557,7 @@ class ActionExecutorV1:
|
||||
if not vlm_description:
|
||||
return None
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
prompt = (
|
||||
@@ -1577,7 +1693,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
if anchor_b64:
|
||||
images.append(anchor_b64)
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s
|
||||
@@ -1678,7 +1794,15 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
replay_next_url,
|
||||
params={"session_id": session_id, "machine_id": machine_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
# 8 mai 2026 — démo GHT : 5s → 30s. Le serveur peut exécuter
|
||||
# extract_text (5-7s) PUIS dispatcher l'action suivante dans
|
||||
# la même réponse HTTP. À 5s, le client coupait avant la
|
||||
# réponse, le serveur stockait l'action dans _retry_pending
|
||||
# mais aucun watchdog ne la republiait → action perdue
|
||||
# silencieusement. Cf. docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md.
|
||||
# Le serveur a sa propre borne 180s + retour server_busy à 4.5s,
|
||||
# 30s côté client est sûr.
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
|
||||
@@ -1696,6 +1820,60 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
self._last_conn_error_logged = False
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# Plan B (8 mai 2026 — démo GHT) : si le serveur signale une pause
|
||||
# supervisée, afficher le pause_message dans la ChatWindow Léa V1
|
||||
# (Tkinter, déjà ouverte sur Windows) sous forme de bulle interactive
|
||||
# avec boutons Continuer / Annuler. Permet à l'utilisateur Windows de
|
||||
# voir physiquement ce que Léa attend (pause_for_human ou échec
|
||||
# résolution). Fallback notifier.notify si la ChatWindow n'est pas
|
||||
# câblée (mode headless / tests).
|
||||
if data.get("replay_paused"):
|
||||
pause_msg = data.get("pause_message") or "Léa a besoin de votre aide"
|
||||
replay_id = data.get("replay_id") or ""
|
||||
pause_key = (replay_id, pause_msg)
|
||||
if getattr(self, "_last_pause_msg_shown", None) != pause_key:
|
||||
self._last_pause_msg_shown = pause_key
|
||||
completed = data.get("current_action_index", 0)
|
||||
total = data.get("total_actions", "?")
|
||||
payload = {
|
||||
"replay_id": replay_id,
|
||||
"workflow": "Replay en cours",
|
||||
"reason": pause_msg,
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
}
|
||||
# UX fix 8 mai 2026 (démo GHT) : un seul affichage. La
|
||||
# bulle ChatWindow EST l'affichage canonique (force show +
|
||||
# topmost + bell sonore). Plus de toast paused_toast en
|
||||
# double — Dom ne voulait qu'un seul popup. Si la ChatWindow
|
||||
# n'est pas câblée (mode headless / tests), on retombe sur
|
||||
# le toast Tkinter custom comme dernier recours visuel.
|
||||
chat_window = getattr(self, "_chat_window_ref", None)
|
||||
if chat_window is not None:
|
||||
try:
|
||||
# _add_paused_bubble est thread-safe (utilise root.after)
|
||||
# et force l'affichage + topmost + bell sonore.
|
||||
chat_window._add_paused_bubble(payload)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"chat_window._add_paused_bubble pause silenced",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
# Fallback : pas de ChatWindow câblée → toast Tkinter
|
||||
# autonome (headless / tests). C'est le SEUL chemin qui
|
||||
# déclenche encore show_paused_toast.
|
||||
try:
|
||||
from ..ui.paused_toast import show_paused_toast
|
||||
show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=pause_msg[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast fallback silenced", exc_info=True)
|
||||
return False
|
||||
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
return False
|
||||
@@ -1771,6 +1949,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(
|
||||
@@ -1778,8 +1959,14 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
json=report,
|
||||
headers=self._auth_headers(),
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if resp2.ok:
|
||||
if resp2.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp2.status_code} sur POST {replay_result_url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
elif resp2.ok:
|
||||
server_resp = resp2.json()
|
||||
msg = (
|
||||
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, "
|
||||
@@ -1796,6 +1983,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 +2127,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:
|
||||
@@ -1909,7 +2232,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
"""
|
||||
import requests as _requests
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
prompt = (
|
||||
@@ -1935,8 +2258,11 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
},
|
||||
{"role": "user", "content": prompt, "images": [screenshot_b64]},
|
||||
]
|
||||
# Prefill pour les modèles "thinking" (qwen3-vl) : force la sortie à commencer
|
||||
# par cette chaîne, évite les longs blocs de raisonnement interne.
|
||||
prefill = "The button to click is: " if _is_thinking_popup else ""
|
||||
if _is_thinking_popup:
|
||||
messages_popup.append({"role": "assistant", "content": "The button to click is: "})
|
||||
messages_popup.append({"role": "assistant", "content": prefill})
|
||||
|
||||
payload = {
|
||||
"model": _vlm_model_popup,
|
||||
@@ -2049,7 +2375,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
best_match = None
|
||||
best_val = 0.0
|
||||
threshold = 0.50 # Seuil équilibré
|
||||
threshold = 0.75 # Démo GHT 8 mai — éviter faux positifs (placeholders italiques, tabs voisins). En dessous, mieux vaut tomber en mode apprentissage humain qu'un clic au pif.
|
||||
|
||||
# Essayer plusieurs tailles de police pour couvrir différentes résolutions
|
||||
for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]:
|
||||
@@ -2223,20 +2549,48 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def _type_text(self, text: str):
|
||||
"""Saisir du texte caractère par caractère (anti-détection robot).
|
||||
def _type_text(self, text: str, paste: bool = False):
|
||||
"""Saisir du texte caractère par caractère (anti-détection robot)
|
||||
OU via clipboard + Ctrl+V si paste=True (opt-in workflow non-Citrix).
|
||||
|
||||
Chaque caractère est tapé individuellement avec un délai aléatoire
|
||||
pour simuler une frappe humaine. Les caractères spéciaux AZERTY
|
||||
(@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
|
||||
Mode char-by-char (défaut) :
|
||||
Chaque caractère tapé individuellement avec délai aléatoire
|
||||
(anti-détection robot, requis pour Citrix).
|
||||
Caractères spéciaux AZERTY via KeyCode.from_char().
|
||||
|
||||
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
|
||||
Mode paste (opt-in via action.paste=True dans le workflow) :
|
||||
Texte poussé dans le clipboard Windows (CF_UNICODETEXT) puis Ctrl+V.
|
||||
Gain massif sur textes longs (~100ms total vs ~80ms/char).
|
||||
Réservé aux workflows hors-Citrix (maquette Easily web OK, Citrix bloque).
|
||||
Fallback char-by-char automatique si exception clipboard.
|
||||
"""
|
||||
import random
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
if paste:
|
||||
try:
|
||||
import win32clipboard
|
||||
win32clipboard.OpenClipboard()
|
||||
win32clipboard.EmptyClipboard()
|
||||
win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
|
||||
win32clipboard.CloseClipboard()
|
||||
# 16 mai 2026 : 0.05s → 0.5s pour laisser NoMachine sync
|
||||
# le clipboard Windows → VM Ubuntu avant le Ctrl+V (sinon
|
||||
# le paste arrive avant que le clipboard guest soit à jour).
|
||||
time.sleep(0.5)
|
||||
self.keyboard.press(Key.ctrl_l)
|
||||
self.keyboard.press(KeyCode.from_char('v'))
|
||||
self.keyboard.release(KeyCode.from_char('v'))
|
||||
self.keyboard.release(Key.ctrl_l)
|
||||
time.sleep(0.1)
|
||||
logger.debug(f"Texte saisi via clipboard paste ({len(text)} chars)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Paste clipboard échoué ({e}) → fallback char-by-char")
|
||||
|
||||
# Chemin par défaut (ou fallback paste) : char-by-char
|
||||
for char in text:
|
||||
try:
|
||||
# Taper le caractère via from_char (respecte le layout clavier)
|
||||
@@ -2353,8 +2707,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
|
||||
)
|
||||
print(
|
||||
f" [APPRENTISSAGE] Montre-moi comment faire.\n"
|
||||
f" Quand tu as fini → Ctrl+Shift+L\n"
|
||||
f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
|
||||
f" Quand vous avez fini → Ctrl+Shift+L\n"
|
||||
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -17,6 +17,7 @@ import threading
|
||||
from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
|
||||
STREAMING_ENDPOINT,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
@@ -86,22 +87,23 @@ class AgentV1:
|
||||
self._state.set_on_stop(self.stop_session)
|
||||
|
||||
# Client serveur pour le chat et les workflows
|
||||
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
|
||||
self._server_client = None
|
||||
if LeaServerClient is not None:
|
||||
# Forcer le token API pour éviter les 401
|
||||
# (le token est set par start.bat dans l'environnement)
|
||||
from .config import API_TOKEN as _token
|
||||
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
|
||||
self._server_client = LeaServerClient(server_host=server_host)
|
||||
self._server_client = LeaServerClient()
|
||||
if _token and not self._server_client._api_token:
|
||||
self._server_client._api_token = _token
|
||||
logger.info("Token API forcé dans LeaServerClient")
|
||||
|
||||
# Fenetre de chat Lea (tkinter natif)
|
||||
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
|
||||
server_host = (
|
||||
self._server_client.server_host
|
||||
if self._server_client is not None
|
||||
else os.getenv("RPA_SERVER_HOST", "localhost")
|
||||
else "localhost"
|
||||
)
|
||||
self._chat_window = ChatWindow(
|
||||
server_client=self._server_client,
|
||||
@@ -114,6 +116,14 @@ class AgentV1:
|
||||
# Executeur pour le replay (doit exister avant le poll)
|
||||
self._executor = ActionExecutorV1()
|
||||
|
||||
# Wiring ChatWindow → Executor pour Plan B (pause_message → bulle interactive)
|
||||
# Permet à l'executor d'afficher une bulle paused dans la fenêtre Léa V1
|
||||
# quand le serveur signale replay_paused=True via /replay/next.
|
||||
try:
|
||||
self._executor._chat_window_ref = self._chat_window
|
||||
except Exception:
|
||||
logger.debug("Wiring chat_window→executor échoué (non bloquant)", exc_info=True)
|
||||
|
||||
# Boucles permanentes (pas besoin de session active)
|
||||
self.running = True
|
||||
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
||||
@@ -363,11 +373,11 @@ class AgentV1:
|
||||
continue
|
||||
self._last_bg_hash = img_hash
|
||||
|
||||
# Envoyer au streaming server (avec token auth)
|
||||
# Envoyer au streaming server (via STREAMING_ENDPOINT unifié)
|
||||
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
|
||||
with open(full_path, 'rb') as f:
|
||||
req.post(
|
||||
f"{SERVER_URL}/traces/stream/image",
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
params={
|
||||
"session_id": bg_session,
|
||||
"shot_id": f"heartbeat_{int(time.time())}",
|
||||
@@ -376,6 +386,7 @@ class AgentV1:
|
||||
headers=headers,
|
||||
files={"file": ("screenshot.png", f, "image/png")},
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
@@ -445,6 +456,12 @@ class AgentV1:
|
||||
window_title = self.vision.get_active_window_title()
|
||||
if window_title:
|
||||
heartbeat_event["active_window_title"] = window_title
|
||||
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
|
||||
try:
|
||||
from .vision.capturer import _enrich_with_monitor_info
|
||||
_enrich_with_monitor_info(heartbeat_event)
|
||||
except Exception:
|
||||
pass
|
||||
self.streamer.push_event(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
|
||||
149
agent_v0/agent_v1/network/feedback_bus.py
Normal file
149
agent_v0/agent_v1/network/feedback_bus.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# agent_v1/network/feedback_bus.py
|
||||
"""Client SocketIO pour le bus feedback Léa.
|
||||
|
||||
Consomme les events 'lea:*' émis par agent_chat (port 5004) et les dispatche
|
||||
vers ChatWindow pour affichage en bulles temps réel.
|
||||
|
||||
Events écoutés :
|
||||
lea:action_started — début d'un workflow ou d'une action
|
||||
lea:action_progress — progression dans le workflow
|
||||
lea:done — fin d'un workflow ou d'un copilot
|
||||
lea:need_confirm — étape copilot en attente de validation
|
||||
lea:step_result — résultat d'une étape copilot
|
||||
lea:paused — basculement en paused_need_help (asset démo)
|
||||
lea:resumed — sortie de pause supervisée
|
||||
|
||||
Fail-safe : toute erreur de connexion ou de dispatch est silencieusement
|
||||
loggée. Le ChatWindow continue de fonctionner même si le bus est mort
|
||||
(comportement strictement identique au pré-J3).
|
||||
|
||||
Usage :
|
||||
bus = FeedbackBusClient(
|
||||
server_url="http://localhost:5004",
|
||||
token=os.environ.get("RPA_API_TOKEN", ""),
|
||||
on_event=lambda event, payload: print(event, payload),
|
||||
)
|
||||
bus.start() # connexion en arrière-plan, non-bloquant
|
||||
# ... ChatWindow tourne ...
|
||||
bus.stop()
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
import socketio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LEA_EVENTS = (
|
||||
'lea:action_started',
|
||||
'lea:action_progress',
|
||||
'lea:done',
|
||||
'lea:need_confirm',
|
||||
'lea:step_result',
|
||||
'lea:paused',
|
||||
'lea:resumed',
|
||||
)
|
||||
|
||||
EventCallback = Callable[[str, dict], None]
|
||||
|
||||
|
||||
class FeedbackBusClient:
|
||||
"""Client SocketIO non-bloquant pour le bus 'lea:*'."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
token: Optional[str] = None,
|
||||
on_event: Optional[EventCallback] = None,
|
||||
):
|
||||
self._url = server_url.rstrip('/')
|
||||
self._token = token or None
|
||||
self._on_event: EventCallback = on_event or (lambda e, p: None)
|
||||
self._sio = socketio.Client(
|
||||
reconnection=True,
|
||||
reconnection_attempts=0, # 0 = illimité
|
||||
reconnection_delay=2,
|
||||
reconnection_delay_max=30,
|
||||
logger=False,
|
||||
engineio_logger=False,
|
||||
)
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._register_handlers()
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
@self._sio.event
|
||||
def connect():
|
||||
logger.info("FeedbackBus connecté à %s", self._url)
|
||||
|
||||
@self._sio.event
|
||||
def disconnect():
|
||||
logger.info("FeedbackBus déconnecté")
|
||||
|
||||
for ev in LEA_EVENTS:
|
||||
self._sio.on(ev, lambda data, e=ev: self._dispatch(e, data))
|
||||
|
||||
def _dispatch(self, event: str, payload: Optional[dict]) -> None:
|
||||
try:
|
||||
self._on_event(event, payload or {})
|
||||
except Exception:
|
||||
logger.debug("FeedbackBus dispatch silenced", exc_info=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Démarrer la connexion en arrière-plan (idempotent, non-bloquant)."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, daemon=True, name="LeaFeedbackBus",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
headers = {}
|
||||
if self._token:
|
||||
headers['Authorization'] = f'Bearer {self._token}'
|
||||
try:
|
||||
self._sio.connect(self._url, headers=headers, wait=True)
|
||||
self._sio.wait()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"FeedbackBus connect échoué (%s) — ChatWindow continue normalement", e,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Arrêter proprement la connexion (idempotent, fail-safe)."""
|
||||
try:
|
||||
if self._sio.connected:
|
||||
self._sio.disconnect()
|
||||
except Exception:
|
||||
logger.debug("FeedbackBus stop silenced", exc_info=True)
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return bool(self._sio.connected)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions utilisateur depuis la bulle paused_need_help (J3.5)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def resume_replay(self, replay_id: str) -> bool:
|
||||
"""Bouton Continuer : émet 'lea:replay_resume' vers agent_chat.
|
||||
|
||||
Retourne True si l'event a pu être émis, False sinon (déconnecté/erreur).
|
||||
"""
|
||||
return self._safe_emit("lea:replay_resume", {"replay_id": replay_id})
|
||||
|
||||
def abort_replay(self, replay_id: str) -> bool:
|
||||
"""Bouton Annuler : émet 'lea:replay_abort' vers agent_chat."""
|
||||
return self._safe_emit("lea:replay_abort", {"replay_id": replay_id})
|
||||
|
||||
def _safe_emit(self, event: str, payload: dict) -> bool:
|
||||
try:
|
||||
if not self._sio.connected:
|
||||
return False
|
||||
self._sio.emit(event, payload)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("FeedbackBus _safe_emit silenced", exc_info=True)
|
||||
return False
|
||||
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,10 +203,18 @@ class TraceStreamer:
|
||||
try:
|
||||
self.queue.put((item_type, data), timeout=0.5)
|
||||
except queue.Full:
|
||||
logger.warning(
|
||||
f"Queue pleine — événement prioritaire droppé "
|
||||
f"(type={item_type})"
|
||||
)
|
||||
# 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 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
|
||||
logger.debug(
|
||||
@@ -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,56 @@ 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}")
|
||||
|
||||
# =========================================================================
|
||||
# Protection redirect POST→GET (INC-7)
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _check_redirect(resp, url: str):
|
||||
"""Detecter et logger une redirection sur un POST.
|
||||
|
||||
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
|
||||
Avec allow_redirects=False, on recoit le 301/302 directement.
|
||||
On log un WARNING explicite pour que l'admin corrige l'URL.
|
||||
"""
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
location = resp.headers.get("Location", "?")
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} detectee sur POST {url} "
|
||||
f"→ {location}. Verifiez que RPA_SERVER_URL utilise "
|
||||
f"https:// si le serveur redirige."
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Envois HTTP
|
||||
# =========================================================================
|
||||
@@ -294,15 +573,20 @@ class TraceStreamer:
|
||||
def _register_session(self):
|
||||
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/register"
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/register",
|
||||
url,
|
||||
params={
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=3,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
logger.warning("Enregistrement session échoué (redirect)")
|
||||
return
|
||||
if resp.ok:
|
||||
logger.info(
|
||||
f"Session {self.session_id} enregistrée sur le serveur "
|
||||
@@ -322,28 +606,32 @@ class TraceStreamer:
|
||||
C'est la dernière chance de sauver les données de la session.
|
||||
"""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/finalize"
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/finalize",
|
||||
url,
|
||||
params={
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=30, # Le build workflow peut prendre du temps
|
||||
allow_redirects=False,
|
||||
)
|
||||
self._check_redirect(resp, url)
|
||||
if resp.ok:
|
||||
result = resp.json()
|
||||
logger.info(f"Session finalisée: {result}")
|
||||
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)."""
|
||||
if not self._server_available:
|
||||
return False
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/event"
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"timestamp": time.time(),
|
||||
@@ -351,24 +639,36 @@ class TraceStreamer:
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/event",
|
||||
url,
|
||||
json=payload,
|
||||
headers=self._auth_headers(),
|
||||
timeout=2,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
return False
|
||||
return resp.ok
|
||||
except Exception as e:
|
||||
logger.debug(f"Streaming Event échoué: {e}")
|
||||
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)
|
||||
@@ -379,19 +679,26 @@ class TraceStreamer:
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
|
||||
url = f"{STREAMING_ENDPOINT}/image"
|
||||
if jpeg_buf is not None:
|
||||
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
|
||||
files = {
|
||||
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
return resp.ok
|
||||
if self._check_redirect(resp, url):
|
||||
return ImageSendResult.FAILED
|
||||
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:
|
||||
@@ -399,13 +706,29 @@ class TraceStreamer:
|
||||
"file": (f"{shot_id}.png", f, "image/png")
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
return resp.ok
|
||||
if self._check_redirect(resp, url):
|
||||
return ImageSendResult.FAILED
|
||||
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,7 +3,9 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
pywebview>=5.0 # Fenêtre de chat Léa intégrée (Edge WebView2 sur Windows)
|
||||
|
||||
0
agent_v0/agent_v1/tools/__init__.py
Normal file
0
agent_v0/agent_v1/tools/__init__.py
Normal file
88
agent_v0/agent_v1/tools/test_lea_pause_flow.py
Normal file
88
agent_v0/agent_v1/tools/test_lea_pause_flow.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# agent_v1/tools/test_lea_pause_flow.py
|
||||
"""Smoke test : simuler un lea:paused localement et vérifier la bulle ChatWindow.
|
||||
|
||||
À lancer SUR WINDOWS (PC démo) :
|
||||
cd C:/rpa_vision
|
||||
.venv\\Scripts\\python.exe -m agent_v1.tools.test_lea_pause_flow
|
||||
|
||||
Ce script ouvre une ChatWindow, simule l'arrivée d'un payload paused_need_help
|
||||
avec un message LONG (350+ chars pour tester le scroll interne), puis attend
|
||||
les clics utilisateur sur Continuer/Annuler. Le test vérifie qu'il y a UN SEUL
|
||||
affichage (la bulle chat), pas de toast supplémentaire.
|
||||
|
||||
Exit code 0 si succès. Logs dans la console.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Configurer le logging avant tout import du package
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
|
||||
)
|
||||
logger = logging.getLogger("test_lea_pause_flow")
|
||||
|
||||
# Forcer le bus feedback (pour que les boutons puissent émettre vers
|
||||
# rpa-agent-chat — port 5004). Si on ne veut PAS du bus, mettre LEA_FEEDBACK_BUS=0.
|
||||
os.environ.setdefault("LEA_FEEDBACK_BUS", "1")
|
||||
os.environ.setdefault("RPA_API_TOKEN", "") # à remplir si serveur exige Bearer
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
from agent_v1.ui.chat_window import ChatWindow
|
||||
except ImportError as e:
|
||||
print(f"[TEST] Import ChatWindow KO : {e}")
|
||||
return 2
|
||||
|
||||
print("[TEST] Création ChatWindow...")
|
||||
cw = ChatWindow(
|
||||
server_client=None,
|
||||
on_start_callback=None,
|
||||
server_host=os.environ.get("RPA_SERVER_HOST", "192.168.1.40"),
|
||||
chat_port=5004,
|
||||
)
|
||||
# Attendre que le tk loop soit prêt
|
||||
time.sleep(2.0)
|
||||
cw.show()
|
||||
time.sleep(0.5)
|
||||
|
||||
print("[TEST] Simulation lea:paused avec long message (350 chars)...")
|
||||
long_msg = (
|
||||
"Je n'arrive pas à trouver le champ « Numéro de dossier patient » "
|
||||
"sur l'écran courant. J'ai essayé 3 stratégies de grounding visuel "
|
||||
"(template matching, OCR, VLM) sans succès. Pouvez-vous me montrer "
|
||||
"l'emplacement exact du champ, ou cliquer dessus à ma place ? "
|
||||
"Quand vous avez fini, cliquez sur Continuer pour que je reprenne."
|
||||
)
|
||||
payload = {
|
||||
"replay_id": "test_replay_pause_flow_001",
|
||||
"workflow": "Démo UHCD",
|
||||
"reason": long_msg,
|
||||
"completed": 5,
|
||||
"total": 12,
|
||||
}
|
||||
cw._add_paused_bubble(payload)
|
||||
print(f"[TEST] Bulle envoyée. Message len={len(long_msg)} chars.")
|
||||
print("[TEST] Vérifiez visuellement :")
|
||||
print(" 1) UN SEUL popup (la bulle chat dans la fenêtre Léa)")
|
||||
print(" 2) Le message long s'affiche en intégralité (scroll interne si besoin)")
|
||||
print(" 3) Boutons Continuer / Annuler visibles")
|
||||
print(" 4) Cliquez sur Annuler → bulle fermée + feedback '✗ Annulé'")
|
||||
print("[TEST] La fenêtre reste ouverte 30s. Ctrl+C pour quitter avant.")
|
||||
|
||||
try:
|
||||
for i in range(30):
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
print("[TEST] Interruption clavier.")
|
||||
|
||||
print("[TEST] Test terminé. Vérifier visuellement les 4 points ci-dessus.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# agent_v1/tools/test_lea_toast.py
|
||||
"""
|
||||
Test visuel rapide du toast Léa (démo GHT 8 mai 2026).
|
||||
|
||||
Lance trois scénarios de toast successifs pour valider l'affichage Windows :
|
||||
1. Toast simple « pause supervisée »
|
||||
2. Toast avec message long (vérifier wraplength)
|
||||
3. Toast type BLOCAGE (= ce que voit l'utilisateur quand Léa est perdue)
|
||||
|
||||
Usage Windows :
|
||||
C:\\rpa_vision\\.venv\\Scripts\\python.exe C:\\rpa_vision\\agent_v1\\tools\\test_lea_toast.py
|
||||
|
||||
Le script s'attend à voir trois toasts successifs en haut-droite de l'écran
|
||||
principal, espacés de ~6 s, fond bleu Léa, autodismiss après 15 s ou clic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _bootstrap_path() -> None:
|
||||
"""Autoriser l'exécution directe sans -m : ajouter C:\\rpa_vision au sys.path."""
|
||||
here = Path(__file__).resolve()
|
||||
# On remonte : tools -> agent_v1 -> rpa_vision (parent du package agent_v1)
|
||||
rpa_root = here.parent.parent.parent
|
||||
if str(rpa_root) not in sys.path:
|
||||
sys.path.insert(0, str(rpa_root))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_bootstrap_path()
|
||||
|
||||
# Import après ajout du path (les deux variantes fonctionnent)
|
||||
try:
|
||||
from agent_v1.ui.paused_toast import show_paused_toast
|
||||
except Exception as e: # pragma: no cover (debug only)
|
||||
print(f"[TEST] ERREUR import agent_v1.ui.paused_toast : {e}")
|
||||
return 1
|
||||
|
||||
scenarios = [
|
||||
(
|
||||
"Toast 1/3 : pause simple",
|
||||
"Léa a besoin de votre aide",
|
||||
"Test 1/3 — Pause supervisée. Cliquez sur 'Continuer' dans la chat.",
|
||||
),
|
||||
(
|
||||
"Toast 2/3 : message long",
|
||||
"Léa — j'attends votre validation",
|
||||
(
|
||||
"Test 2/3 — J'ai trouvé 11 dossiers correspondant à vos critères "
|
||||
"(UHCD, Forfait 1, PE2). Je vais traiter le dossier de M. DUPONT "
|
||||
"Jean en premier. Pouvez-vous valider que c'est le bon ordre "
|
||||
"avant que je continue ?"
|
||||
),
|
||||
),
|
||||
(
|
||||
"Toast 3/3 : blocage cible non trouvée",
|
||||
"Léa — je ne vois pas l'élément",
|
||||
(
|
||||
"Test 3/3 — Je n'arrive pas à trouver « Examens cliniques » à "
|
||||
"l'écran. Pouvez-vous me montrer où cliquer ?"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for label, title, message in scenarios:
|
||||
print(f"[TEST] {label}")
|
||||
ok = show_paused_toast(title=title, message=message)
|
||||
print(f" show_paused_toast() = {ok}")
|
||||
if not ok:
|
||||
print(f" ECHEC : {label}")
|
||||
# Espacer pour que Dom voit chaque toast distinctement
|
||||
# (rate limit interne = 3s pour message identique, mais ici les
|
||||
# messages diffèrent, le rate limit ne s'applique pas)
|
||||
time.sleep(6)
|
||||
|
||||
print("[TEST] Attente 12s supplémentaires pour laisser le dernier toast vivre...")
|
||||
time.sleep(12)
|
||||
print("[TEST] OK — fin du test. Si vous avez vu 3 toasts bleus en haut-droite,")
|
||||
print(" le mécanisme Léa pause est validé.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# agent_v1/ui/_test_paused_toast.py
|
||||
"""
|
||||
Test isolé du toast paused — à exécuter directement sur Windows.
|
||||
|
||||
Usage (sur Windows, depuis C:\\rpa_vision\\agent_v1) :
|
||||
python -m agent_v1.ui._test_paused_toast
|
||||
|
||||
OU plus simple :
|
||||
python C:\\rpa_vision\\agent_v1\\ui\\_test_paused_toast.py
|
||||
|
||||
Le toast doit s'afficher en haut à droite de l'écran principal pendant ~15s.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[TEST] Lancement du toast paused...")
|
||||
|
||||
try:
|
||||
# Import flexible : essai relatif puis absolu
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
except ImportError:
|
||||
from paused_toast import show_paused_toast
|
||||
except Exception as e:
|
||||
print(f"[TEST] ERREUR import : {e}")
|
||||
return 1
|
||||
|
||||
ok = show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=(
|
||||
"Test isolé — démo GHT 8 mai 2026.\n"
|
||||
"Si vous voyez ce toast, le mécanisme de pause supervisée "
|
||||
"fonctionne correctement."
|
||||
),
|
||||
)
|
||||
print(f"[TEST] show_paused_toast() retour = {ok}")
|
||||
|
||||
if not ok:
|
||||
print("[TEST] ÉCHEC : toast non déclenché.")
|
||||
return 2
|
||||
|
||||
print("[TEST] Toast déclenché. Attente de 18s pour le voir s'afficher puis se fermer...")
|
||||
time.sleep(18)
|
||||
print("[TEST] OK — fin du test.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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}")
|
||||
|
||||
@@ -16,6 +16,15 @@ from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# FeedbackBus : import fail-safe (le ChatWindow doit tourner même si python-socketio
|
||||
# n'est pas installé sur le poste client, par exemple ancienne installation Pauline)
|
||||
try:
|
||||
from ..network.feedback_bus import FeedbackBusClient
|
||||
_HAS_FEEDBACK_BUS = True
|
||||
except Exception:
|
||||
FeedbackBusClient = None # type: ignore
|
||||
_HAS_FEEDBACK_BUS = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme — palette professionnelle claire
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -42,6 +51,25 @@ SCROLLBAR_BG = "#E5E7EB" # Fond scrollbar
|
||||
SCROLLBAR_FG = "#9CA3AF" # Curseur scrollbar
|
||||
MSG_BORDER_COLOR = "#D1D5DB" # Bordure subtile des bulles de messages
|
||||
|
||||
# Bulle paused_need_help (J3.5) — alerte non bloquante, asset démo majeur
|
||||
PAUSED_BG = "#FEF3C7" # Jaune pâle
|
||||
PAUSED_BORDER = "#F59E0B" # Orange ambré
|
||||
PAUSED_FG = "#92400E" # Brun foncé (lisible sur fond jaune)
|
||||
PAUSED_BTN_RESUME_BG = "#22C55E" # Vert
|
||||
PAUSED_BTN_RESUME_HOVER = "#16A34A"
|
||||
PAUSED_BTN_ABORT_BG = "#9CA3AF" # Gris neutre (pas dramatique)
|
||||
PAUSED_BTN_ABORT_HOVER = "#6B7280"
|
||||
|
||||
# Bulle "Léa exécute" (J3.4) — distincte des bulles chat normales
|
||||
ACTION_BG = "#F1F5F9" # Gris très clair (différencie d'une réponse chat)
|
||||
ACTION_BORDER = "#CBD5E1" # Gris pâle
|
||||
ACTION_FG = "#1E293B" # Gris foncé
|
||||
ACTION_META_FG = "#94A3B8" # Métadonnées en gris discret
|
||||
ACTION_ICON_RUN = "#3B82F6" # Bleu (en cours)
|
||||
ACTION_ICON_OK = "#22C55E" # Vert (succès)
|
||||
ACTION_ICON_ERR = "#EF4444" # Rouge (échec)
|
||||
ACTION_ICON_INFO = "#64748B" # Gris (neutre)
|
||||
|
||||
# Dimensions — confortables
|
||||
WIN_WIDTH = 600
|
||||
WIN_HEIGHT = 800
|
||||
@@ -62,6 +90,80 @@ FONT_SEND_BTN = ("Segoe UI", 13)
|
||||
FONT_RESIZE_GRIP = ("Segoe UI", 10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates de bulles "Léa exécute" (J3.4)
|
||||
# Chaque template prend un payload et retourne (icon, icon_color, title).
|
||||
# Les libellés sont volontairement neutres : le contexte métier vient du
|
||||
# payload (workflow, action, message), pas de hardcoding.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tpl_action_started(payload: Dict[str, Any]) -> tuple:
|
||||
wf = payload.get("workflow") or "?"
|
||||
return ("▶", ACTION_ICON_RUN, f"Démarrage : {wf}")
|
||||
|
||||
|
||||
def _tpl_action_progress(payload: Dict[str, Any]) -> tuple:
|
||||
cur = payload.get("current", "?")
|
||||
tot = payload.get("total", "?")
|
||||
step = payload.get("step")
|
||||
title = step if step else f"Étape {cur}/{tot}"
|
||||
return ("⋯", ACTION_ICON_RUN, str(title))
|
||||
|
||||
|
||||
def _tpl_done(payload: Dict[str, Any]) -> tuple:
|
||||
success = bool(payload.get("success", True))
|
||||
msg = payload.get("message") or ("Terminé" if success else "Échec")
|
||||
if success:
|
||||
return ("✓", ACTION_ICON_OK, str(msg))
|
||||
return ("✗", ACTION_ICON_ERR, str(msg))
|
||||
|
||||
|
||||
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
|
||||
action = payload.get("action") or {}
|
||||
desc = action.get("description") if isinstance(action, dict) else None
|
||||
title = desc or "Validation requise"
|
||||
return ("?", ACTION_ICON_RUN, str(title))
|
||||
|
||||
|
||||
def _tpl_step_result(payload: Dict[str, Any]) -> tuple:
|
||||
status = (payload.get("status") or "").lower()
|
||||
msg = payload.get("message") or status or "Étape terminée"
|
||||
if status in ("ok", "success", "approved"):
|
||||
return ("✓", ACTION_ICON_OK, str(msg))
|
||||
if status in ("error", "failed"):
|
||||
return ("✗", ACTION_ICON_ERR, str(msg))
|
||||
return ("·", ACTION_ICON_INFO, str(msg))
|
||||
|
||||
|
||||
def _tpl_resumed(payload: Dict[str, Any]) -> tuple:
|
||||
return ("→", ACTION_ICON_OK, "Reprise")
|
||||
|
||||
|
||||
_ACTION_TEMPLATES = {
|
||||
"lea:action_started": _tpl_action_started,
|
||||
"lea:action_progress": _tpl_action_progress,
|
||||
"lea:done": _tpl_done,
|
||||
"lea:need_confirm": _tpl_need_confirm,
|
||||
"lea:step_result": _tpl_step_result,
|
||||
"lea:resumed": _tpl_resumed,
|
||||
}
|
||||
|
||||
|
||||
def _extract_meta(payload: Dict[str, Any]) -> str:
|
||||
"""Métadonnées techniques en pied de bulle (workflow, étape, replay_id court)."""
|
||||
parts = []
|
||||
wf = payload.get("workflow")
|
||||
if wf:
|
||||
parts.append(str(wf))
|
||||
cur, tot = payload.get("current"), payload.get("total")
|
||||
if cur is not None and tot is not None:
|
||||
parts.append(f"étape {cur}/{tot}")
|
||||
rid = payload.get("replay_id")
|
||||
if rid:
|
||||
parts.append(f"#{str(rid)[-6:]}")
|
||||
return " • ".join(parts)
|
||||
|
||||
|
||||
class ChatWindow:
|
||||
"""Fenetre de chat Lea en tkinter natif.
|
||||
|
||||
@@ -91,6 +193,8 @@ class ChatWindow:
|
||||
self._root = None
|
||||
self._ready = threading.Event()
|
||||
self._messages = [] # historique local
|
||||
self._bus: Optional[Any] = None # FeedbackBusClient (J3.3, peut rester None)
|
||||
self._active_paused_bubble: Optional[Dict[str, Any]] = None # bulle paused active (J3.5)
|
||||
|
||||
# S'abonner aux changements de l'etat partage
|
||||
if self._shared_state is not None:
|
||||
@@ -266,6 +370,9 @@ class ChatWindow:
|
||||
# Signaler que la fenetre est prete
|
||||
self._ready.set()
|
||||
|
||||
# Demarrer le bus feedback Lea (events 'lea:*' temps reel)
|
||||
self._start_feedback_bus()
|
||||
|
||||
# Boucle tkinter
|
||||
root.mainloop()
|
||||
|
||||
@@ -608,6 +715,12 @@ class ChatWindow:
|
||||
|
||||
def _do_destroy(self) -> None:
|
||||
"""Detruit la fenetre (appele dans le thread tkinter)."""
|
||||
if self._bus is not None:
|
||||
try:
|
||||
self._bus.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._bus = None
|
||||
if self._root is not None:
|
||||
try:
|
||||
self._root.quit()
|
||||
@@ -617,6 +730,364 @@ class ChatWindow:
|
||||
self._root = None
|
||||
self._visible = False
|
||||
|
||||
# ======================================================================
|
||||
# FeedbackBus — bulles temps reel pendant l'execution (J3.3)
|
||||
# ======================================================================
|
||||
|
||||
def _start_feedback_bus(self) -> None:
|
||||
"""Demarrer la connexion au bus 'lea:*' si flag actif et lib disponible."""
|
||||
if not _HAS_FEEDBACK_BUS:
|
||||
logger.debug("FeedbackBus non disponible (python-socketio manquant)")
|
||||
return
|
||||
flag = os.environ.get("LEA_FEEDBACK_BUS", "0").lower()
|
||||
if flag not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
try:
|
||||
url = f"http://{self._server_host}:{self._chat_port}"
|
||||
token = os.environ.get("RPA_API_TOKEN", "") or None
|
||||
self._bus = FeedbackBusClient(url, token=token, on_event=self._on_lea_event)
|
||||
self._bus.start()
|
||||
logger.info("FeedbackBus demarre : %s", url)
|
||||
except Exception:
|
||||
logger.debug("FeedbackBus init silenced", exc_info=True)
|
||||
self._bus = None
|
||||
|
||||
def _on_lea_event(self, event: str, payload: Dict[str, Any]) -> None:
|
||||
"""Callback bus → bulle Lea. Thread-safe : helpers utilisent root.after."""
|
||||
payload = payload or {}
|
||||
|
||||
# J3.5 : la pause supervisée a sa propre bulle interactive
|
||||
if event == "lea:paused":
|
||||
self._add_paused_bubble(payload)
|
||||
return
|
||||
if event in ("lea:resumed", "lea:done"):
|
||||
self._close_active_paused_bubble(reason=event)
|
||||
# on continue pour afficher la bulle d'action (cf. dispatch ci-dessous)
|
||||
|
||||
# Acks bus (resume_acked, abort_acked) : silencieux côté UI
|
||||
if event in ("lea:resume_acked", "lea:abort_acked"):
|
||||
return
|
||||
|
||||
# J3.4 : bulle "Léa exécute" stylisée (séparée des bulles chat normales)
|
||||
rendered = _ACTION_TEMPLATES.get(event)
|
||||
if rendered is None:
|
||||
# Event inconnu : on affiche en bulle d'action neutre
|
||||
self._add_action_bubble(
|
||||
icon="·", icon_color=ACTION_ICON_INFO,
|
||||
title=event.removeprefix("lea:"),
|
||||
meta=_extract_meta(payload),
|
||||
)
|
||||
return
|
||||
icon, icon_color, title = rendered(payload)
|
||||
self._add_action_bubble(
|
||||
icon=icon, icon_color=icon_color, title=title,
|
||||
meta=_extract_meta(payload),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulle "Léa exécute" stylisée (J3.4)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_action_bubble(
|
||||
self, icon: str, icon_color: str, title: str, meta: str = "",
|
||||
) -> None:
|
||||
if self._root is None:
|
||||
return
|
||||
self._root.after(0, lambda: self._render_action_bubble(icon, icon_color, title, meta))
|
||||
|
||||
def _render_action_bubble(
|
||||
self, icon: str, icon_color: str, title: str, meta: str,
|
||||
) -> None:
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
|
||||
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
||||
container.pack(fill=tk.X, padx=MARGIN, pady=3)
|
||||
|
||||
inner = tk.Frame(
|
||||
container, bg=ACTION_BG, padx=10, pady=6,
|
||||
highlightbackground=ACTION_BORDER, highlightthickness=1,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 70), fill=tk.X)
|
||||
|
||||
row = tk.Frame(inner, bg=ACTION_BG)
|
||||
row.pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
tk.Label(
|
||||
row, text=icon, bg=ACTION_BG, fg=icon_color,
|
||||
font=("Segoe UI", 13, "bold"), padx=4,
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
tk.Label(
|
||||
row, text=title, bg=ACTION_BG, fg=ACTION_FG,
|
||||
font=FONT_MSG, anchor="w", justify=tk.LEFT,
|
||||
wraplength=MSG_WRAP_WIDTH - 60,
|
||||
).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(2, 0))
|
||||
|
||||
if meta:
|
||||
tk.Label(
|
||||
inner, text=f"{meta} • {now}",
|
||||
bg=ACTION_BG, fg=ACTION_META_FG,
|
||||
font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(2, 0))
|
||||
|
||||
# UX fix 8 mai 2026 : auto-scroll après chaque bulle d'action
|
||||
self._scroll_to_bottom()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulle paused_need_help interactive (J3.5)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide).
|
||||
|
||||
IMPORTANT (8 mai 2026, démo GHT) : par défaut la fenêtre démarre cachée
|
||||
(`root.withdraw()`). Il FAUT la rendre visible et la forcer au premier
|
||||
plan, sinon Dom ne voit jamais la bulle. On exécute dans le thread
|
||||
tkinter via `root.after(0, ...)`.
|
||||
|
||||
UX fix 8 mai 2026 : un seul affichage (la bulle chat). Plus de toast
|
||||
en double — on force juste la chat window au premier plan.
|
||||
"""
|
||||
if self._root is None:
|
||||
return
|
||||
|
||||
def _show_and_render():
|
||||
try:
|
||||
self._do_show()
|
||||
# Re-pin topmost pour passer devant les apps actives
|
||||
self._root.attributes("-topmost", True)
|
||||
self._root.lift()
|
||||
# Bell sonore léger pour attirer l'attention (Tkinter natif)
|
||||
try:
|
||||
self._root.bell()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("force-show chat_window silenced", exc_info=True)
|
||||
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
|
||||
# l'attention sur la question (clear visuel uniquement,
|
||||
# self._messages reste intact pour la traçabilité debug).
|
||||
self._clear_chat_history()
|
||||
self._render_paused_bubble(payload)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
def _clear_chat_history(self) -> None:
|
||||
"""Vide la zone d'affichage du chat (widgets enfants de _msg_frame).
|
||||
|
||||
Détruit uniquement les widgets visuels — self._messages (liste mémoire)
|
||||
reste intact pour la traçabilité debug. Cohérent avec _do_remove_typing
|
||||
qui détruit aussi le widget sans toucher à l'historique.
|
||||
"""
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
try:
|
||||
for child in list(self._msg_frame.winfo_children()):
|
||||
child.destroy()
|
||||
self._active_paused_bubble = None
|
||||
if hasattr(self, "_typing_frame"):
|
||||
self._typing_frame = None
|
||||
self._scroll_to_bottom()
|
||||
except Exception:
|
||||
logger.debug("clear chat history silenced", exc_info=True)
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
|
||||
replay_id = str(payload.get("replay_id", "") or "")
|
||||
workflow = payload.get("workflow", "?")
|
||||
reason = payload.get("reason") or "Action incertaine — j'ai besoin de votre validation."
|
||||
completed = payload.get("completed", 0)
|
||||
total = payload.get("total", "?")
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
|
||||
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
||||
container.pack(fill=tk.X, padx=MARGIN, pady=6)
|
||||
|
||||
inner = tk.Frame(
|
||||
container, bg=PAUSED_BG, padx=14, pady=12,
|
||||
highlightbackground=PAUSED_BORDER, highlightthickness=2,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"⏸ Pause supervisée • {now}",
|
||||
bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
|
||||
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
|
||||
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
|
||||
reason_str = str(reason)
|
||||
# Estimation simple : ~70 chars/ligne avec wraplength
|
||||
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
|
||||
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
reason_text = tk.Text(
|
||||
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wrap=tk.WORD, bd=0, height=approx_lines,
|
||||
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
|
||||
)
|
||||
reason_text.insert("1.0", reason_str)
|
||||
reason_text.configure(state="disabled")
|
||||
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
# Scrollbar interne uniquement si le contenu déborde (long messages)
|
||||
if len(reason_str) > 280:
|
||||
reason_scroll = tk.Scrollbar(
|
||||
msg_frame, orient=tk.VERTICAL,
|
||||
command=reason_text.yview, width=8,
|
||||
)
|
||||
reason_text.configure(yscrollcommand=reason_scroll.set)
|
||||
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} — étape {completed}/{total}",
|
||||
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
|
||||
|
||||
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
btn_frame.pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
btn_resume = tk.Button(
|
||||
btn_frame, text="Continuer",
|
||||
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_resume(replay_id),
|
||||
)
|
||||
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
|
||||
|
||||
btn_abort = tk.Button(
|
||||
btn_frame, text="Annuler",
|
||||
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_abort(replay_id),
|
||||
)
|
||||
btn_abort.pack(side=tk.LEFT)
|
||||
|
||||
# Zone de feedback (mise à jour après clic, avant l'ack du bus)
|
||||
feedback_label = tk.Label(
|
||||
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_TIMESTAMP, anchor="w",
|
||||
)
|
||||
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
|
||||
self._active_paused_bubble = {
|
||||
"container": container, "inner": inner,
|
||||
"btn_resume": btn_resume, "btn_abort": btn_abort,
|
||||
"feedback_label": feedback_label,
|
||||
"replay_id": replay_id,
|
||||
}
|
||||
|
||||
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _close_active_paused_bubble(self, reason: str) -> None:
|
||||
if self._active_paused_bubble is None or self._root is None:
|
||||
return
|
||||
self._root.after(0, lambda: self._do_close_paused_bubble(reason))
|
||||
|
||||
def _do_close_paused_bubble(self, reason: str) -> None:
|
||||
bubble = self._active_paused_bubble
|
||||
if bubble is None:
|
||||
return
|
||||
try:
|
||||
bubble["btn_resume"].config(state="disabled")
|
||||
bubble["btn_abort"].config(state="disabled")
|
||||
label_text = {
|
||||
"lea:resumed": "→ Reprise",
|
||||
"lea:done": "→ Terminé",
|
||||
}.get(reason, f"→ {reason}")
|
||||
self._tk.Label(
|
||||
bubble["inner"], text=label_text,
|
||||
bg=PAUSED_BG, fg=PAUSED_FG, font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill="x", anchor="w", pady=(6, 0))
|
||||
except Exception:
|
||||
logger.debug("close paused bubble silenced", exc_info=True)
|
||||
self._active_paused_bubble = None
|
||||
|
||||
def _on_paused_resume(self, replay_id: str) -> None:
|
||||
"""Bouton Continuer : émettre lea:replay_resume + feedback immédiat UI.
|
||||
|
||||
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
|
||||
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
|
||||
arrière-plan ; si la connexion est tombée, on log un warning visible.
|
||||
"""
|
||||
if not replay_id:
|
||||
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
|
||||
return
|
||||
emitted = False
|
||||
if self._bus is not None and self._bus.connected:
|
||||
emitted = self._bus.resume_replay(replay_id)
|
||||
# Feedback immédiat : disable boutons + message
|
||||
self._disable_paused_buttons()
|
||||
if emitted:
|
||||
self._update_paused_feedback("→ Reprise demandée…")
|
||||
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
|
||||
else:
|
||||
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
|
||||
logger.warning("paused_bubble: bus déconnecté, resume non émis")
|
||||
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
|
||||
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
|
||||
try:
|
||||
self._root.after(500, self._do_hide)
|
||||
except Exception:
|
||||
logger.debug("auto-hide on resume silenced", exc_info=True)
|
||||
|
||||
def _on_paused_abort(self, replay_id: str) -> None:
|
||||
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
|
||||
|
||||
UX fix 8 mai 2026 : on ferme la bulle localement dès le clic (le serveur
|
||||
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
|
||||
locale la bulle restait coincée — c'était la cause de "Annuler ne
|
||||
fonctionne pas" rapportée par Dom).
|
||||
"""
|
||||
emitted = False
|
||||
if self._bus is not None and self._bus.connected:
|
||||
emitted = self._bus.abort_replay(replay_id)
|
||||
self._disable_paused_buttons()
|
||||
if emitted:
|
||||
self._update_paused_feedback("✗ Annulé")
|
||||
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
|
||||
else:
|
||||
self._update_paused_feedback("✗ Annulé (bus indisponible)")
|
||||
logger.warning("paused_bubble: bus déconnecté, abort non émis")
|
||||
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
|
||||
self._close_active_paused_bubble(reason="abort_local")
|
||||
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
|
||||
# avec _on_paused_resume, demandé explicitement par Dom).
|
||||
try:
|
||||
self._root.after(500, self._do_hide)
|
||||
except Exception:
|
||||
logger.debug("auto-hide on abort silenced", exc_info=True)
|
||||
|
||||
def _disable_paused_buttons(self) -> None:
|
||||
if not self._active_paused_bubble:
|
||||
return
|
||||
try:
|
||||
self._active_paused_bubble["btn_resume"].config(state="disabled")
|
||||
self._active_paused_bubble["btn_abort"].config(state="disabled")
|
||||
except Exception:
|
||||
logger.debug("disable paused buttons silenced", exc_info=True)
|
||||
|
||||
def _update_paused_feedback(self, text: str) -> None:
|
||||
if not self._active_paused_bubble:
|
||||
return
|
||||
label = self._active_paused_bubble.get("feedback_label")
|
||||
if label is None:
|
||||
return
|
||||
try:
|
||||
label.config(text=text)
|
||||
except Exception:
|
||||
logger.debug("update paused feedback silenced", exc_info=True)
|
||||
|
||||
# ======================================================================
|
||||
# Ajout de messages dans la zone de chat
|
||||
# ======================================================================
|
||||
|
||||
@@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
|
||||
)
|
||||
|
||||
|
||||
def formatter_mode_apprentissage(
|
||||
raison: str = "",
|
||||
description_cible: str = "",
|
||||
titre_fenetre: Optional[str] = None,
|
||||
) -> MessageUtilisateur:
|
||||
"""Message quand Léa passe en mode apprentissage (pause supervisée).
|
||||
|
||||
L'utilisateur doit comprendre :
|
||||
1. Léa est bloquée et a besoin d'aide
|
||||
2. L'utilisateur doit prendre la main et montrer comment faire
|
||||
3. Ctrl+Shift+L pour signaler qu'il a fini
|
||||
|
||||
Le ton est humble, clair, actionnable. Pas technique.
|
||||
|
||||
Exemple :
|
||||
Léa a besoin d'aide
|
||||
Je n'y arrive pas, montrez-moi comment faire.
|
||||
Quand vous avez fini, appuyez sur Ctrl+Shift+L.
|
||||
"""
|
||||
cible = _nettoyer_description_cible(description_cible) if description_cible else ""
|
||||
app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else ""
|
||||
|
||||
# Construire un contexte court si disponible
|
||||
contexte = ""
|
||||
if cible and app:
|
||||
contexte = f" (« {cible} » dans {app})"
|
||||
elif cible:
|
||||
contexte = f" (« {cible} »)"
|
||||
|
||||
corps = (
|
||||
f"Je n'y arrive pas{contexte}, montrez-moi comment faire. "
|
||||
f"Quand vous avez fini, appuyez sur Ctrl+Shift+L."
|
||||
)
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa a besoin d'aide",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
|
||||
"""Message quand la connexion avec le serveur est perdue.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from .messages import (
|
||||
formatter_etape_workflow,
|
||||
formatter_fenetre_incorrecte,
|
||||
formatter_fin_workflow,
|
||||
formatter_mode_apprentissage,
|
||||
formatter_ralentissement,
|
||||
formatter_retry,
|
||||
)
|
||||
@@ -138,10 +139,19 @@ class NotificationManager:
|
||||
|
||||
Les messages BLOCAGE bypass le rate limit pour garantir que
|
||||
l'utilisateur voit qu'on a besoin de lui.
|
||||
|
||||
UX fix 8 mai 2026 (démo GHT) : la bulle ChatWindow Léa V1 (Tkinter
|
||||
topmost + bell + force-show) est désormais l'affichage canonique pour
|
||||
les BLOCAGE de pause supervisée. On NE déclenche PLUS show_paused_toast
|
||||
depuis ici — Dom rapportait 3 popups en parallèle (toast executor,
|
||||
toast bubble, toast notifications). Plyer reste actif comme
|
||||
notification système discrète. Le toast Tkinter custom est conservé
|
||||
pour les fallbacks sans ChatWindow (cf. executor.Plan B).
|
||||
"""
|
||||
bypass = msg.niveau == NiveauMessage.BLOCAGE
|
||||
# Log aussi pour tracer dans les logs fichiers
|
||||
self._log_message(msg)
|
||||
|
||||
return self.notify(
|
||||
title=msg.titre,
|
||||
message=msg.corps,
|
||||
@@ -273,6 +283,20 @@ class NotificationManager:
|
||||
msg = formatter_ecran_inchange(action_type)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_learning_mode(
|
||||
self,
|
||||
raison: str = "",
|
||||
target_description: str = "",
|
||||
window_title: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Notification quand Léa passe en mode apprentissage.
|
||||
|
||||
Léa est bloquée et demande à l'utilisateur de montrer comment faire.
|
||||
Message humble et actionnable pour un utilisateur non technique.
|
||||
"""
|
||||
msg = formatter_mode_apprentissage(raison, target_description, window_title)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
|
||||
"""Notification quand Léa retente une action."""
|
||||
msg = formatter_retry(action_type, tentative)
|
||||
|
||||
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# agent_v1/ui/paused_toast.py
|
||||
"""
|
||||
Toast Tkinter custom pour la pause supervisée (« Léa a besoin de votre aide »).
|
||||
|
||||
Démo GHT 8 mai 2026 — Fallback robuste 100 % autonome quand :
|
||||
- plyer.notification est silencieux sous Windows 11 (Focus Assist, balloon tips
|
||||
bloqués par la stratégie système),
|
||||
- la ChatWindow Léa V1 est `withdraw()`-cachée par défaut (Dom ne la voit pas),
|
||||
- aucune autre UI ne peut garantir que Dom verra physiquement le message.
|
||||
|
||||
Stratégie :
|
||||
- Toplevel topmost overrideredirect en haut à droite de l'écran principal,
|
||||
- fond bleu Léa, titre + message, auto-close après TOAST_DURATION_S,
|
||||
- thread-safe : peut être appelé depuis n'importe quel thread (le polling
|
||||
replay tourne dans un daemon thread, pas le thread principal),
|
||||
- aucune dépendance externe (juste tkinter stdlib),
|
||||
- rate limit interne pour éviter le flood (1 toast / 3s minimum).
|
||||
|
||||
Si un Tk root existe déjà dans le process (ChatWindow), on attache le Toplevel
|
||||
à ce root via `root.after(0, ...)` — c'est l'idiome thread-safe officiel de
|
||||
tkinter. Sinon on crée un Tk() dédié dans un daemon thread.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Couleurs cohérentes avec le thème Léa (cf. chat_window.py)
|
||||
TOAST_BG = "#2563EB" # Bleu Léa (HEADER_BG)
|
||||
TOAST_FG = "#FFFFFF"
|
||||
TOAST_TITLE_BG = "#1E40AF" # Bleu plus foncé pour le bandeau titre
|
||||
TOAST_BORDER = "#1E3A8A"
|
||||
|
||||
TOAST_WIDTH = 380
|
||||
TOAST_PAD_X = 18
|
||||
TOAST_PAD_Y = 14
|
||||
TOAST_DURATION_MS = 15000
|
||||
TOAST_RATE_LIMIT_S = 3.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_shown_at: float = 0.0
|
||||
_last_message: str = ""
|
||||
|
||||
|
||||
def _resolve_existing_root() -> Optional[Any]:
|
||||
"""Tente de récupérer le Tk root déjà créé par la ChatWindow.
|
||||
|
||||
On évite tk._default_root (deprecated) et on remonte plutôt via les
|
||||
threads existants : la ChatWindow garde une référence dans son instance
|
||||
mais n'expose rien de global. On se rabat donc sur la création d'un Tk
|
||||
indépendant si on n'a rien — c'est sûr, tkinter supporte plusieurs Tk()
|
||||
concurrents tant qu'ils sont chacun dans leur propre thread.
|
||||
"""
|
||||
try:
|
||||
import tkinter as tk
|
||||
# tk._default_root est interne mais c'est le moyen le plus simple
|
||||
# de partager un mainloop existant. Si ChatWindow tourne, ce sera
|
||||
# son root.
|
||||
root = getattr(tk, "_default_root", None)
|
||||
if root is not None:
|
||||
# Vérifier qu'il est encore vivant
|
||||
try:
|
||||
root.winfo_exists()
|
||||
return root
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_toast(parent: Any, title: str, message: str) -> Any:
|
||||
"""Construit le Toplevel toast (appelé dans le thread tkinter)."""
|
||||
import tkinter as tk
|
||||
|
||||
top = tk.Toplevel(parent)
|
||||
top.withdraw() # éviter le flash pendant la construction
|
||||
top.overrideredirect(True) # pas de barre de titre
|
||||
top.attributes("-topmost", True)
|
||||
try:
|
||||
# Petit boost de visibilité Windows : alpha légèrement transparent
|
||||
top.attributes("-alpha", 0.97)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bordure visuelle (cadre extérieur foncé)
|
||||
outer = tk.Frame(top, bg=TOAST_BORDER, padx=2, pady=2)
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
# Bandeau titre
|
||||
title_frame = tk.Frame(outer, bg=TOAST_TITLE_BG)
|
||||
title_frame.pack(fill="x")
|
||||
tk.Label(
|
||||
title_frame,
|
||||
text=f" ⏸ {title}",
|
||||
bg=TOAST_TITLE_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
anchor="w",
|
||||
padx=10,
|
||||
pady=8,
|
||||
).pack(fill="x")
|
||||
|
||||
# Corps du message
|
||||
body_frame = tk.Frame(outer, bg=TOAST_BG)
|
||||
body_frame.pack(fill="both", expand=True)
|
||||
tk.Label(
|
||||
body_frame,
|
||||
text=message,
|
||||
bg=TOAST_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 11),
|
||||
wraplength=TOAST_WIDTH - 40,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
padx=TOAST_PAD_X,
|
||||
pady=TOAST_PAD_Y,
|
||||
).pack(fill="both", expand=True)
|
||||
|
||||
# Pied de page : "Cliquez pour fermer"
|
||||
footer = tk.Label(
|
||||
outer,
|
||||
text="Cliquez pour fermer",
|
||||
bg=TOAST_BG,
|
||||
fg="#BFDBFE",
|
||||
font=("Segoe UI", 9, "italic"),
|
||||
anchor="e",
|
||||
padx=10,
|
||||
pady=4,
|
||||
)
|
||||
footer.pack(fill="x", side="bottom")
|
||||
|
||||
# Position : haut-droite de l'écran principal
|
||||
top.update_idletasks()
|
||||
height = top.winfo_reqheight()
|
||||
screen_w = top.winfo_screenwidth()
|
||||
x = screen_w - TOAST_WIDTH - 16
|
||||
y = 16
|
||||
top.geometry(f"{TOAST_WIDTH}x{height}+{x}+{y}")
|
||||
|
||||
# Click anywhere to close
|
||||
def _close(_=None):
|
||||
try:
|
||||
top.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top.bind("<Button-1>", _close)
|
||||
for child in (outer, title_frame, body_frame, footer):
|
||||
try:
|
||||
child.bind("<Button-1>", _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Afficher + boost focus brut pour passer devant Focus Assist
|
||||
top.deiconify()
|
||||
top.lift()
|
||||
try:
|
||||
top.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-pin topmost après 100 ms (Windows désactive parfois -topmost
|
||||
# quand le focus est pris par une autre app)
|
||||
def _repin():
|
||||
try:
|
||||
top.attributes("-topmost", True)
|
||||
top.lift()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
top.after(100, _repin)
|
||||
top.after(500, _repin)
|
||||
top.after(2000, _repin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-close
|
||||
try:
|
||||
top.after(TOAST_DURATION_MS, _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return top
|
||||
|
||||
|
||||
def _show_in_dedicated_thread(title: str, message: str) -> None:
|
||||
"""Crée un Tk() indépendant dans un daemon thread.
|
||||
|
||||
Utilisé en fallback quand aucun Tk root n'existe. Le thread vit le
|
||||
temps du toast (~15s) puis se termine proprement.
|
||||
"""
|
||||
def _run():
|
||||
try:
|
||||
# DPI awareness (Windows haute résolution)
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
dpi = root.winfo_fpixels("1i")
|
||||
root.tk.call("tk", "scaling", dpi / 72.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top = _build_toast(root, title, message)
|
||||
|
||||
# Quitter mainloop quand le toast est détruit
|
||||
def _watch():
|
||||
try:
|
||||
if not top.winfo_exists():
|
||||
root.quit()
|
||||
return
|
||||
except Exception:
|
||||
root.quit()
|
||||
return
|
||||
root.after(200, _watch)
|
||||
|
||||
root.after(200, _watch)
|
||||
root.mainloop()
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("paused_toast dedicated thread failed", exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="paused-toast-tk")
|
||||
t.start()
|
||||
|
||||
|
||||
def show_paused_toast(
|
||||
title: str = "Léa a besoin de votre aide",
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
"""Affiche un toast paused topmost.
|
||||
|
||||
Thread-safe, rate-limité, sans dépendance externe. Retourne True si le
|
||||
toast a été déclenché, False s'il a été ignoré (rate limit ou erreur).
|
||||
"""
|
||||
global _last_shown_at, _last_message
|
||||
|
||||
if not message:
|
||||
message = "Action en attente de votre validation."
|
||||
|
||||
# Rate limit basique : éviter qu'un poll en boucle ouvre 50 toasts
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
same_message = (message == _last_message)
|
||||
elapsed = now - _last_shown_at
|
||||
if same_message and elapsed < TOAST_RATE_LIMIT_S:
|
||||
logger.debug(
|
||||
"paused_toast rate-limited (%.1fs since last identical)", elapsed
|
||||
)
|
||||
return False
|
||||
_last_shown_at = now
|
||||
_last_message = message
|
||||
|
||||
# Tentative 1 : utiliser le Tk root existant (ChatWindow) via after()
|
||||
root = _resolve_existing_root()
|
||||
if root is not None:
|
||||
try:
|
||||
root.after(0, lambda: _build_toast(root, title, message))
|
||||
logger.info("paused_toast scheduled on existing Tk root")
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("paused_toast existing-root path failed", exc_info=True)
|
||||
|
||||
# Tentative 2 : créer un Tk() dans un daemon thread
|
||||
try:
|
||||
_show_in_dedicated_thread(title, message)
|
||||
logger.info("paused_toast scheduled in dedicated thread")
|
||||
return True
|
||||
except Exception:
|
||||
logger.error("paused_toast dedicated-thread path failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["show_paused_toast"]
|
||||
@@ -2,12 +2,20 @@
|
||||
"""
|
||||
Gestionnaire de vision avancé pour Agent V1.
|
||||
Optimisé pour le streaming fibre avec détection de changement.
|
||||
|
||||
Captures disponibles :
|
||||
- Plein écran (full) : contexte global 1920x1080+
|
||||
- Crop ciblé (crop) : 80x80 autour du clic (apprentissage VLM)
|
||||
- Fenêtre active (window) : image isolée de la fenêtre + métadonnées
|
||||
(titre, rect, coordonnées clic relatives) — cross-platform
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
import platform
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
@@ -15,6 +23,69 @@ from .blur_sensitive import blur_sensitive_regions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OS courant (détecté une seule fois)
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
|
||||
try:
|
||||
from screeninfo import get_monitors as _screeninfo_get_monitors
|
||||
_SCREENINFO_AVAILABLE = True
|
||||
except ImportError:
|
||||
_SCREENINFO_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_monitors_geometry() -> List[Dict[str, Any]]:
|
||||
"""Retourne la liste des monitors physiques avec leurs offsets.
|
||||
|
||||
Returns:
|
||||
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
|
||||
indisponible (le serveur tombera sur fallback composite).
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
monitors = _screeninfo_get_monitors()
|
||||
return [
|
||||
{
|
||||
"idx": i,
|
||||
"x": int(m.x),
|
||||
"y": int(m.y),
|
||||
"w": int(m.width),
|
||||
"h": int(m.height),
|
||||
"primary": bool(getattr(m, "is_primary", False)),
|
||||
}
|
||||
for i, m in enumerate(monitors)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_active_monitor_index() -> Optional[int]:
|
||||
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
|
||||
|
||||
Returns:
|
||||
int ou None si indéterminable.
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
import pyautogui # import paresseux : évite la dépendance dure
|
||||
cx, cy = pyautogui.position()
|
||||
for i, m in enumerate(_screeninfo_get_monitors()):
|
||||
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
|
||||
return i
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_with_monitor_info(payload: dict) -> dict:
|
||||
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
|
||||
if isinstance(payload, dict):
|
||||
payload["monitor_index"] = _get_active_monitor_index()
|
||||
payload["monitors_geometry"] = _get_monitors_geometry()
|
||||
return payload
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
@@ -27,13 +98,16 @@ class VisionCapturer:
|
||||
"""
|
||||
Capture l'écran complet.
|
||||
Si force=False, vérifie d'abord si l'écran a changé.
|
||||
|
||||
Enrichit les métadonnées avec le titre de la fenêtre active
|
||||
(utile pour le contextualisation des heartbeats côté serveur).
|
||||
"""
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
sct_img = sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
||||
|
||||
|
||||
# Détection de changement (pour Heartbeat)
|
||||
if not force:
|
||||
current_hash = self._compute_quick_hash(img)
|
||||
@@ -52,8 +126,24 @@ class VisionCapturer:
|
||||
logger.error(f"Erreur Context Capture: {e}")
|
||||
return ""
|
||||
|
||||
def get_active_window_title(self) -> str:
|
||||
"""Retourne le titre de la fenêtre active (pour enrichir les heartbeats).
|
||||
|
||||
Fallback gracieux : retourne une chaîne vide si indisponible.
|
||||
"""
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
info = get_active_window_info()
|
||||
return info.get("title", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def capture_dual(self, x: int, y: int, screenshot_id: str, anonymize=False) -> dict:
|
||||
"""Capture duale (Full + Crop) systématique (forcée car liée à une action)."""
|
||||
"""Capture triple (Full + Crop + Fenêtre active) systématique.
|
||||
|
||||
La fenêtre active est un AJOUT — en cas d'échec, le full + crop
|
||||
sont toujours retournés (fallback gracieux).
|
||||
"""
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
|
||||
@@ -67,7 +157,7 @@ class VisionCapturer:
|
||||
left = max(0, x - w // 2)
|
||||
top = max(0, y - h // 2)
|
||||
crop_img = img.crop((left, top, left + w, top + h))
|
||||
|
||||
|
||||
if anonymize:
|
||||
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
|
||||
|
||||
@@ -82,11 +172,136 @@ class VisionCapturer:
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
|
||||
return {"full": full_path, "crop": crop_path}
|
||||
result = {"full": full_path, "crop": crop_path}
|
||||
|
||||
# --- Capture de la fenêtre active ---
|
||||
# Ajout non-bloquant : enrichit le résultat avec l'image
|
||||
# de la fenêtre seule + métadonnées (titre, rect, clic relatif)
|
||||
window_info = self.capture_active_window(x, y, screenshot_id, full_img=img)
|
||||
if window_info:
|
||||
result["window_capture"] = window_info
|
||||
|
||||
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
|
||||
_enrich_with_monitor_info(result)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Dual Capture: {e}")
|
||||
return {}
|
||||
|
||||
def capture_active_window(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
screenshot_id: str,
|
||||
full_img: Optional[Image.Image] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Capture l'image de la fenêtre active seule + métadonnées.
|
||||
|
||||
Stratégie :
|
||||
1. Obtenir le rectangle de la fenêtre via l'API OS (pywin32 / xdotool / Quartz)
|
||||
2. Cropper depuis le screenshot plein écran (plus fiable que PrintWindow)
|
||||
3. Calculer les coordonnées du clic relatives à la fenêtre
|
||||
|
||||
Args:
|
||||
x, y: coordonnées du clic en pixels écran
|
||||
screenshot_id: identifiant pour le nom de fichier
|
||||
full_img: screenshot plein écran déjà capturé (optionnel, évite une
|
||||
double capture si appelé depuis capture_dual)
|
||||
|
||||
Returns:
|
||||
Dict avec window_image, window_title, window_rect, click_in_window,
|
||||
window_size — ou None si la fenêtre est introuvable.
|
||||
"""
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_rect
|
||||
|
||||
rect_info = get_active_window_rect()
|
||||
if not rect_info:
|
||||
logger.debug("Fenêtre active introuvable — skip capture fenêtre")
|
||||
return None
|
||||
|
||||
win_rect = rect_info["rect"] # [left, top, right, bottom]
|
||||
win_left, win_top, win_right, win_bottom = win_rect
|
||||
win_w, win_h = rect_info["size"] # [width, height]
|
||||
title = rect_info.get("title", "unknown_window")
|
||||
app_name = rect_info.get("app_name", "unknown_app")
|
||||
|
||||
# Ignorer les fenêtres trop petites (barres de tâches, popups système)
|
||||
if win_w < 50 or win_h < 50:
|
||||
logger.debug(f"Fenêtre trop petite ({win_w}x{win_h}) — skip")
|
||||
return None
|
||||
|
||||
# Coordonnées du clic relatives à la fenêtre
|
||||
click_rel_x = x - win_left
|
||||
click_rel_y = y - win_top
|
||||
|
||||
# Si le clic est en dehors de la fenêtre, on le signale mais on continue
|
||||
click_inside = (0 <= click_rel_x <= win_w and 0 <= click_rel_y <= win_h)
|
||||
|
||||
# --- Crop de la fenêtre depuis le plein écran ---
|
||||
if full_img is None:
|
||||
# Pas de screenshot fourni — en capturer un (cas standalone)
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
sct_img = sct.grab(monitor)
|
||||
full_img = Image.frombytes(
|
||||
"RGB", sct_img.size, sct_img.bgra, "raw", "BGRX"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur capture plein écran pour fenêtre : {e}")
|
||||
return None
|
||||
|
||||
# Borner le crop aux limites de l'image plein écran
|
||||
img_w, img_h = full_img.size
|
||||
crop_left = max(0, win_left)
|
||||
crop_top = max(0, win_top)
|
||||
crop_right = min(img_w, win_right)
|
||||
crop_bottom = min(img_h, win_bottom)
|
||||
|
||||
if crop_right <= crop_left or crop_bottom <= crop_top:
|
||||
logger.debug("Fenêtre hors écran — skip capture fenêtre")
|
||||
return None
|
||||
|
||||
window_img = full_img.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
# Floutage conformité AI Act
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(window_img)
|
||||
|
||||
# Sauvegarde
|
||||
window_path = os.path.join(
|
||||
self.shots_dir, f"{screenshot_id}_window.png"
|
||||
)
|
||||
window_img.save(window_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
|
||||
result = {
|
||||
"window_image": window_path,
|
||||
"window_title": title,
|
||||
"app_name": app_name,
|
||||
"window_rect": win_rect,
|
||||
"window_size": [win_w, win_h],
|
||||
"click_in_window": [click_rel_x, click_rel_y],
|
||||
"click_inside_window": click_inside,
|
||||
}
|
||||
|
||||
# QW1 — enrichissement multi-écrans (additif)
|
||||
_enrich_with_monitor_info(result)
|
||||
|
||||
logger.debug(
|
||||
f"Fenêtre capturée : {title} ({win_w}x{win_h}) — "
|
||||
f"clic relatif ({click_rel_x}, {click_rel_y})"
|
||||
)
|
||||
return result
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"Module fenêtre indisponible : {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur capture fenêtre active : {e}")
|
||||
return None
|
||||
|
||||
def _compute_quick_hash(self, img: Image) -> str:
|
||||
"""Calcule un hash rapide basé sur une vignette réduite pour détecter les changements."""
|
||||
# On réduit l'image à 64x64 pour comparer les masses de couleurs (très rapide)
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Dict, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
||||
@@ -36,11 +36,11 @@ def get_active_window_info() -> Dict[str, str]:
|
||||
"title": "...",
|
||||
"app_name": "..."
|
||||
}
|
||||
|
||||
|
||||
Détecte automatiquement l'OS et utilise la méthode appropriée.
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
|
||||
if system == "Linux":
|
||||
return _get_window_info_linux()
|
||||
elif system == "Windows":
|
||||
@@ -51,6 +51,32 @@ def get_active_window_info() -> Dict[str, str]:
|
||||
return {"title": "unknown_window", "app_name": "unknown_app"}
|
||||
|
||||
|
||||
def get_active_window_rect() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Renvoie le rectangle de la fenêtre active :
|
||||
{
|
||||
"title": "...",
|
||||
"app_name": "...",
|
||||
"rect": [left, top, right, bottom],
|
||||
"position": [left, top],
|
||||
"size": [width, height],
|
||||
"hwnd": int # Windows uniquement
|
||||
}
|
||||
|
||||
Retourne None si la fenêtre est introuvable ou minimisée.
|
||||
Détecte automatiquement l'OS et utilise la méthode appropriée.
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
return _get_window_rect_windows()
|
||||
elif system == "Linux":
|
||||
return _get_window_rect_linux()
|
||||
elif system == "Darwin":
|
||||
return _get_window_rect_macos()
|
||||
return None
|
||||
|
||||
|
||||
def _get_window_info_linux() -> Dict[str, str]:
|
||||
"""
|
||||
Linux: utilise xdotool (X11)
|
||||
@@ -178,6 +204,163 @@ def _get_window_info_macos() -> Dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _get_window_rect_windows() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Windows : utilise pywin32 pour obtenir le rectangle de la fenêtre active.
|
||||
|
||||
Retourne None si la fenêtre est minimisée (icônifiée) ou si pywin32 manque.
|
||||
"""
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
import psutil
|
||||
|
||||
hwnd = win32gui.GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return None
|
||||
|
||||
# Ignorer les fenêtres minimisées (pas de contenu visible)
|
||||
if win32gui.IsIconic(hwnd):
|
||||
return None
|
||||
|
||||
title = win32gui.GetWindowText(hwnd) or "unknown_window"
|
||||
|
||||
# Rectangle de la fenêtre (coordonnées écran absolues)
|
||||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
|
||||
# Ignorer les fenêtres de taille nulle ou absurde
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
|
||||
# Nom du processus
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
try:
|
||||
app_name = psutil.Process(pid).name()
|
||||
except Exception:
|
||||
app_name = "unknown_app"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
"rect": [left, top, right, bottom],
|
||||
"position": [left, top],
|
||||
"size": [width, height],
|
||||
"hwnd": hwnd,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_window_rect_linux() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Linux (X11) : utilise xdotool + xwininfo pour obtenir le rectangle.
|
||||
|
||||
Nécessite : sudo apt-get install xdotool x11-utils
|
||||
"""
|
||||
try:
|
||||
# Identifiant de la fenêtre active
|
||||
wid = _run_cmd(["xdotool", "getactivewindow"])
|
||||
if not wid:
|
||||
return None
|
||||
|
||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) or "unknown_window"
|
||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
||||
app_name = "unknown_app"
|
||||
if pid_str:
|
||||
app_name = _run_cmd(["ps", "-p", pid_str.strip(), "-o", "comm="]) or "unknown_app"
|
||||
|
||||
# Géométrie via xdotool --shell (position + taille)
|
||||
geom_raw = _run_cmd(["xdotool", "getwindowgeometry", "--shell", wid])
|
||||
if not geom_raw:
|
||||
return None
|
||||
|
||||
vals: Dict[str, int] = {}
|
||||
for line in geom_raw.strip().splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
try:
|
||||
vals[k.strip()] = int(v.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
|
||||
return None
|
||||
|
||||
x, y = vals["X"], vals["Y"]
|
||||
w, h = vals["WIDTH"], vals["HEIGHT"]
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
"rect": [x, y, x + w, y + h],
|
||||
"position": [x, y],
|
||||
"size": [w, h],
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_window_rect_macos() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
macOS : utilise Quartz (CGWindowListCopyWindowInfo) pour obtenir le rectangle.
|
||||
|
||||
Nécessite : pip install pyobjc-framework-Quartz
|
||||
"""
|
||||
try:
|
||||
from AppKit import NSWorkspace
|
||||
from Quartz import (
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID,
|
||||
)
|
||||
|
||||
active_app = NSWorkspace.sharedWorkspace().activeApplication()
|
||||
app_name = active_app.get("NSApplicationName", "unknown_app")
|
||||
|
||||
window_list = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
||||
)
|
||||
|
||||
for window in window_list:
|
||||
owner_name = window.get("kCGWindowOwnerName", "")
|
||||
if owner_name != app_name:
|
||||
continue
|
||||
|
||||
bounds = window.get("kCGWindowBounds")
|
||||
if not bounds:
|
||||
continue
|
||||
|
||||
x = int(bounds.get("X", 0))
|
||||
y = int(bounds.get("Y", 0))
|
||||
w = int(bounds.get("Width", 0))
|
||||
h = int(bounds.get("Height", 0))
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
|
||||
title = window.get("kCGWindowName", "unknown_window") or "unknown_window"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
"rect": [x, y, x + w, y + h],
|
||||
"position": [x, y],
|
||||
"size": [w, h],
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Test rapide
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
@@ -185,8 +368,13 @@ if __name__ == "__main__":
|
||||
print(f"OS détecté: {platform.system()}")
|
||||
print("\nTest de capture fenêtre active (5 secondes)...")
|
||||
print("Changez de fenêtre pour tester!\n")
|
||||
|
||||
|
||||
for i in range(5):
|
||||
info = get_active_window_info()
|
||||
rect = get_active_window_rect()
|
||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
|
||||
if rect:
|
||||
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
|
||||
else:
|
||||
print(" Rect: non disponible")
|
||||
time.sleep(1)
|
||||
|
||||
@@ -512,6 +512,21 @@ class ActionExecutorV1:
|
||||
x_pct = action.get("x_pct", 0.0)
|
||||
y_pct = action.get("y_pct", 0.0)
|
||||
|
||||
# QW1 — Si le serveur a résolu un monitor cible (idx >= 0),
|
||||
# appliquer son offset aux coords absolues. Pour idx == -1
|
||||
# (composite_fallback), aucun offset (backward compat).
|
||||
# Le calcul des coords reste percent * (width/height) du monitor[1]
|
||||
# côté client (x_pct est exprimé sur l'écran physique principal).
|
||||
mon_res = action.get("monitor_resolution") or {}
|
||||
mon_idx = mon_res.get("idx", -1)
|
||||
mon_offset_x = mon_res.get("offset_x", 0) if mon_idx >= 0 else 0
|
||||
mon_offset_y = mon_res.get("offset_y", 0) if mon_idx >= 0 else 0
|
||||
if mon_idx >= 0 and (mon_offset_x or mon_offset_y):
|
||||
logger.info(
|
||||
f"[REPLAY] QW1 monitor cible idx={mon_idx} source={mon_res.get('source')} "
|
||||
f"offset=({mon_offset_x},{mon_offset_y}) — appliqué aux coords"
|
||||
)
|
||||
|
||||
# ── Diagnostic résolution ──
|
||||
logger.info(
|
||||
f"[REPLAY] Action {action_id} ({action_type}) — "
|
||||
@@ -578,8 +593,8 @@ class ActionExecutorV1:
|
||||
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
|
||||
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
|
||||
if popup_coords:
|
||||
real_x = int(popup_coords["x_pct"] * width)
|
||||
real_y = int(popup_coords["y_pct"] * height)
|
||||
real_x = int(popup_coords["x_pct"] * width) + mon_offset_x
|
||||
real_y = int(popup_coords["y_pct"] * height) + mon_offset_y
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(1.0)
|
||||
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
|
||||
@@ -718,8 +733,8 @@ class ActionExecutorV1:
|
||||
self.notifier.replay_target_not_found(target_desc)
|
||||
return result
|
||||
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
real_x = int(x_pct * width) + mon_offset_x
|
||||
real_y = int(y_pct * height) + mon_offset_y
|
||||
button = action.get("button", "left")
|
||||
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
|
||||
print(
|
||||
@@ -781,8 +796,8 @@ class ActionExecutorV1:
|
||||
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
|
||||
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
|
||||
if x_pct > 0 and y_pct > 0:
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
real_x = int(x_pct * width) + mon_offset_x
|
||||
real_y = int(y_pct * height) + mon_offset_y
|
||||
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(0.3)
|
||||
@@ -808,8 +823,8 @@ class ActionExecutorV1:
|
||||
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "scroll":
|
||||
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
|
||||
real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height)
|
||||
real_x = (int(x_pct * width) if x_pct > 0 else int(0.5 * width)) + mon_offset_x
|
||||
real_y = (int(y_pct * height) if y_pct > 0 else int(0.5 * height)) + mon_offset_y
|
||||
delta = action.get("delta", -3)
|
||||
print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})")
|
||||
self.mouse.position = (real_x, real_y)
|
||||
@@ -1386,6 +1401,16 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
data = resp.json()
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
# pause_for_human : afficher le message de décision à l'utilisateur
|
||||
if data.get("replay_paused") and data.get("pause_message"):
|
||||
msg = data["pause_message"]
|
||||
print(f"[PAUSE] {msg}")
|
||||
logger.info(f"Replay en pause — message : {msg}")
|
||||
self.notifier.notify(
|
||||
title="Léa — Validation requise",
|
||||
message=msg[:250],
|
||||
timeout=30,
|
||||
)
|
||||
return False
|
||||
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
||||
|
||||
@@ -319,7 +319,22 @@ class AgentV1:
|
||||
if img_hash != self._last_heartbeat_hash:
|
||||
self._last_heartbeat_hash = img_hash
|
||||
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
|
||||
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
|
||||
heartbeat_event = {
|
||||
"type": "heartbeat",
|
||||
"image": full_path,
|
||||
"timestamp": time.time(),
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
|
||||
# Additif, fallback gracieux : sans cet enrichissement, le serveur
|
||||
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
|
||||
# pas en continu sur poste Windows multi-écrans.
|
||||
try:
|
||||
from .vision.capturer import _enrich_with_monitor_info
|
||||
_enrich_with_monitor_info(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
|
||||
self.streamer.push_event(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
@@ -8,12 +8,73 @@ import os
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
|
||||
try:
|
||||
from screeninfo import get_monitors as _screeninfo_get_monitors
|
||||
_SCREENINFO_AVAILABLE = True
|
||||
except ImportError:
|
||||
_SCREENINFO_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_monitors_geometry() -> List[Dict[str, Any]]:
|
||||
"""Retourne la liste des monitors physiques avec leurs offsets.
|
||||
|
||||
Returns:
|
||||
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
|
||||
indisponible (le serveur tombera sur fallback composite).
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
monitors = _screeninfo_get_monitors()
|
||||
return [
|
||||
{
|
||||
"idx": i,
|
||||
"x": int(m.x),
|
||||
"y": int(m.y),
|
||||
"w": int(m.width),
|
||||
"h": int(m.height),
|
||||
"primary": bool(getattr(m, "is_primary", False)),
|
||||
}
|
||||
for i, m in enumerate(monitors)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_active_monitor_index() -> Optional[int]:
|
||||
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
|
||||
|
||||
Returns:
|
||||
int ou None si indéterminable.
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
import pyautogui # import paresseux : évite la dépendance dure
|
||||
cx, cy = pyautogui.position()
|
||||
for i, m in enumerate(_screeninfo_get_monitors()):
|
||||
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
|
||||
return i
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_with_monitor_info(payload: dict) -> dict:
|
||||
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
|
||||
if isinstance(payload, dict):
|
||||
payload["monitor_index"] = _get_active_monitor_index()
|
||||
payload["monitors_geometry"] = _get_monitors_geometry()
|
||||
return payload
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
@@ -72,7 +133,12 @@ class VisionCapturer:
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
|
||||
return {"full": full_path, "crop": crop_path}
|
||||
result = {"full": full_path, "crop": crop_path}
|
||||
|
||||
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
|
||||
_enrich_with_monitor_info(result)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Dual Capture: {e}")
|
||||
return {}
|
||||
|
||||
@@ -3,7 +3,9 @@ mss>=9.0.1 # Capture d'écran haute performance
|
||||
pynput>=1.7.7 # Clavier/Souris Cross-plateforme
|
||||
Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
logger = logging.getLogger("lea_ui.server_client")
|
||||
|
||||
|
||||
def _get_server_host() -> str:
|
||||
"""Recuperer l'adresse du serveur Linux.
|
||||
def _get_server_url() -> str:
|
||||
"""Recuperer l'URL du serveur RPA (avec /api/v1).
|
||||
|
||||
Ordre de resolution :
|
||||
1. Variable d'environnement RPA_SERVER_HOST
|
||||
2. Fichier de config agent_config.json (cle "server_host")
|
||||
3. Fallback localhost
|
||||
1. Import depuis agent_v1.config (source de verite unique)
|
||||
2. Variable d'environnement RPA_SERVER_URL
|
||||
3. Fallback http://localhost:5005/api/v1
|
||||
"""
|
||||
# 1. Variable d'environnement
|
||||
host = os.environ.get("RPA_SERVER_HOST", "").strip()
|
||||
if host:
|
||||
return host
|
||||
# 1. Import depuis config.py (source de verite)
|
||||
try:
|
||||
from agent_v1.config import SERVER_URL
|
||||
return SERVER_URL
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 2. Fichier de config
|
||||
config_paths = [
|
||||
os.path.join(os.path.dirname(__file__), "..", "agent_config.json"),
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"),
|
||||
]
|
||||
for config_path in config_paths:
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
host = cfg.get("server_host", "").strip()
|
||||
if host:
|
||||
return host
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
# 2. Variable d'environnement directe
|
||||
url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
|
||||
if url:
|
||||
return url
|
||||
|
||||
# 3. Fallback
|
||||
return "localhost"
|
||||
return "http://localhost:5005/api/v1"
|
||||
|
||||
|
||||
def _get_server_base(server_url: str) -> str:
|
||||
"""Extraire la base URL (sans /api/v1) pour les routes racine (/health)."""
|
||||
return server_url.rsplit("/api/v1", 1)[0]
|
||||
|
||||
|
||||
class LeaServerClient:
|
||||
@@ -67,12 +64,23 @@ class LeaServerClient:
|
||||
chat_port: int = 5004,
|
||||
stream_port: int = 5005,
|
||||
) -> None:
|
||||
self._host = server_host or _get_server_host()
|
||||
# URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1).
|
||||
# _stream_url = URL avec /api/v1 (pour les routes API)
|
||||
# _stream_base = URL sans /api/v1 (pour /health uniquement)
|
||||
self._stream_url = _get_server_url()
|
||||
self._stream_base = _get_server_base(self._stream_url)
|
||||
|
||||
# Extraire le host depuis l'URL pour le chat et pour l'affichage
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(self._stream_base)
|
||||
self._host = parsed.hostname or "localhost"
|
||||
except Exception:
|
||||
self._host = server_host or "localhost"
|
||||
|
||||
self._chat_port = chat_port
|
||||
self._stream_port = stream_port
|
||||
|
||||
self._chat_base = f"http://{self._host}:{self._chat_port}"
|
||||
self._stream_base = f"http://{self._host}:{self._stream_port}"
|
||||
|
||||
# Etat de connexion
|
||||
self._connected = False
|
||||
@@ -95,8 +103,8 @@ class LeaServerClient:
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
logger.info(
|
||||
"LeaServerClient initialise : chat=%s, stream=%s",
|
||||
self._chat_base, self._stream_base,
|
||||
"LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
|
||||
self._chat_base, self._stream_url, self._stream_base,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -146,7 +154,11 @@ class LeaServerClient:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Tester la connexion au serveur streaming (port 5005)."""
|
||||
"""Tester la connexion au serveur streaming (port 5005).
|
||||
|
||||
Le health check utilise _stream_base (sans /api/v1) car la route
|
||||
/health est a la racine du serveur FastAPI, pas sous /api/v1.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
@@ -219,7 +231,7 @@ class LeaServerClient:
|
||||
import requests
|
||||
headers = self._auth_headers()
|
||||
resp = requests.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/workflows",
|
||||
f"{self._stream_url}/traces/stream/workflows",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
@@ -276,7 +288,7 @@ class LeaServerClient:
|
||||
while self._polling:
|
||||
try:
|
||||
resp = req_lib.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
||||
f"{self._stream_url}/traces/stream/replay/next",
|
||||
params={"session_id": self._poll_session_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
@@ -310,7 +322,7 @@ class LeaServerClient:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replays",
|
||||
f"{self._stream_url}/traces/stream/replays",
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
@@ -338,7 +350,7 @@ class LeaServerClient:
|
||||
try:
|
||||
import requests
|
||||
requests.post(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replay/result",
|
||||
f"{self._stream_url}/traces/stream/replay/result",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"action_id": action_id,
|
||||
|
||||
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')})"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,31 @@ 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
|
||||
# QW1 — propager monitor_index et monitors_geometry depuis window_capture
|
||||
if "monitor_index" in window_capture:
|
||||
session.last_window_info["monitor_index"] = window_capture["monitor_index"]
|
||||
if "monitors_geometry" in window_capture:
|
||||
session.last_window_info["monitors_geometry"] = window_capture["monitors_geometry"]
|
||||
|
||||
# QW1 — propager monitor_index/monitors_geometry du payload event
|
||||
# (cas heartbeat enrichi sans window/window_title). Toujours
|
||||
# rafraîchir le focus actif (change souvent) et la géométrie
|
||||
# (l'utilisateur peut brancher/débrancher un écran).
|
||||
if "monitor_index" in event_data:
|
||||
session.last_window_info["monitor_index"] = event_data["monitor_index"]
|
||||
if "monitors_geometry" in event_data and event_data["monitors_geometry"]:
|
||||
session.last_window_info["monitors_geometry"] = event_data["monitors_geometry"]
|
||||
|
||||
# Accumuler les titres/apps pour le nommage automatique
|
||||
title = session.last_window_info.get("title", "").strip()
|
||||
app_name = session.last_window_info.get("app_name", "").strip()
|
||||
@@ -221,18 +312,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:
|
||||
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({
|
||||
# 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")),
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
154
agent_v0/server_v1/loop_detector.py
Normal file
154
agent_v0/server_v1/loop_detector.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# agent_v0/server_v1/loop_detector.py
|
||||
"""LoopDetector composite — détection de stagnation de Léa pendant un replay (QW2).
|
||||
|
||||
Trois signaux indépendants :
|
||||
- screen_static : N captures consécutives avec CLIP similarity > seuil
|
||||
- action_repeat : N actions consécutives identiques (type + coords)
|
||||
- retry_threshold : nombre de retries cumulés >= seuil
|
||||
|
||||
Un seul signal positif → verdict.detected=True. Le serveur bascule alors le
|
||||
replay en paused_need_help avec pause_reason explicite.
|
||||
|
||||
Désactivable via env var RPA_LOOP_DETECTOR_ENABLED=0.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoopVerdict:
|
||||
detected: bool = False
|
||||
reason: str = ""
|
||||
signal: str = "" # "screen_static" | "action_repeat" | "retry_threshold" | ""
|
||||
evidence: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool_enabled(name: str) -> bool:
|
||||
val = os.environ.get(name, "1").strip().lower()
|
||||
return val not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def _cosine_similarity(a, b) -> float:
|
||||
"""Similarité cosine entre deux vecteurs (listes ou np.array). Robuste vecteur nul."""
|
||||
import numpy as np
|
||||
av = np.asarray(a, dtype=np.float32).flatten()
|
||||
bv = np.asarray(b, dtype=np.float32).flatten()
|
||||
na, nb = float(np.linalg.norm(av)), float(np.linalg.norm(bv))
|
||||
if na < 1e-8 or nb < 1e-8:
|
||||
return 0.0
|
||||
return float(np.dot(av, bv) / (na * nb))
|
||||
|
||||
|
||||
class LoopDetector:
|
||||
def __init__(self, clip_embedder=None):
|
||||
self.clip_embedder = clip_embedder
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
state: Dict[str, Any],
|
||||
screenshots: List[Any],
|
||||
actions: List[Dict[str, Any]],
|
||||
) -> LoopVerdict:
|
||||
"""Évalue les 3 signaux. Retourne le premier déclenché.
|
||||
|
||||
Args:
|
||||
state: replay_state (utilisé pour retried_actions)
|
||||
screenshots: anneau d'embeddings CLIP (les N derniers)
|
||||
actions: anneau des N dernières actions exécutées
|
||||
"""
|
||||
if not _env_bool_enabled("RPA_LOOP_DETECTOR_ENABLED"):
|
||||
return LoopVerdict(detected=False)
|
||||
|
||||
# Signal A : screen_static
|
||||
verdict = self._check_screen_static(screenshots)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
# Signal B : action_repeat
|
||||
verdict = self._check_action_repeat(actions)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
# Signal C : retry_threshold
|
||||
verdict = self._check_retry_threshold(state)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
return LoopVerdict(detected=False)
|
||||
|
||||
def _check_screen_static(self, screenshots: List[Any]) -> LoopVerdict:
|
||||
n_required = _env_int("RPA_LOOP_SCREEN_STATIC_N", 4)
|
||||
threshold = _env_float("RPA_LOOP_SCREEN_STATIC_THRESHOLD", 0.99)
|
||||
|
||||
if self.clip_embedder is None or len(screenshots) < n_required:
|
||||
return LoopVerdict()
|
||||
|
||||
try:
|
||||
recent = screenshots[-n_required:]
|
||||
# Embed chaque capture via le CLIP embedder (peut lever)
|
||||
embeddings = [self.clip_embedder.embed_image(img) for img in recent]
|
||||
sims = [_cosine_similarity(embeddings[i], embeddings[i + 1])
|
||||
for i in range(len(embeddings) - 1)]
|
||||
min_sim = min(sims)
|
||||
if min_sim > threshold:
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="screen_static",
|
||||
evidence={"min_similarity": round(min_sim, 4),
|
||||
"n_captures": n_required,
|
||||
"threshold": threshold},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("LoopDetector signal_A erreur (%s) — signal inerte ce tick", e)
|
||||
return LoopVerdict()
|
||||
|
||||
def _check_action_repeat(self, actions: List[Dict[str, Any]]) -> LoopVerdict:
|
||||
n_required = _env_int("RPA_LOOP_ACTION_REPEAT_N", 3)
|
||||
if len(actions) < n_required:
|
||||
return LoopVerdict()
|
||||
recent = actions[-n_required:]
|
||||
|
||||
def _signature(a: Dict[str, Any]) -> tuple:
|
||||
return (a.get("type"), a.get("x_pct"), a.get("y_pct"))
|
||||
|
||||
sigs = [_signature(a) for a in recent]
|
||||
if all(s == sigs[0] for s in sigs):
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="action_repeat",
|
||||
evidence={"signature": sigs[0], "count": n_required},
|
||||
)
|
||||
return LoopVerdict()
|
||||
|
||||
def _check_retry_threshold(self, state: Dict[str, Any]) -> LoopVerdict:
|
||||
threshold = _env_int("RPA_LOOP_RETRY_THRESHOLD", 3)
|
||||
retried = int(state.get("retried_actions", 0))
|
||||
if retried >= threshold:
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="retry_threshold",
|
||||
evidence={"retried_actions": retried, "threshold": threshold},
|
||||
)
|
||||
return LoopVerdict()
|
||||
99
agent_v0/server_v1/monitor_router.py
Normal file
99
agent_v0/server_v1/monitor_router.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# agent_v0/server_v1/monitor_router.py
|
||||
"""MonitorRouter — résolution de l'écran cible pour le replay (QW1).
|
||||
|
||||
Stratégie en cascade :
|
||||
1. action.monitor_index (hérité de la session source) → cible cet écran
|
||||
2. session.last_focused_monitor (focus actif vu en dernier heartbeat) → fallback
|
||||
3. composite (offset 0, 0) → backward compat
|
||||
|
||||
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorTarget:
|
||||
"""Représente l'écran cible résolu pour une action de replay."""
|
||||
idx: int
|
||||
offset_x: int
|
||||
offset_y: int
|
||||
w: int
|
||||
h: int
|
||||
source: str # "action" | "focus" | "composite_fallback"
|
||||
|
||||
|
||||
_COMPOSITE_FALLBACK = MonitorTarget(
|
||||
idx=-1,
|
||||
offset_x=0,
|
||||
offset_y=0,
|
||||
w=0,
|
||||
h=0,
|
||||
source="composite_fallback",
|
||||
)
|
||||
|
||||
|
||||
def _find_monitor(geometry: List[Dict[str, Any]], idx: int) -> Optional[Dict[str, Any]]:
|
||||
"""Retourne le monitor d'index donné, ou None si absent."""
|
||||
for m in geometry:
|
||||
if m.get("idx") == idx:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _to_target(monitor: Dict[str, Any], source: str) -> MonitorTarget:
|
||||
return MonitorTarget(
|
||||
idx=int(monitor["idx"]),
|
||||
offset_x=int(monitor.get("x", 0)),
|
||||
offset_y=int(monitor.get("y", 0)),
|
||||
w=int(monitor.get("w", 0)),
|
||||
h=int(monitor.get("h", 0)),
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def resolve_target_monitor(
|
||||
action: Dict[str, Any],
|
||||
session_state: Dict[str, Any],
|
||||
) -> MonitorTarget:
|
||||
"""Résout l'écran cible d'une action de replay.
|
||||
|
||||
Args:
|
||||
action: Dict de l'action (peut contenir `monitor_index`).
|
||||
session_state: État de la session (doit contenir `monitors_geometry`
|
||||
et `last_focused_monitor`).
|
||||
|
||||
Returns:
|
||||
MonitorTarget avec l'offset à appliquer aux coordonnées de grounding.
|
||||
"""
|
||||
geometry: List[Dict[str, Any]] = session_state.get("monitors_geometry") or []
|
||||
|
||||
# 1. Cible explicite via action
|
||||
explicit_idx = action.get("monitor_index")
|
||||
if explicit_idx is not None and geometry:
|
||||
m = _find_monitor(geometry, int(explicit_idx))
|
||||
if m is not None:
|
||||
return _to_target(m, source="action")
|
||||
# Index invalide → on tombe sur le fallback focus
|
||||
logger.warning(
|
||||
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
|
||||
int(explicit_idx), [g.get("idx") for g in geometry],
|
||||
)
|
||||
|
||||
# 2. Fallback focus actif
|
||||
focused_idx = session_state.get("last_focused_monitor")
|
||||
if focused_idx is not None and geometry:
|
||||
m = _find_monitor(geometry, int(focused_idx))
|
||||
if m is not None:
|
||||
return _to_target(m, source="focus")
|
||||
logger.warning(
|
||||
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
|
||||
int(focused_idx), [g.get("idx") for g in geometry],
|
||||
)
|
||||
|
||||
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
|
||||
return _COMPOSITE_FALLBACK
|
||||
@@ -15,6 +15,7 @@ Extrait de api_stream.py pour clarifier l'architecture.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -32,8 +33,32 @@ _ALLOWED_ACTION_TYPES = {
|
||||
"click", "type", "key_combo", "scroll", "wait",
|
||||
"file_open", "file_save", "file_close", "file_new", "file_dialog",
|
||||
"double_click", "right_click", "drag",
|
||||
"verify_screen", # Replay hybride : vérification visuelle entre groupes
|
||||
"verify_screen", # Replay hybride : vérification visuelle entre groupes
|
||||
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
|
||||
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
|
||||
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
|
||||
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
|
||||
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
|
||||
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
|
||||
"llm_generate", # Génération texte libre côté serveur → variable workflow
|
||||
"paste_and_execute", # Bypass NoMachine : ydotool Ctrl+V+Ctrl+Enter dans VM via SSH
|
||||
}
|
||||
|
||||
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
|
||||
# Le pipeline /replay/next les traite en boucle interne et passe à l'action
|
||||
# suivante jusqu'à trouver une action visuelle (à transmettre au client).
|
||||
_SERVER_SIDE_ACTION_TYPES = {
|
||||
"extract_text",
|
||||
"extract_table",
|
||||
"t2a_decision",
|
||||
"llm_generate",
|
||||
"_concat_text_vars",
|
||||
"paste_and_execute",
|
||||
}
|
||||
|
||||
# Pause par défaut entre Ctrl+End/Home et la capture suivante (ms).
|
||||
# Configurable par step via parameters.scroll_pause_ms ; default ici.
|
||||
SCROLL_PAUSE_MS = 500
|
||||
_MAX_ACTION_TEXT_LENGTH = 10000
|
||||
_MAX_KEYS_PER_COMBO = 10
|
||||
# Touches autorisées dans les key_combo (modificateurs + touches spéciales + caractères simples)
|
||||
@@ -852,6 +877,87 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
|
||||
keys = [action_params["key"]]
|
||||
normalized["keys"] = keys
|
||||
|
||||
elif action_type == "pause_for_human":
|
||||
normalized["type"] = "pause_for_human"
|
||||
normalized["parameters"] = {
|
||||
"message": action_params.get("message", "Validation requise"),
|
||||
}
|
||||
return [normalized] # pas de target/coords pour cette action logique
|
||||
|
||||
elif action_type == "extract_text":
|
||||
normalized["type"] = "extract_text"
|
||||
normalized["parameters"] = {
|
||||
"output_var": action_params.get("output_var", "extracted_text"),
|
||||
"paragraph": bool(action_params.get("paragraph", True)),
|
||||
}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "paste_and_execute":
|
||||
normalized["type"] = "paste_and_execute"
|
||||
normalized["parameters"] = {}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "extract_table":
|
||||
normalized["type"] = "extract_table"
|
||||
normalized["parameters"] = {
|
||||
"output_var": action_params.get("output_var", "table_rows"),
|
||||
"pattern": action_params.get("pattern"),
|
||||
"limit": action_params.get("limit"),
|
||||
"region": action_params.get("region"),
|
||||
}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "extract_text_scroll":
|
||||
# Expansion en séquence : OCR(top) → Ctrl+End → wait → OCR(bottom)
|
||||
# → concat(top, bottom → final) → Ctrl+Home.
|
||||
# variable_name (préféré) ou output_var (compat extract_text).
|
||||
final_var = (
|
||||
action_params.get("variable_name")
|
||||
or action_params.get("output_var")
|
||||
or "extracted_text"
|
||||
)
|
||||
paragraph = bool(action_params.get("paragraph", True))
|
||||
# Pause après scroll Ctrl+End — configurable au step.
|
||||
# Default 500ms (Wikipedia) ; cible 1500-2000ms pour DPI Citrix lent.
|
||||
try:
|
||||
scroll_pause = int(action_params.get("scroll_pause_ms", SCROLL_PAUSE_MS))
|
||||
except (TypeError, ValueError):
|
||||
scroll_pause = SCROLL_PAUSE_MS
|
||||
# Variables internes nommées par préfixe : invisibles à l'utilisateur.
|
||||
# Préfixe `_` pour signaler "interne" et éviter collision.
|
||||
top_var = f"__{final_var}_top"
|
||||
bottom_var = f"__{final_var}_bottom"
|
||||
|
||||
return _expand_extract_text_scroll(
|
||||
base, final_var, top_var, bottom_var, paragraph,
|
||||
scroll_pause_ms=scroll_pause,
|
||||
)
|
||||
|
||||
elif action_type == "t2a_decision":
|
||||
normalized["type"] = "t2a_decision"
|
||||
normalized["parameters"] = {
|
||||
"input_template": action_params.get("input_template", ""),
|
||||
"output_var": action_params.get("output_var", "t2a_result"),
|
||||
"model": action_params.get("model"),
|
||||
}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "llm_generate":
|
||||
normalized["type"] = "llm_generate"
|
||||
normalized["parameters"] = {
|
||||
"prompt": action_params.get("prompt", ""),
|
||||
"context": action_params.get("context", ""),
|
||||
"output_var": (
|
||||
action_params.get("output_var")
|
||||
or action_params.get("variable_name")
|
||||
or "generated_text"
|
||||
),
|
||||
"model": action_params.get("model"),
|
||||
}
|
||||
if action_params.get("temperature") is not None:
|
||||
normalized["parameters"]["temperature"] = action_params.get("temperature")
|
||||
return [normalized]
|
||||
|
||||
else:
|
||||
logger.warning(f"Type d'action inconnu : {action_type}")
|
||||
return []
|
||||
@@ -886,6 +992,521 @@ def _substitute_variables(text: str, params: Dict[str, Any], defaults: Dict[str,
|
||||
return re.sub(r'\$\{(\w+)\}', replacer, text)
|
||||
|
||||
|
||||
# Regex pour le templating runtime : {{var}} ou {{var.champ}} ou {{var.champ.sous}}
|
||||
_RUNTIME_VAR_PATTERN = re.compile(r'\{\{\s*(\w+)(?:\.([\w.]+))?\s*\}\}')
|
||||
|
||||
|
||||
def _resolve_runtime_vars_in_str(text: str, variables: Dict[str, Any]) -> str:
|
||||
"""Remplace {{var}} et {{var.field}} par leur valeur depuis le dict variables.
|
||||
|
||||
Variables/champs absents : laissés tels quels (ne casse pas le pipeline).
|
||||
Pour les valeurs non-str (dict, list), str() est appelé.
|
||||
"""
|
||||
def replacer(match):
|
||||
var_name = match.group(1)
|
||||
path = match.group(2)
|
||||
if var_name not in variables:
|
||||
return match.group(0)
|
||||
value = variables[var_name]
|
||||
if path:
|
||||
for field in path.split('.'):
|
||||
if isinstance(value, dict) and field in value:
|
||||
value = value[field]
|
||||
else:
|
||||
return match.group(0)
|
||||
return str(value)
|
||||
|
||||
return _RUNTIME_VAR_PATTERN.sub(replacer, text)
|
||||
|
||||
|
||||
def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
|
||||
"""Résout récursivement les {{var}} et {{var.field}} dans une valeur.
|
||||
|
||||
Supporte str, dict, list. Les autres types sont retournés tels quels.
|
||||
Si variables est vide ou None, value est retournée inchangée.
|
||||
"""
|
||||
if not variables:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return _resolve_runtime_vars_in_str(value, variables)
|
||||
if isinstance(value, dict):
|
||||
return {k: _resolve_runtime_vars(v, variables) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_resolve_runtime_vars(item, variables) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
|
||||
# =========================================================================
|
||||
|
||||
def _normalize_ollama_endpoint(raw_url: str) -> str:
|
||||
"""Normalise une URL Ollama pour les clients qui attendent l'endpoint racine.
|
||||
|
||||
`OLLAMA_URL` est parfois configuré vers `/api/generate` alors que
|
||||
`LLMActionHandler` attend la racine `http://host:port`.
|
||||
"""
|
||||
endpoint = (raw_url or "http://localhost:11434").strip().rstrip("/")
|
||||
for suffix in ("/api/generate", "/api/chat"):
|
||||
if endpoint.endswith(suffix):
|
||||
return endpoint[: -len(suffix)]
|
||||
return endpoint
|
||||
|
||||
def _handle_extract_text_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
session_id: str,
|
||||
last_heartbeat: Dict[str, Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Traite une action extract_text côté serveur. Stocke le texte OCRisé dans
|
||||
replay_state["variables"][output_var]. Retourne True si succès.
|
||||
|
||||
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke "" et retourne
|
||||
False (le pipeline continue, pas de blocage).
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
# Compatibilité VWB : "variable_name" (VWB) et "output_var" (agent libre)
|
||||
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_text").strip()
|
||||
paragraph = bool(params.get("paragraph", True))
|
||||
|
||||
# Source prioritaire : screenshot envoyé par l'agent après la dernière action.
|
||||
# Si c'est du base64, on le sauvegarde dans un fichier temp pour l'OCR.
|
||||
# Fallback : heartbeat de fond (vrai chemin serveur, via "bg_{machine_id}").
|
||||
path = None
|
||||
raw_screenshot = replay_state.get("last_screenshot") or ""
|
||||
if raw_screenshot:
|
||||
if raw_screenshot.startswith("data:"):
|
||||
# base64 → fichier temp
|
||||
try:
|
||||
import base64, tempfile
|
||||
header, b64data = raw_screenshot.split(",", 1)
|
||||
suffix = ".jpg" if "jpeg" in header else ".png"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.write(base64.b64decode(b64data))
|
||||
tmp.close()
|
||||
path = tmp.name
|
||||
except Exception as e:
|
||||
logger.warning("extract_text: décodage base64 screenshot échoué: %s", e)
|
||||
elif os.path.isfile(raw_screenshot):
|
||||
path = raw_screenshot
|
||||
|
||||
if not path:
|
||||
machine_id = replay_state.get("machine_id", "")
|
||||
bg_session = f"bg_{machine_id}" if machine_id and machine_id != "default" else None
|
||||
heartbeat = (
|
||||
last_heartbeat.get(session_id)
|
||||
or (last_heartbeat.get(bg_session) if bg_session else None)
|
||||
or {}
|
||||
)
|
||||
path = heartbeat.get("path")
|
||||
text = ""
|
||||
|
||||
if path:
|
||||
try:
|
||||
from core.llm import extract_text_from_image
|
||||
text = extract_text_from_image(path, paragraph=paragraph)
|
||||
except Exception as e:
|
||||
logger.warning("extract_text OCR échoué (%s) — variable '%s' = ''", e, output_var)
|
||||
else:
|
||||
logger.warning(
|
||||
"extract_text : pas de heartbeat pour session %s — variable '%s' = ''",
|
||||
session_id, output_var,
|
||||
)
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = text
|
||||
logger.info(
|
||||
"extract_text → variable '%s' (%d chars) replay %s",
|
||||
output_var, len(text), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return bool(text)
|
||||
|
||||
|
||||
def _handle_extract_table_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
session_id: str,
|
||||
last_heartbeat: Dict[str, Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Traite une action extract_table côté serveur. OCR + filtre regex pour
|
||||
retourner une liste structurée (ex : IPP d'un tableau de patients) qui
|
||||
pourra être bouclée par le templating ${patients[i]}.
|
||||
|
||||
Paramètres reconnus :
|
||||
output_var : nom de variable runtime (default "table_rows")
|
||||
pattern : regex à matcher sur chaque token OCR (ex : r"^25\\d{6}$")
|
||||
limit : nb max d'entrées à retourner
|
||||
region : (x, y, w, h) en pixels pour cropper avant OCR
|
||||
(None = image entière)
|
||||
|
||||
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke [] et
|
||||
retourne False — le pipeline continue.
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
output_var = (params.get("output_var") or params.get("variable_name") or "table_rows").strip()
|
||||
pattern = params.get("pattern") or None
|
||||
limit = params.get("limit")
|
||||
region = params.get("region") or None
|
||||
if isinstance(limit, str):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except ValueError:
|
||||
limit = None
|
||||
|
||||
# Source : screenshot du heartbeat (idem extract_text)
|
||||
path = None
|
||||
raw_screenshot = replay_state.get("last_screenshot") or ""
|
||||
if raw_screenshot:
|
||||
if raw_screenshot.startswith("data:"):
|
||||
try:
|
||||
import base64, tempfile
|
||||
header, b64data = raw_screenshot.split(",", 1)
|
||||
suffix = ".jpg" if "jpeg" in header else ".png"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.write(base64.b64decode(b64data))
|
||||
tmp.close()
|
||||
path = tmp.name
|
||||
except Exception as e:
|
||||
logger.warning("extract_table: décodage base64 screenshot échoué: %s", e)
|
||||
elif os.path.isfile(raw_screenshot):
|
||||
path = raw_screenshot
|
||||
|
||||
if not path:
|
||||
machine_id = replay_state.get("machine_id", "")
|
||||
bg_session = f"bg_{machine_id}" if machine_id and machine_id != "default" else None
|
||||
heartbeat = (
|
||||
last_heartbeat.get(session_id)
|
||||
or (last_heartbeat.get(bg_session) if bg_session else None)
|
||||
or {}
|
||||
)
|
||||
path = heartbeat.get("path")
|
||||
|
||||
rows: list = []
|
||||
if path:
|
||||
try:
|
||||
from core.llm import extract_table_from_image
|
||||
rows = extract_table_from_image(
|
||||
path,
|
||||
region=tuple(region) if region else None,
|
||||
pattern=pattern,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"extract_table OCR échoué (%s) — variable '%s' = []", e, output_var,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"extract_table : pas de heartbeat pour session %s — variable '%s' = []",
|
||||
session_id, output_var,
|
||||
)
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = rows
|
||||
logger.info(
|
||||
"extract_table → variable '%s' (%d entrées, pattern=%r, limit=%s) replay %s",
|
||||
output_var, len(rows), pattern, limit, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return bool(rows)
|
||||
|
||||
|
||||
def _handle_t2a_decision_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Traite une action t2a_decision côté serveur. Stocke le résultat JSON
|
||||
dans replay_state["variables"][output_var]. Retourne True si succès.
|
||||
|
||||
Le DPI à analyser vient de action.parameters.input_template (déjà résolu
|
||||
par _resolve_runtime_vars donc les {{var}} sont remplis).
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
output_var = (params.get("output_var") or "t2a_result").strip()
|
||||
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
|
||||
model = params.get("model") or None # None → DEFAULT_MODEL
|
||||
|
||||
# Bypass LLM : si static_result est fourni dans les params, on l'utilise
|
||||
# tel quel comme résultat. Utile pour les démos déterministes (pas de
|
||||
# hallucination, pas de latence, pas de truncation de prompt).
|
||||
static_result = params.get("static_result")
|
||||
if isinstance(static_result, dict) and static_result.get("decision"):
|
||||
replay_state.setdefault("variables", {})[output_var] = static_result
|
||||
logger.info(
|
||||
"t2a_decision (STATIC) → variable '%s' decision=%s replay %s",
|
||||
output_var, static_result.get("decision"), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return True
|
||||
|
||||
if not dpi_text:
|
||||
logger.warning(
|
||||
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
|
||||
)
|
||||
replay_state.setdefault("variables", {})[output_var] = {
|
||||
"decision": "INDETERMINE",
|
||||
"justification": "DPI vide ou non extrait",
|
||||
"confiance": "faible",
|
||||
"_error": "empty_input",
|
||||
}
|
||||
return False
|
||||
|
||||
try:
|
||||
from core.llm import analyze_dpi, DEFAULT_MODEL, build_dpi_enriched
|
||||
# Enrichissement déterministe avant LLM : injection FAITS_CALCULÉS en
|
||||
# tête (durée, âge, CCMU/GEMSA/priorité…) pour neutraliser les
|
||||
# hallucinations de durée (cf. bug "23h" MOREL). La metadata est
|
||||
# capturée pour les garde-fous serveur (commit 2 — Python ↔ LLM).
|
||||
dpi_enriched, metadata = build_dpi_enriched(dpi_text)
|
||||
logger.info(
|
||||
"[build_dpi_enriched] duree_python=%sh decision_terrain=%r warnings=%d",
|
||||
metadata.get("duree_heures_decimales"),
|
||||
metadata.get("decision_terrain"),
|
||||
len(metadata.get("parsing_warnings", [])),
|
||||
)
|
||||
result = analyze_dpi(dpi_enriched, model=model or DEFAULT_MODEL)
|
||||
except Exception as e:
|
||||
logger.warning("t2a_decision : analyze_dpi exception %s", e)
|
||||
result = {
|
||||
"decision": "INDETERMINE",
|
||||
"justification": f"Erreur analyse : {e}",
|
||||
"confiance": "faible",
|
||||
"_error": str(e),
|
||||
}
|
||||
|
||||
# Si parse_error, injecter des valeurs de fallback pour que les templates restent lisibles
|
||||
if result.get("_parse_error"):
|
||||
raw_preview = result.get("_raw", "")[:200]
|
||||
logger.warning("t2a_decision parse_error — raw: %s", raw_preview)
|
||||
result.setdefault("decision", "INDETERMINE")
|
||||
result.setdefault("decision_court", "À vérifier")
|
||||
result.setdefault("preuve_critere1", "Analyse non disponible (erreur LLM)")
|
||||
result.setdefault("preuve_critere2", "Analyse non disponible (erreur LLM)")
|
||||
result.setdefault("preuve_critere3", "Analyse non disponible (erreur LLM)")
|
||||
result.setdefault("justification", f"Réponse LLM non parsable : {raw_preview}")
|
||||
result.setdefault("confiance", "faible")
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = result
|
||||
decision = result.get("decision", "?")
|
||||
elapsed = result.get("_elapsed_s", "?")
|
||||
logger.info(
|
||||
"t2a_decision → variable '%s' decision=%s (%ss) replay %s",
|
||||
output_var, decision, elapsed, replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return "_error" not in result and not result.get("_parse_error")
|
||||
|
||||
|
||||
def _handle_llm_generate_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Traite une action llm_generate côté serveur.
|
||||
|
||||
Stocke le texte généré dans replay_state["variables"][output_var].
|
||||
Les paramètres `prompt` et `context` sont déjà résolus via le templating
|
||||
runtime avant d'arriver ici.
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
output_var = (
|
||||
params.get("output_var")
|
||||
or params.get("variable_name")
|
||||
or "generated_text"
|
||||
).strip()
|
||||
|
||||
# Bypass LLM : si static_text est fourni dans les params, on l'utilise
|
||||
# tel quel. Utile pour les démos déterministes.
|
||||
static_text = params.get("static_text")
|
||||
if isinstance(static_text, str) and static_text.strip():
|
||||
replay_state.setdefault("variables", {})[output_var] = static_text
|
||||
logger.info(
|
||||
"llm_generate (STATIC) → variable '%s' (%d chars) replay %s",
|
||||
output_var, len(static_text), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return True
|
||||
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
context = str(params.get("context") or "")
|
||||
model = params.get("model") or None
|
||||
|
||||
temperature = None
|
||||
if params.get("temperature") is not None:
|
||||
try:
|
||||
temperature = float(params.get("temperature"))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"llm_generate : temperature invalide %r — fallback valeur par défaut",
|
||||
params.get("temperature"),
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
logger.warning(
|
||||
"llm_generate : prompt vide — variable '%s' = ''",
|
||||
output_var,
|
||||
)
|
||||
replay_state.setdefault("variables", {})[output_var] = ""
|
||||
return False
|
||||
|
||||
generated = ""
|
||||
try:
|
||||
from core.execution.llm_actions import LLMActionHandler
|
||||
|
||||
handler = LLMActionHandler(
|
||||
ollama_endpoint=_normalize_ollama_endpoint(
|
||||
os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
generated = handler.generate_text(
|
||||
prompt=prompt,
|
||||
context=context,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
).strip()
|
||||
except Exception as e:
|
||||
logger.warning("llm_generate : génération échouée (%s) — variable '%s' = ''", e, output_var)
|
||||
|
||||
replay_state.setdefault("variables", {})[output_var] = generated
|
||||
logger.info(
|
||||
"llm_generate → variable '%s' (%d chars, model=%s) replay %s",
|
||||
output_var,
|
||||
len(generated),
|
||||
model or "default",
|
||||
replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return bool(generated)
|
||||
|
||||
|
||||
def _handle_concat_text_vars_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Traite une action serveur interne `_concat_text_vars`.
|
||||
|
||||
Concatène deux variables runtime existantes (top_var + separator + bottom_var)
|
||||
et écrit le résultat dans output_var. Variables manquantes traitées comme "".
|
||||
|
||||
Action générée par l'expansion de `extract_text_scroll` ; pas exposée à
|
||||
l'utilisateur final. Robuste aux échecs OCR amont (l'une ou l'autre var vide).
|
||||
"""
|
||||
params = action.get("parameters") or {}
|
||||
top_var = (params.get("top_var") or "").strip()
|
||||
bottom_var = (params.get("bottom_var") or "").strip()
|
||||
output_var = (params.get("output_var") or "extracted_text").strip()
|
||||
separator = params.get("separator", "\n\n")
|
||||
|
||||
variables = replay_state.setdefault("variables", {})
|
||||
top_text = str(variables.get(top_var, "") or "")
|
||||
bottom_text = str(variables.get(bottom_var, "") or "")
|
||||
|
||||
# Si les deux sont vides, output reste "" (cohérent avec _handle_extract_text_action).
|
||||
# Si un seul est vide, on évite un séparateur inutile en début/fin.
|
||||
if top_text and bottom_text:
|
||||
merged = top_text + separator + bottom_text
|
||||
else:
|
||||
merged = top_text or bottom_text
|
||||
|
||||
variables[output_var] = merged
|
||||
# Nettoyage des variables internes pour ne pas polluer l'état.
|
||||
if top_var.startswith("__"):
|
||||
variables.pop(top_var, None)
|
||||
if bottom_var.startswith("__"):
|
||||
variables.pop(bottom_var, None)
|
||||
|
||||
logger.info(
|
||||
"extract_text_scroll concat → variable '%s' (%d chars) replay %s",
|
||||
output_var, len(merged), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return bool(merged)
|
||||
|
||||
|
||||
def _handle_paste_and_execute_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Action serveur : invoque scripts/paste_and_execute_linuxdb.sh pour
|
||||
déclencher Ctrl+V + Ctrl+Enter dans DBeaver de la VM via ydotool.
|
||||
Bypasse Léa/NoMachine (Ctrl mangé par NoMachine passive grab).
|
||||
Cf. handoff 2026-05-16_handoff_ydotool_clipboard.md.
|
||||
"""
|
||||
import subprocess
|
||||
script_path = "/home/dom/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[script_path],
|
||||
timeout=30,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"paste_and_execute échoué (rc=%d) stderr=%s",
|
||||
result.returncode, (result.stderr or "")[:500],
|
||||
)
|
||||
return False
|
||||
logger.info("paste_and_execute OK replay %s", replay_state.get("replay_id", "?"))
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("paste_and_execute timeout (30s)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("paste_and_execute exception : %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _expand_extract_text_scroll(
|
||||
base: Dict[str, Any],
|
||||
final_var: str,
|
||||
top_var: str,
|
||||
bottom_var: str,
|
||||
paragraph: bool,
|
||||
scroll_pause_ms: int = SCROLL_PAUSE_MS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Expanse un step extract_text_scroll en séquence d'actions atomiques.
|
||||
|
||||
Séquence générée :
|
||||
1. extract_text(top_var) — OCR zone visible (haut de page)
|
||||
2. key_combo(ctrl+end) — scroll bas (côté client Léa V1)
|
||||
3. wait(scroll_pause_ms) — laisse DOM/UI se redessiner
|
||||
4. extract_text(bottom_var) — OCR zone visible (bas de page)
|
||||
5. _concat_text_vars(top, bottom→final) — action serveur interne
|
||||
6. key_combo(ctrl+home) — remet en haut
|
||||
|
||||
Toutes les sous-actions héritent de `base` (edge_id, from_node, to_node)
|
||||
pour la traçabilité. Chaque action obtient un action_id unique.
|
||||
|
||||
`scroll_pause_ms` : configurable au step (défaut SCROLL_PAUSE_MS=500ms).
|
||||
"""
|
||||
def _new_action() -> Dict[str, Any]:
|
||||
return {**base, "action_id": f"act_{uuid.uuid4().hex[:8]}"}
|
||||
|
||||
a1 = _new_action()
|
||||
a1["type"] = "extract_text"
|
||||
a1["parameters"] = {"output_var": top_var, "paragraph": paragraph}
|
||||
|
||||
a2 = _new_action()
|
||||
a2["type"] = "key_combo"
|
||||
a2["keys"] = ["ctrl", "end"]
|
||||
|
||||
a3 = _new_action()
|
||||
a3["type"] = "wait"
|
||||
a3["duration_ms"] = scroll_pause_ms
|
||||
|
||||
a4 = _new_action()
|
||||
a4["type"] = "extract_text"
|
||||
a4["parameters"] = {"output_var": bottom_var, "paragraph": paragraph}
|
||||
|
||||
a5 = _new_action()
|
||||
a5["type"] = "_concat_text_vars"
|
||||
a5["parameters"] = {
|
||||
"top_var": top_var,
|
||||
"bottom_var": bottom_var,
|
||||
"output_var": final_var,
|
||||
"separator": "\n\n",
|
||||
}
|
||||
|
||||
a6 = _new_action()
|
||||
a6["type"] = "key_combo"
|
||||
a6["keys"] = ["ctrl", "home"]
|
||||
|
||||
return [a1, a2, a3, a4, a5, a6]
|
||||
|
||||
|
||||
def _expand_compound_steps(
|
||||
steps: List[Dict[str, Any]], base: Dict[str, Any], params: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1208,6 +1829,18 @@ def _create_replay_state(
|
||||
# Champs pour pause supervisée (target_not_found)
|
||||
"failed_action": None, # Contexte de l'action en echec (quand paused_need_help)
|
||||
"pause_message": None, # Message a afficher a l'utilisateur
|
||||
# Variables d'exécution produites en cours de workflow (extract_text,
|
||||
# t2a_decision, etc.). Résolues via templating {{var}} ou {{var.field}}
|
||||
# dans les paramètres des actions suivantes.
|
||||
"variables": {},
|
||||
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
|
||||
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
|
||||
"_action_history": [], # N dernières actions exécutées (signature)
|
||||
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
|
||||
"safety_checks": [], # liste produite par SafetyChecksProvider
|
||||
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
|
||||
"pause_reason": "", # "loop_detected" | "" pour V1
|
||||
"pause_payload": None, # payload complet pour debug/audit
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -26,6 +26,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.grounding.bbox_parser import parse_bbox_to_norm, parse_bbox_to_norm_validated
|
||||
|
||||
logger = logging.getLogger("api_stream")
|
||||
|
||||
|
||||
@@ -125,7 +127,7 @@ def _resolve_by_template_matching(
|
||||
best_scale = 1.0
|
||||
best_anchor_size = (aw, ah)
|
||||
|
||||
for scale in [1.0, 0.9, 1.1, 0.8, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 1.75, 2.0]:
|
||||
for scale in [1.0, 0.95, 0.93, 0.9, 1.05, 1.1, 0.85, 0.8, 1.15, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 0.4, 0.35, 0.3, 0.25, 1.75, 2.0]:
|
||||
if scale != 1.0:
|
||||
new_w = int(aw * scale)
|
||||
new_h = int(ah * scale)
|
||||
@@ -833,51 +835,8 @@ def _resolve_by_grounding(
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Parser la réponse — supporte bbox_2d en pixels, JSON %, arrays bruts
|
||||
x_pct, y_pct = None, None
|
||||
|
||||
# Format 1 : bbox_2d en pixels [x, y] ou [x1, y1, x2, y2]
|
||||
bbox_match = re.search(r'"bbox_2d"\s*:\s*\[([^\]]+)\]', content)
|
||||
if bbox_match:
|
||||
coords = [float(v.strip()) for v in bbox_match.group(1).split(",")]
|
||||
if len(coords) == 2:
|
||||
x_pct = coords[0] / small_w
|
||||
y_pct = coords[1] / small_h
|
||||
elif len(coords) >= 4:
|
||||
x_pct = (coords[0] + coords[2]) / 2 / small_w
|
||||
y_pct = (coords[1] + coords[3]) / 2 / small_h
|
||||
|
||||
# Format 2 : JSON {"x": 0.XX, "y": 0.YY}
|
||||
if x_pct is None:
|
||||
json_match = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content)
|
||||
if json_match:
|
||||
x_val, y_val = float(json_match.group(1)), float(json_match.group(2))
|
||||
# Si > 1, c'est en pixels
|
||||
if x_val > 1:
|
||||
x_pct = x_val / small_w
|
||||
y_pct = y_val / small_h
|
||||
else:
|
||||
x_pct = x_val
|
||||
y_pct = y_val
|
||||
|
||||
# Format 3 : {"x_pct": 0.XX, "y_pct": 0.YY}
|
||||
if x_pct is None:
|
||||
pct_match = re.search(r'"x_pct"\s*:\s*([\d.]+).*?"y_pct"\s*:\s*([\d.]+)', content)
|
||||
if pct_match:
|
||||
x_pct = float(pct_match.group(1))
|
||||
y_pct = float(pct_match.group(2))
|
||||
|
||||
# Format 4 : array brut [x1, y1, x2, y2] ou [x, y]
|
||||
if x_pct is None:
|
||||
arr_match = re.search(r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]', content)
|
||||
if arr_match:
|
||||
vals = [float(v) for v in arr_match.groups() if v is not None]
|
||||
if len(vals) >= 4:
|
||||
x_pct = (vals[0] + vals[2]) / 2 / small_w
|
||||
y_pct = (vals[1] + vals[3]) / 2 / small_h
|
||||
elif len(vals) == 2:
|
||||
x_pct = vals[0] / small_w
|
||||
y_pct = vals[1] / small_h
|
||||
# Parser la réponse — délégué à core.grounding.bbox_parser
|
||||
x_pct, y_pct = parse_bbox_to_norm(content, small_w, small_h)
|
||||
|
||||
if x_pct is None or y_pct is None:
|
||||
# Fallback multi-image : screenshot + crop → grounding sans description
|
||||
@@ -900,21 +859,12 @@ def _resolve_by_grounding(
|
||||
content2 = resp2.json().get("message", {}).get("content", "")
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Parser tous les formats
|
||||
arr2 = re.search(r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]', content2)
|
||||
if arr2:
|
||||
vals = [float(v) for v in arr2.groups() if v is not None]
|
||||
if len(vals) >= 4:
|
||||
x_pct = (vals[0] + vals[2]) / 2 / small_w
|
||||
y_pct = (vals[1] + vals[3]) / 2 / small_h
|
||||
elif len(vals) == 2:
|
||||
x_pct = vals[0] / small_w
|
||||
y_pct = vals[1] / small_h
|
||||
if x_pct is None:
|
||||
json2 = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content2)
|
||||
if json2:
|
||||
x_pct = float(json2.group(1)) / small_w
|
||||
y_pct = float(json2.group(2)) / small_h
|
||||
# Parser la réponse — délégué à core.grounding.bbox_parser
|
||||
# Restriction aux 2 formats attendus par le prompt retry multi-image
|
||||
# (cf. prompt_mi qui demande {"x": NNN, "y": NNN} en pixels).
|
||||
x_pct, y_pct = parse_bbox_to_norm(
|
||||
content2, small_w, small_h, formats={"xy_json", "raw_array"}
|
||||
)
|
||||
if x_pct is not None:
|
||||
logger.info("Grounding multi-image OK (%.1fs)", elapsed)
|
||||
except Exception as e:
|
||||
@@ -1746,6 +1696,49 @@ def _resolve_target_sync(
|
||||
)
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Étape 0.5 : OCR direct (hybrid_text_direct) — chemin rapide
|
||||
# ---------------------------------------------------------------
|
||||
# Si on a un texte cible non vide, le localiser par OCR direct
|
||||
# avant de tomber sur le VLM (~100-300ms vs 2-23s par appel VLM).
|
||||
# Reconnecté le 2026-05-06 : la fonction _resolve_by_ocr_text
|
||||
# existait déjà mais n'était appelée QUE depuis le runtime V4
|
||||
# (resolve_order pré-compilé), qui n'est pas branché côté frontend
|
||||
# (cf. audit project-quality-guardian Cas #5). La cascade legacy
|
||||
# tombait directement sur VLM Quick Find d'où des replays à 23s
|
||||
# par action visuelle au lieu de <500ms attendus.
|
||||
# Le method est rebadgé "hybrid_text_direct" (seuil 0.80 dans
|
||||
# _RESOLUTION_MIN_SCORES, identifiant historique côté client
|
||||
# Agent V1 et logs Learning).
|
||||
if by_text_strict:
|
||||
ocr_result = _resolve_by_ocr_text(
|
||||
screenshot_path=screenshot_path,
|
||||
target_text=by_text_strict,
|
||||
screen_width=screen_width,
|
||||
screen_height=screen_height,
|
||||
)
|
||||
if ocr_result and ocr_result.get("score", 0) >= 0.80:
|
||||
ocr_result["method"] = "hybrid_text_direct"
|
||||
logger.info(
|
||||
"Strict resolve OCR-DIRECT : OK '%s' → (%.4f, %.4f) score=%.2f",
|
||||
by_text_strict[:40],
|
||||
ocr_result.get("x_pct", 0),
|
||||
ocr_result.get("y_pct", 0),
|
||||
ocr_result.get("score", 0),
|
||||
)
|
||||
return ocr_result
|
||||
elif ocr_result:
|
||||
logger.info(
|
||||
"Strict resolve OCR-DIRECT : '%s' trouvé score=%.2f < 0.80, passage VLM",
|
||||
by_text_strict[:40],
|
||||
ocr_result.get("score", 0),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Strict resolve OCR-DIRECT : '%s' non trouvé, passage VLM",
|
||||
by_text_strict[:40],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Étape 1 : VLM Quick Find (fallback, multi-image)
|
||||
# ---------------------------------------------------------------
|
||||
@@ -2114,7 +2107,139 @@ _RESOLUTION_MIN_SCORES: Dict[str, float] = {
|
||||
# Écart maximum toléré entre coords résolues et coords enregistrées
|
||||
# (en fraction d'écran, dans chaque axe). Au-delà, on considère que la
|
||||
# résolution a trouvé un faux positif ailleurs sur l'écran.
|
||||
_RESOLUTION_MAX_DRIFT: float = 0.20
|
||||
# 15 mai 2026 : élargi 0.20 → 0.95 pour respecter le principe "100% vision"
|
||||
# (Dom). Le score template/VLM seul est désormais la source de confiance,
|
||||
# la position de la capture initiale n'est plus contraignante.
|
||||
_RESOLUTION_MAX_DRIFT: float = 0.95
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Pré-check sémantique : OCR de validation de position
|
||||
# ===========================================================================
|
||||
# Avant de dispatcher un clic, on vérifie que le texte attendu (by_text) est
|
||||
# bien présent dans une fenêtre OCR autour de la coordonnée résolue. Cela
|
||||
# attrape les cas où la cascade renvoie une coordonnée plausible mais qui
|
||||
# pointe en réalité sur un autre élément (ex: clic sur "Dossier en cours" du
|
||||
# menu au lieu de "Synthèse Urgences" du tab plus bas).
|
||||
# ===========================================================================
|
||||
|
||||
_VALIDATION_OCR_READER = None
|
||||
_VALIDATION_OCR_LOCK = threading.Lock()
|
||||
_VALIDATION_OCR_FAILED = False
|
||||
|
||||
|
||||
def _get_validation_ocr_reader():
|
||||
"""Singleton EasyOCR partagé pour la validation post-cascade.
|
||||
|
||||
Chargement paresseux à la première requête. En cas d'échec, on cache
|
||||
le statut FAILED pour ne pas retenter à chaque appel et bloquer le flux.
|
||||
"""
|
||||
global _VALIDATION_OCR_READER, _VALIDATION_OCR_FAILED
|
||||
if _VALIDATION_OCR_FAILED:
|
||||
return None
|
||||
with _VALIDATION_OCR_LOCK:
|
||||
if _VALIDATION_OCR_READER is None and not _VALIDATION_OCR_FAILED:
|
||||
try:
|
||||
import easyocr # type: ignore
|
||||
_VALIDATION_OCR_READER = easyocr.Reader(
|
||||
['fr', 'en'], gpu=True, verbose=False
|
||||
)
|
||||
logger.info("[REPLAY] EasyOCR validator chargé (fr+en, GPU)")
|
||||
except Exception as e:
|
||||
logger.warning("[REPLAY] EasyOCR validator indisponible (%s) — pré-check désactivé", e)
|
||||
_VALIDATION_OCR_FAILED = True
|
||||
return None
|
||||
return _VALIDATION_OCR_READER
|
||||
|
||||
|
||||
def _normalize_for_match(s: str) -> str:
|
||||
"""Normalisation pour comparaison textuelle robuste : lowercase, sans
|
||||
accents, ponctuation → espace, espaces multiples écrasés.
|
||||
"""
|
||||
import unicodedata
|
||||
decomposed = unicodedata.normalize('NFD', s.lower())
|
||||
no_accents = ''.join(c for c in decomposed if unicodedata.category(c) != 'Mn')
|
||||
cleaned = ''.join(c if c.isalnum() or c.isspace() else ' ' for c in no_accents)
|
||||
return ' '.join(cleaned.split())
|
||||
|
||||
|
||||
def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.60) -> bool:
|
||||
"""Match tolérant aux imperfections OCR.
|
||||
|
||||
1. Substring exacte → match.
|
||||
2. Sinon : split en tokens ≥3 caractères, retourne True si au moins
|
||||
`min_token_ratio` des tokens attendus apparaissent dans observed.
|
||||
Ex : "Coller ou saisir le dossier patient" → tokens
|
||||
['coller', 'saisir', 'dossier', 'patient'] ; si OCR voit "u saisir
|
||||
le dossier patient" → 3/4 = 75% présents → match accepté.
|
||||
|
||||
Cible le compromis entre strict (faux négatifs sur erreurs OCR) et
|
||||
permissif (faux positifs sur textes voisins).
|
||||
"""
|
||||
nexp = _normalize_for_match(expected)
|
||||
nobs = _normalize_for_match(observed)
|
||||
if not nexp:
|
||||
return True
|
||||
if nexp in nobs:
|
||||
return True
|
||||
tokens = [t for t in nexp.split() if len(t) >= 3]
|
||||
if not tokens:
|
||||
return False
|
||||
matched = sum(1 for t in tokens if t in nobs)
|
||||
return matched / len(tokens) >= min_token_ratio
|
||||
|
||||
|
||||
def _validate_text_at_position(
|
||||
screenshot_path: str,
|
||||
x_pct: float,
|
||||
y_pct: float,
|
||||
expected_text: str,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
radius_px: int = 280,
|
||||
) -> tuple:
|
||||
"""Pré-check sémantique : OCR sur une zone autour de (x_pct, y_pct) et
|
||||
vérifie que `expected_text` y est présent (substring ou fuzzy 50%).
|
||||
|
||||
Retourne (is_valid: bool, observed_text: str, elapsed_ms: float).
|
||||
|
||||
Politique en cas d'échec OCR (lib absente, exception) : retourne
|
||||
(True, "", 0.0) pour ne pas bloquer le flux. Mieux vaut un faux positif
|
||||
rare qu'une régression bloquante introduite par la validation elle-même.
|
||||
"""
|
||||
reader = _get_validation_ocr_reader()
|
||||
if reader is None:
|
||||
return True, "", 0.0
|
||||
if not expected_text or not expected_text.strip():
|
||||
return True, "", 0.0
|
||||
try:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
t0 = time.time()
|
||||
img = Image.open(screenshot_path).convert("RGB")
|
||||
img_w, img_h = img.size
|
||||
cx = int(x_pct * screen_width)
|
||||
cy = int(y_pct * screen_height)
|
||||
# Saturer dans les bornes de l'image (le screenshot peut être plus
|
||||
# large que la fenêtre logique — utiliser min(img_*, screen_*) en sécurité).
|
||||
max_x = min(img_w, screen_width)
|
||||
max_y = min(img_h, screen_height)
|
||||
x1 = max(0, cx - radius_px)
|
||||
y1 = max(0, cy - radius_px)
|
||||
x2 = min(max_x, cx + radius_px)
|
||||
y2 = min(max_y, cy + radius_px)
|
||||
if x2 - x1 < 10 or y2 - y1 < 10:
|
||||
return True, "", 0.0
|
||||
crop = img.crop((x1, y1, x2, y2))
|
||||
results = reader.readtext(np.array(crop))
|
||||
observed = " ".join(r[1] for r in results if r and len(r) >= 2)
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
is_valid = _text_match_fuzzy(expected_text, observed, min_token_ratio=0.50)
|
||||
return is_valid, observed, elapsed_ms
|
||||
except Exception as e:
|
||||
logger.warning("[REPLAY] _validate_text_at_position erreur (%s) — pas de blocage", e)
|
||||
return True, "", 0.0
|
||||
|
||||
|
||||
def _validate_resolution_quality(
|
||||
@@ -2193,6 +2318,30 @@ def _validate_resolution_quality(
|
||||
dx = abs(resolved_x - fallback_x_pct)
|
||||
dy = abs(resolved_y - fallback_y_pct)
|
||||
if dx > _RESOLUTION_MAX_DRIFT or dy > _RESOLUTION_MAX_DRIFT:
|
||||
# Exception : pour les méthodes "haute confiance" qui ont
|
||||
# identifié sémantiquement la cible (texte exact via OCR ou
|
||||
# image quasi parfaite via template), on fait confiance à la
|
||||
# position visuelle peu importe le drift. Le drift par rapport
|
||||
# à l'enregistrement ne reflète qu'un changement de layout
|
||||
# (scroll, redimensionnement, F11, refonte UI, résolution
|
||||
# différente), pas une erreur de résolution.
|
||||
#
|
||||
# - template_matching ≥ 0.95 : image retrouvée pixel-perfect
|
||||
# - hybrid_text_direct ≥ 0.80 : texte exact reconnu par OCR
|
||||
# (0.80 est déjà le seuil d'acceptation côté _RESOLUTION_MIN_SCORES,
|
||||
# au-dessus on a un signal sémantique fiable).
|
||||
_high_confidence_method = (
|
||||
(method.startswith("template_matching") and score >= 0.95)
|
||||
or (method == "hybrid_text_direct" and score >= 0.80)
|
||||
)
|
||||
if _high_confidence_method:
|
||||
logger.info(
|
||||
"[REPLAY] Drift (%.3f, %.3f) > %.2f IGNORÉ : score=%.3f "
|
||||
"sur %s — résultat visuel fiable, on l'utilise",
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT, score, method,
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(
|
||||
"[REPLAY] Resolution REJETÉE (drift trop grand) : "
|
||||
"method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f) "
|
||||
@@ -2201,6 +2350,10 @@ def _validate_resolution_quality(
|
||||
fallback_x_pct, fallback_y_pct,
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT,
|
||||
)
|
||||
# 100% visuel : on ne clique JAMAIS aux coords enregistrées en aveugle.
|
||||
# resolved=False → la couche supérieure tente la méthode suivante
|
||||
# (VLM Quick Find, SoM, grounding) ; si toutes échouent, l'agent
|
||||
# passe par "visual_resolve_failed" → Policy → pause supervisée.
|
||||
return {
|
||||
"resolved": False,
|
||||
"method": f"rejected_drift_{method}",
|
||||
@@ -2363,21 +2516,16 @@ def _locate_popup_button(
|
||||
|
||||
content = resp.json().get("message", {}).get("content", "")
|
||||
|
||||
# Parser bbox_2d — qwen2.5vl retourne des coordonnées en pixels
|
||||
# relatifs à l'image envoyée, PAS sur une grille 1000x1000.
|
||||
# Format JSON : [{"bbox_2d": [x1, y1, x2, y2], "label": "..."}]
|
||||
bbox_match = re.search(
|
||||
r'"bbox_2d"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]',
|
||||
content,
|
||||
# Parser bbox_2d — délégué à core.grounding.bbox_parser
|
||||
# Restriction au format bbox_2d attendu par le prompt
|
||||
# (cf. prompt qui demande "bounding box"). qwen2.5vl retourne
|
||||
# des coordonnées en pixels relatifs à l'image envoyée.
|
||||
cx, cy = parse_bbox_to_norm_validated(
|
||||
content, screen_width, screen_height, formats={"bbox_2d"}
|
||||
)
|
||||
if bbox_match:
|
||||
x1, y1, x2, y2 = [int(bbox_match.group(i)) for i in range(1, 5)]
|
||||
# Normaliser par les dimensions de l'écran (pixels → 0-1)
|
||||
cx = (x1 + x2) / 2 / screen_width
|
||||
cy = (y1 + y2) / 2 / screen_height
|
||||
if 0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0:
|
||||
logger.info(f"Observer : bouton '{button_text}' localisé à ({cx:.3f}, {cy:.3f})")
|
||||
return {"x_pct": cx, "y_pct": cy}
|
||||
if cx is not None:
|
||||
logger.info(f"Observer : bouton '{button_text}' localisé à ({cx:.3f}, {cy:.3f})")
|
||||
return {"x_pct": cx, "y_pct": cy}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Observer grounding bouton erreur : {e}")
|
||||
|
||||
195
agent_v0/server_v1/safety_checks_provider.py
Normal file
195
agent_v0/server_v1/safety_checks_provider.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# agent_v0/server_v1/safety_checks_provider.py
|
||||
"""SafetyChecksProvider — checks hybrides déclaratifs + LLM contextuels (QW4).
|
||||
|
||||
Pour une action pause_for_human :
|
||||
- les checks déclaratifs (workflow) sont toujours inclus
|
||||
- si safety_level == "medical_critical" et RPA_SAFETY_CHECKS_LLM_ENABLED=1,
|
||||
un appel LLM (medgemma:4b par défaut) ajoute jusqu'à N checks contextuels
|
||||
|
||||
Tout échec côté LLM (timeout, exception, parse) → additional_checks=[] :
|
||||
le replay continue avec uniquement les déclaratifs (fallback safe).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PausePayload:
|
||||
checks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pause_reason: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool_enabled(name: str) -> bool:
|
||||
val = os.environ.get(name, "1").strip().lower()
|
||||
return val not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def build_pause_payload(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
last_screenshot: Optional[str],
|
||||
) -> PausePayload:
|
||||
"""Construit le payload de pause enrichi pour une action pause_for_human."""
|
||||
params = action.get("parameters") or {}
|
||||
message = params.get("message", "Validation requise")
|
||||
safety_level = params.get("safety_level")
|
||||
declarative = params.get("safety_checks") or []
|
||||
|
||||
# Normalisation des checks déclaratifs
|
||||
checks: List[Dict[str, Any]] = []
|
||||
for d in declarative:
|
||||
checks.append({
|
||||
"id": d.get("id") or f"decl_{uuid.uuid4().hex[:6]}",
|
||||
"label": d.get("label", "Validation"),
|
||||
"required": bool(d.get("required", True)),
|
||||
"source": "declarative",
|
||||
"evidence": None,
|
||||
})
|
||||
|
||||
# Ajout LLM contextual si applicable
|
||||
if safety_level == "medical_critical" and _env_bool_enabled("RPA_SAFETY_CHECKS_LLM_ENABLED"):
|
||||
try:
|
||||
additional = _call_llm_for_contextual_checks(
|
||||
action=action,
|
||||
replay_state=replay_state,
|
||||
last_screenshot=last_screenshot,
|
||||
existing_labels=[c["label"] for c in checks],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
|
||||
additional = []
|
||||
|
||||
for a in additional:
|
||||
checks.append({
|
||||
"id": f"llm_{uuid.uuid4().hex[:6]}",
|
||||
"label": a.get("label", ""),
|
||||
"required": False, # checks LLM = informationnels, pas obligatoires V1
|
||||
"source": "llm_contextual",
|
||||
"evidence": a.get("evidence", ""),
|
||||
})
|
||||
|
||||
return PausePayload(
|
||||
checks=checks,
|
||||
pause_reason="",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
def _call_llm_for_contextual_checks(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
last_screenshot: Optional[str],
|
||||
existing_labels: List[str],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Appelle Ollama en mode JSON strict pour générer 0-N checks contextuels.
|
||||
|
||||
Returns:
|
||||
List[{label, evidence}] (max RPA_SAFETY_CHECKS_LLM_MAX_CHECKS).
|
||||
[] sur tout échec (timeout, JSON invalide, exception).
|
||||
"""
|
||||
import requests
|
||||
|
||||
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
|
||||
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
|
||||
# retournait systématiquement [] (refus de signaler).
|
||||
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
|
||||
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
|
||||
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
|
||||
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)
|
||||
max_checks = _env_int("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", 3)
|
||||
ollama_url = _env("OLLAMA_URL", "http://localhost:11434")
|
||||
|
||||
params = action.get("parameters") or {}
|
||||
workflow_message = params.get("message", "")
|
||||
existing = ", ".join(existing_labels) if existing_labels else "aucun"
|
||||
|
||||
prompt = f"""Tu es Léa, assistante médicale supervisée.
|
||||
Avant de continuer le workflow, tu dois lister 0 à {max_checks} vérifications supplémentaires
|
||||
que l'humain doit acquitter, en regardant l'écran actuel.
|
||||
|
||||
Contexte workflow : {workflow_message}
|
||||
Checks déjà demandés : {existing}
|
||||
|
||||
NE répète PAS un check déjà demandé.
|
||||
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
{{
|
||||
"additional_checks": [
|
||||
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1, "num_predict": 200},
|
||||
}
|
||||
|
||||
if last_screenshot and os.path.isfile(last_screenshot):
|
||||
try:
|
||||
with open(last_screenshot, "rb") as f:
|
||||
payload["images"] = [base64.b64encode(f.read()).decode("ascii")]
|
||||
except Exception as e:
|
||||
logger.debug("safety_checks: lecture screenshot échouée (%s) — appel sans image", e)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
json=payload,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
|
||||
return []
|
||||
text = response.json().get("response", "").strip()
|
||||
except requests.Timeout:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
|
||||
return []
|
||||
|
||||
# format=json garantit normalement du JSON valide
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
|
||||
return []
|
||||
|
||||
additional = parsed.get("additional_checks") or []
|
||||
if not isinstance(additional, list):
|
||||
return []
|
||||
|
||||
# Filtre + tronc
|
||||
valid = []
|
||||
for item in additional[:max_checks]:
|
||||
if isinstance(item, dict) and item.get("label"):
|
||||
valid.append({
|
||||
"label": str(item["label"])[:200],
|
||||
"evidence": str(item.get("evidence", ""))[:300],
|
||||
})
|
||||
return valid
|
||||
@@ -1791,6 +1791,10 @@ class StreamProcessor:
|
||||
# Workflows construits (pour le matching)
|
||||
self._workflows: Dict[str, Any] = {}
|
||||
|
||||
# Shadow learning : dernier pattern UI détecté par session
|
||||
# Stocke {session_id: {"pattern": str, "ocr_text": str, "screen_state": obj, "shot_id": str}}
|
||||
self._pending_ui_patterns: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Charger les workflows existants depuis le disque
|
||||
self._load_persisted_workflows()
|
||||
|
||||
@@ -1975,6 +1979,9 @@ class StreamProcessor:
|
||||
- key_combo/key_press avec uniquement des modificateurs seuls (ctrl, alt, shift, etc.)
|
||||
- key_combo/key_press avec liste de touches vide
|
||||
- text_input avec texte vide
|
||||
|
||||
Shadow learning : quand un clic suit un pattern UI détecté,
|
||||
on apprend l'association dialogue→bouton.
|
||||
"""
|
||||
if _is_parasitic_event(event_data):
|
||||
logger.debug(
|
||||
@@ -1982,9 +1989,119 @@ class StreamProcessor:
|
||||
f"type={event_data.get('type')}, data={event_data.get('keys', event_data.get('text', ''))}"
|
||||
)
|
||||
return {"status": "event_filtered", "session_id": session_id, "reason": "parasitic"}
|
||||
|
||||
# Shadow learning : si un pattern UI est en attente et qu'on reçoit un clic
|
||||
if event_data.get("type") == "mouse_click":
|
||||
self._try_shadow_learn(session_id, event_data)
|
||||
|
||||
self.session_manager.add_event(session_id, event_data)
|
||||
return {"status": "event_recorded", "session_id": session_id}
|
||||
|
||||
def _try_shadow_learn(self, session_id: str, click_event: Dict[str, Any]):
|
||||
"""Tente d'apprendre un pattern UI depuis un clic observé en Shadow.
|
||||
|
||||
Quand un screenshot contenait un pattern UI détecté (dialogue) et que
|
||||
l'utilisateur clique ensuite, on extrait le texte OCR au point de clic
|
||||
pour apprendre l'association : "quand je vois ce texte → cliquer sur ce bouton".
|
||||
"""
|
||||
with self._data_lock:
|
||||
pending = self._pending_ui_patterns.pop(session_id, None)
|
||||
if not pending:
|
||||
return
|
||||
|
||||
screen_state = pending.get("screen_state")
|
||||
if screen_state is None:
|
||||
return
|
||||
|
||||
# Extraire la position du clic (pixels absolus)
|
||||
pos = click_event.get("pos", [])
|
||||
if not pos or len(pos) != 2:
|
||||
return
|
||||
|
||||
click_x, click_y = pos[0], pos[1]
|
||||
|
||||
# Trouver le texte OCR le plus proche du point de clic
|
||||
# via les ui_elements du ScreenState (ils ont bbox + label)
|
||||
clicked_label = self._find_label_at_position(screen_state, click_x, click_y)
|
||||
if not clicked_label:
|
||||
return
|
||||
|
||||
# Extraire le trigger principal du texte OCR du dialogue
|
||||
ocr_text = pending.get("ocr_text", "")
|
||||
# Utiliser un extrait court comme trigger (max 80 chars, premier segment pertinent)
|
||||
trigger_text = ocr_text[:80].strip().lower()
|
||||
if not trigger_text:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Shadow learning: pattern '{pending['pattern_name']}' "
|
||||
f"→ utilisateur a cliqué '{clicked_label}' | trigger='{trigger_text[:40]}...'"
|
||||
)
|
||||
|
||||
# Sauvegarder le pattern appris
|
||||
try:
|
||||
from core.knowledge.ui_patterns import UIPatternLibrary
|
||||
lib = UIPatternLibrary()
|
||||
lib.save_learned_pattern({
|
||||
"category": "dialog",
|
||||
"triggers": [trigger_text],
|
||||
"action": "click",
|
||||
"target": clicked_label,
|
||||
"os": "windows",
|
||||
"confidence": 0.8,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Shadow learning: échec sauvegarde pattern: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _find_label_at_position(screen_state, click_x: int, click_y: int) -> Optional[str]:
|
||||
"""Trouve le label de l'élément UI le plus proche du point de clic.
|
||||
|
||||
Parcourt les ui_elements du ScreenState et retourne le label de
|
||||
l'élément dont la bbox contient le point, ou le plus proche si aucun
|
||||
ne contient exactement le point.
|
||||
"""
|
||||
ui_elements = getattr(screen_state, "ui_elements", [])
|
||||
if not ui_elements:
|
||||
return None
|
||||
|
||||
best_label = None
|
||||
best_dist = float("inf")
|
||||
|
||||
for elem in ui_elements:
|
||||
bbox = getattr(elem, "bbox", None)
|
||||
label = getattr(elem, "label", "")
|
||||
if not bbox or not label:
|
||||
continue
|
||||
|
||||
# BBox = (x, y, width, height) — extraire les coordonnées
|
||||
try:
|
||||
bx, by = bbox.x, bbox.y
|
||||
bw, bh = bbox.width, bbox.height
|
||||
except AttributeError:
|
||||
# Fallback si bbox est une liste/tuple
|
||||
if hasattr(bbox, '__len__') and len(bbox) >= 4:
|
||||
bx, by, bw, bh = bbox[0], bbox[1], bbox[2], bbox[3]
|
||||
else:
|
||||
continue
|
||||
|
||||
# Vérifier si le clic est dans la bbox
|
||||
if bx <= click_x <= bx + bw and by <= click_y <= by + bh:
|
||||
return label.strip()
|
||||
|
||||
# Sinon calculer la distance au centre
|
||||
cx = bx + bw / 2
|
||||
cy = by + bh / 2
|
||||
dist = ((click_x - cx) ** 2 + (click_y - cy) ** 2) ** 0.5
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_label = label.strip()
|
||||
|
||||
# Ne retourner le plus proche que s'il est raisonnablement proche (< 100px)
|
||||
if best_label and best_dist < 100:
|
||||
return best_label
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Screenshots
|
||||
# =========================================================================
|
||||
@@ -2042,6 +2159,37 @@ class StreamProcessor:
|
||||
self._screen_states[session_id] = []
|
||||
self._screen_states[session_id].append(screen_state)
|
||||
|
||||
# Enrichir avec les patterns UI connus
|
||||
try:
|
||||
from core.knowledge.ui_patterns import UIPatternLibrary
|
||||
detected_text = getattr(screen_state.perception, "detected_text", [])
|
||||
if detected_text:
|
||||
ocr_text = " ".join(str(t) for t in detected_text) if isinstance(detected_text, list) else str(detected_text)
|
||||
lib = UIPatternLibrary()
|
||||
pattern = lib.find_pattern(ocr_text)
|
||||
if pattern:
|
||||
result["ui_pattern"] = pattern["pattern"]
|
||||
result["ui_pattern_action"] = pattern["action"]
|
||||
result["ui_pattern_target"] = pattern["target"]
|
||||
logger.info(f"Pattern UI détecté: {pattern['pattern']} → {pattern['target']}")
|
||||
|
||||
# Shadow learning : mémoriser le pattern en attente du clic utilisateur
|
||||
with self._data_lock:
|
||||
self._pending_ui_patterns[session_id] = {
|
||||
"pattern_name": pattern["pattern"],
|
||||
"ocr_text": ocr_text,
|
||||
"screen_state": screen_state,
|
||||
"shot_id": shot_id,
|
||||
}
|
||||
else:
|
||||
# Pas de pattern connu → effacer le pending (l'écran a changé)
|
||||
with self._data_lock:
|
||||
self._pending_ui_patterns.pop(session_id, None)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Pattern check: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Screenshot analysé: {shot_id} | "
|
||||
f"{result['ui_elements_count']} UI elements, "
|
||||
|
||||
695
archive/historical_recall/recall_2026-05-08.md
Normal file
695
archive/historical_recall/recall_2026-05-08.md
Normal file
@@ -0,0 +1,695 @@
|
||||
Bonjour ! Aujourd'hui, nous devons réaliser la démo de bout en bout et sans accros. Il nous faut être précis pour ne pas avoir à refaire constamment le workflow, c'est long et
|
||||
chronophaque. Il faut penser que l'on doit pouvoir "communiquer" avec Léa pour lui demander de faire le travail. Par exemple, traite moi les 3 premiers dossier (dans vwb on a un
|
||||
outil de lescture de tableau ?). Il faut donc qu'elle soit capable de lire le tableau correctement et faire le traitement propre au fur et à mesure. On peut aussi, si c'est
|
||||
nécessaire modifier l'interface de la maquette pour plus de simplicité. Pour finir, est ce que l'utilisation de vwb est suffisament solide pour faire l'exercice plutôt que
|
||||
d'utiliser le mode d'apprentissage de lea ? Ne code rien, on discute.
|
||||
|
||||
● Bonjour. Avant de raisonner architecture, j'ai besoin de clarifier 3 choses :
|
||||
|
||||
Questions de cadrage
|
||||
|
||||
1. Quand exactement la démo ? La semaine est entamée — on parle de mardi-mercredi-jeudi ? Combien d'heures de prep on a réellement avant ?
|
||||
2. Scénario souhaité : tu te projettes en quoi devant la DSI Carvella ?
|
||||
- "Je dis à Léa : traite les 3 premiers dossiers. Léa lit le tableau, choisit, fait, rapporte" — pur pilotage vocal/chat
|
||||
- Ou : "Je clique le bouton Léa qui lance le workflow A pour le dossier sélectionné" — démo de l'automatisation, pilotage visuel
|
||||
- Ou les deux à des moments différents
|
||||
3. Métier vs tech : Carvella vient en adversaire technique (RPA classique). Tu veux qu'il soit bluffé sur l'INTELLIGENCE de Léa (compréhension naturelle, lecture de tableau,
|
||||
généralisation) ou sur la PROFONDEUR métier (preuves cliniques citées, gain ROI 100k€/mois) ? Les deux mais quel curseur ?
|
||||
|
||||
Maintenant mes réponses sur tes 4 points :
|
||||
|
||||
Outil de lecture de tableau dans VWB
|
||||
|
||||
À ma connaissance non, pas d'action spécifique extract_table. Ce qui existe :
|
||||
- extract_text (OCR sur une zone) → renvoie du texte plat, pas une structure
|
||||
- Import Excel via /api/v3/excel/upload (vu hier dans le code) — mais c'est pour DÉPOSER un Excel, pas lire un tableau à l'écran
|
||||
- Les learned_workflows qui ont une notion de "boucle données → UI" (vu un commit 9da804bb6 feat: import Excel → SQLite + boucle données → UI dans le VWB)
|
||||
|
||||
→ Il faudrait que je vérifie learned_workflows.py et la logique de boucle pour voir si ça peut servir. Mais à ce jour, pour lire le tableau de patients à l'écran et boucler dessus,
|
||||
il y a un trou. C'est probablement à construire ou à contourner.
|
||||
|
||||
VWB vs apprentissage Léa pour la démo
|
||||
|
||||
┌───────────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────┐
|
||||
│ Critère │ Workflow VWB │ Apprentissage Léa │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Déterminisme (rejouable, pas de surprise) │ ✅ │ ⚠️ moins prévisible │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Robustesse aux variations UI │ ❌ (cf. nos 8h d'hier) │ ✅ │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Vitesse de mise au point │ Long, fragile (re-enregistrement) │ Court (Léa s'adapte) │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Crédibilité démo "IA vraie" │ ⚠️ (RPA classique +) │ ✅ (ce qui te démarque d'Evanov) │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Risque le jour J │ Modéré (si répété) │ Plus élevé (moins testé) │
|
||||
├───────────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────┤
|
||||
│ Prêt aujourd'hui │ Presque (re-enregistrement) │ Probablement pas │
|
||||
└───────────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────┘
|
||||
|
||||
Mon avis honnête : pour la démo qui arrive cette semaine, workflow VWB pour le squelette + intelligence Léa pour la partie qui démarque est le bon mix.
|
||||
|
||||
Concrètement :
|
||||
- Léa lit le tableau patients (étape "intelligente" qu'on met en avant)
|
||||
- Léa choisit les N premiers (capacité de compréhension)
|
||||
- Pour chaque patient, Léa lance le workflow VWB Urgence (mécanique fiable)
|
||||
- Léa rapporte au humain (l'analyse PMSI, les preuves citées)
|
||||
|
||||
L'enveloppe est intelligente. Le moteur dedans est du VWB solide. C'est ce qui te démarque tout en étant fiable.
|
||||
|
||||
Communication avec Léa pour le scénario "traite les 3 premiers"
|
||||
|
||||
Architecturalement, ça implique :
|
||||
1. Un endpoint chat Léa (déjà : rpa-agent-chat port 5004) qui reçoit "traite les 3 premiers dossiers"
|
||||
2. Un NLP léger côté Léa qui interprète : action=process_patients, nombre=3, ordre=top
|
||||
3. Une lecture du tableau : OCR de la zone tableau + parsing par lignes (le tableau est régulier : IPP | Nom | Prénom | … donc parser les colonnes par espaces multiples)
|
||||
4. Une boucle : pour chaque IPP des 3 premiers, lancer le workflow Urgence avec ce dossier en paramètre (ce qui implique de variabiliser le by_text="{{patient_id}}")
|
||||
5. Un retour utilisateur : "Dossier 25003284 codé en FORFAIT_URGENCE — Dossier 25003362 codé en FORFAIT — Dossier 25003364 codé en UHCD avec ces preuves : …"
|
||||
|
||||
L'élément manquant central : paramétrisation du workflow par variable d'entrée + mécanisme de boucle.
|
||||
|
||||
Modifier la maquette ? Oui mais avec discernement
|
||||
|
||||
Modifier pour SIMPLIFIER (rendre les anchors plus stables) = OK. Exemples acceptables :
|
||||
- Compacter les 3 textareas pour tenir dans le viewport sans scroll
|
||||
- Ajouter des bordures contrastées sur les zones cibles (template matching plus robuste)
|
||||
- Mettre un placeholder unique dans chaque textarea (texte distinctif que template matching reconnaît)
|
||||
|
||||
Modifier pour TRICHER (afficher déjà la décision UHCD avant que Léa ait analysé) = NON, ça tue la crédibilité.
|
||||
|
||||
Question de précision et non-régression
|
||||
|
||||
C'est ton vrai point critique. Je propose qu'on :
|
||||
1. Fige une procédure d'enregistrement documentée (config Chrome précise, étapes, validation)
|
||||
2. Capture une fois proprement, et après on n'y touche plus
|
||||
3. Crée un script de test E2E qui valide en 30 secondes que le workflow marche encore (à lancer avant chaque session, et avant la démo)
|
||||
|
||||
---
|
||||
Bref, ma recommandation pour aujourd'hui : on construit dans cet ordre :
|
||||
|
||||
1. Discussion : tu réponds à mes 3 questions de cadrage → on aligne sur le scénario démo
|
||||
2. Variabilisation du workflow Urgence : by_text="{{patient_id}}" + extension templating
|
||||
3. Mécanisme Léa lit tableau + boucle sur N patients (enveloppe intelligente autour du workflow VWB)
|
||||
4. Re-enregistrement précis des anchors dans config bureau standard
|
||||
5. Procédure documentée + script de test E2E
|
||||
|
||||
Mais avant tout, je veux entendre tes réponses aux 3 questions de cadrage. Ça change radicalement l'angle.
|
||||
|
||||
✻ Cogitated for 1m 24s
|
||||
|
||||
❯ [Image #2] Il y a bien dans vwb un extracteur de tableau. La démo dans 4 jours. Je n'ai pas compris ta question : "Scénario souhaité : tu te projettes en quoi devant la DSI Carvella
|
||||
?" Pour le 3) je dirais les deux. Pas simplement pour kervella, mais aussi pour les dim/tim/dg. Si je suis assez confiant sur la partie workflow, c'est surtout la partie analyse du
|
||||
dossier qui m"interroge ?
|
||||
⎿ [Image #2]
|
||||
|
||||
● OK, message reçu. L'enjeu majeur c'est la qualité de l'analyse, pas la mécanique. C'est exactement le point qui démarque Léa d'un RPA classique : un workflow qui clique, c'est
|
||||
UiPath. Une Léa qui raisonne juste sur du texte clinique, c'est ton offre.
|
||||
|
||||
Reformulation de ma question scénario
|
||||
|
||||
Concrètement, ce que tes 4 minutes devant les DG vont ressembler. Je veux comprendre le déroulé pour caler le bon niveau de polish à chaque étape :
|
||||
|
||||
- Minute 0 : tu ouvres l'écran, qu'est-ce qui est affiché ? (Easily avec liste de 11 dossiers ? Chat Léa ? Les deux côte à côte ?)
|
||||
- Minute 1 : tu dis quoi à voix / en chat à Léa ? Et qu'est-ce que Carvella+DG voient se passer à l'écran ?
|
||||
- Minute 2-3 : Léa analyse et code 1 ou 3 dossiers. Que regardent-ils ? La maquette qui se remplit ? Léa qui parle ? Un dashboard qui synthétise ?
|
||||
- Minute 4 : Léa rapporte le résultat. Sous quelle forme ? "J'ai codé 3 dossiers : 2 forfaits, 1 UHCD. Voici les preuves de l'UHCD : …". Vocal ? Texte ? Dashboard ?
|
||||
|
||||
Tu n'es pas obligé d'avoir une réponse précise — c'est ce qu'on doit décider ensemble. Mais c'est ce que je voulais dire par "scénario souhaité".
|
||||
|
||||
Sur ton inquiétude (l'analyse) — c'est le vrai sujet
|
||||
|
||||
Je propose un plan de durcissement de l'analyse LLM en 3 axes :
|
||||
|
||||
A. Bench complet sur tes 11 dossiers
|
||||
|
||||
- Lancer t2a_decision sur les 11 dossiers anonymisés (les 4 UHCD + les 4 forfaits + les 3 PE2/SU2/SU2)
|
||||
- Vérifier la décision (vrai positif / vrai négatif)
|
||||
- Vérifier la qualité des preuves (citations littérales vraiment du dossier ? pertinentes ? pas inventées ?)
|
||||
- Vérifier la confiance (le LLM dit "elevee" quand il devrait dire "moyenne" ?)
|
||||
- Tableau de scores : accuracy + qualité preuves + temps + VRAM
|
||||
|
||||
B. Comparer 2-3 modèles
|
||||
|
||||
On a déjà bench medgemma:4b (docs/BENCH_MEDGEMMA.md) et qwen2.5:7b. À tester aussi peut-être gemma3:12b pour voir la marge. Choix final selon : qualité, vitesse, VRAM, robustesse.
|
||||
|
||||
C. Renforcer le prompt si besoin
|
||||
|
||||
Selon le bench, on peut ajouter :
|
||||
- Des exemples few-shot (1 exemple UHCD, 1 forfait, copiés en début de prompt)
|
||||
- Une étape de raisonnement explicite ("Avant de décider, liste les 3 critères et pour chacun cite une preuve")
|
||||
- Un garde-fou : si confiance="faible", Léa demande à l'humain au lieu de coder
|
||||
|
||||
Cela rend l'analyse défendable devant Carvella : tu peux dire "Léa cite le dossier mot pour mot, et quand elle doute, elle nous le dit."
|
||||
|
||||
Pour les 4 jours, je propose ce découpage
|
||||
|
||||
┌───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Jour │ Focus │
|
||||
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Aujourd'hui (J-4) │ Cadrage scénario démo + bench analyse LLM sur 11 dossiers + identification des cas problématiques │
|
||||
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ J-3 │ Si bench OK : variabilisation workflow + Extraire tableau + boucle Léa. Si bench KO : amélioration prompt et/ou modèle │
|
||||
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ J-2 │ Re-enregistrement workflow propre + intégration mécanisme "Léa lit tableau et traite N dossiers" │
|
||||
├───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ J-1 │ Test E2E répété 5-10 fois sur scénario démo. Tweaks finaux. Préparation slides │
|
||||
└───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Ce que je propose maintenant (avant de coder)
|
||||
|
||||
1. Tu réponds aux questions scénario ci-dessus (ça cadre tout le reste)
|
||||
2. Je vais voir le code de "Extraire tableau" pour comprendre ses capacités exactes (lecture seule, on discute ensuite)
|
||||
3. Je propose un script de bench des 11 dossiers (qui ne touche pas le code prod, juste pour mesurer)
|
||||
|
||||
OK ?
|
||||
|
||||
✻ Crunched for 58s
|
||||
|
||||
❯ Alors, pour demarrer, chat lea. Je lui demande de traiter 3 dossiers.Elle se réduit (si ça doit être possible) Elle ouvre chrome, tape l'adresse de la maquette, elle lit le
|
||||
tableau. Elle traite aprés dossier par dossier. A la fin, elle dis que c'est fini ! Tu en penses quoi ? Est ce que c'est complexe, ça demande beaucoup de changement ?
|
||||
|
||||
● Ce que tu décris est clair et faisable en 4 jours. Pas de révolution — on a les briques. Il faut surtout assembler et variabiliser.
|
||||
|
||||
Décomposition technique du scénario
|
||||
|
||||
┌────────────────────────────────────────┬─────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Étape │ Brique nécessaire │ Existe ? │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Chat Léa reçoit "traite 3 dossiers" │ NLP léger (regex ou mini-LLM) côté chat │ ⚠️ partiel — chat existe, NLP commande à coder │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Léa se réduit │ iconify() Tkinter ou raccourci Win+D │ 🟢 trivial │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Léa ouvre Chrome + tape URL │ Action launch_app ou raccourci Win + Chrome déjà ouvert │ ⚠️ à brancher │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Léa lit le tableau │ "Extraire tableau" VWB │ 🟢 existe (bouton vu) — à vérifier ce qu'il rend │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Léa boucle sur 3 IPP │ Mécanisme boucle workflow + variable {{patient_id}} │ ⚠️ "boucle données→UI" existe partiellement (commit 9da804bb6) — à confirmer │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Pour chaque dossier : workflow Urgence │ Workflow existant variabilisé │ ✏️ workflow OK, juste variabiliser le step 2 │
|
||||
├────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Léa dit "c'est fini + synthèse" │ Réponse chat + récap des décisions │ ⚠️ à coder simple │
|
||||
└────────────────────────────────────────┴─────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Évaluation honnête
|
||||
|
||||
Pas complexe au sens "casse-tête". C'est de la plomberie d'assemblage :
|
||||
- 4-5 modifs de code structurelles, chacune ~30 min à 2h
|
||||
- 1 re-enregistrement de workflow (30 min si bien préparé)
|
||||
- 1 jour de test E2E qu'il faut absolument répéter 10× pour la fiabilité
|
||||
|
||||
Le risque majeur : c'est qu'on découvre en cours de route que "Extraire tableau" ne renvoie pas ce qu'on espère, ou que le mécanisme de boucle VWB n'est pas mature, et qu'on doive
|
||||
coder du contournement. Je propose qu'on fasse les 2 vérifications en premier (1h grand max).
|
||||
|
||||
Découpage 4 jours proposé
|
||||
|
||||
┌──────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────┐
|
||||
│ Jour │ Focus │ Livrable │
|
||||
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ J-4 │ (1) Vérifier capacités "Extraire tableau" et boucle VWB. (2) Bench analyse LLM sur les 11 dossiers. (3) Cadrage │ Verdict bench LLM + go/no-go boucle VWB │
|
||||
│ (aujourd'hui) │ final scénario │ │
|
||||
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ J-3 │ (1) Variabilisation workflow + extension templating. (2) NLP commande chat Léa. (3) Action minimiser + ouvrir │ Léa peut traiter 1 dossier sur commande │
|
||||
│ │ Chrome │ chat │
|
||||
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ J-2 │ (1) Re-enregistrement workflow propre. (2) Intégration boucle (Léa traite N dossiers). (3) Synthèse fin │ Démo end-to-end fonctionne au moins 1 │
|
||||
│ │ │ fois │
|
||||
├──────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ J-1 │ Test E2E répété 5-10 fois. Polish. Procédure documentée. │ Démo robuste, reproductible │
|
||||
└──────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────┘
|
||||
|
||||
Liste précise des changements (cumul sur 4 jours)
|
||||
|
||||
1. Variabiliser by_text du step "sélection patient" : 30 min
|
||||
2. Étendre templating {{var}} au by_text et autres champs (aujourd'hui c'est sur text uniquement) : 1h
|
||||
3. NLP commande chat Léa : regex simple "traite (\d+) dossiers?" + dispatch action : 2h
|
||||
4. Action launch_url ou utilisation du Chrome déjà ouvert (selon préférence) : 30 min
|
||||
5. Mécanisme boucle : à valider (peut-être 0, peut-être 4h)
|
||||
6. Re-enregistrement workflow Urgence + test : 1h-2h
|
||||
7. Synthèse fin de boucle envoyée au chat : 1h
|
||||
8. Procédure et tests E2E : 1 journée
|
||||
|
||||
Total 1.5-2 jours de dev pur, 1 jour de re-enregistrement+test, 1 jour de polish/répétition. Confortable sur 4 jours.
|
||||
|
||||
Question pour décider maintenant
|
||||
|
||||
Avant que je vérifie quoi que ce soit, 2 décisions à prendre :
|
||||
|
||||
A. Sur "Léa ouvre Chrome + tape URL" : c'est un détail théâtral (ça impressionne) mais ça ajoute 1 step potentiellement fragile. Alternative : Chrome est déjà ouvert sur la maquette
|
||||
quand la démo commence, et Léa enchaîne directement la lecture du tableau. Tu préfères ?
|
||||
|
||||
B. Sur "Léa se réduit" : tu veux qu'elle disparaisse de l'écran pendant le travail (ça augmente le côté "elle bosse toute seule"), ou qu'elle reste visible en petite popup (côté "je
|
||||
vois ce qu'elle fait") ? Les deux marchent.
|
||||
|
||||
Une fois ces 2 réponses, je vais voir le code "Extraire tableau" et "boucle VWB" — et reviens avec un verdict en 30 min.
|
||||
|
||||
✻ Cooked for 1m 7s
|
||||
|
||||
❯ Pour le A j'ai déjà fait avec vwb sous linux, ça fonctionne bien et c'est rapide. Sous windows pas testé. Donc, on part sans que chrome soit ouvert. Oui, pendant la démo, léa se
|
||||
réduit completement.
|
||||
|
||||
● OK, je vais voir les 4 capacités existantes en mode read-only et reviens avec un verdict.
|
||||
|
||||
Searched for 11 patterns, read 1 file (ctrl+o to expand)
|
||||
|
||||
● Voici mon verdict après vérification :
|
||||
|
||||
Bilan des briques existantes
|
||||
|
||||
┌───────────────────────────────────┬─────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Brique │ État │ Verdict │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Action extract_table │ 🟡 Bouton frontend OK avec params │ Backend NON implémenté — coquille vide actuellement │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Mécanisme boucle db_foreach │ ✅ Codé dans dag_execute.py:274 │ Mais côté backend VWB Flask (Linux), pas côté serveur replay (PC Windows) │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Templating ${current_row.col} │ ✅ Existe pour db_foreach │ Réutilisable │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Templating {{var}} │ ⚠️ Existe mais uniquement sur le champ text │ À étendre au by_text │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Action launch_url / ouvrir Chrome │ ❌ Pas trouvée │ À coder ou contourner │
|
||||
├───────────────────────────────────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Minimiser Léa Tkinter │ ✅ iconify() existe │ Trivial │
|
||||
└───────────────────────────────────┴─────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Ce qu'il faut construire pour ton scénario
|
||||
|
||||
┌─────┬─────────────────────────────────────────────────────────────────────────────────┬─────────────┐
|
||||
│ # │ Brique │ Coût estimé │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 1 │ extract_table côté serveur replay (OCR + parsing par lignes) │ 3-4h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 2 │ Mécanisme boucle côté replay OU orchestration Python depuis chat Léa │ 3-4h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 3 │ Action launch_chrome_url pour PC Windows (subprocess.Popen Chrome.exe avec URL) │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 4 │ Étendre templating {{var}} au by_text du click_anchor │ 30 min │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 5 │ Variabiliser le step 2 du workflow Urgence │ 30 min │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 6 │ NLP commande chat ("traite N dossiers" → action structurée) │ 1-2h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 7 │ Action minimiser Léa │ 30 min │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 8 │ Synthèse fin de boucle envoyée au chat │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 9 │ Re-enregistrement workflow Urgence dans config bureau │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────────────────────┼─────────────┤
|
||||
│ 10 │ Tests E2E répétés │ 1 jour │
|
||||
└─────┴─────────────────────────────────────────────────────────────────────────────────┴─────────────┘
|
||||
|
||||
Total : ~2 jours de dev + 1 jour re-enregistrement + 1 jour de polish. Tient en 4 jours, mais sans marge pour les surprises.
|
||||
|
||||
Le seul vrai point d'incertitude
|
||||
|
||||
extract_table n'a pas d'implémentation serveur. Il faut la construire. C'est faisable proprement (EasyOCR + parsing par espaces multiples sur les colonnes IPP/Nom/Prénom) mais c'est
|
||||
de l'OCR de tableau qui peut avoir des erreurs (caractères mal lus, alignement colonnes). À tester sur ta maquette pour mesurer la précision.
|
||||
|
||||
Plan de mitigation si OCR fragile : la maquette urgence.labs.laurinebazin.design est statique HTML — j'ai vu hier que le <table> est accessible via fetch GET. Si l'OCR rate, on peut
|
||||
récupérer la liste IPP via une simple requête HTTP côté serveur (ce qui n'est pas "tricherie" puisque c'est UNE source de vérité du tableau, juste plus fiable que l'OCR). À garder en
|
||||
filet de sécurité.
|
||||
|
||||
Décision à prendre maintenant
|
||||
|
||||
Option A — On code extract_table côté replay (vraie vision) : aligné feedback_100pct_visual, démontrable comme capacité visuelle de Léa. Risque OCR.
|
||||
|
||||
Option B — Léa lit la liste via fetch HTTP de la maquette : robuste, plus rapide à coder, mais on perd le côté "Léa lit visuellement le tableau" qui est un argument fort vs RPA
|
||||
classique.
|
||||
|
||||
Option C — Hybride : on code extract_table (option A), si l'OCR rate > X%, on bascule auto sur fetch HTTP. Robuste ET visuel.
|
||||
|
||||
Pour la démo qui doit faire effet, C est probablement le bon choix. Mais c'est plus de boulot.
|
||||
|
||||
Mon recommandation : A pour démarrer (~3-4h), test rapide, si OCR donne > 90% de précision sur les 11 lignes c'est gagné. Sinon on bascule B avant la fin de la journée.
|
||||
|
||||
Tu valides A en premier essai ?
|
||||
|
||||
Et pour le chat Léa, tu confirmes que c'est bien rpa-agent-chat (port 5004) qu'on utilise, ou un autre bout d'interface ?
|
||||
|
||||
✻ Baked for 2m 50s
|
||||
|
||||
❯ Ok, il faut que tu me dise pour simplifier l'arbre décisionnel pour moins de "mouvement". On pourrait avoir 1 seul champs texte dans lequel on décompose l'arbre ?
|
||||
|
||||
● Très bonne idée, et je la pousserais même un cran plus loin. Voici ma proposition :
|
||||
|
||||
Maquette simplifiée — passer de 6 anchors à 2
|
||||
|
||||
Avant (actuel)
|
||||
|
||||
- 3 textareas séparées (preuve_critere1/2/3)
|
||||
- 3 checkboxes (validation par critère)
|
||||
- → 6 anchors visuels à enregistrer + 5 actions à mécaniquer
|
||||
|
||||
Après (proposition)
|
||||
|
||||
- 1 grande textarea "Analyse PMSI" avec un texte structuré multi-sections
|
||||
- 1 sélecteur unique de décision : 2 boutons radio [ ] UHCD [ ] Forfait
|
||||
- → 2 anchors visuels + 2 actions
|
||||
|
||||
Format texte unique structuré (rendu par le LLM)
|
||||
|
||||
══════════════════════════════════════════
|
||||
DÉCISION : REQUALIFICATION_HOSPITALISATION (UHCD)
|
||||
Durée passage : 14h | Confiance : élevée
|
||||
|
||||
CRITÈRE 1 — Pathologie potentiellement évolutive : ✓ VALIDÉ
|
||||
« Asthme sévère avec FC 110 bpm, SpO2 92% en air ambiant »
|
||||
Patient instable nécessitant titration salbutamol IV.
|
||||
|
||||
CRITÈRE 2 — Surveillance médicale prolongée : ✓ VALIDÉ
|
||||
« Constantes itératives H+0, H+2, H+4, H+6 »
|
||||
6h de surveillance documentée par IDE.
|
||||
|
||||
CRITÈRE 3 — Examens / actes : ✗ NON VALIDÉ
|
||||
Aucun acte technique au compte-rendu hors aérosols.
|
||||
|
||||
JUSTIFICATION
|
||||
2/3 critères validés → REQUALIFICATION HOSPITALISATION justifiée.
|
||||
══════════════════════════════════════════
|
||||
|
||||
Ce qu'on gagne
|
||||
|
||||
1. Robustesse démo : 2 anchors au lieu de 6 → 3× moins de risques de panne
|
||||
2. Re-enregistrement rapide : ~10 min au lieu de 30
|
||||
3. Workflow plus court : ~20 actions au lieu de 25 (et surtout plus fiable)
|
||||
4. LLM plus simple : 1 sortie narrative + 1 décision booléenne au lieu de 9 champs (3 preuves + 3 booléens + 3 méta)
|
||||
5. Plus lisible pour DG/DIM : un rapport structuré qu'ils peuvent lire et défendre, pas du remplissage de formulaire
|
||||
6. Plus rapide visuellement : 1 frappe + 1 clic au lieu de 3 frappes + 3 clics
|
||||
|
||||
Ce qu'on perd
|
||||
|
||||
1. Distinction "case cochée par critère" : le côté formel "Léa coche chaque case" parle au métier mais c'est aussi un peu bureaucratique. Le rapport unique structuré est plus
|
||||
|
||||
Ce qu'on gagne
|
||||
|
||||
1. Robustesse démo : 2 anchors au lieu de 6 → 3× moins de risques de panne
|
||||
2. Re-enregistrement rapide : ~10 min au lieu de 30
|
||||
3. Workflow plus court : ~20 actions au lieu de 25 (et surtout plus fiable)
|
||||
4. LLM plus simple : 1 sortie narrative + 1 décision booléenne au lieu de 9 champs (3 preuves + 3 booléens + 3 méta)
|
||||
5. Plus lisible pour DG/DIM : un rapport structuré qu'ils peuvent lire et défendre, pas du remplissage de formulaire
|
||||
6. Plus rapide visuellement : 1 frappe + 1 clic au lieu de 3 frappes + 3 clics
|
||||
|
||||
Ce qu'on perd
|
||||
|
||||
1. Distinction "case cochée par critère" : le côté formel "Léa coche chaque case" parle au métier mais c'est aussi un peu bureaucratique. Le rapport unique structuré est plus consultatif.
|
||||
2. Rigueur formelle PMSI : si Easily Assure final exige 3 champs séparés, la maquette diverge — mais c'est un MOCKUP de démo, pas l'outil cible. À argumenter aux DG : "en intégration prod,
|
||||
on remplit les 3 champs métier".
|
||||
|
||||
Ma recommandation : encore mieux ?
|
||||
|
||||
Pour pousser le côté "Léa raisonne et propose" :
|
||||
|
||||
Format affiché en deux parties dans la maquette simplifiée :
|
||||
1. Bloc gauche (auto par Léa) : la grande textarea avec le rapport structuré
|
||||
2. Bloc droit (validation médecin) : les 2 boutons radio UHCD/Forfait
|
||||
|
||||
Léa remplit le bloc gauche (analyse) et coche le bloc droit (décision). Le médecin DIM valide ou corrige. C'est un message démo très fort : "Léa propose, le médecin reste maître". Cela
|
||||
parle aux DG (gain de temps) et au DIM (contrôle métier).
|
||||
|
||||
Décisions à prendre consultatif.
|
||||
2. Rigueur formelle PMSI : si Easily Assure final exige 3 champs séparés, la maquette diverge — mais c'est un MOCKUP de démo, pas l'outil cible. À argumenter aux DG : "en intégration
|
||||
prod, on remplit les 3 champs métier".
|
||||
|
||||
Ma recommandation : encore mieux ?
|
||||
|
||||
Pour pousser le côté "Léa raisonne et propose" :
|
||||
|
||||
Format affiché en deux parties dans la maquette simplifiée :
|
||||
1. Bloc gauche (auto par Léa) : la grande textarea avec le rapport structuré
|
||||
2. Bloc droit (validation médecin) : les 2 boutons radio UHCD/Forfait
|
||||
|
||||
Léa remplit le bloc gauche (analyse) et coche le bloc droit (décision). Le médecin DIM valide ou corrige. C'est un message démo très fort : "Léa propose, le médecin reste maître".
|
||||
Cela parle aux DG (gain de temps) et au DIM (contrôle métier).
|
||||
|
||||
Décisions à prendre
|
||||
|
||||
1. Tu valides la simplification maquette → 1 grande textarea + 2 radio UHCD/Forfait ?
|
||||
2. Tu modifies la maquette toi-même (HTML statique) ou je te propose un patch que tu appliques ?
|
||||
3. Le format texte structuré que j'ai donné en exemple te convient (ASCII frame, sections claires, citations entre « ») ou tu veux autre chose (JSON pretty-print, markdown, tableau)
|
||||
?
|
||||
|
||||
Une fois ça calé, je peux mettre à jour le prompt LLM en conséquence (réécrire t2a_decision.py pour produire 1 rapport narratif + 1 décision au lieu de 9 champs). Et toi tu
|
||||
modifies/me confirmes la maquette → on capture seulement 2 anchors et le workflow devient ultra-stable.
|
||||
|
||||
✻ Crunched for 57s
|
||||
|
||||
※ recap: Objectif : démo Léa "traite N dossiers" jeudi/vendredi en bouclant sur le tableau patients. Tâche actuelle : valider la simplification maquette (1 grande textarea + 2 radios
|
||||
UHCD/Forfait) au lieu de 3 textareas + 3 cases. Prochaine action : ton go/no-go sur cette simplification.
|
||||
|
||||
❯ Pour la simplification de l'interface sur l'arbre de décision et ce ça t'irait ou trop complexe : [Image #3]
|
||||
⎿ [Image #3]
|
||||
|
||||
● OUI excellente idée — cette interface est BEAUCOUP plus forte que ce que je proposais. Et techniquement elle simplifie radicalement la chaîne.
|
||||
|
||||
Ce que ça change positivement
|
||||
|
||||
┌───────────────────────────────┬───────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Aspect │ Avant (3 textareas + 3 checkboxes Easily) │ Après (aiva-vision) │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Anchors visuels à enregistrer │ 6 fragiles │ 2 robustes (textarea DPI + bouton Analyser) │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Actions de saisie │ 3 type_text + 3 click conditionnels │ 1 type_text + 1 click │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Risque de drift UI │ Élevé (formulaire formel) │ Faible (interface stable, contraste fort) │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Démontrabilité │ "Léa coche des cases" (RPA classique++) │ "Léa lit, analyse, propose une décision" (différenciation forte vs Evanov) │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Lisibilité pour DG/DIM │ Formulaire administratif │ Synthèse intelligente avec justification + critères + confiance + latence │
|
||||
├───────────────────────────────┼───────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Crédibilité métier │ "C'est juste de la saisie" │ "C'est une vraie analyse motivée" │
|
||||
└───────────────────────────────┴───────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Et plus subtil — le bandeau "Vérité-terrain"
|
||||
|
||||
Vérité-terrain : FORFAIT_URGENCE — concordance OK
|
||||
|
||||
→ ÇA, c'est génial pour la démo. Carvella va te demander "comment je sais que Léa ne se trompe pas ?". Tu pointes : "On a une vérité-terrain par dossier, on mesure la concordance.
|
||||
Sur les 11 dossiers qu'on a benchés, on est à X% d'accuracy".
|
||||
|
||||
C'est l'argument anti-FUD parfait.
|
||||
|
||||
Le scénario démo redessiné
|
||||
|
||||
Minute 0 : Dom dans le chat Léa
|
||||
« Léa, traite-moi 3 dossiers urgences »
|
||||
|
||||
Minute 0:10 : Léa se réduit. Chrome s'ouvre sur Easily Assure.
|
||||
|
||||
Minute 0:30 : Léa lit le tableau patients (extract_table)
|
||||
et énumère : « 25003284, 25003362, 25003364 »
|
||||
|
||||
Minute 1 : pour chaque dossier :
|
||||
- Ouvrir le dossier (click)
|
||||
- Naviguer onglets, lire (extract_text × 6)
|
||||
- Bascule sur aiva-vision
|
||||
- Coller le DPI consolidé (1 type_text)
|
||||
- Cliquer "Analyser" (1 click)
|
||||
- Aiva affiche la décision en 3s
|
||||
- Léa capture (screenshot d'évidence) + note la décision
|
||||
|
||||
Minute 4 : retour au chat Léa
|
||||
« Terminé.
|
||||
- Dossier 25003284 (MOREL, asthme) : FORFAIT URGENCES — confiance élevée
|
||||
- Dossier 25003362 (LAFFONT, intoxication) : FORFAIT URGENCES — confiance moyenne
|
||||
- Dossier 25003364 (LEROY, dyspnée) : REQUALIFICATION HOSPITALISATION (UHCD)
|
||||
→ valorisation +1500€ (forfait 50€ → MCO 1k-5k€) »
|
||||
|
||||
C'est propre, démontrable, court, et surtout chaque action est fiable (interface aiva-vision stable).
|
||||
|
||||
Mes questions pour finaliser
|
||||
|
||||
1. Cette interface aiva-vision est-elle déjà déployée et accessible depuis le PC Windows ? Quelle URL ?
|
||||
2. Le bouton "Analyser" déclenche le même backend t2a_decision.py côté serveur ou un autre endpoint ?
|
||||
3. Le DPI à coller : tu attends quel format ? (concaténation brute des t0+t1+t2+t3+t5+t6 que les extract_text produisent ?)
|
||||
4. Vérité-terrain : d'où vient-elle ? Stockée par dossier dans la maquette Easily ? Ou en base à part ?
|
||||
5. Question d'orchestration : après l'analyse aiva, on revient sur Easily pour saisir la décision (geste "Léa boucle la boucle" administrativement) ou on reste sur aiva (Léa propose,
|
||||
le médecin DIM saisira manuellement après) ? Les deux marchent, c'est un choix de narration.
|
||||
|
||||
Et la simplification qui découle
|
||||
|
||||
Avec cette interface, je peux supprimer de mon plan :
|
||||
- ❌ Re-enregistrer 6 anchors → seulement 2 désormais (gain : 30 min vs 1h)
|
||||
- ❌ Étendre templating au by_text (plus crucial puisque l'analyse se fait ailleurs)
|
||||
- ❌ La complexité "3 cases conditionnelles"
|
||||
|
||||
Ce qui reste à faire (revu à la baisse) :
|
||||
|
||||
┌─────┬─────────────────────────────────────────────────────────────────┬─────────────────────────┐
|
||||
│ # │ Brique │ Coût │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 1 │ extract_table côté serveur replay (OCR + parsing) │ 3-4h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 2 │ Boucle sur N IPP (orchestration Python OU mécanisme replay) │ 3h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 3 │ Action launch_chrome_url pour PC Windows │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 4 │ Variabiliser le step "sélection patient" │ 30 min │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 5 │ NLP commande chat "traite N dossiers" │ 1-2h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 6 │ Action minimiser Léa │ 30 min │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 7 │ Workflow "lecture aiva-vision après analyse" (capture décision) │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 8 │ Synthèse fin de boucle vers chat │ 1h │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 9 │ Re-enregistrement workflow Urgence simplifié │ 30 min (au lieu de 1h+) │
|
||||
├─────┼─────────────────────────────────────────────────────────────────┼─────────────────────────┤
|
||||
│ 10 │ Tests E2E │ 1 jour │
|
||||
└─────┴─────────────────────────────────────────────────────────────────┴─────────────────────────┘
|
||||
|
||||
Total : ~1.5 jours de dev + 0.5 jour re-enregistrement + 1 jour test. 2 jours de marge sur les 4 pour les imprévus. Bien plus confortable.
|
||||
|
||||
OK pour partir là-dessus ? Réponds aux 5 questions et je peux commencer dès que tu valides.
|
||||
|
||||
✻ Baked for 1m 14s
|
||||
|
||||
❯ Ok, pour moi ! De mon coté, je m'ocuppe de reprendre l'interface. Question, pourquoi un nlp sur léa ? ça ne risque pas d'alourdir et ralentir le traitement ?
|
||||
|
||||
● Excellente question. Tu as raison de challenger.
|
||||
|
||||
"NLP" est un mot un peu fort pour ce qu'on a vraiment besoin
|
||||
|
||||
Pour interpréter « Léa, traite-moi 3 dossiers », on a 3 niveaux de complexité possibles :
|
||||
|
||||
┌──────────────────────────────────────────────┬───────────────────────────────┬───────────────────────────────────────────────────────────────┬─────────────────────────────────┐
|
||||
│ Approche │ Coût │ Robustesse phrasing │ Effet démo │
|
||||
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
|
||||
│ A. Regex simple │ 0 ms, 0 GPU │ Comprend les variations clés (traite N, code N dossiers, │ Suffisant si Dom dit la phrase │
|
||||
│ │ │ etc.) si on les anticipe │ prévue │
|
||||
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
|
||||
│ B. Mini-LLM local (qwen2.5:0.5b ou │ ~500 ms, faible GPU │ Très robuste à toute variation │ Crédible "on parle vraiment à │
|
||||
│ llama3.2:1b, ~500 Mo) │ │ │ Léa" │
|
||||
├──────────────────────────────────────────────┼───────────────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤
|
||||
│ C. Gros LLM (qwen2.5:7b déjà chargé pour │ 1-3 s, conflit GPU avec │ Excellente │ Sur-dimensionné, risque de swap │
|
||||
│ t2a_decision) │ t2a_decision │ │ GPU │
|
||||
└──────────────────────────────────────────────┴───────────────────────────────┴───────────────────────────────────────────────────────────────┴─────────────────────────────────┘
|
||||
|
||||
C est exclu — ça reproduirait le problème de hier soir (t2a_decision LLM + NLP LLM = swap VRAM, ralentissement).
|
||||
|
||||
Ma recommandation : A (regex) pour démo + bouton de secours
|
||||
|
||||
A — Regex :
|
||||
# Patterns reconnus
|
||||
"traite (\d+) dossiers?"
|
||||
"code(?:-moi)? (?:les )?(\d+) (?:premiers )?dossiers?"
|
||||
"(?:peux-tu )?(?:traiter|coder) (\d+) dossiers?"
|
||||
|
||||
Si la regex matche → action lancée immédiatement. Si elle matche pas → Léa répond « Je n'ai pas compris. Tu peux dire par exemple : "traite 3 dossiers" ».
|
||||
|
||||
Coût : ~0 ms, 0 GPU, ultra fiable.
|
||||
|
||||
Bouton de secours dans l'UI chat : à côté du champ texte, un bouton dédié [ Traiter N dossiers ▼ ] (avec un select 1/3/5/tous). Si le jour J Dom est stressé et tape mal, il a
|
||||
toujours le bouton qui marche. Ceinture + bretelles.
|
||||
|
||||
Pour la démo, ça donne
|
||||
|
||||
[Dom dans le chat] : Léa, traite-moi 3 dossiers
|
||||
[Léa] : Compris, je traite 3 dossiers... (commence à bosser)
|
||||
|
||||
Visuellement : pareil que si c'était un gros LLM. Mais 0 latence sur cette étape, 0 risque de mauvais parsing.
|
||||
|
||||
Et si tu veux vraiment l'effet "Léa comprend tout" pour la démo
|
||||
|
||||
Option B (mini-LLM) est viable : qwen2.5:0.5b ou llama3.2:1b font ~500 Mo de VRAM, latence ~500 ms, pas en conflit avec t2a_decision (qui est sur qwen2.5:7b). Mais ça ajoute :
|
||||
- Une dépendance modèle de plus à gérer
|
||||
- Une étape de classification d'intention (parser le naturel en JSON action)
|
||||
- Un risque d'erreur d'interprétation que la regex n'a pas
|
||||
|
||||
Pour la démo, A est largement suffisant et te donne 100% de fiabilité. On garde B en bonus si on a 2h en J-1.
|
||||
|
||||
Et donc
|
||||
|
||||
Tu confirmes A (regex + bouton secours) ? C'est ~30 min de code au lieu de 1-2h, ça allège le plan.
|
||||
|
||||
Une fois que tu reviens avec aiva-vision côté interface, j'ai tout pour démarrer côté code. Réponds-moi en parallèle aux 5 questions techniques de mon précédent message (URL aiva,
|
||||
format DPI, vérité-terrain, etc.) quand tu peux.
|
||||
|
||||
✻ Worked for 51s
|
||||
|
||||
❯ on va essayer B pour mini llm. La question concernant l'interface : "Cette interface aiva-vision est-elle déjà déployée et accessible depuis le PC Windows ? Quelle URL ?" On parle
|
||||
bien de celle-la : [Image #4] ou c'est de celle là : [Image #5] Pour le dpi à coller, je te laisse le choix le plus "facileé. Mais [Image #6] ça serait pas trop mal. Attention, pour
|
||||
qu'il n'y ai pas de confusion, le dpi doit être lu et traiter paendant la démo. On est d'accord ? ça devrait répondre à tes autres questions ?
|
||||
⎿ [Image #4]
|
||||
⎿ [Image #5]
|
||||
⎿ [Image #6]
|
||||
|
||||
● C'est limpide, on tient la chaîne :
|
||||
|
||||
Easily (image 4) ──[Léa lit DPI via extract_text]──▶ aiva-vision (image 5)
|
||||
↑ ↑
|
||||
Source des données Cerveau d'analyse + affichage
|
||||
|
||||
Et oui sur le PENDANT — c'est ce qui fait la valeur démo. La promesse à Carvella & DG :
|
||||
- Léa lit le dossier en direct (extraits OCR du DPI) → reformule → soumet à analyse → décision motivée.
|
||||
- Pas de pré-remplissage caché. Tout est public à l'écran.
|
||||
|
||||
3 questions résiduelles avant de coder
|
||||
|
||||
1. URL de aiva-vision ? (j'imagine aiva.labs.laurinebazin.design ou similaire, mais je veux la vraie pour aller voir le DOM des 2 anchors textarea + bouton)
|
||||
2. Vérité-terrain : sur ton image 5 c'est affiché concordance OK — d'où vient cette info ? Stockée dans aiva-vision (par dossier, étiquettes pré-faites) ou calculée à la volée ?
|
||||
(Important : si la vérité-terrain est cachée dans aiva-vision, Léa peut la voir et un sceptique pourrait crier triche.)
|
||||
3. Une fois l'analyse faite côté aiva : Léa s'arrête là (capture la décision et passe au dossier suivant) ou elle revient sur Easily pour saisir la décision dans le formulaire Codage
|
||||
(boucle administrative complète) ?
|
||||
- Stop à aiva = démo plus courte, message clair "Léa propose"
|
||||
- Retour Easily = démo plus longue, message "Léa va jusqu'au bout"
|
||||
- Mon avis : stop à aiva pour la démo (plus court, plus impactant). On dira "en intégration, Léa pourrait bien sûr remplir Easily aussi".
|
||||
|
||||
Sur le DPI à coller — choix simple et fidèle
|
||||
|
||||
Le format de l'image 6 est exactement ce que la concaténation brute des extract_text produit (Easily a déjà ses sections "Motif", "Constantes", "Examen", "Bio", etc., l'OCR retourne
|
||||
ce texte tel quel). Donc :
|
||||
dpi_complet = f"{t0}\n{t1}\n{t2}\n{t3}\n{t5}\n{t6}"
|
||||
|
||||
Pas besoin de LLM de reformulation, pas d'étape supplémentaire, fidèle à ce que dit le dossier. Si en pratique c'est mal mis en page, on insère un step minimal (regex de nettoyage
|
||||
des doubles sauts de ligne).
|
||||
|
||||
Sur le mini-LLM B
|
||||
|
||||
Bon choix pour la démo. Mes candidats :
|
||||
|
||||
┌──────────────┬────────┬─────────────┬─────────┬──────────────────────────┐
|
||||
│ Modèle │ Taille │ Latence │ VRAM │ Robustesse │
|
||||
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
|
||||
│ llama3.2:1b │ 1.3 GB │ ~300-500 ms │ ~1.5 GB │ Très bonne en français │
|
||||
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
|
||||
│ qwen2.5:0.5b │ 400 MB │ ~200 ms │ ~800 MB │ Bonne, plus léger │
|
||||
├──────────────┼────────┼─────────────┼─────────┼──────────────────────────┤
|
||||
│ gemma3:1b │ 800 MB │ ~400 ms │ ~1.2 GB │ Excellente compréhension │
|
||||
└──────────────┴────────┴─────────────┴─────────┴──────────────────────────┘
|
||||
|
||||
Mon top : llama3.2:1b — meilleur compromis fr / latence / robustesse. ~500 ms pour parser "Léa, traite-moi 3 dossiers" → {action: "process_patients", count: 3, order: "first"}.
|
||||
Imperceptible, et surtout pas en conflit avec qwen2.5:7b (t2a_decision) car charges séparées.
|
||||
|
||||
Tu veux que je parte sur llama3.2:1b pour le NLP, ou tu as une autre préférence ?
|
||||
|
||||
Plan d'action immédiat (dès que tu réponds aux 3 questions)
|
||||
|
||||
1. Je vais voir aiva-vision avec Chrome MCP pour cartographier les 2 anchors
|
||||
2. Je pull llama3.2:1b côté Ollama si pas déjà là
|
||||
3. Je commence par extract_table (3-4h) — la brique la plus risquée à dérisquer en premier
|
||||
|
||||
Toi en parallèle : tu reprends l'interface (modifier la maquette si besoin) et tu me confirmes l'URL aiva.
|
||||
|
||||
✻ Sautéed for 57s
|
||||
|
||||
❯ https://urgence.labs.laurinebazin.design/index.html. Concordance ok est géré comme le reste à la volé, tu le vera lors de l'exploration du dom. Pour l'intégration, je pense que l'on
|
||||
pourrait faire boucle administrative complète. ça montre que l'on sait écrire aussi Pour le modéle, je pense qu'il faut faire des tests non ? tu peux dédier un agent dessus ? Si
|
||||
les modéles ne sont pas présent telecharge les. c'est bon pour toi ? jepeux aller manger ?
|
||||
|
||||
● Vas-y, bon appétit ! Je m'occupe de tout pendant ce temps.
|
||||
|
||||
Mon programme pendant que tu manges :
|
||||
1. Bench NLP via agent dédié (en background) — comparer llama3.2:1b, qwen2.5:0.5b, gemma3:1b sur des commandes types français + rapport comparatif
|
||||
2. Cartographier le DOM aiva-vision (textarea, bouton, zone décision, vérité-terrain)
|
||||
3. Préparer le terrain pour qu'on enchaîne sur le code à ton retour
|
||||
|
||||
● Bash(ollama list 2>&1 | head -30)
|
||||
⎿ Running…
|
||||
|
||||
✻ Hatching… (22s · ↓ 752 tokens)
|
||||
⎿ ◻ Diagnostiquer le pre-click qui ne prend pas le focus textarea
|
||||
✔ Récupérer agent.log Windows pour replay 15:56
|
||||
✔ Trouver le revert type_text x_pct/y_pct dans dag_execute.py
|
||||
✔ Présenter diagnostic complet + recommandation chiffrée
|
||||
✔ Élucider pourquoi 3 dispatch serveur, 1 reçu agent (3ᵉ)
|
||||
… +8 completed
|
||||
|
||||
@@ -76,7 +76,16 @@ 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."""
|
||||
return {
|
||||
@@ -92,9 +101,15 @@ 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
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'StepMetrics':
|
||||
"""Create from dictionary."""
|
||||
@@ -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.
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Args:
|
||||
enabled: Whether analytics collection is enabled
|
||||
analytics_system: Instance d'AnalyticsSystem pré-construite, ou
|
||||
True/False pour activer/désactiver (legacy).
|
||||
enabled: Legacy — si défini, prime sur analytics_system.
|
||||
"""
|
||||
self.enabled = enabled
|
||||
self.analytics = None
|
||||
|
||||
if enabled:
|
||||
# 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,37 +54,50 @@ 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())
|
||||
|
||||
|
||||
if execution_id is None:
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting execution tracking: {e}")
|
||||
|
||||
|
||||
return execution_id
|
||||
|
||||
def on_step_start(
|
||||
@@ -101,110 +132,249 @@ 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
|
||||
self.analytics.realtime_analytics.record_step_complete(
|
||||
execution_id=execution_id,
|
||||
success=success
|
||||
|
||||
# Tracking temps réel
|
||||
try:
|
||||
self.analytics.realtime_analytics.record_step_complete(
|
||||
execution_id=execution_id,
|
||||
success=success,
|
||||
)
|
||||
except Exception as rt_err:
|
||||
logger.debug(f"Realtime tracking skipped: {rt_err}")
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
logger.debug(f"Recorded step: {node_id} ({'success' if success else 'failed'})")
|
||||
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
|
||||
execution_metrics = ExecutionMetrics(
|
||||
execution_id=execution_id,
|
||||
workflow_id=workflow_id,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
duration=duration,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
steps_completed=steps_completed,
|
||||
steps_failed=steps_failed
|
||||
)
|
||||
|
||||
self.analytics.metrics_collector.record_execution(execution_metrics)
|
||||
|
||||
# Flush to ensure persistence
|
||||
self.analytics.metrics_collector.flush()
|
||||
|
||||
# Complete real-time tracking
|
||||
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_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,
|
||||
)
|
||||
# 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)
|
||||
|
||||
# Flush pour garantir la persistance immédiate
|
||||
collector.flush()
|
||||
|
||||
# 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})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording execution completion: {e}")
|
||||
@@ -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}")
|
||||
|
||||
|
||||
643
core/analytics/process_mining_bridge.py
Normal file
643
core/analytics/process_mining_bridge.py
Normal file
@@ -0,0 +1,643 @@
|
||||
"""
|
||||
Bridge entre les workflows Lea (core) et PM4Py pour le process mining.
|
||||
Genere des diagrammes BPMN et KPIs depuis les traces Shadow.
|
||||
|
||||
Usage:
|
||||
from core.analytics.process_mining_bridge import (
|
||||
sessions_to_event_log,
|
||||
workflow_to_event_log,
|
||||
discover_bpmn,
|
||||
compute_kpis,
|
||||
)
|
||||
|
||||
# Depuis des sessions JSONL brutes
|
||||
df = sessions_to_event_log(sessions_data)
|
||||
result = discover_bpmn(df, output_dir="data/analytics/bpmn")
|
||||
kpis = compute_kpis(df)
|
||||
|
||||
# Depuis un workflow core (dict JSON)
|
||||
df = workflow_to_event_log(workflow_dict)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- Import conditionnel PM4Py -----------------------------------------
|
||||
|
||||
try:
|
||||
import pm4py
|
||||
PM4PY_AVAILABLE = True
|
||||
except ImportError:
|
||||
PM4PY_AVAILABLE = False
|
||||
logger.warning("pm4py non installe -- le process mining est desactive")
|
||||
|
||||
|
||||
def _sanitize_label(label: str) -> str:
|
||||
"""
|
||||
Supprime les caracteres de controle (0x00-0x1F sauf tab/newline)
|
||||
qui sont invalides en XML et font planter PM4Py.
|
||||
"""
|
||||
return "".join(
|
||||
c if c in ("\t", "\n", "\r") or ord(c) >= 0x20 else f"<0x{ord(c):02x}>"
|
||||
for c in label
|
||||
)
|
||||
|
||||
|
||||
# ---- Types d'evenements a ignorer (bruit) --------------------------------
|
||||
|
||||
_NOISE_EVENT_TYPES = frozenset({
|
||||
"heartbeat",
|
||||
"action_result",
|
||||
"screenshot",
|
||||
})
|
||||
|
||||
# Types d'evenements significatifs pour le process mining
|
||||
_RELEVANT_EVENT_TYPES = frozenset({
|
||||
"mouse_click",
|
||||
"text_input",
|
||||
"key_press",
|
||||
"key_combo",
|
||||
"window_focus_change",
|
||||
})
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Conversion sessions JSONL -> event log PM4Py
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _build_activity_label(event: dict) -> Optional[str]:
|
||||
"""
|
||||
Construit un label d'activite lisible depuis un event JSONL brut.
|
||||
|
||||
Regles :
|
||||
- mouse_click -> "Clic - <app_name> (<window_title tronque>)"
|
||||
- text_input -> "Saisie '<text>' - <app_name>"
|
||||
- key_press -> "Touche <key> - <app_name>"
|
||||
- key_combo -> "Raccourci <keys> - <app_name>"
|
||||
- window_focus_change -> "Fenetre <to.title> (<to.app_name>)"
|
||||
|
||||
Tous les labels sont sanitises pour supprimer les caracteres de controle
|
||||
(ex: \\x13 pour Ctrl+S) qui sont invalides en XML/BPMN.
|
||||
"""
|
||||
evt = event.get("event", event)
|
||||
etype = evt.get("type", "")
|
||||
|
||||
if etype in _NOISE_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
# Extraction fenetre
|
||||
window = evt.get("window", {})
|
||||
app_name = window.get("app_name", "inconnu")
|
||||
win_title = window.get("title", "")
|
||||
# Tronquer le titre a 40 caracteres
|
||||
short_title = (win_title[:40] + "...") if len(win_title) > 40 else win_title
|
||||
|
||||
label: Optional[str] = None
|
||||
|
||||
if etype == "mouse_click":
|
||||
label = f"Clic - {app_name} ({short_title})"
|
||||
|
||||
elif etype == "text_input":
|
||||
text = evt.get("text", "")
|
||||
# Tronquer le texte a 20 caracteres pour rester lisible
|
||||
short_text = (text[:20] + "...") if len(text) > 20 else text
|
||||
label = f"Saisie '{short_text}' - {app_name}"
|
||||
|
||||
elif etype == "key_press":
|
||||
key = evt.get("key", "?")
|
||||
label = f"Touche {key} - {app_name}"
|
||||
|
||||
elif etype == "key_combo":
|
||||
keys = evt.get("keys", [])
|
||||
combo = "+".join(str(k) for k in keys)
|
||||
label = f"Raccourci {combo} - {app_name}"
|
||||
|
||||
elif etype == "window_focus_change":
|
||||
to_info = evt.get("to", {})
|
||||
if not to_info:
|
||||
return None
|
||||
to_title = to_info.get("title", "?")
|
||||
to_app = to_info.get("app_name", "?")
|
||||
label = f"Fenetre {to_title} ({to_app})"
|
||||
|
||||
else:
|
||||
# Types non reconnus : label generique
|
||||
label = f"{etype} - {app_name}"
|
||||
|
||||
return _sanitize_label(label) if label else None
|
||||
|
||||
|
||||
def _extract_timestamp(event: dict) -> Optional[float]:
|
||||
"""Extrait le timestamp unix depuis un event JSONL."""
|
||||
# Le timestamp peut etre au niveau racine ou dans event.timestamp
|
||||
evt = event.get("event", event)
|
||||
ts = evt.get("timestamp") or event.get("timestamp")
|
||||
if ts is not None:
|
||||
return float(ts)
|
||||
# Fallback sur le champ 't' (format simplifie)
|
||||
t = evt.get("t") or event.get("t")
|
||||
if t is not None:
|
||||
return float(t)
|
||||
return None
|
||||
|
||||
|
||||
def sessions_to_event_log(
|
||||
sessions_data: List[dict],
|
||||
deduplicate_windows: bool = True,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Convertit des traces de sessions brutes (events JSONL) en event log PM4Py.
|
||||
|
||||
Chaque event pertinent devient une ligne :
|
||||
- case:concept:name = session_id
|
||||
- concept:name = label d'activite (ex: "Clic - Notepad.exe (Bloc-notes)")
|
||||
- time:timestamp = timestamp UTC
|
||||
|
||||
Args:
|
||||
sessions_data: liste de dicts, chaque dict est une ligne JSONL parsee.
|
||||
deduplicate_windows: si True, supprime les window_focus_change
|
||||
consecutifs vers la meme fenetre (bruit typique de Windows).
|
||||
|
||||
Returns:
|
||||
DataFrame pret pour PM4Py.
|
||||
"""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
|
||||
# Regrouper par session_id pour le deduplication
|
||||
sessions: Dict[str, List[dict]] = {}
|
||||
for event in sessions_data:
|
||||
sid = event.get("session_id", "unknown")
|
||||
sessions.setdefault(sid, []).append(event)
|
||||
|
||||
for sid, events in sessions.items():
|
||||
# Trier par timestamp
|
||||
events.sort(key=lambda e: _extract_timestamp(e) or 0.0)
|
||||
last_window_label: Optional[str] = None
|
||||
|
||||
for event in events:
|
||||
label = _build_activity_label(event)
|
||||
if label is None:
|
||||
continue
|
||||
|
||||
ts = _extract_timestamp(event)
|
||||
if ts is None:
|
||||
continue
|
||||
|
||||
# Deduplication des changements de fenetre consecutifs
|
||||
evt = event.get("event", event)
|
||||
if deduplicate_windows and evt.get("type") == "window_focus_change":
|
||||
if label == last_window_label:
|
||||
continue
|
||||
last_window_label = label
|
||||
else:
|
||||
last_window_label = None
|
||||
|
||||
rows.append({
|
||||
"case:concept:name": sid,
|
||||
"concept:name": label,
|
||||
"time:timestamp": pd.Timestamp(
|
||||
datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
),
|
||||
"event_type": evt.get("type", ""),
|
||||
"app_name": evt.get("window", {}).get("app_name", ""),
|
||||
})
|
||||
|
||||
if not rows:
|
||||
logger.warning("Aucun evenement pertinent trouve dans les sessions")
|
||||
return pd.DataFrame(columns=[
|
||||
"case:concept:name",
|
||||
"concept:name",
|
||||
"time:timestamp",
|
||||
"event_type",
|
||||
"app_name",
|
||||
])
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
|
||||
logger.info(
|
||||
"Event log cree : %d evenements, %d sessions, %d activites distinctes",
|
||||
len(df),
|
||||
df["case:concept:name"].nunique(),
|
||||
df["concept:name"].nunique(),
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Conversion workflow core (dict JSON) -> event log PM4Py
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def workflow_to_event_log(workflow_dict: dict) -> pd.DataFrame:
|
||||
"""
|
||||
Convertit un workflow core (dict JSON) en DataFrame PM4Py.
|
||||
|
||||
Utilise les nodes et edges pour reconstituer une trace.
|
||||
Chaque chemin du entry_node vers un end_node = un case.
|
||||
|
||||
Mapping :
|
||||
- case:concept:name = workflow_id + suffixe de chemin
|
||||
- concept:name = node.name
|
||||
- time:timestamp = deduced from edge stats ou created_at
|
||||
"""
|
||||
wf_id = workflow_dict.get("workflow_id", "wf_unknown")
|
||||
nodes = {n["node_id"]: n for n in workflow_dict.get("nodes", [])}
|
||||
edges = workflow_dict.get("edges", [])
|
||||
entry_nodes = workflow_dict.get("entry_nodes", [])
|
||||
created_at = workflow_dict.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||
|
||||
if not nodes or not edges:
|
||||
logger.warning("Workflow vide ou sans edges : %s", wf_id)
|
||||
return pd.DataFrame(columns=[
|
||||
"case:concept:name",
|
||||
"concept:name",
|
||||
"time:timestamp",
|
||||
])
|
||||
|
||||
# Construire un graphe d'adjacence
|
||||
adjacency: Dict[str, List[dict]] = {}
|
||||
for edge in edges:
|
||||
from_node = edge.get("from_node") or edge.get("source_node", "")
|
||||
adjacency.setdefault(from_node, []).append(edge)
|
||||
|
||||
# Parcours DFS pour trouver les chemins (limites a eviter l'explosion)
|
||||
MAX_PATHS = 100
|
||||
paths: List[List[str]] = []
|
||||
|
||||
def _dfs(current: str, path: List[str], visited: set) -> None:
|
||||
if len(paths) >= MAX_PATHS:
|
||||
return
|
||||
if current in visited:
|
||||
# Boucle detectee, sauvegarder le chemin tel quel
|
||||
paths.append(path[:])
|
||||
return
|
||||
visited.add(current)
|
||||
path.append(current)
|
||||
|
||||
outgoing = adjacency.get(current, [])
|
||||
if not outgoing:
|
||||
# End node
|
||||
paths.append(path[:])
|
||||
else:
|
||||
for edge in outgoing:
|
||||
to_node = edge.get("to_node") or edge.get("target_node", "")
|
||||
if to_node:
|
||||
_dfs(to_node, path, visited)
|
||||
path.pop()
|
||||
visited.discard(current)
|
||||
|
||||
for entry in entry_nodes:
|
||||
if entry in nodes:
|
||||
_dfs(entry, [], set())
|
||||
|
||||
# Si pas d'entry nodes, essayer tous les nodes sans edges entrants
|
||||
if not paths:
|
||||
target_nodes = set()
|
||||
for edge in edges:
|
||||
to_node = edge.get("to_node") or edge.get("target_node", "")
|
||||
target_nodes.add(to_node)
|
||||
root_nodes = [nid for nid in nodes if nid not in target_nodes]
|
||||
for root in root_nodes[:3]:
|
||||
_dfs(root, [], set())
|
||||
|
||||
# Construire le DataFrame
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
base_time = pd.Timestamp(datetime.fromisoformat(created_at))
|
||||
except (ValueError, TypeError):
|
||||
base_time = pd.Timestamp(datetime.now(timezone.utc))
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
case_id = f"{wf_id}_path_{i}"
|
||||
for step_idx, node_id in enumerate(path):
|
||||
node = nodes.get(node_id, {})
|
||||
rows.append({
|
||||
"case:concept:name": case_id,
|
||||
"concept:name": node.get("name", node_id),
|
||||
"time:timestamp": base_time + pd.Timedelta(seconds=step_idx),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
if not df.empty:
|
||||
df = df.sort_values(["case:concept:name", "time:timestamp"]).reset_index(drop=True)
|
||||
logger.info(
|
||||
"Event log depuis workflow : %d evenements, %d chemins",
|
||||
len(df), len(paths),
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Decouverte BPMN
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def discover_bpmn(
|
||||
event_log_df: pd.DataFrame,
|
||||
output_dir: str = "data/analytics/bpmn",
|
||||
name: str = "process",
|
||||
) -> dict:
|
||||
"""
|
||||
Decouvre un modele BPMN depuis un event log via Inductive Miner.
|
||||
|
||||
Args:
|
||||
event_log_df: DataFrame au format PM4Py.
|
||||
output_dir: repertoire de sortie pour les fichiers generes.
|
||||
name: prefixe pour les noms de fichiers.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'bpmn_xml_path': str,
|
||||
'bpmn_image_path': str,
|
||||
'petri_net_image_path': str,
|
||||
'dfg_image_path': str,
|
||||
'stats': {
|
||||
'activities': int,
|
||||
'variants': int,
|
||||
'cases': int,
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not PM4PY_AVAILABLE:
|
||||
raise ImportError("pm4py n'est pas installe. Installez-le : pip install pm4py")
|
||||
|
||||
if event_log_df.empty:
|
||||
raise ValueError("Event log vide, impossible de decouvrir un BPMN")
|
||||
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Decouverte BPMN par Inductive Miner
|
||||
bpmn_model = pm4py.discover_bpmn_inductive(event_log_df)
|
||||
|
||||
# Export BPMN XML
|
||||
bpmn_xml_path = str(out / f"{name}.bpmn")
|
||||
try:
|
||||
pm4py.write_bpmn(bpmn_model, bpmn_xml_path)
|
||||
except Exception as e:
|
||||
# PM4Py layout peut echouer avec des labels contenant des caracteres
|
||||
# speciaux (accents, guillemets, etc.). Fallback : export via l'exporter
|
||||
# interne sans layout.
|
||||
logger.warning("Layout BPMN echoue (%s), export sans layout", e)
|
||||
from pm4py.objects.bpmn.exporter import exporter as bpmn_exporter
|
||||
bpmn_exporter.apply(bpmn_model, bpmn_xml_path)
|
||||
logger.info("BPMN XML exporte : %s", bpmn_xml_path)
|
||||
|
||||
# Export image BPMN (PNG) — grande taille pour lisibilité
|
||||
bpmn_image_path = str(out / f"{name}_bpmn.png")
|
||||
try:
|
||||
from pm4py.visualization.bpmn import visualizer as bpmn_vis
|
||||
gviz = bpmn_vis.apply(bpmn_model, parameters={
|
||||
"rankdir": "TB",
|
||||
"font_size": "12",
|
||||
})
|
||||
gviz.graph_attr["dpi"] = "150"
|
||||
gviz.graph_attr["size"] = "40,20!"
|
||||
gviz.graph_attr["rankdir"] = "TB"
|
||||
gviz.render(filename=bpmn_image_path.replace(".png", ""), format="png", cleanup=True)
|
||||
logger.info("BPMN PNG exporte : %s", bpmn_image_path)
|
||||
except Exception as e:
|
||||
logger.warning("BPMN image fallback : %s", e)
|
||||
try:
|
||||
pm4py.save_vis_bpmn(bpmn_model, bpmn_image_path)
|
||||
except Exception:
|
||||
bpmn_image_path = None
|
||||
|
||||
# DFG (Directly-Follows Graph) — grande taille
|
||||
dfg_image_path = str(out / f"{name}_dfg.png")
|
||||
try:
|
||||
from pm4py.visualization.dfg import visualizer as dfg_vis
|
||||
dfg, sa, ea = pm4py.discover_dfg(event_log_df)
|
||||
gviz = dfg_vis.apply(dfg, activities_count=sa, parameters={
|
||||
"start_activities": sa,
|
||||
"end_activities": ea,
|
||||
"rankdir": "TB",
|
||||
"font_size": "11",
|
||||
})
|
||||
gviz.graph_attr["dpi"] = "150"
|
||||
gviz.graph_attr["size"] = "40,20!"
|
||||
gviz.graph_attr["rankdir"] = "TB"
|
||||
gviz.render(filename=dfg_image_path.replace(".png", ""), format="png", cleanup=True)
|
||||
logger.info("DFG PNG exporte : %s", dfg_image_path)
|
||||
except Exception as e:
|
||||
logger.warning("DFG image fallback : %s", e)
|
||||
try:
|
||||
pm4py.save_vis_dfg(*pm4py.discover_dfg(event_log_df), file_path=dfg_image_path)
|
||||
except Exception:
|
||||
dfg_image_path = None
|
||||
|
||||
# Petri net via Inductive Miner (pour visualisation alternative)
|
||||
petri_image_path = str(out / f"{name}_petri.png")
|
||||
try:
|
||||
net, im, fm = pm4py.discover_petri_net_inductive(event_log_df)
|
||||
pm4py.save_vis_petri_net(net, im, fm, file_path=petri_image_path)
|
||||
logger.info("Petri net PNG exporte : %s", petri_image_path)
|
||||
except Exception as e:
|
||||
logger.warning("Impossible de generer le Petri net : %s", e)
|
||||
petri_image_path = None
|
||||
|
||||
# Stats de base
|
||||
variants = pm4py.get_variants(event_log_df)
|
||||
n_cases = event_log_df["case:concept:name"].nunique()
|
||||
n_activities = event_log_df["concept:name"].nunique()
|
||||
|
||||
result = {
|
||||
"bpmn_xml_path": bpmn_xml_path,
|
||||
"bpmn_image_path": bpmn_image_path,
|
||||
"petri_net_image_path": petri_image_path,
|
||||
"dfg_image_path": dfg_image_path,
|
||||
"stats": {
|
||||
"activities": n_activities,
|
||||
"variants": len(variants),
|
||||
"cases": n_cases,
|
||||
},
|
||||
}
|
||||
logger.info("Decouverte BPMN terminee : %s", result["stats"])
|
||||
return result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# KPIs de process mining
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def compute_kpis(event_log_df: pd.DataFrame) -> dict:
|
||||
"""
|
||||
Calcule les KPIs de process mining.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'total_cases': int,
|
||||
'total_events': int,
|
||||
'unique_activities': int,
|
||||
'variants_count': int,
|
||||
'variants_top5': list,
|
||||
'avg_case_duration_seconds': float,
|
||||
'median_case_duration_seconds': float,
|
||||
'avg_events_per_case': float,
|
||||
'activity_stats': {
|
||||
'<activity_name>': {
|
||||
'count': int,
|
||||
'avg_duration_seconds': float,
|
||||
'min_duration_seconds': float,
|
||||
'max_duration_seconds': float,
|
||||
}
|
||||
},
|
||||
'bottlenecks': [...], # top 3 activites les plus lentes
|
||||
'app_distribution': { '<app_name>': int },
|
||||
}
|
||||
"""
|
||||
if event_log_df.empty:
|
||||
return {
|
||||
"total_cases": 0,
|
||||
"total_events": 0,
|
||||
"unique_activities": 0,
|
||||
"variants_count": 0,
|
||||
"variants_top5": [],
|
||||
"avg_case_duration_seconds": 0.0,
|
||||
"median_case_duration_seconds": 0.0,
|
||||
"avg_events_per_case": 0.0,
|
||||
"activity_stats": {},
|
||||
"bottlenecks": [],
|
||||
"app_distribution": {},
|
||||
}
|
||||
|
||||
df = event_log_df.copy()
|
||||
|
||||
# ---- Metriques globales ----
|
||||
total_cases = df["case:concept:name"].nunique()
|
||||
total_events = len(df)
|
||||
unique_activities = df["concept:name"].nunique()
|
||||
|
||||
# ---- Variantes (PM4Py) ----
|
||||
if PM4PY_AVAILABLE:
|
||||
variants = pm4py.get_variants(df)
|
||||
variants_count = len(variants)
|
||||
# Top 5 variantes par frequence
|
||||
sorted_variants = sorted(variants.items(), key=lambda x: x[1], reverse=True)
|
||||
variants_top5 = [
|
||||
{"variant": " -> ".join(v), "count": c}
|
||||
for v, c in sorted_variants[:5]
|
||||
]
|
||||
else:
|
||||
variants_count = 0
|
||||
variants_top5 = []
|
||||
|
||||
# ---- Duree par case ----
|
||||
case_durations: List[float] = []
|
||||
for _case_id, group in df.groupby("case:concept:name"):
|
||||
ts = group["time:timestamp"]
|
||||
if len(ts) >= 2:
|
||||
duration = (ts.max() - ts.min()).total_seconds()
|
||||
case_durations.append(duration)
|
||||
|
||||
avg_case_dur = float(pd.Series(case_durations).mean()) if case_durations else 0.0
|
||||
median_case_dur = float(pd.Series(case_durations).median()) if case_durations else 0.0
|
||||
avg_events_per_case = total_events / total_cases if total_cases > 0 else 0.0
|
||||
|
||||
# ---- Stats par activite ----
|
||||
activity_stats: Dict[str, Dict[str, Any]] = {}
|
||||
# Calculer la duree entre chaque evenement et le suivant dans le meme case
|
||||
df_sorted = df.sort_values(["case:concept:name", "time:timestamp"])
|
||||
df_sorted["next_timestamp"] = df_sorted.groupby("case:concept:name")[
|
||||
"time:timestamp"
|
||||
].shift(-1)
|
||||
df_sorted["duration_to_next"] = (
|
||||
df_sorted["next_timestamp"] - df_sorted["time:timestamp"]
|
||||
).dt.total_seconds()
|
||||
|
||||
for activity, grp in df_sorted.groupby("concept:name"):
|
||||
durations = grp["duration_to_next"].dropna()
|
||||
# Filtrer les durees aberrantes (> 5 min = probablement une pause)
|
||||
durations = durations[durations <= 300]
|
||||
stats: Dict[str, Any] = {
|
||||
"count": len(grp),
|
||||
"avg_duration_seconds": round(float(durations.mean()), 2) if len(durations) > 0 else 0.0,
|
||||
"min_duration_seconds": round(float(durations.min()), 2) if len(durations) > 0 else 0.0,
|
||||
"max_duration_seconds": round(float(durations.max()), 2) if len(durations) > 0 else 0.0,
|
||||
}
|
||||
activity_stats[activity] = stats
|
||||
|
||||
# ---- Goulots d'etranglement (top 3 activites les plus lentes) ----
|
||||
bottlenecks = sorted(
|
||||
[
|
||||
{"activity": act, "avg_duration_seconds": s["avg_duration_seconds"]}
|
||||
for act, s in activity_stats.items()
|
||||
if s["avg_duration_seconds"] > 0
|
||||
],
|
||||
key=lambda x: x["avg_duration_seconds"],
|
||||
reverse=True,
|
||||
)[:3]
|
||||
|
||||
# ---- Distribution par application ----
|
||||
app_distribution: Dict[str, int] = {}
|
||||
if "app_name" in df.columns:
|
||||
app_distribution = df["app_name"].value_counts().to_dict()
|
||||
|
||||
return {
|
||||
"total_cases": total_cases,
|
||||
"total_events": total_events,
|
||||
"unique_activities": unique_activities,
|
||||
"variants_count": variants_count,
|
||||
"variants_top5": variants_top5,
|
||||
"avg_case_duration_seconds": round(avg_case_dur, 2),
|
||||
"median_case_duration_seconds": round(median_case_dur, 2),
|
||||
"avg_events_per_case": round(avg_events_per_case, 1),
|
||||
"activity_stats": activity_stats,
|
||||
"bottlenecks": bottlenecks,
|
||||
"app_distribution": app_distribution,
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Helpers : chargement sessions JSONL
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def load_jsonl_session(jsonl_path: str) -> List[dict]:
|
||||
"""
|
||||
Charge un fichier live_events.jsonl en liste de dicts.
|
||||
|
||||
Ignore les lignes vides ou invalides.
|
||||
"""
|
||||
events: List[dict] = []
|
||||
path = Path(jsonl_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Fichier JSONL introuvable : {jsonl_path}")
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Ligne %d invalide dans %s : %s", line_num, jsonl_path, e)
|
||||
|
||||
logger.info("Charge %d evenements depuis %s", len(events), jsonl_path)
|
||||
return events
|
||||
|
||||
|
||||
def load_multiple_sessions(session_dirs: List[str]) -> List[dict]:
|
||||
"""
|
||||
Charge plusieurs sessions depuis leurs repertoires.
|
||||
|
||||
Cherche un fichier live_events.jsonl dans chaque repertoire.
|
||||
"""
|
||||
all_events: List[dict] = []
|
||||
for session_dir in session_dirs:
|
||||
jsonl_path = Path(session_dir) / "live_events.jsonl"
|
||||
if jsonl_path.exists():
|
||||
all_events.extend(load_jsonl_session(str(jsonl_path)))
|
||||
else:
|
||||
logger.warning("Pas de live_events.jsonl dans %s", session_dir)
|
||||
return all_events
|
||||
60
core/analytics/screen_change_detector.py
Normal file
60
core/analytics/screen_change_detector.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Détection rapide de changement d'écran via perceptual hash (pHash).
|
||||
|
||||
Utilise imagehash pour calculer un hash perceptuel par screenshot.
|
||||
La distance de Hamming entre deux hashes indique le degré de changement :
|
||||
- < 5 : même écran (bruit, curseur déplacé)
|
||||
- 5-15 : changement mineur (scroll, popup, champ rempli)
|
||||
- > 15 : nouvel écran (nouvelle fenêtre, navigation)
|
||||
|
||||
Performance : ~15ms par hash sur CPU pour des screenshots 2560x1600.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
import imagehash
|
||||
from typing import Tuple, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ScreenChangeLevel(Enum):
|
||||
SAME = "same" # distance < 5
|
||||
MINOR = "minor" # 5 <= distance < 15
|
||||
MAJOR = "major" # distance >= 15
|
||||
|
||||
|
||||
def compute_phash(image: Image.Image, hash_size: int = 8) -> imagehash.ImageHash:
|
||||
"""Calcule le pHash d'une image PIL."""
|
||||
return imagehash.phash(image, hash_size=hash_size)
|
||||
|
||||
|
||||
def compare_screenshots(img1: Image.Image, img2: Image.Image, hash_size: int = 8) -> Tuple[int, ScreenChangeLevel]:
|
||||
"""
|
||||
Compare deux screenshots et retourne la distance + le niveau de changement.
|
||||
|
||||
Returns:
|
||||
(distance, level) — distance de Hamming et niveau de changement
|
||||
"""
|
||||
h1 = compute_phash(img1, hash_size)
|
||||
h2 = compute_phash(img2, hash_size)
|
||||
distance = h1 - h2
|
||||
|
||||
if distance < 5:
|
||||
level = ScreenChangeLevel.SAME
|
||||
elif distance < 15:
|
||||
level = ScreenChangeLevel.MINOR
|
||||
else:
|
||||
level = ScreenChangeLevel.MAJOR
|
||||
|
||||
return distance, level
|
||||
|
||||
|
||||
def compare_hashes(hash1: imagehash.ImageHash, hash2: imagehash.ImageHash) -> Tuple[int, ScreenChangeLevel]:
|
||||
"""Compare deux hashes pré-calculés."""
|
||||
distance = hash1 - hash2
|
||||
if distance < 5:
|
||||
level = ScreenChangeLevel.SAME
|
||||
elif distance < 15:
|
||||
level = ScreenChangeLevel.MINOR
|
||||
else:
|
||||
level = ScreenChangeLevel.MAJOR
|
||||
return distance, level
|
||||
@@ -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,11 +109,40 @@ 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):
|
||||
@@ -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)
|
||||
0
core/cognition/__init__.py
Normal file
0
core/cognition/__init__.py
Normal file
191
core/cognition/vram_orchestrator.py
Normal file
191
core/cognition/vram_orchestrator.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Orchestrateur VRAM — gère le chargement/déchargement des modèles selon le mode.
|
||||
|
||||
Deux modes :
|
||||
- SHADOW : streaming server + agent_chat actifs, VLM raisonnement déchargé
|
||||
- REPLAY : VLM raisonnement (qwen2.5vl:7b) chargé, services non-essentiels stoppés
|
||||
|
||||
Bascule automatique ou manuelle selon le contexte.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
REASONING_MODEL = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
MIN_VRAM_FOR_REASONING = 5.0 # Go minimum pour charger le modèle de raisonnement
|
||||
|
||||
|
||||
class VRAMMode(Enum):
|
||||
SHADOW = "shadow"
|
||||
REPLAY = "replay"
|
||||
|
||||
|
||||
class VRAMOrchestrator:
|
||||
"""Gère la VRAM pour éviter les conflits entre modèles."""
|
||||
|
||||
def __init__(self):
|
||||
self._current_mode: Optional[VRAMMode] = None
|
||||
self._stopped_services: list = []
|
||||
|
||||
def get_free_vram_gb(self) -> float:
|
||||
"""Retourne la VRAM libre en Go."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return float(result.stdout.strip()) / 1024
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_used_vram_gb(self) -> float:
|
||||
"""Retourne la VRAM utilisée en Go."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return float(result.stdout.strip()) / 1024
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def switch_to_replay(self) -> bool:
|
||||
"""Bascule en mode replay : libère la VRAM pour le VLM de raisonnement.
|
||||
|
||||
1. Stoppe les services non-essentiels (agent_chat)
|
||||
2. Redémarre Ollama pour libérer les modèles chargés
|
||||
3. Précharge le modèle de raisonnement
|
||||
"""
|
||||
if self._current_mode == VRAMMode.REPLAY:
|
||||
logger.info("Déjà en mode REPLAY")
|
||||
return True
|
||||
|
||||
logger.info("Bascule en mode REPLAY...")
|
||||
|
||||
# Stopper agent_chat si il tourne
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "agent_chat"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
if pid.strip():
|
||||
subprocess.run(["kill", pid.strip()], timeout=5)
|
||||
self._stopped_services.append(("agent_chat", pid.strip()))
|
||||
logger.info(f"agent_chat stoppé (PID {pid.strip()})")
|
||||
except Exception as e:
|
||||
logger.debug(f"Pas d'agent_chat à stopper: {e}")
|
||||
|
||||
# Redémarrer Ollama pour libérer la mémoire
|
||||
try:
|
||||
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
|
||||
timeout=10, check=True)
|
||||
time.sleep(2)
|
||||
logger.info("Ollama redémarré")
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de redémarrer Ollama: {e}")
|
||||
|
||||
# Vérifier la VRAM disponible
|
||||
free = self.get_free_vram_gb()
|
||||
logger.info(f"VRAM libre: {free:.1f} Go")
|
||||
|
||||
if free < MIN_VRAM_FOR_REASONING:
|
||||
logger.warning(f"VRAM insuffisante ({free:.1f} Go < {MIN_VRAM_FOR_REASONING} Go)")
|
||||
return False
|
||||
|
||||
# Précharger le modèle de raisonnement
|
||||
try:
|
||||
import requests
|
||||
logger.info(f"Préchargement {REASONING_MODEL}...")
|
||||
resp = requests.post(f"{OLLAMA_URL}/api/generate", json={
|
||||
"model": REASONING_MODEL,
|
||||
"prompt": "test",
|
||||
"stream": False,
|
||||
"options": {"num_predict": 1}
|
||||
}, timeout=60)
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"{REASONING_MODEL} chargé en VRAM")
|
||||
free_after = self.get_free_vram_gb()
|
||||
logger.info(f"VRAM libre après chargement: {free_after:.1f} Go")
|
||||
except Exception as e:
|
||||
logger.warning(f"Préchargement échoué: {e}")
|
||||
|
||||
self._current_mode = VRAMMode.REPLAY
|
||||
return True
|
||||
|
||||
def switch_to_shadow(self) -> bool:
|
||||
"""Bascule en mode shadow : relance les services d'observation.
|
||||
|
||||
1. Redémarre Ollama (décharge le VLM de raisonnement)
|
||||
2. Relance les services stoppés
|
||||
"""
|
||||
if self._current_mode == VRAMMode.SHADOW:
|
||||
logger.info("Déjà en mode SHADOW")
|
||||
return True
|
||||
|
||||
logger.info("Bascule en mode SHADOW...")
|
||||
|
||||
# Redémarrer Ollama
|
||||
try:
|
||||
subprocess.run(["sudo", "systemctl", "restart", "ollama"],
|
||||
timeout=10, check=True)
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de redémarrer Ollama: {e}")
|
||||
|
||||
# Relancer les services stoppés
|
||||
for service_name, _pid in self._stopped_services:
|
||||
try:
|
||||
if service_name == "agent_chat":
|
||||
subprocess.Popen(
|
||||
["python3", "-m", "agent_chat.app"],
|
||||
cwd="/home/dom/ai/rpa_vision_v3",
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
logger.info(f"{service_name} relancé")
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de relancer {service_name}: {e}")
|
||||
|
||||
self._stopped_services.clear()
|
||||
self._current_mode = VRAMMode.SHADOW
|
||||
return True
|
||||
|
||||
def ensure_reasoning_ready(self) -> bool:
|
||||
"""Vérifie que le VLM de raisonnement est prêt. Bascule si nécessaire."""
|
||||
free = self.get_free_vram_gb()
|
||||
if free >= MIN_VRAM_FOR_REASONING:
|
||||
return True
|
||||
return self.switch_to_replay()
|
||||
|
||||
@property
|
||||
def current_mode(self) -> Optional[str]:
|
||||
return self._current_mode.value if self._current_mode else None
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"mode": self.current_mode,
|
||||
"vram_free_gb": round(self.get_free_vram_gb(), 1),
|
||||
"vram_used_gb": round(self.get_used_vram_gb(), 1),
|
||||
"reasoning_model": REASONING_MODEL,
|
||||
"stopped_services": [s[0] for s in self._stopped_services],
|
||||
}
|
||||
|
||||
|
||||
# Singleton
|
||||
_orchestrator: Optional[VRAMOrchestrator] = None
|
||||
|
||||
|
||||
def get_orchestrator() -> VRAMOrchestrator:
|
||||
global _orchestrator
|
||||
if _orchestrator is None:
|
||||
_orchestrator = VRAMOrchestrator()
|
||||
return _orchestrator
|
||||
260
core/cognition/working_memory.py
Normal file
260
core/cognition/working_memory.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Mémoire de travail de Léa — contexte cognitif pendant l'exécution.
|
||||
|
||||
Donne à Léa la conscience de "où elle en est" :
|
||||
- Quel objectif elle poursuit
|
||||
- Quel écran elle voit
|
||||
- Ce qu'elle vient de faire
|
||||
- Ce qu'elle doit faire ensuite
|
||||
- Ce qu'elle a appris en cours de route
|
||||
|
||||
Sans ça, chaque étape est indépendante — Léa est amnésique entre
|
||||
deux actions. Avec ça, elle raisonne en contexte.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Observation:
|
||||
"""Ce que Léa observe sur l'écran à un instant donné."""
|
||||
timestamp: datetime
|
||||
window_title: str = ""
|
||||
application: str = ""
|
||||
ocr_text: str = ""
|
||||
ui_pattern: Optional[str] = None
|
||||
screen_description: str = ""
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionRecord:
|
||||
"""Une action que Léa a effectuée."""
|
||||
timestamp: datetime
|
||||
action_type: str
|
||||
target: str = ""
|
||||
result: str = ""
|
||||
success: bool = True
|
||||
duration_ms: float = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CognitiveContext:
|
||||
"""Contexte cognitif complet — la "pensée" de Léa à un instant donné.
|
||||
|
||||
C'est le bloc-notes interne qui est réinjecté à chaque décision.
|
||||
Le VLM reçoit ce contexte pour raisonner en connaissance de cause.
|
||||
"""
|
||||
|
||||
# Objectif global (ce que Léa essaie d'accomplir)
|
||||
objective: str = ""
|
||||
|
||||
# Étape courante dans le plan
|
||||
current_step: int = 0
|
||||
total_steps: int = 0
|
||||
current_step_description: str = ""
|
||||
|
||||
# Ce que Léa voit maintenant
|
||||
current_observation: Optional[Observation] = None
|
||||
|
||||
# Historique des N dernières actions (mémoire court terme)
|
||||
action_history: List[ActionRecord] = field(default_factory=list)
|
||||
max_history: int = 10
|
||||
|
||||
# Ce que Léa a appris pendant cette session
|
||||
learned_facts: List[str] = field(default_factory=list)
|
||||
|
||||
# Plan : les étapes restantes
|
||||
remaining_steps: List[str] = field(default_factory=list)
|
||||
|
||||
# État émotionnel / confiance
|
||||
confidence: float = 1.0
|
||||
needs_help: bool = False
|
||||
help_reason: str = ""
|
||||
|
||||
# Timing
|
||||
session_id: str = ""
|
||||
machine_id: str = ""
|
||||
started_at: Optional[datetime] = None
|
||||
step_started_at: Optional[datetime] = None
|
||||
step_durations: Dict[str, List[float]] = field(default_factory=dict)
|
||||
|
||||
# Ce que Léa devrait voir à l'écran (comparaison attendu vs réel)
|
||||
expected_screen: str = ""
|
||||
|
||||
def record_action(self, action_type: str, target: str = "",
|
||||
result: str = "", success: bool = True,
|
||||
duration_ms: float = 0):
|
||||
"""Enregistre une action dans l'historique."""
|
||||
self.action_history.append(ActionRecord(
|
||||
timestamp=datetime.now(),
|
||||
action_type=action_type,
|
||||
target=target,
|
||||
result=result,
|
||||
success=success,
|
||||
duration_ms=duration_ms,
|
||||
))
|
||||
if len(self.action_history) > self.max_history:
|
||||
self.action_history = self.action_history[-self.max_history:]
|
||||
|
||||
if not success:
|
||||
self.confidence = max(0, self.confidence - 0.2)
|
||||
else:
|
||||
self.confidence = min(1.0, self.confidence + 0.05)
|
||||
|
||||
def observe(self, window_title: str = "", application: str = "",
|
||||
ocr_text: str = "", ui_pattern: Optional[str] = None,
|
||||
screen_description: str = ""):
|
||||
"""Met à jour l'observation courante."""
|
||||
self.current_observation = Observation(
|
||||
timestamp=datetime.now(),
|
||||
window_title=window_title,
|
||||
application=application,
|
||||
ocr_text=ocr_text,
|
||||
ui_pattern=ui_pattern,
|
||||
screen_description=screen_description,
|
||||
)
|
||||
|
||||
def advance_step(self):
|
||||
"""Passe à l'étape suivante du plan."""
|
||||
# Enregistrer la durée de l'étape précédente
|
||||
if self.step_started_at:
|
||||
duration = (datetime.now() - self.step_started_at).total_seconds()
|
||||
step_key = self.current_step_description or f"step_{self.current_step}"
|
||||
self.step_durations.setdefault(step_key, []).append(duration)
|
||||
|
||||
self.current_step += 1
|
||||
self.step_started_at = datetime.now()
|
||||
if self.remaining_steps:
|
||||
self.current_step_description = self.remaining_steps.pop(0)
|
||||
|
||||
def get_step_timing(self) -> Optional[Dict[str, Any]]:
|
||||
"""Retourne les infos de timing de l'étape en cours."""
|
||||
if not self.step_started_at:
|
||||
return None
|
||||
|
||||
elapsed = (datetime.now() - self.step_started_at).total_seconds()
|
||||
step_key = self.current_step_description or f"step_{self.current_step}"
|
||||
history = self.step_durations.get(step_key, [])
|
||||
avg = sum(history) / len(history) if history else None
|
||||
|
||||
result = {"elapsed_seconds": elapsed}
|
||||
if avg:
|
||||
result["avg_previous"] = avg
|
||||
result["is_slow"] = elapsed > avg * 2
|
||||
return result
|
||||
|
||||
def set_expected_screen(self, description: str):
|
||||
"""Définit ce que Léa devrait voir à l'écran pour cette étape."""
|
||||
self.expected_screen = description
|
||||
|
||||
def check_screen_matches_expected(self) -> Optional[bool]:
|
||||
"""Compare l'observation actuelle avec l'écran attendu."""
|
||||
if not self.expected_screen or not self.current_observation:
|
||||
return None
|
||||
obs_text = (self.current_observation.window_title + " " +
|
||||
self.current_observation.ocr_text).lower()
|
||||
expected_words = self.expected_screen.lower().split()
|
||||
matches = sum(1 for w in expected_words if w in obs_text)
|
||||
return matches / max(len(expected_words), 1) > 0.3
|
||||
|
||||
def learn(self, fact: str):
|
||||
"""Enregistre un fait appris pendant l'exécution."""
|
||||
if fact not in self.learned_facts:
|
||||
self.learned_facts.append(fact)
|
||||
logger.info(f"Fait appris: {fact}")
|
||||
|
||||
def ask_for_help(self, reason: str):
|
||||
"""Signale que Léa a besoin d'aide."""
|
||||
self.needs_help = True
|
||||
self.help_reason = reason
|
||||
self.confidence = max(0, self.confidence - 0.3)
|
||||
logger.warning(f"Léa demande de l'aide: {reason}")
|
||||
|
||||
def to_prompt_context(self) -> str:
|
||||
"""Génère le contexte à injecter dans le prompt VLM.
|
||||
|
||||
C'est ce texte qui donne au VLM la conscience de la situation.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
if self.objective:
|
||||
lines.append(f"OBJECTIF : {self.objective}")
|
||||
|
||||
if self.current_step > 0:
|
||||
lines.append(f"PROGRESSION : étape {self.current_step}/{self.total_steps}")
|
||||
if self.current_step_description:
|
||||
lines.append(f"ÉTAPE EN COURS : {self.current_step_description}")
|
||||
|
||||
if self.current_observation:
|
||||
obs = self.current_observation
|
||||
if obs.window_title:
|
||||
lines.append(f"FENÊTRE ACTIVE : {obs.window_title}")
|
||||
if obs.application:
|
||||
lines.append(f"APPLICATION : {obs.application}")
|
||||
if obs.ui_pattern:
|
||||
lines.append(f"DIALOGUE DÉTECTÉ : {obs.ui_pattern}")
|
||||
|
||||
if self.action_history:
|
||||
last_actions = self.action_history[-3:]
|
||||
lines.append("DERNIÈRES ACTIONS :")
|
||||
for a in last_actions:
|
||||
status = "OK" if a.success else "ÉCHEC"
|
||||
lines.append(f" - {a.action_type} '{a.target}' → {status}")
|
||||
|
||||
if self.learned_facts:
|
||||
lines.append("FAITS APPRIS :")
|
||||
for fact in self.learned_facts[-5:]:
|
||||
lines.append(f" - {fact}")
|
||||
|
||||
if self.remaining_steps:
|
||||
lines.append("PROCHAINES ÉTAPES :")
|
||||
for step in self.remaining_steps[:3]:
|
||||
lines.append(f" - {step}")
|
||||
|
||||
timing = self.get_step_timing()
|
||||
if timing:
|
||||
lines.append(f"TEMPS ÉTAPE : {timing['elapsed_seconds']:.1f}s")
|
||||
if timing.get('avg_previous'):
|
||||
lines.append(f"MOYENNE PRÉCÉDENTE : {timing['avg_previous']:.1f}s")
|
||||
if timing.get('is_slow'):
|
||||
lines.append("⚠ ÉTAPE ANORMALEMENT LENTE")
|
||||
|
||||
if self.expected_screen:
|
||||
match = self.check_screen_matches_expected()
|
||||
if match is False:
|
||||
lines.append(f"⚠ ÉCRAN INATTENDU (attendu: {self.expected_screen})")
|
||||
elif match is True:
|
||||
lines.append(f"ÉCRAN CONFORME : {self.expected_screen}")
|
||||
|
||||
lines.append(f"CONFIANCE : {self.confidence:.0%}")
|
||||
|
||||
if self.needs_help:
|
||||
lines.append(f"BESOIN D'AIDE : {self.help_reason}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise le contexte pour le stockage/transport."""
|
||||
return {
|
||||
"objective": self.objective,
|
||||
"current_step": self.current_step,
|
||||
"total_steps": self.total_steps,
|
||||
"current_step_description": self.current_step_description,
|
||||
"confidence": self.confidence,
|
||||
"needs_help": self.needs_help,
|
||||
"help_reason": self.help_reason,
|
||||
"action_count": len(self.action_history),
|
||||
"learned_facts": self.learned_facts,
|
||||
"remaining_steps": self.remaining_steps,
|
||||
"last_observation": {
|
||||
"window_title": self.current_observation.window_title,
|
||||
"application": self.current_observation.application,
|
||||
"ui_pattern": self.current_observation.ui_pattern,
|
||||
} if self.current_observation else None,
|
||||
}
|
||||
@@ -68,11 +68,11 @@ 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
|
||||
|
||||
|
||||
# FAISS
|
||||
faiss_dimensions: int = 512
|
||||
faiss_index_type: str = "Flat"
|
||||
@@ -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
|
||||
|
||||
@@ -23,9 +23,9 @@ class OllamaClient:
|
||||
Permet d'envoyer des images et prompts à un VLM via l'API Ollama.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
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('/')
|
||||
self.model = model
|
||||
# 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,21 +865,24 @@ 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:
|
||||
"""
|
||||
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,
|
||||
|
||||
198
core/detection/vlm_config.py
Normal file
198
core/detection/vlm_config.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
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)
|
||||
# Bench 2026-05-16 : tentatives qwen2.5vl:7b et :3b écartées (runtime Ollama
|
||||
# avec context = 10-13 GB → débordent toutes en 100% CPU sur RTX 5070 12 GB).
|
||||
# qwen3-vl:8b écarté : think:false ignoré → tout en thinking field, pas de réponse.
|
||||
# gemma4:latest reste le seul stable malgré son cold start ~20s (1 fois par run).
|
||||
DEFAULT_VLM_MODEL = "gemma4:latest"
|
||||
|
||||
# Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo
|
||||
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
|
||||
@@ -58,9 +58,19 @@ class CLIPEmbedder(EmbedderBase):
|
||||
"Install it with: pip install open-clip-torch"
|
||||
)
|
||||
|
||||
# Default to CPU to save GPU for vision models (Qwen3-VL, etc.)
|
||||
if device is None:
|
||||
device = "cpu"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
free_vram = torch.cuda.mem_get_info()[0] / 1024**3
|
||||
if free_vram > 1.5:
|
||||
device = "cuda"
|
||||
else:
|
||||
device = "cpu"
|
||||
else:
|
||||
device = "cpu"
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
|
||||
self.model_name = model_name
|
||||
self.pretrained = pretrained
|
||||
|
||||
@@ -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)
|
||||
|
||||
# Créer instance
|
||||
# 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
|
||||
|
||||
# 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"],
|
||||
|
||||
@@ -10,6 +10,7 @@ from .error_handler import ErrorHandler, ErrorType, RecoveryStrategy
|
||||
from .workflow_runner import WorkflowRunner, RunResult, RunStatus, RunnerConfig
|
||||
from .dag_executor import DAGExecutor, WorkflowStep, StepType, StepStatus, DAGExecutionResult
|
||||
from .llm_actions import LLMActionHandler
|
||||
from .observe_reason_act import ORALoop, Observation, Decision, VerificationResult, LoopResult
|
||||
|
||||
# Import tardif pour éviter import circulaire avec pipeline
|
||||
def _get_execution_loop():
|
||||
@@ -34,5 +35,11 @@ __all__ = [
|
||||
'StepStatus',
|
||||
'DAGExecutionResult',
|
||||
'LLMActionHandler',
|
||||
# ORA — boucle Observe-Raisonne-Agit avec vérification
|
||||
'ORALoop',
|
||||
'Observation',
|
||||
'Decision',
|
||||
'VerificationResult',
|
||||
'LoopResult',
|
||||
# ExecutionLoop accessible via import direct du module
|
||||
]
|
||||
|
||||
@@ -654,7 +654,8 @@ class ActionExecutor:
|
||||
if PYAUTOGUI_AVAILABLE:
|
||||
pyautogui.click(click_x, click_y)
|
||||
time.sleep(0.2)
|
||||
pyautogui.write(text, interval=0.05)
|
||||
from .input_handler import safe_type_text
|
||||
safe_type_text(text)
|
||||
else:
|
||||
logger.info(f" (Simulated click at {click_x:.0f}, {click_y:.0f})")
|
||||
logger.info(f" (Simulated typing: {text[:50]}...)")
|
||||
|
||||
@@ -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,25 +548,47 @@ 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(
|
||||
execution_id=self.context.execution_id
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
@@ -533,56 +598,142 @@ class ExecutionLoop:
|
||||
def _execute_step(self) -> Optional[StepResult]:
|
||||
"""
|
||||
Exécuter une étape du workflow.
|
||||
|
||||
|
||||
Returns:
|
||||
StepResult ou None si pas de match
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# 1. Capturer l'écran
|
||||
screenshot_path = self._capture_screen()
|
||||
if not screenshot_path:
|
||||
logger.warning("Failed to capture screen")
|
||||
return None
|
||||
|
||||
|
||||
self.context.last_screenshot_path = screenshot_path
|
||||
|
||||
# 2. Identifier l'état actuel (matching)
|
||||
match = self.pipeline.match_current_state(
|
||||
screenshot_path,
|
||||
workflow_id=self.context.workflow_id
|
||||
|
||||
# 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)
|
||||
#
|
||||
# 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:
|
||||
logger.debug("No match found for current screen")
|
||||
return None
|
||||
|
||||
|
||||
current_node_id = match["node_id"]
|
||||
confidence = match["confidence"]
|
||||
self.context.current_node_id = current_node_id
|
||||
self.context.last_match_confidence = confidence
|
||||
|
||||
|
||||
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,33 +816,40 @@ 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
|
||||
if action_result and action_result.status == ExecutionStatus.SUCCESS:
|
||||
self.context.steps_succeeded += 1
|
||||
elif action_result:
|
||||
self.context.steps_failed += 1
|
||||
|
||||
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
|
||||
|
||||
return StepResult(
|
||||
success=action_result.status == ExecutionStatus.SUCCESS if action_result else True,
|
||||
node_id=current_node_id,
|
||||
@@ -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,61 +889,45 @@ 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)
|
||||
edge = workflow.get_edge(action_info["edge_id"])
|
||||
|
||||
|
||||
if not edge:
|
||||
return ExecutionResult(
|
||||
status=ExecutionStatus.FAILED,
|
||||
message=f"Edge not found: {action_info['edge_id']}",
|
||||
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(
|
||||
edge,
|
||||
screen_state,
|
||||
context=self.context.variables
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Action executed: {result.status.value} - {result.message}")
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Action execution failed: {e}")
|
||||
return ExecutionResult(
|
||||
@@ -781,6 +936,286 @@ class ExecutionLoop:
|
||||
duration_ms=0,
|
||||
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."""
|
||||
|
||||
757
core/execution/input_handler.py
Normal file
757
core/execution/input_handler.py
Normal file
@@ -0,0 +1,757 @@
|
||||
"""
|
||||
Module partagé de saisie texte et gestion des dialogues.
|
||||
|
||||
Utilisé par les deux executors :
|
||||
- VWB executor (visual_workflow_builder/backend/api_v3/execute.py)
|
||||
- Core executor (core/execution/action_executor.py)
|
||||
|
||||
Garantit le même comportement AZERTY/VM/Citrix partout.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except Exception:
|
||||
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
|
||||
# quand X n'est pas accessible — typique d'un service systemd côté serveur.
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import mss
|
||||
MSS_AVAILABLE = True
|
||||
except ImportError:
|
||||
MSS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
|
||||
|
||||
def safe_type_text(text: str):
|
||||
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
|
||||
|
||||
Priorité :
|
||||
1. xdotool type avec refresh layout → traverse les VM spice/QEMU
|
||||
2. Presse-papier (xclip) + Ctrl+V → fallback
|
||||
3. pyautogui.write() → dernier recours
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Méthode 1 : xdotool type avec refresh du layout clavier
|
||||
if shutil.which('xdotool') and shutil.which('setxkbmap'):
|
||||
try:
|
||||
subprocess.run(['setxkbmap', 'fr'], timeout=2)
|
||||
subprocess.run(
|
||||
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
|
||||
timeout=max(30, len(text) * 0.05),
|
||||
check=True
|
||||
)
|
||||
logger.debug(f"Saisie via xdotool type ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"xdotool type échoué: {e}")
|
||||
|
||||
# Méthode 2 : Presse-papier
|
||||
xclip = shutil.which('xclip')
|
||||
if xclip and PYAUTOGUI_AVAILABLE:
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
['xclip', '-selection', 'clipboard'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
p.stdin.write(text.encode('utf-8'))
|
||||
p.stdin.close()
|
||||
time.sleep(0.2)
|
||||
pyautogui.hotkey('ctrl', 'v')
|
||||
time.sleep(0.3)
|
||||
logger.debug(f"Saisie via presse-papier ({len(text)} car.)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"xclip échoué: {e}")
|
||||
|
||||
# Méthode 3 : pyautogui
|
||||
if PYAUTOGUI_AVAILABLE:
|
||||
logger.warning("Saisie via pyautogui.write() (AZERTY non garanti)")
|
||||
pyautogui.write(text, interval=0.02)
|
||||
else:
|
||||
logger.warning(f"Aucune méthode de saisie disponible pour: {text[:50]}")
|
||||
|
||||
|
||||
def check_screen_for_patterns() -> Optional[Dict[str, Any]]:
|
||||
"""Vérifie si l'écran contient un pattern UI connu (dialogue, popup).
|
||||
|
||||
Capture l'écran, extrait le texte via OCR, et cherche un pattern
|
||||
dans la UIPatternLibrary.
|
||||
|
||||
Returns:
|
||||
Dict avec le pattern trouvé, ou None.
|
||||
"""
|
||||
try:
|
||||
from core.knowledge.ui_patterns import UIPatternLibrary
|
||||
import mss
|
||||
from PIL import Image
|
||||
|
||||
lib = UIPatternLibrary()
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
try:
|
||||
# Essayer docTR d'abord (peut être importé depuis différents chemins)
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_text
|
||||
except ImportError:
|
||||
from core.extraction.field_extractor import FieldExtractor
|
||||
extractor = FieldExtractor()
|
||||
ocr_extract_text = lambda img: extractor.extract_text_from_image(img)
|
||||
|
||||
ocr_text = ocr_extract_text(screen)
|
||||
except ImportError:
|
||||
logger.debug("OCR non disponible pour pattern check")
|
||||
return None
|
||||
|
||||
if not ocr_text or len(ocr_text) < 5:
|
||||
return None
|
||||
|
||||
pattern = lib.find_pattern(ocr_text)
|
||||
if pattern and pattern['category'] in ('dialog', 'popup'):
|
||||
print(f"🧠 [PatternCheck] Détecté: '{pattern['pattern']}' → {pattern['action']} '{pattern['target']}'")
|
||||
return pattern
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [PatternCheck] Erreur: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
|
||||
"""Gère automatiquement un pattern UI détecté.
|
||||
|
||||
Cherche le bouton cible via OCR (position réelle sur l'écran).
|
||||
100% vision — zéro coordonnée hardcodée.
|
||||
|
||||
Returns:
|
||||
True si le pattern a été géré avec succès.
|
||||
"""
|
||||
if not PYAUTOGUI_AVAILABLE:
|
||||
logger.warning("pyautogui non disponible — impossible de gérer le pattern")
|
||||
return False
|
||||
|
||||
action = pattern.get('action')
|
||||
target = pattern.get('target', '')
|
||||
alternatives = pattern.get('alternatives', [])
|
||||
|
||||
if action == 'click':
|
||||
candidates_labels = [target] + alternatives
|
||||
print(f"🔧 [Réflexe/handle] Recherche bouton parmi: {candidates_labels}")
|
||||
|
||||
try:
|
||||
import mss
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
# EasyOCR (rapide, bonne qualité GUI) avec fallback docTR.
|
||||
# gpu=True : harmonisé avec dialog_handler.py et title_verifier.py.
|
||||
# Coût VRAM ~0.5 GB, sous le budget RTX 5070 (cf. deploy/VRAM_BUDGET.md).
|
||||
words = []
|
||||
try:
|
||||
import easyocr
|
||||
_reader = easyocr.Reader(['fr', 'en'], gpu=True, verbose=False)
|
||||
results = _reader.readtext(np.array(screen))
|
||||
for (bbox_pts, text, conf) in results:
|
||||
if not text or len(text.strip()) < 1:
|
||||
continue
|
||||
x1 = int(min(p[0] for p in bbox_pts))
|
||||
y1 = int(min(p[1] for p in bbox_pts))
|
||||
x2 = int(max(p[0] for p in bbox_pts))
|
||||
y2 = int(max(p[1] for p in bbox_pts))
|
||||
words.append({'text': text.strip(), 'bbox': [x1, y1, x2, y2]})
|
||||
except ImportError:
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_words
|
||||
words = ocr_extract_words(screen) or []
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print(f"🔧 [Réflexe/handle] {len(words)} mots OCR détectés")
|
||||
|
||||
# Collecter tous les matchs, prendre le plus bas (bouton = bas du dialogue)
|
||||
all_matches = []
|
||||
|
||||
for candidate in candidates_labels:
|
||||
candidate_lower = candidate.lower()
|
||||
for word in words:
|
||||
word_text = word['text'].lower()
|
||||
if len(word_text) < 2 or len(candidate_lower) < 2:
|
||||
continue
|
||||
# Match exact ou inclusion
|
||||
if word_text == candidate_lower or candidate_lower in word_text or word_text in candidate_lower:
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({
|
||||
'text': word['text'],
|
||||
'x': int((x1 + x2) / 2),
|
||||
'y': int((y1 + y2) / 2),
|
||||
'candidate': candidate,
|
||||
})
|
||||
|
||||
if all_matches:
|
||||
best = max(all_matches, key=lambda m: m['y'])
|
||||
print(f"✅ [Réflexe/handle] Clic sur '{best['text']}' à ({best['x']}, {best['y']})")
|
||||
pyautogui.click(best['x'], best['y'])
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
|
||||
print(f"⚠️ [Réflexe/handle] Bouton '{target}' introuvable parmi {[w['text'] for w in words[:15]]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Réflexe/handle] Erreur: {e}")
|
||||
return False
|
||||
|
||||
elif action == 'hotkey':
|
||||
keys = target.split('+')
|
||||
logger.info(f"Raccourci automatique: {target}")
|
||||
pyautogui.hotkey(*keys)
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def vlm_reason_about_screen(objective: str = "", context: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Demande au VLM de raisonner sur l'écran actuel et proposer une action.
|
||||
|
||||
Utilisé quand les réflexes (patterns) ne suffisent pas.
|
||||
Le VLM voit l'écran et décide quoi faire.
|
||||
|
||||
Args:
|
||||
objective: Ce que Léa essaie de faire (ex: "cliquer sur Enregistrer")
|
||||
context: Contexte additionnel (ex: "un dialogue est apparu")
|
||||
|
||||
Returns:
|
||||
Dict avec 'action', 'target', 'reasoning' ou None si le VLM ne peut pas aider.
|
||||
"""
|
||||
try:
|
||||
import mss
|
||||
import requests
|
||||
import json
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
screen.save(buffer, format='JPEG', quality=70)
|
||||
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
prompt = f"""Analyse cet écran et dis-moi quoi faire.
|
||||
|
||||
Objectif : {objective or "Interagir avec l'interface visible"}
|
||||
Contexte : {context or "Aucun contexte supplémentaire"}
|
||||
|
||||
Réponds en JSON strict :
|
||||
{{
|
||||
"action": "click" ou "type" ou "wait" ou "nothing",
|
||||
"target": "texte exact du bouton ou champ à cliquer",
|
||||
"reasoning": "explication courte de ton choix"
|
||||
}}
|
||||
|
||||
Si tu vois un dialogue ou une popup, indique quel bouton cliquer.
|
||||
Si l'écran est normal sans action nécessaire, réponds action="nothing".
|
||||
Réponds UNIQUEMENT le JSON, pas d'explication."""
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = os.environ.get("RPA_REASONING_MODEL", "qwen2.5vl:7b")
|
||||
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [image_b64],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1, "num_predict": 200}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"VLM reasoning failed: HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
result = response.json()
|
||||
text = result.get('response', '').strip()
|
||||
|
||||
import re
|
||||
match = re.search(r'\{[\s\S]*\}', text)
|
||||
if match:
|
||||
parsed = json.loads(match.group())
|
||||
logger.info(f"VLM reasoning: {parsed.get('action')} '{parsed.get('target')}' — {parsed.get('reasoning', '')[:80]}")
|
||||
return parsed
|
||||
|
||||
logger.debug(f"VLM response not parseable: {text[:100]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"VLM reasoning failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def find_element_on_screen(
|
||||
target_text: str,
|
||||
target_description: str = "",
|
||||
anchor_image_base64: Optional[str] = None,
|
||||
anchor_bbox: Optional[Dict] = None,
|
||||
monitor_idx: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Cherche un élément sur l'écran en utilisant 3 méthodes en cascade.
|
||||
|
||||
Niveau 1 — OCR (rapide, ~1s) : docTR pour trouver le texte exact
|
||||
Niveau 2 — UI-TARS grounding (~3s) : modèle GUI spécialisé
|
||||
Niveau 3 — VLM reasoning (~10s) : raisonnement + OCR de confirmation
|
||||
|
||||
Args:
|
||||
target_text: Texte de l'élément à trouver (ex: "Demo", "Enregistrer")
|
||||
target_description: Description plus longue (ex: "le dossier Demo sur le bureau")
|
||||
anchor_image_base64: Image de référence de l'ancre (pour CLIP matching, réservé futur)
|
||||
anchor_bbox: Position originale de l'ancre (pour désambiguïser les matchs multiples)
|
||||
monitor_idx: Index logique 0..N-1 du monitor à scruter. None = composite legacy.
|
||||
|
||||
Returns:
|
||||
{'x': int, 'y': int, 'method': str, 'confidence': float} ou None
|
||||
"""
|
||||
# Si le target_text est vide ou c'est juste le type d'action,
|
||||
# utiliser le VLM pour décrire l'image de l'ancre
|
||||
action_types = {'click_anchor', 'double_click_anchor', 'right_click_anchor',
|
||||
'hover_anchor', 'focus_anchor', 'scroll_to_anchor'}
|
||||
has_useful_text = target_text and target_text not in action_types
|
||||
|
||||
if not has_useful_text and anchor_image_base64:
|
||||
desc = _describe_anchor_image(anchor_image_base64)
|
||||
if desc:
|
||||
logger.info(f"[Grounding] Ancre décrite par VLM: '{desc}'")
|
||||
target_description = desc
|
||||
if not has_useful_text:
|
||||
target_text = desc
|
||||
|
||||
if not target_text and not target_description:
|
||||
logger.debug("find_element_on_screen: ni target_text ni target_description fournis")
|
||||
return None
|
||||
|
||||
# Propager monitor_idx au niveau OCR via anchor_bbox (sans muter l'argument original)
|
||||
if monitor_idx is not None and anchor_bbox is not None:
|
||||
anchor_bbox = dict(anchor_bbox) # copie pour ne pas muter l'argument
|
||||
anchor_bbox["monitor_idx"] = monitor_idx
|
||||
elif monitor_idx is not None:
|
||||
anchor_bbox = {"monitor_idx": monitor_idx}
|
||||
|
||||
search_label = target_description or target_text
|
||||
logger.info(f"[Grounding] Recherche élément: '{search_label}' (cascade 3 niveaux)")
|
||||
|
||||
# ─── Niveau 1 — OCR (rapide, ~1s) ───
|
||||
result = _grounding_ocr(target_text, anchor_bbox=anchor_bbox)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# ─── Niveau 2 — UI-TARS grounding (~3s) ───
|
||||
result = _grounding_ui_tars(target_text, target_description, monitor_idx=monitor_idx)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# ─── Niveau 3 — VLM reasoning (~10s) ───
|
||||
result = _grounding_vlm(target_text, target_description, monitor_idx=monitor_idx)
|
||||
if result:
|
||||
return result
|
||||
|
||||
logger.warning(f"[Grounding] ÉCHEC total pour '{search_label}' — aucune méthode n'a trouvé l'élément")
|
||||
return None
|
||||
|
||||
|
||||
def _describe_anchor_image(anchor_image_base64: str) -> Optional[str]:
|
||||
"""Demande au VLM de décrire l'image de l'ancre en quelques mots.
|
||||
|
||||
Utilisé quand le label est vide — le VLM regarde le crop de l'ancre
|
||||
et décrit ce qu'il voit ("folder icon named Demo", "Save button", etc.)
|
||||
pour que UI-TARS puisse chercher cet élément sur l'écran complet.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
import os
|
||||
|
||||
if ',' in anchor_image_base64:
|
||||
anchor_image_base64 = anchor_image_base64.split(',', 1)[1]
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = "qwen2.5vl:3b"
|
||||
|
||||
logger.info(f"[Grounding] Description ancre via {model}...")
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": "Describe this UI element in 5 words maximum. Just the element name, nothing else. Example: 'folder icon named Demo' or 'Save button' or 'Chrome browser icon'",
|
||||
"images": [anchor_image_base64],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1, "num_predict": 20}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
desc = response.json().get('response', '').strip().strip('"').strip("'")
|
||||
if desc and len(desc) > 2:
|
||||
return desc
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Grounding] Description ancre échouée: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _capture_screen(monitor_idx=None):
|
||||
"""Capture l'écran et retourne (PIL.Image, width, height, offset_x, offset_y).
|
||||
|
||||
Args:
|
||||
monitor_idx: Index logique 0..N-1 du monitor à capturer (cf. screeninfo).
|
||||
Si None : capture composite (mss.monitors[0]) — comportement legacy.
|
||||
|
||||
Returns:
|
||||
(image, w, h, offset_x, offset_y). offset = (0,0) en mode composite.
|
||||
"""
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
if monitor_idx is None:
|
||||
# Comportement actuel : composite tous écrans
|
||||
monitor = sct.monitors[0]
|
||||
offset_x, offset_y = 0, 0
|
||||
else:
|
||||
# mss skip monitors[0] (composite). Index logique 0 → mss.monitors[1].
|
||||
mss_idx = int(monitor_idx) + 1
|
||||
if mss_idx >= len(sct.monitors):
|
||||
logger.warning(
|
||||
"mss.monitors[%d] hors limites (n=%d) — fallback composite",
|
||||
mss_idx, len(sct.monitors),
|
||||
)
|
||||
monitor = sct.monitors[0]
|
||||
offset_x, offset_y = 0, 0
|
||||
else:
|
||||
monitor = sct.monitors[mss_idx]
|
||||
offset_x = int(monitor.get("left", 0))
|
||||
offset_y = int(monitor.get("top", 0))
|
||||
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = PILImage.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
return screen, monitor['width'], monitor['height'], offset_x, offset_y
|
||||
except Exception as e:
|
||||
logger.debug(f"Capture écran échouée: {e}")
|
||||
return None, 0, 0, 0, 0
|
||||
|
||||
|
||||
def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Niveau 1 — Cherche le texte par OCR (docTR). ~1s.
|
||||
|
||||
Collecte TOUS les matchs et choisit le plus pertinent :
|
||||
- Si anchor_bbox fourni → le plus proche de la position originale
|
||||
- Sinon → le plus proche du centre de l'écran (zone contenu)
|
||||
"""
|
||||
logger.debug(f"[Grounding/OCR] target='{target_text}' bbox={anchor_bbox}")
|
||||
if not target_text:
|
||||
return None
|
||||
|
||||
try:
|
||||
monitor_idx_param = anchor_bbox.get("monitor_idx") if anchor_bbox else None
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx_param)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_words
|
||||
except ImportError:
|
||||
from core.extraction.field_extractor import FieldExtractor
|
||||
extractor = FieldExtractor()
|
||||
def ocr_extract_words(img):
|
||||
return extractor.extract_words_from_image(img)
|
||||
|
||||
words = ocr_extract_words(screen)
|
||||
if not words:
|
||||
logger.debug("[Grounding/OCR] Aucun mot détecté")
|
||||
return None
|
||||
|
||||
target_lower = target_text.lower()
|
||||
all_matches = []
|
||||
|
||||
# Collecter tous les matchs
|
||||
for word in words:
|
||||
word_lower = word['text'].lower()
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
|
||||
|
||||
if word_lower == target_lower:
|
||||
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'exact', 'conf': 0.95})
|
||||
elif len(word_lower) >= 3 and len(target_lower) >= 3:
|
||||
if target_lower in word_lower or word_lower in target_lower:
|
||||
# Pénaliser les matchs partiels trop courts par rapport au target
|
||||
ratio = len(word_lower) / max(len(target_lower), 1)
|
||||
conf = 0.80 if ratio > 0.5 else 0.50
|
||||
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'partial', 'conf': conf})
|
||||
|
||||
# Matching lettre initiale manquante
|
||||
if not all_matches and len(target_lower) > 3:
|
||||
partial = target_lower[1:]
|
||||
for word in words:
|
||||
if partial in word['text'].lower():
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
all_matches.append({'text': word['text'], 'x': int((x1+x2)/2), 'y': int((y1+y2)/2), 'type': 'partial_cut', 'conf': 0.70})
|
||||
|
||||
if not all_matches:
|
||||
logger.debug(f"[Grounding/OCR] '{target_text}' non trouvé parmi {len(words)} mots")
|
||||
return None
|
||||
|
||||
# Choisir le meilleur match
|
||||
if len(all_matches) == 1:
|
||||
best = all_matches[0]
|
||||
elif anchor_bbox:
|
||||
# Prendre le plus proche de la position originale de l'ancre
|
||||
orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) / 2
|
||||
orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) / 2
|
||||
best = min(all_matches, key=lambda m: ((m['x'] - orig_x)**2 + (m['y'] - orig_y)**2))
|
||||
else:
|
||||
# Prendre le plus central (zone contenu, pas les barres de titre)
|
||||
center_x, center_y = screen_w / 2, screen_h / 2
|
||||
best = min(all_matches, key=lambda m: ((m['x'] - center_x)**2 + (m['y'] - center_y)**2))
|
||||
|
||||
for m in all_matches:
|
||||
sel = " ← CHOISI" if m is best else ""
|
||||
logger.info(f" [OCR] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['type']}]{sel}")
|
||||
|
||||
return {'x': best['x'] + ox, 'y': best['y'] + oy, 'method': 'ocr', 'confidence': best['conf']}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Grounding/OCR] Erreur: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
|
||||
"""Niveau 2 — UI-TARS grounding visuel (~3s)."""
|
||||
try:
|
||||
import requests
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
# Encoder le screenshot en base64
|
||||
buffer = io.BytesIO()
|
||||
screen.save(buffer, format='JPEG', quality=70)
|
||||
image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Construire le prompt pour UI-TARS
|
||||
click_target = target_description or target_text
|
||||
prompt = f"click on {click_target}"
|
||||
|
||||
ollama_url = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
model = "0000/ui-tars-1.5-7b-q8_0:7b"
|
||||
|
||||
logger.info(f"[Grounding/UI-TARS] Envoi à {model}: '{prompt}'")
|
||||
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": [image_b64],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1, "num_predict": 50}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[Grounding/UI-TARS] HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
result = response.json()
|
||||
text = result.get('response', '').strip()
|
||||
logger.debug(f"[Grounding/UI-TARS] Réponse brute: {text[:200]}")
|
||||
|
||||
# Parser les coordonnées de UI-TARS
|
||||
coords = _parse_ui_tars_coordinates(text, screen_w, screen_h)
|
||||
if coords:
|
||||
x, y = coords
|
||||
# Valider que les coordonnées sont dans l'écran
|
||||
if 0 <= x <= screen_w and 0 <= y <= screen_h:
|
||||
logger.info(f"[Grounding/UI-TARS] Grounding → ({x}, {y})")
|
||||
return {'x': x + ox, 'y': y + oy, 'method': 'ui_tars', 'confidence': 0.85}
|
||||
else:
|
||||
logger.warning(f"[Grounding/UI-TARS] Coordonnées hors écran: ({x}, {y}) pour {screen_w}x{screen_h}")
|
||||
return None
|
||||
|
||||
logger.debug(f"[Grounding/UI-TARS] Pas de coordonnées parsées dans: {text[:100]}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Grounding/UI-TARS] Erreur: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ui_tars_coordinates(text: str, screen_w: int, screen_h: int) -> Optional[tuple]:
|
||||
"""Parse les coordonnées retournées par UI-TARS.
|
||||
|
||||
UI-TARS peut retourner :
|
||||
- Coordonnées normalisées (0-1000) : "click at (500, 300)"
|
||||
- Coordonnées en pixels : "click at (960, 540)"
|
||||
- Format (x, y) ou [x, y] ou x,y
|
||||
- Format "Action: click\nCoordinate: (500, 300)" ou "[500, 300]"
|
||||
|
||||
Returns:
|
||||
(x_pixel, y_pixel) ou None
|
||||
"""
|
||||
import re
|
||||
|
||||
# Chercher des patterns de coordonnées
|
||||
patterns = [
|
||||
r'Coordinate:\s*\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
|
||||
r'click\s+(?:at\s+)?\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?\]?',
|
||||
r'\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)',
|
||||
r'\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
raw_x = float(match.group(1))
|
||||
raw_y = float(match.group(2))
|
||||
|
||||
# UI-TARS utilise souvent des coordonnées normalisées 0-1000
|
||||
if raw_x <= 1000 and raw_y <= 1000 and (raw_x > 1 or raw_y > 1):
|
||||
# Probablement normalisées sur 1000
|
||||
x = int(raw_x * screen_w / 1000)
|
||||
y = int(raw_y * screen_h / 1000)
|
||||
elif raw_x <= 1.0 and raw_y <= 1.0:
|
||||
# Normalisées 0-1
|
||||
x = int(raw_x * screen_w)
|
||||
y = int(raw_y * screen_h)
|
||||
else:
|
||||
# Pixels directs
|
||||
x = int(raw_x)
|
||||
y = int(raw_y)
|
||||
|
||||
return (x, y)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _grounding_vlm(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
|
||||
"""Niveau 3 — VLM reasoning + confirmation OCR (~10s)."""
|
||||
try:
|
||||
search_label = target_description or target_text
|
||||
|
||||
vlm_result = vlm_reason_about_screen(
|
||||
objective=f"Cliquer sur {search_label}",
|
||||
context=f"Je cherche l'élément '{target_text}' sur l'écran pour cliquer dessus"
|
||||
)
|
||||
|
||||
if not vlm_result:
|
||||
logger.debug("[Grounding/VLM] VLM n'a pas retourné de résultat")
|
||||
return None
|
||||
|
||||
if vlm_result.get('action') != 'click' or not vlm_result.get('target'):
|
||||
logger.debug(f"[Grounding/VLM] VLM action={vlm_result.get('action')}, pas un clic")
|
||||
return None
|
||||
|
||||
vlm_target = vlm_result['target']
|
||||
logger.info(f"[Grounding/VLM] VLM suggère de cliquer sur: '{vlm_target}'")
|
||||
|
||||
# Confirmation par OCR : chercher le target VLM sur l'écran
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
try:
|
||||
from services.ocr_service import ocr_extract_words
|
||||
except ImportError:
|
||||
from core.extraction.field_extractor import FieldExtractor
|
||||
extractor = FieldExtractor()
|
||||
def ocr_extract_words(img):
|
||||
return extractor.extract_words_from_image(img)
|
||||
|
||||
words = ocr_extract_words(screen)
|
||||
|
||||
vlm_target_lower = vlm_target.lower()
|
||||
for word in words:
|
||||
if vlm_target_lower in word['text'].lower() or word['text'].lower() in vlm_target_lower:
|
||||
x1, y1, x2, y2 = word['bbox']
|
||||
x = int((x1 + x2) / 2)
|
||||
y = int((y1 + y2) / 2)
|
||||
logger.info(f"[Grounding/VLM] Confirmé par OCR: '{word['text']}' à ({x}, {y})")
|
||||
return {'x': x + ox, 'y': y + oy, 'method': 'vlm', 'confidence': 0.75}
|
||||
|
||||
logger.debug(f"[Grounding/VLM] Target VLM '{vlm_target}' non trouvé par OCR")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Grounding/VLM] OCR de confirmation échoué: {e}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Grounding/VLM] Erreur: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def post_execution_cleanup(execution_mode: str = 'debug'):
|
||||
"""Vérifie l'écran après exécution et gère les dialogues restants.
|
||||
|
||||
Appelé après la dernière étape d'un workflow pour laisser l'écran propre.
|
||||
"""
|
||||
if execution_mode not in ('intelligent', 'debug'):
|
||||
return
|
||||
|
||||
logger.info("Vérification écran final...")
|
||||
time.sleep(1.0)
|
||||
for _ in range(3):
|
||||
detected = check_screen_for_patterns()
|
||||
if detected:
|
||||
logger.info(f"Dialogue résiduel détecté: {detected.get('pattern')}")
|
||||
handle_detected_pattern(detected)
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
vlm_result = vlm_reason_about_screen(
|
||||
objective="Vérifier que l'écran est propre après l'exécution",
|
||||
context="Le workflow vient de se terminer"
|
||||
)
|
||||
if vlm_result and vlm_result.get('action') in ('click', 'type'):
|
||||
logger.info(f"VLM post-workflow: {vlm_result.get('action')} '{vlm_result.get('target')}'")
|
||||
break
|
||||
@@ -40,12 +40,16 @@ class LLMActionHandler:
|
||||
def __init__(
|
||||
self,
|
||||
ollama_endpoint: str = "http://localhost:11434",
|
||||
model: str = "qwen3-vl:8b",
|
||||
model: str = None,
|
||||
temperature: float = 0.1,
|
||||
timeout: int = 120,
|
||||
):
|
||||
self.endpoint = ollama_endpoint.rstrip("/")
|
||||
self.model = model
|
||||
if model is not None:
|
||||
self.model = model
|
||||
else:
|
||||
from core.detection.vlm_config import get_vlm_model
|
||||
self.model = get_vlm_model()
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
2008
core/execution/observe_reason_act.py
Normal file
2008
core/execution/observe_reason_act.py
Normal file
File diff suppressed because it is too large
Load Diff
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",
|
||||
]
|
||||
@@ -1694,15 +1694,9 @@ class TargetResolver:
|
||||
tie_break_criterion = "confidence"
|
||||
|
||||
logger.debug(f"Selected element {best_elem.element_id} with tie-break criterion: {tie_break_criterion}")
|
||||
|
||||
|
||||
return best_elem, tie_break_criterion
|
||||
|
||||
# Spatial analyzer (lazy load) - Exigence 5.3
|
||||
self._spatial_analyzer: Optional[SpatialAnalyzer] = None
|
||||
self._spatial_relations_cache: Dict[str, List[SpatialRelation]] = {}
|
||||
|
||||
logger.info(f"TargetResolver initialized (threshold={similarity_threshold}, spatial={use_spatial_fallback})")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Résolution principale
|
||||
# =========================================================================
|
||||
|
||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration Ollama (coherente avec le reste du projet)
|
||||
OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b")
|
||||
OLLAMA_DEFAULT_MODEL = os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))
|
||||
|
||||
|
||||
class FieldExtractor:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
GPU Resource Management Module for RPA Vision V3
|
||||
|
||||
This module provides dynamic GPU resource allocation between ML models:
|
||||
- Ollama VLM (qwen3-vl:8b) for UI classification
|
||||
- Ollama VLM (gemma4:e4b par défaut, configurable via RPA_VLM_MODEL) for UI classification
|
||||
- CLIP (ViT-B-32) for embedding matching
|
||||
|
||||
The GPUResourceManager optimizes VRAM usage by:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
GPU Resource Manager - Central orchestrator for GPU resource allocation
|
||||
|
||||
Manages dynamic allocation of GPU resources between:
|
||||
- Ollama VLM (qwen3-vl:8b) - ~10.5 GB VRAM for UI classification
|
||||
- Ollama VLM (gemma4:e4b par défaut) - ~10 GB VRAM for UI classification
|
||||
- CLIP (ViT-B-32) - ~500 MB VRAM for embedding matching
|
||||
|
||||
Optimizes VRAM usage based on execution mode:
|
||||
@@ -12,13 +12,14 @@ Optimizes VRAM usage based on execution mode:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,7 +54,7 @@ class VRAMInfo:
|
||||
class GPUResourceConfig:
|
||||
"""Configuration for GPU resource management."""
|
||||
ollama_endpoint: str = "http://localhost:11434"
|
||||
vlm_model: str = "qwen3-vl:8b"
|
||||
vlm_model: str = "gemma4:e4b"
|
||||
clip_model: str = "ViT-B-32"
|
||||
idle_timeout_seconds: int = 300 # 5 minutes
|
||||
vram_threshold_for_clip_gpu_mb: int = 1024 # 1 GB
|
||||
@@ -126,6 +127,12 @@ class GPUResourceManager:
|
||||
# Operation queue for sequential processing
|
||||
self._operation_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._operation_lock = asyncio.Lock()
|
||||
|
||||
# Lock d'inférence synchrone : sérialise les appels GPU concurrents
|
||||
# (ScreenAnalyzer.analyze, UIDetector, CLIP.encode) entre
|
||||
# ExecutionLoop et stream_processor pour éviter la saturation VRAM
|
||||
# sur RTX 5070 (12 Go). Un seul analyze à la fois sur le GPU.
|
||||
self._inference_lock = threading.Lock()
|
||||
|
||||
# Event callbacks
|
||||
self._on_resource_changed: List[Callable[[ResourceChangedEvent], None]] = []
|
||||
@@ -207,7 +214,45 @@ class GPUResourceManager:
|
||||
def get_execution_mode(self) -> ExecutionMode:
|
||||
"""Get the current execution mode."""
|
||||
return self._execution_mode
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Inference serialization (sync)
|
||||
# =========================================================================
|
||||
|
||||
@contextlib.contextmanager
|
||||
def acquire_inference(self, timeout: Optional[float] = None) -> Iterator[bool]:
|
||||
"""
|
||||
Context manager synchrone pour sérialiser les inférences GPU.
|
||||
|
||||
Garantit qu'un seul appel d'inférence (ScreenAnalyzer.analyze,
|
||||
UIDetector.detect, CLIP.encode…) tourne à la fois sur le GPU.
|
||||
Évite la saturation VRAM quand ExecutionLoop et stream_processor
|
||||
appellent analyze() simultanément sur une RTX 5070 (12 Go).
|
||||
|
||||
Args:
|
||||
timeout: Délai max d'attente (secondes). None = bloquant.
|
||||
|
||||
Yields:
|
||||
True si le lock est acquis, False en cas de timeout.
|
||||
|
||||
Example:
|
||||
>>> with gpu_manager.acquire_inference(timeout=30.0) as acquired:
|
||||
... if not acquired:
|
||||
... logger.warning("GPU lock timeout")
|
||||
... state = analyzer.analyze(path)
|
||||
"""
|
||||
if timeout is None:
|
||||
self._inference_lock.acquire()
|
||||
acquired = True
|
||||
else:
|
||||
acquired = self._inference_lock.acquire(timeout=timeout)
|
||||
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
self._inference_lock.release()
|
||||
|
||||
# =========================================================================
|
||||
# VLM Management
|
||||
# =========================================================================
|
||||
|
||||
@@ -32,7 +32,7 @@ class OllamaManager:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "http://localhost:11434",
|
||||
model: str = "qwen3-vl:8b",
|
||||
model: str = "gemma4:e4b",
|
||||
default_keep_alive: str = "5m"
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -173,10 +173,14 @@ class GraphBuilder:
|
||||
clustering_eps: float = 0.08,
|
||||
clustering_min_samples: int = 2,
|
||||
enable_quality_validation: bool = True,
|
||||
ui_detector: Optional[Any] = None,
|
||||
screen_analyzer: Optional[Any] = None,
|
||||
enable_ui_enrichment: bool = True,
|
||||
element_proximity_max_px: float = 50.0,
|
||||
):
|
||||
"""
|
||||
Initialiser le GraphBuilder.
|
||||
|
||||
|
||||
Args:
|
||||
embedding_builder: Builder pour State Embeddings (créé si None)
|
||||
faiss_manager: Manager FAISS pour indexation (optionnel)
|
||||
@@ -185,6 +189,17 @@ class GraphBuilder:
|
||||
clustering_eps: Epsilon pour DBSCAN (distance max entre points)
|
||||
clustering_min_samples: Nombre minimum d'échantillons pour un cluster
|
||||
enable_quality_validation: Activer la validation de qualité
|
||||
ui_detector: UIDetector optionnel. Si fourni, sera utilisé par
|
||||
l'analyzer lazy-initialisé. Sinon, fallback sur le singleton
|
||||
partagé (`get_screen_analyzer()`).
|
||||
screen_analyzer: Instance ScreenAnalyzer à utiliser directement.
|
||||
Si None, lazy init via le singleton partagé C1.
|
||||
enable_ui_enrichment: Active l'enrichissement visuel des
|
||||
ScreenStates lors de `_create_screen_states` (OCR + UIDetector).
|
||||
False = comportement historique (ui_elements=[], detected_text=[]).
|
||||
element_proximity_max_px: Distance maximale (en pixels) entre un
|
||||
clic et le bbox le plus proche pour qu'un UIElement soit
|
||||
considéré comme cible. Au-delà, le clic reste sans ancre.
|
||||
"""
|
||||
self.embedding_builder = embedding_builder or StateEmbeddingBuilder()
|
||||
self.faiss_manager = faiss_manager
|
||||
@@ -193,15 +208,65 @@ class GraphBuilder:
|
||||
self.clustering_eps = clustering_eps
|
||||
self.clustering_min_samples = clustering_min_samples
|
||||
self.enable_quality_validation = enable_quality_validation
|
||||
self._screen_analyzer = None # ScreenAnalyzer (lazy import)
|
||||
|
||||
self.enable_ui_enrichment = enable_ui_enrichment
|
||||
self.element_proximity_max_px = element_proximity_max_px
|
||||
# UIDetector explicite (optionnel) — injecté dans l'analyzer lazy.
|
||||
self._ui_detector = ui_detector
|
||||
# Instance ScreenAnalyzer. Si fournie, on l'utilise telle quelle ;
|
||||
# sinon, on bascule sur le singleton partagé (lazy init).
|
||||
self._screen_analyzer = screen_analyzer
|
||||
|
||||
logger.info(
|
||||
f"GraphBuilder initialized: "
|
||||
f"min_repetitions={min_pattern_repetitions}, "
|
||||
f"eps={clustering_eps}, "
|
||||
f"min_samples={clustering_min_samples}, "
|
||||
f"quality_validation={enable_quality_validation}"
|
||||
f"quality_validation={enable_quality_validation}, "
|
||||
f"ui_enrichment={enable_ui_enrichment}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Résolution paresseuse du ScreenAnalyzer (singleton C1 par défaut)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_screen_analyzer(self):
|
||||
"""
|
||||
Retourner l'instance ScreenAnalyzer à utiliser.
|
||||
|
||||
Priorité :
|
||||
1. Instance injectée via le constructeur (`screen_analyzer=…`).
|
||||
2. Singleton partagé `get_screen_analyzer()` (C1) — évite le double
|
||||
chargement GPU quand ExecutionLoop et stream_processor tournent.
|
||||
3. En dernier recours (import circulaire, tests), création locale.
|
||||
"""
|
||||
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(
|
||||
ui_detector=self._ui_detector,
|
||||
)
|
||||
return self._screen_analyzer
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Impossible d'obtenir le ScreenAnalyzer singleton "
|
||||
f"({e}); fallback sur une instance locale."
|
||||
)
|
||||
try:
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
|
||||
self._screen_analyzer = ScreenAnalyzer(
|
||||
ui_detector=self._ui_detector,
|
||||
)
|
||||
return self._screen_analyzer
|
||||
except Exception as e2:
|
||||
logger.error(
|
||||
f"Impossible d'instancier ScreenAnalyzer: {e2}. "
|
||||
"Enrichissement UI désactivé."
|
||||
)
|
||||
return None
|
||||
|
||||
def build_from_session(
|
||||
self,
|
||||
@@ -209,6 +274,7 @@ class GraphBuilder:
|
||||
workflow_name: Optional[str] = None,
|
||||
precomputed_states: Optional[List["ScreenState"]] = None,
|
||||
precomputed_embeddings: Optional[List] = None,
|
||||
sequential: bool = False,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Construire un Workflow complet depuis une RawSession.
|
||||
@@ -216,7 +282,7 @@ class GraphBuilder:
|
||||
Processus:
|
||||
1. Créer ScreenStates depuis screenshots (ou utiliser precomputed_states)
|
||||
2. Calculer embeddings pour chaque état (ou réutiliser precomputed_embeddings)
|
||||
3. Détecter patterns via clustering
|
||||
3. Détecter patterns via clustering (ou mode séquentiel)
|
||||
4. Construire nodes depuis clusters
|
||||
5. Construire edges depuis transitions
|
||||
|
||||
@@ -228,6 +294,10 @@ class GraphBuilder:
|
||||
precomputed_embeddings: Embeddings déjà calculés (streaming).
|
||||
Si fourni et de la bonne longueur (= len(screen_states)),
|
||||
saute l'étape 2 (pas de recalcul CLIP).
|
||||
sequential: Si True, crée un node par état d'écran (pas de
|
||||
clustering DBSCAN). Approprié pour les enregistrements
|
||||
single-pass d'un workflow — chaque screenshot est une étape
|
||||
distincte avec ses actions associées.
|
||||
|
||||
Returns:
|
||||
Workflow construit avec nodes et edges
|
||||
@@ -242,6 +312,7 @@ class GraphBuilder:
|
||||
f"Building workflow from session {session.session_id} "
|
||||
f"with {len(precomputed_states or session.screenshots)} "
|
||||
f"{'precomputed states' if precomputed_states else 'screenshots'}"
|
||||
f"{' (mode séquentiel)' if sequential else ''}"
|
||||
)
|
||||
|
||||
# Étape 1: Créer ScreenStates (ou réutiliser ceux pré-calculés)
|
||||
@@ -266,16 +337,28 @@ class GraphBuilder:
|
||||
embeddings = self._compute_embeddings(screen_states)
|
||||
logger.debug(f"Computed {len(embeddings)} embeddings")
|
||||
|
||||
# Étape 3: Détecter patterns
|
||||
clusters = self._detect_patterns(embeddings, screen_states)
|
||||
logger.info(f"Detected {len(clusters)} patterns")
|
||||
# Étape 3: Détecter patterns ou mode séquentiel
|
||||
if sequential:
|
||||
# Mode séquentiel : chaque état d'écran est un node distinct.
|
||||
# Pas de clustering — essentiel pour les enregistrements single-pass
|
||||
# où l'on veut reproduire fidèlement la séquence des actions.
|
||||
clusters = {i: [i] for i in range(len(screen_states))}
|
||||
logger.info(
|
||||
f"Mode séquentiel: {len(clusters)} nodes (1 par état)"
|
||||
)
|
||||
else:
|
||||
clusters = self._detect_patterns(embeddings, screen_states)
|
||||
logger.info(f"Detected {len(clusters)} patterns")
|
||||
|
||||
# Étape 4: Construire nodes
|
||||
nodes = self._build_nodes(clusters, screen_states, embeddings)
|
||||
logger.info(f"Built {len(nodes)} workflow nodes")
|
||||
|
||||
# Étape 5: Construire edges (passer les embeddings pour éviter recalcul)
|
||||
edges = self._build_edges(nodes, screen_states, session, embeddings=embeddings)
|
||||
edges = self._build_edges(
|
||||
nodes, screen_states, session, embeddings=embeddings,
|
||||
sequential=sequential,
|
||||
)
|
||||
logger.info(f"Built {len(edges)} workflow edges")
|
||||
|
||||
# Créer Workflow
|
||||
@@ -388,18 +471,35 @@ class GraphBuilder:
|
||||
Liste de ScreenStates enrichis
|
||||
"""
|
||||
screen_states = []
|
||||
|
||||
|
||||
# Créer un mapping screenshot_id -> événement
|
||||
screenshot_to_event = {}
|
||||
for event in session.events:
|
||||
if event.screenshot_id:
|
||||
screenshot_to_event[event.screenshot_id] = event
|
||||
|
||||
|
||||
# Récupérer (une seule fois) l'analyzer partagé si l'enrichissement est actif.
|
||||
# Le singleton C1 garantit qu'on ne recharge pas UIDetector/CLIP inutilement.
|
||||
analyzer = None
|
||||
if self.enable_ui_enrichment:
|
||||
analyzer = self._get_screen_analyzer()
|
||||
|
||||
# Cache partagé (C1) : réutiliser les analyses si même screenshot est
|
||||
# repassé plusieurs fois (peu fréquent en construction, utile en tests).
|
||||
try:
|
||||
from core.pipeline import get_screen_state_cache
|
||||
|
||||
state_cache = get_screen_state_cache()
|
||||
except Exception as e:
|
||||
logger.debug(f"ScreenStateCache indisponible ({e}); aucun cache utilisé.")
|
||||
state_cache = None
|
||||
|
||||
enriched_count = 0
|
||||
for i, screenshot in enumerate(session.screenshots):
|
||||
# Trouver l'événement associé
|
||||
event = screenshot_to_event.get(screenshot.screenshot_id)
|
||||
|
||||
# Créer WindowContext depuis l'événement
|
||||
|
||||
# Construire WindowContext depuis l'événement (si dispo)
|
||||
screen_env = session.environment.get("screen", {})
|
||||
screen_res = screen_env.get("primary_resolution", [1920, 1080])
|
||||
if event and event.window:
|
||||
@@ -426,60 +526,128 @@ class GraphBuilder:
|
||||
os_theme=session.environment.get("os_theme", "unknown"),
|
||||
os_language=session.environment.get("os_language", "unknown"),
|
||||
)
|
||||
|
||||
# Créer RawLevel
|
||||
# Construire chemin absolu : data/training/sessions/{session_id}/{session_id}/{relative_path}
|
||||
screenshot_absolute_path = f"data/training/sessions/{session.session_id}/{session.session_id}/{screenshot.relative_path}"
|
||||
|
||||
# Chemin absolu du screenshot
|
||||
screenshot_absolute_path = (
|
||||
f"data/training/sessions/{session.session_id}/"
|
||||
f"{session.session_id}/{screenshot.relative_path}"
|
||||
)
|
||||
screenshot_path = Path(screenshot_absolute_path)
|
||||
|
||||
# Timestamp
|
||||
if isinstance(screenshot.captured_at, str):
|
||||
timestamp = datetime.fromisoformat(
|
||||
screenshot.captured_at.replace('Z', '+00:00')
|
||||
)
|
||||
else:
|
||||
timestamp = screenshot.captured_at
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Enrichissement visuel : déléguer au ScreenAnalyzer partagé
|
||||
# ------------------------------------------------------------
|
||||
# L'analyzer renvoie un ScreenState complet avec :
|
||||
# - raw (image + file_size)
|
||||
# - perception (OCR + embedding ref)
|
||||
# - ui_elements (détection UIDetector)
|
||||
# On récupère ces niveaux et on rebâtit un état final avec le
|
||||
# WindowContext et les metadata issus de la session brute (les
|
||||
# données "metier" que l'analyzer ignore).
|
||||
# ------------------------------------------------------------
|
||||
detected_text: List[str] = []
|
||||
text_method = "none"
|
||||
ui_elements: List = []
|
||||
raw = RawLevel(
|
||||
screenshot_path=str(screenshot_path),
|
||||
capture_method="mss",
|
||||
file_size_bytes=screenshot_path.stat().st_size if screenshot_path.exists() else 0
|
||||
file_size_bytes=(
|
||||
screenshot_path.stat().st_size
|
||||
if screenshot_path.exists()
|
||||
else 0
|
||||
),
|
||||
)
|
||||
|
||||
# Créer PerceptionLevel — enrichir avec OCR si le screenshot existe
|
||||
detected_text = []
|
||||
text_method = "none"
|
||||
|
||||
if screenshot_path.exists():
|
||||
if analyzer is not None and screenshot_path.exists():
|
||||
try:
|
||||
if self._screen_analyzer is None:
|
||||
from core.pipeline.screen_analyzer import ScreenAnalyzer
|
||||
self._screen_analyzer = ScreenAnalyzer(session_id=session.session_id)
|
||||
extracted = self._screen_analyzer._extract_text(str(screenshot_path))
|
||||
if extracted:
|
||||
detected_text = extracted
|
||||
text_method = self._screen_analyzer._get_ocr_method_name()
|
||||
except Exception as e:
|
||||
logger.debug(f"OCR échoué pour {screenshot_path}: {e}")
|
||||
# Construire l'info fenêtre pour donner le contexte à
|
||||
# l'UIDetector (certains détecteurs s'en servent pour
|
||||
# filtrer hors-fenêtre).
|
||||
window_info = {
|
||||
"app_name": window.app_name,
|
||||
"title": window.window_title,
|
||||
"screen_resolution": list(window.screen_resolution or []),
|
||||
}
|
||||
|
||||
analyzed = analyzer.analyze(
|
||||
str(screenshot_path),
|
||||
window_info=window_info,
|
||||
enable_ocr=True,
|
||||
enable_ui_detection=True,
|
||||
session_id=session.session_id,
|
||||
)
|
||||
detected_text = list(analyzed.perception.detected_text or [])
|
||||
text_method = (
|
||||
analyzed.perception.text_detection_method or "none"
|
||||
)
|
||||
ui_elements = list(analyzed.ui_elements or [])
|
||||
# Garder les métriques OCR/UI si présentes (debug)
|
||||
analyzer_metadata = dict(analyzed.metadata or {})
|
||||
raw = analyzed.raw # conserver file_size réel mesuré
|
||||
if ui_elements:
|
||||
enriched_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Enrichissement visuel échoué pour {screenshot_path}: {e}. "
|
||||
"Fallback sur ScreenState minimal."
|
||||
)
|
||||
analyzer_metadata = {"analyzer_error": str(e)}
|
||||
else:
|
||||
analyzer_metadata = {}
|
||||
if self.enable_ui_enrichment and not screenshot_path.exists():
|
||||
logger.debug(
|
||||
f"Screenshot introuvable: {screenshot_path} "
|
||||
"— ui_elements restera vide"
|
||||
)
|
||||
|
||||
# PerceptionLevel : vector_id calculé de façon déterministe.
|
||||
perception = PerceptionLevel(
|
||||
embedding=EmbeddingRef(
|
||||
provider="openclip_ViT-B-32",
|
||||
vector_id=f"data/embeddings/screens/{session.session_id}_state_{i:04d}.npy",
|
||||
dimensions=512
|
||||
vector_id=(
|
||||
f"data/embeddings/screens/"
|
||||
f"{session.session_id}_state_{i:04d}.npy"
|
||||
),
|
||||
dimensions=512,
|
||||
),
|
||||
detected_text=detected_text,
|
||||
text_detection_method=text_method,
|
||||
confidence_avg=0.85 if detected_text else 0.0
|
||||
confidence_avg=0.85 if detected_text else 0.0,
|
||||
)
|
||||
|
||||
# Créer ContextLevel
|
||||
|
||||
# ContextLevel (métier)
|
||||
context = ContextLevel(
|
||||
current_workflow_candidate=None,
|
||||
workflow_step=i,
|
||||
user_id=session.user.get("id", "unknown"),
|
||||
tags=list(session.context.get("tags", [])) if isinstance(session.context.get("tags"), list) else [],
|
||||
business_variables={}
|
||||
tags=(
|
||||
list(session.context.get("tags", []))
|
||||
if isinstance(session.context.get("tags"), list)
|
||||
else []
|
||||
),
|
||||
business_variables={},
|
||||
)
|
||||
|
||||
# Parser timestamp
|
||||
if isinstance(screenshot.captured_at, str):
|
||||
timestamp = datetime.fromisoformat(screenshot.captured_at.replace('Z', '+00:00'))
|
||||
else:
|
||||
timestamp = screenshot.captured_at
|
||||
|
||||
# Créer ScreenState complet
|
||||
|
||||
# Metadata : on garde le lien événement/session + éventuels
|
||||
# compteurs remontés par l'analyzer.
|
||||
metadata = {
|
||||
"screenshot_id": screenshot.screenshot_id,
|
||||
"event_type": event.type if event else None,
|
||||
"event_time": event.t if event else None,
|
||||
}
|
||||
# Propager les indicateurs utiles de l'analyzer sans écraser la base.
|
||||
for key in ("ocr_ms", "ui_ms", "analyzer_error"):
|
||||
if key in analyzer_metadata:
|
||||
metadata[key] = analyzer_metadata[key]
|
||||
|
||||
state = ScreenState(
|
||||
screen_state_id=f"{session.session_id}_state_{i:04d}",
|
||||
timestamp=timestamp,
|
||||
@@ -488,17 +656,17 @@ class GraphBuilder:
|
||||
raw=raw,
|
||||
perception=perception,
|
||||
context=context,
|
||||
metadata={
|
||||
"screenshot_id": screenshot.screenshot_id,
|
||||
"event_type": event.type if event else None,
|
||||
"event_time": event.t if event else None
|
||||
},
|
||||
ui_elements=[] # Sera rempli par UIDetector si disponible
|
||||
metadata=metadata,
|
||||
ui_elements=ui_elements,
|
||||
)
|
||||
|
||||
|
||||
screen_states.append(state)
|
||||
|
||||
logger.info(f"Created {len(screen_states)} enriched screen states")
|
||||
|
||||
logger.info(
|
||||
f"Created {len(screen_states)} enriched screen states "
|
||||
f"({enriched_count} avec UI détectée, "
|
||||
f"ui_enrichment={self.enable_ui_enrichment})"
|
||||
)
|
||||
return screen_states
|
||||
|
||||
def _compute_embeddings(
|
||||
@@ -924,6 +1092,99 @@ class GraphBuilder:
|
||||
constraints.sort(key=lambda c: role_counts.get(c.get("role", ""), 0), reverse=True)
|
||||
return constraints[:8]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Association spatiale clic → UIElement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_clicked_element(
|
||||
self,
|
||||
event: Event,
|
||||
ui_elements: List[Any],
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Identifier l'UIElement cible d'un clic par proximité spatiale.
|
||||
|
||||
Règle :
|
||||
1. Si un bbox contient strictement la position du clic → match.
|
||||
2. Sinon, on prend le bbox le plus proche (distance euclidienne
|
||||
au bord) sous réserve qu'il soit à <= `element_proximity_max_px`.
|
||||
3. Sinon, aucun ancrage possible → None.
|
||||
|
||||
Cette association transforme un clic "aveugle" (coordonnées brutes)
|
||||
en un clic "intelligent" (rôle + label), permettant au matcher de
|
||||
retrouver l'élément même si la résolution ou la position change.
|
||||
|
||||
Args:
|
||||
event: Événement `mouse_click` (avec `data["pos"] = [x, y]`).
|
||||
ui_elements: Liste des UIElement détectés sur l'écran source.
|
||||
|
||||
Returns:
|
||||
UIElement le plus pertinent, ou None si rien ne correspond.
|
||||
"""
|
||||
if not ui_elements:
|
||||
return None
|
||||
if not event or event.type != "mouse_click":
|
||||
return None
|
||||
|
||||
pos = event.data.get("pos") if event.data else None
|
||||
if not pos or len(pos) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
click_x = float(pos[0])
|
||||
click_y = float(pos[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
best_contained = None
|
||||
best_contained_area = None
|
||||
best_near = None
|
||||
best_near_distance = None
|
||||
|
||||
for element in ui_elements:
|
||||
bbox = getattr(element, "bbox", None)
|
||||
if bbox is None:
|
||||
continue
|
||||
|
||||
# Extraction défensive des coordonnées (BBox Pydantic ou tuple)
|
||||
try:
|
||||
bx = int(getattr(bbox, "x", bbox[0]))
|
||||
by = int(getattr(bbox, "y", bbox[1]))
|
||||
bw = int(getattr(bbox, "width", bbox[2]))
|
||||
bh = int(getattr(bbox, "height", bbox[3]))
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
continue
|
||||
|
||||
# Cas 1 : la position est strictement dans le bbox.
|
||||
if bx <= click_x <= bx + bw and by <= click_y <= by + bh:
|
||||
# Sélectionner le plus petit bbox qui contient (élément le plus spécifique)
|
||||
area = max(1, bw * bh)
|
||||
if best_contained is None or area < best_contained_area:
|
||||
best_contained = element
|
||||
best_contained_area = area
|
||||
continue
|
||||
|
||||
# Cas 2 : calculer la distance au bord le plus proche.
|
||||
dx = max(bx - click_x, 0, click_x - (bx + bw))
|
||||
dy = max(by - click_y, 0, click_y - (by + bh))
|
||||
distance = (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
if best_near is None or distance < best_near_distance:
|
||||
best_near = element
|
||||
best_near_distance = distance
|
||||
|
||||
if best_contained is not None:
|
||||
return best_contained
|
||||
|
||||
if (
|
||||
best_near is not None
|
||||
and best_near_distance is not None
|
||||
and best_near_distance <= self.element_proximity_max_px
|
||||
):
|
||||
return best_near
|
||||
|
||||
return None
|
||||
|
||||
# Patterns d'erreur courants pour la détection fail_fast
|
||||
_ERROR_PATTERNS = [
|
||||
"erreur", "error", "échec", "failed", "impossible",
|
||||
@@ -937,12 +1198,14 @@ class GraphBuilder:
|
||||
screen_states: List[ScreenState],
|
||||
session: RawSession,
|
||||
embeddings: Optional[List[np.ndarray]] = None,
|
||||
sequential: bool = False,
|
||||
) -> List[WorkflowEdge]:
|
||||
"""
|
||||
Construire WorkflowEdges depuis les transitions observées.
|
||||
|
||||
Algorithme:
|
||||
1. Mapper chaque ScreenState vers son node (via embedding similarity)
|
||||
En mode séquentiel, le mapping est direct (state i → node i).
|
||||
2. Identifier les transitions (state_i -> state_j où node change)
|
||||
3. Extraire l'action depuis l'événement entre les deux états
|
||||
4. Créer WorkflowEdge avec action, pré-conditions et post-conditions
|
||||
@@ -960,6 +1223,7 @@ class GraphBuilder:
|
||||
screen_states: ScreenStates
|
||||
session: Session brute (pour événements)
|
||||
embeddings: Embeddings pré-calculés (évite un recalcul dans _map_states_to_nodes)
|
||||
sequential: Mode séquentiel — chaque paire consécutive = transition
|
||||
|
||||
Returns:
|
||||
Liste de WorkflowEdges
|
||||
@@ -975,7 +1239,19 @@ class GraphBuilder:
|
||||
node_by_id = {node.node_id: node for node in nodes}
|
||||
|
||||
# Étape 1: Mapper chaque état vers son node
|
||||
state_to_node = self._map_states_to_nodes(screen_states, nodes, embeddings=embeddings)
|
||||
if sequential:
|
||||
# Mode séquentiel : mapping direct state[i] → node[i]
|
||||
state_to_node = {}
|
||||
for i, state in enumerate(screen_states):
|
||||
if i < len(nodes):
|
||||
state_to_node[state.screen_state_id] = nodes[i].node_id
|
||||
logger.debug(
|
||||
f"Mode séquentiel: {len(state_to_node)} states mappés directement"
|
||||
)
|
||||
else:
|
||||
state_to_node = self._map_states_to_nodes(
|
||||
screen_states, nodes, embeddings=embeddings
|
||||
)
|
||||
|
||||
# Étape 2: Récupérer la résolution d'écran pour normaliser les coordonnées
|
||||
screen_env = session.environment.get("screen", {})
|
||||
@@ -989,8 +1265,11 @@ class GraphBuilder:
|
||||
current_node_id = state_to_node.get(current_state.screen_state_id)
|
||||
next_node_id = state_to_node.get(next_state.screen_state_id)
|
||||
|
||||
# Si les deux états sont dans des nodes différents, c'est une transition
|
||||
if current_node_id and next_node_id and current_node_id != next_node_id:
|
||||
# En mode séquentiel, chaque paire consécutive est une transition
|
||||
# En mode clustering, uniquement si les nodes sont différents
|
||||
if current_node_id and next_node_id and (
|
||||
sequential or current_node_id != next_node_id
|
||||
):
|
||||
# Trouver TOUS les événements entre les deux états
|
||||
transition_events = self._find_transition_events(
|
||||
current_state, next_state, session.events
|
||||
@@ -1012,6 +1291,7 @@ class GraphBuilder:
|
||||
target_node=target_node,
|
||||
all_events=transition_events,
|
||||
screen_resolution=screen_resolution,
|
||||
source_state=current_state,
|
||||
)
|
||||
edges.append(edge)
|
||||
|
||||
@@ -1094,6 +1374,32 @@ class GraphBuilder:
|
||||
|
||||
return state_to_node
|
||||
|
||||
def _get_state_time(self, state: ScreenState, fallback: float = 0) -> float:
|
||||
"""Extraire le timestamp d'un ScreenState.
|
||||
|
||||
Priorité :
|
||||
1. metadata['event_time'] (set par _create_screen_states)
|
||||
2. metadata['shot_timestamp'] (set par le reprocessing)
|
||||
3. state.timestamp converti en epoch si c'est un datetime
|
||||
4. fallback
|
||||
|
||||
Note : event_time peut être 0.0 (timestamps relatifs), donc on
|
||||
vérifie `is not None` et non `> 0`.
|
||||
"""
|
||||
if state.metadata:
|
||||
et = state.metadata.get("event_time")
|
||||
if et is not None:
|
||||
return float(et)
|
||||
st = state.metadata.get("shot_timestamp")
|
||||
if st is not None:
|
||||
return float(st)
|
||||
if state.timestamp:
|
||||
try:
|
||||
return state.timestamp.timestamp()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
return fallback
|
||||
|
||||
def _find_transition_events(
|
||||
self,
|
||||
current_state: ScreenState,
|
||||
@@ -1108,6 +1414,9 @@ class GraphBuilder:
|
||||
C'est essentiel pour le replay : une transition peut nécessiter
|
||||
plusieurs actions (ex: Win+R → taper "notepad" → Entrée).
|
||||
|
||||
Timestamps : utilise _get_state_time() qui supporte plusieurs
|
||||
sources (event_time, shot_timestamp, datetime).
|
||||
|
||||
Args:
|
||||
current_state: État source
|
||||
next_state: État cible
|
||||
@@ -1117,8 +1426,8 @@ class GraphBuilder:
|
||||
Liste ordonnée (par timestamp) de tous les événements d'action
|
||||
entre les deux états. Peut être vide.
|
||||
"""
|
||||
current_time = current_state.metadata.get("event_time", 0)
|
||||
next_time = next_state.metadata.get("event_time", float('inf'))
|
||||
current_time = self._get_state_time(current_state, fallback=0)
|
||||
next_time = self._get_state_time(next_state, fallback=float('inf'))
|
||||
|
||||
action_events = []
|
||||
for event in events:
|
||||
@@ -1155,6 +1464,7 @@ class GraphBuilder:
|
||||
target_node: Optional[WorkflowNode] = None,
|
||||
all_events: Optional[List[Event]] = None,
|
||||
screen_resolution: Tuple[int, int] = (1920, 1080),
|
||||
source_state: Optional[ScreenState] = None,
|
||||
) -> WorkflowEdge:
|
||||
"""
|
||||
Créer un WorkflowEdge depuis une transition observée.
|
||||
@@ -1180,12 +1490,24 @@ class GraphBuilder:
|
||||
# Si on a plusieurs événements, créer une action compound
|
||||
events_to_use = all_events or ([event] if event else [])
|
||||
|
||||
# UIElements de l'écran source — sert à ancrer les clics sur un vrai
|
||||
# élément UI (rôle, texte, bbox) plutôt que sur une coordonnée brute.
|
||||
source_ui_elements = (
|
||||
list(source_state.ui_elements)
|
||||
if source_state and source_state.ui_elements
|
||||
else []
|
||||
)
|
||||
|
||||
if len(events_to_use) > 1:
|
||||
action = self._build_compound_action(
|
||||
events_to_use, screen_resolution
|
||||
events_to_use, screen_resolution,
|
||||
source_ui_elements=source_ui_elements,
|
||||
)
|
||||
elif len(events_to_use) == 1:
|
||||
action = self._build_single_action(events_to_use[0])
|
||||
action = self._build_single_action(
|
||||
events_to_use[0],
|
||||
source_ui_elements=source_ui_elements,
|
||||
)
|
||||
else:
|
||||
action = Action(
|
||||
type="unknown",
|
||||
@@ -1235,15 +1557,29 @@ class GraphBuilder:
|
||||
metadata=edge_metadata,
|
||||
)
|
||||
|
||||
def _build_single_action(self, event: Event) -> Action:
|
||||
def _build_single_action(
|
||||
self,
|
||||
event: Event,
|
||||
source_ui_elements: Optional[List[Any]] = None,
|
||||
) -> Action:
|
||||
"""
|
||||
Construire une Action simple depuis un seul événement.
|
||||
|
||||
Rétrocompatible avec l'ancien format : un type d'action direct
|
||||
(mouse_click, key_press, text_input) avec ses paramètres.
|
||||
Pour un clic, si `source_ui_elements` est fourni, on tente d'ancrer
|
||||
l'action sur l'UIElement le plus proche (par proximité spatiale).
|
||||
Le TargetSpec devient alors discriminant :
|
||||
- `by_role` = rôle sémantique de l'élément (ex: "primary_action")
|
||||
- `by_text` = label détecté (ex: "Valider")
|
||||
- `selection_policy` = "by_similarity" (laisse le matcher scorer)
|
||||
- `context_hints["anchor_element_id"]` = traçabilité
|
||||
- `context_hints["anchor_bbox"]` = invariant spatial debug
|
||||
|
||||
À défaut d'ancrage (pas d'UIElement ou clic hors de toute bbox
|
||||
proche), on retombe sur `by_role="unknown_element"` (legacy).
|
||||
"""
|
||||
action_type = event.type
|
||||
action_params = {}
|
||||
action_params: Dict[str, Any] = {}
|
||||
target_spec: Optional[TargetSpec] = None
|
||||
|
||||
if action_type == "mouse_click":
|
||||
action_params = {
|
||||
@@ -1251,39 +1587,111 @@ class GraphBuilder:
|
||||
"position": event.data.get("pos", [0, 0]),
|
||||
"wait_after_ms": 500,
|
||||
}
|
||||
target_role = "unknown_element"
|
||||
target_spec = self._build_click_target_spec(
|
||||
event, source_ui_elements or []
|
||||
)
|
||||
|
||||
elif action_type == "key_press":
|
||||
action_params = {
|
||||
"keys": event.data.get("keys", []),
|
||||
"wait_after_ms": 200,
|
||||
}
|
||||
target_role = "keyboard_input"
|
||||
target_spec = TargetSpec(
|
||||
by_role="keyboard_input",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
|
||||
elif action_type == "text_input":
|
||||
action_params = {
|
||||
"text": event.data.get("text", ""),
|
||||
"wait_after_ms": 300,
|
||||
}
|
||||
target_role = "text_field"
|
||||
target_spec = TargetSpec(
|
||||
by_role="text_field",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
else:
|
||||
action_params = {}
|
||||
target_role = "unknown"
|
||||
target_spec = TargetSpec(
|
||||
by_role="unknown",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
|
||||
return Action(
|
||||
type=action_type,
|
||||
target=TargetSpec(
|
||||
by_role=target_role,
|
||||
target=target_spec,
|
||||
parameters=action_params,
|
||||
)
|
||||
|
||||
def _build_click_target_spec(
|
||||
self,
|
||||
event: Event,
|
||||
source_ui_elements: List[Any],
|
||||
) -> TargetSpec:
|
||||
"""
|
||||
Construire un TargetSpec pour un clic, en essayant de l'ancrer à
|
||||
un UIElement détecté sur l'écran source.
|
||||
|
||||
Retourne toujours un TargetSpec valide :
|
||||
- ancré (role + text + context_hints) si un élément proche existe ;
|
||||
- fallback `unknown_element` sinon (comportement historique).
|
||||
"""
|
||||
clicked = self._find_clicked_element(event, source_ui_elements)
|
||||
|
||||
if clicked is None:
|
||||
return TargetSpec(
|
||||
by_role="unknown_element",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
),
|
||||
parameters=action_params,
|
||||
)
|
||||
|
||||
# Extraction défensive des attributs de l'élément.
|
||||
role = getattr(clicked, "role", None) or "unknown_element"
|
||||
label = getattr(clicked, "label", None) or None
|
||||
element_id = getattr(clicked, "element_id", None)
|
||||
|
||||
# Contexte de traçabilité — `context_hints` est le seul dict libre
|
||||
# disponible dans TargetSpec (pas de champ `metadata` dédié).
|
||||
context_hints: Dict[str, Any] = {}
|
||||
if element_id:
|
||||
context_hints["anchor_element_id"] = str(element_id)
|
||||
|
||||
bbox = getattr(clicked, "bbox", None)
|
||||
if bbox is not None:
|
||||
try:
|
||||
context_hints["anchor_bbox"] = {
|
||||
"x": int(getattr(bbox, "x", bbox[0])),
|
||||
"y": int(getattr(bbox, "y", bbox[1])),
|
||||
"width": int(getattr(bbox, "width", bbox[2])),
|
||||
"height": int(getattr(bbox, "height", bbox[3])),
|
||||
}
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
pass
|
||||
|
||||
# Center (utile comme ancre de fallback quand le matcher échoue)
|
||||
center = getattr(clicked, "center", None)
|
||||
if center is not None:
|
||||
try:
|
||||
context_hints["anchor_center"] = [int(center[0]), int(center[1])]
|
||||
except (IndexError, TypeError):
|
||||
pass
|
||||
|
||||
return TargetSpec(
|
||||
by_role=role,
|
||||
by_text=label,
|
||||
selection_policy="by_similarity",
|
||||
fallback_strategy="visual_similarity",
|
||||
context_hints=context_hints,
|
||||
)
|
||||
|
||||
def _build_compound_action(
|
||||
self,
|
||||
events: List[Event],
|
||||
screen_resolution: Tuple[int, int] = (1920, 1080),
|
||||
source_ui_elements: Optional[List[Any]] = None,
|
||||
) -> Action:
|
||||
"""
|
||||
Construire une Action compound (multi-étapes) depuis plusieurs événements.
|
||||
@@ -1360,21 +1768,33 @@ class GraphBuilder:
|
||||
# La cible du compound = cible de la dernière action (le clic final, etc.)
|
||||
last_event = events[-1]
|
||||
if last_event.type == "mouse_click":
|
||||
target_role = "unknown_element"
|
||||
# On tente d'ancrer le clic final aux UIElements détectés,
|
||||
# comme dans _build_single_action.
|
||||
target_spec = self._build_click_target_spec(
|
||||
last_event, source_ui_elements or []
|
||||
)
|
||||
elif last_event.type == "text_input":
|
||||
target_role = "text_field"
|
||||
target_spec = TargetSpec(
|
||||
by_role="text_field",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
elif last_event.type == "key_press":
|
||||
target_role = "keyboard_input"
|
||||
target_spec = TargetSpec(
|
||||
by_role="keyboard_input",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
else:
|
||||
target_role = "unknown"
|
||||
target_spec = TargetSpec(
|
||||
by_role="unknown",
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
)
|
||||
|
||||
return Action(
|
||||
type="compound",
|
||||
target=TargetSpec(
|
||||
by_role=target_role,
|
||||
selection_policy="first",
|
||||
fallback_strategy="visual_similarity",
|
||||
),
|
||||
target=target_spec,
|
||||
parameters={
|
||||
"steps": steps,
|
||||
"step_count": len(steps),
|
||||
|
||||
20
core/grounding/__init__.py
Normal file
20
core/grounding/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# core/grounding — Module de localisation d'éléments UI
|
||||
#
|
||||
# Centralise les méthodes de grounding visuel : template matching,
|
||||
# OCR, VLM, etc. Chaque méthode produit un GroundingResult uniforme.
|
||||
#
|
||||
# Le serveur de grounding (server.py) tourne dans un process séparé
|
||||
# sur le port 8200. Le client HTTP (UITarsGrounder) l'appelle via HTTP.
|
||||
# Le pipeline (GroundingPipeline) orchestre template → OCR → UI-TARS → static.
|
||||
|
||||
from core.grounding.template_matcher import TemplateMatcher, MatchResult
|
||||
from core.grounding.target import GroundingTarget, GroundingResult
|
||||
from core.grounding.ui_tars_grounder import UITarsGrounder
|
||||
from core.grounding.pipeline import GroundingPipeline
|
||||
|
||||
__all__ = [
|
||||
'TemplateMatcher', 'MatchResult',
|
||||
'GroundingTarget', 'GroundingResult',
|
||||
'UITarsGrounder',
|
||||
'GroundingPipeline',
|
||||
]
|
||||
120
core/grounding/bbox_parser.py
Normal file
120
core/grounding/bbox_parser.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Parser des réponses VLM de grounding (bbox_2d, x/y, x_pct/y_pct, array brut).
|
||||
|
||||
Centralise le parsing des coordonnées retournées par les modèles VLM
|
||||
(Qwen-VL via Ollama, vLLM ou Transformers direct) vers une représentation
|
||||
normalisée (x_pct, y_pct).
|
||||
|
||||
Module pur : regex + arithmétique, sans dépendance lourde.
|
||||
|
||||
Convention des diviseurs (DETTE-006 ouverte) : actuellement les call sites
|
||||
passent les dimensions de l'image envoyée au VLM (PRE-resize). C'est la
|
||||
source du bug d'échelle pixel grounding — sera corrigé au commit 3/5 du
|
||||
fix DETTE-006 en passant les dimensions POST-smart_resize.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_ALL_FORMATS = frozenset({"bbox_2d", "xy_json", "xy_pct", "raw_array"})
|
||||
|
||||
|
||||
def parse_bbox_to_norm(
|
||||
content: str,
|
||||
divisor_w: int | float,
|
||||
divisor_h: int | float,
|
||||
*,
|
||||
formats: set[str] | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""Parse une réponse VLM en (x_pct, y_pct) normalisés.
|
||||
|
||||
Cascade des formats (premier qui matche gagne) :
|
||||
1. ``"bbox_2d"`` : ``{"bbox_2d": [x, y]}`` ou ``[x1, y1, x2, y2]``
|
||||
2. ``"xy_json"`` : ``{"x": ..., "y": ...}`` (heuristique x>1 → pixels)
|
||||
3. ``"xy_pct"`` : ``{"x_pct": ..., "y_pct": ...}``
|
||||
4. ``"raw_array"`` : array brut ``[...]`` 2 ou 4 coords
|
||||
|
||||
Args:
|
||||
content: réponse texte du VLM.
|
||||
divisor_w, divisor_h: dimensions normalisant les pixels en pct.
|
||||
formats: ensemble des formats à essayer. Si ``None`` (défaut),
|
||||
cascade complète des 4. Pour restreindre, passer un sous-ensemble
|
||||
de ``{"bbox_2d", "xy_json", "xy_pct", "raw_array"}``.
|
||||
|
||||
Returns:
|
||||
``(x_pct, y_pct)`` ou ``(None, None)`` si aucun format ne matche.
|
||||
"""
|
||||
enabled = _ALL_FORMATS if formats is None else formats
|
||||
x_pct, y_pct = None, None
|
||||
|
||||
# Format 1 : bbox_2d en pixels [x, y] ou [x1, y1, x2, y2]
|
||||
if "bbox_2d" in enabled:
|
||||
bbox_match = re.search(r'"bbox_2d"\s*:\s*\[([^\]]+)\]', content)
|
||||
if bbox_match:
|
||||
coords = [float(v.strip()) for v in bbox_match.group(1).split(",")]
|
||||
if len(coords) == 2:
|
||||
x_pct = coords[0] / divisor_w
|
||||
y_pct = coords[1] / divisor_h
|
||||
elif len(coords) >= 4:
|
||||
x_pct = (coords[0] + coords[2]) / 2 / divisor_w
|
||||
y_pct = (coords[1] + coords[3]) / 2 / divisor_h
|
||||
|
||||
# Format 2 : JSON {"x": 0.XX, "y": 0.YY}
|
||||
if x_pct is None and "xy_json" in enabled:
|
||||
json_match = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content)
|
||||
if json_match:
|
||||
x_val, y_val = float(json_match.group(1)), float(json_match.group(2))
|
||||
if x_val > 1:
|
||||
x_pct = x_val / divisor_w
|
||||
y_pct = y_val / divisor_h
|
||||
else:
|
||||
x_pct = x_val
|
||||
y_pct = y_val
|
||||
|
||||
# Format 3 : JSON {"x_pct": 0.XX, "y_pct": 0.YY}
|
||||
if x_pct is None and "xy_pct" in enabled:
|
||||
pct_match = re.search(r'"x_pct"\s*:\s*([\d.]+).*?"y_pct"\s*:\s*([\d.]+)', content)
|
||||
if pct_match:
|
||||
x_pct = float(pct_match.group(1))
|
||||
y_pct = float(pct_match.group(2))
|
||||
|
||||
# Format 4 : array brut [x1, y1, x2, y2] ou [x, y]
|
||||
if x_pct is None and "raw_array" in enabled:
|
||||
arr_match = re.search(
|
||||
r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]',
|
||||
content,
|
||||
)
|
||||
if arr_match:
|
||||
vals = [float(v) for v in arr_match.groups() if v is not None]
|
||||
if len(vals) >= 4:
|
||||
x_pct = (vals[0] + vals[2]) / 2 / divisor_w
|
||||
y_pct = (vals[1] + vals[3]) / 2 / divisor_h
|
||||
elif len(vals) == 2:
|
||||
x_pct = vals[0] / divisor_w
|
||||
y_pct = vals[1] / divisor_h
|
||||
|
||||
return x_pct, y_pct
|
||||
|
||||
|
||||
def parse_bbox_to_norm_validated(
|
||||
content: str,
|
||||
divisor_w: int | float,
|
||||
divisor_h: int | float,
|
||||
*,
|
||||
formats: set[str] | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""Idem :func:`parse_bbox_to_norm` + validation domaine [0, 1].
|
||||
|
||||
Retourne ``(None, None)`` si le résultat parsé est hors ``[0, 1]`` sur
|
||||
l'un des deux axes — comportement de ``_locate_popup_button``
|
||||
(cf. resolve_engine.py:2569-2580).
|
||||
|
||||
Implémentation : appelle :func:`parse_bbox_to_norm` puis valide. Pas
|
||||
de duplication de la logique de parsing.
|
||||
"""
|
||||
x_pct, y_pct = parse_bbox_to_norm(content, divisor_w, divisor_h, formats=formats)
|
||||
if x_pct is None or y_pct is None:
|
||||
return None, None
|
||||
if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0):
|
||||
return None, None
|
||||
return x_pct, y_pct
|
||||
256
core/grounding/dialog_handler.py
Normal file
256
core/grounding/dialog_handler.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
core/grounding/dialog_handler.py — Gestion intelligente des dialogues
|
||||
|
||||
Quand un dialogue inattendu apparaît (pHash change après une action) :
|
||||
1. Lire le titre de la fenêtre (EasyOCR crop 45px, ~130ms)
|
||||
2. Si titre connu (Enregistrer sous, Confirmer, etc.) → action connue
|
||||
3. Demander à InfiGUI de cliquer sur le bon bouton (~3s)
|
||||
4. Vérifier que le dialogue a disparu (pHash)
|
||||
|
||||
Pas de patterns prédéfinis pour les boutons. InfiGUI comprend
|
||||
visuellement le dialogue et clique au bon endroit.
|
||||
|
||||
Utilisation :
|
||||
from core.grounding.dialog_handler import DialogHandler
|
||||
|
||||
handler = DialogHandler()
|
||||
result = handler.handle_if_dialog(screenshot_pil)
|
||||
if result['handled']:
|
||||
print(f"Dialogue '{result['title']}' géré → {result['action']}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
# Titres connus → quelle action demander à InfiGUI.
|
||||
#
|
||||
# IMPORTANT — ordre du dict = priorité de matching.
|
||||
# L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup
|
||||
# modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière
|
||||
# "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les
|
||||
# fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas
|
||||
# le focus.
|
||||
KNOWN_DIALOGS = {
|
||||
# ── Popups modaux de confirmation (priorité HAUTE) ──────────────────
|
||||
"voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"},
|
||||
"do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
|
||||
"existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"},
|
||||
"already exists": {"target": "Yes", "description": "Click Yes, the file already exists"},
|
||||
"remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"},
|
||||
"replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
|
||||
"écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"},
|
||||
"overwrite": {"target": "Yes", "description": "Click Yes to overwrite"},
|
||||
"confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"},
|
||||
"confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"},
|
||||
# ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ───────
|
||||
"erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"},
|
||||
"error": {"target": "OK", "description": "Click OK to close the error message"},
|
||||
"avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"},
|
||||
"warning": {"target": "OK", "description": "Click OK to close the warning"},
|
||||
# ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─
|
||||
"voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"},
|
||||
"do you want to save": {"target": "Save", "description": "Click Save to save changes"},
|
||||
"enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"},
|
||||
"save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"},
|
||||
}
|
||||
|
||||
|
||||
class DialogHandler:
|
||||
"""Gestion intelligente des dialogues via titre + InfiGUI."""
|
||||
|
||||
def __init__(self):
|
||||
self._easyocr_reader = None
|
||||
|
||||
def handle_if_dialog(
|
||||
self,
|
||||
screenshot_pil,
|
||||
previous_title: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Vérifie si l'écran montre un dialogue et le gère.
|
||||
|
||||
Args:
|
||||
screenshot_pil: Screenshot PIL actuel.
|
||||
previous_title: Titre de la fenêtre avant l'action (pour comparaison).
|
||||
|
||||
Returns:
|
||||
Dict avec 'handled' (bool), 'title', 'action', 'position'.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
# 1. Lire le titre de la fenêtre
|
||||
title = self._read_title(screenshot_pil)
|
||||
if not title or len(title) < 3:
|
||||
return {'handled': False, 'title': '', 'reason': 'Titre illisible'}
|
||||
|
||||
print(f"🔍 [Dialog] Titre lu: '{title}'")
|
||||
|
||||
# 2. Chercher si c'est un dialogue connu
|
||||
matched_dialog = None
|
||||
for key, action_info in KNOWN_DIALOGS.items():
|
||||
if key in title.lower():
|
||||
matched_dialog = (key, action_info)
|
||||
break
|
||||
|
||||
if not matched_dialog:
|
||||
# Pas un dialogue connu — le workflow continue normalement
|
||||
return {'handled': False, 'title': title, 'reason': 'Pas un dialogue connu'}
|
||||
|
||||
dialog_key, action_info = matched_dialog
|
||||
target = action_info['target']
|
||||
description = action_info['description']
|
||||
|
||||
print(f"🧠 [Dialog] Dialogue détecté: '{dialog_key}' → clic '{target}'")
|
||||
|
||||
# 3. Demander à InfiGUI de cliquer sur le bouton
|
||||
click_result = self._click_via_infigui(
|
||||
target, description, screenshot_pil
|
||||
)
|
||||
|
||||
dt = (time.time() - t0) * 1000
|
||||
|
||||
if click_result:
|
||||
print(f"✅ [Dialog] Clic '{target}' à ({click_result['x']}, {click_result['y']}) ({dt:.0f}ms)")
|
||||
return {
|
||||
'handled': True,
|
||||
'title': title,
|
||||
'dialog_type': dialog_key,
|
||||
'action': f"click '{target}'",
|
||||
'position': (click_result['x'], click_result['y']),
|
||||
'time_ms': dt,
|
||||
}
|
||||
else:
|
||||
# InfiGUI n'a pas trouvé le bouton — essayer le clic direct via OCR
|
||||
print(f"⚠️ [Dialog] InfiGUI n'a pas trouvé '{target}', essai OCR direct")
|
||||
ocr_result = self._click_via_ocr(target, screenshot_pil)
|
||||
dt = (time.time() - t0) * 1000
|
||||
|
||||
if ocr_result:
|
||||
print(f"✅ [Dialog] OCR clic '{target}' à ({ocr_result[0]}, {ocr_result[1]}) ({dt:.0f}ms)")
|
||||
return {
|
||||
'handled': True,
|
||||
'title': title,
|
||||
'dialog_type': dialog_key,
|
||||
'action': f"click '{target}' (OCR)",
|
||||
'position': ocr_result,
|
||||
'time_ms': dt,
|
||||
}
|
||||
|
||||
print(f"❌ [Dialog] Impossible de cliquer '{target}' ({dt:.0f}ms)")
|
||||
return {
|
||||
'handled': False,
|
||||
'title': title,
|
||||
'dialog_type': dialog_key,
|
||||
'reason': f"Bouton '{target}' introuvable",
|
||||
'time_ms': dt,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lecture titre
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_title(self, screenshot_pil) -> str:
|
||||
"""Lit TOUT le texte visible via EasyOCR full-screen (~500ms).
|
||||
|
||||
En VM QEMU, la barre de titre Windows est à l'intérieur du framebuffer,
|
||||
pas en haut absolu de l'écran. On fait l'OCR full-screen et on cherche
|
||||
les mots-clés des dialogues connus dans le texte complet.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
reader = self._get_easyocr()
|
||||
if reader is None:
|
||||
return ""
|
||||
|
||||
results = reader.readtext(np.array(screenshot_pil))
|
||||
full_text = ' '.join(r[1] for r in results if r[1].strip())
|
||||
return full_text
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Dialog] Erreur lecture écran: {e}")
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Clic via InfiGUI (serveur grounding)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _click_via_infigui(
|
||||
self, target: str, description: str, screenshot_pil
|
||||
) -> Optional[Dict]:
|
||||
"""Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton."""
|
||||
try:
|
||||
from core.grounding.ui_tars_grounder import UITarsGrounder
|
||||
|
||||
grounder = UITarsGrounder.get_instance()
|
||||
result = grounder.ground(
|
||||
target_text=target,
|
||||
target_description=description,
|
||||
screen_pil=screenshot_pil,
|
||||
)
|
||||
|
||||
if result and result.x is not None:
|
||||
import pyautogui
|
||||
pyautogui.click(result.x, result.y)
|
||||
return {'x': result.x, 'y': result.y}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Dialog/InfiGUI] Erreur: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Clic via OCR (fallback rapide)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _click_via_ocr(self, target: str, screenshot_pil) -> Optional[tuple]:
|
||||
"""Cherche le bouton par OCR et clique dessus."""
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
reader = self._get_easyocr()
|
||||
if reader is None:
|
||||
return None
|
||||
|
||||
results = reader.readtext(np.array(screenshot_pil))
|
||||
|
||||
target_lower = target.lower()
|
||||
matches = []
|
||||
for (bbox_pts, text, conf) in results:
|
||||
if target_lower in text.lower() or text.lower() in target_lower:
|
||||
x = int(sum(p[0] for p in bbox_pts) / 4)
|
||||
y = int(sum(p[1] for p in bbox_pts) / 4)
|
||||
matches.append((x, y, text))
|
||||
|
||||
if matches:
|
||||
# Prendre le match le plus bas (boutons = bas du dialogue)
|
||||
best = max(matches, key=lambda m: m[1])
|
||||
import pyautogui
|
||||
pyautogui.click(best[0], best[1])
|
||||
return (best[0], best[1])
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Dialog/OCR] Erreur: {e}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# EasyOCR singleton
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_easyocr(self):
|
||||
if self._easyocr_reader is not None:
|
||||
return self._easyocr_reader
|
||||
|
||||
try:
|
||||
import easyocr
|
||||
self._easyocr_reader = easyocr.Reader(
|
||||
['fr', 'en'], gpu=True, verbose=False
|
||||
)
|
||||
return self._easyocr_reader
|
||||
except ImportError:
|
||||
return None
|
||||
239
core/grounding/element_signature.py
Normal file
239
core/grounding/element_signature.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
core/grounding/element_signature.py — Signatures d'éléments UI apprises
|
||||
|
||||
Chaque élément cliqué avec succès enrichit sa signature :
|
||||
- texte OCR, type, position relative, voisins contextuels
|
||||
- nombre de succès/échecs, confiance moyenne
|
||||
- variantes observées (résolutions, positions)
|
||||
|
||||
Les signatures sont stockées en SQLite pour un lookup rapide.
|
||||
Pattern identique à TargetMemoryStore (validé en prod).
|
||||
|
||||
Utilisation :
|
||||
from core.grounding.element_signature import SignatureStore
|
||||
|
||||
store = SignatureStore()
|
||||
|
||||
# Après un clic réussi
|
||||
store.record_success("btn_valider", "notepad_1920x1080", element, confidence=0.92)
|
||||
|
||||
# Au replay
|
||||
sig = store.lookup("btn_valider", "notepad_1920x1080")
|
||||
if sig:
|
||||
print(f"Signature connue : {sig['text']} position={sig['relative_position']}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.grounding.fast_types import DetectedUIElement
|
||||
|
||||
# Chemin par défaut de la DB
|
||||
_DEFAULT_DB = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
"data", "learning", "element_signatures.db",
|
||||
)
|
||||
|
||||
|
||||
class SignatureStore:
|
||||
"""Stockage SQLite des signatures d'éléments UI appris."""
|
||||
|
||||
def __init__(self, db_path: str = _DEFAULT_DB):
|
||||
self.db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._ensure_db()
|
||||
|
||||
def _ensure_db(self):
|
||||
"""Crée la DB et la table si nécessaire."""
|
||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS signatures (
|
||||
target_key TEXT NOT NULL,
|
||||
screen_context TEXT NOT NULL,
|
||||
text TEXT DEFAULT '',
|
||||
element_type TEXT DEFAULT 'element',
|
||||
relative_position TEXT DEFAULT '',
|
||||
neighbors TEXT DEFAULT '[]',
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
avg_confidence REAL DEFAULT 0.0,
|
||||
last_seen TEXT DEFAULT '',
|
||||
variants TEXT DEFAULT '[]',
|
||||
PRIMARY KEY (target_key, screen_context)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_target_key
|
||||
ON signatures(target_key)
|
||||
""")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lookup(self, target_key: str, screen_context: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Cherche une signature connue.
|
||||
|
||||
Args:
|
||||
target_key: Clé unique de la cible (hash du texte + description).
|
||||
screen_context: Contexte d'écran (hash titre fenêtre + résolution).
|
||||
|
||||
Returns:
|
||||
Dict avec les champs de la signature, ou None.
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Chercher avec le contexte exact d'abord
|
||||
row = conn.execute(
|
||||
"SELECT * FROM signatures WHERE target_key = ? AND screen_context = ?",
|
||||
(target_key, screen_context),
|
||||
).fetchone()
|
||||
|
||||
# Fallback : chercher sans contexte (toutes les variantes)
|
||||
if row is None and screen_context:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM signatures WHERE target_key = ? ORDER BY success_count DESC LIMIT 1",
|
||||
(target_key,),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"target_key": row["target_key"],
|
||||
"screen_context": row["screen_context"],
|
||||
"text": row["text"],
|
||||
"element_type": row["element_type"],
|
||||
"relative_position": row["relative_position"],
|
||||
"neighbors": json.loads(row["neighbors"]),
|
||||
"success_count": row["success_count"],
|
||||
"fail_count": row["fail_count"],
|
||||
"avg_confidence": row["avg_confidence"],
|
||||
"last_seen": row["last_seen"],
|
||||
"variants": json.loads(row["variants"]),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Enregistrement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_success(
|
||||
self,
|
||||
target_key: str,
|
||||
screen_context: str,
|
||||
element: DetectedUIElement,
|
||||
confidence: float,
|
||||
):
|
||||
"""Enregistre un succès — crée ou enrichit la signature."""
|
||||
with self._lock:
|
||||
existing = self.lookup(target_key, screen_context)
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
if existing:
|
||||
# Enrichir la signature existante
|
||||
n = existing["success_count"]
|
||||
new_avg = (existing["avg_confidence"] * n + confidence) / (n + 1)
|
||||
|
||||
# Ajouter la variante si position différente
|
||||
variants = existing["variants"]
|
||||
variant = {
|
||||
"position": element.relative_position,
|
||||
"center": list(element.center),
|
||||
"confidence": confidence,
|
||||
"timestamp": now,
|
||||
}
|
||||
variants.append(variant)
|
||||
# Garder les 20 dernières variantes max
|
||||
variants = variants[-20:]
|
||||
|
||||
# Mettre à jour les voisins (union)
|
||||
neighbors = list(set(existing["neighbors"] + element.neighbors))[:10]
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
UPDATE signatures SET
|
||||
success_count = success_count + 1,
|
||||
avg_confidence = ?,
|
||||
last_seen = ?,
|
||||
neighbors = ?,
|
||||
variants = ?,
|
||||
relative_position = ?
|
||||
WHERE target_key = ? AND screen_context = ?
|
||||
""", (
|
||||
new_avg, now,
|
||||
json.dumps(neighbors),
|
||||
json.dumps(variants),
|
||||
element.relative_position,
|
||||
target_key, screen_context,
|
||||
))
|
||||
else:
|
||||
# Créer une nouvelle signature
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO signatures
|
||||
(target_key, screen_context, text, element_type, relative_position,
|
||||
neighbors, success_count, fail_count, avg_confidence, last_seen, variants)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?)
|
||||
""", (
|
||||
target_key, screen_context,
|
||||
element.ocr_text,
|
||||
element.element_type,
|
||||
element.relative_position,
|
||||
json.dumps(element.neighbors[:10]),
|
||||
confidence, now,
|
||||
json.dumps([{
|
||||
"position": element.relative_position,
|
||||
"center": list(element.center),
|
||||
"confidence": confidence,
|
||||
"timestamp": now,
|
||||
}]),
|
||||
))
|
||||
|
||||
print(f"📝 [Signature] '{target_key}' {'enrichie' if existing else 'créée'} "
|
||||
f"(conf={confidence:.2f}, ctx='{screen_context[:30]}')")
|
||||
|
||||
def record_failure(self, target_key: str, screen_context: str):
|
||||
"""Enregistre un échec pour une signature."""
|
||||
with self._lock:
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
UPDATE signatures SET fail_count = fail_count + 1, last_seen = ?
|
||||
WHERE target_key = ? AND screen_context = ?
|
||||
""", (time.strftime("%Y-%m-%dT%H:%M:%S"), target_key, screen_context))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilitaires
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def make_target_key(text: str, description: str = "") -> str:
|
||||
"""Génère une clé unique pour une cible."""
|
||||
raw = f"{text.lower().strip()}|{description.lower().strip()}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def make_screen_context(window_title: str, resolution: tuple = (0, 0)) -> str:
|
||||
"""Génère un contexte d'écran."""
|
||||
raw = f"{window_title.lower().strip()}|{resolution[0]}x{resolution[1]}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Statistiques de la base de signatures."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM signatures").fetchone()[0]
|
||||
reliable = conn.execute(
|
||||
"SELECT COUNT(*) FROM signatures WHERE success_count >= 3 AND fail_count = 0"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"total_signatures": total,
|
||||
"reliable": reliable,
|
||||
"db_path": self.db_path,
|
||||
}
|
||||
326
core/grounding/fast_detector.py
Normal file
326
core/grounding/fast_detector.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
core/grounding/fast_detector.py — Layer FAST : détection rapide des éléments UI
|
||||
|
||||
Capture l'écran, détecte tous les éléments UI via RF-DETR (~120ms),
|
||||
enrichit chaque élément avec le texte OCR et le contexte spatial.
|
||||
|
||||
Produit un ScreenSnapshot utilisable par le SmartMatcher.
|
||||
|
||||
Utilisation :
|
||||
from core.grounding.fast_detector import FastDetector
|
||||
|
||||
detector = FastDetector()
|
||||
snapshot = detector.detect()
|
||||
print(f"{len(snapshot.elements)} éléments en {snapshot.total_time_ms:.0f}ms")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from core.grounding.fast_types import DetectedUIElement, ScreenSnapshot
|
||||
|
||||
|
||||
class FastDetector:
|
||||
"""Détection rapide de tous les éléments UI visibles sur l'écran.
|
||||
|
||||
Combine RF-DETR (détection bbox) + docTR (OCR) pour produire
|
||||
un ScreenSnapshot enrichi.
|
||||
|
||||
Le modèle RF-DETR est un singleton chargé au premier appel (~1s),
|
||||
puis les appels suivants sont rapides (~120ms).
|
||||
"""
|
||||
|
||||
def __init__(self, detection_threshold: float = 0.30):
|
||||
self.detection_threshold = detection_threshold
|
||||
self._last_snapshot: Optional[ScreenSnapshot] = None
|
||||
self._last_phash: str = ""
|
||||
|
||||
def detect(
|
||||
self,
|
||||
screenshot_pil: Optional[Any] = None,
|
||||
phash: str = "",
|
||||
window_title: str = "",
|
||||
) -> ScreenSnapshot:
|
||||
"""Détecte et enrichit tous les éléments UI de l'écran.
|
||||
|
||||
Args:
|
||||
screenshot_pil: Image PIL. Si None, capture via mss.
|
||||
phash: Hash perceptuel pour le cache. Si identique au dernier, réutilise le cache.
|
||||
window_title: Titre de la fenêtre active.
|
||||
|
||||
Returns:
|
||||
ScreenSnapshot avec tous les éléments enrichis.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
# Cache : même écran → même résultat
|
||||
if phash and phash == self._last_phash and self._last_snapshot is not None:
|
||||
print(f"⚡ [FAST] Cache hit (pHash identique)")
|
||||
return self._last_snapshot
|
||||
|
||||
# Capture si pas fourni
|
||||
if screenshot_pil is None:
|
||||
screenshot_pil = self._capture_screen()
|
||||
if screenshot_pil is None:
|
||||
return ScreenSnapshot(elements=[], ocr_words=[], resolution=(0, 0))
|
||||
|
||||
w, h = screenshot_pil.size
|
||||
|
||||
# --- Détection RF-DETR (~120ms) ---
|
||||
t_det = time.time()
|
||||
raw_elements = self._detect_rfdetr(screenshot_pil)
|
||||
detection_ms = (time.time() - t_det) * 1000
|
||||
|
||||
# --- OCR sur les crops des éléments détectés (pas full screen) ---
|
||||
t_ocr = time.time()
|
||||
ocr_words = self._ocr_extract(screenshot_pil)
|
||||
ocr_ms = (time.time() - t_ocr) * 1000
|
||||
|
||||
# --- Enrichissement : attribuer texte + voisins + position ---
|
||||
enriched = self._enrich_elements(raw_elements, ocr_words, w, h)
|
||||
|
||||
total_ms = (time.time() - t0) * 1000
|
||||
|
||||
snapshot = ScreenSnapshot(
|
||||
elements=enriched,
|
||||
ocr_words=ocr_words,
|
||||
resolution=(w, h),
|
||||
window_title=window_title,
|
||||
phash=phash,
|
||||
detection_time_ms=detection_ms,
|
||||
ocr_time_ms=ocr_ms,
|
||||
total_time_ms=total_ms,
|
||||
)
|
||||
|
||||
# Mettre en cache
|
||||
if phash:
|
||||
self._last_phash = phash
|
||||
self._last_snapshot = snapshot
|
||||
|
||||
print(f"⚡ [FAST] {len(enriched)} éléments détectés en {total_ms:.0f}ms "
|
||||
f"(det={detection_ms:.0f}ms, ocr={ocr_ms:.0f}ms)")
|
||||
|
||||
return snapshot
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Détection RF-DETR
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_rfdetr(self, image) -> List[DetectedUIElement]:
|
||||
"""Détecte les éléments via RF-DETR (réutilise le singleton existant)."""
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, 'visual_workflow_builder/backend')
|
||||
from services.ui_detection_service import detect_ui_elements
|
||||
|
||||
result = detect_ui_elements(image, threshold=self.detection_threshold)
|
||||
|
||||
elements = []
|
||||
for e in result.elements:
|
||||
x1 = e.bbox["x1"]
|
||||
y1 = e.bbox["y1"]
|
||||
x2 = e.bbox["x2"]
|
||||
y2 = e.bbox["y2"]
|
||||
elements.append(DetectedUIElement(
|
||||
id=e.id,
|
||||
bbox=(x1, y1, x2, y2),
|
||||
center=(e.center["x"], e.center["y"]),
|
||||
confidence=e.confidence,
|
||||
))
|
||||
|
||||
return elements
|
||||
|
||||
except Exception as ex:
|
||||
print(f"⚠️ [FAST/detect] RF-DETR erreur: {ex}")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OCR
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_easyocr_reader = None # Singleton EasyOCR (chargé une fois)
|
||||
|
||||
def _ocr_extract(self, image) -> List[Dict[str, Any]]:
|
||||
"""Extrait les mots visibles via EasyOCR (GPU, ~500ms).
|
||||
|
||||
Fallback sur docTR si EasyOCR non disponible.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
import easyocr
|
||||
|
||||
# Singleton : charger le reader une seule fois
|
||||
if FastDetector._easyocr_reader is None:
|
||||
print(f"🔍 [FAST/ocr] Chargement EasyOCR (GPU)...")
|
||||
FastDetector._easyocr_reader = easyocr.Reader(
|
||||
['fr', 'en'], gpu=True, verbose=False
|
||||
)
|
||||
|
||||
results = FastDetector._easyocr_reader.readtext(np.array(image))
|
||||
|
||||
words = []
|
||||
for (bbox_pts, text, conf) in results:
|
||||
if not text or len(text.strip()) < 1:
|
||||
continue
|
||||
# bbox_pts = [[x1,y1],[x2,y1],[x2,y2],[x1,y2]]
|
||||
x1 = int(min(p[0] for p in bbox_pts))
|
||||
y1 = int(min(p[1] for p in bbox_pts))
|
||||
x2 = int(max(p[0] for p in bbox_pts))
|
||||
y2 = int(max(p[1] for p in bbox_pts))
|
||||
words.append({
|
||||
'text': text.strip(),
|
||||
'bbox': [x1, y1, x2, y2],
|
||||
'confidence': float(conf),
|
||||
})
|
||||
|
||||
return words
|
||||
|
||||
except ImportError:
|
||||
# Fallback docTR
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, 'visual_workflow_builder/backend')
|
||||
from services.ocr_service import ocr_extract_words
|
||||
return ocr_extract_words(image) or []
|
||||
except Exception:
|
||||
return []
|
||||
except Exception as ex:
|
||||
print(f"⚠️ [FAST/ocr] EasyOCR erreur: {ex}")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Enrichissement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _enrich_elements(
|
||||
self,
|
||||
elements: List[DetectedUIElement],
|
||||
ocr_words: List[Dict[str, Any]],
|
||||
screen_w: int,
|
||||
screen_h: int,
|
||||
) -> List[DetectedUIElement]:
|
||||
"""Enrichit chaque élément avec texte OCR, voisins et position relative."""
|
||||
|
||||
for elem in elements:
|
||||
# 1. Attribuer le texte OCR par intersection bbox
|
||||
elem.ocr_text = self._assign_ocr_text(elem, ocr_words)
|
||||
|
||||
# 2. Position relative dans l'écran (grille 3x3)
|
||||
elem.relative_position = self._compute_relative_position(
|
||||
elem.center, screen_w, screen_h
|
||||
)
|
||||
|
||||
# 3. Classifier le type d'élément (heuristique taille + ratio)
|
||||
elem.element_type = self._classify_element_type(elem)
|
||||
|
||||
# 4. Calculer les voisins (texte des éléments proches)
|
||||
for elem in elements:
|
||||
elem.neighbors = self._find_neighbors(elem, elements)
|
||||
|
||||
return elements
|
||||
|
||||
def _assign_ocr_text(
|
||||
self,
|
||||
elem: DetectedUIElement,
|
||||
ocr_words: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Attribue le texte OCR à un élément par intersection géométrique."""
|
||||
x1, y1, x2, y2 = elem.bbox
|
||||
# Élargir la bbox de 20% pour capturer le texte autour
|
||||
margin_x = int((x2 - x1) * 0.2)
|
||||
margin_y = int((y2 - y1) * 0.2)
|
||||
ex1, ey1 = x1 - margin_x, y1 - margin_y
|
||||
ex2, ey2 = x2 + margin_x, y2 + margin_y
|
||||
|
||||
texts = []
|
||||
for word in ocr_words:
|
||||
wb = word.get('bbox', [0, 0, 0, 0])
|
||||
if len(wb) < 4:
|
||||
continue
|
||||
wx1, wy1, wx2, wy2 = wb[0], wb[1], wb[2], wb[3]
|
||||
# Intersection ?
|
||||
if wx1 < ex2 and wx2 > ex1 and wy1 < ey2 and wy2 > ey1:
|
||||
text = word.get('text', '').strip()
|
||||
if text and len(text) > 1:
|
||||
texts.append(text)
|
||||
|
||||
return ' '.join(texts)
|
||||
|
||||
@staticmethod
|
||||
def _compute_relative_position(
|
||||
center: Tuple[int, int],
|
||||
screen_w: int,
|
||||
screen_h: int,
|
||||
) -> str:
|
||||
"""Calcule la position relative dans une grille 3x3."""
|
||||
cx, cy = center
|
||||
col = "left" if cx < screen_w / 3 else ("right" if cx > 2 * screen_w / 3 else "center")
|
||||
row = "top" if cy < screen_h / 3 else ("bottom" if cy > 2 * screen_h / 3 else "middle")
|
||||
return f"{row}_{col}"
|
||||
|
||||
@staticmethod
|
||||
def _classify_element_type(elem: DetectedUIElement) -> str:
|
||||
"""Classifie le type d'élément par heuristique taille/ratio."""
|
||||
w, h = elem.width, elem.height
|
||||
if w == 0 or h == 0:
|
||||
return "element"
|
||||
ratio = w / h
|
||||
area = w * h
|
||||
|
||||
# Petit carré → icône
|
||||
if area < 5000 and 0.5 < ratio < 2.0:
|
||||
return "icon"
|
||||
# Large et fin → bouton ou champ
|
||||
if ratio > 3.0 and h < 60:
|
||||
return "input"
|
||||
if ratio > 2.0 and h < 50:
|
||||
return "button"
|
||||
# Grand bloc → zone de contenu
|
||||
if area > 50000:
|
||||
return "container"
|
||||
|
||||
return "element"
|
||||
|
||||
@staticmethod
|
||||
def _find_neighbors(
|
||||
elem: DetectedUIElement,
|
||||
all_elements: List[DetectedUIElement],
|
||||
max_neighbors: int = 5,
|
||||
) -> List[str]:
|
||||
"""Trouve les textes OCR des éléments proches (rayon 1.5x diagonale)."""
|
||||
diag = math.sqrt(elem.width**2 + elem.height**2)
|
||||
radius = max(diag * 1.5, 100) # minimum 100px
|
||||
|
||||
neighbors = []
|
||||
for other in all_elements:
|
||||
if other.id == elem.id or not other.ocr_text:
|
||||
continue
|
||||
dx = other.center[0] - elem.center[0]
|
||||
dy = other.center[1] - elem.center[1]
|
||||
dist = math.sqrt(dx**2 + dy**2)
|
||||
if dist < radius:
|
||||
neighbors.append(other.ocr_text)
|
||||
|
||||
return neighbors[:max_neighbors]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capture écran
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _capture_screen():
|
||||
"""Capture l'écran via mss."""
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image
|
||||
|
||||
with mss.mss() as sct:
|
||||
mon = sct.monitors[0]
|
||||
grab = sct.grab(mon)
|
||||
return Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX')
|
||||
except Exception as ex:
|
||||
print(f"⚠️ [FAST/capture] Erreur: {ex}")
|
||||
return None
|
||||
216
core/grounding/fast_pipeline.py
Normal file
216
core/grounding/fast_pipeline.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
core/grounding/fast_pipeline.py — Pipeline FAST → SMART → THINK
|
||||
|
||||
Orchestrateur central : détecte les éléments (FAST), matche avec la cible (SMART),
|
||||
et demande au VLM de trancher si le score est trop bas (THINK).
|
||||
|
||||
Seuils de confiance :
|
||||
≥ 0.90 → action directe (FAST/SMART)
|
||||
0.60-0.90 → VLM confirme (THINK)
|
||||
< 0.60 → VLM cherche seul (THINK)
|
||||
|
||||
L'ancien GroundingPipeline est utilisé en fallback si tout échoue.
|
||||
|
||||
Utilisation :
|
||||
from core.grounding.fast_pipeline import FastSmartThinkPipeline
|
||||
from core.grounding.target import GroundingTarget
|
||||
|
||||
pipeline = FastSmartThinkPipeline()
|
||||
result = pipeline.locate(GroundingTarget(text="Valider"))
|
||||
if result:
|
||||
print(f"({result.x}, {result.y}) via {result.method} en {result.time_ms:.0f}ms")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from core.grounding.target import GroundingTarget, GroundingResult
|
||||
from core.grounding.fast_types import LocateResult
|
||||
from core.grounding.fast_detector import FastDetector
|
||||
from core.grounding.smart_matcher import SmartMatcher
|
||||
from core.grounding.think_arbiter import ThinkArbiter
|
||||
from core.grounding.element_signature import SignatureStore
|
||||
|
||||
|
||||
# Singleton
|
||||
_instance: Optional[FastSmartThinkPipeline] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
|
||||
class FastSmartThinkPipeline:
|
||||
"""Pipeline FAST → SMART → THINK pour la localisation d'éléments UI.
|
||||
|
||||
Chaque appel à locate() suit la cascade :
|
||||
1. FAST : détection RF-DETR + OCR enrichissement (~120ms+1s)
|
||||
2. SMART : matching texte/type/position/voisins (< 1ms)
|
||||
3. THINK : VLM arbitre si score insuffisant (~3-5s)
|
||||
4. Fallback : ancien pipeline si tout échoue
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
confidence_direct: float = 0.90,
|
||||
confidence_think: float = 0.60,
|
||||
enable_think: bool = True,
|
||||
enable_learning: bool = True,
|
||||
):
|
||||
self.confidence_direct = confidence_direct
|
||||
self.confidence_think = confidence_think
|
||||
self.enable_think = enable_think
|
||||
self.enable_learning = enable_learning
|
||||
|
||||
self._detector = FastDetector()
|
||||
self._matcher = SmartMatcher()
|
||||
self._arbiter = ThinkArbiter()
|
||||
self._signatures = SignatureStore()
|
||||
self._fallback_pipeline = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> FastSmartThinkPipeline:
|
||||
"""Retourne l'instance singleton."""
|
||||
global _instance
|
||||
if _instance is None:
|
||||
with _instance_lock:
|
||||
if _instance is None:
|
||||
_instance = cls()
|
||||
return _instance
|
||||
|
||||
def set_fallback_pipeline(self, pipeline) -> None:
|
||||
"""Configure l'ancien pipeline comme safety net."""
|
||||
self._fallback_pipeline = pipeline
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API principale
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def locate(
|
||||
self,
|
||||
target: GroundingTarget,
|
||||
screenshot_pil=None,
|
||||
phash: str = "",
|
||||
window_title: str = "",
|
||||
) -> Optional[GroundingResult]:
|
||||
"""Localise un élément UI via la cascade FAST → SMART → THINK.
|
||||
|
||||
Args:
|
||||
target: Ce qu'on cherche (texte, description, bbox d'origine).
|
||||
screenshot_pil: Image PIL. Si None, capture via mss.
|
||||
phash: Hash perceptuel pour le cache.
|
||||
window_title: Titre de la fenêtre active.
|
||||
|
||||
Returns:
|
||||
GroundingResult compatible avec le pipeline existant, ou None.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
# --- FAST : détecter tous les éléments ---
|
||||
snapshot = self._detector.detect(
|
||||
screenshot_pil=screenshot_pil,
|
||||
phash=phash,
|
||||
window_title=window_title,
|
||||
)
|
||||
|
||||
if not snapshot.elements:
|
||||
print(f"⚡ [Pipeline] FAST : aucun élément détecté")
|
||||
return self._try_fallback(target)
|
||||
|
||||
# --- Lookup signature apprise ---
|
||||
target_key = SignatureStore.make_target_key(
|
||||
target.text or "", target.description or ""
|
||||
)
|
||||
screen_ctx = SignatureStore.make_screen_context(
|
||||
window_title, snapshot.resolution
|
||||
)
|
||||
signature = self._signatures.lookup(target_key, screen_ctx)
|
||||
|
||||
# --- SMART : matcher avec la cible ---
|
||||
candidate = self._matcher.match(snapshot, target, signature)
|
||||
|
||||
if candidate:
|
||||
dt = (time.time() - t0) * 1000
|
||||
|
||||
# Score suffisant → action directe
|
||||
if candidate.score >= self.confidence_direct:
|
||||
print(f"✅ [Pipeline] FAST→SMART direct : '{candidate.element.ocr_text}' "
|
||||
f"score={candidate.score:.3f} ({candidate.method}) "
|
||||
f"→ ({candidate.element.center[0]}, {candidate.element.center[1]}) "
|
||||
f"en {dt:.0f}ms")
|
||||
|
||||
# Apprentissage
|
||||
if self.enable_learning:
|
||||
self._signatures.record_success(
|
||||
target_key, screen_ctx,
|
||||
candidate.element, candidate.score,
|
||||
)
|
||||
|
||||
return GroundingResult(
|
||||
x=candidate.element.center[0],
|
||||
y=candidate.element.center[1],
|
||||
method=f"fast_{candidate.method}",
|
||||
confidence=candidate.score,
|
||||
time_ms=dt,
|
||||
)
|
||||
|
||||
# Score moyen → demander au VLM de confirmer
|
||||
if candidate.score >= self.confidence_think and self.enable_think:
|
||||
print(f"🤔 [Pipeline] SMART score={candidate.score:.3f} — THINK pour confirmer")
|
||||
think_result = self._arbiter.arbitrate(
|
||||
target,
|
||||
candidates=[candidate],
|
||||
screenshot_pil=screenshot_pil or snapshot.elements[0] if False else screenshot_pil,
|
||||
)
|
||||
dt = (time.time() - t0) * 1000
|
||||
|
||||
if think_result:
|
||||
# VLM a confirmé
|
||||
if self.enable_learning:
|
||||
self._signatures.record_success(
|
||||
target_key, screen_ctx,
|
||||
candidate.element, think_result.confidence,
|
||||
)
|
||||
return GroundingResult(
|
||||
x=think_result.x, y=think_result.y,
|
||||
method="smart_think_confirmed",
|
||||
confidence=think_result.confidence,
|
||||
time_ms=dt,
|
||||
)
|
||||
|
||||
# --- THINK : score trop bas ou pas de candidat → VLM cherche seul ---
|
||||
if self.enable_think:
|
||||
score_info = f"score={candidate.score:.3f}" if candidate else "aucun candidat"
|
||||
print(f"🤔 [Pipeline] {score_info} — THINK recherche complète")
|
||||
think_result = self._arbiter.arbitrate(
|
||||
target, candidates=[], screenshot_pil=screenshot_pil,
|
||||
)
|
||||
dt = (time.time() - t0) * 1000
|
||||
|
||||
if think_result:
|
||||
return GroundingResult(
|
||||
x=think_result.x, y=think_result.y,
|
||||
method="think_vlm",
|
||||
confidence=think_result.confidence,
|
||||
time_ms=dt,
|
||||
)
|
||||
|
||||
# --- Fallback : ancien pipeline ---
|
||||
return self._try_fallback(target)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fallback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _try_fallback(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""Tente l'ancien pipeline en dernier recours."""
|
||||
if self._fallback_pipeline is None:
|
||||
print(f"❌ [Pipeline] Aucune méthode n'a trouvé '{target.text}'")
|
||||
return None
|
||||
|
||||
print(f"⚠️ [Pipeline] Fallback ancien pipeline pour '{target.text}'")
|
||||
try:
|
||||
return self._fallback_pipeline.locate(target)
|
||||
except Exception as ex:
|
||||
print(f"⚠️ [Pipeline] Fallback échoué: {ex}")
|
||||
return None
|
||||
81
core/grounding/fast_types.py
Normal file
81
core/grounding/fast_types.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
core/grounding/fast_types.py — Structures de données pour le pipeline FAST→SMART→THINK
|
||||
|
||||
Utilisées exclusivement par le pipeline de localisation rapide.
|
||||
Compatibles avec GroundingTarget/GroundingResult existants via conversion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedUIElement:
|
||||
"""Élément UI détecté par le layer FAST (RF-DETR) puis enrichi par OCR."""
|
||||
id: int
|
||||
bbox: Tuple[int, int, int, int] # (x1, y1, x2, y2) pixels absolus
|
||||
center: Tuple[int, int] # (cx, cy)
|
||||
confidence: float # confidence détecteur (0-1)
|
||||
element_type: str = "element" # "button", "input", "icon", "text", "element"
|
||||
ocr_text: str = "" # texte OCR extrait de la région
|
||||
neighbors: List[str] = field(default_factory=list) # textes des éléments proches
|
||||
relative_position: str = "" # "top_left", "center", "bottom_right", etc.
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.bbox[2] - self.bbox[0]
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.bbox[3] - self.bbox[1]
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
return self.width * self.height
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenSnapshot:
|
||||
"""État complet de l'écran à un instant t — sortie du layer FAST."""
|
||||
elements: List[DetectedUIElement]
|
||||
ocr_words: List[Dict[str, Any]] # mots OCR bruts [{text, bbox}]
|
||||
resolution: Tuple[int, int] # (width, height)
|
||||
window_title: str = ""
|
||||
phash: str = ""
|
||||
detection_time_ms: float = 0.0
|
||||
ocr_time_ms: float = 0.0
|
||||
total_time_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchCandidate:
|
||||
"""Résultat du matching SMART pour un élément candidat."""
|
||||
element: DetectedUIElement
|
||||
score: float # score combiné (0-1)
|
||||
score_detail: Dict[str, float] = field(default_factory=dict)
|
||||
method: str = "" # "exact_text", "fuzzy_text", "position", etc.
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocateResult:
|
||||
"""Résultat final du pipeline FAST→SMART→THINK."""
|
||||
x: int
|
||||
y: int
|
||||
confidence: float
|
||||
method: str # "fast_exact", "fast_fuzzy", "smart_vote", "think_vlm"
|
||||
time_ms: float
|
||||
tier: str = "fast" # "fast", "smart", "think"
|
||||
element: Optional[DetectedUIElement] = None
|
||||
candidates_count: int = 0
|
||||
|
||||
def to_grounding_result(self):
|
||||
"""Conversion vers GroundingResult pour compatibilité."""
|
||||
from core.grounding.target import GroundingResult
|
||||
return GroundingResult(
|
||||
x=self.x, y=self.y,
|
||||
method=self.method,
|
||||
confidence=self.confidence,
|
||||
time_ms=self.time_ms,
|
||||
)
|
||||
290
core/grounding/infigui_server.py
Normal file
290
core/grounding/infigui_server.py
Normal file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
core/grounding/infigui_server.py — Service grounding persistant InfiGUI
|
||||
|
||||
Charge InfiGUI-G1-3B en 4-bit une fois (~2.4 GB VRAM), puis sert les requêtes
|
||||
de grounding via un Unix socket. Évite le coût de chargement (~10s) à chaque
|
||||
appel que paie le subprocess one-shot.
|
||||
|
||||
Protocole (length-prefixed JSON) :
|
||||
Requête : [4 octets uint32 BE = longueur] + payload JSON UTF-8
|
||||
Réponse : [4 octets uint32 BE = longueur] + payload JSON UTF-8
|
||||
|
||||
Opérations supportées (champ "op", défaut "ground") :
|
||||
- "ping" → {"ok": true, "vram_gb": float, "uptime_s": float}
|
||||
- "ground" → {"x": int|None, "y": int|None, "confidence": float, ...}
|
||||
- "shutdown" → {"ok": true} puis arrêt propre du serveur
|
||||
|
||||
Le payload "ground" reprend exactement le format de l'ancien worker one-shot :
|
||||
{"target": str, "description": str, "image_path": str, "anchor_image_path": str}
|
||||
|
||||
Les images restent passées via fichiers (/tmp/...) — pas de bytes sur le socket.
|
||||
|
||||
Lancement (manuel ou via systemd user unit rpa-grounding.service) :
|
||||
cd ~/ai/rpa_vision_v3
|
||||
.venv/bin/python -m core.grounding.infigui_server
|
||||
|
||||
Variables d'environnement :
|
||||
RPA_GROUNDING_SOCKET chemin du socket (défaut: $XDG_RUNTIME_DIR/rpa-grounding.sock
|
||||
sinon /tmp/rpa-grounding.sock)
|
||||
RPA_GROUNDING_BACKLOG taille listen backlog (défaut: 4)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Réutilise la logique de chargement et d'inférence du worker one-shot.
|
||||
# load_model() et infer() sont conçus pour être appelés en process indépendant ;
|
||||
# on les appelle ici dans un process unique de longue durée.
|
||||
from core.grounding.infigui_worker import infer, load_model
|
||||
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _default_socket_path() -> str:
|
||||
# /run/rpa/ est la convention "production" (RuntimeDirectory=rpa partagé
|
||||
# entre les services systemd). Cohérent avec ui_tars_grounder._default_socket_path.
|
||||
if os.path.isdir("/run/rpa"):
|
||||
return "/run/rpa/grounding.sock"
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if runtime_dir and os.path.isdir(runtime_dir):
|
||||
return os.path.join(runtime_dir, "rpa-grounding.sock")
|
||||
return "/tmp/rpa-grounding.sock"
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("RPA_GROUNDING_SOCKET") or _default_socket_path()
|
||||
LISTEN_BACKLOG = int(os.environ.get("RPA_GROUNDING_BACKLOG", "4"))
|
||||
|
||||
# Limite raisonnable pour un payload JSON (la requête contient juste des chemins
|
||||
# et du texte court — 4 MB suffit largement pour parer un client buggé).
|
||||
MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
# ── Protocole length-prefixed ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _recv_exact(conn: socket.socket, n: int) -> Optional[bytes]:
|
||||
"""Lit exactement n octets ou retourne None si la connexion ferme avant."""
|
||||
chunks = []
|
||||
remaining = n
|
||||
while remaining > 0:
|
||||
chunk = conn.recv(remaining)
|
||||
if not chunk:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def recv_message(conn: socket.socket) -> Optional[Dict[str, Any]]:
|
||||
header = _recv_exact(conn, 4)
|
||||
if header is None:
|
||||
return None
|
||||
(length,) = struct.unpack(">I", header)
|
||||
if length == 0 or length > MAX_PAYLOAD_BYTES:
|
||||
raise ValueError(f"Longueur payload invalide: {length}")
|
||||
payload = _recv_exact(conn, length)
|
||||
if payload is None:
|
||||
return None
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
|
||||
|
||||
def send_message(conn: socket.socket, obj: Dict[str, Any]) -> None:
|
||||
payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
conn.sendall(struct.pack(">I", len(payload)) + payload)
|
||||
|
||||
|
||||
# ── Serveur ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InfiGUIServer:
|
||||
"""Daemon qui sert les requêtes de grounding sur un Unix socket."""
|
||||
|
||||
def __init__(self, socket_path: str = SOCKET_PATH):
|
||||
self.socket_path = socket_path
|
||||
self._server_sock: Optional[socket.socket] = None
|
||||
self._stop = threading.Event()
|
||||
# CUDA n'est pas thread-safe sur le même modèle ; sérialise les inférences.
|
||||
self._infer_lock = threading.Lock()
|
||||
self._model = None
|
||||
self._processor = None
|
||||
self._start_time = time.time()
|
||||
self._request_count = 0
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
# 1. Charger le modèle AVANT d'ouvrir le socket : si le chargement échoue,
|
||||
# on n'expose pas un endpoint à moitié fonctionnel aux clients.
|
||||
print(f"[infigui-server] Chargement InfiGUI-G1-3B...")
|
||||
self._model, self._processor = load_model()
|
||||
|
||||
# 2. Ouvrir le Unix socket (suppression d'un éventuel ancien socket fantôme)
|
||||
try:
|
||||
os.unlink(self.socket_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._server_sock.bind(self.socket_path)
|
||||
# Permissions : owner-only (rw-------) pour éviter les autres utilisateurs
|
||||
os.chmod(self.socket_path, 0o600)
|
||||
self._server_sock.listen(LISTEN_BACKLOG)
|
||||
# Petit timeout sur accept pour que la boucle réagisse aux signaux
|
||||
self._server_sock.settimeout(1.0)
|
||||
|
||||
print(f"[infigui-server] Écoute sur {self.socket_path}")
|
||||
|
||||
# 3. Signaux d'arrêt propre
|
||||
signal.signal(signal.SIGTERM, self._on_signal)
|
||||
signal.signal(signal.SIGINT, self._on_signal)
|
||||
|
||||
# 4. Boucle accept
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
conn, _ = self._server_sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
raise
|
||||
# Une requête à la fois (CUDA non thread-safe). On gère néanmoins
|
||||
# la connexion dans un thread pour pouvoir lire/écrire sans bloquer
|
||||
# l'accept boucle quand le client traîne — l'inférence elle-même
|
||||
# est sérialisée par self._infer_lock.
|
||||
threading.Thread(
|
||||
target=self._handle_client,
|
||||
args=(conn,),
|
||||
daemon=True,
|
||||
).start()
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def _on_signal(self, signum, _frame) -> None:
|
||||
print(f"[infigui-server] Signal {signum} reçu, arrêt...")
|
||||
self._stop.set()
|
||||
# Casse un éventuel accept() bloqué
|
||||
try:
|
||||
if self._server_sock is not None:
|
||||
self._server_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
try:
|
||||
if self._server_sock is not None:
|
||||
self._server_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.socket_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print(f"[infigui-server] Arrêté ({self._request_count} requêtes traitées)")
|
||||
|
||||
# ── Gestion d'une connexion ──────────────────────────────────────────
|
||||
|
||||
def _handle_client(self, conn: socket.socket) -> None:
|
||||
try:
|
||||
# Une connexion = N requêtes (keep-alive). Le client peut envoyer
|
||||
# plusieurs grounds successifs sans repayer le coût TCP/socket.
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
req = recv_message(conn)
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
self._safe_send(conn, {"error": f"requête invalide: {e}"})
|
||||
return
|
||||
if req is None:
|
||||
return # client a fermé proprement
|
||||
|
||||
op = req.get("op", "ground")
|
||||
if op == "ping":
|
||||
self._safe_send(conn, self._do_ping())
|
||||
elif op == "shutdown":
|
||||
self._safe_send(conn, {"ok": True})
|
||||
self._stop.set()
|
||||
try:
|
||||
if self._server_sock is not None:
|
||||
self._server_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
elif op == "ground":
|
||||
resp = self._do_ground(req)
|
||||
self._safe_send(conn, resp)
|
||||
else:
|
||||
self._safe_send(conn, {"error": f"op inconnue: {op}"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._safe_send(conn, {"error": str(e)})
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _safe_send(self, conn: socket.socket, obj: Dict[str, Any]) -> None:
|
||||
try:
|
||||
send_message(conn, obj)
|
||||
except OSError:
|
||||
# Client parti ; pas de quoi paniquer
|
||||
pass
|
||||
|
||||
# ── Opérations ───────────────────────────────────────────────────────
|
||||
|
||||
def _do_ping(self) -> Dict[str, Any]:
|
||||
try:
|
||||
import torch
|
||||
|
||||
vram_gb = round(torch.cuda.memory_allocated() / 1e9, 2) if torch.cuda.is_available() else 0.0
|
||||
except Exception:
|
||||
vram_gb = 0.0
|
||||
return {
|
||||
"ok": True,
|
||||
"vram_gb": vram_gb,
|
||||
"uptime_s": round(time.time() - self._start_time, 1),
|
||||
"requests": self._request_count,
|
||||
}
|
||||
|
||||
def _do_ground(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with self._infer_lock:
|
||||
self._request_count += 1
|
||||
try:
|
||||
return infer(self._model, self._processor, req)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"x": None,
|
||||
"y": None,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
server = InfiGUIServer()
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[infigui-server] Erreur fatale: {e}")
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
210
core/grounding/infigui_worker.py
Normal file
210
core/grounding/infigui_worker.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Worker InfiGUI — process indépendant, communication par fichiers.
|
||||
|
||||
Charge le modèle, surveille /tmp/infigui_request.json, infère, écrit /tmp/infigui_response.json.
|
||||
|
||||
Lancement :
|
||||
cd ~/ai/rpa_vision_v3
|
||||
.venv/bin/python3 -m core.grounding.infigui_worker
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import gc
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import torch
|
||||
|
||||
REQUEST_FILE = "/tmp/infigui_request.json"
|
||||
RESPONSE_FILE = "/tmp/infigui_response.json"
|
||||
READY_FILE = "/tmp/infigui_ready"
|
||||
|
||||
|
||||
def load_model():
|
||||
"""Charge InfiGUI-G1-3B en 4-bit NF4."""
|
||||
torch.cuda.empty_cache()
|
||||
gc.collect()
|
||||
|
||||
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor, BitsAndBytesConfig
|
||||
|
||||
model_id = "InfiX-ai/InfiGUI-G1-3B"
|
||||
print(f"[infigui-worker] Chargement {model_id}...")
|
||||
|
||||
bnb = BitsAndBytesConfig(
|
||||
load_in_4bit=True, bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
||||
model_id, quantization_config=bnb, device_map={"": "cuda:0"},
|
||||
)
|
||||
model.eval()
|
||||
processor = AutoProcessor.from_pretrained(
|
||||
model_id, padding_side="left",
|
||||
min_pixels=100 * 28 * 28, max_pixels=5600 * 28 * 28,
|
||||
)
|
||||
|
||||
vram = torch.cuda.memory_allocated() / 1e9
|
||||
print(f"[infigui-worker] Prêt — VRAM: {vram:.2f}GB")
|
||||
|
||||
# Signal "prêt"
|
||||
with open(READY_FILE, "w") as f:
|
||||
f.write(f"ready {vram:.2f}GB")
|
||||
|
||||
return model, processor
|
||||
|
||||
|
||||
def infer(model, processor, req):
|
||||
"""Fait une inférence.
|
||||
|
||||
Modes :
|
||||
- texte seul (target/description) : grounding classique
|
||||
- fusionné (anchor_image_path présent) : on passe en plus le crop d'ancre
|
||||
comme image de référence et le modèle doit retrouver cet élément sur
|
||||
le screenshot. Évite la double passe describe→ground.
|
||||
"""
|
||||
from PIL import Image
|
||||
from qwen_vl_utils import process_vision_info
|
||||
|
||||
target = req.get("target", "")
|
||||
description = req.get("description", "")
|
||||
label = f"{target} — {description}" if description else target
|
||||
|
||||
# Image principale (screenshot complet)
|
||||
image_path = req.get("image_path", "")
|
||||
if image_path and os.path.exists(image_path):
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
else:
|
||||
import mss
|
||||
with mss.mss() as sct:
|
||||
grab = sct.grab(sct.monitors[0])
|
||||
img = Image.frombytes("RGB", grab.size, grab.bgra, "raw", "BGRX")
|
||||
|
||||
# Image d'ancre (optionnelle) — mode fusionné describe+ground
|
||||
anchor_image_path = req.get("anchor_image_path", "")
|
||||
anchor_img = None
|
||||
if anchor_image_path and os.path.exists(anchor_image_path):
|
||||
anchor_img = Image.open(anchor_image_path).convert("RGB")
|
||||
|
||||
if not label.strip() and anchor_img is None:
|
||||
return {"x": None, "y": None, "error": "target ou anchor_image requis"}
|
||||
|
||||
W, H = img.size
|
||||
factor = 28
|
||||
rH = max(factor, round(H / factor) * factor)
|
||||
rW = max(factor, round(W / factor) * factor)
|
||||
|
||||
system = (
|
||||
"You FIRST think about the reasoning process as an internal monologue "
|
||||
"and then provide the final answer.\n"
|
||||
"The reasoning process MUST BE enclosed within <think> </think> tags."
|
||||
)
|
||||
|
||||
# Construction du prompt selon le mode
|
||||
if anchor_img is not None:
|
||||
# Mode fusionné : Image1 = crop d'ancre, Image2 = screenshot
|
||||
hint = f' Hint: this element looks like "{label}".' if label.strip() else ""
|
||||
user_text = (
|
||||
f"The first image is a small crop of a UI element captured previously. "
|
||||
f"The second image is the current screen ({rW}x{rH}).{hint}\n"
|
||||
f"Locate on the second image the UI element that visually matches the first image. "
|
||||
f"Output the coordinates using JSON format: "
|
||||
f'[{{"point_2d": [x, y]}}, ...]'
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": [
|
||||
{"type": "image", "image": anchor_img},
|
||||
{"type": "image", "image": img},
|
||||
{"type": "text", "text": user_text},
|
||||
]},
|
||||
]
|
||||
else:
|
||||
# Mode classique : texte seul
|
||||
user_text = (
|
||||
f'The screen\'s resolution is {rW}x{rH}.\n'
|
||||
f'Locate the UI element(s) for "{label}", '
|
||||
f'output the coordinates using JSON format: '
|
||||
f'[{{"point_2d": [x, y]}}, ...]'
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": [
|
||||
{"type": "image", "image": img},
|
||||
{"type": "text", "text": user_text},
|
||||
]},
|
||||
]
|
||||
|
||||
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
image_inputs, video_inputs = process_vision_info(messages)
|
||||
inputs = processor(
|
||||
text=[text], images=image_inputs, videos=video_inputs,
|
||||
padding=True, return_tensors="pt",
|
||||
).to(model.device)
|
||||
|
||||
t0 = time.time()
|
||||
with torch.no_grad():
|
||||
gen = model.generate(**inputs, max_new_tokens=512)
|
||||
infer_ms = (time.time() - t0) * 1000
|
||||
|
||||
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, gen)]
|
||||
raw = processor.batch_decode(
|
||||
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False,
|
||||
)[0].strip()
|
||||
|
||||
mode_str = "fused" if anchor_img is not None else "text"
|
||||
print(f"[infigui-worker] [{mode_str}] '{label[:40]}' ({infer_ms:.0f}ms)")
|
||||
|
||||
# Parser JSON point_2d
|
||||
json_part = raw.split("</think>")[-1] if "</think>" in raw else raw
|
||||
json_part = json_part.replace("```json", "").replace("```", "").strip()
|
||||
|
||||
px, py = None, None
|
||||
try:
|
||||
parsed = json.loads(json_part)
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
pt = parsed[0].get("point_2d", [])
|
||||
if len(pt) >= 2:
|
||||
px = int(pt[0] * W / rW)
|
||||
py = int(pt[1] * H / rH)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r'"point_2d"\s*:\s*\[(\d+),\s*(\d+)\]', raw)
|
||||
if m:
|
||||
px = int(int(m.group(1)) * W / rW)
|
||||
py = int(int(m.group(2)) * H / rH)
|
||||
|
||||
return {
|
||||
"x": px, "y": py,
|
||||
"method": "infigui",
|
||||
"confidence": 0.90 if px else 0.0,
|
||||
"time_ms": round(infer_ms, 1),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Mode one-shot : lit une requête sur stdin, infère, écrit le résultat sur stdout."""
|
||||
# Lire la requête
|
||||
input_data = sys.stdin.read().strip()
|
||||
if not input_data:
|
||||
print(json.dumps({"x": None, "y": None, "error": "pas de requête"}))
|
||||
return
|
||||
|
||||
try:
|
||||
req = json.loads(input_data)
|
||||
except json.JSONDecodeError:
|
||||
print(json.dumps({"x": None, "y": None, "error": "JSON invalide"}))
|
||||
return
|
||||
|
||||
model, processor = load_model()
|
||||
result = infer(model, processor, req)
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
190
core/grounding/pipeline.py
Normal file
190
core/grounding/pipeline.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
core/grounding/pipeline.py — Pipeline de grounding en cascade
|
||||
|
||||
Orchestre les methodes de localisation dans l'ordre :
|
||||
1. Template matching (TemplateMatcher, local, ~80ms)
|
||||
2. OCR (docTR via input_handler, local, ~1s)
|
||||
3. UI-TARS (HTTP vers serveur grounding, ~3s)
|
||||
4. Static fallback (coordonnees d'origine du workflow)
|
||||
|
||||
Chaque methode est essayee dans l'ordre. Des qu'une reussit, on retourne
|
||||
le resultat. Cela permet un equilibre entre vitesse (template) et robustesse
|
||||
(UI-TARS pour les elements qui ont change de position/apparence).
|
||||
|
||||
Utilisation :
|
||||
from core.grounding.pipeline import GroundingPipeline
|
||||
from core.grounding.target import GroundingTarget
|
||||
|
||||
pipeline = GroundingPipeline()
|
||||
result = pipeline.locate(GroundingTarget(
|
||||
text="Valider",
|
||||
description="bouton vert en bas",
|
||||
template_b64=screenshot_b64,
|
||||
original_bbox={"x": 100, "y": 200, "width": 80, "height": 30},
|
||||
))
|
||||
if result:
|
||||
print(f"Trouve a ({result.x}, {result.y}) via {result.method}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from core.grounding.target import GroundingTarget, GroundingResult
|
||||
|
||||
|
||||
class GroundingPipeline:
|
||||
"""Pipeline de localisation en cascade : template -> OCR -> UI-TARS -> static."""
|
||||
|
||||
def __init__(self, template_threshold: float = 0.75, enable_uitars: bool = True):
|
||||
self.template_threshold = template_threshold
|
||||
self.enable_uitars = enable_uitars
|
||||
|
||||
def locate(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""Localise un element UI en essayant les methodes en cascade.
|
||||
|
||||
Args:
|
||||
target: description de l'element a localiser
|
||||
|
||||
Returns:
|
||||
GroundingResult ou None si aucune methode ne trouve l'element
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
# --- Methode 1 : Template matching (~80ms) ---
|
||||
result = self._try_template(target)
|
||||
if result:
|
||||
print(f"[GroundingPipeline] Localise via {result.method} en "
|
||||
f"{(time.time() - t0) * 1000:.0f}ms")
|
||||
return result
|
||||
|
||||
# --- Methode 2 : OCR texte (~1s) ---
|
||||
result = self._try_ocr(target)
|
||||
if result:
|
||||
print(f"[GroundingPipeline] Localise via {result.method} en "
|
||||
f"{(time.time() - t0) * 1000:.0f}ms")
|
||||
return result
|
||||
|
||||
# --- Methode 3 : UI-TARS via serveur HTTP (~3s) ---
|
||||
if self.enable_uitars:
|
||||
result = self._try_uitars(target)
|
||||
if result:
|
||||
print(f"[GroundingPipeline] Localise via {result.method} en "
|
||||
f"{(time.time() - t0) * 1000:.0f}ms")
|
||||
return result
|
||||
|
||||
# --- Methode 4 : Fallback statique ---
|
||||
result = self._try_static(target)
|
||||
if result:
|
||||
print(f"[GroundingPipeline] Localise via {result.method} en "
|
||||
f"{(time.time() - t0) * 1000:.0f}ms")
|
||||
return result
|
||||
|
||||
print(f"[GroundingPipeline] ECHEC: '{target.text}' introuvable "
|
||||
f"(toutes methodes epuisees, {(time.time() - t0) * 1000:.0f}ms)")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Methodes individuelles
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _try_template(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""Template matching — rapide, exact, mais sensible aux changements visuels."""
|
||||
if not target.template_b64:
|
||||
return None
|
||||
|
||||
try:
|
||||
from core.grounding.template_matcher import TemplateMatcher
|
||||
matcher = TemplateMatcher(threshold=self.template_threshold)
|
||||
match = matcher.match_screen(anchor_b64=target.template_b64)
|
||||
if match:
|
||||
print(f"[GroundingPipeline/template] score={match.score:.3f} "
|
||||
f"pos=({match.x},{match.y}) ({match.time_ms:.0f}ms)")
|
||||
return GroundingResult(
|
||||
x=match.x,
|
||||
y=match.y,
|
||||
method='template',
|
||||
confidence=match.score,
|
||||
time_ms=match.time_ms,
|
||||
)
|
||||
else:
|
||||
diag = matcher.match_screen_diagnostic(anchor_b64=target.template_b64)
|
||||
print(f"[GroundingPipeline/template] pas de match — best={diag}")
|
||||
except Exception as e:
|
||||
print(f"[GroundingPipeline/template] ERREUR: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _try_ocr(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""OCR : cherche le texte cible sur l'ecran via docTR."""
|
||||
if not target.text:
|
||||
return None
|
||||
|
||||
try:
|
||||
from core.execution.input_handler import _grounding_ocr
|
||||
bbox = target.original_bbox if target.original_bbox else None
|
||||
result = _grounding_ocr(target.text, anchor_bbox=bbox)
|
||||
if result:
|
||||
print(f"[GroundingPipeline/OCR] '{target.text}' -> ({result['x']}, {result['y']})")
|
||||
return GroundingResult(
|
||||
x=result['x'],
|
||||
y=result['y'],
|
||||
method='ocr',
|
||||
confidence=result.get('confidence', 0.80),
|
||||
time_ms=result.get('time_ms', 0),
|
||||
)
|
||||
else:
|
||||
print(f"[GroundingPipeline/OCR] '{target.text}' non trouve")
|
||||
except Exception as e:
|
||||
print(f"[GroundingPipeline/OCR] ERREUR: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _try_uitars(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""UI-TARS via serveur HTTP — robust, gere les changements de layout."""
|
||||
if not target.text and not target.description:
|
||||
return None
|
||||
|
||||
try:
|
||||
from core.grounding.ui_tars_grounder import UITarsGrounder
|
||||
grounder = UITarsGrounder.get_instance()
|
||||
result = grounder.ground(
|
||||
target_text=target.text,
|
||||
target_description=target.description,
|
||||
)
|
||||
if result:
|
||||
print(f"[GroundingPipeline/UI-TARS] ({result.x}, {result.y}) "
|
||||
f"conf={result.confidence:.2f} ({result.time_ms:.0f}ms)")
|
||||
return result
|
||||
else:
|
||||
print(f"[GroundingPipeline/UI-TARS] pas de resultat")
|
||||
except Exception as e:
|
||||
print(f"[GroundingPipeline/UI-TARS] ERREUR: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _try_static(self, target: GroundingTarget) -> Optional[GroundingResult]:
|
||||
"""Fallback : coordonnees d'origine du workflow (centre du bounding box)."""
|
||||
bbox = target.original_bbox
|
||||
if not bbox:
|
||||
return None
|
||||
|
||||
w = bbox.get('width', 0)
|
||||
h = bbox.get('height', 0)
|
||||
if not w or not h:
|
||||
return None
|
||||
|
||||
x = int(bbox.get('x', 0) + w / 2)
|
||||
y = int(bbox.get('y', 0) + h / 2)
|
||||
|
||||
print(f"[GroundingPipeline/static] fallback ({x}, {y}) "
|
||||
f"depuis bbox {bbox}")
|
||||
|
||||
return GroundingResult(
|
||||
x=x,
|
||||
y=y,
|
||||
method='static_fallback',
|
||||
confidence=0.30,
|
||||
time_ms=0.0,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user