Compare commits
238 Commits
v1.0-stabl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
203dc00d53 | ||
|
|
e9a028134a | ||
|
|
01bba7bc6c | ||
|
|
d5285de99c | ||
|
|
33c198b827 | ||
|
|
816b37af98 | ||
|
|
d82aad984f | ||
|
|
057c37131f | ||
|
|
9bcce3fc68 | ||
|
|
f96f6322ec | ||
|
|
02ee2d7b5b | ||
|
|
47993e2ee9 | ||
|
|
7cc03f6f10 | ||
|
|
a21f1ea9fa | ||
|
|
9188bd7df1 | ||
|
|
f82753debe | ||
|
|
b92cb9db03 | ||
|
|
e66629ce1a | ||
|
|
cecdf417b7 | ||
|
|
56e3cc052a | ||
|
|
332366b58c | ||
|
|
ac9c207474 | ||
|
|
f85d56ac05 | ||
|
|
172167f6c0 | ||
|
|
42d49dd8bd | ||
|
|
f541bb8ce4 | ||
|
|
a6eb4c168f | ||
|
|
f6ad5ff2b2 | ||
|
|
2ac781343a | ||
|
|
bffcfb2db3 | ||
|
|
cc673755f7 | ||
|
|
4509038bf0 | ||
|
|
99041f0117 | ||
|
|
72a9651b94 | ||
|
|
8589e87a13 | ||
|
|
8a1dfc6e8b | ||
|
|
3bcf59e16f | ||
|
|
46206d9396 | ||
|
|
d3e928bebe | ||
|
|
a679fbb62b | ||
|
|
f0b311306d | ||
|
|
1c5ff42006 | ||
|
|
b09a3df054 | ||
|
|
fceb76de1f | ||
|
|
6d4ff4f215 | ||
|
|
2486e43def | ||
|
|
20b74286f7 | ||
|
|
a1c97504ab | ||
|
|
d6c7346898 | ||
|
|
90ee8ca8f4 | ||
|
|
84a91630e9 | ||
|
|
91614fbff0 | ||
|
|
c1ce6a3964 | ||
|
|
0bd0fbb8c5 | ||
|
|
394342be7e | ||
|
|
6724f43950 | ||
|
|
d99b17394a | ||
|
|
875367dea9 | ||
|
|
a74056ca22 | ||
|
|
6937b94f2a | ||
|
|
4f5c518d3a | ||
|
|
7dec3ab63a | ||
|
|
68d5bb7dd1 | ||
|
|
ef5d595d98 | ||
|
|
5ceee9c393 | ||
|
|
5e0b53cfd1 | ||
|
|
e8a8a588c1 | ||
|
|
18792fd7b4 | ||
|
|
1e8e2dd9f3 | ||
|
|
1253a40051 | ||
|
|
a92d04621a | ||
|
|
13390a71e7 | ||
|
|
4c76dca992 | ||
|
|
2ddccff108 | ||
|
|
3417f09598 | ||
|
|
bbe506c63a | ||
|
|
647aa610fd | ||
|
|
c2dc8f8fe4 | ||
|
|
d5deac3029 | ||
|
|
fe5e0ba83d | ||
|
|
24a947b51d | ||
|
|
90ee91caf9 | ||
|
|
ad7ff3bce4 | ||
|
|
5973058f08 | ||
|
|
aa39af327f | ||
|
|
757432ee19 | ||
|
|
792cc2aa9a | ||
|
|
f340eab628 | ||
|
|
353c2a347e | ||
|
|
40e5fba86c | ||
|
|
97d708c6f5 | ||
|
|
58e8bbafff | ||
|
|
81d2d016ff | ||
|
|
d4871249ea | ||
|
|
ae65be2555 | ||
|
|
af83552923 | ||
|
|
5a07e0dee5 | ||
|
|
5d7ef46c93 | ||
|
|
8d6b49277f | ||
|
|
32c6808afb | ||
|
|
4e217e30dd | ||
|
|
8175b39eba | ||
|
|
371db69543 | ||
|
|
dd149c1cbb | ||
|
|
3bd23d6135 | ||
|
|
1e18194e31 | ||
|
|
fb648e730f | ||
|
|
edd1c2efdb | ||
|
|
928b9e1065 | ||
|
|
97cb2957d5 | ||
|
|
9da804bb6e | ||
|
|
5e3865d328 | ||
|
|
ad15237fe0 | ||
|
|
cf495dd82f | ||
|
|
74a1cb4e03 | ||
|
|
463f1dd95e | ||
|
|
8f31ba95d3 | ||
|
|
7df01f2642 | ||
|
|
599dd02399 | ||
|
|
766c57e126 | ||
|
|
79c19c5e9d | ||
|
|
148321dffd | ||
|
|
de779af5a1 | ||
|
|
c2feca29c4 | ||
|
|
773ee78949 | ||
|
|
786e640de9 | ||
|
|
2cb53901a1 | ||
|
|
75260e3254 | ||
|
|
4c9a6d293f | ||
|
|
3ff36e3c79 |
@@ -30,7 +30,9 @@ DASHBOARD_PORT=5001
|
||||
CLIP_MODEL=ViT-B-32
|
||||
CLIP_PRETRAINED=openai
|
||||
CLIP_DEVICE=cpu # cpu or cuda
|
||||
VLM_MODEL=qwen3-vl:8b
|
||||
RPA_VLM_MODEL=gemma4:latest # gemma4:latest (défaut), qwen3-vl:8b, ui-tars (fallback)
|
||||
VLM_MODEL=gemma4:latest # alias de compatibilité
|
||||
# VLM_ALLOW_CLOUD=false # true pour activer les APIs cloud en fallback (OpenAI, Gemini, Anthropic)
|
||||
VLM_ENDPOINT=http://localhost:11434
|
||||
OWL_MODEL=google/owlv2-base-patch16-ensemble
|
||||
OWL_CONFIDENCE_THRESHOLD=0.1
|
||||
|
||||
207
.gitea/workflows/security-audit.yml
Normal file
207
.gitea/workflows/security-audit.yml
Normal file
@@ -0,0 +1,207 @@
|
||||
# ------------------------------------------------------------------
|
||||
# Audit sécurité — bandit + pip-audit + scan secrets
|
||||
# ------------------------------------------------------------------
|
||||
# Jamais bloquant : on reporte les warnings, on ne casse pas la CI.
|
||||
# Utile pour détecter les dérives progressives (nouveaux CVE, secrets
|
||||
# oubliés dans un commit, patterns risqués).
|
||||
#
|
||||
# Fréquence : à chaque push sur main + hebdo (cron).
|
||||
# ------------------------------------------------------------------
|
||||
name: security-audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
# Tous les lundis à 6h UTC (8h Paris hiver, 7h Paris été).
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------------
|
||||
# Job 1 — bandit (bonnes pratiques sécu Python)
|
||||
# ----------------------------------------------------------------
|
||||
bandit:
|
||||
name: Bandit (scan statique)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Installation bandit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "bandit[toml]==1.7.10"
|
||||
|
||||
- name: Scan bandit sur core/
|
||||
run: |
|
||||
# -ll : niveau LOW minimum (remonte tout)
|
||||
# -ii : confiance LOW minimum
|
||||
# --skip B101 : on ignore les asserts (usuels en tests/validation)
|
||||
bandit -r core/ \
|
||||
--skip B101,B404,B603 \
|
||||
--format txt \
|
||||
--exit-zero \
|
||||
--output bandit-report.txt
|
||||
echo "=== RAPPORT BANDIT ==="
|
||||
cat bandit-report.txt
|
||||
|
||||
- name: Upload rapport bandit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bandit-report
|
||||
path: bandit-report.txt
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 2 — pip-audit (CVE sur requirements)
|
||||
# ----------------------------------------------------------------
|
||||
pip-audit:
|
||||
name: pip-audit (CVE dépendances)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Installation pip-audit
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "pip-audit==2.7.3"
|
||||
|
||||
- name: Audit CVE sur requirements-ci.txt
|
||||
run: |
|
||||
if [ -f requirements-ci.txt ]; then
|
||||
pip-audit -r requirements-ci.txt \
|
||||
--format json \
|
||||
--output pip-audit-ci.json \
|
||||
--progress-spinner off \
|
||||
--disable-pip || echo "::warning::CVE détectées dans requirements-ci.txt"
|
||||
echo "=== RAPPORT pip-audit (CI) ==="
|
||||
cat pip-audit-ci.json || true
|
||||
else
|
||||
echo "::notice::requirements-ci.txt absent — skip"
|
||||
fi
|
||||
|
||||
- name: Audit CVE sur requirements.txt (best-effort)
|
||||
run: |
|
||||
# Timeout généreux car requirements.txt est massif (torch, CUDA).
|
||||
timeout 120 pip-audit -r requirements.txt \
|
||||
--format json \
|
||||
--output pip-audit-full.json \
|
||||
--progress-spinner off \
|
||||
--disable-pip 2>&1 | head -200 || \
|
||||
echo "::warning::pip-audit sur requirements.txt a timeout ou échoué (non bloquant)"
|
||||
|
||||
- name: Upload rapports pip-audit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pip-audit-reports
|
||||
path: |
|
||||
pip-audit-ci.json
|
||||
pip-audit-full.json
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Job 3 — Scan secrets en clair (grep simple)
|
||||
# ----------------------------------------------------------------
|
||||
# Patterns recherchés : clés API Anthropic (sk-ant-), OpenAI (sk-),
|
||||
# Google (AIzaSy), AWS (AKIA), tokens Hugging Face (hf_).
|
||||
# Ne cherche QUE dans les fichiers trackés (pas .env, pas .venv).
|
||||
# ----------------------------------------------------------------
|
||||
secrets-scan:
|
||||
name: Scan secrets (grep)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout (historique complet)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan patterns de secrets
|
||||
run: |
|
||||
# Chemins exclus : venvs, caches, data, htmlcov, models.
|
||||
EXCLUDES='--exclude-dir=.venv --exclude-dir=venv_v3 --exclude-dir=.git \
|
||||
--exclude-dir=node_modules --exclude-dir=htmlcov --exclude-dir=models \
|
||||
--exclude-dir=data --exclude-dir=__pycache__ --exclude-dir=.pytest_cache \
|
||||
--exclude=*.lock --exclude=*.log --exclude=*.md'
|
||||
|
||||
echo "=== Recherche de secrets potentiels ==="
|
||||
FOUND=0
|
||||
|
||||
# Anthropic
|
||||
if grep -rnI $EXCLUDES -E 'sk-ant-[a-zA-Z0-9_-]{20,}' . 2>/dev/null; then
|
||||
echo "::warning::Clé Anthropic potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# OpenAI
|
||||
if grep -rnI $EXCLUDES -E 'sk-proj-[a-zA-Z0-9_-]{20,}|sk-[a-zA-Z0-9]{40,}' . 2>/dev/null; then
|
||||
echo "::warning::Clé OpenAI potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Google Cloud / API Keys
|
||||
if grep -rnI $EXCLUDES -E 'AIzaSy[a-zA-Z0-9_-]{33}' . 2>/dev/null; then
|
||||
echo "::warning::Clé Google API potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# AWS
|
||||
if grep -rnI $EXCLUDES -E 'AKIA[0-9A-Z]{16}' . 2>/dev/null; then
|
||||
echo "::warning::Clé AWS potentielle détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Hugging Face
|
||||
if grep -rnI $EXCLUDES -E 'hf_[a-zA-Z0-9]{30,}' . 2>/dev/null; then
|
||||
echo "::warning::Token Hugging Face potentiel détecté"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# Mots-clés suspects à côté d'assignations
|
||||
if grep -rnI $EXCLUDES -E '(password|passwd|secret|api_key|apikey|token)\s*=\s*["\x27][a-zA-Z0-9_\-!@#\$%]{12,}["\x27]' . 2>/dev/null \
|
||||
| grep -viE '(example|dummy|placeholder|test|fake|xxx|changeme|\$\{)' 2>/dev/null; then
|
||||
echo "::warning::Assignation suspecte d'un secret détectée"
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "Aucun secret détecté par les patterns de base."
|
||||
else
|
||||
echo ""
|
||||
echo "::notice::Vérifier manuellement les occurrences ci-dessus."
|
||||
echo "::notice::Si faux positif : ajouter le fichier aux exclusions ou reformater."
|
||||
fi
|
||||
|
||||
# Toujours succès (job non bloquant).
|
||||
exit 0
|
||||
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
|
||||
143
.gitignore
vendored
143
.gitignore
vendored
@@ -1,70 +1,113 @@
|
||||
# Python
|
||||
# === Python ===
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv*/
|
||||
env/
|
||||
.venv/
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
*.whl
|
||||
|
||||
# Data
|
||||
data/
|
||||
instance/
|
||||
# === Virtual environments ===
|
||||
.venv/
|
||||
venv/
|
||||
venv_*/
|
||||
env/
|
||||
|
||||
# === ML Models & Data ===
|
||||
*.pt
|
||||
*.pth
|
||||
*.onnx
|
||||
*.bin
|
||||
*.safetensors
|
||||
*.h5
|
||||
*.hdf5
|
||||
*.pkl
|
||||
*.pickle
|
||||
*.npy
|
||||
*.npz
|
||||
*.faiss
|
||||
*.db
|
||||
models/
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
# === Documents & Media ===
|
||||
*.pdf
|
||||
*.docx
|
||||
*.xlsx
|
||||
*.csv
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.mp3
|
||||
*.wav
|
||||
*.mp4
|
||||
|
||||
# === IDE ===
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Tests
|
||||
.pytest_cache/
|
||||
.hypothesis/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Temporary
|
||||
*.tmp
|
||||
*.bak
|
||||
*.zip
|
||||
.~lock.*
|
||||
*.pid
|
||||
|
||||
# OS
|
||||
# === OS ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.~lock.*
|
||||
|
||||
# Project specific
|
||||
.snapshots/
|
||||
# === Secrets ===
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
# === Logs & Cache ===
|
||||
*.log
|
||||
logs/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# === Backups ===
|
||||
*_backup_*
|
||||
backups/
|
||||
*.bak
|
||||
*.bak_*
|
||||
*.orig
|
||||
*.old
|
||||
|
||||
# === 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
|
||||
archives/
|
||||
backups*/
|
||||
frontend_broken*/
|
||||
.snapshots/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Models (large files)
|
||||
models/*.pt
|
||||
models/*.pth
|
||||
models/*.onnx
|
||||
*.safetensors
|
||||
# === 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
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
# Agent Upload Real Functionality Test - Complete Implementation
|
||||
|
||||
**Date**: January 6, 2026
|
||||
**Status**: ✅ COMPLETE
|
||||
|
||||
## 🎯 Objective
|
||||
|
||||
Transform the `test_agent_uploader_direct.py` test from a basic simulation to a comprehensive real functionality test that validates the complete agent upload flow without mocks or simulations.
|
||||
|
||||
## ✅ Improvements Implemented
|
||||
|
||||
### 1. **Realistic Session Data Creation**
|
||||
|
||||
**Before**: Used dummy binary PNG data and minimal session structure
|
||||
```python
|
||||
# Old approach - dummy data
|
||||
png_data = b'\x89PNG\r\n\x1a\n...' # Hard-coded binary
|
||||
```
|
||||
|
||||
**After**: Creates authentic session data using real system information
|
||||
```python
|
||||
# New approach - real data
|
||||
def create_realistic_session():
|
||||
# Real platform detection
|
||||
hostname = socket.gethostname()
|
||||
platform_name = platform.system().lower()
|
||||
|
||||
# Real screenshot creation with PIL
|
||||
img = Image.new('RGB', (800, 600), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
# Add realistic UI elements...
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Uses actual system information (hostname, platform, Python version)
|
||||
- ✅ Creates real PNG screenshots with simulated UI elements
|
||||
- ✅ Includes proper event timing and realistic user interactions
|
||||
- ✅ Tests with authentic file sizes and data structures
|
||||
|
||||
### 2. **Server Integration Validation**
|
||||
|
||||
**Before**: Only tested upload success/failure
|
||||
```python
|
||||
success = upload_session_zip(str(zip_path), session_id)
|
||||
```
|
||||
|
||||
**After**: Comprehensive server-side validation
|
||||
```python
|
||||
def validate_server_response(session_id: str, original_session_data: dict):
|
||||
# Check server status
|
||||
# Validate session was stored correctly
|
||||
# Verify data integrity
|
||||
# Confirm processing pipeline triggered
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Validates server receives and processes data correctly
|
||||
- ✅ Checks data integrity end-to-end
|
||||
- ✅ Verifies session appears in server's session list
|
||||
- ✅ Confirms event and screenshot counts match
|
||||
|
||||
### 3. **Real Component Integration**
|
||||
|
||||
**Before**: Limited to agent uploader only
|
||||
|
||||
**After**: Tests complete system integration
|
||||
```python
|
||||
def test_agent_uploader_integration():
|
||||
# 1. Check server availability
|
||||
# 2. Create realistic session
|
||||
# 3. Test agent uploader
|
||||
# 4. Validate server processing
|
||||
# 5. Check data model compatibility
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Tests real server API endpoints
|
||||
- ✅ Validates complete upload → processing → storage flow
|
||||
- ✅ Checks compatibility with core RPA Vision V3 models
|
||||
- ✅ Tests retry logic and error handling
|
||||
|
||||
### 4. **Data Model Compatibility Testing**
|
||||
|
||||
**New Feature**: Validates compatibility with core models
|
||||
```python
|
||||
def test_data_model_compatibility():
|
||||
# Import core RawSession model
|
||||
from core.models.raw_session import RawSession
|
||||
|
||||
# Validate test data can be loaded by real models
|
||||
raw_session = RawSession.from_dict(session_dict)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Ensures test data matches production data structures
|
||||
- ✅ Validates schema compatibility
|
||||
- ✅ Tests integration with core RPA Vision V3 components
|
||||
|
||||
### 5. **Comprehensive Error Handling**
|
||||
|
||||
**Before**: Basic try/catch with minimal feedback
|
||||
|
||||
**After**: Detailed error reporting and diagnostics
|
||||
```python
|
||||
def check_server_availability():
|
||||
# Test server connectivity
|
||||
# Provide helpful error messages
|
||||
# Suggest solutions for common issues
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Clear error messages with actionable solutions
|
||||
- ✅ Server availability checking before tests
|
||||
- ✅ Detailed validation feedback
|
||||
- ✅ Proper cleanup in all scenarios
|
||||
|
||||
## 📊 Test Coverage Improvements
|
||||
|
||||
### Before
|
||||
- ✅ Basic upload functionality
|
||||
- ❌ No server validation
|
||||
- ❌ Dummy test data
|
||||
- ❌ No integration testing
|
||||
- ❌ Limited error scenarios
|
||||
|
||||
### After
|
||||
- ✅ Complete upload flow testing
|
||||
- ✅ Server-side processing validation
|
||||
- ✅ Realistic session data creation
|
||||
- ✅ End-to-end integration testing
|
||||
- ✅ Data model compatibility
|
||||
- ✅ Retry logic testing
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Server availability checking
|
||||
- ✅ Data integrity validation
|
||||
|
||||
## 🔧 Real Components Tested
|
||||
|
||||
### Agent V0 Components
|
||||
- ✅ `uploader.py` - Real upload logic with retry
|
||||
- ✅ Session data structure creation
|
||||
- ✅ ZIP file creation and compression
|
||||
- ✅ Authentication handling (disabled mode)
|
||||
- ✅ Environment variable configuration
|
||||
|
||||
### Server Components
|
||||
- ✅ `api_upload.py` - Upload endpoint
|
||||
- ✅ Session storage and validation
|
||||
- ✅ Processing pipeline integration
|
||||
- ✅ Data integrity checks
|
||||
- ✅ Status and session listing endpoints
|
||||
|
||||
### Core Models
|
||||
- ✅ `RawSession` data model compatibility
|
||||
- ✅ Schema version validation
|
||||
- ✅ Event and screenshot structure
|
||||
- ✅ Metadata handling
|
||||
|
||||
## 🚀 Usage Instructions
|
||||
|
||||
### Prerequisites
|
||||
1. Start the server:
|
||||
```bash
|
||||
python server/api_upload.py
|
||||
```
|
||||
|
||||
2. Ensure environment is set up:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Running the Test
|
||||
```bash
|
||||
python test_agent_uploader_direct.py
|
||||
```
|
||||
|
||||
### Expected Output
|
||||
```
|
||||
🤖 Real Functionality Test: Agent V0 Uploader Integration
|
||||
============================================================
|
||||
Testing complete upload flow with real components:
|
||||
• Real agent uploader with retry logic
|
||||
• Real server API with processing pipeline
|
||||
• Real file system operations
|
||||
• Real session data structures
|
||||
• End-to-end data integrity validation
|
||||
============================================================
|
||||
|
||||
✅ Server is running: online
|
||||
|
||||
📝 Creating realistic test session...
|
||||
✅ Session created: sess_20260106T143022_realtest
|
||||
ZIP path: /tmp/tmp_xyz/sess_20260106T143022_realtest.zip
|
||||
ZIP size: 15,234 bytes
|
||||
Events: 4
|
||||
Screenshots: 3
|
||||
Auth disabled: true
|
||||
Server URL: http://127.0.0.1:8000/api/traces/upload
|
||||
|
||||
📤 Testing agent uploader...
|
||||
✅ Upload completed in 0.85 seconds
|
||||
|
||||
🔍 Validating server-side processing...
|
||||
✅ Session found in server: sess_20260106T143022_realtest
|
||||
✅ Events count matches: 4
|
||||
✅ Screenshots count matches: 3
|
||||
✅ User ID matches: real_test_user
|
||||
✅ Server-side validation passed!
|
||||
|
||||
🔍 Testing data model compatibility...
|
||||
✅ RawSession created successfully
|
||||
Session ID: sess_20260106T143022_realtest
|
||||
Events: 4
|
||||
Screenshots: 3
|
||||
Schema version: rawsession_v1
|
||||
|
||||
============================================================
|
||||
🎉 ALL TESTS PASSED!
|
||||
✅ Agent uploader integration works correctly
|
||||
✅ Server processes uploads properly
|
||||
✅ Data integrity is maintained end-to-end
|
||||
✅ Data models are compatible
|
||||
|
||||
The agent can now upload sessions and the server
|
||||
can process them through the complete pipeline.
|
||||
============================================================
|
||||
```
|
||||
|
||||
## 🎯 Key Achievements
|
||||
|
||||
### Real Functionality Testing
|
||||
- ✅ **No Mocks**: Uses actual agent and server components
|
||||
- ✅ **Real Data**: Creates authentic session data with proper structure
|
||||
- ✅ **Integration**: Tests complete upload → processing → storage flow
|
||||
- ✅ **Validation**: Verifies data integrity end-to-end
|
||||
|
||||
### Production Readiness
|
||||
- ✅ **Error Handling**: Comprehensive error scenarios and recovery
|
||||
- ✅ **Performance**: Measures upload times and validates efficiency
|
||||
- ✅ **Compatibility**: Ensures compatibility with core RPA Vision V3 models
|
||||
- ✅ **Reliability**: Tests retry logic and failure scenarios
|
||||
|
||||
### Developer Experience
|
||||
- ✅ **Clear Output**: Detailed progress and validation feedback
|
||||
- ✅ **Actionable Errors**: Helpful error messages with solutions
|
||||
- ✅ **Easy Setup**: Simple prerequisites and execution
|
||||
- ✅ **Comprehensive**: Single test covers entire upload flow
|
||||
|
||||
## 📈 Impact
|
||||
|
||||
This improved test provides:
|
||||
|
||||
1. **Confidence**: Validates the complete agent upload system works correctly
|
||||
2. **Quality**: Ensures data integrity throughout the entire pipeline
|
||||
3. **Reliability**: Tests error handling and retry mechanisms
|
||||
4. **Integration**: Validates compatibility between agent and server components
|
||||
5. **Maintainability**: Real functionality tests catch regressions early
|
||||
|
||||
## 🔄 Future Enhancements
|
||||
|
||||
Potential improvements for even more comprehensive testing:
|
||||
|
||||
1. **Authentication Testing**: Test with real tokens when auth is enabled
|
||||
2. **Encryption Testing**: Test with encrypted session files
|
||||
3. **Load Testing**: Test with multiple concurrent uploads
|
||||
4. **Network Failure Simulation**: Test retry logic with simulated failures
|
||||
5. **Processing Pipeline Validation**: Verify embeddings and workflow creation
|
||||
|
||||
---
|
||||
|
||||
**Result**: The agent upload system now has comprehensive real functionality testing that validates the complete flow from agent session creation through server processing and storage, ensuring production readiness and data integrity.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Agent V0 Authentication & Encryption Issue - RESOLVED
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The Agent V0 was experiencing authentication and encryption issues when uploading sessions to the server:
|
||||
|
||||
1. **Initial Issue**: HTTP 401 "unauthorized" errors
|
||||
2. **Secondary Issue**: After authentication was fixed, encryption/decryption failures with "Padding invalide" errors
|
||||
|
||||
## Root Causes Identified
|
||||
|
||||
### 1. Authentication Issue
|
||||
- **Cause**: Agent V0 was not loading environment variables properly
|
||||
- **Solution**: Modified `agent_v0/config.py` to auto-load `.env.local` from parent directory
|
||||
- **Result**: Agent now correctly uses `RPA_TOKEN_ADMIN` for authentication
|
||||
|
||||
### 2. Encryption Key Mismatch
|
||||
- **Cause**: Old encrypted files were created with incorrect/inconsistent passwords
|
||||
- **Solution**:
|
||||
- Ensured `agent_config.json` has correct `encryption_password` matching `.env.local`
|
||||
- Moved corrupted old `.enc` files to backup directory
|
||||
- Verified encryption/decryption cycle works with fresh files
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Configuration Files
|
||||
- **`.env.local`**: Contains synchronized encryption password and tokens
|
||||
- **`agent_config.json`**: Updated with correct encryption password
|
||||
- **`agent_v0/config.py`**: Auto-loads environment variables
|
||||
|
||||
### Development Server
|
||||
- **`start_dev_server_simple.py`**: Development server on port 8001
|
||||
- **`stop_dev_server.py`**: Clean shutdown script
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Authentication Test
|
||||
```bash
|
||||
curl -X GET -H "Authorization: Bearer $RPA_TOKEN_ADMIN" http://127.0.0.1:8001/api/traces/status
|
||||
# Result: {"status":"online","encryption_enabled":true}
|
||||
```
|
||||
|
||||
### Encryption/Decryption Test
|
||||
- Fresh session creation: Success
|
||||
- Encryption with correct password: Success
|
||||
- Decryption verification: Success
|
||||
- ZIP file validation: Success
|
||||
|
||||
### Complete Upload Flow Test
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $RPA_TOKEN_ADMIN" \
|
||||
-F "file=@agent_v0/sessions/sess_20260105T195912_49cd3470.enc" \
|
||||
-F "session_id=sess_20260105T195912_49cd3470" \
|
||||
http://127.0.0.1:8001/api/traces/upload
|
||||
# Result: {"status":"success","events_count":1,"received_at":"2026-01-05T19:59:19.305371"}
|
||||
```
|
||||
|
||||
## Current Status: RESOLVED
|
||||
|
||||
- **Authentication**: Working correctly with Bearer token
|
||||
- **Encryption**: Working correctly with synchronized passwords
|
||||
- **Upload Flow**: Complete end-to-end success
|
||||
- **Server Processing**: Successfully decrypts and processes sessions
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Clean up old corrupted files**: Old `.enc` files moved to `agent_v0/sessions/backup_corrupted/`
|
||||
2. **Test with real agent sessions**: Agent V0 should now work correctly for new capture sessions
|
||||
3. **Monitor logs**: Verify no more "Padding invalide" errors in server logs
|
||||
|
||||
The Agent V0 authentication and encryption system is now fully functional and ready for production use.
|
||||
@@ -1,254 +0,0 @@
|
||||
# Analyse du Projet RPA Vision V3 - 09 Janvier 2026
|
||||
|
||||
## Score Global : 8.3/10
|
||||
|
||||
| Aspect | Score |
|
||||
|--------|-------|
|
||||
| Architecture | 9/10 |
|
||||
| Organisation Code | 8/10 |
|
||||
| Tests | 8/10 |
|
||||
| Config Management | 9/10 |
|
||||
| Error Handling | 9/10 |
|
||||
| Propreté du Repo | 5/10 |
|
||||
|
||||
---
|
||||
|
||||
## Métriques
|
||||
|
||||
- **Lignes de code (core)** : 55,914
|
||||
- **Modules core** : 27
|
||||
- **Tests** : 118 fichiers
|
||||
- **Documentation** : 251 fichiers MD à la racine
|
||||
|
||||
---
|
||||
|
||||
## Points Forts
|
||||
|
||||
1. **Architecture 5 couches** bien implémentée :
|
||||
- Couche 0: RawSession (événements bruts)
|
||||
- Couche 1: ScreenState (abstraction)
|
||||
- Couche 2: UIElement (détection sémantique)
|
||||
- Couche 3: StateEmbedding (fusion multi-modale)
|
||||
- Couche 4: WorkflowGraph (exécution)
|
||||
|
||||
2. **Modules core solides** :
|
||||
- execution/ (10k lignes) - Actions, recovery, circuit breaker
|
||||
- analytics/ (5.2k) - Métriques, rapports
|
||||
- embedding/ (2.9k) - CLIP, FAISS, fusion
|
||||
- detection/ (2.5k) - UI detection hybride
|
||||
|
||||
3. **Gestion d'erreurs robuste** :
|
||||
- 983 instances try/except/finally
|
||||
- ErrorHandler centralisé
|
||||
- Recovery strategies
|
||||
- Circuit breaker pattern
|
||||
|
||||
4. **Configuration centralisée** (`core/config.py` - 652 lignes)
|
||||
|
||||
5. **Pas d'imports cassés ni cycles de dépendances**
|
||||
|
||||
---
|
||||
|
||||
## Problèmes Identifiés
|
||||
|
||||
### Critiques (à nettoyer)
|
||||
|
||||
| Problème | Fichiers | Action |
|
||||
|----------|----------|--------|
|
||||
| Tests à la racine | 84 fichiers `test_*.py`, `demo_*.py` | Déplacer vers `tests/` |
|
||||
| Documentation racine | 251 fichiers `.md` | Archiver dans `docs/archive/` |
|
||||
| Fichiers pip corrompus | `=0.0.9`, `=0.15.0`, etc. | Supprimer |
|
||||
| Archives ZIP | 6 fichiers | Supprimer ou archiver |
|
||||
| Backups | `*.backup_*`, `*.bak` | Supprimer |
|
||||
| Logs volumineux | 181 MB | Implémenter rotation |
|
||||
|
||||
### Majeurs (refactoring)
|
||||
|
||||
| Fichier | Lignes | Recommandation |
|
||||
|---------|--------|----------------|
|
||||
| `web_dashboard/app.py` | 39,500 | Découper en modules (routes/, handlers/, services/) |
|
||||
| `core/execution/target_resolver.py` | 3,495 | Pattern Strategy (8 resolvers séparés) |
|
||||
| `server/api_upload_dev_*.py` | 16k x2 | Supprimer duplication |
|
||||
|
||||
### Mineurs
|
||||
|
||||
- Fichiers vides : `agent_v0/workflow_browser.py`, `workflow_locator.py`
|
||||
- 34 TODOs/FIXMEs dans core/
|
||||
- Pas de CI/CD pipeline
|
||||
|
||||
---
|
||||
|
||||
## Recommandations par Priorité
|
||||
|
||||
### 1. Court Terme (Nettoyage)
|
||||
|
||||
```bash
|
||||
# Fichiers à supprimer
|
||||
rm -f =0.0.9 =0.15.0 =0.9.54 =1.24.0 =1.3.0 =1.7.4 =10.0.0 =2.0.0 =2.20.0 =2.31.0 =4.0.0 =4.30.0 =4.8.0 =5.15.0 =7.0.0 =9.0.0
|
||||
rm -f .deps_installed
|
||||
rm -f *.backup_*
|
||||
rm -f *.bak
|
||||
|
||||
# Archives à déplacer
|
||||
mkdir -p archives/
|
||||
mv *.zip archives/
|
||||
mv capture_element_cible_vwb_*/ archives/
|
||||
mv rpa_vision_v3_code_docs_*/ archives/
|
||||
|
||||
# Documentation à organiser
|
||||
mkdir -p docs/archive/sessions/
|
||||
mkdir -p docs/archive/phases/
|
||||
mkdir -p docs/archive/fiches/
|
||||
mv SESSION_*.md docs/archive/sessions/
|
||||
mv PHASE*.md docs/archive/phases/
|
||||
mv FICHE_*.md docs/archive/fiches/
|
||||
mv TASK_*.md docs/archive/
|
||||
|
||||
# Tests à déplacer
|
||||
mkdir -p tests/legacy/
|
||||
mv test_*.py tests/legacy/
|
||||
mv demo_*.py tests/legacy/
|
||||
mv fix_*.py scripts/fixes/
|
||||
mv debug_*.py scripts/debug/
|
||||
mv diagnostic_*.py scripts/diagnostic/
|
||||
```
|
||||
|
||||
### 2. Moyen Terme (Refactoring)
|
||||
|
||||
#### Découper web_dashboard/app.py
|
||||
|
||||
```
|
||||
web_dashboard/
|
||||
├── app.py (bootstrap, 200 lignes max)
|
||||
├── routes/
|
||||
│ ├── __init__.py
|
||||
│ ├── sessions.py
|
||||
│ ├── workflows.py
|
||||
│ ├── metrics.py
|
||||
│ └── system.py
|
||||
├── handlers/
|
||||
│ ├── execution_handler.py
|
||||
│ └── analytics_handler.py
|
||||
├── services/
|
||||
│ ├── storage_service.py
|
||||
│ └── processing_service.py
|
||||
└── websocket/
|
||||
└── realtime.py
|
||||
```
|
||||
|
||||
#### Découper target_resolver.py
|
||||
|
||||
```
|
||||
core/execution/resolvers/
|
||||
├── __init__.py
|
||||
├── base_resolver.py
|
||||
├── by_role_resolver.py
|
||||
├── by_text_resolver.py
|
||||
├── by_position_resolver.py
|
||||
├── by_embedding_resolver.py
|
||||
├── by_hierarchy_resolver.py
|
||||
├── by_context_resolver.py
|
||||
├── by_spatial_resolver.py
|
||||
└── composite_resolver.py
|
||||
```
|
||||
|
||||
### 3. Long Terme
|
||||
|
||||
- Ajouter CI/CD (.github/workflows/)
|
||||
- Pre-commit hooks (black, isort, flake8, mypy)
|
||||
- Log rotation (RotatingFileHandler)
|
||||
- Migration vers Poetry/pipenv
|
||||
- Documentation API (Swagger/OpenAPI)
|
||||
|
||||
---
|
||||
|
||||
## Modules Principaux
|
||||
|
||||
### Core (55.9k lignes)
|
||||
|
||||
| Module | Lignes | Rôle |
|
||||
|--------|--------|------|
|
||||
| execution/ | 10,000 | Exécution actions, recovery |
|
||||
| analytics/ | 5,200 | Métriques, rapports |
|
||||
| visual/ | 4,500 | Gestion targets visuels |
|
||||
| workflow/ | 3,900 | Composition workflows |
|
||||
| models/ | 3,200 | Structures données |
|
||||
| embedding/ | 2,900 | FAISS, CLIP, fusion |
|
||||
| security/ | 2,700 | Tokens, validation |
|
||||
| detection/ | 2,500 | Détection UI |
|
||||
| evaluation/ | 2,200 | Simulation, replay |
|
||||
| healing/ | 2,200 | Auto-healing |
|
||||
| learning/ | 2,100 | Apprentissage persistant |
|
||||
| system/ | 2,100 | Circuit breaker, GPU |
|
||||
| training/ | 1,900 | Pipeline entraînement |
|
||||
| monitoring/ | 1,700 | Logging, métriques |
|
||||
|
||||
### Server (2.9k lignes)
|
||||
|
||||
- `api_core.py` - REST endpoints
|
||||
- `api_upload.py` - Upload files
|
||||
- `processing_pipeline.py` - Pipeline traitement
|
||||
- `worker_daemon.py` - Worker background
|
||||
|
||||
### Agent V0 (6.6k lignes)
|
||||
|
||||
- `tray_ui.py` - Interface systray
|
||||
- `enhanced_event_captor.py` - Event capturing
|
||||
- `uploader.py` - Upload au serveur
|
||||
- `storage_encrypted.py` - Chiffrement
|
||||
|
||||
### Web Dashboard
|
||||
|
||||
- `app.py` - 39.5k lignes (à découper)
|
||||
- Port 5001
|
||||
- WebSocket temps réel
|
||||
|
||||
---
|
||||
|
||||
## Dépendances Clés
|
||||
|
||||
```
|
||||
core/config.py (central)
|
||||
│
|
||||
├── core/models
|
||||
├── core/capture
|
||||
├── core/detection
|
||||
├── core/embedding
|
||||
│
|
||||
└── core/execution
|
||||
│
|
||||
├── core/graph
|
||||
├── core/learning
|
||||
├── core/healing
|
||||
├── core/analytics
|
||||
│
|
||||
└── server/api_core
|
||||
│
|
||||
└── web_dashboard/app.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Services Systemd
|
||||
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| rpa-vision-v3-api | 8000 | enabled |
|
||||
| rpa-vision-v3-dashboard | 5001 | enabled |
|
||||
| rpa-vision-v3-worker | - | enabled |
|
||||
|
||||
---
|
||||
|
||||
## Prochaines Actions
|
||||
|
||||
1. [ ] Nettoyer fichiers racine (pip corrompus, backups)
|
||||
2. [ ] Organiser documentation (251 MD → docs/archive/)
|
||||
3. [ ] Déplacer tests legacy (84 fichiers → tests/legacy/)
|
||||
4. [ ] Implémenter log rotation
|
||||
5. [ ] Découper web_dashboard/app.py
|
||||
6. [ ] Refactorer target_resolver.py
|
||||
7. [ ] Ajouter CI/CD
|
||||
|
||||
---
|
||||
|
||||
*Généré le 09 janvier 2026*
|
||||
@@ -1,578 +0,0 @@
|
||||
# RAPPORT D'AUDIT SÉCURITÉ & LOGS - VWB RPA Vision v3
|
||||
|
||||
**Date**: 14 janvier 2026
|
||||
**Auteur**: Claude (revue automatisée)
|
||||
**Contexte**: Environnements sensibles (Santé, Défense, Administration)
|
||||
**Mode**: Revue uniquement - Aucun code modifié
|
||||
**Statut**: À CORRIGER APRÈS LES DÉMOS
|
||||
|
||||
---
|
||||
|
||||
## SCORE GLOBAL : 3/10 - NON PRÊT POUR PRODUCTION SENSIBLE
|
||||
|
||||
> **Note**: Ce rapport est à traiter APRÈS les démonstrations en cours.
|
||||
> Les corrections de sécurité peuvent impacter le fonctionnement actuel.
|
||||
|
||||
---
|
||||
|
||||
## TABLE DES MATIÈRES
|
||||
|
||||
1. [Vulnérabilités Critiques](#1-vulnérabilités-critiques)
|
||||
2. [Problèmes Logs & Traçabilité](#2-problèmes-logs--traçabilité)
|
||||
3. [Headers Sécurité Manquants](#3-headers-sécurité-manquants)
|
||||
4. [Endpoints Non Protégés](#4-endpoints-non-protégés)
|
||||
5. [Conformité Réglementaire](#5-conformité-réglementaire)
|
||||
6. [Plan de Remédiation](#6-plan-de-remédiation)
|
||||
7. [Détails Techniques Complets](#7-détails-techniques-complets)
|
||||
|
||||
---
|
||||
|
||||
## 1. VULNÉRABILITÉS CRITIQUES
|
||||
|
||||
### Résumé (6 vulnérabilités critiques)
|
||||
|
||||
| # | Vulnérabilité | Fichier | Ligne | Impact |
|
||||
|---|---------------|---------|-------|--------|
|
||||
| 1 | Tokens de production hardcodés | `core/security/api_tokens.py` | 93-96 | Compromis total auth |
|
||||
| 2 | CORS = "*" partout | `backend/app.py` | 34 | CSRF, accès cross-origin |
|
||||
| 3 | Zéro authentification sur /api/* | `backend/api/workflows.py` | - | Exécution workflows non autorisée |
|
||||
| 4 | SECRET_KEY par défaut | `backend/app.py` | 24 | Sessions forgées |
|
||||
| 5 | WebSocket sans auth | `backend/api/websocket_handlers.py` | - | Espionnage temps réel |
|
||||
| 6 | Path traversal | `backend/services/serialization.py` | 115 | Lecture/écriture fichiers système |
|
||||
|
||||
### 1.1 Tokens de Production Hardcodés (CRITIQUE)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/core/security/api_tokens.py` lignes 93-109
|
||||
|
||||
```python
|
||||
# Temporary fix: Add production tokens directly
|
||||
prod_admin_token = "73cf0db73f9a5064e79afebba96c85338be65cc2060b9c1d42c3ea5dd7d4e490"
|
||||
prod_readonly_token = "7eea1de415cc69c02381ce09ff63aeebf3e1d9b476d54aa6730ba9de849e3dc6"
|
||||
self.admin_tokens.add(prod_admin_token)
|
||||
self.read_only_tokens.add(prod_readonly_token)
|
||||
```
|
||||
|
||||
**Problème**:
|
||||
- Tokens de production en dur dans le code source
|
||||
- Tokens visibles dans les dépôts Git
|
||||
- Réutilisés pour tous les environnements
|
||||
- Commentaires "Temporary fix" indiquant du code en attente
|
||||
|
||||
**Impact**: Compromis complet de l'authentification en production
|
||||
|
||||
**Correction recommandée**:
|
||||
```python
|
||||
# Utiliser UNIQUEMENT les variables d'environnement
|
||||
admin_token = os.getenv("RPA_TOKEN_ADMIN")
|
||||
readonly_token = os.getenv("RPA_TOKEN_READONLY")
|
||||
|
||||
if not admin_token or not readonly_token:
|
||||
if os.getenv('ENVIRONMENT') == 'production':
|
||||
raise ValueError("Tokens must be configured via environment variables")
|
||||
```
|
||||
|
||||
### 1.2 CORS Ouvert à Tous (CRITIQUE)
|
||||
|
||||
**Fichiers impactés**:
|
||||
- `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/app.py:34-40`
|
||||
- `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/app_lightweight.py:512-516`
|
||||
|
||||
```python
|
||||
# SocketIO
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*", # VULNÉRABLE
|
||||
async_mode='threading'
|
||||
)
|
||||
|
||||
# Flask CORS
|
||||
CORS(app, origins="*", # VULNÉRABLE
|
||||
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "Accept", "X-Requested-With"],
|
||||
supports_credentials=False)
|
||||
```
|
||||
|
||||
**Correction recommandée**:
|
||||
```python
|
||||
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
|
||||
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins=CORS_ORIGINS,
|
||||
async_mode='threading'
|
||||
)
|
||||
|
||||
CORS(app,
|
||||
origins=CORS_ORIGINS,
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
supports_credentials=True,
|
||||
max_age=3600)
|
||||
```
|
||||
|
||||
### 1.3 SECRET_KEY par Défaut (CRITIQUE)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/app.py:24`
|
||||
|
||||
```python
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
```
|
||||
|
||||
**Correction recommandée**:
|
||||
```python
|
||||
secret_key = os.getenv('SECRET_KEY')
|
||||
if not secret_key or 'change-in-production' in secret_key:
|
||||
if os.getenv('ENVIRONMENT') == 'production':
|
||||
raise ValueError("SECRET_KEY must be set to a secure value in production")
|
||||
secret_key = 'dev-only-key'
|
||||
app.config['SECRET_KEY'] = secret_key
|
||||
```
|
||||
|
||||
### 1.4 WebSocket Sans Authentification (CRITIQUE)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/api/websocket_handlers.py`
|
||||
|
||||
```python
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
client_id = request.sid
|
||||
emit('connected', {...}) # AUCUNE VÉRIFICATION D'AUTH
|
||||
```
|
||||
|
||||
**Correction recommandée**:
|
||||
```python
|
||||
@socketio.on('connect')
|
||||
def handle_connect(auth):
|
||||
token = auth.get('token') if auth else None
|
||||
if not token or not validate_token(token):
|
||||
return False # Refuse la connexion
|
||||
# ... reste du code
|
||||
```
|
||||
|
||||
### 1.5 Path Traversal (CRITIQUE)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/services/serialization.py:115-118`
|
||||
|
||||
```python
|
||||
def _path(self, workflow_id: str) -> str:
|
||||
safe_id = "".join(c for c in workflow_id if c.isalnum() or c in ("_", "-")) or workflow_id
|
||||
return os.path.join(self.root_dir, f"{safe_id}.json")
|
||||
```
|
||||
|
||||
**Problème**: Le fallback `or workflow_id` contourne le filtre si tous les caractères sont supprimés.
|
||||
|
||||
**Correction recommandée**:
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
def _path(self, workflow_id: str) -> str:
|
||||
# Filtrer strictement
|
||||
safe_id = "".join(c for c in workflow_id if c.isalnum() or c == "_")
|
||||
if not safe_id:
|
||||
safe_id = "default_workflow"
|
||||
|
||||
# Vérifier que le chemin reste dans root_dir
|
||||
file_path = Path(self.root_dir) / f"{safe_id}.json"
|
||||
resolved = file_path.resolve()
|
||||
|
||||
# Sécurité: vérifier qu'on ne sort pas du répertoire
|
||||
if not str(resolved).startswith(str(Path(self.root_dir).resolve())):
|
||||
raise ValueError("Invalid workflow ID - path traversal detected")
|
||||
|
||||
return str(file_path)
|
||||
```
|
||||
|
||||
### 1.6 Mode Debug Activable en Production (HAUTE)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/app.py:185-193`
|
||||
|
||||
```python
|
||||
socketio.run(
|
||||
app,
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
debug=debug,
|
||||
use_reloader=debug,
|
||||
allow_unsafe_werkzeug=True # DANGEREUX EN PRODUCTION
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. PROBLÈMES LOGS & TRAÇABILITÉ
|
||||
|
||||
### 2.1 Lacunes Identifiées
|
||||
|
||||
| Lacune | Sévérité | Conformité impactée |
|
||||
|--------|----------|---------------------|
|
||||
| `user_id` toujours `null` dans les logs | CRITIQUE | HIPAA, RGPD, ISO 27001 |
|
||||
| Pas d'audit trail workflow (qui/quoi/quand) | HAUTE | Tous secteurs |
|
||||
| Logs corrompus détectés (`logs/0.log`) | MOYENNE | Intégrité données |
|
||||
| Pas de rotation logs application | HAUTE | Disk full possible |
|
||||
| Rétention max 100MB (vs 7 ans HIPAA) | CRITIQUE | Santé |
|
||||
| Stack traces exposées en réponse API | HAUTE | OWASP |
|
||||
| IPs partiellement masquées (3 octets visibles) | MOYENNE | RGPD |
|
||||
|
||||
### 2.2 Structure de Log Actuelle (Insuffisante)
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/core/security/audit_log.py`
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "api_access",
|
||||
"timestamp": "2026-01-06T00:59:45.467453Z",
|
||||
"message": "request_success",
|
||||
"user_id": null, // TOUJOURS NULL - PROBLÈME
|
||||
"ip_address": "127.0.0.xxx", // Masquage insuffisant (3 octets visibles)
|
||||
"endpoint": "/api/traces/status",
|
||||
"method": "GET",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Structure de Log Requise (HIPAA/RGPD)
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "data_access",
|
||||
"timestamp": "2026-01-14T10:30:00.123456Z",
|
||||
"user_id": "admin@example.com", // OBLIGATOIRE
|
||||
"session_id": "sess_abc123", // Pour corrélation
|
||||
"correlation_id": "req_999", // Pour traçage distribué
|
||||
"action": "read_workflow",
|
||||
"resource_id": "workflow_123",
|
||||
"resource_type": "workflow",
|
||||
"ip_address": "192.168.x.x", // 2 octets max visibles
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"data_classification": "SENSITIVE", // Classification données
|
||||
"duration_ms": 234,
|
||||
"status": "success",
|
||||
"changes": { // Pour modifications
|
||||
"before": {...},
|
||||
"after": {...}
|
||||
},
|
||||
"signature": "hmac_sha256_..." // Immuabilité audit trail
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Logs Corrompus Détectés
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/logs/0.log`
|
||||
|
||||
```
|
||||
2025-12-13 13:41:37,006 - rpa.0 - INFO - vÏÊ « ← CORRUPTION ENCODAGE
|
||||
2025-12-13 13:41:37,009 - rpa.0 - ERROR - ← MESSAGE VIDE
|
||||
```
|
||||
|
||||
### 2.5 Configuration Rotation Actuelle
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/core/security/audit_log.py:68-106`
|
||||
|
||||
```python
|
||||
self.log_dir = Path(os.getenv("AUDIT_LOG_DIR", "logs/audit"))
|
||||
self.max_file_size = int(os.getenv("AUDIT_LOG_MAX_SIZE", "10485760")) # 10MB
|
||||
self.max_files = int(os.getenv("AUDIT_LOG_MAX_FILES", "10"))
|
||||
```
|
||||
|
||||
**Problèmes**:
|
||||
- Total max: 100MB (10 fichiers x 10MB)
|
||||
- Pas de rétention temporelle (HIPAA exige 7 ans)
|
||||
- Pas de compression des archives
|
||||
- Logs applicatifs non rotatés
|
||||
|
||||
---
|
||||
|
||||
## 3. HEADERS SÉCURITÉ MANQUANTS
|
||||
|
||||
| Header | État | Risque | Correction |
|
||||
|--------|------|--------|------------|
|
||||
| `Strict-Transport-Security` | ABSENT | Downgrade HTTPS | `max-age=31536000; includeSubDomains` |
|
||||
| `Content-Security-Policy` | ABSENT | XSS | `default-src 'self'` |
|
||||
| `X-Frame-Options` | ABSENT | Clickjacking | `DENY` |
|
||||
| `X-Content-Type-Options` | ABSENT | MIME sniffing | `nosniff` |
|
||||
| `X-XSS-Protection` | ABSENT | XSS legacy | `1; mode=block` |
|
||||
| `Referrer-Policy` | ABSENT | Fuite referrer | `strict-origin-when-cross-origin` |
|
||||
|
||||
**Correction recommandée** (à ajouter dans `app.py`):
|
||||
|
||||
```python
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'"
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ENDPOINTS NON PROTÉGÉS
|
||||
|
||||
### 4.1 Backend VWB (`/api/*`)
|
||||
|
||||
| Méthode | Endpoint | Risque | Auth requise |
|
||||
|---------|----------|--------|--------------|
|
||||
| GET | `/api/workflows/` | Enumération | Oui |
|
||||
| POST | `/api/workflows/` | Création non autorisée | Oui |
|
||||
| GET | `/api/workflows/<id>` | Lecture données | Oui |
|
||||
| PUT | `/api/workflows/<id>` | Modification | Oui |
|
||||
| DELETE | `/api/workflows/<id>` | Suppression | Oui |
|
||||
| POST | `/api/screen-capture` | Capture écran | Oui |
|
||||
|
||||
### 4.2 Dashboard Web
|
||||
|
||||
| Méthode | Endpoint | Risque | Auth requise |
|
||||
|---------|----------|--------|--------------|
|
||||
| POST | `/api/workflows/<id>/execute` | **EXÉCUTION SANS AUTH** | CRITIQUE |
|
||||
| POST | `/api/agent/sessions/<id>/process` | Traitement sessions | Oui |
|
||||
| GET | `/api/agent/sessions` | Enumération | Oui |
|
||||
| GET | `/api/logs` | **LOGS SYSTÈME PUBLICS** | CRITIQUE |
|
||||
| POST | `/api/logs/download` | Téléchargement logs | Oui |
|
||||
| GET | `/api/system/status` | Info système | Oui |
|
||||
|
||||
### 4.3 Endpoints Debug à Supprimer en Production
|
||||
|
||||
**Fichier**: `/home/dom/ai/rpa_vision_v3/core/security/fastapi_security.py:61`
|
||||
|
||||
```python
|
||||
DEFAULT_PUBLIC_PATHS = {
|
||||
"/api/traces/debug-auth", # EXPOSÉ - À RETIRER
|
||||
"/api/traces/debug-env", # EXPOSÉ - À RETIRER
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CONFORMITÉ RÉGLEMENTAIRE
|
||||
|
||||
### 5.1 Matrice de Conformité
|
||||
|
||||
| Standard | Exigence | État | Gap |
|
||||
|----------|----------|------|-----|
|
||||
| **HIPAA** | Rétention 7 ans | ❌ | Max 100 MB |
|
||||
| **HIPAA** | User audit trail | ❌ | user_id = null |
|
||||
| **HIPAA** | Data access logs | ❌ | Non implémenté |
|
||||
| **RGPD** | Droit à l'oubli | ❌ | Pas de TTL/purge |
|
||||
| **RGPD** | PII masquage | ❌ | Loggé en clair |
|
||||
| **RGPD** | Consentement logs | ❌ | Non tracé |
|
||||
| **SOC 2** | Log retention | ❌ | 100 MB insuffisant |
|
||||
| **SOC 2** | Integrity verification | ❌ | JSONL non signé |
|
||||
| **ISO 27001** | Change tracking | ❌ | Pas de before/after |
|
||||
| **ISO 27001** | Admin actions | ~ | Partiel |
|
||||
|
||||
### 5.2 Verdict par Secteur
|
||||
|
||||
| Secteur | État | Bloqueurs principaux |
|
||||
|---------|------|----------------------|
|
||||
| **Santé (HIPAA)** | ❌ NO-GO | user_id null, rétention insuffisante |
|
||||
| **Défense** | ❌ NO-GO | Pas de classification, pas de clearance |
|
||||
| **Administration (RGPD)** | ❌ NO-GO | PII en clair, pas de droit à l'oubli |
|
||||
| **Entreprise standard** | ⚠️ RISQUÉ | Authentification manquante |
|
||||
|
||||
---
|
||||
|
||||
## 6. PLAN DE REMÉDIATION
|
||||
|
||||
### Phase 1 - URGENCE (24-48h après les démos)
|
||||
|
||||
**Priorité**: Sécurité de base
|
||||
|
||||
- [ ] **1.1** Supprimer tokens hardcodés de `api_tokens.py` (lignes 93-109)
|
||||
- [ ] **1.2** Configurer CORS avec origines explicites (pas "*")
|
||||
- [ ] **1.3** Changer SECRET_KEY avec valeur sécurisée
|
||||
- [ ] **1.4** Masquer erreurs détaillées en production
|
||||
- [ ] **1.5** Retirer endpoints debug (`/api/traces/debug-*`)
|
||||
|
||||
**Fichiers à modifier**:
|
||||
```
|
||||
core/security/api_tokens.py
|
||||
visual_workflow_builder/backend/app.py
|
||||
visual_workflow_builder/backend/app_lightweight.py
|
||||
core/security/fastapi_security.py
|
||||
```
|
||||
|
||||
### Phase 2 - Court terme (1-2 semaines)
|
||||
|
||||
**Priorité**: Authentification & Protection
|
||||
|
||||
- [ ] **2.1** Ajouter middleware d'authentification sur `/api/*`
|
||||
- [ ] **2.2** Implémenter rate limiting (flask-limiter)
|
||||
- [ ] **2.3** Authentifier connexions WebSocket
|
||||
- [ ] **2.4** Ajouter headers de sécurité
|
||||
- [ ] **2.5** Corriger path traversal dans serialization.py
|
||||
- [ ] **2.6** Valider uploads (taille, type, contenu)
|
||||
|
||||
**Exemple middleware auth**:
|
||||
```python
|
||||
from functools import wraps
|
||||
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if not token or not validate_token(token):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
# Appliquer sur les routes
|
||||
@app.route('/api/workflows/', methods=['POST'])
|
||||
@require_auth
|
||||
def create_workflow():
|
||||
...
|
||||
```
|
||||
|
||||
### Phase 3 - Moyen terme (1 mois)
|
||||
|
||||
**Priorité**: Logs & Audit
|
||||
|
||||
- [ ] **3.1** Ajouter `user_id` aux logs d'audit
|
||||
- [ ] **3.2** Implémenter audit trail workflow complet
|
||||
- [ ] **3.3** Rotation et rétention logs conforme (7 ans si HIPAA)
|
||||
- [ ] **3.4** Masquage automatique PII
|
||||
- [ ] **3.5** Signature des logs pour immuabilité
|
||||
- [ ] **3.6** Compression archives logs
|
||||
|
||||
**Structure logging recommandée**:
|
||||
```python
|
||||
import logging.config
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'json': {
|
||||
'class': 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
||||
'format': '%(timestamp)s %(level)s %(name)s %(message)s'
|
||||
}
|
||||
},
|
||||
'handlers': {
|
||||
'rotating_file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/vwb.log',
|
||||
'maxBytes': 10485760, # 10MB
|
||||
'backupCount': 100, # 1GB total
|
||||
'formatter': 'json'
|
||||
}
|
||||
},
|
||||
'root': {
|
||||
'level': 'INFO',
|
||||
'handlers': ['rotating_file']
|
||||
}
|
||||
}
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
```
|
||||
|
||||
### Phase 4 - Long terme (2-3 mois)
|
||||
|
||||
**Priorité**: Conformité complète
|
||||
|
||||
- [ ] **4.1** Intégration SIEM (syslog/ELK/Splunk)
|
||||
- [ ] **4.2** RBAC (Role-Based Access Control)
|
||||
- [ ] **4.3** Chiffrement données au repos
|
||||
- [ ] **4.4** Backup et recovery audit trail
|
||||
- [ ] **4.5** Penetration testing
|
||||
- [ ] **4.6** Documentation sécurité
|
||||
|
||||
---
|
||||
|
||||
## 7. DÉTAILS TECHNIQUES COMPLETS
|
||||
|
||||
### 7.1 Fichiers Critiques à Corriger
|
||||
|
||||
| Fichier | Problèmes | Priorité |
|
||||
|---------|-----------|----------|
|
||||
| `core/security/api_tokens.py` | Tokens hardcodés | P1 |
|
||||
| `backend/app.py` | CORS, SECRET_KEY, debug, auth | P1 |
|
||||
| `backend/app_lightweight.py` | CORS | P1 |
|
||||
| `backend/api/websocket_handlers.py` | Auth WebSocket | P1 |
|
||||
| `backend/services/serialization.py` | Path traversal | P1 |
|
||||
| `core/security/audit_log.py` | user_id, masquage IP | P2 |
|
||||
| `backend/api/workflows.py` | Validation entrées | P2 |
|
||||
| `core/security/fastapi_security.py` | Endpoints debug | P2 |
|
||||
|
||||
### 7.2 Variables d'Environnement Requises
|
||||
|
||||
```bash
|
||||
# Production - À configurer OBLIGATOIREMENT
|
||||
SECRET_KEY=<générer avec: python -c "import secrets; print(secrets.token_hex(32))">
|
||||
TOKEN_SECRET_KEY=<générer avec: python -c "import secrets; print(secrets.token_hex(32))">
|
||||
RPA_TOKEN_ADMIN=<générer avec: python -c "import secrets; print(secrets.token_hex(32))">
|
||||
RPA_TOKEN_READONLY=<générer avec: python -c "import secrets; print(secrets.token_hex(32))">
|
||||
CORS_ORIGINS=https://app.example.com,https://admin.example.com
|
||||
ENVIRONMENT=production
|
||||
FLASK_ENV=production
|
||||
|
||||
# Logs
|
||||
AUDIT_LOG_DIR=/var/log/vwb/audit
|
||||
AUDIT_LOG_MAX_SIZE=10485760
|
||||
AUDIT_LOG_MAX_FILES=1000
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### 7.3 Commandes de Génération de Secrets
|
||||
|
||||
```bash
|
||||
# Générer un nouveau SECRET_KEY
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# Générer un nouveau token admin
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# Vérifier les permissions des fichiers .env
|
||||
chmod 600 .env.local
|
||||
chown $USER:$USER .env.local
|
||||
```
|
||||
|
||||
### 7.4 Tests de Sécurité à Effectuer
|
||||
|
||||
```bash
|
||||
# Test CORS
|
||||
curl -H "Origin: http://evil.com" -I http://localhost:5002/api/workflows/
|
||||
|
||||
# Test authentification (doit retourner 401)
|
||||
curl -X POST http://localhost:5002/api/workflows/
|
||||
|
||||
# Test path traversal
|
||||
curl http://localhost:5002/api/workflows/..%2F..%2Fetc%2Fpasswd
|
||||
|
||||
# Test rate limiting (après implémentation)
|
||||
for i in {1..100}; do curl http://localhost:5002/api/workflows/; done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ANNEXES
|
||||
|
||||
### A. Checklist Pré-Production
|
||||
|
||||
```
|
||||
[ ] Tokens hardcodés supprimés
|
||||
[ ] SECRET_KEY unique et sécurisé
|
||||
[ ] CORS configuré avec origines explicites
|
||||
[ ] Authentification sur tous les endpoints /api/*
|
||||
[ ] WebSocket authentifié
|
||||
[ ] Headers de sécurité ajoutés
|
||||
[ ] Endpoints debug retirés
|
||||
[ ] Erreurs masquées en production
|
||||
[ ] Rate limiting actif
|
||||
[ ] Logs avec user_id
|
||||
[ ] Rotation logs configurée
|
||||
[ ] HTTPS forcé
|
||||
[ ] Fichiers .env exclus de Git
|
||||
[ ] Permissions fichiers correctes (600)
|
||||
```
|
||||
|
||||
### B. Contacts & Ressources
|
||||
|
||||
- OWASP Top 10: https://owasp.org/Top10/
|
||||
- Flask Security: https://flask.palletsprojects.com/en/2.0.x/security/
|
||||
- HIPAA Security Rule: https://www.hhs.gov/hipaa/for-professionals/security/
|
||||
|
||||
---
|
||||
|
||||
**Fin du rapport - À traiter après les démonstrations**
|
||||
@@ -1,74 +0,0 @@
|
||||
═══════════════════════════════════════════════════════════════
|
||||
✅ BUGFIX COMPLETE - Demo Fonctionnel
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🐛 PROBLÈMES CORRIGÉS:
|
||||
|
||||
1. ✅ Syntax Error dans insight_generator.py (ligne 269)
|
||||
- Parenthèse en trop supprimée
|
||||
|
||||
2. ✅ Import Flask optionnel
|
||||
- Flask n'est pas installé → import rendu optionnel
|
||||
- API REST désactivée gracieusement si Flask absent
|
||||
|
||||
3. ✅ Demo simplifié
|
||||
- demo_analytics.py simplifié pour montrer l'initialisation
|
||||
- demo_integrated_execution.py fonctionne avec warnings mineurs
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
✅ TESTS RÉUSSIS:
|
||||
|
||||
$ python3 demo_analytics.py
|
||||
✅ Fonctionne - Système initialisé avec succès
|
||||
|
||||
$ python3 demo_integrated_execution.py
|
||||
✅ Fonctionne - 3 workflows exécutés avec tracking
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ WARNINGS (Non-bloquants):
|
||||
|
||||
- Flask not available → API REST désactivée (normal)
|
||||
- Resource monitoring not available → Optionnel
|
||||
- Quelques noms de paramètres à harmoniser (duration vs duration_ms)
|
||||
|
||||
Ces warnings n'empêchent PAS le fonctionnement du système.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🎉 RÉSULTAT:
|
||||
|
||||
Le système analytics est FONCTIONNEL et prêt à l'emploi !
|
||||
|
||||
Tous les composants principaux fonctionnent:
|
||||
✅ Initialisation du système
|
||||
✅ Tracking d'exécution
|
||||
✅ Collection de métriques
|
||||
✅ Real-time analytics
|
||||
✅ Intégration ExecutionLoop
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🚀 UTILISATION:
|
||||
|
||||
# Demo simple
|
||||
python3 demo_analytics.py
|
||||
|
||||
# Demo avec intégration
|
||||
python3 demo_integrated_execution.py
|
||||
|
||||
# Voir les guides
|
||||
cat ANALYTICS_INTEGRATION_GUIDE.md
|
||||
cat MISSION_COMPLETE.txt
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
✨ STATUS FINAL: PRODUCTION READY
|
||||
|
||||
Le système est prêt pour l'utilisation en production !
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Date: 1er Décembre 2024
|
||||
Status: ✅ FONCTIONNEL
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -1,36 +0,0 @@
|
||||
# Corrections Finales - Workflows & Embeddings
|
||||
|
||||
## Corrections effectuées:
|
||||
|
||||
1. graph_builder.py ligne 508:
|
||||
- AVANT: screen_template=template
|
||||
- APRÈS: template=template
|
||||
- Ajouté: description="Cluster detected from X observations"
|
||||
|
||||
2. processing_pipeline.py ligne 297:
|
||||
- AVANT: f"data/training/sessions/{session.session_id}/{session.session_id}/{screenshot.relative_path}"
|
||||
- APRÈS: f"data/training/sessions/{session.session_id}/{screenshot.relative_path}"
|
||||
|
||||
## Déploiement:
|
||||
|
||||
sudo cp /home/dom/ai/rpa_vision_v3/processing_pipeline.py /opt/rpa_vision_v3/server/processing_pipeline.py
|
||||
sudo chown rpa:rpa /opt/rpa_vision_v3/server/processing_pipeline.py
|
||||
|
||||
sudo cp /home/dom/ai/rpa_vision_v3/graph_builder.py /opt/rpa_vision_v3/core/graph/graph_builder.py
|
||||
sudo chown rpa:rpa /opt/rpa_vision_v3/core/graph/graph_builder.py
|
||||
|
||||
sudo systemctl restart rpa-vision-v3-worker.service
|
||||
|
||||
## Test:
|
||||
|
||||
cd /home/dom/ai/rpa_vision_v3/agent_v0
|
||||
./run.sh
|
||||
# Actions 30 secondes, Ctrl+C
|
||||
# Attendre 2 minutes
|
||||
|
||||
## Vérification:
|
||||
|
||||
ls -lh /opt/rpa_vision_v3/data/training/workflows/
|
||||
ls -lh /opt/rpa_vision_v3/data/training/prototypes/
|
||||
find /opt/rpa_vision_v3/data/training/embeddings -name "*.npy" | wc -l
|
||||
journalctl -u rpa-vision-v3-worker -n 50 | grep -E "(Embeddings générés|Workflow créé)"
|
||||
@@ -1,186 +0,0 @@
|
||||
# 🎉 CORRECTION COMPLÈTE DES ERREURS TYPESCRIPT VWB - 12 JANVIER 2026
|
||||
|
||||
**Auteur :** Dom, Alice, Kiro
|
||||
**Date :** 12 janvier 2026
|
||||
**Statut :** ✅ **MISSION ACCOMPLIE**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Résumé Exécutif
|
||||
|
||||
**OBJECTIF ATTEINT :** Toutes les erreurs TypeScript du Visual Workflow Builder ont été corrigées définitivement. Le frontend compile maintenant parfaitement et est prêt pour la production.
|
||||
|
||||
### 🎯 Résultats Obtenus
|
||||
|
||||
- ✅ **0 erreur TypeScript** - Compilation parfaite
|
||||
- ✅ **Build de production** - Génération réussie (315.94 kB)
|
||||
- ✅ **Tests automatisés** - 100% de réussite
|
||||
- ✅ **Architecture préservée** - Fonctionnalités VWB intactes
|
||||
- ✅ **Standards respectés** - Code en français, bien documenté
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Corrections Apportées
|
||||
|
||||
### 1. **StepNode.tsx** - Interface Props Corrigée
|
||||
```typescript
|
||||
// ❌ AVANT - Props incompatibles
|
||||
return <VWBStepNodeExtension {...{ data, selected, id: (stepData.id || 'unknown') as string }} />;
|
||||
|
||||
// ✅ APRÈS - Props simplifiées
|
||||
return <VWBStepNodeExtension data={data} selected={selected} />;
|
||||
```
|
||||
|
||||
### 2. **VWBStepNodeExtension.tsx** - Interface Spécialisée
|
||||
```typescript
|
||||
// ❌ AVANT - Interface trop restrictive
|
||||
const VWBStepNodeExtension: React.FC<NodeProps> = ({ data, selected }) => {
|
||||
|
||||
// ✅ APRÈS - Interface adaptée
|
||||
interface VWBStepNodeExtensionProps {
|
||||
data: any;
|
||||
selected: boolean;
|
||||
}
|
||||
const VWBStepNodeExtension: React.FC<VWBStepNodeExtensionProps> = ({ data, selected }) => {
|
||||
```
|
||||
|
||||
### 3. **Executor/index.tsx** - Architecture Refactorisée
|
||||
```typescript
|
||||
// ❌ AVANT - Variables hors scope
|
||||
const { isVWBStep } = useVWBExecutionService(); // Hors du composant
|
||||
const hasVWBSteps = useMemo(() => ...); // Erreur de scope
|
||||
|
||||
// ✅ APRÈS - Variables dans le composant
|
||||
const Executor: React.FC<ExecutorProps> = ({ workflow, ... }) => {
|
||||
const { isVWBStep } = useVWBExecutionService();
|
||||
const hasVWBSteps = useMemo(() =>
|
||||
workflow.steps.some(step => isVWBStep(step)),
|
||||
[workflow.steps, isVWBStep]
|
||||
);
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Validation Complète
|
||||
|
||||
### Tests de Compilation
|
||||
```bash
|
||||
# Vérification TypeScript
|
||||
npx tsc --noEmit
|
||||
✅ Aucune erreur détectée
|
||||
|
||||
# Build de production
|
||||
npm run build
|
||||
✅ Compilation réussie
|
||||
✅ 315.94 kB (gzippé) - Optimisé
|
||||
|
||||
# Tests automatisés
|
||||
python3 tests/integration/test_typescript_compilation_complete_12jan2026.py
|
||||
✅ 2/2 tests réussis
|
||||
```
|
||||
|
||||
### Métriques de Performance
|
||||
- **Taille finale :** 315.94 kB (gzippé)
|
||||
- **Fichiers générés :** 1 JS principal + 1 CSS + chunks
|
||||
- **Temps de compilation :** ~13 secondes
|
||||
- **Compatibilité :** React 19.2.3 + TypeScript 4.9.5
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Respectée
|
||||
|
||||
### Conformité aux Standards du Projet
|
||||
|
||||
| Critère | Status | Détails |
|
||||
|---------|--------|---------|
|
||||
| **Langue française** | ✅ | Tous commentaires et docs en français |
|
||||
| **Attribution** | ✅ | "Dom, Alice, Kiro" avec dates |
|
||||
| **Organisation docs** | ✅ | Centralisé dans `docs/` |
|
||||
| **Organisation tests** | ✅ | Structuré dans `tests/` |
|
||||
| **Cohérence** | ✅ | Architecture et conventions respectées |
|
||||
|
||||
### Types TypeScript
|
||||
- ✅ Interfaces bien définies dans `types/index.ts`
|
||||
- ✅ Props typées correctement
|
||||
- ✅ Imports/exports cohérents
|
||||
- ✅ Pas d'utilisation abusive de `any`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Fonctionnalités Préservées
|
||||
|
||||
### Support VWB Complet
|
||||
- ✅ **Actions VisionOnly** - Catalogue complet fonctionnel
|
||||
- ✅ **États visuels** - Animations et feedback temps réel
|
||||
- ✅ **Evidence Viewer** - Visualisation des preuves d'exécution
|
||||
- ✅ **Propriétés Panel** - Configuration des étapes
|
||||
- ✅ **Système d'exécution** - Workflow robuste
|
||||
|
||||
### Interface Utilisateur
|
||||
- ✅ **Canvas interactif** - Glisser-déposer fonctionnel
|
||||
- ✅ **Palette d'outils** - Catalogue d'actions complet
|
||||
- ✅ **Panneau propriétés** - Configuration dynamique
|
||||
- ✅ **Contrôles d'exécution** - Play/Pause/Stop
|
||||
- ✅ **Indicateurs visuels** - États et progression
|
||||
|
||||
---
|
||||
|
||||
## 📁 Fichiers Créés/Modifiés
|
||||
|
||||
### Corrections Principales
|
||||
- `visual_workflow_builder/frontend/src/components/Canvas/StepNode.tsx`
|
||||
- `visual_workflow_builder/frontend/src/components/Canvas/VWBStepNodeExtension.tsx`
|
||||
- `visual_workflow_builder/frontend/src/components/Executor/index.tsx`
|
||||
|
||||
### Documentation
|
||||
- `docs/CORRECTION_FINALE_TYPESCRIPT_VWB_12JAN2026.md`
|
||||
- `docs/rapport_validation_typescript_vwb_12jan2026.json`
|
||||
|
||||
### Scripts et Tests
|
||||
- `fix_typescript_errors_vwb_complete_12jan2026.py`
|
||||
- `scripts/validation_finale_typescript_vwb_12jan2026.py`
|
||||
- `tests/integration/test_typescript_compilation_complete_12jan2026.py`
|
||||
- `tests/integration/test_vwb_frontend_startup_final_12jan2026.py`
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Recommandations Futures
|
||||
|
||||
### Prévention des Erreurs
|
||||
1. **CI/CD Pipeline :** Intégrer `tsc --noEmit` dans les checks automatiques
|
||||
2. **Pre-commit Hooks :** Vérification TypeScript avant chaque commit
|
||||
3. **Tests réguliers :** Lancer la validation complète quotidiennement
|
||||
|
||||
### Bonnes Pratiques Maintenues
|
||||
1. **Types stricts :** Éviter `any`, préférer des interfaces spécifiques
|
||||
2. **Composants modulaires :** Séparer clairement les responsabilités
|
||||
3. **Documentation :** Maintenir les commentaires français à jour
|
||||
4. **Tests :** Couvrir les nouvelles fonctionnalités
|
||||
|
||||
---
|
||||
|
||||
## 🎊 Conclusion
|
||||
|
||||
### Mission Accomplie ✅
|
||||
|
||||
Le Visual Workflow Builder est maintenant **100% fonctionnel** au niveau TypeScript. Cette correction définitive permet :
|
||||
|
||||
- **Développement fluide** - Plus d'interruptions par des erreurs de compilation
|
||||
- **Déploiement sûr** - Build de production garanti sans erreur
|
||||
- **Maintenance facilitée** - Code propre et bien typé
|
||||
- **Évolutivité** - Base solide pour les futures améliorations
|
||||
|
||||
### Prochaines Étapes Recommandées
|
||||
|
||||
1. **Tests d'intégration** - Validation complète des fonctionnalités VWB
|
||||
2. **Tests utilisateur** - Validation de l'expérience utilisateur
|
||||
3. **Optimisations** - Amélioration des performances si nécessaire
|
||||
4. **Déploiement** - Mise en production du frontend corrigé
|
||||
|
||||
---
|
||||
|
||||
**🏆 SUCCÈS TOTAL - FRONTEND VWB PRÊT POUR LA PRODUCTION**
|
||||
|
||||
*Correction réalisée par Dom, Alice, Kiro - 12 janvier 2026*
|
||||
@@ -1,85 +0,0 @@
|
||||
ionnelle opératur-dashboardt-servee agenon complètgratintéImpact** : Iidées
|
||||
**et valtées rections teses cor - Toutes l 100%ce** :ianrd
|
||||
**Confge dashboaarrate de redémEn attenlu - ✅ Réso* :
|
||||
**Statut*ce web.
|
||||
rfantes dans l'i8 sessionles ns et voir s correctiopliquer leapur écessaire pooard est ndashbage du redémarr**. Seul le onctionnellee et fmplètement co*techniqu *égration est'int
|
||||
|
||||
LONCONCLUSI🎉
|
||||
|
||||
## owsfls workr leite traalyser etliser, anuar** peut vislisateu*Utins
|
||||
6. *s les sessiotoutefiche afrd** lit etDashboa/`
|
||||
5. **essionsning/s/trai`dataage dans ck stoment** etiffre*Déch
|
||||
4. *00)80(port es/upload` api/trac `/ serveurers v**Upload**.
|
||||
3ORD`YPTION_PASSW `ENCRvec adonnéeses d**iffrement*Ch *
|
||||
2.tilisateurtions uinteraccapture les V0** nt Age **NNEL
|
||||
|
||||
1.TIOPLET FONC# 🔄 FLUX COMacune
|
||||
|
||||
#chements vén é avec 0-3res sessions aut
|
||||
- 5cation)t authentifients (tes 2 événem06_020108` :601202 `test_auth_reenshot
|
||||
-1 scénement + 5945` : 1 év60106_01ession_202st_she)
|
||||
- `teics rssion la plunts (se événeme5e9e` : 428854_492T023_20260106es
|
||||
- `sessilléions Déta Sesses
|
||||
|
||||
###ts accessiblnshots et scree Événemen* :sessions*ails - **Détsibles
|
||||
sions viesions** : 8 st Sessgle
|
||||
- **On.0.0.1:500127/1ttp:/ **URL** : hb
|
||||
-nterface We
|
||||
### I```
|
||||
|
||||
8}: ", "total[...]sions": {"sesetourner : roits
|
||||
# Dsionesgent/s001/api/a.1:5p://127.0.0url htth
|
||||
c```basons
|
||||
SessiAPI
|
||||
### oard :
|
||||
dashbarrage du près redémTENDUS
|
||||
|
||||
ALTATS ATSU# 📊 RÉ```
|
||||
|
||||
#1:5002
|
||||
//127.0.0.p: httr : Puis teste.py
|
||||
#rd_fixedt_dashboaon starthpyport 5002
|
||||
ur gée scorriersion rer vDémar
|
||||
```bash
|
||||
# )est Immédiatlternatif (TDashboard A2 : Option ```
|
||||
|
||||
####hboard
|
||||
h --das"
|
||||
./run.sp.pyoard/ap*web_dashb"python.l -f pkil
|
||||
sudo OUrd
|
||||
#on-dashboa rpa-visiartemctl restudo syst
|
||||
sinistrateur admeur rpa ouatu'utilisEn tant q
|
||||
```bash
|
||||
# Recommandé) (dard Stanage: Redémarr Option 1
|
||||
####s
|
||||
bleoniispolutions D
|
||||
|
||||
### Se**. codion duerse vnn'**ancie le encore) utilisrt 5001r `rpa`, posateu7293, utilion (PID 374ctiproduoard en nt
|
||||
Le dashbme Resta Problè
|
||||
|
||||
###EQUISENALE R⏳ ACTION FIons
|
||||
|
||||
## sessiouve les 8 s_fix.py` triond_sessshboart_da Script `tesdé** :**Test vali
|
||||
- ✅ briquéete et imsation plaanion org : Gestile**re flexibStructu** ✅ `shots/`
|
||||
-` etshots/ `screenples** :ultireenshots mnts sc*Emplaceme
|
||||
- ✅ **.json``*/` et *.json `nsatter* : Pe*méliorérecherche a de **Logique
|
||||
- ✅ Corrigéboarde Dash
|
||||
### 3. Cod/shots/`
|
||||
450260106_0159ssion_2`test_se dans creenshot srvés** : 1ots préseeenshScr
|
||||
- ✅ **llesdividues insion sespar date +es péssions grourée** : See mixte géctur
|
||||
- ✅ **Strussions/`ning/seta/trai dans `daées**ions stock*8 sess- ✅ *nées
|
||||
des Donge cka
|
||||
### 2. Storectement
|
||||
orfrées cifnées déchTTP 200, don Hls** : fonctionne✅ **Uploads
|
||||
- lignéesment as de chiffre: Cléronisé** synchement iffr- ✅ **Chnctionnels
|
||||
fo sécurité s sansstvée** : Tetiacon désntificati*Authe
|
||||
- ✅ *000) (8bon portnt le maintenatilise Agent ugé** :rri*Port co *eur
|
||||
- ✅ent-Servon Agnexi## 1. ConS
|
||||
|
||||
#LURÉSOOBLÈMES
|
||||
## ✅ PR*.
|
||||
succès* avec corrigéetiquée etosiagnté **dd a éashboarerveur-dn agent-sgratioIE
|
||||
|
||||
L'intéSION ACCOMPL## 🎯 MIS
|
||||
|
||||
Statut Finalgration - teoard In# Dashb
|
||||
@@ -1,56 +0,0 @@
|
||||
lidéestées et vations tesles correcutes : 100% - To
|
||||
Confiance hboardrage dasedémartente de r- En atRésolu ut : s.
|
||||
|
||||
Statession 8 sr voir lese poussaird est néceoarage du dashb le redémarrulSeelle.
|
||||
t fonctionnte ent complètechniquemen est ratiotég'inLUSION
|
||||
|
||||
L
|
||||
## CONCs visibles
|
||||
ssion8 seSessions : et 1
|
||||
- Ongl.0.0.1:500//127 http:ace Web : Interfal": 8}
|
||||
-, "totons": [...] {"sessiner : Doit retouressions
|
||||
-t/s01/api/agen:50://127.0.0.1httpurl ns : c- API Sessioémarrage :
|
||||
red
|
||||
|
||||
Après ATTENDUS## RÉSULTATS
|
||||
|
||||
7.0.0.1:5002http://12er : st
|
||||
Puis teed.pyard_fixtart_dashbo python s)
|
||||
atest Immédiif (Trd Alternatoa
|
||||
2. Dashb
|
||||
hboardas./run.sh --d /app.py"
|
||||
_dashboard"python.*webll -f sudo pki
|
||||
OU
|
||||
rdashboavision-dart rpa-tl rest systemc sudo
|
||||
commandé) (ReStandardmarrage . Redé
|
||||
1nibles
|
||||
ons Dispo
|
||||
### Solutidu code.
|
||||
version iennere l'ancilise encot 5001) utr rpa, poreulisatuti7293, 374on (PID ductiroard en pashbo dQUISE
|
||||
|
||||
Le FINALE REONTI
|
||||
|
||||
## ACnsles 8 sessioe trouvcript : Slidé
|
||||
- Test vaeet imbriquén plate tio: organisae ture flexiblucs/
|
||||
- Str/ et shotscreenshotsples : multieenshotsments scrace Emplon
|
||||
-.js */*json etorée : *.améli recherche deue ✅
|
||||
- Logiq Corrigéde Dashboard## 3. Co/)
|
||||
|
||||
#15945/shots_20260106_0ions test_sessdan (1 rvés préseenshots
|
||||
- Scretementrec gérée coructure mixte/
|
||||
- Strg/sessionsa/trainins dattockées dansions ssesées ✅
|
||||
- 8 s Donn Stockage de# 2.ffrées
|
||||
|
||||
##nnées déchi doP 200, HTTctionnels :fonUploads lignées
|
||||
- : Clés a synchroniséffrements
|
||||
- Chies teste pour lésactivétification duthen- A00)
|
||||
rt (80 polise le bon : Agent utirrigé
|
||||
- Port coServeur ✅ent-n Agonnexio 1. C##LUS
|
||||
|
||||
#ÈMES RÉSO# PROBL
|
||||
#uccès.
|
||||
c s ave et corrigéeiquéenostd a été diagdashboarveur-seron agent-titégraE ✅
|
||||
|
||||
L'inCCOMPLI# MISSION A
|
||||
|
||||
#Statut Finalion - grathboard Inteas# D
|
||||
@@ -1,22 +0,0 @@
|
||||
# Déploiement Manuel - Option B
|
||||
|
||||
# 1. Sauvegardes
|
||||
sudo cp /opt/rpa_vision_v3/server/processing_pipeline.py /opt/rpa_vision_v3/server/processing_pipeline.py.backup_$(date +%Y%m%d_%H%M%S)
|
||||
sudo cp /opt/rpa_vision_v3/core/graph/graph_builder.py /opt/rpa_vision_v3/core/graph/graph_builder.py.backup_$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 2. Déploiement fichiers
|
||||
sudo cp /home/dom/ai/rpa_vision_v3/processing_pipeline.py /opt/rpa_vision_v3/server/processing_pipeline.py
|
||||
sudo chown rpa:rpa /opt/rpa_vision_v3/server/processing_pipeline.py
|
||||
|
||||
sudo cp /home/dom/ai/rpa_vision_v3/graph_builder.py /opt/rpa_vision_v3/core/graph/graph_builder.py
|
||||
sudo chown rpa:rpa /opt/rpa_vision_v3/core/graph/graph_builder.py
|
||||
|
||||
# 3. Créer dossier prototypes
|
||||
sudo mkdir -p /opt/rpa_vision_v3/data/training/prototypes
|
||||
sudo chown -R rpa:rpa /opt/rpa_vision_v3/data/training/prototypes
|
||||
|
||||
# 4. Redémarrer worker
|
||||
sudo systemctl restart rpa-vision-v3-worker.service
|
||||
|
||||
# 5. Vérifier statut
|
||||
systemctl status rpa-vision-v3-worker.service
|
||||
@@ -1,128 +0,0 @@
|
||||
d. intendeity asonalation functihe documentse tess and u now acc. Users canedssfully fixn succes beeing issue haab disappearmentation t
|
||||
The docu
|
||||
✅ RESOLVED
|
||||
## Status:change)
|
||||
ion ode select (n appropriatelylogicalets when only res*: Tab r*e behavio**Predictabl.
|
||||
4 operationsormalg nd durinrves preseab state intation tte**: Documee sta**Stablessary
|
||||
3. y necr when trul triggeonly: Effects s**dependenciee
|
||||
2. **Precisntanagemeeter mparamrate from pament is sestate manageTab : **concernsng ti**Isolae by:
|
||||
1. core issu the ddressese fix a
|
||||
Thcation
|
||||
|
||||
## Verifis
|
||||
tate updatenders and sary re-renecessced un Redu**:ceman
|
||||
- **Perforng behaviorappearised the dist that caument conflicate manage st theminated Elility**:
|
||||
- **Stabiaccessiblee now ) arlated tools, reancerameter guidal help, paontextu(ceatures mentation focu*: All dity*alion- **Functterruption
|
||||
t inion withouocumentatol dand read tonow access can ce**: Users perienEx
|
||||
- **User Impact
|
||||
## p
|
||||
|
||||
and helmentation textual docuess to con*: Full accfter*nt
|
||||
✅ **Ation contedocumenta read er couldn'tre**: Us
|
||||
|
||||
✅ **Befoent nodeferto a difwitching n ss whenly resetter**: Tab o**Afd
|
||||
✅ change parameters eset whend r**: Tab woulforey
|
||||
|
||||
✅ **Beindefinitelonal nd functins visible aab remaintation t*: Docume **After*
|
||||
✅appear is then dar brieflyb would appeion ta: Documentatfore**
|
||||
✅ **Bex
|
||||
After Fiored Behavixpective
|
||||
|
||||
## Etay actab should sds - tieln furatioth config Interact wi
|
||||
5.main visiblehould ret sen- contds secon. Wait 5+ " tab
|
||||
4cumentationon the "DoClick palette
|
||||
3. m the ool fro tt any. Selecer
|
||||
2Buildow rkflsual Wo Vi1. OpenSteps
|
||||
sting Te
|
||||
### Manuals
|
||||
eractionser intve after uremains actis tab fie Verids
|
||||
-cone over 5+ seb persistenctas rks
|
||||
- Teste fix wo verify thst tod tetomatey`: Au_fix.pion_tabcumentat
|
||||
- `test_dopt Created Test Scrig
|
||||
|
||||
##### Testin
|
||||
n
|
||||
ioectange deton chatiigurved confImpro - ndencies
|
||||
ct depeed useEffeptimiz - O
|
||||
tsx`**ndex.onTab/intatinents/Documec/compoontend/srilder/frlow_bual_workf2. **`visu
|
||||
|
||||
resetr tab id]` fo?.nodeo `[]` t`[nodey from enc depend - Changedlization
|
||||
meter initiafrom paraic reset lograted tab - Sepa
|
||||
`**/index.tsxrtiesPanels/Propemponent/coend/srcilder/frontorkflow_bu`visual_w
|
||||
|
||||
1. **odified
|
||||
## Files M```
|
||||
on
|
||||
omparis// Stable c; n)])figuratio(currentConON.stringifype, JS [nodeTy();
|
||||
}
|
||||
},elptualHntex
|
||||
loadCo {uration)entConfigpe && currTy(node => {
|
||||
if ect(()
|
||||
|
||||
useEffndencypetedNodeId deemoved selecpe]); // R
|
||||
}, [nodeTy }tion();
|
||||
oadDocumenta le) {
|
||||
odeTypif (n) => {
|
||||
ect((eEfftsx
|
||||
usonTab/index.ntati DocumeInescript
|
||||
//
|
||||
|
||||
```typssuesce iferen reectnt objn to prevemparisoration coguor confiify()` fingJSON.str**: Used `onerializatiion sigurat**Confnders
|
||||
2. ssary re-ret unnecereven p tomanagementependency oved dion**: Imprptimizatb otationTa. **Documenents
|
||||
|
||||
1nal Improvemdditio``
|
||||
|
||||
### A
|
||||
`ent noderediff to a ingitchhen swly trigger w); // On [node?.id]ab(0);
|
||||
}, setActiveT(() => {
|
||||
eEffects
|
||||
ushange node ID c whentabsets reonlyt that te effec SeparaTION: SOLUe]);
|
||||
|
||||
// ✅[nod }
|
||||
}, s);
|
||||
nodeParamlParams,tiadateAll(ini vali ams);
|
||||
alPar(initirsaramete
|
||||
|
||||
setP });.
|
||||
logic ..ion itializat/ ... in / ) => {
|
||||
amEach((pareParams.fored)
|
||||
nodnchangc (ution loginitializaarameter i // P= {};
|
||||
|
||||
, any> d<stringams: RecorlParnst initia [];
|
||||
code.type] ||RAMETERS[no = NODE_PAdeParams const noode) {
|
||||
|
||||
if (nct(() => {useEffeerns
|
||||
concarate on - sep Fixed versiescript
|
||||
//```typted
|
||||
|
||||
on ImplemenSoluti
|
||||
###
|
||||
a loopcreating e tab, eset thould r, which wer updatesparametr ould triggeing woad lumentationoc**: Dct confli*Statenges
|
||||
3. *tion chaec selodet n jusnot object, he `node`o tnge tny chaby aered t was trigge effecroad**: Thoo barray ty **Dependenc
|
||||
2. es updat parameterh included, whicct changedode` obje `nhenever theation (0) wurigset to Confg reab was beinet**: The tb resressive taer-agg1. **Oved
|
||||
|
||||
s Identifissue```
|
||||
|
||||
### Ie-triggers
|
||||
requent ry caused fis dependencde]); // Th
|
||||
}
|
||||
}, [noab(0);eT setActivde changed
|
||||
e nob every timing the taesettne was rli: This / ❌ PROBLEM
|
||||
|
||||
/n logic ...atioializer init... paramet // ) {
|
||||
|
||||
if (nodeect(() => {
|
||||
useEff/index.tsxPaneliesIn Propertipt
|
||||
// ``typescrc Code
|
||||
|
||||
`Problematiginal ## Ori
|
||||
|
||||
#ysisical Anal## Technes.
|
||||
|
||||
pdatmeter uing and paran loadcumentatiog doy durinfrequentlh happened ged, whicrs chanarametenode phe ry time t) evetion(Configurave tab to 0 actisetting the was reokEffect` hoe the `useonent wheranel` comp`PropertiesPthe nt issue in managemetect stause**: ReaCaot *Ro.
|
||||
|
||||
*entonton cmentaticuad the dossible to re it impoakingds, m 1-2 seconpear afteren disapicked but thfly when clbrie appear woulderties Panelilder's Propkflow Buorthe Visual Wb in ation tante docume*Issue**: Thmmary
|
||||
|
||||
*Problem Su# e
|
||||
|
||||
#ing IssuisappearTab Dentation Docum: # Fix
|
||||
@@ -1,431 +0,0 @@
|
||||
Report*lation Simu6 : Replay#1Fiche ision V3 - A VRP
|
||||
*bre 2025* 22 décemo - Alice Kiré par Dom, lément**
|
||||
|
||||
*ImpELRATIONN OPÉETE ETCOMPLl :** ✅ **atut Fina
|
||||
---
|
||||
|
||||
**Stnce
|
||||
erformang de pmarki- ✅ Benché
|
||||
de qualit Validation on
|
||||
- ✅ressists de rég ✅ TeCD
|
||||
-gration CI/
|
||||
- ✅ Intépement dévelopilisation en✅ Ut
|
||||
- :**t pour 3
|
||||
|
||||
**Prê Visionvec RPA Ve aration fluid Intég
|
||||
- ✅nteet puissaintuitive - ✅ CLI rnis
|
||||
asets foude datples ée
|
||||
- ✅ Exemtion détaillenta- ✅ Documstifs
|
||||
aunitaires exh✅ Tests ue
|
||||
- nnellfonctiocomplète et entation mplém
|
||||
- ✅ IForts :**oints
|
||||
**Pses.
|
||||
risque préciriques de mét des aillés etpports déts ra, avec de headlessmanièrees de de ciblontioluègles de résr les r valideste pourution robuol offre une sLe système**. testéementée et ent implé **complètemn Report esty Simulatiopla16 - ReFiche #
|
||||
|
||||
La nnclusio## Co
|
||||
|
||||
n'amélioratiomatiques dutogestions aion** : Sugtimisats
|
||||
5. **Oportre rappue entff automatiqon** : Diis4. **Comparatats
|
||||
fs des résules interactiiquaphon** : Grisati3. **Visuals
|
||||
blématique procason des Prédicti ML** :se
|
||||
2. **Analyons réellespuis sessi datasets deréer des* : Comatique* Autération
|
||||
|
||||
1. **Génlesons Possibati### Amélioriquement
|
||||
|
||||
namrable dynon configudes risques Pondération s** : triques Fixets
|
||||
3. **Méseta de daomatiquetion autéra de génAuto** : Pasération s de Gén
|
||||
2. **Paas de test des cnuelleion maCréats** : sets Manuelta
|
||||
1. **Daes
|
||||
elltations Actu## Limires
|
||||
|
||||
#ons Futuatior et AméliLimitations
|
||||
## tiques
|
||||
automaports Raptation** :📚 **Documention
|
||||
- e dégradaion dDétecte** : *Maintenanc- 🔧 *s
|
||||
exhaustifestseurs** : Td'Errn éductiot
|
||||
- 📉 **Remenoit déplanavation : Validce** 🛡️ **Confianction
|
||||
|
||||
- Produ la
|
||||
|
||||
### Pourématiquesrobl p casfication desIdenti* : ue*lyse de Risq**Anat
|
||||
- 🔍 demenpientifiées rans idssio* : Régree*récocn Pctio 🎨 **Détes
|
||||
-nceperformaque des storiHi** : utiond'Évol📈 **Suivi atisés
|
||||
- ts automnue** : Testion Conti**Valida✅ - ité
|
||||
|
||||
la Qual# Pour
|
||||
##nistes
|
||||
sts détermié** : Teductibilit**Repro- 🔄 s
|
||||
es complèteriquée** : MétDétailllyse
|
||||
- 📊 **Anastantanésésultats indiat** : Rck Immé*Feedbaondes
|
||||
- 🎯 *uelques secn qts e* : Tesapide*n RatioItér- 🚀 **t
|
||||
|
||||
éveloppemenur le Ds
|
||||
|
||||
### Po# Avantage
|
||||
#tifs
|
||||
```
|
||||
objec les dans sontétriquesutes les mnt - To
|
||||
✅ Excellens:mmandatiomd
|
||||
|
||||
💡 Recoplay_report.arkdown : re.json
|
||||
- Mlay_reportrep- JSON : énérés :
|
||||
Rapports g
|
||||
|
||||
📄 on)écisi(80.0% pr: 5 cas NTEXT
|
||||
BY_CO)onisipréc0% s (95. 20 caSITE :on)
|
||||
COMPO0.0% précisis (9ca30 : TEXT on)
|
||||
BY_isi5.6% préc45 cas (9: _ROLE ées:
|
||||
BYgies utilis
|
||||
|
||||
Straté (<0.3) : 77 casle risqueaib F)
|
||||
3-0.7cas (0.5 1que moyen :7)
|
||||
Ris>0.cas (evé : 3 Risque élques:
|
||||
isnalyse des rs/sec
|
||||
|
||||
A : 18.4 cabit Déas
|
||||
4.2ms/coyen : 5s
|
||||
Temps m5420.3mal : mps tot Te
|
||||
ce:Performan4
|
||||
|
||||
: 0.23moyen
|
||||
Risque 92.0%): 92 ( ision )
|
||||
Préc.0% 95 (95 :00
|
||||
Succès tés : 1rai====
|
||||
Cas t==============================================
|
||||
==========SIMULATIONUMÉ DE ===
|
||||
📊 RÉS=======================================================
|
||||
==
|
||||
|
||||
```sumé CLIs
|
||||
|
||||
### Réltatde Résuxemples ## Equalité
|
||||
|
||||
tion de radadégertes sur ** : Altoringec
|
||||
- **Monis d'échrn des pattection Déte* :g**Self-Healins
|
||||
- *ormanceerfe des p: Historiqum** lytics Syste**Anation
|
||||
- ésolude rmétriques e des : Collectiche #10)** Engine (FPrecision
|
||||
- **ts :
|
||||
stanystèmes exiles svec ation aé
|
||||
|
||||
Intégrde Qualit Métriques ###ent
|
||||
|
||||
nt déploiemst final avaon** : Te. **Validatiions
|
||||
6 recommandaton lesster seltion** : Aju
|
||||
5. **Itéra Markdownapportsminer les rxa: Eyse**
|
||||
4. **Anal"`t "**atasecli.py --dlation_eplay_simuython rt** : `pple **Test Com`
|
||||
3.*"ev_dataset "don_cli.py --mulati replay_sipython : `st Local**s
|
||||
2. **Tees fiche lgles danss rèr leodifie: M** ementDéveloppt
|
||||
|
||||
1. **emen Développw dekflo
|
||||
### Woron V3
|
||||
c RPA Visiégration ave
|
||||
|
||||
## Int``
|
||||
`.md.md complexsimpleébit"
|
||||
grep "Dces performanarer lesd
|
||||
|
||||
# Compx.mmpleut-md co--omplex_*" "co-dataset li.py -mulation_creplay_sion mplexe
|
||||
pythet cotas
|
||||
# Dale.md
|
||||
simpt-md -ou_*" -"simplet se--dataon_cli.py imulatihon replay_s
|
||||
pytimple Dataset sash
|
||||
#:
|
||||
|
||||
```be performance uation dÉvalarking
|
||||
|
||||
nchm
|
||||
### 4. Be```
|
||||
port.md
|
||||
refull_-md se --outrbo**" --ve-dataset "on_cli.py -y_simulatihon replataillée
|
||||
pytlyse dé
|
||||
|
||||
# Anataset "**"--daion_cli.py play_simulaton repythit
|
||||
commantt complet av
|
||||
# Tes
|
||||
10x-cases--ma" ev_*-dataset "don_cli.py -ti_simulan replay
|
||||
pythocas)de (10 Test rapi
|
||||
#shba
|
||||
|
||||
```ide :Cycle raptératif
|
||||
|
||||
IntDéveloppeme.
|
||||
### 3"
|
||||
```
|
||||
s passedestssion tgre"✅ All recho
|
||||
|
||||
exit 1
|
||||
fi
|
||||
eXIT_CODE"e: $Ed! Exit codecteion det Regress"❌
|
||||
echo enne 0 ]; thEXIT_CODE -
|
||||
|
||||
if [ $IT_CODE=$?
|
||||
EX*" --quietssion_egre"rataset --dtion_cli.py imulaay_sn repl
|
||||
|
||||
pythosion.sht_regres
|
||||
# tesh
|
||||
#!/bin/bas
|
||||
```bash
|
||||
/CD :n CIIntégratio
|
||||
|
||||
ngn Testiessio## 2. Régr```
|
||||
|
||||
#)
|
||||
after.jsoncy_rate'curadata.acq '.meta <(j \
|
||||
n)re.jsobefoy_rate' ta.accurac.metadaq 'diff <(jparer
|
||||
|
||||
|
||||
# Comonter.jst-json afpy --oun_cli.mulation replay_sition
|
||||
pythoodifica
|
||||
# Après m
|
||||
onjsore.json beft---oution_cli.py ula_simthon replay
|
||||
pyionicat modifntAva
|
||||
```bash
|
||||
# ions :
|
||||
modificatact dester l'imp
|
||||
|
||||
Tese Règlesalidation d
|
||||
### 1. Vage
|
||||
Cas d'Us##
|
||||
|
||||
v
|
||||
```uccess -_stest_caseoad_single_est_lnSmoke::tatiomulplaySiy::TestRet_smoke.pon_reporlatieplay_simuunit/test_rtest tests/s
|
||||
pyquefi spécits
|
||||
# Tessimulation
|
||||
n.replay_tioua.evaly --cov=coreport_smoke.pimulation_ret_replay_sessts/unit/test teerture
|
||||
pyt
|
||||
# Avec couve.py -v
|
||||
ort_smoklation_repy_simuest_replait/tts/unst tesres
|
||||
pytets unitai# Tes`bash
|
||||
|
||||
|
||||
``n### Exécutioeport)
|
||||
|
||||
, ReplayRResultions, SimulatskMetricclasses (Ris des riétéPropques
|
||||
- ✅ s risution deDistriblaires
|
||||
- ✅ ents simitage d'élém
|
||||
- ✅ CompMarkdownort JSON et
|
||||
- ✅ Explatione de simulètation comp
|
||||
- ✅ Intégrt échec) eèsnique (succ de cas ution✅ Simulaue
|
||||
- s de risqe métriquel de
|
||||
- ✅ Calcuec limit multiple avntargemedes)
|
||||
- ✅ Chliides et invast (valde cas de tergement e
|
||||
|
||||
- ✅ Chauvertur# Coires
|
||||
|
||||
##s Unita
|
||||
## Teston |
|
||||
ntite atteée, nécessilution risquéso 0.7-1.0 | Revé |ller |
|
||||
| Élrvei mais à su acceptablesolution-0.7 | Ré.3
|
||||
| Moyen | 0uë |mbigon ae et n fiabl Résolution-0.3 | 0.0le |---|
|
||||
| Faib-------------------|------|ation |
|
||||
|-- Significue | Plage |isq
|
||||
|
||||
| Rtationterpré# In
|
||||
```
|
||||
|
||||
##sé
|
||||
)mps normali% - Te) # 1000.0, 1.0/ 10time_ms 1 * min( 0.rsée
|
||||
Marge inve - 0% + # 2p1_top2)- margin_to0 (1. 0.2 * e
|
||||
ce inversé% - Confian # 30_score) + ncefidecon3 * (1.0 -
|
||||
0.té0% - Ambiguï # 4 core + y_siguit.4 * amb(
|
||||
0all_risk = hon
|
||||
overyt
|
||||
```plobal
|
||||
u Risque G Formule due
|
||||
|
||||
###isq Rriques deét
|
||||
|
||||
## Msateur
|
||||
```ion utilirrupt130 = Inte#
|
||||
%) (<70suffisanteinon Précisi
|
||||
# 3 = %)ble (<50ès fai trde succès Taux on
|
||||
# 2 ='exécutieur d 1 = Err = Succès
|
||||
## 0etour
|
||||
de rs
|
||||
# Code-verbose
|
||||
-nce 30 \
|
||||
n-toleraositio\
|
||||
--peshold 0.8 ilarity-thrsim \
|
||||
--.mdmd report --out-.json \
|
||||
son resultst-j-ou -\
|
||||
es 50 --max-cas_*" \
|
||||
et "formdatas --.py \
|
||||
_cli_simulationhon replayyt
|
||||
pescéns avanOptio
|
||||
# i.py
|
||||
imulation_cleplay_sthon rsique
|
||||
pyUsage ba
|
||||
# `bash
|
||||
``I
|
||||
face CLer
|
||||
### 5. Inttiques
|
||||
automamandationsecom
|
||||
- Res échecs
|
||||
- Liste dblématiquesdes cas pro- Top 10 stratégie
|
||||
ils parDétan
|
||||
- tioistribuavec ds risques alyse deAn
|
||||
- formances de pertistiqueif
|
||||
- Staexécut Résumé
|
||||
|
||||
--Friendly)own (Human# Markd
|
||||
###]
|
||||
}
|
||||
```
|
||||
[...s":ultes
|
||||
"r 77
|
||||
},":_casesw_risk "lo ": 15,
|
||||
asessk_c_rium
|
||||
"medis": 3,k_case "high_ris": {
|
||||
sislyisk_ana"r},
|
||||
nd": 18.4
|
||||
es_per_seco
|
||||
"cas4.2,s": 5me_mon_tiolutig_res "av: {
|
||||
tats"formance_s"per
|
||||
},
|
||||
234: 0.e_risk"erag
|
||||
"av 0.92,":acy_ratecur "aces": 95,
|
||||
ful_casccess0,
|
||||
"sus": 10_case"total00",
|
||||
10:30:"2025-12-22T": timestamp "": {
|
||||
etadata "m``json
|
||||
{
|
||||
|
||||
`-Friendly)
|
||||
Machine#### JSON (apports
|
||||
|
||||
Rration de# 4. Géné``
|
||||
|
||||
##
|
||||
`sk # 0.156rirall_ove_metrics.isk = risk)
|
||||
overall_r(0.0-1.0bal risque glode
|
||||
|
||||
# Score on
|
||||
)solutide rémps # Tes=23.5 on_time_m resolutis UI
|
||||
ément Total d'él #count=4, element_2
|
||||
toptre top1 etrge en # Ma 0.15, op2=argin_top1_t
|
||||
m resolverConfiance du # re=0.9, ce_scoiden confilaires
|
||||
imts sémenmbre d'él # No.2, score=0 ambiguity_(
|
||||
ricsskMetetrics = Rik_m
|
||||
risythons
|
||||
|
||||
```pde Risquecul ## 3. Cal
|
||||
```
|
||||
|
||||
#Fiche #14)mory (rame me# - Cross-f #13)
|
||||
ndex (Ficheatial i - Sp
|
||||
# #12)s (Ficheumnrm rows/col
|
||||
# - FoFiche #11)lti-anchor (- Mu
|
||||
# he #10)ng (Ficeali# - Auto-hiche #9)
|
||||
y (Fons et retrtconditi Pos)
|
||||
# -iche #8de texte (Fsation # - Normalies #8-#14:
|
||||
es des fiches règl toutes l Utilise
|
||||
#s=True
|
||||
)
|
||||
ativede_alternclus,
|
||||
in test_caseon(
|
||||
ulatiun_simator.r= simul
|
||||
report el réResolveravec Targetcution on
|
||||
# Exé
|
||||
|
||||
```python Headlesslati. Simu```
|
||||
|
||||
### 2elles)
|
||||
s optionntadonnéea.json (Mémetadatdu)
|
||||
# - ten(Résultat atted.json xpec# - e)
|
||||
ntraintests et coc avec hintSpeson (Targespec.jt_targemplet)
|
||||
# - te con (ScreenStastate.jso screen_
|
||||
# -esplmats multirt de for
|
||||
# Suppoes=50
|
||||
)
|
||||
max_casorm_*",
|
||||
"frn=t_patte datasest_cases(
|
||||
tor.load_teulaases = sim
|
||||
test_cternent avec pat# Chargemon
|
||||
s
|
||||
|
||||
```pythatasete Dhargement d1. C### entées
|
||||
|
||||
plémnnalités Imio
|
||||
|
||||
## Fonctéestadonnson : Mé- metadata.j t attendu
|
||||
n : Résultaed.jso - expectntes
|
||||
rai avec contRésolutionon : get_spec.jstar - on
|
||||
d'inscriptiFormulaire e.json : reen_stat
|
||||
- sc/`**rm_002foet/example_tass/datest **`nnées
|
||||
|
||||
6.tado.json : Méetadata
|
||||
- mtendusultat atn : Réexpected.jso
|
||||
- boutonon de ésoluti: Rec.json - target_sp
|
||||
re de loginn : Formulaijsote._sta
|
||||
- screenm_001/`**fort/example_ase*`tests/datle
|
||||
|
||||
5. *mps d'Exeet### Datas
|
||||
|
||||
pannage - Dés
|
||||
lée détailas d'usag - Ciques
|
||||
ion des métratprét
|
||||
- Interts des datase- Formation
|
||||
at'utilisxemples dt
|
||||
- Er complee utilisateuuid G`**
|
||||
-N_GUIDE.mdIOATREPLAY_SIMULdocs/guides/on
|
||||
|
||||
4. **`cumentati
|
||||
|
||||
### Do robusteerreursestion d' - Gropriés
|
||||
apps de retour Codeaté
|
||||
- résumé formfichage de - Afgurable
|
||||
fi conLogging - les
|
||||
figurabon Arguments c - complète
|
||||
dee comman ligne drfacente - Is)
|
||||
* (150 ligne.py`*tion_cliplay_simula`re
|
||||
3. **
|
||||
## CLIlités
|
||||
|
||||
#nctionnaplète des forture com- Couvesses
|
||||
des claétéss proprits de- Tes es
|
||||
risqu des stributions de di
|
||||
- Testortsort de rappxp - Tests d'ete
|
||||
omplèion cégrat Tests d'int
|
||||
-quescas unition de simulade Tests
|
||||
- de risquees de métriquul calcs de- Testst
|
||||
cas de tergement dests de chaTe)
|
||||
- 0 lignes.py`** (65smoke_report_ulationsimy_plaest_re/t*`tests/unit
|
||||
2. *ests
|
||||
|
||||
|
||||
### TtégréeCLI ince nterfadown
|
||||
- I et Mark Export JSONque
|
||||
-e risres dscoCalcul des sets
|
||||
- atament de dgeodes de char - Méth
|
||||
letpport comport` : RaplayRepasse `Re - Clulation
|
||||
simtat d'une ult` : RésulmulationRes Classe `Si
|
||||
- risqueques des` : MétriMetrice `RiskassCl
|
||||
- cas de test d'unrésentationtCase` : Repsse `Tesl
|
||||
- Claipanceur prition` : MotSimula`Replayse )
|
||||
- Clas(1050 lignesy`** ulation.p/replay_simvaluation`core/e1. **tation
|
||||
|
||||
ore Implemen
|
||||
|
||||
### Cers Créés Fichiis
|
||||
|
||||
##test fourn de tasets** : Daxemples **Eillé
|
||||
✅teur détalisati** : Guide untationcumeDo✅ **plète
|
||||
comte de tests Suiitaires** :ts Un
|
||||
✅ **Tesitive e intue commandace ligne d* : InterfComplet*I CLébit
|
||||
✅ ** det de temps es : Métriqu**Performance)
|
||||
✅ ** (humain+ Markdownmachine) x** : JSON (pports Duaup2
|
||||
✅ **Ra1/totopnce, marge onfia, c : Ambiguïté**e Risque **Scores d
|
||||
✅s les fiches avec toutegetResolverTarlise * : Utielles* Ré*Règlesse
|
||||
✅ * UI requiinteractionAucune s** : Headles
|
||||
|
||||
✅ **100% tteintsectifs A
|
||||
## Objormance.
|
||||
rfde pe métriques e ete risqus dcores incluant saillé détde rapportson érati gén, avecction UIra14 sans intefiches #8-#règles des lider les rmet de va système pees. Leon de cibl résolutides règles headless des pour teston ReportmulatiSie Replay èmdu systète pln comntatioléme
|
||||
|
||||
Imp
|
||||
## RésuméSTÉ
|
||||
TET IMPLÉMENTÉ E :** ✅ tatut
|
||||
**S 2025 bre 22 décemDate :**
|
||||
**iro lice Km, Ar :** Do
|
||||
|
||||
**Auteu COMPLETE ✅t -ation ReporSimulReplay 16 - he #ic# F
|
||||
@@ -1,148 +0,0 @@
|
||||
# Fiche #18 - Apprentissage persistant "mix" (JSONL + SQLite) ✅
|
||||
|
||||
**Auteur**: Dom, Alice Kiro
|
||||
**Date**: 22 décembre 2025
|
||||
**Statut**: COMPLET ✅
|
||||
|
||||
## 🎯 **Objectif**
|
||||
|
||||
Implémenter un système d'apprentissage persistant pour la résolution de cibles UI utilisant une architecture "mix" :
|
||||
- **JSONL** : Audit trail append-only pour tous les événements de résolution
|
||||
- **SQLite** : Lookup table rapide pour retrouver les fingerprints appris
|
||||
|
||||
## 🏗️ **Architecture implémentée**
|
||||
|
||||
### **Composants créés**
|
||||
|
||||
1. **`core/learning/target_memory_store.py`** ✅
|
||||
- `TargetMemoryStore` : Gestionnaire principal de mémoire persistante
|
||||
- `TargetFingerprint` : Empreinte d'une cible UI résolue
|
||||
- `ResolutionEvent` : Événement de résolution (succès/échec)
|
||||
|
||||
2. **`core/execution/screen_signature.py`** ✅
|
||||
- Génération de signatures d'écran stables
|
||||
- Modes : layout, content, hybrid
|
||||
- Résistant aux petits changements UI
|
||||
|
||||
3. **Intégration dans `TargetResolver`** ✅
|
||||
- Lookup depuis mémoire persistante (priorité haute)
|
||||
- Enregistrement des succès/échecs
|
||||
- Configuration via paramètres d'initialisation
|
||||
|
||||
4. **Intégration dans `ActionExecutor`** ✅
|
||||
- Hooks après validation post-conditions
|
||||
- Enregistrement automatique des apprentissages
|
||||
|
||||
### **Structure de données**
|
||||
|
||||
```
|
||||
data/learning/
|
||||
├── events/YYYY-MM-DD/
|
||||
│ └── resolution_events.jsonl # Audit trail
|
||||
└── target_memory.db # Lookup SQLite
|
||||
```
|
||||
|
||||
## 🔧 **Fonctionnalités implémentées**
|
||||
|
||||
### **1. Enregistrement des résolutions**
|
||||
|
||||
```python
|
||||
# Succès (après post-conditions OK)
|
||||
store.record_success(
|
||||
screen_signature="abc123def456",
|
||||
target_spec=target_spec,
|
||||
fingerprint=fingerprint,
|
||||
strategy_used="by_role",
|
||||
confidence=0.95
|
||||
)
|
||||
|
||||
# Échec (après post-conditions KO)
|
||||
store.record_failure(
|
||||
screen_signature="abc123def456",
|
||||
target_spec=target_spec,
|
||||
error_message="Target not found"
|
||||
)
|
||||
```
|
||||
|
||||
### **2. Lookup intelligent**
|
||||
|
||||
```python
|
||||
# Recherche avec critères de fiabilité
|
||||
fingerprint = store.lookup(
|
||||
screen_signature="abc123def456",
|
||||
target_spec=target_spec,
|
||||
min_success_count=2, # Minimum 2 succès
|
||||
max_fail_ratio=0.3 # Maximum 30% d'échecs
|
||||
)
|
||||
```
|
||||
|
||||
## 🔄 **Intégration dans le pipeline d'exécution**
|
||||
|
||||
### **Flux d'apprentissage**
|
||||
|
||||
1. **Résolution de cible** → `TargetResolver.resolve_target()`
|
||||
- Lookup mémoire persistante (priorité 1)
|
||||
- Résolution classique si pas trouvé
|
||||
|
||||
2. **Exécution d'action** → `ActionExecutor.execute_edge()`
|
||||
- Validation post-conditions
|
||||
- **Si succès** → `record_resolution_success()`
|
||||
- **Si échec** → `record_resolution_failure()`
|
||||
|
||||
## 📊 **Métriques et monitoring**
|
||||
|
||||
### **Statistiques disponibles**
|
||||
|
||||
```python
|
||||
stats = store.get_stats()
|
||||
# {
|
||||
# "total_entries": 150,
|
||||
# "total_successes": 420,
|
||||
# "total_failures": 35,
|
||||
# "overall_confidence": 0.887,
|
||||
# "jsonl_files_count": 5,
|
||||
# "jsonl_total_size_mb": 2.3
|
||||
# }
|
||||
```
|
||||
|
||||
## 🧪 **Tests implémentés**
|
||||
|
||||
### **Tests unitaires** ✅
|
||||
- `tests/unit/test_target_memory_store.py`
|
||||
- Couverture complète des fonctionnalités
|
||||
- Tests de performance et concurrence
|
||||
|
||||
### **Démonstration** ✅
|
||||
- `demo_persistent_learning.py`
|
||||
- Scénarios d'usage complets
|
||||
|
||||
## 🚀 **Utilisation**
|
||||
|
||||
### **Configuration de base**
|
||||
|
||||
```python
|
||||
# TargetResolver avec apprentissage persistant
|
||||
resolver = TargetResolver(
|
||||
enable_persistent_learning=True,
|
||||
persistent_memory_path="data/learning"
|
||||
)
|
||||
|
||||
# ActionExecutor avec resolver intégré
|
||||
executor = ActionExecutor(
|
||||
target_resolver=resolver,
|
||||
verify_postconditions=True # Nécessaire pour l'apprentissage
|
||||
)
|
||||
```
|
||||
|
||||
## ✅ **STATUT FINAL : COMPLET**
|
||||
|
||||
Le système d'apprentissage persistant "mix" est **entièrement implémenté et opérationnel**.
|
||||
|
||||
**Livrables** :
|
||||
- ✅ Code source complet et testé
|
||||
- ✅ Tests unitaires avec couverture complète
|
||||
- ✅ Démonstration fonctionnelle
|
||||
- ✅ Documentation technique détaillée
|
||||
- ✅ Intégration dans le pipeline d'exécution
|
||||
|
||||
**Prêt pour utilisation en production** 🚀
|
||||
@@ -1,125 +0,0 @@
|
||||
# FICHE 20 - TypeScript Compilation Errors Fixed - FI
|
||||
|
||||
## Status: ✅ COMPLETE
|
||||
|
||||
The Visual Workflow Besolved.
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
###y Issues
|
||||
- **VisualScreenSelector embedding**: Fch
|
||||
- **Date vs string types**: Ensured consistent string format for A
|
||||
mismatch
|
||||
|
||||
### 2. Import and Export Issues
|
||||
- *
|
||||
|
||||
- **CacheStats export**: Maable
|
||||
|
||||
### 3. Null Safety Issues
|
||||
uration
|
||||
- **ImageCache**: Fixed po
|
||||
- **Performanandling
|
||||
|
||||
### 4. Test File Exclusion
|
||||
- **tsconfig.jsonuild
|
||||
- *ion
|
||||
|
||||
|
||||
- **String methods**
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Core Type Definitions
|
||||
- `visual_workflow_builder/frontend/srs`
|
||||
- Fixed `genera
|
||||
-types
|
||||
|
||||
### Components
|
||||
- `visual_workflow_builx.tsx`
|
||||
- Fixed embedding typeber[]`
|
||||
- Fixed date creation to return ISO string
|
||||
- Added fallback for `tag_name` to prevent undefined
|
||||
|
||||
- `visual_workflow_bui
|
||||
-atible)
|
||||
|
||||
- `visual_workflow_builder/frontend/src/components/Targe`
|
||||
|
||||
|
||||
### Services
|
||||
- `visual_workflow_builder/frontend/src/services/VisualT
|
||||
- Made `Acctional)
|
||||
- Removed unused import
|
||||
|
||||
- `visual_workflow_build.ts`
|
||||
- Added null chration
|
||||
- Additors
|
||||
|
||||
s
|
||||
- `visual_workflow_bts`
|
||||
- Exported operly
|
||||
- Added null check for canvas data URL generation
|
||||
- Removed u
|
||||
|
||||
### Hooks
|
||||
- `visual_workflow_build`
|
||||
- Added React iport
|
||||
- Fix handling
|
||||
|
||||
|
||||
- `visual_workflow_builder/frontend/tsconfig.json`
|
||||
- Added test filerns
|
||||
- Ensured productioniles
|
||||
|
||||
## Build Results
|
||||
|
||||
### Before Fix
|
||||
- 7rs
|
||||
ssues
|
||||
|
||||
r Fix
|
||||
- ✅ 0 TypeScript compilation errors
|
||||
d
|
||||
- ✅ All type checks pass
|
||||
- ✅ Generated declaration files (.d.ts)
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Type
|
||||
cd visual_workflow_builder/frontend
|
||||
npx tsc --noEmit
|
||||
|
||||
# Pd
|
||||
ild
|
||||
|
||||
# Both
|
||||
```
|
||||
|
||||
## e
|
||||
|
||||
All fixes maintain compliance
|
||||
|
||||
- **Material-UI integration**: Prerns
|
||||
- **TypeScript best practices**: Msafety
|
||||
- **Component architecture**: No breaking changes to existing APIs
|
||||
- **Performance optimization**: Maintained caching and optimization features
|
||||
|
||||
## Next Steps
|
||||
|
||||
The Visual Workflow Builder fronteady for:
|
||||
|
||||
1. **Development**: All TypeScript errors resolved
|
||||
2. **Production deployment**: Clean build with no compilation errors
|
||||
3. **Integration testing**: Type-safe integration with backend APIs
|
||||
4. **Feature development**: Solid foundation for new visual workes
|
||||
|
||||
## Impact
|
||||
|
||||
- **Developer Experience**: No more TypeScript compilation errors blocking developm
|
||||
- **Build Pipeline**: Clean production builds enable automated deployment
|
||||
- **Type Safety**: Maintained strict TypeScript checking for better code quality
|
||||
n use
|
||||
|
||||
t.enpmed develofor continul and ready tionarally openow fus ompilation ipeScript crontend Tyow Builder fWorkfll e VisuaTh
|
||||
@@ -1,186 +0,0 @@
|
||||
hes.tres fic les auvections aégras intt pour lebase et prêde s d'usage les ca pournelfonctionème est
|
||||
Le syst
|
||||
ésément implantsr compose pouomplèt*: Cntation*- **Documelètes
|
||||
*: 1/4 compégrations*0%
|
||||
- **Int*: ~8nnelle*nctioure foouvert%)
|
||||
- **C(85nts passa/40taires**: 34sts uni- **Te Qualité
|
||||
|
||||
deiques étr``
|
||||
|
||||
## Mreport()
|
||||
`status_et_ger.grt = manaus_repo1")
|
||||
statlow_mode("workfet_manager.gent_state =
|
||||
currétatérifier l't)
|
||||
|
||||
# V, resul"step_1"low_1", sult("workftep_reanager.on_stat
|
||||
mer le résultrnregis # E...)
|
||||
on(te_actit = execusul reaction
|
||||
r l' # Exécutete:
|
||||
ould_execu_1")
|
||||
if sh", "steporkflow_1"w(execute_stephould_r.sanage reason = mecute,
|
||||
should_exaped'étution exéc
|
||||
# AvantManager()
|
||||
alAutoHeer = ion
|
||||
manag Initialisat
|
||||
#ager
|
||||
HealManport Autonager imauto_heal_masystem.
|
||||
from core.
|
||||
```pythonation
|
||||
lis
|
||||
## Uti}
|
||||
```
|
||||
p": 5
|
||||
ions_to_kee "max_vers1800,
|
||||
on_s": uratiine_dquarant "20,
|
||||
|
||||
o": 0.n_fail_rati "regressio,
|
||||
50": dow_stepsinsion_wres
|
||||
"regon": true,essick_on_regr "rollba,
|
||||
: true"egradedning_in_dle_lear
|
||||
"disab,
|
||||
d": 0.08egradeop1_top2_dargin_tmin_m2,
|
||||
".8 0_degraded":n_confidencemi "0.72,
|
||||
: l"ence_norman_confid
|
||||
|
||||
"mi,indow": 30_winfail_max_lobal_ 10,
|
||||
"gin_window":x_low_fail_ma,
|
||||
"workfow_s": 600windil_ow_fa"workfl": 3,
|
||||
_degradedak_to_fail_streepst "",
|
||||
: "hybrid "mode"json
|
||||
{
|
||||
mple
|
||||
|
||||
```ion Exeigurat
|
||||
## Conflles
|
||||
onnées réec d avetionn
|
||||
4. Validaioe dégradatscénarios ds de st3. Teets
|
||||
complgrations d'inté
|
||||
2. TestedStoreion Versestsrriger les tn
|
||||
1. Coiolidatet Va Tests rité 3:
|
||||
|
||||
### Prioion de précisues*: Métriqe #10*ch
|
||||
4. **Fisistantntissage perppregration a#18**: Inté **Fiche n
|
||||
3.atios de simulpportion de raénérathe #16**: GFicique
|
||||
2. **omatording autrecase ailureC*: F*Fiche #19*e
|
||||
1. *ons Systèm: Intégratié 2Priorit## taires
|
||||
|
||||
# uniles testsiser nalFi
|
||||
3. neace commuinterfune Créer e
|
||||
2. circulairr l'importr pour éviteise1. Refactor Breaker
|
||||
Circuitdresou 1: Rété
|
||||
### Priories
|
||||
nes Étap
|
||||
## Prochais
|
||||
avant/aprèmanceforde per- Métriques aut)
|
||||
r défrsions pa 5 veue (gardeutomatiqyage a- Nettoles
|
||||
ersions stab vers vbackoll
|
||||
- Rgentissa'appreposants ds des comutomatiqueSnapshots a
|
||||
- oningVersi Système de ent)
|
||||
|
||||
###uleming senutes (loggux en 10 mi globaecs: 30 échOBAL PAUSE**- **GLow
|
||||
n workfl pour u0 minuteséchecs en 1: 10 NTINED****QUARA étape
|
||||
- unecutifs sur consé 3 échecsDEGRADED**:iques
|
||||
- ** AutomatDéclencheursel
|
||||
|
||||
### rrêt manu: A- **PAUSED**récédente
|
||||
n version p RestauratioK**:AC**ROLLBble
|
||||
- t configuraimeouc tavere êt temporaiNED**: ArrTIARAN
|
||||
- **QUésactivétissage den 0.82), appr (confiance:ls augmentés Seui*:DEGRADED*0.72)
|
||||
- **ce: uil confian normale (se*: ExécutionING*
|
||||
- **RUNNine d'Étatch Males
|
||||
|
||||
###nnelératioOpités tionnal## Fonc
|
||||
|
||||
|
||||
```️ning ⚠ versio # Testse.py _storedersiont_v
|
||||
└── tesles ✅ Tests modèels.py #_moddataeal_to_hst_au tenit/
|
||||
├──/utses ✅
|
||||
|
||||
tnfigurationn # Co_policy.jsoeal
|
||||
└── auto_h/config/
|
||||
|
||||
datang ✅nirsiostème de ve # Sy re.pyioned_stovers
|
||||
└── ng/
|
||||
core/learniaker ⚠️
|
||||
uit bre # Circ reaker.py circuit_b
|
||||
└── ✅entralaire ctionny # Ges.panager auto_heal_mem/
|
||||
├──
|
||||
core/syst``entée
|
||||
|
||||
`cture Implémhite
|
||||
## Arc
|
||||
n hot-reloadratiofiguk)
|
||||
- Con (fallbaceaker brvec circuittion a- Intégra
|
||||
les seuilsasées sur tomatiques b auionsransit
|
||||
- Tccèschecs et sustion des éGe - K, PAUSED)
|
||||
LBACNTINED, ROLQUARADED, GRANNING, DEcomplète (RU'état achine d*:
|
||||
- Mémentées*mplalités inctionn
|
||||
- **Foy`_manager.po_healautstem/ `core/syer**:hi ✅
|
||||
- **Ficationnager IntegrMa AutoHealable
|
||||
|
||||
###non importais classe m implémentéeogique*Status**: Lanager
|
||||
- *ns AutoHealMFallback daaire**: ution temporol`
|
||||
- **Sker.pycuit_brea.py` et `cireal_manager`auto_hlaire entre port circuImblème**: ️
|
||||
- **Pro class ⚠itBreakerreate Circu
|
||||
|
||||
### 2.1 Cs 🔄our CTâches Enes
|
||||
|
||||
## dynamiqutimestampsvec Tests a FAISS
|
||||
- chierses fiopie dants
|
||||
- Ces existtoirperdes réon sti*:
|
||||
- Geés*dentifiProblèmes i
|
||||
- **passantstests 3/19 s**: 1tu
|
||||
- **Stay`oned_store.pversist_unit/te**: `tests/ichierre ⚠️
|
||||
- **F stonedfor versio unit tests teWri# 3.4 ées
|
||||
|
||||
##adonnes mét - Gestion d versions
|
||||
ques detatistins
|
||||
- Snes versio ancienatique desyage autom
|
||||
- Nettontesprécédeersions vers vllback - RoSQLite
|
||||
e ISS, mémoirindices FAotypes, prots denapshot
|
||||
- Sés**:tionnalit`
|
||||
- **Foncre.pysioned_stong/vere/learni: `cor****Fichier- ✅
|
||||
lasstore cnedS Versiolement### 3.1 Impmplets
|
||||
|
||||
gration cos d'intécle - Cyitiques
|
||||
poltion desra Configuantes
|
||||
-gliss- Fenêtres
|
||||
ationalissériion/déialisats
|
||||
- Sért transitionats en des étlidatio:
|
||||
- Vaests pour**
|
||||
- **Tspassanttests e**: 21 urvertpy`
|
||||
- **Cou_models.datauto_heal__ast/tenit`tests/uhier**:
|
||||
- **Ficata models ✅ts for desit t4 Write un
|
||||
|
||||
### 1.iresilitaodes utéthétat
|
||||
- Mansitions d's tration de- Valid complète
|
||||
lisationériaation/déslis
|
||||
- Sériaalités**:onncti*Fon)
|
||||
- *versionons de ` (informatisionInfoe)
|
||||
- `Verissantfenêtre glow` (eWind - `Failur)
|
||||
'échec(événement dlureEvent` - `Fai
|
||||
low)d'un workfo` (état ionStateInf - `Executlides)
|
||||
tions vaec transienum av` (ionStatexecut - `Eées**:
|
||||
implémentasses.py`
|
||||
- **Clager_manalhesystem/auto_: `core/r**Fichie✅
|
||||
- **data models ement base Impl
|
||||
### 1.3 lencheurs
|
||||
es déc tous lourles p configurab - Seuilsggressive
|
||||
avative,serid, conybrModes: h
|
||||
- litiquesdes poload Hot-reidation
|
||||
-vec valSON aiguration Jonf
|
||||
- C*:alités*onn- **Fonctianager.py`
|
||||
uto_heal_mystem/a`core/sfig` dans Cone**: `Policy**Classson`
|
||||
- y.jpoliceal_nfig/auto_h*: `data/co **Fichier*ystem ✅
|
||||
-guration sy confipolicate re
|
||||
### 1.1 Cminées ✅
|
||||
Terâches
|
||||
|
||||
## Tngereux.est dat quand c'e localemenêt'arrt flou, et sc'esd res quanritèes c et durcit lntitle sûr, ra que c'ester tant à fonctionne continue Le systèmrité.sécurvice et e secontinuité d équilibre uiybride qng h'auto-healime d systè dutationeném
|
||||
|
||||
Impl
|
||||
|
||||
## Résumé avancées- Tâches 1-3cours *: En tus*Sta 2024
|
||||
**écembre de**: 23*Dat
|
||||
*ent
|
||||
ancemt d'Avybride - Étato-Heal H #22 Au# Fiche
|
||||
@@ -1,112 +0,0 @@
|
||||
es fiches. les autravecons atintégrur les irêt pode base et pge usaes cas d' lnnel pourtiofoncystème est Le st()
|
||||
```
|
||||
|
||||
orreptus_get_stat = manager.tus_repor)
|
||||
staw_1""workfloget_mode( = manager.rrent_stateétat
|
||||
cu l'
|
||||
# Vérifierlt)
|
||||
1", resuep_stflow_1", "rk"wo_result(on_steper. managrésultat
|
||||
le trer # Enregison(...)
|
||||
execute_actiult =
|
||||
reser l'actionxécut # E
|
||||
execute:uld_)
|
||||
if sho1"ep_1", "st"workflow_ute_step(hould_exec = manager.scute, reasonexee
|
||||
should_tion d'étap Avant exécuger()
|
||||
|
||||
#utoHealManaanager = Ation
|
||||
malisati
|
||||
|
||||
# InierlManageaimport AutoHr _managem.auto_heale.systefrom corython
|
||||
on
|
||||
|
||||
```plisati
|
||||
|
||||
## Utiaprèsavant/mance e perfor d
|
||||
- Métriquesaut)par défons arde 5 versimatique (gtoyage auto- Netles
|
||||
stabions k vers versRollbac
|
||||
- ageprentissposants d'aps comques deatipshots autom- Snaioning
|
||||
e Verse d Systèment)
|
||||
|
||||
###ulemses (logging 10 minuteobaux englcs E**: 30 écheUSOBAL PAGL **ow
|
||||
-kflour un wornutes p0 mi échecs en 1NTINED**: 10- **QUARAne étape
|
||||
ifs sur us consécut échecADED**: 3*DEGRes
|
||||
- *Automatiqucheurs Déclen
|
||||
|
||||
###êt manuelSED**: Arrnte
|
||||
- **PAUcédeprén ioerstion vstaura**: ReLLBACK **ROurable
|
||||
-fig conoutc timeaveire temporaD**: Arrêt ARANTINEtivé
|
||||
- **QU désacageprentiss ap: 0.82),(confiances augmentés ED**: SeuilEGRAD)
|
||||
- **D 0.72ce:euil confianle (sn norma**: Exécutio*RUNNING- *t
|
||||
chine d'ÉtaMa
|
||||
|
||||
### ationnelless Opérnctionnalité
|
||||
|
||||
## Fo ⚠️
|
||||
```rsioning Tests ve #.py sioned_store─ test_ver✅
|
||||
└─es sts modèly # Temodels.pal_data_he─ test_auto_t/
|
||||
├─ests/uni✅
|
||||
|
||||
tguration Confion # y.js_polic auto_healonfig/
|
||||
└──/c✅
|
||||
|
||||
dataersioning Système de v # ore.py sioned_stg/
|
||||
└── verarninre/le
|
||||
|
||||
coreaker ⚠️uit bCirc # aker.py uit_brerc
|
||||
└── cintral ✅ire cenna # Gestioager.py l_man├── auto_heare/system/
|
||||
|
||||
```
|
||||
coplémentée
|
||||
tecture Im
|
||||
## Archidonnées
|
||||
méta Gestion desersions
|
||||
-de vs Statistiqueons
|
||||
- nes versi des ancientomatiqueauettoyage entes
|
||||
- Ncédrsions prévers veRollback - e
|
||||
SQLitSS, mémoire FAIes, indices de prototypshots- Snap:
|
||||
nnalités***Fonctioe.py`
|
||||
- *ned_storsioing/verarn`core/ler**:
|
||||
- **Fichiee class ✅orrsionedStement Ve 3.1 Implets
|
||||
|
||||
###plion comégratcles d'intues
|
||||
- Cytiqn des poliio- Configurat
|
||||
ntesêtres glissaFenion
|
||||
- rialisattion/désélisas
|
||||
- Sériansitionats et tra des étonati:
|
||||
- Validour**Tests p- **sants
|
||||
tests pasrture**: 21 ve**Cou.py`
|
||||
- ta_models_da_auto_healit/test*: `tests/un- **Fichier*els ✅
|
||||
for data modts tesunitrite # 1.4 W##s
|
||||
|
||||
itairees utilodétht
|
||||
- M'étaransitions dtion des talidae
|
||||
- Vomplètation csérialisation/délisria Sé:
|
||||
-nalités****Fonctionersion)
|
||||
- mations de vinforionInfo` (rs
|
||||
- `Veante) glissenêtreeWindow` (f - `Failurc)
|
||||
t d'écheévénemenlureEvent` (ailow)
|
||||
- `Frkfd'un wot (étaeInfo`ionStat- `Executalides)
|
||||
sitions vrannum avec tte` (eionStaut - `Execes**:
|
||||
implémentélasses **C- .py`
|
||||
anageruto_heal_m/system/a`core*: Fichier*
|
||||
- **dels ✅e data mot bas Implemen 1.3heurs
|
||||
|
||||
###ncécleus les dbles pour touras configSeuil -
|
||||
ggressivetive, arva conses: hybrid,es
|
||||
- Modetiqudes polioad rel - Hot-tion
|
||||
validaJSON avec guration - Confi nalités**:
|
||||
**Fonctionpy`
|
||||
-eal_manager.em/auto_hyst/s` dans `corePolicyConfigsse**: `Cla**y.json`
|
||||
- heal_polico_a/config/aut `datFichier**:m ✅
|
||||
- **ation systeconfigureate policy .1 Cr## 1ées ✅
|
||||
|
||||
#s Terminche Tâeux.
|
||||
|
||||
## dangerestnt quand c'e localeme s'arrêtflou, etest s quand c'es critère lrcitet dulentit , raue c'est sûr tant qonctionner finue àconte système curité. Le et sérvicté de sentinuire coi équilibg hybride quauto-healinme d'n du systèntatioé
|
||||
|
||||
ImplémesumRées
|
||||
|
||||
## 1-3 avancéesrs - Tâch**: En cou
|
||||
**Statusembre 2024 te**: 23 déc
|
||||
**Daancement
|
||||
tat d'Avde - Éybrial H-Hetoiche #22 Au# F
|
||||
@@ -1,228 +0,0 @@
|
||||
és sécurisux endpointsuveas no sur le équipesdesFormation s
|
||||
- s existantrviceec les seon avtitégrasts d'inion
|
||||
- Teroductnnement p d'enviroariablestion des vConfiguraest
|
||||
- nnement de ten envirot iemenplo**
|
||||
- Déecommandées: étapes rnes**Prochai
|
||||
---
|
||||
|
||||
on V3.
|
||||
e RPA Visiécosystèms l'te dan complè intégrationbuste et unerité ro une sécution avecuca prodrêt pour l pme est
|
||||
|
||||
Le systè d'urgencer modes* pouion*tegrat Switch Inafetyés
|
||||
7. ✅ **Ss intégrécorateure** avec dwarsk Middle
|
||||
6. ✅ **Flas sécuriséesceendanec dép avMiddleware***FastAPI ✅ *uré
|
||||
5.ructt JSONL st* en formadit Logging**Au
|
||||
4. ✅ * algorithmetoken buck tecavLimiting** **Rate . ✅ upport
|
||||
3 et proxy s avec CIDR** Allowlistn
|
||||
2. ✅ **IPxpiratios et e rôle avecon**Authenticati ✅ **Token 1.
|
||||
|
||||
s:fonctionnelsants compoec tous lesMENTÉE** avLÉT IMPÈTEMENest COMPLance vern GoI Security &he #23 - AP
|
||||
|
||||
**Ficsionnclu
|
||||
|
||||
## Colocalhostnt avec IPs loppemeéve✅ Mode dcurisée
|
||||
- ut séion par défa✅ ConfiguratastAPI)
|
||||
- lask/F (Fnels option ✅ Importst
|
||||
-ème Existan# Systente
|
||||
|
||||
## transparontiigras
|
||||
- ✅ Mng changekirea ✅ Pas de bxistant
|
||||
-in-Token eX-Admt ✅ Supporide)
|
||||
- to-Heal Hybr2 (Auche #2# Fiité
|
||||
|
||||
##atibilRétrocomp
|
||||
|
||||
## ritée sécuviolations dles r veille*: Surring*nitoe
|
||||
5. **Mohivagl'arcet otation urer la r: Config**
|
||||
4. **Logsnduege attea charter selon l**: Ajus Limits. **Ratestructure
|
||||
3on l'infrahe selncblaer la liste Configur
|
||||
2. **IPs**:)caractèress (32+ rets fort secer desis: Util**Tokens**iement
|
||||
1. Déplotions mmanda
|
||||
### Reconces
|
||||
pour urgeitchon kill-sw✅ Intégratiormation
|
||||
- nfns fuite d'ierreurs san des Gestioc.)
|
||||
- ✅ s, ettion, X-Frame-OpCSPécurité (ders de sHea✅ NL
|
||||
- en JSOl complett trai- ✅ Audi les abus
|
||||
e contrestimiting robu✅ Rate l- ec CIDR
|
||||
IPs avdes on stricte Validati
|
||||
- ✅ 56)MAC-SHA2sécurisés (Hnt aphiquemecryptogrs - ✅ Tokenctées
|
||||
gences Respe# Exiuction
|
||||
|
||||
##té Prod# Sécurimum
|
||||
|
||||
#ONLY minien READ_Requiert tokytics/*`: /anal `/apion IP
|
||||
-validativalide + en tokiert Requs/upload`:session
|
||||
- `/api/ngitiate limlide + rvaken uiert to: Req/execute`/workflowsMIN
|
||||
- `/api token AD: Requiert/admin/*`- `/apiés
|
||||
ints Protég
|
||||
|
||||
### Endposessionssé des écurid s/`): Uploa`agent_v0gent V0** (act
|
||||
- ✅ **AFrontend Relask + Backend Fbuilder/`):w_l_workfloisuar** (`vdelow Buill Workfisuaask
|
||||
- ✅ **Vrface Fl): Inteoard/`_dashb (`webboard**ash ✅ **Web D
|
||||
-ec FastAPIEST av`): API R`server/* ( **Server*
|
||||
- ✅atiblesmpices Co# Serv V3
|
||||
|
||||
##ionvec RPA Visgration a## Inté`
|
||||
|
||||
py
|
||||
``curity.e23_api_sechst_fihon3 tees)
|
||||
pyteurons mintie correcessitomplet (néc
|
||||
|
||||
# Test ce.pysimplst_fiche23_
|
||||
python3 te rapideTestsh
|
||||
# `ba
|
||||
``elleon Manu# Validatis)
|
||||
|
||||
##ssaireéceineures norrections m(avec cplets Tests com`:y.pyi_securitiche23_ap_fst
|
||||
- ✅ `tenelsase fonctionTests de bimple.py`: 23_sst_fichete✅ `és
|
||||
- ément# Tests Impln
|
||||
|
||||
##alidatio Tests et V```
|
||||
|
||||
##y.com"
|
||||
panadmin@comCT="TACY_CONGEN2"
|
||||
EMER1,featuretureATURES="feaED_FEh
|
||||
DISABLwitcill_so_safe|kemrmal|dnormal" # E="noSAFETY_MODtch
|
||||
ety Swie"
|
||||
|
||||
# SafIVE="truSH_SENSIT_HAITUD"
|
||||
A10S="LOG_MAX_FILE
|
||||
AUDIT_# 10MB485760" 10_MAX_SIZE="DIT_LOG
|
||||
AU"logs/auditT_LOG_DIR="
|
||||
AUDIingudit Logg5"
|
||||
|
||||
# AIN="30:MIT_API_ADM0"
|
||||
RATE_LI120:2ORKFLOWS="LIMIT_API_W
|
||||
RATE_="10"_LIMIT_BURSTEFAULT_RATE="60"
|
||||
DIT_RPMULT_RATE_LIMting
|
||||
DEFA# Rate Limi"true"
|
||||
|
||||
OCKED_IPS=
|
||||
LOG_BLue""tr_HEADERS=PROXYE_1"
|
||||
ENABL0.6.0.1,10.0.XIES="172.1TED_PRO
|
||||
TRUS0/8"0.0.0.0/24,1.168.1.0.0.1,192IPS="127.ALLOWED_st
|
||||
li
|
||||
# IP Allow"24"
|
||||
IRY_HOURS=
|
||||
TOKEN_EXPébilitatiRétrocomp" # -admin-tokencyOKEN="legaADMIN_Token-1"
|
||||
X_"readonly-tS=D_ONLY_TOKEN2"
|
||||
REAn-in-tokeadm-token-1,dminN_TOKENS="aDMI
|
||||
Auction"odpry-change-in-cret-keY="your-seECRET_KEns
|
||||
TOKEN_Sash
|
||||
# Tokement
|
||||
```b d'Environneblesariate
|
||||
|
||||
### Vmplèon Corati Configu
|
||||
##ence
|
||||
ions d'urges activatogging d
|
||||
- ✅ Lensibless stionnalitéque des fonctiutomactivation a
|
||||
- ✅ Désa KILL_SWITCHEMO_SAFE,AL, Des NORMs modespect dey`
|
||||
- ✅ R.pwitchty_ssafere/system/avec `coplète comIntégration ✅ on
|
||||
-ratintegwitch I Sty## 7. Safe``
|
||||
|
||||
#
|
||||
`ig": {...}}turn {"conf
|
||||
rein_config():def adm_admin
|
||||
k_require")
|
||||
@flasnfigin/cooute("/admpp.r)
|
||||
|
||||
@ay(applask_securit_)
|
||||
init_fme_nask(__Fla
|
||||
app = in
|
||||
_require_admlaskurity, fflask_sect_rt iniy impoecurit.flask_s.securitycore
|
||||
from *
|
||||
```python**Usage:*ques
|
||||
|
||||
automati sécurité Headers de
|
||||
- ✅ és personnalisres d'erreurionnai
|
||||
- ✅ Gestinfo`/token/tyecuri/sstatus`, ` `/security/s:ires utilitaoutelet
|
||||
- ✅ Rsetup compr )` pousecurity(k_init_flas `- ✅ Fonctionoken`
|
||||
y_tk_require_anflasdmin`, `@sk_require_as: `@fla✅ Décorateur
|
||||
- equestuest/after_rfore_req bek aveceware Flas Middlpy`)
|
||||
- ✅y.uritflask_secre/security/(`coeware Middlity Flask Secur`
|
||||
|
||||
### 6.}
|
||||
``rs": [...]turn {"use reoken)):
|
||||
e_admin_t(requir Depends =olerole: TokenRer_et_users(us def g
|
||||
async")rs/use"/admin
|
||||
@app.get(
|
||||
_tokendminire_at requity imporapi_secururity.faste.secfrom corhon
|
||||
e:**
|
||||
```pyt
|
||||
|
||||
**Usag Switchon Safety✅ Intégrati
|
||||
- riésappropP s HTTc codeeurs avetion des err
|
||||
- ✅ Ges)ons, etc.Frame-Optié (CSP, X-e sécurit ders ✅ Headateur
|
||||
-le utilis rôque duomatin autExtractio- ✅ oken`
|
||||
_any_t`require`, _admin_tokenrequi: `rendances Dépe- ✅ons
|
||||
ificatiles véroutes plet avec tomre cddlewa✅ Mity.py`)
|
||||
- tapi_securiurity/fasecare (`core/siddlew Security M5. FastAPI
|
||||
|
||||
### e tokensons dValidatiTION`: EN_VALIDATOKsées
|
||||
- `non autori IPs CKED`:P_BLO
|
||||
- `Iimites de lssementsEEDED`: DépaIMIT_EXC
|
||||
- `RATE_Ltéesations détecTION`: ViolIOLAURITY_V
|
||||
- `SECtus codesc stadpoints aveccès aux en`: AAPI_ACCESS
|
||||
- `échouéessies/ons réusonnexi CTION`:ENTICA`AUTH*
|
||||
- ts:*d'événemen*Types UTC
|
||||
|
||||
*01SO 86ps Itams
|
||||
- ✅ Timesplètelles comes contextuetadonné
|
||||
- ✅ Mé etc.violation,security_cess, , api_acticationts: authens d'événemen✅ Type- sibles
|
||||
nées senes donhage d
|
||||
- ✅ Haclogstique des ion automaotatacile
|
||||
- ✅ Ring fé pour parsNL structurormat JSO- ✅ Flog.py`)
|
||||
it_security/aud (`core/SONLing Jgg. Audit Lo
|
||||
### 4ue
|
||||
```
|
||||
écifiq sp # endpoint20:20"FLOWS="1I_WORK_LIMIT_AP
|
||||
RATE"10"_BURST=RATE_LIMITLT_0"
|
||||
DEFAU"6MIT_RPM=_RATE_LIULT
|
||||
DEFAsh**
|
||||
```ban:tio**Configurary_after
|
||||
|
||||
c retveeded` aitExceRateLimn `✅ Exceptiofs
|
||||
- nactikets ides bucomatique age auttoy
|
||||
- ✅ NetteLimit-*)X-Ratifs (informaTTP Headers Hcity)
|
||||
- ✅burst capaible (RPM, flexration gufionint
|
||||
- ✅ Ceur, endpo utilisatr IP,ion paimitatue
|
||||
- ✅ Lautomatiqc refill veen bucket aithme tok)
|
||||
- ✅ Algor.py`rate_limitery//securitore(`cn Bucket Tokeng avecate Limiti
|
||||
|
||||
### 3. R
|
||||
```"true"XY_HEADERS=E_PRO1"
|
||||
ENABL.0.0.10172.16.0.1,_PROXIES="ED8"
|
||||
TRUST0/0/24,10.0.0.1.1,192.168..0.0."127S=ED_IPash
|
||||
ALLOWn:**
|
||||
```bguratioonfi
|
||||
**Cdéfaut
|
||||
r avec IPs pament développe Mode
|
||||
- ✅oquéesPs bldes ILogging ✅ ement
|
||||
-ronnenvid'variables uration par fig
|
||||
- ✅ ConX-Real-IPFor, rwarded-c X-Fofiance avee con ✅ Proxies d24)
|
||||
-92.168.1.0/IDR (ex: 1ges C- ✅ Pla et IPv6
|
||||
t IPv4 ✅ Supporst.py`)
|
||||
-ip_allowlie/security/corCIDR (`avec Allowlist ### 2. IPging
|
||||
|
||||
bug()` pour denfo_safeget_token_iace `Interf`
|
||||
- rorlidationErTokenVaavec `es erreurs dstion
|
||||
- Gein-Tokendm-AToken, Xr, X-API-Bearerization port Autho- Supature`
|
||||
ign|scenonres_at||expirole|user_id: `ec payloadés av sign Tokens**
|
||||
-s:clés nnalitéionct**Fo
|
||||
|
||||
P multiplesers HTTeadn depuis h ✅ Extractioste
|
||||
-que robuptographition cryValida
|
||||
- ✅ e #22) (fichmin-Token avec X-AdtéiliompatibRétroc ✅ okens
|
||||
-es tfigurable dn conpiratio ✅ ExLY
|
||||
-t READ_ONes ADMIN ert des rôlppo- ✅ SuHA256
|
||||
ec HMAC-Savcurisés s séion de tokenrat)
|
||||
- ✅ Génépy`pi_tokens.y/asecuriton (`core/catised Authenti 1. Token-baentés
|
||||
|
||||
###pléms Immposant
|
||||
|
||||
## Coudite débit et alimitation dtion, orisa, autationntificuthemplet avec aPI coité Ae sécurstème djectif**: Sy**Obre 2025
|
||||
mb*: 24 déce
|
||||
|
||||
**Date*EPLÉMENTÉtatut: ✅ IMLETE
|
||||
|
||||
## S COMPernance - Gov Security & APIe #23 -ch# Fi
|
||||
@@ -1,166 +0,0 @@
|
||||
urisésdpoints séc en suripes équtionFormastants
|
||||
- exiec services avtionégra'ints don
|
||||
- Testent producti'environnemiables dvares figuration dContest
|
||||
- ement de vironn enment enie
|
||||
- Déplos étapes:**
|
||||
**Prochaine3.
|
||||
|
||||
---
|
||||
PA Vision Vme Rl'écosystè dans mplète co intégration et unerobusterité vec une sécu* aproduction*ur la *prêt postème est * syLealidés
|
||||
|
||||
nels v fonctionsts#22
|
||||
8. ✅ Tehe avec fictibilitéétrocompa Rnce
|
||||
7. ✅ges d'urdech pour moty Switgration Safe✅ Intéi
|
||||
6. à l'emploask prêts astAPI et Flares Flew5. ✅ Middé en JSONL
|
||||
urging structAudit log
|
||||
4. ✅ ken bucketavec to robuste iting lim Rateies
|
||||
3. ✅R et proxort CIDc suppche IP aveListe blan ✅ c rôles
|
||||
2.ave tokens ion paruthentificat Système d'ac:
|
||||
|
||||
1. ✅ENTÉE** aveIMPLÉMOMPLÈTEMENT nance est C& Goverecurity - API S#23**Fiche
|
||||
|
||||
Conclusion
|
||||
|
||||
##ion finaleumentatTE.md` - Doc_23_COMPLECHEns
|
||||
- `FIicatioSpécif- nts.md` quiremence/reovernaurity-g/api-secpecs/s
|
||||
- `.kiro mineures)correctionslets (s comppy` - Testi_security.e23_apst_fiche ✅
|
||||
- `teels de basionnct - Tests fon_simple.py`_fiche23ston
|
||||
- `teocumentatiet D
|
||||
### Tests jour)
|
||||
isés (mis àtralrts cen` - Impo_.pynit_/__iurity- `core/secware Flask
|
||||
Middlety.py` - ecuriy/flask_se/securit- `corAPI
|
||||
eware Fastddl - Miity.py`stapi_secururity/fa `core/secit JSONL
|
||||
-g d'audginog.py` - Log/audit_lre/securitycocket
|
||||
- `token bue débit don - Limitatir.py`ate_limite/security/rcore
|
||||
- `ec CIDRche IP avblanListe t.py` - lowlisalp_ity/isecur`core/ns
|
||||
- ion par tokeficattithenns.py` - Auokeity/api_turec- `core/sre
|
||||
dules Co
|
||||
### Moréés
|
||||
hiers C Fic##ente
|
||||
|
||||
transparigrations
|
||||
- Mgeng chanas de breaki- PastAPI)
|
||||
s (Flask/Fs optionnelImport22
|
||||
- fiche #a ken de lToin-rt X-Adm✅
|
||||
- Suppoité ilrocompatib## Rét
|
||||
|
||||
#r urgencespouswitch
|
||||
- ✅ Kill-é standardurit séc✅ Headers deL
|
||||
- JSONetl complaiAudit tr abus
|
||||
- ✅ contre lesng itiim ✅ Rate l
|
||||
-DRte avec CIicIP stration - ✅ Validsés
|
||||
ement sécuriographiquypt✅ Tokens crs
|
||||
- pectéences Resxige
|
||||
### En ✅
|
||||
uctio Produrité
|
||||
|
||||
## SécNLY minimumen READ_O: Tokcs/*`nalyti `/api/aP
|
||||
-alidation I`: Token + v/uploadi/sessionsing
|
||||
- `/ape limitalide + rat Token vws/execute`:workflo `/api/requis
|
||||
-ADMIN n/*`: Token dmi/api/a- `égés
|
||||
nts Protoidp# En
|
||||
##ens
|
||||
sé avec tokcurioad sé: Uplgent V0**sé
|
||||
- **AFlask sécurickend r**: BaldeWorkflow Bui*Visual
|
||||
- *séesécuri set routesécorateurs D (Flask):Dashboard**ts
|
||||
- **Web endances prê dépetiddleware stAPI): M** (Fa **Server✅
|
||||
-s patiblevices Comer V3
|
||||
|
||||
### Ssion RPA Vintégration## I
|
||||
```
|
||||
|
||||
h_switce|killsaf|demo_# normall" ="normaTY_MODESwitch
|
||||
SAFEafety
|
||||
|
||||
# S0MB5760" # 1"1048E=SIZIT_LOG_MAX_t"
|
||||
AUDogs/audiLOG_DIR="lT_ogging
|
||||
AUDIAudit L"10"
|
||||
|
||||
# _BURST=IMITTE_L
|
||||
DEFAULT_RAM="60"_LIMIT_RPULT_RATEg
|
||||
DEFAte Limitin
|
||||
# Ra"
|
||||
0.0.16.0.1,10..1ES="172RUSTED_PROXI
|
||||
T.0/8"0.0.04,192.168.1.0/2127.0.0.1,1_IPS="
|
||||
ALLOWEDP Allowlist ité
|
||||
|
||||
# Irocompatibil" # Rétmin-tokeny-adOKEN="legacIN_Tn-2"
|
||||
X_ADMdmin-toke-token-1,a="adminDMIN_TOKENSuction"
|
||||
Aange-in-prodcret-key-chY="your-seKERET_
|
||||
TOKEN_SEC Tokensbash
|
||||
#s
|
||||
```ment Cléronnees d'Enviariablion
|
||||
|
||||
### Von Productrati
|
||||
## Configuh
|
||||
afety Switc SgrationtéSONL
|
||||
- ✅ Informat Jgging en it loAud
|
||||
- ✅ atifsrs informvec headeimiting aRate l✅ 1.0/24)
|
||||
- , 192.168.27.0.0.1ec CIDR (1tion IP avda✅ Vali
|
||||
- n de tokenslidatioet vanération tés
|
||||
- ✅ Géessants T## Compo
|
||||
#
|
||||
```
|
||||
tionntegra Iety Switch SafNL
|
||||
•ing JSOdit Loggting
|
||||
• Aue Limi Rat
|
||||
•DR ist avec CI Allowl IPcation
|
||||
•sed Authenti• Token-ba
|
||||
validées:ités tionnaloncÉE
|
||||
|
||||
📋 FENTMPLÉMce: Iernanovty & Gecuri23 - API SFiche #ENT!
|
||||
✅ PASSSTS LES TEOUStat:
|
||||
🎉 Tsul.py
|
||||
|
||||
# Réimpleiche23_s test_fpython3de - PASSE
|
||||
rapi Test
|
||||
```bash
|
||||
#ctionnels ✅ts Fon
|
||||
|
||||
### Tesations et Valid
|
||||
## Testty()
|
||||
urik_seclasnit_fec i complet av- Setupsonnalisés
|
||||
d'erreur pernaires onsti
|
||||
- Gefoen/inty/tokuritus, /seccurity/staitaires: /setes util
|
||||
- Routokenquire_any_, @flask_reinadmire_sk_requateurs: @flae ✅
|
||||
- DécordlewarSecurity Midlask
|
||||
### 6. Fh
|
||||
y Switcon SafettégratiIn
|
||||
- )-OptionsFrameé (CSP, X-e sécurit dHeadersn
|
||||
- e_any_tokeoken, requir_tre_adminces: requipendanDétions
|
||||
- icaifoutes véravec tre complet - Middlewaleware ✅
|
||||
MiddurityFastAPI Sec5. s
|
||||
|
||||
### complèteellescontextunées - Métadonensibles
|
||||
es données sachage don
|
||||
- Hiolati_vcuritys, sen, api_accestiouthentica Types: a
|
||||
-otation avec ruréructt JSONL stma
|
||||
- For ✅SONL Joggingit L
|
||||
### 4. Aud
|
||||
s inactifs des buckettiqueautomaNettoyage -*)
|
||||
- imitifs (X-RateLTP informateaders HTint
|
||||
- Hateur/endpolispar IP/utiation que
|
||||
- Limittima autoavec refillt token buckeAlgorithmeket ✅
|
||||
- Token Bucimitinge L. Rat
|
||||
|
||||
### 3autr défec IPs paement avde développment
|
||||
- Monneenviroar exible pration fl- ConfiguFor)
|
||||
warded-ance (X-Fore confi- Proxies des CIDR
|
||||
Pv6 et plagt IPv4/I
|
||||
- SupporR ✅ st avec CIDP Allowli
|
||||
|
||||
### 2. InI-TokeAPearer, X-on Bti Authoriza
|
||||
- Supportche #22)fiToken (X-Admin-ité bilpati
|
||||
- Rétrocom expirationavecONLY /READ_MIN- Rôles ADHA256
|
||||
risés HMAC-Sokens sécuération ton ✅
|
||||
- Génhenticatid Autseoken-ba
|
||||
|
||||
### 1. T LivrésntsComposa
|
||||
## 3
|
||||
ision VA V pour RPompletI cté APe de sécuri: Systèm*Objectif**5
|
||||
*e 202 décembr**: 24te
|
||||
**Da PLÉMENTÉE t**: IMatu
|
||||
**Stcutif
|
||||
ésumé ExéTE ✅
|
||||
|
||||
## Re - COMPLEernancrity & GovPI Secu - Ache #23# Fi
|
||||
@@ -1,139 +0,0 @@
|
||||
FICHIERS CRÉÉS - PHASE 11 : OUTILS D'AMÉLIORATION CONTINUE
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Date: 23 novembre 2025
|
||||
|
||||
SCRIPTS PYTHON (3)
|
||||
──────────────────
|
||||
1. analyze_failed_matches.py (327 lignes, 12K)
|
||||
- Analyse statistique des échecs de matching
|
||||
- Identification des nodes problématiques
|
||||
- Recommandations de seuil
|
||||
- Export JSON
|
||||
|
||||
2. monitor_matching_health.py (180 lignes, 5K)
|
||||
- Monitoring temps réel
|
||||
- Système d'alertes
|
||||
- Mode continu
|
||||
- Sauvegarde historique
|
||||
|
||||
3. auto_improve_matching.py (355 lignes, 14K)
|
||||
- Amélioration automatique
|
||||
- UPDATE_PROTOTYPE, CREATE_NODE, ADJUST_THRESHOLD
|
||||
- Mode simulation
|
||||
- Application sécurisée
|
||||
|
||||
DOCUMENTATION (4)
|
||||
─────────────────
|
||||
4. MATCHING_TOOLS_README.md (2.5K)
|
||||
- Guide d'utilisation complet
|
||||
- Workflow recommandé
|
||||
- Exemples de cas réels
|
||||
- Dépannage
|
||||
|
||||
5. QUICK_START_MATCHING_TOOLS.md (4.0K)
|
||||
- Démarrage rapide
|
||||
- Commandes essentielles
|
||||
- Interprétation des résultats
|
||||
|
||||
6. PHASE11_MATCHING_IMPROVEMENT_TOOLS.md (8.7K)
|
||||
- Documentation technique complète
|
||||
- Architecture des données
|
||||
- Métriques de succès
|
||||
- Intégration CI/CD
|
||||
|
||||
7. SUMMARY_PHASE11.md (8.1K)
|
||||
- Résumé exécutif
|
||||
- Statistiques
|
||||
- Bénéfices et apprentissages
|
||||
|
||||
TESTS (1)
|
||||
─────────
|
||||
8. test_matching_tools.sh (1.6K)
|
||||
- Tests automatisés des 3 outils
|
||||
- Création de données fictives
|
||||
- Vérification du bon fonctionnement
|
||||
|
||||
CHANGELOG (1)
|
||||
─────────────
|
||||
9. CHANGELOG_PHASE11.md (5.6K)
|
||||
- Historique des changements
|
||||
- Fonctionnalités ajoutées
|
||||
- Modifications apportées
|
||||
|
||||
RÉSUMÉS (1)
|
||||
───────────
|
||||
10. PHASE11_COMPLETE.txt (3.5K)
|
||||
- Résumé ultra-concis
|
||||
- Vue d'ensemble complète
|
||||
- Utilisation rapide
|
||||
|
||||
FICHIERS MODIFIÉS
|
||||
─────────────────
|
||||
- INDEX.md
|
||||
+ Ajout section "Outils d'Amélioration Continue"
|
||||
+ Liens vers tous les nouveaux fichiers
|
||||
+ Workflow recommandé
|
||||
|
||||
- core/graph/node_matcher.py (Phase 10)
|
||||
+ Ajout _log_failed_match()
|
||||
+ Ajout _generate_suggestions()
|
||||
+ Intégration dans _match_linear()
|
||||
|
||||
TOTAL
|
||||
─────
|
||||
Fichiers créés: 10
|
||||
Fichiers modifiés: 2
|
||||
Lignes de code: ~850
|
||||
Documentation: ~30 pages
|
||||
Tests: ✅ Automatisés
|
||||
Statut: ✅ Production Ready
|
||||
|
||||
STRUCTURE DES DONNÉES
|
||||
─────────────────────
|
||||
data/
|
||||
├── failed_matches/ # Échecs enregistrés
|
||||
│ └── failed_match_YYYYMMDD_HHMMSS/
|
||||
│ ├── screenshot.png # Capture d'écran
|
||||
│ ├── state_embedding.npy # Vecteur 512D
|
||||
│ └── report.json # Rapport complet
|
||||
│
|
||||
└── monitoring/ # Métriques de santé
|
||||
└── matching_health_YYYYMMDD.jsonl # Historique
|
||||
|
||||
COMMANDES RAPIDES
|
||||
─────────────────
|
||||
# Analyse
|
||||
./analyze_failed_matches.py --last 10
|
||||
./analyze_failed_matches.py --since-hours 24
|
||||
./analyze_failed_matches.py --export rapport.json
|
||||
|
||||
# Monitoring
|
||||
./monitor_matching_health.py
|
||||
./monitor_matching_health.py --continuous
|
||||
./monitor_matching_health.py --continuous --interval 30
|
||||
|
||||
# Amélioration
|
||||
./auto_improve_matching.py
|
||||
./auto_improve_matching.py --apply
|
||||
./auto_improve_matching.py --min-confidence 0.70
|
||||
|
||||
# Tests
|
||||
./test_matching_tools.sh
|
||||
|
||||
DOCUMENTATION
|
||||
─────────────
|
||||
Quick Start: QUICK_START_MATCHING_TOOLS.md
|
||||
Guide Complet: MATCHING_TOOLS_README.md
|
||||
Doc Technique: PHASE11_MATCHING_IMPROVEMENT_TOOLS.md
|
||||
Résumé: SUMMARY_PHASE11.md
|
||||
Changelog: CHANGELOG_PHASE11.md
|
||||
Résumé Concis: PHASE11_COMPLETE.txt
|
||||
Liste Fichiers: FILES_CREATED_PHASE11.txt (ce fichier)
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
Phase 11 : ✅ COMPLÉTÉ
|
||||
Date: 23 novembre 2025
|
||||
Durée: ~2 heures
|
||||
Statut: Production Ready
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -1,64 +0,0 @@
|
||||
# Intégration Validation TypeScript Automatique - COMPLETE
|
||||
|
||||
**Auteur :** Dom, Alice, Kiro
|
||||
**Date :** 12 janvier 2026
|
||||
**Statut :** ✅ TERMINÉ
|
||||
|
||||
## Mission Accomplie
|
||||
|
||||
L'intégration de la validation TypeScript automatique dans la task list du Visual Workflow Builder est **complètement terminée**.
|
||||
|
||||
## Réalisations
|
||||
|
||||
### ✅ Corrections TypeScript
|
||||
- Corrigé toutes les erreurs TypeScript dans les fichiers VWB
|
||||
- Supprimé les imports et variables inutilisés
|
||||
- Validation : `npx tsc --noEmit` ✅ 0 erreur
|
||||
|
||||
### ✅ Script de Validation Automatique
|
||||
- Créé `scripts/validation_typescript_automatique_vwb_12jan2026.py`
|
||||
- Validation TypeScript + compilation build automatique
|
||||
- Messages en français, gestion d'erreurs robuste
|
||||
|
||||
### ✅ Intégration Task List
|
||||
- Modifié `.kiro/specs/visual-workflow-builder/tasks.md`
|
||||
- Ajouté 12 tâches de validation TypeScript après chaque modification frontend
|
||||
- Format standardisé et cohérent
|
||||
|
||||
### ✅ Tests d'Intégration
|
||||
- Créé `tests/integration/test_validation_typescript_automatique_integration_12jan2026.py`
|
||||
- 8 tests d'intégration avec 100% de réussite
|
||||
- Validation complète du processus
|
||||
|
||||
### ✅ Documentation
|
||||
- Documentation complète dans `docs/`
|
||||
- Conformité aux règles du projet (français, attribution auteur)
|
||||
- Guide d'utilisation et processus détaillé
|
||||
|
||||
## Validation Finale
|
||||
|
||||
```bash
|
||||
# Test du script
|
||||
python3 scripts/validation_typescript_automatique_vwb_12jan2026.py
|
||||
# ✅ Vérification TypeScript réussie - aucune erreur
|
||||
# ✅ Compilation de build réussie
|
||||
|
||||
# Test d'intégration
|
||||
python3 tests/integration/test_validation_typescript_automatique_integration_12jan2026.py
|
||||
# ✅ Ran 8 tests in 51.778s - OK
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- **Stabilité TypeScript** garantie après chaque modification
|
||||
- **Processus automatisé** intégré au workflow de développement
|
||||
- **Prévention des régressions** dans le frontend VWB
|
||||
- **Qualité de code** maintenue en permanence
|
||||
|
||||
## Prêt pour Utilisation
|
||||
|
||||
Le système est **opérationnel immédiatement** et peut être utilisé dès la prochaine modification du frontend VWB.
|
||||
|
||||
---
|
||||
|
||||
🎉 **MISSION COMPLETE** - Validation TypeScript automatique intégrée avec succès
|
||||
@@ -1,283 +0,0 @@
|
||||
# Localisation du Composant RealDemo - Implémentation Complète
|
||||
|
||||
> **Extension du système de localisation RPA Vision V3**
|
||||
> Auteur : Dom, Alice, Kiro - 8 janvier 2026
|
||||
|
||||
## 🎯 Résumé de l'Implémentation
|
||||
|
||||
Le composant RealDemo du Visual Workflow Builder a été entièrement localisé, étendant le système de localisation existant avec 3 nouvelles clés de traduction dans les 4 langues supportées.
|
||||
|
||||
## 📊 Statistiques Mises à Jour
|
||||
|
||||
### Avant l'Implémentation
|
||||
- **Total des clés** : 127 traductions
|
||||
- **Composant RealDemo** : Texte codé en dur en français
|
||||
|
||||
### Après l'Implémentation
|
||||
- **Total des clés** : 156 traductions (+3 nouvelles clés)
|
||||
- **Composant RealDemo** : Entièrement localisé
|
||||
- **Couverture** : 100% dans les 4 langues
|
||||
|
||||
## 🔧 Modifications Apportées
|
||||
|
||||
### 1. Nouvelles Clés de Traduction
|
||||
|
||||
#### Structure Ajoutée dans Tous les Fichiers JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"realDemo": {
|
||||
"component": {
|
||||
"title": "Démonstration Réelle - RPA Vision V3",
|
||||
"description": "Ce composant permettra de tester le système RPA en temps réel.",
|
||||
"startButton": "Démarrer la Démonstration"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Traductions par Langue
|
||||
|
||||
| Clé | Français | Anglais | Espagnol | Allemand |
|
||||
|-----|----------|---------|----------|----------|
|
||||
| `title` | Démonstration Réelle - RPA Vision V3 | Real Demonstration - RPA Vision V3 | Demostración Real - RPA Vision V3 | Echte Demonstration - RPA Vision V3 |
|
||||
| `description` | Ce composant permettra de tester le système RPA en temps réel. | This component will allow testing the RPA system in real time. | Este componente permitirá probar el sistema RPA en tiempo real. | Diese Komponente ermöglicht es, das RPA-System in Echtzeit zu testen. |
|
||||
| `startButton` | Démarrer la Démonstration | Start Demonstration | Iniciar Demostración | Demonstration Starten |
|
||||
|
||||
### 2. Composant RealDemo Modifié
|
||||
|
||||
#### Code Avant (Texte Codé en Dur)
|
||||
```typescript
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Démonstration Réelle - RPA Vision V3
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
Ce composant permettra de tester le système RPA en temps réel.
|
||||
</Typography>
|
||||
|
||||
<Button variant="contained" startIcon={<PlayIcon />} onClick={handleExecute}>
|
||||
Démarrer la Démonstration
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
```
|
||||
|
||||
#### Code Après (Localisé)
|
||||
```typescript
|
||||
import { useLocalization } from '../../services/LocalizationService';
|
||||
|
||||
const RealDemo: React.FC<RealDemoProps> = ({ onWorkflowExecute }) => {
|
||||
const { t } = useLocalization();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{t('realDemo.component.title')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" paragraph>
|
||||
{t('realDemo.component.description')}
|
||||
</Typography>
|
||||
|
||||
<Button variant="contained" startIcon={<PlayIcon />} onClick={handleExecute}>
|
||||
{t('realDemo.component.startButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## ✅ Validation et Tests
|
||||
|
||||
### Validation Automatique Réussie
|
||||
|
||||
```bash
|
||||
$ python3 i18n/validate_translations.py
|
||||
|
||||
🔍 Démarrage de la validation des traductions...
|
||||
📋 Validation de la configuration...
|
||||
📂 Chargement des fichiers de traduction...
|
||||
✅ Chargé: fr.json
|
||||
✅ Chargé: en.json
|
||||
✅ Chargé: es.json
|
||||
✅ Chargé: de.json
|
||||
🔍 Validation de la structure...
|
||||
📋 Clés de référence (fr): 156
|
||||
🔍 en: 156 clés (0 manquantes, 0 supplémentaires)
|
||||
🔍 es: 156 clés (0 manquantes, 0 supplémentaires)
|
||||
🔍 de: 156 clés (0 manquantes, 0 supplémentaires)
|
||||
|
||||
✅ VALIDATION RÉUSSIE: Aucun problème détecté!
|
||||
```
|
||||
|
||||
### Validation TypeScript
|
||||
|
||||
- ✅ **Compilation** : Aucune erreur TypeScript
|
||||
- ✅ **Types** : Hook `useLocalization` correctement typé
|
||||
- ✅ **Imports** : Service de localisation importé correctement
|
||||
- ✅ **Fonctionnalité** : Comportement du composant préservé
|
||||
|
||||
## 🌍 Expérience Utilisateur Multilingue
|
||||
|
||||
### Interface en Français (par défaut)
|
||||
```
|
||||
Titre : "Démonstration Réelle - RPA Vision V3"
|
||||
Description : "Ce composant permettra de tester le système RPA en temps réel."
|
||||
Bouton : "Démarrer la Démonstration"
|
||||
```
|
||||
|
||||
### Interface en Anglais
|
||||
```
|
||||
Titre : "Real Demonstration - RPA Vision V3"
|
||||
Description : "This component will allow testing the RPA system in real time."
|
||||
Bouton : "Start Demonstration"
|
||||
```
|
||||
|
||||
### Interface en Espagnol
|
||||
```
|
||||
Titre : "Demostración Real - RPA Vision V3"
|
||||
Description : "Este componente permitirá probar el sistema RPA en tiempo real."
|
||||
Bouton : "Iniciar Demostración"
|
||||
```
|
||||
|
||||
### Interface en Allemand
|
||||
```
|
||||
Titre : "Echte Demonstration - RPA Vision V3"
|
||||
Description : "Diese Komponente ermöglicht es, das RPA-System in Echtzeit zu testen."
|
||||
Bouton : "Demonstration Starten"
|
||||
```
|
||||
|
||||
## 🎨 Respect du Design System
|
||||
|
||||
### Cohérence Visuelle Maintenue
|
||||
- ✅ **Material-UI** : Utilisation des composants existants
|
||||
- ✅ **Thème sombre** : Couleurs du design system respectées
|
||||
- ✅ **Typographie** : Variants Material-UI (`h5`, `body1`)
|
||||
- ✅ **Espacement** : Padding et marges cohérents (`sx={{ p: 3 }}`)
|
||||
- ✅ **Icônes** : Material-UI Icons (`PlayArrow`)
|
||||
|
||||
### Responsive Design
|
||||
- ✅ **Breakpoints** : Adaptation automatique Material-UI
|
||||
- ✅ **Longueur des textes** : Traductions adaptées à l'interface
|
||||
- ✅ **Mise en page** : Structure préservée dans toutes les langues
|
||||
|
||||
## 🔄 Intégration avec l'Existant
|
||||
|
||||
### Cohérence Terminologique
|
||||
- **"Démonstration"** : Cohérent avec `realDemo.title` existant
|
||||
- **"RPA Vision V3"** : Nom du produit maintenu identique
|
||||
- **"Temps réel"** : Terminologie cohérente avec les traductions existantes
|
||||
|
||||
### Architecture Préservée
|
||||
- ✅ **Service existant** : Utilisation de `LocalizationService` sans modification
|
||||
- ✅ **Cache** : Pas d'impact sur les performances
|
||||
- ✅ **Fallback** : Mécanisme de secours automatique maintenu
|
||||
- ✅ **Persistance** : Choix de langue utilisateur préservé
|
||||
|
||||
## 📈 Métriques de Qualité
|
||||
|
||||
### Technique
|
||||
- **Erreurs de validation** : 0
|
||||
- **Erreurs TypeScript** : 0
|
||||
- **Couverture de localisation** : 100%
|
||||
- **Impact performance** : Négligeable
|
||||
|
||||
### Fonctionnel
|
||||
- **Changement de langue** : Instantané
|
||||
- **Persistance** : Fonctionnelle
|
||||
- **Fallback** : Automatique vers français
|
||||
- **Interface** : Cohérente dans toutes les langues
|
||||
|
||||
### Linguistique
|
||||
- **Traductions naturelles** : Validées
|
||||
- **Conventions culturelles** : Respectées
|
||||
- **Longueur appropriée** : Vérifiée
|
||||
- **Cohérence terminologique** : Maintenue
|
||||
|
||||
## 🚀 Utilisation Pratique
|
||||
|
||||
### Pour les Développeurs
|
||||
|
||||
```typescript
|
||||
// Import du hook de localisation
|
||||
import { useLocalization } from '../../services/LocalizationService';
|
||||
|
||||
// Utilisation dans le composant
|
||||
const { t } = useLocalization();
|
||||
|
||||
// Traduction des textes
|
||||
<Typography>{t('realDemo.component.title')}</Typography>
|
||||
```
|
||||
|
||||
### Pour les Utilisateurs
|
||||
|
||||
1. **Changement de langue** : Via le sélecteur de langue existant
|
||||
2. **Persistance** : Le choix est sauvegardé automatiquement
|
||||
3. **Expérience fluide** : Changement instantané sans rechargement
|
||||
|
||||
## 🔮 Extensibilité Future
|
||||
|
||||
### Architecture Préparée
|
||||
- **Nouvelles clés** : Ajout facile dans la structure `realDemo.component.*`
|
||||
- **Nouvelles langues** : Système extensible existant
|
||||
- **Validation automatique** : Détection des incohérences
|
||||
- **Documentation** : Mise à jour automatique des statistiques
|
||||
|
||||
### Patterns Établis
|
||||
```typescript
|
||||
// Pattern pour futurs composants
|
||||
const { t } = useLocalization();
|
||||
|
||||
// Utilisation cohérente
|
||||
<Typography variant="h5">{t('module.component.title')}</Typography>
|
||||
<Button>{t('module.component.action')}</Button>
|
||||
```
|
||||
|
||||
## 📋 Checklist de Validation
|
||||
|
||||
### Implémentation
|
||||
- [x] Nouvelles clés ajoutées dans les 4 fichiers JSON
|
||||
- [x] Composant RealDemo modifié pour utiliser la localisation
|
||||
- [x] Import du service de localisation ajouté
|
||||
- [x] Toutes les chaînes externalisées
|
||||
|
||||
### Validation
|
||||
- [x] Script de validation automatique passé (0 erreur)
|
||||
- [x] Compilation TypeScript réussie (0 erreur)
|
||||
- [x] Structure JSON cohérente dans toutes les langues
|
||||
- [x] Clés nommées selon les conventions
|
||||
|
||||
### Qualité
|
||||
- [x] Traductions naturelles et idiomatiques
|
||||
- [x] Cohérence avec les traductions existantes
|
||||
- [x] Respect des conventions culturelles
|
||||
- [x] Longueur appropriée pour l'interface
|
||||
|
||||
### Documentation
|
||||
- [x] Spécification complète créée
|
||||
- [x] Documentation mise à jour
|
||||
- [x] Statistiques actualisées
|
||||
- [x] Exemples d'utilisation fournis
|
||||
|
||||
## 🎉 Conclusion
|
||||
|
||||
L'implémentation de la localisation du composant RealDemo est **entièrement réussie** :
|
||||
|
||||
- ✅ **3 nouvelles clés** traduites dans 4 langues
|
||||
- ✅ **156 traductions** au total (vs 127 précédemment)
|
||||
- ✅ **Validation automatique** sans erreur
|
||||
- ✅ **Cohérence parfaite** avec le système existant
|
||||
- ✅ **Expérience utilisateur** multilingue de qualité
|
||||
- ✅ **Architecture extensible** pour futures localisations
|
||||
|
||||
Le composant RealDemo offre maintenant une **expérience utilisateur internationale complète**, s'intégrant parfaitement dans l'écosystème de localisation RPA Vision V3 ! 🌍✨
|
||||
|
||||
---
|
||||
|
||||
**Prochaines étapes recommandées :**
|
||||
1. Tester l'interface dans les 4 langues via le navigateur
|
||||
2. Valider l'expérience utilisateur avec des locuteurs natifs
|
||||
3. Documenter ce pattern pour les futurs composants à localiser
|
||||
@@ -1,172 +0,0 @@
|
||||
═══════════════════════════════════════════════════════════════
|
||||
🎉 MISSION COMPLETE - 1er Décembre 2024
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
✅ OBJECTIF: Compléter Tasks 8, 9, 10, 14 + Intégration
|
||||
|
||||
📊 RÉSULTAT FINAL:
|
||||
|
||||
Task 8 (Analytics) : ✅ 95% (19/19 impl + 10/16 tests)
|
||||
Task 9 (Composition) : ✅ 100% (14/14 impl + 22/22 tests)
|
||||
Task 10 (Self-Healing) : ✅ 100% (8/8 impl + 9/9 tests)
|
||||
Task 14 (Monitoring) : ✅ 95% (11/11 impl + 13/15 tests)
|
||||
Integration ExecutionLoop: ✅ 100% COMPLETE
|
||||
|
||||
GLOBAL: 98% COMPLETE - PRODUCTION READY 🚀
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📦 LIVRABLES (16 fichiers):
|
||||
|
||||
Phase 1 - Implémentations (8 fichiers):
|
||||
✅ SuccessRateCalculator (320 lignes)
|
||||
✅ ArchiveStorage (380 lignes)
|
||||
✅ RetentionPolicyEngine
|
||||
✅ ReportGenerator (420 lignes)
|
||||
✅ DashboardManager (450 lignes)
|
||||
✅ AnalyticsAPI (380 lignes)
|
||||
✅ AnalyticsSystem (220 lignes)
|
||||
✅ tasks.md Self-Healing
|
||||
|
||||
Phase 2 - Property Tests (2 fichiers):
|
||||
✅ test_analytics_properties.py (10 tests)
|
||||
✅ test_admin_monitoring_properties.py (13 tests)
|
||||
|
||||
Phase 3 - Intégration (3 fichiers):
|
||||
✅ AnalyticsExecutionIntegration
|
||||
✅ ANALYTICS_INTEGRATION_GUIDE.md
|
||||
✅ demo_integrated_execution.py
|
||||
|
||||
Documentation (3 fichiers):
|
||||
✅ ANALYTICS_QUICKSTART.md
|
||||
✅ SESSION_01DEC_ANALYTICS_COMPLETE.md
|
||||
✅ SESSION_01DEC_INTEGRATION_COMPLETE.md
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📈 STATISTIQUES:
|
||||
|
||||
Lignes de code : 7,000+ lignes
|
||||
Fichiers créés : 16 fichiers
|
||||
Property tests : 23 tests (54/62 total)
|
||||
Documentation : 10 documents
|
||||
Demos : 3 demos fonctionnels
|
||||
Erreurs : 0
|
||||
Durée session : ~6 heures
|
||||
Qualité : Production-ready
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🚀 FONCTIONNALITÉS COMPLÈTES:
|
||||
|
||||
Analytics:
|
||||
✅ Collection automatique de métriques
|
||||
✅ Stockage time-series (SQLite)
|
||||
✅ Analyse de performance (avg, median, p95, p99)
|
||||
✅ Détection de bottlenecks
|
||||
✅ Détection d'anomalies
|
||||
✅ Génération d'insights automatiques
|
||||
✅ Calcul de taux de succès
|
||||
✅ Catégorisation des échecs
|
||||
✅ Classement de fiabilité
|
||||
✅ Tracking temps réel avec ETA
|
||||
✅ Archivage avec compression gzip
|
||||
✅ Politiques de rétention automatiques
|
||||
✅ Rapports (JSON, CSV, HTML, PDF)
|
||||
✅ Dashboards personnalisables
|
||||
✅ API REST (15+ endpoints)
|
||||
|
||||
Intégration:
|
||||
✅ Hooks ExecutionLoop
|
||||
✅ Collection transparente
|
||||
✅ Intégration self-healing
|
||||
✅ Gestion d'erreurs robuste
|
||||
✅ Performance optimisée (<1% overhead)
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🎯 UTILISATION:
|
||||
|
||||
# Tester l'intégration
|
||||
python demo_integrated_execution.py
|
||||
|
||||
# Tester analytics complet
|
||||
python demo_analytics.py
|
||||
|
||||
# Intégrer dans votre code
|
||||
from core.analytics.integration import get_analytics_integration
|
||||
analytics = get_analytics_integration(enabled=True)
|
||||
|
||||
# Voir les guides
|
||||
cat ANALYTICS_INTEGRATION_GUIDE.md
|
||||
cat ANALYTICS_QUICKSTART.md
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🏆 IMPACT:
|
||||
|
||||
Avant:
|
||||
❌ Pas d'analytics centralisé
|
||||
❌ Collection manuelle
|
||||
❌ Pas de tracking temps réel
|
||||
❌ Pas de corrélation self-healing
|
||||
|
||||
Après:
|
||||
✅ Analytics complet et automatique
|
||||
✅ Collection transparente
|
||||
✅ Tracking temps réel avec ETA
|
||||
✅ Corrélation complète
|
||||
✅ Insights automatiques
|
||||
✅ Rapports automatiques
|
||||
✅ Dashboards temps réel
|
||||
✅ API REST complète
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
✨ HIGHLIGHTS:
|
||||
|
||||
1. Système analytics COMPLET et fonctionnel
|
||||
2. 23 property tests validant la correction
|
||||
3. Intégration ExecutionLoop TRANSPARENTE
|
||||
4. Documentation EXHAUSTIVE
|
||||
5. 3 demos FONCTIONNELS
|
||||
6. 0 erreurs de diagnostic
|
||||
7. Production-ready
|
||||
8. Performance optimisée
|
||||
9. Extensible et maintenable
|
||||
10. Prêt à l'emploi
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📝 PROCHAINES ÉTAPES (Optionnel):
|
||||
|
||||
Court terme:
|
||||
- Tester avec vrais workflows
|
||||
- Configurer dashboards personnalisés
|
||||
- Mettre en place rapports automatiques
|
||||
|
||||
Long terme:
|
||||
- WebSocket pour real-time
|
||||
- OpenAPI documentation
|
||||
- 6 property tests avancés restants
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🎊 CONCLUSION:
|
||||
|
||||
Session EXCEPTIONNELLEMENT productive !
|
||||
|
||||
En 6 heures, nous avons créé un système analytics de niveau
|
||||
PRODUCTION avec collection automatique, tracking temps réel,
|
||||
intégration self-healing, et documentation complète.
|
||||
|
||||
Le système RPA Vision V3 est maintenant équipé d'un système
|
||||
analytics professionnel prêt pour la production.
|
||||
|
||||
MISSION ACCOMPLIE ! 🚀
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Date: 1er Décembre 2024
|
||||
Status: ✅ 98% COMPLETE - PRODUCTION READY
|
||||
Next: Utiliser et profiter ! 🎉
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -1,35 +0,0 @@
|
||||
# Fichiers Créés/Modifiés - Phase 10
|
||||
|
||||
## Nouveaux Fichiers Créés
|
||||
|
||||
### Core
|
||||
rpa_vision_v3/core/execution/error_handler.py
|
||||
|
||||
### Tests
|
||||
rpa_vision_v3/tests/unit/test_error_handler.py
|
||||
rpa_vision_v3/tests/integration/test_error_recovery.py
|
||||
|
||||
### Documentation
|
||||
rpa_vision_v3/ERROR_HANDLING_GUIDE.md
|
||||
rpa_vision_v3/PHASE10_COMPLETE.md
|
||||
rpa_vision_v3/SESSION_24NOV_PHASE10_COMPLETE.md
|
||||
rpa_vision_v3/PHASE10_SUMMARY.txt
|
||||
rpa_vision_v3/PHASE10_FILES.txt
|
||||
|
||||
### Scripts
|
||||
rpa_vision_v3/run_error_handler_tests.sh
|
||||
|
||||
## Fichiers Modifiés
|
||||
|
||||
### Core (Intégration ErrorHandler)
|
||||
rpa_vision_v3/core/execution/action_executor.py
|
||||
rpa_vision_v3/core/graph/node_matcher.py
|
||||
|
||||
### Documentation
|
||||
rpa_vision_v3/STATUS_24NOV.md
|
||||
|
||||
## Total
|
||||
|
||||
Nouveaux fichiers: 9
|
||||
Fichiers modifiés: 3
|
||||
Total: 12 fichiers
|
||||
@@ -1,186 +0,0 @@
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ PHASE 10 : GESTION D'ERREURS - COMPLÈTE ✅ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Date: 24 novembre 2024
|
||||
Statut: ✅ TOUTES LES TÂCHES TERMINÉES
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ TÂCHES COMPLÉTÉES (6/6) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
✅ Task 9.1 : ErrorHandler créé
|
||||
✅ Task 9.2 : Intégration ActionExecutor
|
||||
✅ Task 9.3 : Intégration NodeMatcher
|
||||
✅ Task 9.4 : Tests unitaires (26 tests)
|
||||
✅ Task 9.5 : Tests d'intégration
|
||||
✅ Task 9.6 : Documentation complète
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ FICHIERS CRÉÉS │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Core:
|
||||
• core/execution/error_handler.py (~600 lignes)
|
||||
|
||||
Tests:
|
||||
• tests/unit/test_error_handler.py (~500 lignes)
|
||||
• tests/integration/test_error_recovery.py (~300 lignes)
|
||||
|
||||
Documentation:
|
||||
• ERROR_HANDLING_GUIDE.md
|
||||
• PHASE10_COMPLETE.md
|
||||
• SESSION_24NOV_PHASE10_COMPLETE.md
|
||||
|
||||
Scripts:
|
||||
• run_error_handler_tests.sh
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ FONCTIONNALITÉS │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Types d'erreurs gérés (6):
|
||||
• MATCHING_FAILED - Échec de matching de node
|
||||
• TARGET_NOT_FOUND - Target d'action introuvable
|
||||
• POSTCONDITION_FAILED - Post-conditions non satisfaites
|
||||
• UI_CHANGED - Changement d'UI détecté
|
||||
• EXECUTION_TIMEOUT - Timeout d'exécution
|
||||
• UNKNOWN - Erreur inconnue
|
||||
|
||||
Stratégies de récupération (6):
|
||||
• RETRY - Réessayer l'opération
|
||||
• FALLBACK - Utiliser stratégie alternative
|
||||
• SKIP - Ignorer et continuer
|
||||
• ROLLBACK - Annuler dernière action
|
||||
• PAUSE - Pause pour analyse manuelle
|
||||
• ABORT - Abandonner l'exécution
|
||||
|
||||
Fonctionnalités avancées:
|
||||
• Logging détaillé avec screenshots
|
||||
• Historique des erreurs
|
||||
• Compteurs d'échecs par edge
|
||||
• Détection d'edges problématiques (>3 échecs)
|
||||
• Système de rollback avec historique
|
||||
• Génération de suggestions automatiques
|
||||
• 3 niveaux de fallback pour targets
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ TESTS │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Tests unitaires: 26 tests
|
||||
• TestErrorHandlerInitialization (3)
|
||||
• TestMatchingFailureHandling (3)
|
||||
• TestTargetNotFoundHandling (4)
|
||||
• TestPostconditionFailureHandling (2)
|
||||
• TestUIChangeDetection (2)
|
||||
• TestRollbackSystem (4)
|
||||
• TestStatisticsAndReporting (3)
|
||||
• TestErrorLogging (2)
|
||||
• TestSuggestionGeneration (3)
|
||||
|
||||
Tests d'intégration:
|
||||
• ActionExecutor + ErrorHandler
|
||||
• NodeMatcher + ErrorHandler
|
||||
• Scénarios de bout en bout
|
||||
• Agrégation de statistiques
|
||||
|
||||
Exécution:
|
||||
./run_error_handler_tests.sh
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ STATISTIQUES │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Code:
|
||||
• ~1800 lignes de code au total
|
||||
• ~600 lignes ErrorHandler
|
||||
• ~800 lignes de tests
|
||||
• ~400 lignes de documentation
|
||||
|
||||
Temps de développement:
|
||||
• Task 9.1-9.3: Déjà complétées
|
||||
• Task 9.4: ~45 min (tests unitaires)
|
||||
• Task 9.5: ~30 min (tests intégration)
|
||||
• Task 9.6: ~30 min (documentation)
|
||||
• Total session: ~2h15
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ UTILISATION │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Configuration:
|
||||
from core.execution.error_handler import ErrorHandler
|
||||
from core.execution.action_executor import ActionExecutor
|
||||
|
||||
error_handler = ErrorHandler()
|
||||
executor = ActionExecutor(error_handler=error_handler)
|
||||
|
||||
Exécution:
|
||||
result = executor.execute_edge(edge, screen_state)
|
||||
|
||||
if result.status == ExecutionStatus.TARGET_NOT_FOUND:
|
||||
stats = executor.get_error_statistics()
|
||||
print(f"Erreurs: {stats['total_errors']}")
|
||||
|
||||
Statistiques:
|
||||
stats = error_handler.get_error_statistics()
|
||||
problematic = error_handler.get_problematic_edges()
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ DOCUMENTATION │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Guides:
|
||||
• ERROR_HANDLING_GUIDE.md - Guide complet
|
||||
• PHASE10_COMPLETE.md - Résumé de la phase
|
||||
• SESSION_24NOV_PHASE10_COMPLETE.md - Résumé session
|
||||
|
||||
Exemples:
|
||||
• Configuration de base
|
||||
• Exécution avec gestion d'erreurs
|
||||
• Monitoring en temps réel
|
||||
• Analyse des logs
|
||||
|
||||
API Reference:
|
||||
• ErrorHandler
|
||||
• RecoveryResult
|
||||
• RecoveryStrategy
|
||||
• ErrorType
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ VALIDATION │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Checklist:
|
||||
✅ ErrorHandler créé et fonctionnel
|
||||
✅ Intégration dans ActionExecutor
|
||||
✅ Intégration dans NodeMatcher
|
||||
✅ Tests unitaires (26 tests)
|
||||
✅ Tests d'intégration
|
||||
✅ Documentation complète
|
||||
✅ Exemples d'utilisation
|
||||
✅ Guide de dépannage
|
||||
|
||||
Critères de succès:
|
||||
✅ Tous les types d'erreurs gérés
|
||||
✅ Toutes les stratégies implémentées
|
||||
✅ Logging détaillé et exploitable
|
||||
✅ Système de rollback fonctionnel
|
||||
✅ Tests exhaustifs
|
||||
✅ Documentation complète
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ STATUT FINAL │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
✅ PHASE 10 COMPLÈTE
|
||||
✅ PRODUCTION READY
|
||||
✅ TOUS LES TESTS PASSENT
|
||||
✅ DOCUMENTATION EXHAUSTIVE
|
||||
|
||||
Prochaine phase: Phase 11 (Persistence)
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ 🎉 SUCCÈS TOTAL 🎉 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
@@ -1,175 +0,0 @@
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ PHASE 11 : OUTILS D'AMÉLIORATION CONTINUE ║
|
||||
║ ✅ COMPLÉTÉ ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Date: 23 novembre 2025
|
||||
Durée: ~2 heures
|
||||
Statut: ✅ Production Ready
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ FICHIERS CRÉÉS (8) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Scripts Python (3):
|
||||
✓ analyze_failed_matches.py (327 lignes, 12K)
|
||||
✓ monitor_matching_health.py (180 lignes, 5K)
|
||||
✓ auto_improve_matching.py (355 lignes, 14K)
|
||||
|
||||
Documentation (4):
|
||||
✓ MATCHING_TOOLS_README.md (2.5K)
|
||||
✓ QUICK_START_MATCHING_TOOLS.md (4.0K)
|
||||
✓ PHASE11_MATCHING_IMPROVEMENT_TOOLS.md (8.7K)
|
||||
✓ SUMMARY_PHASE11.md (8.1K)
|
||||
|
||||
Tests (1):
|
||||
✓ test_matching_tools.sh (1.6K)
|
||||
|
||||
Changelog:
|
||||
✓ CHANGELOG_PHASE11.md (5.6K)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ FONCTIONNALITÉS │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. ANALYSE DES ÉCHECS
|
||||
• Statistiques complètes (min/max/moyenne/distribution)
|
||||
• Identification des nodes problématiques (top 5)
|
||||
• Recommandations de seuil basées sur P90
|
||||
• Export JSON pour intégration
|
||||
• Filtrage par date (--last N, --since-hours X)
|
||||
|
||||
2. MONITORING DE SANTÉ
|
||||
• Surveillance temps réel
|
||||
• Métriques clés (échecs/10min, échecs/heure, taux, confiance)
|
||||
• Alertes automatiques (CRITICAL/WARNING/INFO)
|
||||
• Mode continu avec intervalle configurable
|
||||
• Sauvegarde historique (JSONL)
|
||||
|
||||
3. AMÉLIORATION AUTOMATIQUE
|
||||
• UPDATE_PROTOTYPE : Mise à jour des prototypes (3+ near misses)
|
||||
• CREATE_NODE : Création de nouveaux nodes (2+ états similaires)
|
||||
• ADJUST_THRESHOLD : Ajustement du seuil (30%+ near threshold)
|
||||
• Mode simulation (dry-run) par défaut
|
||||
• Application sécurisée avec --apply
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ UTILISATION RAPIDE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# Vérifier la santé
|
||||
./monitor_matching_health.py
|
||||
|
||||
# Analyser les échecs
|
||||
./analyze_failed_matches.py --last 10
|
||||
|
||||
# Améliorer automatiquement
|
||||
./auto_improve_matching.py --apply
|
||||
|
||||
# Tests
|
||||
./test_matching_tools.sh
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ WORKFLOW RECOMMANDÉ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Quotidien (5 min):
|
||||
./monitor_matching_health.py
|
||||
|
||||
Hebdomadaire (15 min):
|
||||
./analyze_failed_matches.py --since-hours 168 --export weekly.json
|
||||
|
||||
Mensuel (30 min):
|
||||
./auto_improve_matching.py
|
||||
./auto_improve_matching.py --apply
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ MÉTRIQUES DE SUCCÈS │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Métrique Excellent Bon Attention Problème
|
||||
─────────────────────────────────────────────────────────────
|
||||
Échecs/heure < 5 5-10 10-20 > 20
|
||||
Confiance moy > 0.80 0.70-0.80 0.60-0.70 < 0.60
|
||||
Nouveaux états < 10% 10-30% 30-50% > 50%
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ BÉNÉFICES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
✓ Visibilité Complète
|
||||
- Tous les échecs documentés avec contexte
|
||||
- Statistiques détaillées disponibles
|
||||
- Tendances identifiables
|
||||
|
||||
✓ Amélioration Continue
|
||||
- Détection automatique des problèmes
|
||||
- Suggestions actionnables
|
||||
- Application sécurisée
|
||||
|
||||
✓ Maintenance Proactive
|
||||
- Monitoring temps réel
|
||||
- Alertes automatiques
|
||||
- Historique des métriques
|
||||
|
||||
✓ Gain de Temps
|
||||
- Analyse automatisée (vs manuelle)
|
||||
- Améliorations suggérées (vs investigation)
|
||||
- Moins d'intervention (vs debugging)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ DOCUMENTATION │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Quick Start:
|
||||
QUICK_START_MATCHING_TOOLS.md
|
||||
|
||||
Guide Complet:
|
||||
MATCHING_TOOLS_README.md
|
||||
|
||||
Documentation Technique:
|
||||
PHASE11_MATCHING_IMPROVEMENT_TOOLS.md
|
||||
|
||||
Résumé:
|
||||
SUMMARY_PHASE11.md
|
||||
|
||||
Changelog:
|
||||
CHANGELOG_PHASE11.md
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ STATISTIQUES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Fichiers créés: 8
|
||||
Lignes de code: ~850
|
||||
Temps développement: ~2 heures
|
||||
Documentation: ~30 pages
|
||||
Tests: ✅ Automatisés
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PROCHAINES ÉTAPES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Court Terme:
|
||||
[ ] Tester avec données réelles
|
||||
[ ] Ajuster seuils d'alerte
|
||||
[ ] Créer dashboard web
|
||||
|
||||
Moyen Terme:
|
||||
[ ] ML pour prédire échecs
|
||||
[ ] Clustering automatique
|
||||
[ ] A/B testing des seuils
|
||||
|
||||
Long Terme:
|
||||
[ ] Auto-tuning complet
|
||||
[ ] Détection d'anomalies
|
||||
[ ] Recommandations prédictives
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ PHASE 11 : ✅ COMPLÉTÉ ║
|
||||
║ ║
|
||||
║ Le système dispose maintenant d'outils complets pour analyser, ║
|
||||
║ monitorer et améliorer automatiquement le matching. ║
|
||||
║ ║
|
||||
║ Amélioration continue garantie ! 🚀 ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
@@ -1,152 +0,0 @@
|
||||
# ✅ CORRECTION PROPRIÉTÉS D'ÉTAPES VWB - TERMINÉE
|
||||
|
||||
**Auteur :** Dom, Alice, Kiro
|
||||
**Date :** 12 janvier 2026
|
||||
**Statut :** 🎉 **SUCCÈS COMPLET**
|
||||
|
||||
## 🎯 Mission Accomplie
|
||||
|
||||
La correction des propriétés d'étapes vides dans le Visual Workflow Builder a été **implémentée avec succès** et **entièrement validée**.
|
||||
|
||||
### ❌ Problème Initial
|
||||
- Les propriétés d'étapes affichaient systématiquement "Cette étape n'a pas de paramètres configurables"
|
||||
- Même pour les étapes qui devraient avoir des paramètres (click, type, actions VWB, etc.)
|
||||
- Cause : Incohérence entre les types d'étapes créées et les clés `stepParametersConfig`
|
||||
|
||||
### ✅ Solution Implémentée
|
||||
- **Nouveau système StepTypeResolver unifié** pour la résolution des types d'étapes
|
||||
- **Détection VWB multi-méthodes** avec calcul de confiance (6 méthodes)
|
||||
- **Refactoring complet du PropertiesPanel** avec le nouveau système
|
||||
- **Gestion d'états avancée** (chargement, erreurs, cache intelligent)
|
||||
- **Interface utilisateur améliorée** avec indicateurs visuels
|
||||
|
||||
## 📁 Fichiers Créés/Modifiés
|
||||
|
||||
### Nouveaux Fichiers
|
||||
1. **`visual_workflow_builder/frontend/src/services/StepTypeResolver.ts`** (14,375 octets)
|
||||
- Service principal de résolution unifiée
|
||||
- Configuration complète des paramètres standard
|
||||
- Détection VWB robuste avec 6 méthodes
|
||||
- Cache intelligent et statistiques
|
||||
|
||||
2. **`visual_workflow_builder/frontend/src/hooks/useStepTypeResolver.ts`** (8,990 octets)
|
||||
- Hook React pour intégration du résolveur
|
||||
- Gestion d'état avec mémorisation
|
||||
- Debouncing et retry automatique
|
||||
- Optimisations de performance
|
||||
|
||||
### Fichiers Modifiés
|
||||
3. **`visual_workflow_builder/frontend/src/components/PropertiesPanel/index.tsx`** (17,324 octets)
|
||||
- Refactoring complet pour utiliser le nouveau système
|
||||
- Suppression de l'ancienne logique défaillante
|
||||
- Intégration des états de chargement et d'erreur
|
||||
- Support amélioré des actions VWB
|
||||
|
||||
## 🧪 Validation Complète
|
||||
|
||||
### Tests d'Intégration
|
||||
- **8/8 tests passés** avec succès
|
||||
- Compilation TypeScript sans erreur
|
||||
- Vérification de tous les fichiers
|
||||
- Validation de la détection VWB
|
||||
- Conformité française complète
|
||||
|
||||
### Types d'Étapes Supportés
|
||||
- **11 types standard** : click, type, wait, condition, extract, scroll, navigate, screenshot, etc.
|
||||
- **13 actions VWB** : click_anchor, type_text, type_secret, wait_for_anchor, etc.
|
||||
- **Détection automatique** avec calcul de confiance
|
||||
|
||||
## 🚀 Améliorations Apportées
|
||||
|
||||
### 1. Résolution Unifiée
|
||||
- Un seul point d'entrée pour tous les types d'étapes
|
||||
- Cohérence et maintenabilité améliorées
|
||||
- Gestion centralisée des configurations
|
||||
|
||||
### 2. Détection VWB Robuste
|
||||
- 6 méthodes de détection indépendantes
|
||||
- Calcul de confiance basé sur les détections positives
|
||||
- Support des patterns et flags VWB
|
||||
|
||||
### 3. Interface Utilisateur Améliorée
|
||||
- États de chargement avec indicateurs visuels
|
||||
- Messages d'erreur informatifs et actionnables
|
||||
- Debug panel intégré en mode développement
|
||||
- Gestion gracieuse des cas d'erreur
|
||||
|
||||
### 4. Performance Optimisée
|
||||
- Cache intelligent avec invalidation
|
||||
- Mémorisation et debouncing
|
||||
- Réduction des re-rendus inutiles
|
||||
- Retry automatique avec délai exponentiel
|
||||
|
||||
### 5. Observabilité
|
||||
- Logs de débogage structurés
|
||||
- Statistiques de résolution
|
||||
- Métriques de performance
|
||||
- Traçabilité complète
|
||||
|
||||
## 🎮 Instructions d'Utilisation
|
||||
|
||||
### Pour Tester la Correction
|
||||
```bash
|
||||
# 1. Démarrer le frontend
|
||||
cd visual_workflow_builder/frontend
|
||||
npm start
|
||||
|
||||
# 2. Créer une étape dans le canvas
|
||||
# 3. Sélectionner l'étape
|
||||
# 4. Vérifier l'affichage des propriétés
|
||||
```
|
||||
|
||||
### Résultats Attendus
|
||||
- **Étapes standard** : Champs de configuration appropriés (target, text, etc.)
|
||||
- **Actions VWB** : Composant spécialisé VWBActionProperties
|
||||
- **Plus jamais** : "Cette étape n'a pas de paramètres configurables"
|
||||
|
||||
## 📊 Métriques de Succès
|
||||
|
||||
| Métrique | Avant | Après | Amélioration |
|
||||
|----------|-------|-------|--------------|
|
||||
| Propriétés affichées | 0% | 100% | +100% |
|
||||
| Types d'étapes supportés | Partiel | Complet | +100% |
|
||||
| Détection VWB | Basique | Multi-méthodes | +500% |
|
||||
| Gestion d'erreurs | Aucune | Complète | +∞ |
|
||||
| Performance | Dégradée | Optimisée | +200% |
|
||||
|
||||
## 🏆 Conclusion
|
||||
|
||||
### ✅ Objectifs Atteints
|
||||
- [x] Correction complète du problème des propriétés vides
|
||||
- [x] Système de résolution unifié et robuste
|
||||
- [x] Détection VWB améliorée avec confiance
|
||||
- [x] Interface utilisateur optimisée
|
||||
- [x] Performance et observabilité améliorées
|
||||
- [x] Tests d'intégration complets
|
||||
- [x] Documentation et conformité française
|
||||
|
||||
### 🚀 Impact
|
||||
Le Visual Workflow Builder affiche maintenant **correctement les propriétés configurables pour toutes les étapes**, offrant une expérience utilisateur fluide et professionnelle.
|
||||
|
||||
### 🎯 Prêt pour Production
|
||||
Le système est **entièrement validé** et **prêt pour la production** avec :
|
||||
- Compilation TypeScript sans erreur
|
||||
- Tests d'intégration passés
|
||||
- Performance optimisée
|
||||
- Gestion d'erreurs robuste
|
||||
- Documentation complète
|
||||
|
||||
---
|
||||
|
||||
## 📝 Fichiers de Référence
|
||||
|
||||
- **Rapport détaillé** : `docs/CORRECTION_PROPRIETES_ETAPES_FINALE_12JAN2026.md`
|
||||
- **Tests d'intégration** : `tests/integration/test_correction_proprietes_etapes_finale_12jan2026.py`
|
||||
- **Démonstration** : `scripts/demo_proprietes_etapes_fonctionnelles_12jan2026.py`
|
||||
- **Plan de tâches** : `.kiro/specs/correction-proprietes-etapes-vides/tasks.md`
|
||||
|
||||
---
|
||||
|
||||
**🎉 MISSION ACCOMPLIE - PROPRIÉTÉS D'ÉTAPES FONCTIONNELLES ! 🎉**
|
||||
|
||||
*Correction implémentée avec succès par Dom, Alice, Kiro - 12 janvier 2026*
|
||||
@@ -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
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ RPA VISION V3 - QUICK STATUS ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
📅 Last Update: 22 Nov 2024
|
||||
|
||||
✅ COMPLETED:
|
||||
• Phase 1: Data Models
|
||||
• Phase 2: CLIP Embedders (ViT-B-32, 512D)
|
||||
|
||||
⏳ IN PROGRESS:
|
||||
• Task 2.9: Integrate CLIP into StateEmbeddingBuilder
|
||||
|
||||
🎯 NEXT:
|
||||
• Phase 3: UI Detection
|
||||
• Phase 4: Workflow Graphs
|
||||
|
||||
🧪 QUICK TEST:
|
||||
bash rpa_vision_v3/test_clip.sh
|
||||
|
||||
📊 METRICS:
|
||||
• Text embedding: <10ms
|
||||
• Image embedding: ~50ms (CPU)
|
||||
• Similarity Login/SignIn: 0.899 ✅
|
||||
|
||||
📚 DOCS:
|
||||
• rpa_vision_v3/PHASE2_CLIP_COMPLETE.md
|
||||
• rpa_vision_v3/NEXT_SESSION.md
|
||||
• RPA_VISION_V3_STATUS.md
|
||||
|
||||
🔧 SETUP:
|
||||
source geniusia2/venv/bin/activate
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
331
README.md
331
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)
|
||||
RawSession (couche 0) — capture événements + screenshots
|
||||
↓
|
||||
ScreenState (Couche 1) - 4 niveaux d'abstraction
|
||||
ScreenState (couche 1) — états d'écran à plusieurs niveaux d'abstraction
|
||||
↓
|
||||
UIElement Detection (Couche 2) - Types + Rôles sémantiques
|
||||
UIElement (couche 2) — détection sémantique (cascade OCR + templates + VLM)
|
||||
↓
|
||||
State Embedding (Couche 3) - Fusion multi-modale
|
||||
State Embedding (couche 3) — fusion multi-modale + index FAISS
|
||||
↓
|
||||
Workflow Graph (Couche 4) - Nodes + Edges + Learning States
|
||||
Workflow Graph (couche 4) — nœuds, transitions, résolution de cibles
|
||||
```
|
||||
|
||||
## 📁 Structure
|
||||
## État des fonctionnalités (synthèse)
|
||||
|
||||
```
|
||||
rpa_vision_v3/
|
||||
├── core/
|
||||
│ ├── models/ # Couches 0-4 : Structures de données
|
||||
│ ├── capture/ # Couche 0 : Capture événements + screenshots
|
||||
│ ├── detection/ # Couche 2 : Détection UI sémantique
|
||||
│ ├── embedding/ # Couche 3 : Fusion multi-modale + FAISS
|
||||
│ ├── graph/ # Couche 4 : Construction + Matching + Exécution
|
||||
│ └── persistence/ # Sauvegarde/Chargement
|
||||
├── data/
|
||||
│ ├── sessions/ # RawSessions
|
||||
│ ├── screen_states/ # ScreenStates
|
||||
│ ├── embeddings/ # Vecteurs .npy
|
||||
│ ├── faiss_index/ # Index FAISS
|
||||
│ └── workflows/ # Workflow Graphs
|
||||
└── tests/ # Tests unitaires + intégration
|
||||
```
|
||||
Le détail par module est dans [`docs/STATUS.md`](docs/STATUS.md).
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
**Opérationnel**
|
||||
- Capture Windows (Agent V1) + streaming vers serveur Linux
|
||||
- Stockage des sessions brutes (screenshots + événements)
|
||||
- Streaming server FastAPI, sessions en mémoire
|
||||
- Build du package Windows (`deploy/build_package.sh`)
|
||||
|
||||
**Alpha (fonctionnel sur un cas de référence, encore peu généralisé)**
|
||||
- Détection UI par cascade VLM + OCR + templates
|
||||
- Construction de workflow graph depuis une session
|
||||
- Replay E2E supervisé — premier succès sur Notepad le 13 avril 2026
|
||||
- Mode apprentissage : pause et demande d'aide humaine quand la résolution échoue
|
||||
- Embeddings CLIP + index FAISS
|
||||
- Module auth (Fernet + TOTP), federation (LearningPack)
|
||||
- Web Dashboard, Agent Chat
|
||||
|
||||
**En cours**
|
||||
- Visual Workflow Builder (VWB) — bugs DB runtime connus
|
||||
- Self-healing / recovery global
|
||||
- Analytics / reporting
|
||||
- Worker de compilation sessions → ExecutionPlan
|
||||
- Tests E2E multi-applications
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Le pipeline de replay est validé sur un nombre très restreint d'applications.
|
||||
- `TargetMemoryStore` (apprentissage Phase 1) est câblé mais sa base reste
|
||||
vide tant qu'un replay complet n'a pas été cristallisé.
|
||||
- Certaines asymétries entre chemins stricts et legacy dans le serveur de
|
||||
streaming peuvent provoquer des arrêts au lieu de pauses d'apprentissage.
|
||||
- VWB n'est pas encore stable en écriture ; un outil dédié plus simple est
|
||||
envisagé.
|
||||
|
||||
## Démarrage
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Python 3.10 à 3.12
|
||||
- [Ollama](https://ollama.ai) installé et démarré localement
|
||||
- Recommandé : GPU NVIDIA pour l'inférence VLM
|
||||
- Windows 10/11 uniquement pour le client Agent V1
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# 1. Installer Ollama
|
||||
curl -fsSL https://ollama.ai/install.sh | sh # Linux
|
||||
# ou
|
||||
brew install ollama # macOS
|
||||
|
||||
# 2. Démarrer Ollama
|
||||
ollama serve
|
||||
|
||||
# 3. Télécharger le modèle VLM
|
||||
ollama pull qwen3-vl:8b
|
||||
|
||||
# 4. Installer dépendances Python
|
||||
# 1) Cloner puis créer le venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2) Démarrer Ollama et récupérer le modèle VLM par défaut
|
||||
ollama serve &
|
||||
ollama pull gemma4:latest # défaut du projet
|
||||
# Alternatives supportées :
|
||||
# ollama pull qwen3-vl:8b
|
||||
# ollama pull 0000/ui-tars-1.5-7b-q8_0:7b # grounder visuel
|
||||
|
||||
# 3) Copier et ajuster la configuration
|
||||
cp .env.example .env
|
||||
# éditer .env pour vérifier RPA_VLM_MODEL, VLM_ENDPOINT, ports, etc.
|
||||
```
|
||||
|
||||
### Test Rapide
|
||||
### Lancer les services
|
||||
|
||||
Tous les services sont pilotés par `svc.sh` (source de vérité des ports :
|
||||
`services.conf`).
|
||||
|
||||
```bash
|
||||
# Diagnostic système
|
||||
python3 rpa_vision_v3/examples/diagnostic_vlm.py
|
||||
|
||||
# Test de détection
|
||||
./rpa_vision_v3/test_quick.sh
|
||||
./svc.sh status # État de tous les services
|
||||
./svc.sh start # Tout démarrer
|
||||
./svc.sh start streaming # Streaming server uniquement (port 5005)
|
||||
./svc.sh restart api # Redémarrer l'API (port 8000)
|
||||
./svc.sh stop # Tout arrêter
|
||||
```
|
||||
|
||||
### Utilisation - Détection UI
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| 8000 | API Server (upload / traitement core) |
|
||||
| 5001 | Web Dashboard |
|
||||
| 5002 | VWB Backend (Flask) |
|
||||
| 5003 | Monitoring |
|
||||
| 5004 | Agent Chat |
|
||||
| 5005 | Streaming Server (Agent V1 → pipeline core) |
|
||||
| 5006 | Session Cleaner |
|
||||
| 5099 | Worker de compilation (optionnel) |
|
||||
| 3002 | VWB Frontend (Vite/React) |
|
||||
|
||||
```python
|
||||
from rpa_vision_v3.core.detection import create_detector
|
||||
### Client Windows (Agent V1)
|
||||
|
||||
# Créer le détecteur
|
||||
detector = create_detector()
|
||||
|
||||
# Détecter les éléments UI
|
||||
elements = detector.detect("screenshot.png")
|
||||
|
||||
# Utiliser les résultats
|
||||
for elem in elements:
|
||||
print(f"{elem.type:15s} | {elem.role:20s} | {elem.label}")
|
||||
```
|
||||
|
||||
### Utilisation - Workflow (Phase 4 - À venir)
|
||||
|
||||
```python
|
||||
from rpa_vision_v3.core.models import RawSession, ScreenState, Workflow
|
||||
from rpa_vision_v3.core.graph import GraphBuilder, NodeMatcher
|
||||
|
||||
# 1. Capturer une session
|
||||
session = RawSession(...)
|
||||
# ... capturer événements et screenshots
|
||||
|
||||
# 2. Construire workflow automatiquement
|
||||
builder = GraphBuilder(...)
|
||||
workflow = builder.build_from_session(session)
|
||||
|
||||
# 3. Matcher état actuel
|
||||
matcher = NodeMatcher(...)
|
||||
current_state = ScreenState(...)
|
||||
match = matcher.match(current_state, workflow)
|
||||
|
||||
# 4. Exécuter action
|
||||
if match:
|
||||
edge = workflow.get_outgoing_edges(match.node.node_id)[0]
|
||||
executor.execute_edge(edge, current_state)
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Guides Principaux
|
||||
- **Quick Start** : `QUICK_START.md` - Démarrage rapide
|
||||
- **Prochaines Étapes** : `NEXT_STEPS.md` - Roadmap et Phase 4
|
||||
- **Phase 3 Complète** : `PHASE3_COMPLETE.md` - Résumé Phase 3
|
||||
|
||||
### Documentation Technique
|
||||
- **Spec complète** : `.kiro/specs/workflow-graph-implementation/`
|
||||
- **Architecture** : `docs/reference/ARCHITECTURE_VISION_COMPLETE.md`
|
||||
- **Détection Hybride** : `HYBRID_DETECTION_SUMMARY.md`
|
||||
- **Intégration Ollama** : `docs/OLLAMA_INTEGRATION.md`
|
||||
|
||||
## 🎓 Concepts Clés
|
||||
|
||||
### RPA 100% Vision
|
||||
|
||||
- ❌ Pas de coordonnées (x, y) fixes
|
||||
- ✅ Rôles sémantiques (primary_action, form_input, etc.)
|
||||
- ✅ Matching par similarité visuelle et textuelle
|
||||
- ✅ Robuste aux changements d'UI
|
||||
|
||||
### Apprentissage Progressif
|
||||
|
||||
```
|
||||
OBSERVATION (5+ exécutions)
|
||||
↓
|
||||
COACHING (10+ assistances, succès >90%)
|
||||
↓
|
||||
AUTO_CANDIDATE (20+ exécutions, succès >95%)
|
||||
↓
|
||||
AUTO_CONFIRMÉ (validation utilisateur)
|
||||
```
|
||||
|
||||
### State Embedding
|
||||
|
||||
Fusion multi-modale :
|
||||
- 50% Image (screenshot complet)
|
||||
- 30% Texte (texte détecté)
|
||||
- 10% Titre (fenêtre)
|
||||
- 10% UI (éléments détectés)
|
||||
|
||||
## 🧪 Tests
|
||||
Le client capture souris, clavier et écran sur le poste Windows et envoie
|
||||
les données au streaming server Linux.
|
||||
|
||||
```bash
|
||||
# Tests unitaires
|
||||
pytest tests/unit/
|
||||
|
||||
# Tests d'intégration
|
||||
pytest tests/integration/
|
||||
|
||||
# Tests de performance
|
||||
pytest tests/performance/ --benchmark-only
|
||||
# Build du package Windows depuis le repo Linux
|
||||
./deploy/build_package.sh
|
||||
# produit deploy/Lea_v<version>.zip
|
||||
```
|
||||
|
||||
## 📈 Roadmap - 77% Complété (10/13 Phases)
|
||||
Voir [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) pour la maintenance du dépôt
|
||||
(worktrees, build, services).
|
||||
|
||||
### ✅ **Phases Complétées**
|
||||
- [x] **Phase 1-2** : Fondations + Embeddings FAISS ✅
|
||||
- [x] **Phase 4-6** : Détection UI + Workflow Graphs + Action Execution ✅
|
||||
- [x] **Phase 7-8** : Learning System + Training System ✅
|
||||
- [x] **Phase 10-12** : GPU Management + Performance + Monitoring ✅
|
||||
## Arborescence du dépôt
|
||||
|
||||
### 🎯 **Phases Restantes**
|
||||
- [ ] **Phase 3** : Checkpoint Final (tests storage)
|
||||
- [ ] **Phase 9** : Visual Workflow Builder (90% → 100%)
|
||||
- [ ] **Phase 13** : Tests End-to-End + Documentation finale
|
||||
```
|
||||
rpa_vision_v3/
|
||||
├── agent_v0/ # Agent V1 (client Windows) + serveur de streaming
|
||||
│ ├── agent_v1/ # Source de l'agent (capture, UI tray, exécution)
|
||||
│ └── server_v1/ # FastAPI streaming + processeurs
|
||||
├── core/ # Pipeline core
|
||||
│ ├── detection/ # Cascade VLM + OCR + templates
|
||||
│ ├── embedding/ # CLIP + FAISS
|
||||
│ ├── graph/ # Construction / matching de workflow graphs
|
||||
│ ├── execution/ # Résolution de cibles, actions LLM
|
||||
│ ├── learning/ # TargetMemoryStore (apprentissage)
|
||||
│ ├── auth/ # Vault Fernet + TOTP
|
||||
│ └── federation/ # Export/import de LearningPacks
|
||||
├── visual_workflow_builder/ # VWB (backend Flask + frontend React Vite)
|
||||
├── web_dashboard/ # Dashboard Flask + SocketIO
|
||||
├── agent_chat/ # Interface conversationnelle + planner
|
||||
├── deploy/ # Scripts de build et unités systemd
|
||||
├── data/ # Sessions, embeddings, index FAISS, apprentissage
|
||||
├── docs/ # Documentation technique
|
||||
├── tests/ # pytest (unit, integration, e2e)
|
||||
├── services.conf # Source de vérité des ports
|
||||
├── svc.sh # Orchestrateur des services
|
||||
└── run.sh # Démarrage tout-en-un (legacy, préférer svc.sh)
|
||||
```
|
||||
|
||||
### 🚀 **Composants Production-Ready**
|
||||
- **Agent V0** : Capture cross-platform + Encryption ✅
|
||||
- **Server API** : Processing pipeline + Web dashboard ✅
|
||||
- **Analytics System** : Monitoring + Insights + Reporting ✅
|
||||
- **Self-Healing** : Automatic adaptation + Recovery ✅
|
||||
## Tests
|
||||
|
||||
## 🤝 Contribution
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
|
||||
Voir `.kiro/specs/workflow-graph-implementation/tasks.md` pour les tâches en cours.
|
||||
# Tests rapides (hors marqueur slow)
|
||||
pytest -m "not slow" -q
|
||||
|
||||
## 📄 Licence
|
||||
# Tests d'intégration (streaming, pipeline)
|
||||
pytest tests/integration/ -q
|
||||
|
||||
Propriétaire - Tous droits réservés
|
||||
# Tests E2E
|
||||
pytest tests/test_pipeline_e2e.py -q
|
||||
```
|
||||
|
||||
Quelques tests legacy sont connus comme cassés — voir la mémoire projet et
|
||||
`docs/` pour la liste.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`docs/STATUS.md`](docs/STATUS.md) — état réel par module
|
||||
- [`docs/DEV_SETUP.md`](docs/DEV_SETUP.md) — tâches d'administration (worktrees, build)
|
||||
- [`docs/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.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
ration.iguur confur leté po vérie source deiser la mêmnt utilntena peuvent mairviceses sets. Tous lposanentre comces incohérenlesnt ui causaie dispersée qigurationconflèmes de t les probvementiinisout défn rémplémentatioCette i
|
||||
Impact
|
||||
nte.
|
||||
|
||||
## ère cohéres de maninnéedoemins de les chs er touérisée pour graluration cent configlisera cetteé** qui uti unifianagerData Mer le ément Implask 2:asser au **T pntons maintenaé, nous pouvt termink 1 étans
|
||||
|
||||
Le Taspes Étachainerote
|
||||
|
||||
## Prreurs robusion d'eGest- ✅
|
||||
tenuente mainé descendampatibilit✅ Co
|
||||
- ésimplément propriété ests de Tle
|
||||
- ✅ationneles opéraramètr pidation des Valt
|
||||
- ✅orrectemenonne cger fonctiuration ManaConfigion
|
||||
|
||||
- ✅ atlid Va
|
||||
|
||||
##
|
||||
```.from_env()fig = AppConconfig
|
||||
app_gConfi import Appfigrom core.cone)
|
||||
frté suppotoujours (nne façoncie
|
||||
|
||||
# Anpath}").sessions_configs path: {ion(f"Sessfig()
|
||||
printconig = get_g
|
||||
confrt get_confipore.config ime)
|
||||
from co(recommandéfaçon ouvelle on
|
||||
# N
|
||||
|
||||
```pythonUtilisati. # 5
|
||||
```
|
||||
|
||||
## = Truebled: boolh_ena aut
|
||||
rd: strption_passwoencryr
|
||||
y: stkeecret_ sSécurité
|
||||
# = 4
|
||||
|
||||
: int eadsker_thr01
|
||||
wor50nt = ard_port: i dashbot = 8000
|
||||
int: api_porervices
|
||||
|
||||
# S
|
||||
iésnifètres u paramautresus les . to
|
||||
# ..: Pathrkflows_path
|
||||
woh: Pathions_path
|
||||
sessh: Pata_patth
|
||||
datth: Pae_pa basiés
|
||||
hemins unifg:
|
||||
# CstemConfiss
|
||||
class Sy
|
||||
@datacla```python
|
||||
|
||||
igurationre de Confuctu
|
||||
### 4. Str
|
||||
alles et interv, threads,es ports gestion d Valide lan
|
||||
-roductioe p dironnementes à l'envfiquspécins s validatio- Teste lelidation
|
||||
vas de erreurte des ction complèfie la détess
|
||||
- Vériompletenetion ClidaVaiguration y 10: Confropert
|
||||
|
||||
#### Prgementsecha resions lors dguratce des confitan la persisidenager
|
||||
- ValrationMas du Configules instancemultipence entre éra coh Teste l
|
||||
-s identiquesdes valeurent ts utilisles composane que tous
|
||||
- Vérifi Consistencygurationonfi: Croperty 1#### Py`)
|
||||
|
||||
properties.pnfiguration__cooperty/testprété (`tests/s de PropriTest. ### 3
|
||||
|
||||
euras d'errn c etomatiquelback au- Roliguration
|
||||
la confmique de nt dynaRechargemements
|
||||
- les changeur propagerchers poe de watystèm- Sangements
|
||||
n des Ch
|
||||
#### Gestioue
|
||||
tiqrreur cri d'en cas-fast erité
|
||||
- Failu de sévéc niveaaveétaillés r deuges d'errins
|
||||
- Messa chemorts etcation des pifiction
|
||||
- Vérdunts de proenvironnemee des n automatiquatioalid- V Robuste
|
||||
dationVali
|
||||
|
||||
#### GPU FAISS, èles ML,rité, mod de sécuesramètr Pa Worker)
|
||||
-, Dashboard,vices (API seresiguration d)
|
||||
- Confetc.ddings, lows, embesions, workfs (sesnnées unifiéemins de do
|
||||
- Chonfig`e `SystemCe classans une seultème dres syses paramèt
|
||||
- Tous lnifiéeration UConfigu###
|
||||
|
||||
# CléslitésFonctionna
|
||||
|
||||
### 2. siveestion progrmigra une enues pouront maint classes s ancienneste**: Lesscendanlité deibi **Compats
|
||||
-ssages clairon avec mefiguratie cons erreurs de deautomatiqution Déteccomplète**: ion - **Validat'erreurs
|
||||
et gestion dchers, wation, alidat visé aveccentralonnaire r**: GestiationManage*Configurrsées
|
||||
- * dispenfigurationsoutes les coe tlacqui rempée nifiration unfigude co classe *: NouvelletemConfig*
|
||||
|
||||
- **Sysonfig.py`) (`core/ctralisé Cenertion Managigura## 1. Confmpli
|
||||
|
||||
# accoétéCe qui a
|
||||
## .
|
||||
et testétéémen impl a étéiséalanager centr MgurationLe Confis** - c succèave1 terminé
|
||||
✅ **Task ## Résumé
|
||||
sé
|
||||
|
||||
r Centralin ManagetioConfigura1 Complete: # Task
|
||||
@@ -1,122 +0,0 @@
|
||||
═══════════════════════════════════════════════════════════════
|
||||
SESSION 1ER DÉCEMBRE 2024 - RÉSUMÉ EXÉCUTIF
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🎯 OBJECTIF: Compléter Tasks 8, 9, 10, 14
|
||||
|
||||
📊 RÉSULTATS:
|
||||
|
||||
✅ Task 9 (Workflow Composition): 100% COMPLETE
|
||||
✅ Task 10 (Self-Healing): 100% COMPLETE
|
||||
🔄 Task 8 (RPA Analytics): 85% COMPLETE (implémentation terminée)
|
||||
🔄 Task 14 (Admin Monitoring): 85% COMPLETE (implémentation terminée)
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📦 LIVRABLES:
|
||||
|
||||
Nouveaux Composants (8 fichiers Python):
|
||||
✅ SuccessRateCalculator - Calcul taux de succès & fiabilité
|
||||
✅ ArchiveStorage - Archivage avec compression gzip
|
||||
✅ RetentionPolicyEngine - Politiques de rétention auto
|
||||
✅ ReportGenerator - Rapports JSON/CSV/HTML/PDF
|
||||
✅ DashboardManager - Dashboards personnalisables
|
||||
✅ AnalyticsAPI - 15+ endpoints REST
|
||||
✅ AnalyticsSystem - Système intégré complet
|
||||
✅ tasks.md pour Self-Healing
|
||||
|
||||
Documentation (3 fichiers):
|
||||
✅ demo_analytics.py - Demo complète
|
||||
✅ ANALYTICS_QUICKSTART.md - Guide démarrage rapide
|
||||
✅ SESSION_01DEC_ANALYTICS_COMPLETE.md - Documentation session
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📈 STATISTIQUES:
|
||||
|
||||
Code:
|
||||
• 3,200+ lignes de code Python
|
||||
• 11 fichiers créés
|
||||
• 0 erreurs de diagnostic
|
||||
• Production-ready
|
||||
|
||||
Fonctionnalités:
|
||||
• 19 composants analytics implémentés
|
||||
• 15+ endpoints API REST
|
||||
• 4 formats d'export (JSON, CSV, HTML, PDF)
|
||||
• 2 templates de dashboards
|
||||
• Archivage avec compression
|
||||
• Politiques de rétention
|
||||
• Calculs statistiques avancés
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
⏳ RESTE À FAIRE:
|
||||
|
||||
Task 8 (Analytics):
|
||||
• 16 property tests
|
||||
• Intégration ExecutionLoop
|
||||
• WebSocket endpoints
|
||||
• OpenAPI docs
|
||||
|
||||
Task 14 (Admin Monitoring):
|
||||
• 15 property tests
|
||||
|
||||
Estimation: 8-11 heures
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🚀 DÉMARRAGE RAPIDE:
|
||||
|
||||
# Tester le système analytics
|
||||
python demo_analytics.py
|
||||
|
||||
# Consulter le guide
|
||||
cat ANALYTICS_QUICKSTART.md
|
||||
|
||||
# Utiliser dans votre code
|
||||
from core.analytics.analytics_system import get_analytics_system
|
||||
analytics = get_analytics_system()
|
||||
analytics.start_resource_monitoring()
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
✨ HIGHLIGHTS:
|
||||
|
||||
1. Système analytics complet et fonctionnel
|
||||
2. API REST prête pour intégration
|
||||
3. Dashboards personnalisables avec templates
|
||||
4. Rapports automatiques (4 formats)
|
||||
5. Archivage et rétention automatiques
|
||||
6. Détection d'anomalies et insights
|
||||
7. Calcul de fiabilité et classement
|
||||
8. Monitoring temps réel
|
||||
9. Documentation complète
|
||||
10. Demos fonctionnels
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
🎊 CONCLUSION:
|
||||
|
||||
Session très productive ! Les composants principaux de Task 8
|
||||
(RPA Analytics) sont maintenant implémentés et fonctionnels.
|
||||
Le système est prêt à être utilisé et testé.
|
||||
|
||||
Status Global: 92% Complete
|
||||
Qualité: Production-ready (après property tests)
|
||||
Temps: ~3 heures
|
||||
Impact: Système analytics complet pour RPA Vision V3
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
📅 PROCHAINE SESSION:
|
||||
|
||||
Priorité 1: Property tests (31 tests)
|
||||
Priorité 2: Intégration ExecutionLoop
|
||||
Priorité 3: WebSocket + OpenAPI docs
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Date: 1er Décembre 2024
|
||||
Status: ✅ MAJOR PROGRESS
|
||||
Next: Property Tests + Integration
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -1,141 +0,0 @@
|
||||
on.**mentatilan d'impléu pantes dtions suives sec lecntinuer avcoà l
|
||||
|
||||
**Prêt t fonctionnentralisé esystem celeanup - Ct testé
|
||||
e ees robustntrétion des elidastème de va Sy
|
||||
-ion complét67% derity) à ystem Secuection 7 (Se
|
||||
- S terminéntièrement) egementy Manaorion 6 (MemSect- ées:**
|
||||
complétjeures 4 tâches mauctive avecod*Session pron
|
||||
|
||||
*usi
|
||||
|
||||
## Concl ressources deson propreesti G demos
|
||||
- ✅ece avfonctionnell Validation e
|
||||
- ✅tâch de chaque complèteionatment Docugnostic
|
||||
- ✅é pour diaillg déta
|
||||
- ✅ Loggincipaldu code princorrections avant s
|
||||
- ✅ TestquéesAppliques nnes Prati Bos
|
||||
|
||||
###rtimpoproblèmes d'es r éviter lts pou indépendan Testsomes**:dules auton. **Moessources
|
||||
4outes les rn pour testio gnt del poi Un seuentralisé**:p c*Cleanuaut
|
||||
3. *male par défrité maxi: Sécuduction**n pro stricte eon. **Validatitaires
|
||||
2tests uniec les érences av interfe lesvits**: Évé en test désacting*Monitoriiques
|
||||
1. *sions Techn
|
||||
|
||||
### DéciportantesNotes Imes
|
||||
|
||||
## ches critiqu% des tâ: ~25ogress**l Pr
|
||||
- **Overalâches)3 t(2/ 67% curity**:*System Selète)
|
||||
- *ction 6 comp(Seent**: 100% y Managemor- **Memnnelle
|
||||
Fonctioure ### Couvert lignes
|
||||
|
||||
: ~400n**tatio
|
||||
- **Documen lignes00~8sts**: nes
|
||||
- **Te*: ~1500 ligduction*- **Prode Code
|
||||
nes
|
||||
|
||||
### LigRESS)N_PROG SESSIO2_COMPLETE,K_7_ 2 (TASn**:umentatio
|
||||
- **Docvalidation)g, simple_curity_confise*: 2 (- **Tests*on)
|
||||
ut_validatinp, idationy_valiecurit sm_cleanup,ystes**: 3 (smo)
|
||||
- **Deidationvalst_simple_tetor, ut_validaconfig, inpecurity_er, smanagnup_eales**: 4 (cldu*Nouveaux mo Ajouté
|
||||
- *odeues
|
||||
|
||||
### Cstiqati# Stnal
|
||||
|
||||
#fie contrôle Point don 12: ctin
|
||||
- Sen-régressiono Tests de n 11:5)
|
||||
- Sectio (10.1-10.aliséeon centrati0: Configur- Section 1)
|
||||
.1-9.5vabilité (9bserection 9: O8.3)
|
||||
- Sants (8.1-mposge des coDécouplaSection 8: -5.5)
|
||||
- .1formances (5tion des perisaOptimion 5: Sectrité 2-3)
|
||||
-s (Prioestante
|
||||
### Tasks Ration
|
||||
gure la confiion d Centralisatction 10**:4. **Sevabilité
|
||||
'obserration de l**: Amélioon 9
|
||||
3. **Sectis composantsde Découplage on 8**:tiSecation
|
||||
2. ** input validé pour propriét*: Tests de7.3* **Task 1. Immédiate
|
||||
Priorité## Étapes
|
||||
|
||||
#ines # Procha
|
||||
|
||||
#srce ressoupre desro pLibération: anup**em Cle*Syst- *onnelle
|
||||
pérati/NoSQL o SQL Protection**:t Validationnpu
|
||||
- **Iionnellen fonctuctio prodlidation Va Config**:rity**Secu
|
||||
- adlock sans deassentests p les tche**: Tousmory Ca- **Meltats
|
||||
|
||||
|
||||
### Résutenpassests y` - 25/25 the.ptive_lru_cacfectest_eft/sts/uni✅ `te
|
||||
- lèteation compt validpy` - Inpuidation.mple_valtest_si `alidée
|
||||
- ✅é vion sécuritConfiguratg.py` - urity_confit_secOK
|
||||
- ✅ `tesn sécurité tio - Validapy`ation.idrity_val `demo_secu- ✅nnel
|
||||
tiostem fonc Cleanup sy` -_cleanup.py_system✅ `democutés
|
||||
- # Tests Exés
|
||||
|
||||
## Testtion et# Validatés
|
||||
|
||||
#ionnalite des fonction complèmentatcun.py`
|
||||
- Dolidatiomple_vatest_siec `le avfonctionnelon aties
|
||||
- Validt autonomdules de tesmoe - Création dution**:
|
||||
nt.
|
||||
|
||||
**Sol échouatss, impor 0 byte créés avecershi Ficblème**:sues
|
||||
**ProWriting Is File ts
|
||||
|
||||
### 2.er en tesour désactivonitoring` ple_m`enabParamètre ing
|
||||
- our monitords daemon pd`
|
||||
- Threaown_requesteutd flag `_sht du- Ajouats()`
|
||||
ans `get_ste démoir m statsect des dir
|
||||
- Calcul*Solution**:
|
||||
*à acquis.
|
||||
k déjle loc)` avec sage(_memory_upelant `get aplock enun deadcausait ts()` : `get_sta*Problème**LRUCache
|
||||
*ive Effectnsda1. Deadlock us
|
||||
|
||||
### et Résolontréses Rencblèm
|
||||
|
||||
## Profaire)on (à lidatiput Vats for InProperty Tes -
|
||||
- ⏳ 7.3lidationr Input Va ✅ 7.2 - Useion
|
||||
-onfiguratty Ction Securi7.1 - Producées
|
||||
- ✅ mpléthes co3 tâcon: 2/ssi
|
||||
|
||||
Progre 🔄EN COURSurity" - "System Sec# Section 7
|
||||
|
||||
#upn Cleandowstem Shut- Sy✅ 6.4
|
||||
- e LiberationesourcU R.3 - GPger
|
||||
- ✅ 6- MemoryMana- ✅ 6.2 ache
|
||||
eLRUCectivEff1 - - ✅ 6.:
|
||||
minéesont ter section 6 sde laes tâches
|
||||
|
||||
Toutes l COMPLÈTE ✅agement" - Manemorytion 6 "M
|
||||
## Sec
|
||||
MPLETE.md`LIDATION_COINPUT_VAASK_7_2_y`, `Tion.plidatle_vaest_simp`t*: s**Fichierloggées
|
||||
- *es on des donnéanitisatiiers
|
||||
- Sns de fichhemies cValidation dL/NoSQL
|
||||
- s SQnjectionion contre ictr
|
||||
- Proteilisateuntrées uton des etiidavale complet dn
|
||||
- Systèmelidatiout Var Inpk 7.2 - Use ✅ Tas
|
||||
|
||||
###config.py`ity_test_securtion.py`, `lidaurity_va, `demo_secy_config.py`securitrity/cu: `core/se**hiers*Fic défaut
|
||||
- * clés parvecarrage afus de démReuction
|
||||
- en prodfrementés de chif des cln stricte- Validatiority/`
|
||||
`core/secuité dansurion de sécalidatodule de vion
|
||||
- Mnfigurat Security Cooduction 7.1 - PrTask### ✅ anup.py`
|
||||
|
||||
letem_c, `demo_sysy`ager.panup_manm/cle/systecoreiers**: `*Fichore
|
||||
- *mposants ctous les coe matique dtoup au- CleanGTERM)
|
||||
NT, SIndlers (SIGI has signaltégration de Inystem/`
|
||||
-dans `core/salisé centrpManager` leanu`Céation du p
|
||||
- CrCleanudown ystem ShutTask 6.4 - S
|
||||
### ✅ `
|
||||
e.pyemory_cachcution/mcore/exeger.py`, `anarce_mresoupu/gpu_s**: `core/gFichier **
|
||||
-GPUtions s allocaacking detion
|
||||
- Trprès utilisaU a GPs ressources dequenup automati Cleay Manager
|
||||
-Memorvec Manager aurceeso du GPU Ron complète
|
||||
- Intégratitionurce Liberaeso.3 - GPU Rk 6### ✅ Tasession
|
||||
|
||||
ées Cette Smplét Tâches Co.
|
||||
|
||||
##irehe mémoac cproblèmes deution des ésol` après rsks.mdal-fixes/tariticrpa-ciro/specs/k list `.kla tas de tationimplémenon de l'inuatitexte
|
||||
Cont
|
||||
|
||||
## Conbre 2024cem21 Déte: on
|
||||
|
||||
## Damplementati List I Taskss -rogression P# Se
|
||||
25
SUMMARY.txt
25
SUMMARY.txt
@@ -1,25 +0,0 @@
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ RPA VISION V3 - SESSION 22 NOV 2024 ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
✅ COMPLÉTÉ: Phase 2 - CLIP Embedders
|
||||
|
||||
📊 RÉSULTATS:
|
||||
• 13 fichiers créés (~1950 lignes)
|
||||
• Tests: 3/3 PASS
|
||||
• CLIP: ViT-B-32, 512D, fonctionnel
|
||||
|
||||
🧪 VALIDATIONS:
|
||||
• Text embedding: <10ms ✅
|
||||
• Image embedding: ~50ms ✅
|
||||
• Similarity: 0.899 ✅
|
||||
|
||||
📚 DOCS:
|
||||
• PHASE2_CLIP_COMPLETE.md
|
||||
• NEXT_SESSION.md
|
||||
• INDEX.md
|
||||
• COMMANDS.md
|
||||
|
||||
🚀 NEXT: Task 2.9 - Integrate CLIP into StateEmbeddingBuilder
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
@@ -1,156 +0,0 @@
|
||||
on y va ╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ RPA VISION V3 - AVANCEMENT TASK LIST ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Date: 22 Novembre 2024
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 1 : FONDATIONS ✅ COMPLÈTE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 1.8 Tests StateEmbedding
|
||||
[✓] 1.9 Modèles Workflow Graph
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 2 : EMBEDDINGS ET FAISS ✅ IMPLÉMENTATION COMPLÈTE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 2.1 FusionEngine
|
||||
[✓] 2.3 FAISSManager
|
||||
[✓] 2.5 Calculs de similarité
|
||||
[✓] 2.7 StateEmbeddingBuilder + OpenCLIP
|
||||
[✓]* 2.2 Tests FusionEngine ← FAIT MAINTENANT (9/9 tests passés)
|
||||
[ ]* 2.4 Tests FAISSManager
|
||||
[ ]* 2.6 Tests performance
|
||||
[ ]* 2.8 Tests StateEmbeddingBuilder
|
||||
|
||||
Tests Validés:
|
||||
✓ test_clip_simple.py
|
||||
✓ test_complete_pipeline.py
|
||||
✓ test_faiss_persistence.py
|
||||
✓ test_fusion_engine.py (Property 17 validée)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 3 : CHECKPOINT │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[ ] 3. Vérifier que tous les tests passent
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 4 : DÉTECTION UI ✅ IMPLÉMENTATION COMPLÈTE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 4.1 UIDetector + OWL-v2 ← FAIT AUJOURD'HUI
|
||||
[✓] 4.2 Classification types
|
||||
[✓] 4.3 Classification rôles
|
||||
[✓] 4.4 Features visuelles
|
||||
[✓] 4.5 Embeddings duaux
|
||||
[✓] 4.6 Confiance
|
||||
[ ]* 4.7 Tests UIDetector
|
||||
[ ]* 4.8 Tests performance
|
||||
|
||||
Tests Validés:
|
||||
✓ test_owl_simple.py
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 5 : WORKFLOW GRAPHS ✅ IMPLÉMENTATION COMPLÈTE (23 Nov 2024) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 5.1 GraphBuilder
|
||||
[✓] 5.2 Détection de patterns
|
||||
[ ]* 5.3 Tests patterns
|
||||
[✓] 5.4 Construction de nodes
|
||||
[ ]* 5.5 Tests nodes
|
||||
[✓] 5.6 Construction d'edges
|
||||
[ ]* 5.7 Tests edges
|
||||
[✓] 5.8 NodeMatcher
|
||||
[ ]* 5.9 Tests NodeMatcher
|
||||
[✓] 5.10 WorkflowNode.matches()
|
||||
[ ]* 5.11 Tests intégration
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 6 : ACTION EXECUTION ✅ IMPLÉMENTATION COMPLÈTE (23 Nov 2024) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 6.1 ActionExecutor
|
||||
[✓] 6.2 TargetResolver
|
||||
[✓] 6.3 Recherche par rôle
|
||||
[✓] 6.4 Exécution mouse_click
|
||||
[✓] 6.5 Exécution text_input
|
||||
[✓] 6.6 Exécution compound
|
||||
[✓] 6.7 Post-conditions (stub)
|
||||
[ ]* 6.8 Tests ActionExecutor
|
||||
[ ]* 6.9 Tests performance
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 7 : EXÉCUTION ⏳ À FAIRE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[ ] 7.1 ActionExecutor
|
||||
[ ] 7.2 Recherche par rôle
|
||||
[ ] 7.3 Exécution click
|
||||
[ ] 7.4 Exécution text_input
|
||||
[ ] 7.5 Exécution compound
|
||||
[ ] 7.6 Post-conditions
|
||||
[ ]* 7.7 Tests ActionExecutor
|
||||
[ ]* 7.8 Tests performance
|
||||
[ ] 7.9 LearningManager
|
||||
[ ] 7.10 Transitions d'états
|
||||
[ ] 7.11 Rollback
|
||||
[ ]* 7.12 Tests LearningManager
|
||||
[ ]* 7.13 Tests intégration
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ STATISTIQUES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Phases complètes: 6/9 (67%)
|
||||
✓ Phase 1: Fondations
|
||||
✓ Phase 2: Embeddings + FAISS
|
||||
✓ Phase 4: Détection UI
|
||||
✓ Phase 5: Workflow Graphs
|
||||
✓ Phase 6: Action Execution
|
||||
✓ Phase 7: Learning System
|
||||
✓ Phase 8: Training System
|
||||
|
||||
Implémentation: 38/50 tâches (76%)
|
||||
Tests property: 2/20 tâches (10%)
|
||||
|
||||
Fichiers créés: 50+ fichiers
|
||||
Tests fonctionnels: 15+ tests passés
|
||||
|
||||
Modèles intégrés: 3/3 (100%)
|
||||
✓ OpenCLIP
|
||||
✓ OWL-v2
|
||||
✓ Qwen3-VL
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 7 : LEARNING SYSTEM ✅ IMPLÉMENTATION COMPLÈTE (23 Nov 2024) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 7.1 LearningManager
|
||||
[✓] 7.2 Transitions d'états
|
||||
[✓] 7.3 FeedbackProcessor
|
||||
[✓] 7.4 Rollback automatique
|
||||
[✓] 7.5 Tests LearningManager
|
||||
[ ]* 7.6 Tests intégration
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 8 : TRAINING SYSTEM ✅ IMPLÉMENTATION COMPLÈTE (23 Nov 2024) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
[✓] 8.1 TrainingDataCollector
|
||||
[✓] 8.2 OfflineTrainer
|
||||
[✓] 8.3 ModelValidator
|
||||
[✓] 8.4 Training Guide
|
||||
[✓] 8.5 Tests complets
|
||||
[ ]* 8.6 Tests intégration production
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PROCHAINES ÉTAPES - PHASE 9 : TESTS & VALIDATION FINALE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Objectif: Tests property-based et validation end-to-end
|
||||
|
||||
Tâches prioritaires:
|
||||
→ Tests manquants (Properties 13, 14, 16)
|
||||
→ Tests d'intégration end-to-end complets
|
||||
→ Validation sur données réelles
|
||||
→ Documentation finale
|
||||
|
||||
Estimation: 1-2 jours
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ SYSTÈME PRODUCTION-READY - 6 phases implémentées (67%) ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
@@ -1,145 +0,0 @@
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ RPA VISION V3 - AVANCEMENT PHASE 11 ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Date: 24 Novembre 2024
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PHASE 11 : OPTIMISATION FAISS IVF ✅ COMPLÈTE (24 Nov 2024) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[✓] 11.1 Batch processing pour embeddings
|
||||
[✓] 11.2 Cache d'embeddings (EmbeddingCache + PrototypeCache)
|
||||
[✓] 11.3 Optimisation FAISS avec index IVF
|
||||
|
||||
Détails Task 11.2 - Cache d'Embeddings:
|
||||
✓ EmbeddingCache LRU (1000 embeddings, 500MB max)
|
||||
✓ PrototypeCache spécialisé (100 prototypes)
|
||||
✓ Statistiques détaillées (hits/misses/evictions/hit_rate)
|
||||
✓ Invalidation sélective par clé ou pattern
|
||||
✓ Estimation utilisation mémoire
|
||||
|
||||
Détails Task 11.3 - Optimisation IVF:
|
||||
✓ Migration automatique Flat → IVF (>10k embeddings)
|
||||
✓ Entraînement automatique de l'index IVF (100 vecteurs)
|
||||
✓ Calcul optimal de nlist (√n_vectors, min=100, max=65536)
|
||||
✓ Optimisation périodique de l'index
|
||||
✓ Support GPU préparé (détection auto, fallback CPU)
|
||||
✓ DirectMap activé pour reconstruction
|
||||
✓ Normalisation correcte des vecteurs
|
||||
✓ Sauvegarde/chargement avec métadonnées complètes
|
||||
✓ 8/8 tests passent
|
||||
|
||||
Tests Validés:
|
||||
✓ test_ivf_training
|
||||
✓ test_nlist_calculation
|
||||
✓ test_auto_migration_flat_to_ivf
|
||||
✓ test_ivf_search_quality
|
||||
✓ test_ivf_nprobe_effect
|
||||
✓ test_optimize_index
|
||||
✓ test_save_load_ivf
|
||||
✓ test_stats_with_ivf
|
||||
|
||||
Fichiers Créés/Modifiés:
|
||||
✓ core/embedding/embedding_cache.py (279 lignes)
|
||||
✓ core/embedding/faiss_manager.py (optimisé, +150 lignes)
|
||||
✓ tests/unit/test_faiss_ivf_optimization.py (270 lignes, 8 tests)
|
||||
✓ PHASE11_IVF_OPTIMIZATION_COMPLETE.md (documentation)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PERFORMANCES ATTENDUES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Comparaison Flat vs IVF:
|
||||
|
||||
Recherche sur 10k vecteurs:
|
||||
Flat: ~50ms → IVF: ~5-10ms (5-10x plus rapide)
|
||||
|
||||
Recherche sur 100k vecteurs:
|
||||
Flat: ~500ms → IVF: ~10-20ms (25-50x plus rapide)
|
||||
|
||||
Recherche sur 1M vecteurs:
|
||||
Flat: ~5s → IVF: ~20-50ms (100-250x plus rapide)
|
||||
|
||||
Précision:
|
||||
Flat: 100% → IVF (nprobe=8): ~95-99%
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ RECOMMANDATIONS D'UTILISATION │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
< 10k embeddings:
|
||||
→ Utiliser Flat (recherche exacte, rapide)
|
||||
|
||||
10k - 100k embeddings:
|
||||
→ Utiliser IVF avec nprobe=8 (bon compromis)
|
||||
|
||||
> 100k embeddings:
|
||||
→ Utiliser IVF avec nprobe=16-32 (meilleure qualité)
|
||||
|
||||
> 1M embeddings:
|
||||
→ Considérer IVF avec GPU
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PARAMÈTRES CONFIGURABLES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
FAISSManager(
|
||||
dimensions=512,
|
||||
index_type="IVF", # "Flat", "IVF", "HNSW"
|
||||
metric="cosine", # "cosine", "l2", "ip"
|
||||
nlist=None, # Auto si None (√n_vectors)
|
||||
nprobe=8, # Clusters à visiter (1-nlist)
|
||||
use_gpu=False, # GPU si disponible
|
||||
auto_optimize=True # Migration auto Flat→IVF
|
||||
)
|
||||
|
||||
Choix de nprobe (compromis vitesse/qualité):
|
||||
nprobe=1: Très rapide, qualité ~80%
|
||||
nprobe=8: Bon compromis, qualité ~95%
|
||||
nprobe=16: Plus lent, qualité ~98%
|
||||
nprobe=nlist: Équivalent Flat (100%)
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ STATISTIQUES GLOBALES │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Phases complètes: 8/13 (62%)
|
||||
✓ Phase 1: Fondations
|
||||
✓ Phase 2: Embeddings + FAISS
|
||||
✓ Phase 4: Détection UI
|
||||
✓ Phase 5: Workflow Graphs
|
||||
✓ Phase 6: Action Execution
|
||||
✓ Phase 7: Learning System
|
||||
✓ Phase 8: Training System
|
||||
✓ Phase 10: Error Handling
|
||||
✓ Phase 11: Persistence & Storage
|
||||
✓ Phase 11: FAISS IVF Optimization ← NOUVEAU
|
||||
|
||||
Implémentation: 42/50 tâches (84%)
|
||||
Tests property: 2/20 tâches (10%)
|
||||
|
||||
Fichiers créés: 55+ fichiers
|
||||
Tests fonctionnels: 23+ tests passés
|
||||
|
||||
Modèles intégrés: 3/3 (100%)
|
||||
✓ OpenCLIP
|
||||
✓ OWL-v2
|
||||
✓ Qwen3-VL
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PROCHAINES ÉTAPES - PHASE 11 SUITE │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Objectif: Finaliser optimisations de performance
|
||||
|
||||
Tâches restantes:
|
||||
→ 11.4 Optimiser détection UI avec ROI
|
||||
→ 11.5 Tests de performance complets
|
||||
→ 12. Checkpoint Final
|
||||
|
||||
Estimation: 2-3 heures
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ SYSTÈME HAUTE PERFORMANCE - IVF + Cache Implémentés (84%) ║
|
||||
╚══════════════════════════════════════════════════════════════════════╝
|
||||
44
TEST_NOW.sh
44
TEST_NOW.sh
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# TEST_NOW.sh
|
||||
# Script ultra-simple pour tester le serveur immédiatement
|
||||
|
||||
echo "🚀 RPA Vision V3 - Test Rapide"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# 1. Vérifier l'environnement
|
||||
if [ ! -d "venv_v3" ]; then
|
||||
echo "❌ Environnement virtuel non trouvé"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source venv_v3/bin/activate
|
||||
|
||||
# 2. Vérifier les dépendances
|
||||
echo "📦 Vérification dépendances..."
|
||||
python -c "import fastapi, flask, cryptography" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "⚠️ Installation des dépendances..."
|
||||
pip install -q fastapi 'uvicorn[standard]' python-multipart flask cryptography
|
||||
fi
|
||||
echo "✅ Dépendances OK"
|
||||
echo ""
|
||||
|
||||
# 3. Lancer les tests
|
||||
echo "🧪 Lancement des tests..."
|
||||
pytest tests/integration/test_server_pipeline.py -v --tb=short 2>&1 | grep -E "(PASSED|FAILED|passed|failed)"
|
||||
echo ""
|
||||
|
||||
# 4. Démarrer le serveur
|
||||
echo "🚀 Démarrage du serveur..."
|
||||
echo ""
|
||||
echo "📝 Commandes disponibles:"
|
||||
echo " - Démarrer: ./server/start_all.sh"
|
||||
echo " - Dashboard: xdg-open http://localhost:5001"
|
||||
echo " - Test API: curl http://localhost:8000/api/traces/status"
|
||||
echo ""
|
||||
echo "📚 Documentation:"
|
||||
echo " - Quick Start: QUICK_START_SERVER.md"
|
||||
echo " - Guide complet: SERVER_READY_TO_TEST.md"
|
||||
echo ""
|
||||
echo "✅ Prêt pour les tests!"
|
||||
@@ -1,214 +0,0 @@
|
||||
!*re du RPAhistoil'a dans erate qui rest Une dier 2026 -é le 7 Janvlét comp
|
||||
*Projet*
|
||||
PE !*'ÉQUITOUTE LONS À TIICITA🏆 FÉL---
|
||||
|
||||
**nts.
|
||||
|
||||
eas plus exigion le de productmentsronneviens our les pequisebilité ret la fiaion précisnt laaintena en mus toutessible à totion acctomatisaendant l'auon du RPA, rns l'évoluti daue**historiqpe ue une **étan marqalisatiote ré
|
||||
|
||||
Cetsation**cité d'utilipliim **S*
|
||||
- 👥aximale*Robustesse m️ **e**
|
||||
- 🛡 enterpris*Performance🚀 *
|
||||
- **perfect pixel- **Précision
|
||||
- 🎯nte** poielle deficince Arti**Intellige
|
||||
- 🧠 :
|
||||
ombinant , cde**mon au avancéws le plus e workfloe création dtème d le **syst désormais3 esn Visioer de RPA Vildrkflow Bue Visual Wo**
|
||||
|
||||
LNCE !XCELLEC EIE AVEION ACCOMPL
|
||||
|
||||
**MISSConclusion## 🎊
|
||||
---
|
||||
|
||||
onitoring
|
||||
té et m sécuriavecdy** tion-readucro*Code p
|
||||
- *ion rapidedopt* pour aive*on exhaustcumentatits
|
||||
- **Do par tesvalidéesion** orrect cétés de **45 propriuccès
|
||||
-c s aveomplétées**14 tâches c4/*1ution
|
||||
- *écence d'Ex### Excell
|
||||
|
||||
ptimiséeormance oc perf* avegrade*enterprise-e *Architecturterface
|
||||
- * d'inpréhensionur la comée** poe avancficiell artince**Intellige
|
||||
- ath** CSS/XPurs fragilessélectes complète deion inate
|
||||
- **Élim** au mondsion-based 100% vimeer systèmi **Preine RPA :
|
||||
-le domadans e** ologiquhnution tec une **révolprésenterojet rerough
|
||||
Ce preakthInnovation B
|
||||
### echnique
|
||||
issance T## 🏅 Reconnas
|
||||
|
||||
---
|
||||
|
||||
gékflows partal** : Worps réeemon toratiCollabiles
|
||||
4. **obces m interfatension auxpport** : Exobile subles
|
||||
3. **Mes et scalas distribué: APIn cloud** ratio**Intéges
|
||||
2. modèlcontinue desoration : Amélie** e automatiqutissagens
|
||||
1. **Apprs Futureutionvol
|
||||
### É intégrées
|
||||
triquesméion** avec oduct pring4. **Monitordes créés
|
||||
avec les gui** uipesn éqatio. **Formduction
|
||||
3l de proie* sur matérmance*orks perfenchmar
|
||||
2. **Bon fourniementatic la docu avesateur**tion utiliaccepta*Tests d't
|
||||
1. *Déploiemense de haes
|
||||
|
||||
### Pmmandétapes Reco ÉProchaines# 🚀 ---
|
||||
|
||||
#
|
||||
s le RPA
|
||||
gique danlohip technoadersLetion** : ova **Innady
|
||||
-prise-reture enterechitlité** : Arccalabiws
|
||||
- **Sfloes workfiée dmplince sintena : Maioûts**ion cductsed
|
||||
- **Rébaon-visition 100% lue so* : Premièriel*urrentge conc- **AvantaEntreprise
|
||||
# Pour l'r
|
||||
|
||||
##ppeuur et dévelotelisades utiète** : Gui complionumentat**Docavancé
|
||||
- ed testing y-basrtrope* : P exhaustifs***Testscumentés
|
||||
- EST do REndpoints* : ètes*omplIs cI
|
||||
- **AP Material-Ut + + TypeScripcterne** : Reacture modchite**Ar
|
||||
- éveloppeurss D
|
||||
### Pour lebles
|
||||
inue des cidation cont* : Vali temps réel*Feedbacke
|
||||
- ** naturelln visuelleSélectioe** : e intuitiv*Interfac
|
||||
- *aces d'interf changementsistance auxle** : Rémaximatesse
|
||||
- **Robuseshniquissances tecnnaoin de coesus bnaire** : Plolutionplicité rév- **Simeurs
|
||||
ilisat les Ut# Pourices
|
||||
|
||||
##et Bénéf# 🌟 Impact -
|
||||
|
||||
#idé)
|
||||
|
||||
--al: >80% (vn** ctiodétece **Confianrôlée)
|
||||
- B (cont: <100M** reation mémoi**Utilissé)
|
||||
- (optimi** : >80% cache **Taux de int)
|
||||
-attetif s (objec<3 secondeion** : Temps détectteint)
|
||||
- **objectif atdes (* : <2 secons capture**Tempance
|
||||
- *formques de Perétrie
|
||||
|
||||
### Mncilierést t système etarence é Cohé5** : **P41-P4moire
|
||||
-rmance et méé perfobilitScalaP36-P40** : **
|
||||
-uesures uniqt signatnées eé donIntégritP35** : - **P31-eurs
|
||||
n errtioet gesme stesse systè** : RobuP26-P30rs
|
||||
- **-moniteunées multi coordon MappingP21-P25** :ance
|
||||
- **nfi coion etect détmeéterminisP20** : De
|
||||
- **P16-tion cachance et ges** : Perform**P11-P15données
|
||||
- métaet uelles les vislidation cib : Va**P6-P10** boxes
|
||||
- et boundingdonnées ence coorér CohP1-P5** :tés)
|
||||
- **rié (45 Propedrty-BasropeTests P
|
||||
### ue
|
||||
tion Techniqida
|
||||
## 🔬 Val-
|
||||
mages
|
||||
|
||||
--essif des ient progr : Chargemng**loadiy **Laz(300ms)
|
||||
- timisées entes options fréquéra : OpDebouncing**- **ptimisées
|
||||
longues ostes Liation** :rtualiz0MB
|
||||
- **Vimite 5c liU aveCache LRU/LF: * g*mage cachin
|
||||
- **Imizationstiformance Op## Peravier
|
||||
|
||||
#vigation clARIA et nas ributlité** : AttssibiAcce
|
||||
- **ivesatadapt grilles akpoints etn** : Brensive desig*Respo- *l-UI
|
||||
ants Materiaes compose dmalaxitilisation méuérents** : Rts coh**Composan
|
||||
- 2c55e)ss Green (#2d2), Succee (#1976ry Blu : Primaleurs**de couPalette ion
|
||||
- **gratal-UI Interi
|
||||
### Mateem
|
||||
n Systé Desigformit
|
||||
## 🎨 Con
|
||||
--`
|
||||
|
||||
-pannage
|
||||
``uide dé# G md OOTING.LESH── TROUBeur
|
||||
└ développtionrauide intég # G ION.md _INTEGRAT├── API
|
||||
eur complet utilisat # GuideE.md CTION_GUID_SELE├── VISUALlder/docs/
|
||||
buil_workflow_ua
|
||||
|
||||
vists Pythones# Terties.py lder_proplow_buivisual_workft_testy/
|
||||
└── roperts/pn
|
||||
```
|
||||
tesumentatio Doc Tests et
|
||||
###
|
||||
```
|
||||
nt) (existature d'écran API cap # .py een_captures
|
||||
└── scrntlémen éAPI détectio # .py on_detectint elemees
|
||||
├──isuell vibles # API c s.py rget── visual_taapi/
|
||||
├backend/builder/l_workflow_sua
|
||||
vi``+ Python
|
||||
` Flask ackend``
|
||||
|
||||
### B
|
||||
`edroperty-bassts p Te # s tion.test.tisualSelec└── v
|
||||
properties/ts__/esges
|
||||
└── __tligent imaCache intel # .ts mageCache
|
||||
│ └── Ils/ce
|
||||
├── utirmanations perfoOptimisn.ts # izationceOptim usePerforma
|
||||
│ └─── hooks/oniteurs
|
||||
├─on multi-msti # Ge ts e.Servicnitor
|
||||
│ └── Mos IA élémentDétectionts # ice.rvectionSe ElementDetisé
|
||||
│ ├──imre opt captu # Service eService.tsCapturScreen│ ├── les
|
||||
bles visuelstion ci# Ge.ts ervicesualTargetS ├── Vi
|
||||
│ services/
|
||||
├──chargementicateurs de # Ind or/ icatLoadingInds
|
||||
│ └── iteurn multi-monélectio S #/ orSelector├── Monit
|
||||
│ iesées enrich# Métadonn splay/ taDiisualMetada Vs
|
||||
│ ├──isuelles vibleration c Configu # fig/ rgetConisualTa── Vce
|
||||
│ ├ren de réféturesfichage cap# Af ew/ creenshotViferenceS ├── Ree
|
||||
│ e principaltion visuell # Sélec ctor/ lenSereealSc ├── Visu/
|
||||
│mponents├── contend/src/
|
||||
uilder/froworkflow_bisual_```
|
||||
vpeScript
|
||||
Tyact +ontend Re## Fr
|
||||
#nts Créés
|
||||
posa 🛠 Com
|
||||
|
||||
##
|
||||
---eur
|
||||
veloppdét isateur etil* - Guides uration*tation Intég✅ **Documen
|
||||
14. hérentlet et copt comp TypeScris Types** -finition**Dé
|
||||
13. ✅ idéesn valrectioés de corpropriét 45 ty-Based** -sts Proper
|
||||
12. ✅ **Te(12-14)ualité ches Q
|
||||
### 🟢 Tâmplets
|
||||
cos REST pointnd - EComplètes**PIs Backend **Anées
|
||||
11. ✅doncoor DPI et apping Mteurs** - Multi-Moniupport✅ **Sg
|
||||
10. ebouncinalisation, drtuhe, vi** - Cacrformancesation Peptimi ✅ **Oturel
|
||||
9. langage naenscriptions - Decé**nées Avan MétadonAffichage8. ✅ **-11)
|
||||
ches Core (8
|
||||
|
||||
### 🟡 Tâlidationance et va** - PersistnagerualTargetMan Vistégratio. ✅ **Ine
|
||||
7le purvisueln uratioConfigtConfig** - ualTargeomposant Viss
|
||||
6. ✅ **C overlayge avec - Affichaw**creenshotViet ReferenceSmposan✅ **Coelle
|
||||
5. su% vice 100 Interfalector** -alScreenSetor Visu*Refac4. ✅ *lle
|
||||
pérationnen oio de détect IAs** -Élément Détection rationtégé
|
||||
3. ✅ **Inntégron V3 i RPA Visi** - BackendCapture Service ationégr**Intlète
|
||||
2. ✅ ompimination c* - Élh*at/XPre CSSastructuression Infr
|
||||
1. ✅ **Supp (1-7)iques Critâches🔴 T###
|
||||
|
||||
ies (14/14) Accomplches 📋 Tâ
|
||||
|
||||
##ans
|
||||
|
||||
--- multi-écronsuratinfigs cote demplè Gestion co* :r Support*lti-Monito**Muride
|
||||
- U hyb LRU/LFe cache avecème dt** : Systgentelli In**Cachevancée
|
||||
- ec IA aavéments d'élion: Détectndes** <3 secotection **Déimisée
|
||||
- réel opt temps ure d'écranCaptes** : secondapture <2 prise
|
||||
- **Crmance Enter
|
||||
#### Perfo
|
||||
élémentsntre iales espatations on des relréhensi: Companding** tual Underst
|
||||
- **Contexce >80%avec confians cibles tinue deion con : Validation**ate Valid**Real-tim
|
||||
- élémentpour chaque ques es uniuellisures v** : Signat Embeddingsdallti-mo
|
||||
- **Muvisuellehension compréinte pour laes IA de poodèl** : M Integration OWL-ViTP +
|
||||
- **CLIsion-Centricture Vihitec
|
||||
#### Arcologique
|
||||
ation Techn 🔬 Innov##A.
|
||||
|
||||
#RPe domaindans le lutionnaire avancée révont une eprésentaléments, rion d'éur la sélectur podinatesion par ora vilusivement lésormais exclise dder uti Builal Workflow
|
||||
Le Visuh**CSS/XPatlecteurs des sélèteination compÉlimINT
|
||||
✅ **ipal ATTEjectif Princ
|
||||
|
||||
### 🎯 Obeuresions Majalisat# 🚀 Ré--
|
||||
|
||||
#
|
||||
|
||||
-avec succèsréalisé d ion-base% visworkflow 100tème de gique:** Sysnolo TechRévolutionâches)
|
||||
**4 t4/1TERMINÉ (1:** 100%
|
||||
**Statutier 2026 ** 7 Janvetion:Compl
|
||||
**Date de ished
|
||||
sion Accompl🏆 Mis
|
||||
|
||||
## PLETE!ROJECT COMctor - PVision RefaBuilder w rkflol Wo# 🎉 Visua
|
||||
1638
agent_chat/app.py
1638
agent_chat/app.py
File diff suppressed because it is too large
Load Diff
@@ -147,8 +147,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
|
||||
@@ -197,7 +199,8 @@ NOT_FOUND"""
|
||||
prompt=prompt,
|
||||
image=screenshot,
|
||||
temperature=0.1,
|
||||
max_tokens=100
|
||||
max_tokens=100,
|
||||
assistant_prefill="COORDINATES:",
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
|
||||
644
agent_chat/gesture_catalog.py
Normal file
644
agent_chat/gesture_catalog.py
Normal file
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RPA Vision V3 - Catalogue de Primitives Gestuelles
|
||||
|
||||
Bibliothèque de gestes universels Windows (raccourcis clavier) que le système
|
||||
connaît nativement, sans apprentissage visuel.
|
||||
|
||||
Trois usages :
|
||||
1. Chat : l'utilisateur demande "ferme la fenêtre" → match direct → exécution
|
||||
2. Replay : une action enregistrée correspond à un geste connu → substitution
|
||||
automatique par le raccourci clavier (plus fiable que le clic visuel)
|
||||
3. Workflows : enrichissement automatique des workflows avec les primitives
|
||||
|
||||
Auteur: Dom — Mars 2026
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gesture:
|
||||
"""Un geste primitif universel."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
keys: List[str] # Ex: ["alt", "f4"], ["ctrl", "t"]
|
||||
aliases: List[str] = field(default_factory=list) # Termes alternatifs
|
||||
tags: List[str] = field(default_factory=list)
|
||||
context: str = "windows" # "windows", "chrome", "explorer", etc.
|
||||
category: str = "window" # "window", "navigation", "editing", "system"
|
||||
|
||||
def to_replay_action(self) -> Dict:
|
||||
"""Convertir en action de replay pour l'Agent V1."""
|
||||
return {
|
||||
"action_id": f"gesture_{self.id}_{uuid.uuid4().hex[:6]}",
|
||||
"type": "key_combo",
|
||||
"keys": self.keys,
|
||||
"gesture_id": self.id,
|
||||
"gesture_name": self.name,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Catalogue des primitives
|
||||
# =============================================================================
|
||||
|
||||
GESTURES: List[Gesture] = [
|
||||
# --- Gestion de fenêtres ---
|
||||
Gesture(
|
||||
id="win_close", name="Fermer la fenêtre",
|
||||
description="Fermer la fenêtre active",
|
||||
keys=["alt", "f4"],
|
||||
aliases=["fermer", "close", "quitter la fenêtre", "fermer l'application",
|
||||
"fermer le programme", "close window"],
|
||||
tags=["fenêtre", "fermer", "close"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_maximize", name="Agrandir la fenêtre",
|
||||
description="Agrandir la fenêtre au maximum",
|
||||
keys=["super", "up"],
|
||||
aliases=["agrandir", "maximize", "plein écran", "maximiser",
|
||||
"fullscreen", "agrandir la fenêtre"],
|
||||
tags=["fenêtre", "agrandir", "maximize"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_minimize", name="Réduire la fenêtre",
|
||||
description="Réduire la fenêtre dans la barre des tâches",
|
||||
keys=["super", "down"],
|
||||
aliases=["réduire", "minimize", "minimiser", "réduire la fenêtre",
|
||||
"mettre en bas"],
|
||||
tags=["fenêtre", "réduire", "minimize"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_minimize_all", name="Afficher le bureau",
|
||||
description="Réduire toutes les fenêtres (afficher le bureau)",
|
||||
keys=["super", "d"],
|
||||
aliases=["bureau", "desktop", "afficher le bureau", "tout réduire",
|
||||
"montrer le bureau", "show desktop"],
|
||||
tags=["bureau", "desktop", "minimize all"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_switch", name="Basculer entre fenêtres",
|
||||
description="Basculer vers la fenêtre suivante",
|
||||
keys=["alt", "tab"],
|
||||
aliases=["basculer", "switch", "changer de fenêtre",
|
||||
"fenêtre suivante", "alt tab"],
|
||||
tags=["fenêtre", "basculer", "switch"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_snap_left", name="Fenêtre à gauche",
|
||||
description="Ancrer la fenêtre à gauche de l'écran",
|
||||
keys=["super", "left"],
|
||||
aliases=["fenêtre à gauche", "snap left", "ancrer à gauche",
|
||||
"moitié gauche"],
|
||||
tags=["fenêtre", "snap", "gauche"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_snap_right", name="Fenêtre à droite",
|
||||
description="Ancrer la fenêtre à droite de l'écran",
|
||||
keys=["super", "right"],
|
||||
aliases=["fenêtre à droite", "snap right", "ancrer à droite",
|
||||
"moitié droite"],
|
||||
tags=["fenêtre", "snap", "droite"],
|
||||
category="window",
|
||||
),
|
||||
Gesture(
|
||||
id="win_restore", name="Restaurer la fenêtre",
|
||||
description="Restaurer la taille normale de la fenêtre",
|
||||
keys=["super", "down"],
|
||||
aliases=["restaurer", "restore", "taille normale",
|
||||
"fenêtre normale"],
|
||||
tags=["fenêtre", "restaurer", "restore"],
|
||||
category="window",
|
||||
),
|
||||
|
||||
# --- Navigation Chrome / navigateur ---
|
||||
Gesture(
|
||||
id="chrome_new_tab", name="Nouvel onglet",
|
||||
description="Ouvrir un nouvel onglet dans le navigateur",
|
||||
keys=["ctrl", "t"],
|
||||
aliases=["nouvel onglet", "new tab", "ouvrir un onglet",
|
||||
"ajouter un onglet", "nouveau tab"],
|
||||
tags=["chrome", "onglet", "tab", "nouveau"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_close_tab", name="Fermer l'onglet",
|
||||
description="Fermer l'onglet actif du navigateur",
|
||||
keys=["ctrl", "w"],
|
||||
aliases=["fermer l'onglet", "close tab", "fermer le tab",
|
||||
"fermer cet onglet"],
|
||||
tags=["chrome", "onglet", "fermer"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_next_tab", name="Onglet suivant",
|
||||
description="Passer à l'onglet suivant",
|
||||
keys=["ctrl", "tab"],
|
||||
aliases=["onglet suivant", "next tab", "tab suivant",
|
||||
"prochain onglet"],
|
||||
tags=["chrome", "onglet", "suivant"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_prev_tab", name="Onglet précédent",
|
||||
description="Passer à l'onglet précédent",
|
||||
keys=["ctrl", "shift", "tab"],
|
||||
aliases=["onglet précédent", "previous tab", "tab précédent",
|
||||
"onglet d'avant"],
|
||||
tags=["chrome", "onglet", "précédent"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_reopen_tab", name="Rouvrir le dernier onglet",
|
||||
description="Rouvrir le dernier onglet fermé",
|
||||
keys=["ctrl", "shift", "t"],
|
||||
aliases=["rouvrir l'onglet", "reopen tab", "onglet fermé",
|
||||
"restaurer l'onglet"],
|
||||
tags=["chrome", "onglet", "rouvrir"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_address_bar", name="Barre d'adresse",
|
||||
description="Sélectionner la barre d'adresse du navigateur",
|
||||
keys=["ctrl", "l"],
|
||||
aliases=["barre d'adresse", "address bar", "url bar",
|
||||
"aller à l'adresse", "sélectionner l'url"],
|
||||
tags=["chrome", "url", "adresse"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_refresh", name="Rafraîchir la page",
|
||||
description="Recharger la page web actuelle",
|
||||
keys=["f5"],
|
||||
aliases=["rafraîchir", "refresh", "recharger", "actualiser",
|
||||
"reload"],
|
||||
tags=["chrome", "rafraîchir", "reload"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_back", name="Page précédente",
|
||||
description="Retourner à la page précédente",
|
||||
keys=["alt", "left"],
|
||||
aliases=["retour", "back", "page précédente", "revenir en arrière",
|
||||
"page d'avant"],
|
||||
tags=["chrome", "retour", "back"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_forward", name="Page suivante",
|
||||
description="Aller à la page suivante",
|
||||
keys=["alt", "right"],
|
||||
aliases=["avancer", "forward", "page suivante"],
|
||||
tags=["chrome", "avancer", "forward"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_find", name="Rechercher dans la page",
|
||||
description="Ouvrir la barre de recherche dans la page",
|
||||
keys=["ctrl", "f"],
|
||||
aliases=["rechercher", "find", "chercher dans la page", "ctrl f",
|
||||
"trouver"],
|
||||
tags=["chrome", "rechercher", "find"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
Gesture(
|
||||
id="chrome_new_window", name="Nouvelle fenêtre",
|
||||
description="Ouvrir une nouvelle fenêtre de navigateur",
|
||||
keys=["ctrl", "n"],
|
||||
aliases=["nouvelle fenêtre", "new window", "ouvrir une fenêtre"],
|
||||
tags=["chrome", "fenêtre", "nouveau"],
|
||||
context="chrome",
|
||||
category="navigation",
|
||||
),
|
||||
|
||||
# --- Édition / presse-papier ---
|
||||
Gesture(
|
||||
id="edit_copy", name="Copier",
|
||||
description="Copier la sélection dans le presse-papier",
|
||||
keys=["ctrl", "c"],
|
||||
aliases=["copier", "copy", "ctrl c"],
|
||||
tags=["édition", "copier", "presse-papier"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_paste", name="Coller",
|
||||
description="Coller le contenu du presse-papier",
|
||||
keys=["ctrl", "v"],
|
||||
aliases=["coller", "paste", "ctrl v"],
|
||||
tags=["édition", "coller", "presse-papier"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_cut", name="Couper",
|
||||
description="Couper la sélection",
|
||||
keys=["ctrl", "x"],
|
||||
aliases=["couper", "cut", "ctrl x"],
|
||||
tags=["édition", "couper"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_undo", name="Annuler",
|
||||
description="Annuler la dernière action",
|
||||
keys=["ctrl", "z"],
|
||||
aliases=["annuler", "undo", "défaire", "ctrl z"],
|
||||
tags=["édition", "annuler", "undo"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_redo", name="Rétablir",
|
||||
description="Rétablir l'action annulée",
|
||||
keys=["ctrl", "y"],
|
||||
aliases=["rétablir", "redo", "refaire", "ctrl y"],
|
||||
tags=["édition", "rétablir", "redo"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_select_all", name="Tout sélectionner",
|
||||
description="Sélectionner tout le contenu",
|
||||
keys=["ctrl", "a"],
|
||||
aliases=["tout sélectionner", "select all", "sélectionner tout",
|
||||
"ctrl a"],
|
||||
tags=["édition", "sélection", "tout"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="edit_save", name="Enregistrer",
|
||||
description="Enregistrer le document/fichier actuel",
|
||||
keys=["ctrl", "s"],
|
||||
aliases=["enregistrer", "save", "sauvegarder", "ctrl s"],
|
||||
tags=["édition", "enregistrer", "save"],
|
||||
category="editing",
|
||||
),
|
||||
|
||||
# --- Système ---
|
||||
Gesture(
|
||||
id="sys_start_menu", name="Menu Démarrer",
|
||||
description="Ouvrir le menu Démarrer Windows",
|
||||
keys=["super"],
|
||||
aliases=["menu démarrer", "start menu", "démarrer", "windows",
|
||||
"touche windows"],
|
||||
tags=["système", "démarrer", "menu"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_task_manager", name="Gestionnaire des tâches",
|
||||
description="Ouvrir le gestionnaire des tâches",
|
||||
keys=["ctrl", "shift", "escape"],
|
||||
aliases=["gestionnaire des tâches", "task manager",
|
||||
"gestionnaire tâches", "processes"],
|
||||
tags=["système", "tâches", "processus"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_lock", name="Verrouiller le PC",
|
||||
description="Verrouiller la session Windows",
|
||||
keys=["super", "l"],
|
||||
aliases=["verrouiller", "lock", "verrouiller le pc",
|
||||
"verrouiller la session"],
|
||||
tags=["système", "verrouiller", "lock"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_screenshot", name="Capture d'écran",
|
||||
description="Prendre une capture d'écran",
|
||||
keys=["super", "shift", "s"],
|
||||
aliases=["capture d'écran", "screenshot", "capture écran",
|
||||
"impr écran"],
|
||||
tags=["système", "capture", "screenshot"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_explorer", name="Ouvrir l'explorateur",
|
||||
description="Ouvrir l'explorateur de fichiers Windows",
|
||||
keys=["super", "e"],
|
||||
aliases=["explorateur", "explorer", "ouvrir l'explorateur",
|
||||
"mes fichiers", "file explorer", "explorateur de fichiers"],
|
||||
tags=["système", "explorateur"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_run", name="Exécuter (Run)",
|
||||
description="Ouvrir la boîte de dialogue Exécuter",
|
||||
keys=["super", "r"],
|
||||
aliases=["exécuter", "run", "boîte exécuter"],
|
||||
tags=["système", "exécuter", "run"],
|
||||
category="system",
|
||||
),
|
||||
Gesture(
|
||||
id="sys_settings", name="Paramètres Windows",
|
||||
description="Ouvrir les paramètres Windows",
|
||||
keys=["super", "i"],
|
||||
aliases=["paramètres", "settings", "réglages",
|
||||
"paramètres windows"],
|
||||
tags=["système", "paramètres", "settings"],
|
||||
category="system",
|
||||
),
|
||||
|
||||
# --- Navigation texte ---
|
||||
Gesture(
|
||||
id="nav_home", name="Début de ligne",
|
||||
description="Aller au début de la ligne",
|
||||
keys=["home"],
|
||||
aliases=["début de ligne", "home", "début"],
|
||||
tags=["navigation", "texte", "début"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="nav_end", name="Fin de ligne",
|
||||
description="Aller à la fin de la ligne",
|
||||
keys=["end"],
|
||||
aliases=["fin de ligne", "end", "fin"],
|
||||
tags=["navigation", "texte", "fin"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="nav_enter", name="Valider / Entrée",
|
||||
description="Appuyer sur Entrée",
|
||||
keys=["enter"],
|
||||
aliases=["entrée", "enter", "valider", "confirmer", "ok"],
|
||||
tags=["navigation", "entrée", "valider"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="nav_escape", name="Échap / Annuler",
|
||||
description="Appuyer sur Échap (fermer popup, annuler)",
|
||||
keys=["escape"],
|
||||
aliases=["échap", "escape", "esc", "annuler", "fermer le popup",
|
||||
"fermer la popup", "fermer le dialogue"],
|
||||
tags=["navigation", "échap", "annuler", "popup"],
|
||||
category="editing",
|
||||
),
|
||||
Gesture(
|
||||
id="nav_tab", name="Champ suivant",
|
||||
description="Passer au champ suivant (Tab)",
|
||||
keys=["tab"],
|
||||
aliases=["tab", "champ suivant", "suivant", "prochain champ",
|
||||
"tabulation"],
|
||||
tags=["navigation", "tab", "champ"],
|
||||
category="editing",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class GestureCatalog:
|
||||
"""
|
||||
Catalogue de gestes primitifs avec matching sémantique.
|
||||
|
||||
Utilisé par :
|
||||
- Le chat (match direct quand l'utilisateur demande un geste)
|
||||
- Le replay (substitution automatique d'actions enregistrées)
|
||||
"""
|
||||
|
||||
def __init__(self, gestures: List[Gesture] = None):
|
||||
self.gestures = gestures or GESTURES
|
||||
# Index pour recherche rapide
|
||||
self._by_id: Dict[str, Gesture] = {g.id: g for g in self.gestures}
|
||||
# Pré-calculer les termes de recherche normalisés
|
||||
self._search_index: List[Tuple[Gesture, List[str]]] = []
|
||||
for g in self.gestures:
|
||||
terms = [g.name.lower(), g.description.lower()]
|
||||
terms.extend(a.lower() for a in g.aliases)
|
||||
terms.extend(t.lower() for t in g.tags)
|
||||
self._search_index.append((g, terms))
|
||||
|
||||
logger.info(f"GestureCatalog: {len(self.gestures)} primitives chargées")
|
||||
|
||||
def match(self, query: str, min_score: float = 0.45) -> Optional[Tuple[Gesture, float]]:
|
||||
"""
|
||||
Trouver le geste le plus proche d'une requête textuelle.
|
||||
|
||||
Returns:
|
||||
(Gesture, score) si match trouvé, None sinon.
|
||||
"""
|
||||
query_lower = query.lower().strip()
|
||||
if not query_lower:
|
||||
return None
|
||||
|
||||
best_gesture = None
|
||||
best_score = 0.0
|
||||
|
||||
for gesture, terms in self._search_index:
|
||||
score = self._compute_score(query_lower, terms, gesture)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_gesture = gesture
|
||||
|
||||
if best_gesture and best_score >= min_score:
|
||||
logger.debug(f"Gesture match: '{query}' → {best_gesture.id} (score={best_score:.2f})")
|
||||
return (best_gesture, best_score)
|
||||
|
||||
return None
|
||||
|
||||
def match_action(self, action: Dict) -> Optional[Gesture]:
|
||||
"""
|
||||
Détecter si une action de workflow correspond à un geste primitif.
|
||||
|
||||
Utilisé pendant le replay pour auto-substituer les actions visuelles
|
||||
par des raccourcis clavier plus fiables.
|
||||
|
||||
Patterns détectés :
|
||||
- Clic sur boutons de contrôle fenêtre (X, □, ─)
|
||||
- key_combo qui matche déjà un geste
|
||||
- Actions avec target_text contenant des mots-clés de geste
|
||||
"""
|
||||
action_type = action.get("type", "")
|
||||
|
||||
# key_combo → vérifier si c'est déjà un geste connu
|
||||
if action_type == "key_combo":
|
||||
keys = action.get("keys", [])
|
||||
return self._match_by_keys(keys)
|
||||
|
||||
# Clic sur un bouton de contrôle de fenêtre
|
||||
if action_type == "click":
|
||||
return self._match_click_as_gesture(action)
|
||||
|
||||
return None
|
||||
|
||||
def get_by_id(self, gesture_id: str) -> Optional[Gesture]:
|
||||
return self._by_id.get(gesture_id)
|
||||
|
||||
def get_by_category(self, category: str) -> List[Gesture]:
|
||||
return [g for g in self.gestures if g.category == category]
|
||||
|
||||
def get_by_context(self, context: str) -> List[Gesture]:
|
||||
"""Gestes applicables à un contexte (inclut toujours 'windows')."""
|
||||
return [
|
||||
g for g in self.gestures
|
||||
if g.context == context or g.context == "windows"
|
||||
]
|
||||
|
||||
def list_all(self) -> List[Dict]:
|
||||
"""Lister tous les gestes pour l'affichage."""
|
||||
return [
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"keys": "+".join(g.keys),
|
||||
"category": g.category,
|
||||
"context": g.context,
|
||||
}
|
||||
for g in self.gestures
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# Scoring interne
|
||||
# =========================================================================
|
||||
|
||||
def _compute_score(self, query: str, terms: List[str], gesture: Gesture) -> float:
|
||||
"""Calculer le score de correspondance entre une requête et un geste."""
|
||||
best = 0.0
|
||||
query_words = set(query.split())
|
||||
|
||||
for term in terms:
|
||||
# Match exact
|
||||
if query == term:
|
||||
return 1.0
|
||||
|
||||
# Contenu dans l'un ou l'autre sens
|
||||
if query in term:
|
||||
score = len(query) / len(term) * 0.95
|
||||
best = max(best, score)
|
||||
continue
|
||||
if term in query:
|
||||
# Si le terme est un alias exact (mot unique) présent dans la requête
|
||||
# c'est un signal très fort : "copier le texte" contient "copier"
|
||||
if term in query_words:
|
||||
best = max(best, 0.85)
|
||||
else:
|
||||
score = len(term) / len(query) * 0.9
|
||||
best = max(best, score)
|
||||
continue
|
||||
|
||||
# Similarité de séquence
|
||||
ratio = SequenceMatcher(None, query, term).ratio()
|
||||
best = max(best, ratio)
|
||||
|
||||
# Bonus si tous les mots de la requête sont présents dans les termes
|
||||
all_terms_text = " ".join(terms)
|
||||
matched_words = sum(1 for w in query_words if w in all_terms_text)
|
||||
if query_words:
|
||||
word_ratio = matched_words / len(query_words)
|
||||
if word_ratio >= 0.8:
|
||||
best = max(best, 0.5 + word_ratio * 0.4)
|
||||
|
||||
return best
|
||||
|
||||
def _match_by_keys(self, keys: List[str]) -> Optional[Gesture]:
|
||||
"""Trouver un geste par sa combinaison de touches exacte."""
|
||||
keys_normalized = [k.lower() for k in keys]
|
||||
for gesture in self.gestures:
|
||||
if gesture.keys == keys_normalized:
|
||||
return gesture
|
||||
return None
|
||||
|
||||
def _match_click_as_gesture(self, action: Dict) -> Optional[Gesture]:
|
||||
"""
|
||||
Détecter si un clic correspond à un geste primitif.
|
||||
|
||||
Patterns :
|
||||
- Clic en haut à droite de la fenêtre (x > 95%, y < 5%) → fermer
|
||||
- target_text contenant ✕, ×, X, □, ─, etc.
|
||||
"""
|
||||
# Vérifier le target_text
|
||||
target_text = (
|
||||
action.get("target_text", "") or
|
||||
action.get("target_spec", {}).get("by_text", "")
|
||||
).strip()
|
||||
|
||||
if target_text:
|
||||
target_lower = target_text.lower()
|
||||
# Bouton fermer
|
||||
if target_lower in ("✕", "×", "x", "close", "fermer"):
|
||||
return self._by_id.get("win_close")
|
||||
# Bouton maximiser
|
||||
if target_lower in ("□", "☐", "maximize", "agrandir"):
|
||||
return self._by_id.get("win_maximize")
|
||||
# Bouton minimiser
|
||||
if target_lower in ("─", "—", "_", "minimize", "réduire"):
|
||||
return self._by_id.get("win_minimize")
|
||||
|
||||
# Vérifier la position relative (coin haut-droite = fermer)
|
||||
x_pct = action.get("x_pct", 0)
|
||||
y_pct = action.get("y_pct", 0)
|
||||
|
||||
if x_pct > 0.96 and y_pct < 0.04:
|
||||
return self._by_id.get("win_close")
|
||||
if 0.92 < x_pct < 0.96 and y_pct < 0.04:
|
||||
return self._by_id.get("win_maximize")
|
||||
if 0.88 < x_pct < 0.92 and y_pct < 0.04:
|
||||
return self._by_id.get("win_minimize")
|
||||
|
||||
return None
|
||||
|
||||
def optimize_replay_actions(self, actions: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Optimiser une liste d'actions de replay en substituant les gestes connus.
|
||||
|
||||
Pour chaque action, si elle correspond à un geste primitif,
|
||||
on la remplace par le raccourci clavier équivalent.
|
||||
|
||||
Retourne la liste d'actions optimisée (les originales non-matchées
|
||||
sont conservées telles quelles).
|
||||
"""
|
||||
optimized = []
|
||||
substitutions = 0
|
||||
|
||||
for action in actions:
|
||||
gesture = self.match_action(action)
|
||||
if gesture and action.get("type") != "key_combo":
|
||||
# Substituer par le raccourci clavier
|
||||
new_action = gesture.to_replay_action()
|
||||
# Conserver l'action_id original pour le tracking
|
||||
new_action["action_id"] = action.get("action_id", new_action["action_id"])
|
||||
new_action["original_type"] = action.get("type")
|
||||
optimized.append(new_action)
|
||||
substitutions += 1
|
||||
logger.debug(
|
||||
f"Geste substitué: {action.get('type')} → {gesture.id} ({gesture.name})"
|
||||
)
|
||||
else:
|
||||
optimized.append(action)
|
||||
|
||||
if substitutions:
|
||||
logger.info(
|
||||
f"Replay optimisé: {substitutions} action(s) substituée(s) par des primitives"
|
||||
)
|
||||
|
||||
return optimized
|
||||
|
||||
|
||||
# Singleton
|
||||
_catalog: Optional[GestureCatalog] = None
|
||||
|
||||
|
||||
def get_gesture_catalog() -> GestureCatalog:
|
||||
global _catalog
|
||||
if _catalog is None:
|
||||
_catalog = GestureCatalog()
|
||||
return _catalog
|
||||
@@ -29,12 +29,15 @@ class IntentType(Enum):
|
||||
LIST = "list" # Lister les workflows disponibles
|
||||
CONFIGURE = "configure" # Configurer un paramètre
|
||||
HELP = "help" # Demander de l'aide
|
||||
GREETING = "greeting" # Salutation
|
||||
STATUS = "status" # Vérifier le statut
|
||||
CANCEL = "cancel" # Annuler l'exécution en cours
|
||||
HISTORY = "history" # Voir l'historique
|
||||
CONFIRM = "confirm" # Confirmer une action
|
||||
DENY = "deny" # Refuser une action
|
||||
CLARIFY = "clarify" # Demander une clarification
|
||||
DATA_IMPORT = "data_import" # Importer des données (Excel, CSV)
|
||||
SMALL_TALK = "small_talk" # Conversation informelle (merci, café, ça va...)
|
||||
UNKNOWN = "unknown" # Intention non reconnue
|
||||
|
||||
|
||||
@@ -73,28 +76,106 @@ class IntentParser:
|
||||
|
||||
# Patterns pour la détection d'intentions par règles
|
||||
INTENT_PATTERNS = {
|
||||
IntentType.DATA_IMPORT: [
|
||||
# Import de fichiers Excel/CSV
|
||||
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.xlsx?)\b",
|
||||
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.csv)\b",
|
||||
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+)?excel\s+(.+)",
|
||||
r"(?:importe|charge|lis|lire)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier\s+|de\s+)(.+)",
|
||||
r"(?:crée?|créer?)\s+une?\s+table\s+(?:à\s+partir\s+d[eu]'?\s*)(.+\.xlsx?)\b",
|
||||
# Lister les tables
|
||||
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?\b",
|
||||
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans\s+la\s+base)",
|
||||
r"liste\s+(?:des?\s+)?tables?\s+(?:de\s+)?(?:la\s+)?(?:base)?",
|
||||
# Infos sur une table
|
||||
r"(?:combien\s+de\s+lignes?\s+(?:dans|pour)\s+(?:la\s+)?table\s+)(\w+)",
|
||||
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table\s+(\w+)",
|
||||
],
|
||||
IntentType.EXECUTE: [
|
||||
r"(?:lance|exécute|démarre|fait|run|start|execute)\s+(.+)",
|
||||
r"(?:je veux|je voudrais|peux-tu)\s+(.+)",
|
||||
# Verbes d'action explicites
|
||||
r"(?:lance[rz]?|exécute[rz]?|démarre[rz]?|fai[st]|run|start|execute)\s+(.+)",
|
||||
r"(?:je veux|je voudrais|peux-tu|pouvez-vous)\s+(.+)",
|
||||
r"(?:facturer?|créer?|générer?|exporter?)\s+(.+)",
|
||||
r"^(.+)\s+(?:maintenant|tout de suite|svp|stp)$",
|
||||
# Langage humain — demande de replay
|
||||
r"(?:refai[st](?:es)?|refaire|recommence[rz]?|rejoue[rz]?)\s+(?:la\s+)?(?:tâche\s+)?(.+)",
|
||||
# Gestes courants (UI actions) — doivent rester EXECUTE
|
||||
r"(?:ferme[rz]?|ouvr[eir]+[sz]?|clique[rz]?|sélectionne[rz]?|coche[rz]?|décoche[rz]?)\s+(.+)",
|
||||
r"(?:copie[rz]?|colle[rz]?|coupe[rz]?|supprime[rz]?|efface[rz]?)\s+(.+)",
|
||||
r"(?:tape[rz]?|écri[rstv]+[sz]?|saisi[rstv]*[sz]?|rempli[rstv]*[sz]?|entre[rz]?)\s+(.+)",
|
||||
r"(?:scroll(?:e[rz]?)?|défile[rz]?|fait(?:es)?\s+défiler)\s*(.+)?",
|
||||
r"(?:glisse[rz]?|drag(?:ue)?[rz]?|déplace[rz]?|bouge[rz]?)\s+(.+)",
|
||||
r"(?:double[- ]?clique[rz]?|clic\s+droit)\s+(.+)?",
|
||||
r"(?:enregistre[rz]?|sauvegarde[rz]?|save)\s+(.+)?",
|
||||
r"(?:imprime[rz]?|print)\s+(.+)?",
|
||||
r"(?:envoie[rz]?|send|mail(?:e[rz]?)?|transmet[sz]?)\s+(.+)",
|
||||
r"(?:télécharge[rz]?|download|upload)\s+(.+)?",
|
||||
r"(?:actualise[rz]?|rafraîchi[rstv]*[sz]?|refresh|recharge[rz]?)\s*(.+)?",
|
||||
r"(?:valide[rz]?|confirme[rz]?|soumets?|submit)\s+(.+)",
|
||||
r"(?:connecte[rz]?|login|log\s*in|sign\s*in)\s*(.+)?",
|
||||
r"(?:déconnecte[rz]?|logout|log\s*out|sign\s*out)\s*(.+)?",
|
||||
# Raccourcis clavier
|
||||
r"(?:ctrl|alt|shift|maj)\s*\+\s*\w+",
|
||||
# Langage humain — demande d'apprentissage (déclenche l'enregistrement)
|
||||
r"(?:apprends|apprenez)[- ]moi\s+(.+)",
|
||||
],
|
||||
IntentType.LIST: [
|
||||
r"(?:liste|montre|affiche|quels sont)\s+(?:les\s+|des\s+)?(?:workflows?|processus|automatisations?)",
|
||||
r"liste\s+des\s+workflows?",
|
||||
r"(?:qu'est-ce que|que)\s+(?:je peux|tu peux)\s+faire",
|
||||
r"(?:workflows?|processus)\s+disponibles?",
|
||||
r"(?:voir|afficher)\s+(?:les\s+|tous\s+les\s+)?workflows?",
|
||||
r"(?:liste|montre|affiche|quels?\s+sont)\s+(?:les\s+|des\s+)?(?:workflows?|tâches?|processus|automatisations?)",
|
||||
r"(?:quels?|quelles?)\s+(?:workflows?|tâches?|processus|automatisations?)",
|
||||
r"liste\s+des\s+(?:workflows?|tâches?)",
|
||||
r"(?:workflows?|tâches?|processus)\s+disponibles?",
|
||||
r"(?:voir|afficher)\s+(?:les\s+|tous\s+les\s+|mes\s+)?(?:workflows?|tâches?)",
|
||||
# Langage humain — demande de liste
|
||||
r"(?:qu'est-ce que\s+(?:tu|vous)\s+sai[st]\s+faire)",
|
||||
r"(?:que\s+sai[st]-(?:tu|vous)\s+faire)",
|
||||
r"mes\s+tâches?",
|
||||
],
|
||||
# SMALL_TALK doit être AVANT QUERY pour que "qui es-tu" ne soit pas
|
||||
# capturé par le pattern générique "qui + ..." de QUERY
|
||||
IntentType.SMALL_TALK: [
|
||||
# Remerciements
|
||||
r"^(?:merci|thanks?|thx|super|génial|parfait|cool|nickel|impec|impeccable|excellent|formidable)(?:\s.*)?$",
|
||||
# Adieux
|
||||
r"^(?:au revoir|à plus|bye|bonne nuit|à bientôt|à demain|ciao|tchao|tchuss|adieu)(?:\s.*)?$",
|
||||
# Compliments
|
||||
r"^(?:bien joué|bravo|top|chapeau|impressionnant|pas mal|bien fait|beau travail|good job|nice|trop bien|magnifique)(?:\s.*)?$",
|
||||
# Mécontentement
|
||||
r"^(?:c'est nul|nul|pas bien|pas top|pas ouf|bof|mauvais|moche|horrible|catastrophe|c'est pas bon|ça craint|erreur|bug|naze|pourri)(?:\s.*)?$",
|
||||
# Humour / boissons / nourriture / détente
|
||||
r"(?:une? (?:café|coca|thé|chocolat|verre|jus|bière|apéro|croissant|gâteau|bonbon|pause|pizza|glace)|café|coca|thé|chocolat|fais-moi rire|blague|raconte.+blague|drôle|rigol[eo]|mdr|lol|haha|ptdr|xd|😂|🤣|j'ai faim|j'ai soif|pause|il fait (?:chaud|froid|beau)|je suis (?:fatigué|crevé|motivé|content)|la flemme|trop bien|trop cool|vive .+|c'est la vie|oh là là|waouh|wow)",
|
||||
# Identité — qui es-tu ?
|
||||
r"(?:qui es[- ]tu|t'es qui|comment tu t'appelles|c'est quoi ton (?:nom|prénom)|t'es quoi|vous êtes qui|tu es quoi|tu t'appelles comment)",
|
||||
# Sentiments — ça va ?
|
||||
r"(?:ça va|comment (?:ça |tu |vous )?va[st]?|comment allez[- ]vous|tu vas bien|la forme|en forme|et toi|et vous)",
|
||||
],
|
||||
IntentType.QUERY: [
|
||||
r"(?:comment|pourquoi|quand|où|qui)\s+(.+)\?",
|
||||
# Questions directes avec mots interrogatifs
|
||||
r"(?:comment|pourquoi|quand|où|qui)\s+(.+)\??",
|
||||
r"(?:explique|décris|détaille)\s+(.+)",
|
||||
r"(?:qu'est-ce que|c'est quoi)\s+(.+)",
|
||||
# Questions avec "quel/quelle/quels/quelles" (exclure workflows → LIST)
|
||||
r"(?:quels?|quelles?)\s+(?!workflows?|processus|automatisations?)(.+)\??",
|
||||
# "quoi" comme question (pas une commande, pas "quoi faire" = HELP)
|
||||
r"^(?:c'est\s+)?quoi\s+(?!faire)(.+)\??$",
|
||||
r"^quoi\s*\?+$",
|
||||
# Questions indirectes
|
||||
r"(?:dis[- ]moi|raconte|informe[- ]moi)\s+(.+)",
|
||||
r"(?:je\s+(?:me\s+)?demande|je\s+(?:ne\s+)?comprends?\s+pas)\s+(.+)",
|
||||
],
|
||||
IntentType.HELP: [
|
||||
r"(?:aide|help|assistance|sos)",
|
||||
r"(?:comment ça marche|comment utiliser)",
|
||||
r"^(?:aide|help|assistance|sos)$",
|
||||
r"comment ça (?:marche|fonctionne)\s*\??",
|
||||
r"comment (?:utiliser|ça s'utilise|on fait)\s*\??",
|
||||
r"\?{2,}",
|
||||
# "que peux-tu faire", "quoi faire" = demande d'aide
|
||||
r"(?:qu'est-ce que|que)\s+(?:je peux|tu peux|vous pouvez)\s+faire",
|
||||
r"^quoi\s+faire\s*\??$",
|
||||
r"(?:que\s+)?(?:puis-je|peux-tu|pouvez-vous|peut-on)\s+faire\s*\??",
|
||||
r"(?:besoin\s+d'aide|j'ai\s+besoin\s+d'aide)",
|
||||
],
|
||||
IntentType.GREETING: [
|
||||
r"^(?:bonjour|bonsoir|salut|hello|hi|hey|coucou|yo|wesh)(?:\s.*)?$",
|
||||
r"^(?:bonne?\s+(?:journée|soirée|nuit|matinée))$",
|
||||
],
|
||||
IntentType.STATUS: [
|
||||
r"(?:statut|status|état|où en est)",
|
||||
@@ -102,8 +183,10 @@ class IntentParser:
|
||||
r"(?:terminé|fini|done)\s*\?",
|
||||
],
|
||||
IntentType.CANCEL: [
|
||||
r"(?:annule|stop|arrête|cancel|abort)",
|
||||
r"(?:laisse tomber|oublie)",
|
||||
r"(?:annule[rz]?|stop|arrête[rz]?|cancel|abort)",
|
||||
r"(?:laisse[rz]?\s+tomber|oublie[rz]?)",
|
||||
# Langage humain — stop courant
|
||||
r"^(?:arrêtez|stoppe[rz]?)$",
|
||||
],
|
||||
IntentType.HISTORY: [
|
||||
r"(?:historique|history|dernières?\s+commandes?)",
|
||||
@@ -119,6 +202,35 @@ class IntentParser:
|
||||
],
|
||||
}
|
||||
|
||||
# Verbes d'action reconnus pour le fallback EXECUTE
|
||||
# Si aucun pattern ne matche, on vérifie la présence d'un de ces verbes
|
||||
# avant de classifier en EXECUTE
|
||||
ACTION_VERBS = {
|
||||
# Actions de workflow/exécution
|
||||
"lance", "lancer", "exécute", "exécuter", "démarre", "démarrer",
|
||||
"fait", "fais", "run", "start", "execute",
|
||||
# Actions métier
|
||||
"facture", "facturer", "crée", "créer", "génère", "générer",
|
||||
"exporte", "exporter", "importe", "importer",
|
||||
# Actions UI / gestes
|
||||
"ferme", "fermer", "ouvre", "ouvrir", "clique", "cliquer",
|
||||
"sélectionne", "sélectionner", "coche", "cocher", "décoche", "décocher",
|
||||
"copie", "copier", "colle", "coller", "coupe", "couper",
|
||||
"supprime", "supprimer", "efface", "effacer",
|
||||
"tape", "taper", "écris", "écrire", "saisis", "saisir",
|
||||
"remplis", "remplir", "entre", "entrer",
|
||||
"scroll", "scroller", "défile", "défiler",
|
||||
"glisse", "glisser", "déplace", "déplacer", "drag",
|
||||
"enregistre", "enregistrer", "sauvegarde", "sauvegarder", "save",
|
||||
"imprime", "imprimer", "print",
|
||||
"envoie", "envoyer", "send", "transmet", "transmettre",
|
||||
"télécharge", "télécharger", "download", "upload",
|
||||
"actualise", "actualiser", "rafraîchis", "rafraîchir", "refresh",
|
||||
"valide", "valider", "confirme", "confirmer", "soumets", "soumettre",
|
||||
"connecte", "connecter", "déconnecte", "déconnecter",
|
||||
"login", "logout",
|
||||
}
|
||||
|
||||
# Patterns pour l'extraction d'entités
|
||||
ENTITY_PATTERNS = {
|
||||
"client": [
|
||||
@@ -139,6 +251,29 @@ class IntentParser:
|
||||
r"de\s+([A-Za-z])\s+à\s+([A-Za-z])",
|
||||
r"(\d+)\s*(?:-|à|to)\s*(\d+)",
|
||||
],
|
||||
"expression": [
|
||||
# Expressions mathématiques : 5+2, 100*3, 12/4, 7-3, 2.5+3.1
|
||||
r"(\d+(?:[.,]\d+)?\s*[+\-*/x×÷]\s*\d+(?:[.,]\d+)?)",
|
||||
],
|
||||
"file_path": [
|
||||
# Chemins Windows : C:\data\fichier.xlsx
|
||||
r"([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv))",
|
||||
# Chemins Unix : /data/fichier.xlsx
|
||||
r"(/[^\s,]+\.(?:xlsx?|csv))",
|
||||
# Noms de fichier simples : patients.xlsx
|
||||
r"(?:^|\s)([\w\-\.]+\.(?:xlsx?|csv))(?:\s|$)",
|
||||
],
|
||||
"folder_path": [
|
||||
# Dossiers Windows : C:\data\imports
|
||||
r"(?:dossier|répertoire|dir|directory)\s+([A-Za-z]:\\[^\s,]+)",
|
||||
r"([A-Za-z]:\\[^\s,]+)(?:\s|$)",
|
||||
# Dossiers Unix : /data/imports
|
||||
r"(?:dossier|répertoire|dir|directory)\s+(/[^\s,]+)",
|
||||
],
|
||||
"table_name": [
|
||||
# Noms de table (exclure les mots courants comme "à", "de", "la")
|
||||
r"(?:table|la\s+table)\s+['\"]?(\w{2,})['\"]?",
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -223,6 +358,10 @@ class IntentParser:
|
||||
# 4. Construire les paramètres depuis les entités
|
||||
parameters = self._entities_to_parameters(entities)
|
||||
|
||||
# 4b. Enrichir les paramètres DATA_IMPORT avec l'action et le chemin
|
||||
if intent_type == IntentType.DATA_IMPORT:
|
||||
parameters = self._enrich_data_import_params(normalized, query, parameters, entities)
|
||||
|
||||
# 5. Si le LLM est disponible et la confiance est basse, utiliser le LLM
|
||||
if self.use_llm and self.llm_available and rule_confidence < 0.7:
|
||||
llm_result = self._parse_with_llm(query, context)
|
||||
@@ -245,13 +384,89 @@ class IntentParser:
|
||||
clarification_question=clarification_question
|
||||
)
|
||||
|
||||
def _enrich_data_import_params(
|
||||
self,
|
||||
normalized: str,
|
||||
raw_query: str,
|
||||
parameters: Dict[str, Any],
|
||||
entities: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Enrichir les paramètres pour une intention DATA_IMPORT.
|
||||
|
||||
Détermine l'action (import_file, import_folder, list_tables, table_info)
|
||||
et extrait le chemin de fichier / nom de table.
|
||||
"""
|
||||
# Déterminer l'action
|
||||
list_patterns = [
|
||||
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?",
|
||||
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans)",
|
||||
r"liste\s+(?:des?\s+)?tables?",
|
||||
]
|
||||
info_patterns = [
|
||||
r"combien\s+de\s+lignes?",
|
||||
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table",
|
||||
]
|
||||
folder_patterns = [
|
||||
r"(?:feuilles?\s+excel|fichiers?\s+excel)\s+(?:du|de)\s+(?:dossier|répertoire)",
|
||||
r"(?:importe|charge|lis)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier|de\s+)",
|
||||
]
|
||||
|
||||
action = "import_file" # Par défaut
|
||||
|
||||
for pat in list_patterns:
|
||||
if re.search(pat, normalized, re.IGNORECASE):
|
||||
action = "list_tables"
|
||||
break
|
||||
|
||||
if action == "import_file":
|
||||
for pat in info_patterns:
|
||||
if re.search(pat, normalized, re.IGNORECASE):
|
||||
action = "table_info"
|
||||
break
|
||||
|
||||
if action == "import_file":
|
||||
for pat in folder_patterns:
|
||||
if re.search(pat, normalized, re.IGNORECASE):
|
||||
action = "import_folder"
|
||||
break
|
||||
|
||||
parameters["action"] = action
|
||||
|
||||
# Extraire le chemin de fichier depuis les entités
|
||||
for entity in entities:
|
||||
if entity["type"] == "file_path" and "file_path" not in parameters:
|
||||
parameters["file_path"] = entity["value"]
|
||||
elif entity["type"] == "folder_path" and "folder_path" not in parameters:
|
||||
parameters["folder_path"] = entity["value"]
|
||||
elif entity["type"] == "table_name" and "table_name" not in parameters:
|
||||
parameters["table_name"] = entity["value"]
|
||||
|
||||
# Fallback : extraire un chemin de fichier depuis la requête brute
|
||||
if "file_path" not in parameters and action == "import_file":
|
||||
# Chercher un .xlsx/.xls/.csv dans la requête brute (supporte les chemins Windows)
|
||||
fp_match = re.search(
|
||||
r'([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv)|/[^\s,]+\.(?:xlsx?|csv)|[\w\-\.]+\.(?:xlsx?|csv))',
|
||||
raw_query,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if fp_match:
|
||||
parameters["file_path"] = fp_match.group(1)
|
||||
|
||||
# Extraire table_name pour table_info depuis la requête
|
||||
if action == "table_info" and "table_name" not in parameters:
|
||||
tm = re.search(r"table\s+['\"]?(\w+)['\"]?", normalized, re.IGNORECASE)
|
||||
if tm:
|
||||
parameters["table_name"] = tm.group(1)
|
||||
|
||||
return parameters
|
||||
|
||||
def _normalize_query(self, query: str) -> str:
|
||||
"""Normaliser une requête pour le matching."""
|
||||
# Convertir en minuscules
|
||||
normalized = query.lower()
|
||||
|
||||
# Supprimer la ponctuation excessive
|
||||
normalized = re.sub(r'[!.]+$', '', normalized)
|
||||
# Supprimer la ponctuation finale
|
||||
normalized = re.sub(r'[!.?]+$', '', normalized)
|
||||
|
||||
# Normaliser les espaces
|
||||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||
@@ -276,11 +491,18 @@ class IntentParser:
|
||||
best_confidence = confidence
|
||||
best_intent = intent_type
|
||||
|
||||
# Si aucune intention trouvée mais la requête ressemble à une commande
|
||||
# Fallback durci : ne classifier en EXECUTE que si un verbe d'action est présent
|
||||
if best_intent == IntentType.UNKNOWN and len(query.split()) >= 2:
|
||||
# Supposer que c'est une demande d'exécution
|
||||
words = query.lower().split()
|
||||
# Vérifier si au moins un mot est un verbe d'action connu
|
||||
has_action_verb = any(word in self.ACTION_VERBS for word in words)
|
||||
if has_action_verb:
|
||||
best_intent = IntentType.EXECUTE
|
||||
best_confidence = 0.4
|
||||
best_confidence = 0.40
|
||||
else:
|
||||
# Pas de verbe d'action reconnu → demander clarification
|
||||
best_intent = IntentType.CLARIFY
|
||||
best_confidence = 0.30
|
||||
|
||||
return best_intent, best_confidence
|
||||
|
||||
@@ -357,9 +579,9 @@ class IntentParser:
|
||||
"""Vérifier si une clarification est nécessaire."""
|
||||
|
||||
if intent_type == IntentType.EXECUTE:
|
||||
# Si pas de hint de workflow, demander clarification
|
||||
# Si pas de hint de tâche, demander clarification
|
||||
if not workflow_hint:
|
||||
return True, "Quel workflow souhaitez-vous exécuter ?"
|
||||
return True, "Quelle tâche souhaitez-vous lancer ?"
|
||||
|
||||
# Si le hint est trop vague
|
||||
if len(workflow_hint.split()) <= 1:
|
||||
@@ -378,22 +600,24 @@ class IntentParser:
|
||||
workflow_names = [w.get("name", "") for w in self._workflows_cache[:15]]
|
||||
workflows_context = f"\nWorkflows disponibles: {', '.join(workflow_names)}"
|
||||
|
||||
prompt = f"""Tu es un assistant RPA. Analyse cette requête utilisateur.
|
||||
prompt = f"""Tu es Léa, une assistante chaleureuse. Analyse cette requête utilisateur.
|
||||
|
||||
REQUÊTE: "{query}"
|
||||
{workflows_context}
|
||||
{f"Contexte conversation: {json.dumps(context, ensure_ascii=False)}" if context else ""}
|
||||
|
||||
INTENTIONS POSSIBLES:
|
||||
- execute: l'utilisateur veut lancer/exécuter un workflow
|
||||
- list: l'utilisateur veut voir les workflows disponibles (mots-clés: liste, quels, workflows, disponibles, montrer)
|
||||
- query: l'utilisateur pose une question sur un workflow
|
||||
- execute: l'utilisateur veut lancer/refaire une tâche ou une action UI (geste). Inclut "apprends-moi", "refais la tâche", "lance"
|
||||
- list: l'utilisateur veut voir les tâches disponibles (mots-clés: liste, quels, tâches, qu'est-ce que tu sais faire, mes tâches)
|
||||
- query: l'utilisateur pose une question (comment, pourquoi, c'est quoi, quel)
|
||||
- status: l'utilisateur demande le statut d'exécution
|
||||
- cancel: l'utilisateur veut annuler
|
||||
- cancel: l'utilisateur veut arrêter/annuler (arrête, stop, annule)
|
||||
- history: l'utilisateur veut voir l'historique
|
||||
- help: l'utilisateur demande de l'aide
|
||||
- help: l'utilisateur demande de l'aide ou ce qu'il peut faire
|
||||
- greeting: l'utilisateur dit bonjour/salut/hello
|
||||
- confirm: l'utilisateur confirme (oui, ok, go)
|
||||
- deny: l'utilisateur refuse (non, annule)
|
||||
- small_talk: conversation informelle (merci, café, ça va, qui es-tu, bravo, c'est nul)
|
||||
- unknown: impossible à déterminer
|
||||
|
||||
Réponds UNIQUEMENT en JSON valide (pas de texte avant/après):
|
||||
@@ -500,16 +724,46 @@ if __name__ == "__main__":
|
||||
parser = IntentParser(use_llm=False)
|
||||
|
||||
test_queries = [
|
||||
# EXECUTE — actions explicites
|
||||
"facturer le client Acme",
|
||||
"lance le workflow de facturation",
|
||||
"quels workflows sont disponibles ?",
|
||||
"aide",
|
||||
"oui",
|
||||
"annule",
|
||||
"statut",
|
||||
"exporter le rapport en PDF pour Client ABC",
|
||||
"créer une facture de 1500€ pour Société XYZ",
|
||||
"facturer les clients de A à Z",
|
||||
# EXECUTE — gestes UI
|
||||
"ferme la fenêtre",
|
||||
"ouvre un nouvel onglet",
|
||||
"copier le texte",
|
||||
"lance la facturation",
|
||||
# LIST
|
||||
"quels workflows sont disponibles ?",
|
||||
"liste des workflows",
|
||||
# QUERY — questions
|
||||
"comment ça marche ?",
|
||||
"c'est quoi ce workflow",
|
||||
"pourquoi ce processus est lent ?",
|
||||
# HELP
|
||||
"aide",
|
||||
"quoi faire ?",
|
||||
"que peux-tu faire ?",
|
||||
# GREETING
|
||||
"bonjour",
|
||||
"salut",
|
||||
# Confirmations / annulations
|
||||
"oui",
|
||||
"annule",
|
||||
"statut",
|
||||
# SMALL_TALK — conversation informelle
|
||||
"merci",
|
||||
"un café",
|
||||
"ça va ?",
|
||||
"qui es-tu ?",
|
||||
"c'est nul",
|
||||
"bravo",
|
||||
"au revoir",
|
||||
"t'es qui",
|
||||
# Fallback — ne doit PAS être EXECUTE
|
||||
"blah blah test",
|
||||
]
|
||||
|
||||
print("=== Tests IntentParser ===\n")
|
||||
|
||||
@@ -14,6 +14,7 @@ Auteur: Dom - Janvier 2026
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, List, Optional
|
||||
@@ -60,32 +61,40 @@ class ResponseGenerator:
|
||||
"""
|
||||
|
||||
# Templates de réponses par type d'intention
|
||||
# Ton : collègue chaleureuse et professionnelle, vouvoiement
|
||||
RESPONSE_TEMPLATES = {
|
||||
IntentType.EXECUTE: {
|
||||
"success": [
|
||||
"J'ai lancé le workflow '{workflow}'. {details}",
|
||||
"Le workflow '{workflow}' est en cours d'exécution. {details}",
|
||||
"C'est parti pour '{workflow}' ! {details}"
|
||||
"C'est parti, je lance '{workflow}'. {details}",
|
||||
"Je m'occupe de '{workflow}'. {details}",
|
||||
"'{workflow}' est en cours ! {details}"
|
||||
],
|
||||
"error": [
|
||||
"Impossible d'exécuter '{workflow}': {error}",
|
||||
"Erreur lors du lancement de '{workflow}': {error}",
|
||||
"Le workflow '{workflow}' a échoué: {error}"
|
||||
"Hmm, je n'ai pas réussi à faire '{workflow}' : {error}",
|
||||
"Désolée, '{workflow}' a rencontré un souci : {error}",
|
||||
"Oups, '{workflow}' n'a pas fonctionné : {error}"
|
||||
],
|
||||
"not_found": [
|
||||
"Je n'ai pas trouvé de workflow correspondant à '{query}'.",
|
||||
"Aucun workflow ne correspond à '{query}'. Voulez-vous voir la liste ?",
|
||||
"'{query}' ne correspond à aucun workflow connu."
|
||||
"Je ne connais pas encore '{query}'. Montrez-moi comment faire et je l'apprendrai !",
|
||||
"'{query}' m'est inconnu pour l'instant. Vous pouvez me montrer en cliquant sur « Apprenez-moi ».",
|
||||
"Je ne sais pas encore faire '{query}'. Montrez-moi et je m'en souviendrai !"
|
||||
],
|
||||
"gesture": [
|
||||
"{gesture_name} ({gesture_keys}) envoyé !",
|
||||
"Raccourci {gesture_name} ({gesture_keys}) exécuté.",
|
||||
],
|
||||
"copilot": [
|
||||
"Mode pas-à-pas activé pour '{workflow}'. Je vous demande de valider chaque étape.",
|
||||
]
|
||||
},
|
||||
IntentType.LIST: {
|
||||
"success": [
|
||||
"Voici les workflows disponibles :\n{list}",
|
||||
"J'ai trouvé {count} workflows :\n{list}",
|
||||
"Voici les tâches que je sais faire :\n{list}",
|
||||
"J'ai {count} tâches en mémoire :\n{list}",
|
||||
],
|
||||
"empty": [
|
||||
"Aucun workflow n'est configuré pour le moment.",
|
||||
"La liste des workflows est vide."
|
||||
"Je n'ai encore appris aucune tâche. Montrez-moi quelque chose !",
|
||||
"Ma liste est vide pour le moment. Apprenez-moi une première tâche !"
|
||||
]
|
||||
},
|
||||
IntentType.QUERY: {
|
||||
@@ -94,70 +103,78 @@ class ResponseGenerator:
|
||||
"À propos de '{topic}' :\n{answer}"
|
||||
],
|
||||
"not_found": [
|
||||
"Je n'ai pas d'information sur '{topic}'.",
|
||||
"Je ne peux pas répondre à cette question sur '{topic}'."
|
||||
"Je n'ai pas d'information sur '{topic}'. Pouvez-vous préciser ?",
|
||||
"Désolée, je ne peux pas vous répondre sur '{topic}'."
|
||||
]
|
||||
},
|
||||
IntentType.HELP: {
|
||||
"general": [
|
||||
"Je suis votre assistant RPA. Voici ce que je peux faire :\n\n"
|
||||
"• Exécuter des workflows : \"lance facturation client Acme\"\n"
|
||||
"• Lister les workflows : \"quels workflows sont disponibles ?\"\n"
|
||||
"• Voir le statut : \"où en est l'exécution ?\"\n"
|
||||
"• Annuler : \"annule\"\n\n"
|
||||
"Tapez votre commande en langage naturel !",
|
||||
"Je suis Léa, votre assistante. Voici ce que je peux faire :\n\n"
|
||||
"• Apprendre une tâche : cliquez sur « Apprenez-moi »\n"
|
||||
"• Refaire une tâche : \"lance facturation\" ou cliquez sur « Lancer »\n"
|
||||
"• Voir mes tâches : \"qu'est-ce que tu sais faire ?\"\n"
|
||||
"• Importer des données : \"importe le fichier Excel\"\n"
|
||||
"• Arrêter : \"arrête\"\n\n"
|
||||
"Parlez-moi naturellement, je fais de mon mieux pour comprendre !",
|
||||
]
|
||||
},
|
||||
IntentType.GREETING: {
|
||||
"default": [
|
||||
"Bonjour ! Je suis Léa. Que puis-je faire pour vous ?",
|
||||
"Bonjour ! Comment puis-je vous aider aujourd'hui ?",
|
||||
"Bonjour ! Dites-moi ce dont vous avez besoin, ou tapez « aide ».",
|
||||
]
|
||||
},
|
||||
IntentType.STATUS: {
|
||||
"running": [
|
||||
"Exécution en cours : '{workflow}'\nProgression : {progress}%\n{message}",
|
||||
"Le workflow '{workflow}' s'exécute ({progress}%): {message}"
|
||||
"Je suis en train de faire '{workflow}' — progression : {progress}%\n{message}",
|
||||
"'{workflow}' est en cours ({progress}%) : {message}"
|
||||
],
|
||||
"idle": [
|
||||
"Aucune exécution en cours. Système prêt.",
|
||||
"Tout est calme. Que puis-je faire pour vous ?"
|
||||
"Tout est calme, je suis disponible. Que puis-je faire pour vous ?",
|
||||
"Rien en cours. Je suis prête !"
|
||||
],
|
||||
"completed": [
|
||||
"Dernière exécution : '{workflow}' - {status}",
|
||||
"La dernière tâche '{workflow}' est terminée : {status}",
|
||||
"'{workflow}' est terminé : {status}"
|
||||
]
|
||||
},
|
||||
IntentType.CANCEL: {
|
||||
"success": [
|
||||
"Exécution annulée.",
|
||||
"J'ai arrêté le workflow en cours.",
|
||||
"Annulation effectuée."
|
||||
"C'est arrêté.",
|
||||
"J'ai tout arrêté.",
|
||||
"Annulation faite."
|
||||
],
|
||||
"nothing": [
|
||||
"Rien à annuler, aucune exécution en cours.",
|
||||
"Il n'y a pas d'exécution active."
|
||||
"Il n'y a rien en cours à arrêter.",
|
||||
"Rien à annuler, je suis disponible."
|
||||
]
|
||||
},
|
||||
IntentType.HISTORY: {
|
||||
"success": [
|
||||
"Voici vos dernières commandes :\n{history}",
|
||||
"Voici vos dernières actions :\n{history}",
|
||||
"Historique récent :\n{history}"
|
||||
],
|
||||
"empty": [
|
||||
"Pas encore d'historique.",
|
||||
"Vous n'avez pas encore exécuté de commandes."
|
||||
"Vous n'avez encore rien fait avec moi."
|
||||
]
|
||||
},
|
||||
IntentType.CONFIRM: {
|
||||
"accepted": [
|
||||
"Très bien, j'exécute '{workflow}'.",
|
||||
"Très bien, je m'en occupe : '{workflow}'.",
|
||||
"C'est parti pour '{workflow}' !",
|
||||
"Confirmé. Lancement de '{workflow}'."
|
||||
"Entendu. Je lance '{workflow}'."
|
||||
],
|
||||
"no_pending": [
|
||||
"Il n'y a rien à confirmer.",
|
||||
"Aucune action en attente de confirmation."
|
||||
"Il n'y a rien à confirmer pour le moment.",
|
||||
"Aucune action en attente."
|
||||
]
|
||||
},
|
||||
IntentType.DENY: {
|
||||
"cancelled": [
|
||||
"Action annulée.",
|
||||
"D'accord, j'annule.",
|
||||
"D'accord, c'est annulé.",
|
||||
"Entendu, j'annule.",
|
||||
"Compris, on oublie."
|
||||
]
|
||||
},
|
||||
@@ -166,11 +183,91 @@ class ResponseGenerator:
|
||||
"{question}",
|
||||
]
|
||||
},
|
||||
IntentType.DATA_IMPORT: {
|
||||
"preview": [
|
||||
"J'ai trouvé le fichier **{filename}** — {total_rows} lignes, colonnes : {columns}. Je l'importe dans la table '{table_name}' ?",
|
||||
"Fichier **{filename}** prêt : {total_rows} lignes avec les colonnes {columns}. On crée la table '{table_name}' ?",
|
||||
],
|
||||
"imported": [
|
||||
"Table **'{table_name}'** créée avec {row_count} lignes et {col_count} colonnes ({columns}). Vous pouvez maintenant l'utiliser dans une tâche !",
|
||||
"Import réussi ! Table **'{table_name}'** : {row_count} lignes, {col_count} colonnes ({columns}).",
|
||||
],
|
||||
"list_tables": [
|
||||
"Voici vos tables de données :\n{tables_list}",
|
||||
"Tables disponibles :\n{tables_list}",
|
||||
],
|
||||
"no_tables": [
|
||||
"Vous n'avez pas encore de données importées. Envoyez-moi un fichier Excel pour commencer !",
|
||||
"La base est vide. Importez un fichier Excel pour créer votre première table.",
|
||||
],
|
||||
"table_info": [
|
||||
"La table **'{table_name}'** contient {row_count} lignes et {col_count} colonnes :\n{columns_detail}",
|
||||
],
|
||||
"folder_list": [
|
||||
"J'ai trouvé {count} fichiers Excel dans le dossier :\n{files_list}\n\nDites-moi lequel importer !",
|
||||
],
|
||||
"folder_empty": [
|
||||
"Je n'ai trouvé aucun fichier Excel dans '{folder}'. Vérifiez le chemin.",
|
||||
],
|
||||
"file_not_found": [
|
||||
"Je n'ai pas trouvé le fichier '{file_path}'. Vérifiez le chemin ou envoyez-le directement.",
|
||||
"Fichier introuvable : '{file_path}'. Vous pouvez aussi glisser un fichier dans le chat.",
|
||||
],
|
||||
"error": [
|
||||
"Désolée, l'import a échoué : {error}",
|
||||
"Oups, un souci lors de l'import : {error}",
|
||||
],
|
||||
"uploaded": [
|
||||
"Fichier **{filename}** reçu ! Je l'analyse...",
|
||||
],
|
||||
},
|
||||
IntentType.SMALL_TALK: {
|
||||
"thanks": [
|
||||
"Avec plaisir ! N'hésitez pas si vous avez besoin d'autre chose 😊",
|
||||
"De rien ! Je suis là pour ça 👍",
|
||||
"Merci à vous ! Toujours prête à aider.",
|
||||
],
|
||||
"farewell": [
|
||||
"À bientôt ! Je reste dans la barre des tâches si vous avez besoin 😊",
|
||||
"Bonne continuation ! N'hésitez pas à revenir.",
|
||||
"À plus tard ! Je ne bouge pas 👋",
|
||||
],
|
||||
"compliment": [
|
||||
"Merci, c'est gentil ! J'apprends un peu plus chaque jour grâce à vous 😊",
|
||||
"Oh merci ! Ça me fait plaisir 😄",
|
||||
"C'est vous qui êtes formidable ! Merci pour votre confiance.",
|
||||
],
|
||||
"complaint": [
|
||||
"Je suis désolée... Dites-moi ce qui ne va pas, je vais essayer de m'améliorer.",
|
||||
"Oups... N'hésitez pas à me dire ce qui n'a pas marché, je ferai mieux la prochaine fois.",
|
||||
"Pardon pour le désagrément. Comment puis-je corriger ça ?",
|
||||
],
|
||||
"humor": [
|
||||
"Pas encore de machine à café intégrée... mais j'y travaille ! 😄",
|
||||
"Ha ha ! Si seulement je pouvais... 😄 Dites-moi plutôt comment vous aider !",
|
||||
"Bonne idée ! Malheureusement je ne sais pas encore faire ça 😊 Mais pour vos tâches informatiques, je suis là !",
|
||||
],
|
||||
"mood": [
|
||||
"Je comprends ! Prenez une pause, je m'occupe du reste 😊",
|
||||
"Courage ! Si vous avez des tâches ennuyeuses, confiez-les moi pendant votre pause.",
|
||||
"On fait tous des pauses ! Je reste là si vous avez besoin 👍",
|
||||
],
|
||||
"identity": [
|
||||
"Je suis Léa, votre assistante ! Je peux apprendre vos tâches répétitives et les refaire à votre place 😊",
|
||||
"Moi c'est Léa ! Je suis là pour automatiser tout ce qui vous ennuie au quotidien.",
|
||||
"Je m'appelle Léa. Mon job : observer, apprendre, et vous faire gagner du temps 👍",
|
||||
],
|
||||
"feelings": [
|
||||
"Très bien, merci de demander ! Et vous ? Prête à travailler si vous avez besoin 😊",
|
||||
"En pleine forme ! Et vous, comment ça va ? Dites-moi si je peux aider.",
|
||||
"Ça va super bien ! Toujours motivée pour vous donner un coup de main 💪",
|
||||
],
|
||||
},
|
||||
IntentType.UNKNOWN: {
|
||||
"default": [
|
||||
"Je n'ai pas compris. Pouvez-vous reformuler ?",
|
||||
"Désolé, je ne comprends pas '{query}'. Tapez 'aide' pour voir les commandes.",
|
||||
"'{query}' ? Je ne suis pas sûr de comprendre."
|
||||
"Je n'ai pas bien compris. Vous pouvez me demander de l'aide avec le bouton ❓",
|
||||
"Désolée, je ne comprends pas. Tapez « aide » pour voir ce que je sais faire.",
|
||||
"Hmm, je n'ai pas saisi votre demande. Essayez de reformuler ou tapez « aide »."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -179,21 +276,30 @@ class ResponseGenerator:
|
||||
CONTEXTUAL_SUGGESTIONS = {
|
||||
"after_execute": [
|
||||
"voir le statut",
|
||||
"annuler",
|
||||
"liste des workflows"
|
||||
"arrêter",
|
||||
"mes tâches"
|
||||
],
|
||||
"after_error": [
|
||||
"aide",
|
||||
"liste des workflows",
|
||||
"mes tâches",
|
||||
"réessayer"
|
||||
],
|
||||
"after_list": [
|
||||
"exécuter un workflow",
|
||||
"lancer une tâche",
|
||||
"aide"
|
||||
],
|
||||
"idle": [
|
||||
"facturer client X",
|
||||
"liste des workflows",
|
||||
"qu'est-ce que tu sais faire ?",
|
||||
"apprenez-moi",
|
||||
"aide"
|
||||
],
|
||||
"after_import": [
|
||||
"montre les tables",
|
||||
"importer un autre fichier",
|
||||
"aide"
|
||||
],
|
||||
"after_table_list": [
|
||||
"importer un fichier Excel",
|
||||
"aide"
|
||||
]
|
||||
}
|
||||
@@ -273,7 +379,7 @@ class ResponseGenerator:
|
||||
Générer un message de progression.
|
||||
|
||||
Args:
|
||||
workflow_name: Nom du workflow
|
||||
workflow_name: Nom de la tâche
|
||||
progress: Pourcentage de progression
|
||||
step: Étape actuelle
|
||||
current: Numéro de l'étape
|
||||
@@ -287,11 +393,11 @@ class ResponseGenerator:
|
||||
filled = int(bar_length * progress / 100)
|
||||
bar = "█" * filled + "░" * (bar_length - filled)
|
||||
|
||||
message = f"**{workflow_name}** [{bar}] {progress}%\n\nÉtape {current}/{total}: {step}"
|
||||
message = f"**{workflow_name}** [{bar}] {progress}%\n\nÉtape {current}/{total} : {step}"
|
||||
|
||||
return GeneratedResponse(
|
||||
message=message,
|
||||
suggestions=["annuler"] if progress < 100 else [],
|
||||
suggestions=["arrêter"] if progress < 100 else [],
|
||||
action_required=False,
|
||||
metadata={
|
||||
"workflow": workflow_name,
|
||||
@@ -311,7 +417,7 @@ class ResponseGenerator:
|
||||
Générer un message de résultat d'exécution.
|
||||
|
||||
Args:
|
||||
workflow_name: Nom du workflow
|
||||
workflow_name: Nom de la tâche
|
||||
success: Succès ou échec
|
||||
message: Message détaillé
|
||||
duration: Durée d'exécution en secondes
|
||||
@@ -320,18 +426,14 @@ class ResponseGenerator:
|
||||
GeneratedResponse avec le résultat
|
||||
"""
|
||||
if success:
|
||||
emoji = "✅"
|
||||
status = "terminé avec succès"
|
||||
response_message = f"C'est fait ! **{workflow_name}** s'est bien passé.\n\n{message}"
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["idle"]
|
||||
else:
|
||||
emoji = "❌"
|
||||
status = "échoué"
|
||||
response_message = f"Hmm, **{workflow_name}** n'a pas fonctionné.\n\n{message}"
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_error"]
|
||||
|
||||
response_message = f"{emoji} **{workflow_name}** {status}\n\n{message}"
|
||||
|
||||
if duration:
|
||||
response_message += f"\n\nDurée: {duration:.1f}s"
|
||||
response_message += f"\n\nDurée : {duration:.1f}s"
|
||||
|
||||
return GeneratedResponse(
|
||||
message=response_message,
|
||||
@@ -355,7 +457,21 @@ class ResponseGenerator:
|
||||
"""Handler pour les intentions d'exécution."""
|
||||
templates = self.RESPONSE_TEMPLATES[IntentType.EXECUTE]
|
||||
|
||||
if result.get("success"):
|
||||
if result.get("gesture"):
|
||||
# Geste primitif (raccourci clavier)
|
||||
template = random.choice(templates["gesture"])
|
||||
message = template.format(
|
||||
gesture_name=result.get("gesture_name", "?"),
|
||||
gesture_keys=result.get("gesture_keys", "?"),
|
||||
)
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_execute"]
|
||||
|
||||
elif result.get("mode") == "copilot":
|
||||
template = random.choice(templates["copilot"])
|
||||
message = template.format(workflow=result.get("workflow", "?"))
|
||||
suggestions = ["approuver", "passer", "annuler"]
|
||||
|
||||
elif result.get("success"):
|
||||
template = random.choice(templates["success"])
|
||||
workflow = result.get("workflow", intent.workflow_hint or "inconnu")
|
||||
details = ""
|
||||
@@ -369,8 +485,9 @@ class ResponseGenerator:
|
||||
|
||||
elif result.get("not_found"):
|
||||
template = random.choice(templates["not_found"])
|
||||
message = template.format(query=intent.raw_query)
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_error"]
|
||||
query = result.get("query", intent.raw_query)
|
||||
message = template.format(query=query)
|
||||
suggestions = ["mes tâches", "aide", "apprenez-moi"]
|
||||
|
||||
else:
|
||||
template = random.choice(templates["error"])
|
||||
@@ -426,6 +543,22 @@ class ResponseGenerator:
|
||||
action_required=False
|
||||
)
|
||||
|
||||
def _handle_greeting(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
context: Dict[str, Any],
|
||||
result: Dict[str, Any]
|
||||
) -> GeneratedResponse:
|
||||
"""Handler pour les salutations."""
|
||||
templates = self.RESPONSE_TEMPLATES[IntentType.GREETING]
|
||||
message = random.choice(templates["default"])
|
||||
|
||||
return GeneratedResponse(
|
||||
message=message,
|
||||
suggestions=self.CONTEXTUAL_SUGGESTIONS["idle"],
|
||||
action_required=False
|
||||
)
|
||||
|
||||
def _handle_status(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
@@ -578,6 +711,187 @@ class ResponseGenerator:
|
||||
action_required=False
|
||||
)
|
||||
|
||||
def _handle_data_import(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
context: Dict[str, Any],
|
||||
result: Dict[str, Any]
|
||||
) -> GeneratedResponse:
|
||||
"""Handler pour les imports de données (Excel/CSV)."""
|
||||
templates = self.RESPONSE_TEMPLATES[IntentType.DATA_IMPORT]
|
||||
|
||||
if result.get("file_not_found"):
|
||||
template = random.choice(templates["file_not_found"])
|
||||
message = template.format(file_path=result.get("file_path", "?"))
|
||||
suggestions = ["aide"]
|
||||
|
||||
elif result.get("preview"):
|
||||
# Aperçu avant import
|
||||
template = random.choice(templates["preview"])
|
||||
preview = result["preview"]
|
||||
cols_str = ", ".join(preview.get("columns", [])[:8])
|
||||
if len(preview.get("columns", [])) > 8:
|
||||
cols_str += f"... (+{len(preview['columns']) - 8})"
|
||||
message = template.format(
|
||||
filename=result.get("filename", "?"),
|
||||
total_rows=preview.get("total_rows", 0),
|
||||
columns=cols_str,
|
||||
table_name=result.get("table_name", "?"),
|
||||
)
|
||||
suggestions = ["oui", "non"]
|
||||
|
||||
elif result.get("imported"):
|
||||
# Import réussi
|
||||
template = random.choice(templates["imported"])
|
||||
imp = result["imported"]
|
||||
cols_str = ", ".join(list(imp.get("columns", {}).keys())[:6])
|
||||
if len(imp.get("columns", {})) > 6:
|
||||
cols_str += "..."
|
||||
message = template.format(
|
||||
table_name=imp.get("table_name", "?"),
|
||||
row_count=imp.get("row_count", 0),
|
||||
col_count=imp.get("column_count", 0),
|
||||
columns=cols_str,
|
||||
)
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_import"]
|
||||
|
||||
elif result.get("tables_list") is not None:
|
||||
tables = result["tables_list"]
|
||||
if tables:
|
||||
lines = []
|
||||
for t in tables:
|
||||
lines.append(f" **{t['name']}** ({t['row_count']} lignes)")
|
||||
template = random.choice(templates["list_tables"])
|
||||
message = template.format(tables_list="\n".join(lines))
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
|
||||
else:
|
||||
message = random.choice(templates["no_tables"])
|
||||
suggestions = ["importer un fichier Excel"]
|
||||
|
||||
elif result.get("table_info"):
|
||||
info = result["table_info"]
|
||||
cols_detail = "\n".join(
|
||||
f" {c['name']} ({c['type']})" for c in info.get("columns", [])
|
||||
if c["name"] not in ("_rowid", "_imported_at")
|
||||
)
|
||||
template = random.choice(templates["table_info"])
|
||||
message = template.format(
|
||||
table_name=info.get("table_name", "?"),
|
||||
row_count=info.get("row_count", 0),
|
||||
col_count=len([c for c in info.get("columns", []) if c["name"] not in ("_rowid", "_imported_at")]),
|
||||
columns_detail=cols_detail,
|
||||
)
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
|
||||
|
||||
elif result.get("folder_files") is not None:
|
||||
files = result["folder_files"]
|
||||
if files:
|
||||
files_list = "\n".join(f" {f}" for f in files)
|
||||
template = random.choice(templates["folder_list"])
|
||||
message = template.format(count=len(files), files_list=files_list)
|
||||
else:
|
||||
template = random.choice(templates["folder_empty"])
|
||||
message = template.format(folder=result.get("folder", "?"))
|
||||
suggestions = ["aide"]
|
||||
|
||||
elif result.get("uploaded"):
|
||||
template = random.choice(templates["uploaded"])
|
||||
message = template.format(filename=result.get("filename", "?"))
|
||||
suggestions = []
|
||||
|
||||
elif result.get("error"):
|
||||
template = random.choice(templates["error"])
|
||||
message = template.format(error=result["error"])
|
||||
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_error"]
|
||||
|
||||
else:
|
||||
message = "Je n'ai pas compris votre demande. Précisez le fichier ou dites « montre les tables »."
|
||||
suggestions = ["montre les tables", "aide"]
|
||||
|
||||
return GeneratedResponse(
|
||||
message=message,
|
||||
suggestions=suggestions,
|
||||
action_required=result.get("needs_confirmation", False),
|
||||
action_type="data_import_confirm" if result.get("needs_confirmation") else None,
|
||||
metadata=result,
|
||||
)
|
||||
|
||||
def _handle_small_talk(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
context: Dict[str, Any],
|
||||
result: Dict[str, Any]
|
||||
) -> GeneratedResponse:
|
||||
"""Handler pour la conversation informelle (merci, café, ça va, etc.)."""
|
||||
templates = self.RESPONSE_TEMPLATES[IntentType.SMALL_TALK]
|
||||
query = intent.raw_query.lower().strip()
|
||||
|
||||
# Déterminer la sous-catégorie de small talk
|
||||
category = self._classify_small_talk(query)
|
||||
category_templates = templates.get(category, templates["humor"])
|
||||
message = random.choice(category_templates)
|
||||
|
||||
return GeneratedResponse(
|
||||
message=message,
|
||||
suggestions=[],
|
||||
action_required=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _classify_small_talk(query: str) -> str:
|
||||
"""Classifier le type de small talk à partir de la requête brute."""
|
||||
# Remerciements
|
||||
if re.search(
|
||||
r"\b(?:merci|thanks?|thx|super|génial|parfait|cool|nickel|impec|impeccable|excellent|formidable)\b",
|
||||
query
|
||||
):
|
||||
return "thanks"
|
||||
|
||||
# Adieux
|
||||
if re.search(
|
||||
r"\b(?:au revoir|à plus|bye|bonne nuit|à bientôt|à demain|ciao|tchao|tchuss|adieu)\b",
|
||||
query
|
||||
):
|
||||
return "farewell"
|
||||
|
||||
# Identité
|
||||
if re.search(
|
||||
r"(?:qui es[- ]tu|t'es qui|comment tu t'appelles|c'est quoi ton (?:nom|prénom)|t'es quoi|vous êtes qui|tu t'appelles comment)",
|
||||
query
|
||||
):
|
||||
return "identity"
|
||||
|
||||
# Sentiments
|
||||
if re.search(
|
||||
r"(?:ça va|comment (?:ça |tu |vous )?va[st]?|comment allez[- ]vous|tu vas bien|la forme|en forme)",
|
||||
query
|
||||
):
|
||||
return "feelings"
|
||||
|
||||
# Mécontentement
|
||||
if re.search(
|
||||
r"\b(?:nul|pas bien|pas top|pas ouf|bof|mauvais|moche|horrible|catastrophe|ça craint|erreur|bug|naze|pourri)\b",
|
||||
query
|
||||
):
|
||||
return "complaint"
|
||||
|
||||
# Compliments
|
||||
if re.search(
|
||||
r"\b(?:bien joué|bravo|top|chapeau|impressionnant|pas mal|bien fait|beau travail|good job|nice|trop bien|magnifique)\b",
|
||||
query
|
||||
):
|
||||
return "compliment"
|
||||
|
||||
# Fatigue / état physique
|
||||
if re.search(
|
||||
r"(?:fatigué|crevé|la flemme|j'ai faim|j'ai soif|pause|il fait (?:chaud|froid|beau)|je suis (?:motivé|content))",
|
||||
query
|
||||
):
|
||||
return "mood"
|
||||
|
||||
# Humour / boissons / café (fallback small_talk)
|
||||
return "humor"
|
||||
|
||||
def _handle_unknown(
|
||||
self,
|
||||
intent: ParsedIntent,
|
||||
@@ -591,7 +905,7 @@ class ResponseGenerator:
|
||||
|
||||
return GeneratedResponse(
|
||||
message=message,
|
||||
suggestions=["aide", "liste des workflows"],
|
||||
suggestions=["aide", "mes tâches"],
|
||||
action_required=False
|
||||
)
|
||||
|
||||
|
||||
@@ -447,6 +447,26 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-message-bot);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -617,11 +637,8 @@
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="mode-toggle">
|
||||
<button class="mode-btn active" onclick="setMode('workflow')" id="modeWorkflow">
|
||||
📋 Workflows
|
||||
</button>
|
||||
<button class="mode-btn" onclick="setMode('agent')" id="modeAgent">
|
||||
🚀 Agent Libre
|
||||
<button class="mode-btn active" id="modeWorkflow">
|
||||
💬 Assistant
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-pill" id="statusPill">
|
||||
@@ -653,6 +670,10 @@
|
||||
<div class="welcome-suggestion-title">📋 Voir les workflows</div>
|
||||
<div class="welcome-suggestion-desc">Lister les workflows disponibles</div>
|
||||
</div>
|
||||
<div class="welcome-suggestion" onclick="sendSuggestion('Montre-moi les tables')">
|
||||
<div class="welcome-suggestion-title">📊 Importer des données</div>
|
||||
<div class="welcome-suggestion-desc">Importer un fichier Excel ou voir les tables existantes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -660,6 +681,10 @@
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<div class="input-container">
|
||||
<button class="attach-btn" onclick="document.getElementById('fileInput').click()" title="Joindre un fichier Excel">
|
||||
<i class="bi bi-paperclip"></i>
|
||||
</button>
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" style="display:none" onchange="handleFileUpload(event)">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
@@ -715,6 +740,23 @@
|
||||
updateAgentProgress(data);
|
||||
});
|
||||
|
||||
// Copilot events
|
||||
socket.on('copilot_step', (data) => {
|
||||
showCopilotStep(data);
|
||||
});
|
||||
|
||||
socket.on('copilot_step_result', (data) => {
|
||||
updateCopilotStepResult(data);
|
||||
});
|
||||
|
||||
socket.on('copilot_complete', (data) => {
|
||||
completeCopilot(data);
|
||||
});
|
||||
|
||||
socket.on('copilot_error', (data) => {
|
||||
addMessage(`Copilot: ${data.message}`);
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// UI Functions
|
||||
// =====================================================
|
||||
@@ -853,40 +895,6 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
function createAgentPlanCard(plan) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'action-card';
|
||||
|
||||
const stepsHtml = plan.steps.map((step, i) => `
|
||||
<div class="progress-step pending" id="step-${i}">
|
||||
<div class="progress-step-icon">${i + 1}</div>
|
||||
<span>${step.description}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="action-card-header">
|
||||
<div class="action-card-title">
|
||||
🚀 Plan d'exécution
|
||||
<span class="confidence-badge">${plan.steps.length} étapes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-steps" style="margin-bottom: 12px;">
|
||||
${stepsHtml}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="executeAgentPlan()">
|
||||
<i class="bi bi-play-fill"></i> Exécuter
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="cancelAction()">
|
||||
<i class="bi bi-x"></i> Annuler
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function createExecutionProgress() {
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'execution-progress';
|
||||
@@ -1033,11 +1041,7 @@
|
||||
addTypingIndicator();
|
||||
|
||||
try {
|
||||
if (currentMode === 'agent') {
|
||||
await sendAgentRequest(message);
|
||||
} else {
|
||||
await sendChatRequest(message);
|
||||
}
|
||||
} catch (error) {
|
||||
removeTypingIndicator();
|
||||
addMessage(`❌ Erreur: ${error.message}`);
|
||||
@@ -1065,7 +1069,11 @@
|
||||
sessionId = data.session_id;
|
||||
|
||||
// Handle different response types
|
||||
if (data.result?.needs_confirmation) {
|
||||
if (data.result?.needs_confirmation && data.result?.preview) {
|
||||
// Import de données — apercu avec demande de confirmation
|
||||
addMessage(data.response.message);
|
||||
addSuggestions(['oui', 'non']);
|
||||
} else if (data.result?.needs_confirmation && data.result?.confirmation) {
|
||||
pendingConfirmation = data.result.confirmation;
|
||||
const card = createActionCard(
|
||||
pendingConfirmation.workflow_name,
|
||||
@@ -1073,44 +1081,58 @@
|
||||
data.intent?.confidence || 0.9
|
||||
);
|
||||
addMessage(data.response.message, 'bot', card);
|
||||
} else if (data.result?.gesture) {
|
||||
// Geste primitif exécuté
|
||||
addMessage(data.response.message);
|
||||
} else if (data.result?.mode === 'copilot') {
|
||||
// Mode copilot — les étapes arrivent via WebSocket
|
||||
addMessage(data.response.message);
|
||||
} else if (data.result?.success) {
|
||||
const progress = createExecutionProgress();
|
||||
addMessage(data.response.message, 'bot', progress);
|
||||
} else if (data.result?.teach_me) {
|
||||
// Workflow non trouvé — proposer l'apprentissage
|
||||
const teachCard = document.createElement('div');
|
||||
teachCard.className = 'action-card';
|
||||
teachCard.innerHTML = `
|
||||
<div class="action-card-header">
|
||||
<div class="action-card-title">
|
||||
Apprentissage disponible
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 8px 0; opacity: 0.8; font-size: 0.9em;">
|
||||
Lancez l'enregistrement sur votre PC et montrez-moi comment faire.
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="window.open('/api/help', '_blank')">
|
||||
<i class="bi bi-mortarboard"></i> Comment m'apprendre ?
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
addMessage(data.response.message, 'bot', teachCard);
|
||||
} else if (data.result?.workflows) {
|
||||
let msg = data.response.message + '\n\n';
|
||||
data.result.workflows.slice(0, 5).forEach(w => {
|
||||
msg += `• **${w.name}**: ${w.description || 'Pas de description'}\n`;
|
||||
});
|
||||
addMessage(msg);
|
||||
} else if (data.result?.imported) {
|
||||
// Import de données réussi
|
||||
addMessage(data.response.message);
|
||||
if (data.response.suggestions?.length > 0) {
|
||||
addSuggestions(data.response.suggestions);
|
||||
}
|
||||
} else if (data.result?.tables_list !== undefined || data.result?.table_info) {
|
||||
// Liste des tables ou info table
|
||||
addMessage(data.response.message);
|
||||
if (data.response.suggestions?.length > 0) {
|
||||
addSuggestions(data.response.suggestions);
|
||||
}
|
||||
} else {
|
||||
addMessage(data.response.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAgentRequest(message) {
|
||||
const response = await fetch('/api/agent/plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request: message })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
removeTypingIndicator();
|
||||
|
||||
if (data.error) {
|
||||
addMessage(`❌ ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.plan) {
|
||||
pendingConfirmation = data.plan;
|
||||
const card = createAgentPlanCard(data.plan);
|
||||
addMessage(`J'ai préparé un plan pour "${message}":`, 'bot', card);
|
||||
} else {
|
||||
addMessage(data.message || "Je n'ai pas pu créer de plan pour cette demande.");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAction() {
|
||||
if (!pendingConfirmation) return;
|
||||
|
||||
@@ -1127,40 +1149,11 @@
|
||||
|
||||
// Show execution progress
|
||||
const progress = createExecutionProgress();
|
||||
addMessage("⏳ Exécution en cours...", 'bot', progress);
|
||||
addMessage("Execution en cours...", 'bot', progress);
|
||||
|
||||
pendingConfirmation = null;
|
||||
}
|
||||
|
||||
async function executeAgentPlan() {
|
||||
if (!pendingConfirmation) return;
|
||||
|
||||
isProcessing = true;
|
||||
updateInputState();
|
||||
|
||||
addMessage("⏳ Exécution du plan en cours...", 'bot');
|
||||
|
||||
const response = await fetch('/api/agent/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan: pendingConfirmation })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const results = data.results || [];
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
addMessage(`✅ Plan exécuté: ${successCount}/${results.length} étapes réussies`);
|
||||
} else {
|
||||
addMessage(`❌ Erreur: ${data.error}`);
|
||||
}
|
||||
|
||||
pendingConfirmation = null;
|
||||
isProcessing = false;
|
||||
updateInputState();
|
||||
}
|
||||
|
||||
function modifyAction() {
|
||||
if (!pendingConfirmation) return;
|
||||
addMessage("✏️ Modification non implémentée. Décrivez les changements souhaités.");
|
||||
@@ -1173,7 +1166,126 @@
|
||||
|
||||
function cancelExecution() {
|
||||
socket.emit('cancel_execution');
|
||||
addMessage("⏹️ Demande d'annulation envoyée...");
|
||||
addMessage("Demande d'annulation envoyée...");
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// File Upload
|
||||
// =====================================================
|
||||
async function handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Afficher le message utilisateur
|
||||
addMessage(`📎 ${file.name}`, 'user');
|
||||
addTypingIndicator();
|
||||
isProcessing = true;
|
||||
updateInputState();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('session_id', sessionId || '');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
removeTypingIndicator();
|
||||
|
||||
if (data.error && !data.success) {
|
||||
addMessage(`Erreur : ${data.error}`);
|
||||
} else if (data.message) {
|
||||
addMessage(data.message);
|
||||
if (data.needs_confirmation) {
|
||||
addSuggestions(['oui', 'non']);
|
||||
}
|
||||
} else {
|
||||
addMessage(`Fichier ${file.name} recu.`);
|
||||
}
|
||||
} catch (error) {
|
||||
removeTypingIndicator();
|
||||
addMessage(`Erreur d'upload : ${error.message}`);
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
updateInputState();
|
||||
// Reset le champ fichier pour permettre de re-uploader le meme fichier
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// Copilot Mode
|
||||
// =====================================================
|
||||
|
||||
function showCopilotStep(data) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'action-card';
|
||||
card.id = `copilot-step-${data.step_index}`;
|
||||
card.innerHTML = `
|
||||
<div class="action-card-header">
|
||||
<div class="action-card-title">
|
||||
Copilot - Étape ${data.step_index + 1}/${data.total}
|
||||
</div>
|
||||
<span style="font-size: 0.8em; opacity: 0.6;">${data.workflow}</span>
|
||||
</div>
|
||||
<p style="margin: 8px 0; font-size: 0.95em;">
|
||||
<strong>${data.action.type}</strong>: ${data.action.description}
|
||||
</p>
|
||||
<div class="action-buttons" id="copilot-btns-${data.step_index}">
|
||||
<button class="btn btn-primary" onclick="copilotApprove(${data.step_index})">
|
||||
<i class="bi bi-check-lg"></i> Exécuter
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="copilotSkip(${data.step_index})">
|
||||
<i class="bi bi-skip-forward"></i> Passer
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="copilotAbort()">
|
||||
<i class="bi bi-x-circle"></i> Annuler tout
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
addMessage(`Copilot étape ${data.step_index + 1}/${data.total}`, 'bot', card);
|
||||
}
|
||||
|
||||
function copilotApprove(stepIndex) {
|
||||
socket.emit('copilot_approve');
|
||||
const btns = document.getElementById(`copilot-btns-${stepIndex}`);
|
||||
if (btns) btns.innerHTML = '<span style="color: var(--success);">Approuvé - en cours...</span>';
|
||||
}
|
||||
|
||||
function copilotSkip(stepIndex) {
|
||||
socket.emit('copilot_skip');
|
||||
const btns = document.getElementById(`copilot-btns-${stepIndex}`);
|
||||
if (btns) btns.innerHTML = '<span style="color: var(--warning);">Passé</span>';
|
||||
}
|
||||
|
||||
function copilotAbort() {
|
||||
socket.emit('copilot_abort');
|
||||
}
|
||||
|
||||
function updateCopilotStepResult(data) {
|
||||
const card = document.getElementById(`copilot-step-${data.step_index}`);
|
||||
if (!card) return;
|
||||
|
||||
const btns = card.querySelector('.action-buttons') ||
|
||||
document.getElementById(`copilot-btns-${data.step_index}`);
|
||||
if (!btns) return;
|
||||
|
||||
if (data.status === 'completed') {
|
||||
btns.innerHTML = '<span style="color: var(--success);">Réussi</span>';
|
||||
} else if (data.status === 'failed') {
|
||||
btns.innerHTML = `<span style="color: var(--error);">Échoué: ${data.message}</span>`;
|
||||
} else if (data.status === 'skipped') {
|
||||
btns.innerHTML = '<span style="color: var(--warning);">Passé</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function completeCopilot(data) {
|
||||
const statusColor = data.status === 'completed' ? 'var(--success)' :
|
||||
data.status === 'aborted' ? 'var(--error)' : 'var(--warning)';
|
||||
addMessage(`<span style="color: ${statusColor};">Copilot terminé: ${data.message}</span>`);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
|
||||
3
agent_rust/lea_uia/.gitignore
vendored
Normal file
3
agent_rust/lea_uia/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
**/target/
|
||||
|
||||
384
agent_rust/lea_uia/Cargo.lock
generated
Normal file
384
agent_rust/lea_uia/Cargo.lock
generated
Normal file
@@ -0,0 +1,384 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lea_uia"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
34
agent_rust/lea_uia/Cargo.toml
Normal file
34
agent_rust/lea_uia/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "lea_uia"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Dom <dom@rpa-vision-v3>"]
|
||||
description = "Helper Windows UI Automation pour Léa (agent RPA V3)"
|
||||
license = "Proprietary"
|
||||
|
||||
[[bin]]
|
||||
name = "lea_uia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Ole",
|
||||
"Win32_System_Variant",
|
||||
"Win32_UI_Accessibility",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Graphics_Gdi",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Taille minimale
|
||||
lto = true # Link-time optimization
|
||||
codegen-units = 1 # Meilleure optimisation
|
||||
strip = true # Retirer les symboles
|
||||
panic = "abort" # Pas d'unwinding → binaire plus petit
|
||||
564
agent_rust/lea_uia/src/main.rs
Normal file
564
agent_rust/lea_uia/src/main.rs
Normal file
@@ -0,0 +1,564 @@
|
||||
// lea_uia — Helper Windows UI Automation pour Léa
|
||||
//
|
||||
// Binaire standalone qui expose 3 commandes UIA :
|
||||
// query → retourne l'élément UIA à une position (x, y)
|
||||
// find → retrouve un élément par son chemin logique
|
||||
// capture → liste les éléments visibles (debug)
|
||||
//
|
||||
// Communication avec l'agent Python via stdin/stdout JSON.
|
||||
// Tous les appels sont non-bloquants et retournent du JSON structuré.
|
||||
//
|
||||
// Sur Linux (développement) : retourne des stubs d'erreur.
|
||||
// Sur Windows : utilise UIAutomationCore via `windows-rs`.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "lea_uia")]
|
||||
#[command(about = "Helper UI Automation pour Léa", long_about = None)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Retourner l'élément UIA à une position donnée (x, y en pixels écran)
|
||||
Query {
|
||||
/// Coordonnée X (pixels)
|
||||
#[arg(long)]
|
||||
x: i32,
|
||||
/// Coordonnée Y (pixels)
|
||||
#[arg(long)]
|
||||
y: i32,
|
||||
/// Inclure la hiérarchie des parents (peut être lent)
|
||||
#[arg(long, default_value_t = true)]
|
||||
with_parents: bool,
|
||||
},
|
||||
/// Rechercher un élément par son chemin logique ou son nom
|
||||
Find {
|
||||
/// Nom de l'élément (Name property)
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Type de contrôle (Button, Edit, MenuItem, etc.)
|
||||
#[arg(long)]
|
||||
control_type: Option<String>,
|
||||
/// AutomationId
|
||||
#[arg(long)]
|
||||
automation_id: Option<String>,
|
||||
/// Limite la recherche à cette fenêtre (titre exact)
|
||||
#[arg(long)]
|
||||
window: Option<String>,
|
||||
/// Timeout en millisecondes
|
||||
#[arg(long, default_value_t = 2000)]
|
||||
timeout_ms: u32,
|
||||
},
|
||||
/// Lister tous les éléments visibles de la fenêtre active (debug)
|
||||
Capture {
|
||||
/// Profondeur maximale de l'arbre
|
||||
#[arg(long, default_value_t = 3)]
|
||||
max_depth: u32,
|
||||
},
|
||||
/// Vérifier que UIA est disponible et fonctionnel
|
||||
Health,
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Modèles de sortie JSON
|
||||
// =========================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct UiaElement {
|
||||
/// Nom visible de l'élément
|
||||
name: String,
|
||||
/// Type de contrôle (Button, Edit, MenuItem, Window, ...)
|
||||
control_type: String,
|
||||
/// Classe Windows (Edit, Static, #32770, ...)
|
||||
class_name: String,
|
||||
/// AutomationId (ID interne, parfois vide)
|
||||
automation_id: String,
|
||||
/// Rectangle absolu [x1, y1, x2, y2] en pixels écran
|
||||
bounding_rect: [i32; 4],
|
||||
/// Est-ce que l'élément est activable
|
||||
is_enabled: bool,
|
||||
/// Est-ce que l'élément est visible
|
||||
is_offscreen: bool,
|
||||
/// Hiérarchie des parents (chemin logique)
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
parent_path: Vec<ParentHint>,
|
||||
/// Process owning this element
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
process_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct ParentHint {
|
||||
name: String,
|
||||
control_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "status")]
|
||||
enum UiaResponse {
|
||||
#[serde(rename = "ok")]
|
||||
Ok {
|
||||
element: Option<UiaElement>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
elements: Vec<UiaElement>,
|
||||
elapsed_ms: u64,
|
||||
},
|
||||
#[serde(rename = "not_found")]
|
||||
NotFound {
|
||||
reason: String,
|
||||
elapsed_ms: u64,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error {
|
||||
message: String,
|
||||
code: String,
|
||||
},
|
||||
#[serde(rename = "unavailable")]
|
||||
Unavailable {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Implémentation Windows
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(windows)]
|
||||
mod uia_impl {
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
|
||||
COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::UI::Accessibility::{
|
||||
CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTreeWalker,
|
||||
};
|
||||
|
||||
struct ComGuard;
|
||||
impl ComGuard {
|
||||
fn new() -> windows::core::Result<Self> {
|
||||
unsafe {
|
||||
let hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
||||
if hr.is_err() {
|
||||
// RPC_E_CHANGED_MODE : le thread est déjà initialisé → OK
|
||||
let code = hr.0 as u32;
|
||||
if code != 0x80010106 {
|
||||
return Err(windows::core::Error::from(hr));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
impl Drop for ComGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { CoUninitialize() };
|
||||
}
|
||||
}
|
||||
|
||||
fn get_automation() -> windows::core::Result<IUIAutomation> {
|
||||
unsafe { CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) }
|
||||
}
|
||||
|
||||
fn element_to_struct(
|
||||
element: &IUIAutomationElement,
|
||||
with_parents: bool,
|
||||
) -> windows::core::Result<UiaElement> {
|
||||
let mut result = UiaElement {
|
||||
name: String::new(),
|
||||
control_type: String::new(),
|
||||
class_name: String::new(),
|
||||
automation_id: String::new(),
|
||||
bounding_rect: [0, 0, 0, 0],
|
||||
is_enabled: false,
|
||||
is_offscreen: true,
|
||||
parent_path: Vec::new(),
|
||||
process_name: String::new(),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if let Ok(name) = element.CurrentName() {
|
||||
result.name = name.to_string();
|
||||
}
|
||||
if let Ok(ct) = element.CurrentLocalizedControlType() {
|
||||
result.control_type = ct.to_string();
|
||||
}
|
||||
if let Ok(cn) = element.CurrentClassName() {
|
||||
result.class_name = cn.to_string();
|
||||
}
|
||||
if let Ok(aid) = element.CurrentAutomationId() {
|
||||
result.automation_id = aid.to_string();
|
||||
}
|
||||
if let Ok(rect) = element.CurrentBoundingRectangle() {
|
||||
result.bounding_rect = [rect.left, rect.top, rect.right, rect.bottom];
|
||||
}
|
||||
if let Ok(enabled) = element.CurrentIsEnabled() {
|
||||
result.is_enabled = enabled.as_bool();
|
||||
}
|
||||
if let Ok(offscreen) = element.CurrentIsOffscreen() {
|
||||
result.is_offscreen = offscreen.as_bool();
|
||||
}
|
||||
if with_parents {
|
||||
// Remonter la hiérarchie jusqu'à la Window root
|
||||
if let Ok(automation) = get_automation() {
|
||||
let walker = automation.ControlViewWalker();
|
||||
if let Ok(walker) = walker {
|
||||
let mut current = element.clone();
|
||||
for _ in 0..10 {
|
||||
match walker.GetParentElement(¤t) {
|
||||
Ok(parent) => {
|
||||
let name = parent
|
||||
.CurrentName()
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let ct = parent
|
||||
.CurrentLocalizedControlType()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_default();
|
||||
if name.is_empty() && ct.is_empty() {
|
||||
break;
|
||||
}
|
||||
result.parent_path.insert(
|
||||
0,
|
||||
ParentHint {
|
||||
name,
|
||||
control_type: ct,
|
||||
},
|
||||
);
|
||||
current = parent;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn query_at_point(x: i32, y: i32, with_parents: bool) -> UiaResponse {
|
||||
let start = Instant::now();
|
||||
let _com = match ComGuard::new() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CoInitializeEx: {}", e),
|
||||
code: "com_init_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let automation = match get_automation() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CUIAutomation: {}", e),
|
||||
code: "automation_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let point = POINT { x, y };
|
||||
let element = unsafe { automation.ElementFromPoint(point) };
|
||||
match element {
|
||||
Ok(el) => match element_to_struct(&el, with_parents) {
|
||||
Ok(e) => UiaResponse::Ok {
|
||||
element: Some(e),
|
||||
elements: Vec::new(),
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
Err(e) => UiaResponse::Error {
|
||||
message: format!("element_to_struct: {}", e),
|
||||
code: "extract_failed".into(),
|
||||
},
|
||||
},
|
||||
Err(_) => UiaResponse::NotFound {
|
||||
reason: format!("Aucun élément UIA à ({}, {})", x, y),
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_element(
|
||||
name: Option<String>,
|
||||
_control_type: Option<String>,
|
||||
_automation_id: Option<String>,
|
||||
_window: Option<String>,
|
||||
_timeout_ms: u32,
|
||||
) -> UiaResponse {
|
||||
let start = Instant::now();
|
||||
let _com = match ComGuard::new() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CoInitializeEx: {}", e),
|
||||
code: "com_init_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let automation = match get_automation() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CUIAutomation: {}", e),
|
||||
code: "automation_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let root = match unsafe { automation.GetRootElement() } {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("GetRootElement: {}", e),
|
||||
code: "root_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Recherche simple par parcours d'arbre (MVP)
|
||||
// L'arbre UIA peut être énorme → on limite la profondeur
|
||||
if let Some(target_name) = name {
|
||||
let walker = unsafe { automation.ControlViewWalker() };
|
||||
if let Ok(walker) = walker {
|
||||
if let Some(found) =
|
||||
walk_and_find(&walker, &root, &target_name, 0, 6, &_control_type, &_automation_id)
|
||||
{
|
||||
match element_to_struct(&found, true) {
|
||||
Ok(e) => {
|
||||
return UiaResponse::Ok {
|
||||
element: Some(e),
|
||||
elements: Vec::new(),
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("element_to_struct: {}", e),
|
||||
code: "extract_failed".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UiaResponse::NotFound {
|
||||
reason: "Aucun élément trouvé".into(),
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parcours récursif de l'arbre UIA pour trouver un élément par nom
|
||||
fn walk_and_find(
|
||||
walker: &IUIAutomationTreeWalker,
|
||||
element: &IUIAutomationElement,
|
||||
target_name: &str,
|
||||
depth: u32,
|
||||
max_depth: u32,
|
||||
target_control_type: &Option<String>,
|
||||
target_automation_id: &Option<String>,
|
||||
) -> Option<IUIAutomationElement> {
|
||||
if depth > max_depth {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Tester l'élément courant
|
||||
unsafe {
|
||||
if let Ok(name) = element.CurrentName() {
|
||||
if name.to_string() == target_name {
|
||||
// Vérifier les filtres additionnels
|
||||
let mut matches = true;
|
||||
if let Some(ct) = target_control_type {
|
||||
if let Ok(local_ct) = element.CurrentLocalizedControlType() {
|
||||
if !local_ct.to_string().to_lowercase().contains(&ct.to_lowercase()) {
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
if let Some(aid) = target_automation_id {
|
||||
if let Ok(local_aid) = element.CurrentAutomationId() {
|
||||
if local_aid.to_string() != *aid {
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return Some(element.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcourir les enfants
|
||||
if let Ok(first_child) = walker.GetFirstChildElement(element) {
|
||||
let mut current = first_child;
|
||||
loop {
|
||||
if let Some(found) = walk_and_find(
|
||||
walker,
|
||||
¤t,
|
||||
target_name,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
target_control_type,
|
||||
target_automation_id,
|
||||
) {
|
||||
return Some(found);
|
||||
}
|
||||
match walker.GetNextSiblingElement(¤t) {
|
||||
Ok(next) => current = next,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn capture_tree(_max_depth: u32) -> UiaResponse {
|
||||
let start = Instant::now();
|
||||
let _com = match ComGuard::new() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CoInitializeEx: {}", e),
|
||||
code: "com_init_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let automation = match get_automation() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return UiaResponse::Error {
|
||||
message: format!("CUIAutomation: {}", e),
|
||||
code: "automation_failed".into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let focused = unsafe { automation.GetFocusedElement() };
|
||||
match focused {
|
||||
Ok(el) => match element_to_struct(&el, true) {
|
||||
Ok(e) => UiaResponse::Ok {
|
||||
element: Some(e),
|
||||
elements: Vec::new(),
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
},
|
||||
Err(e) => UiaResponse::Error {
|
||||
message: format!("element_to_struct: {}", e),
|
||||
code: "extract_failed".into(),
|
||||
},
|
||||
},
|
||||
Err(e) => UiaResponse::Error {
|
||||
message: format!("GetFocusedElement: {}", e),
|
||||
code: "focused_failed".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check() -> UiaResponse {
|
||||
let _com = match ComGuard::new() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return UiaResponse::Unavailable {
|
||||
reason: format!("COM init failed: {}", e),
|
||||
}
|
||||
}
|
||||
};
|
||||
match get_automation() {
|
||||
Ok(_) => UiaResponse::Ok {
|
||||
element: None,
|
||||
elements: Vec::new(),
|
||||
elapsed_ms: 0,
|
||||
},
|
||||
Err(e) => UiaResponse::Unavailable {
|
||||
reason: format!("UIA not available: {}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Stub Linux (pour développement et tests)
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod uia_impl {
|
||||
use super::*;
|
||||
|
||||
pub fn query_at_point(_x: i32, _y: i32, _with_parents: bool) -> UiaResponse {
|
||||
UiaResponse::Unavailable {
|
||||
reason: "UIA n'est disponible que sur Windows".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_element(
|
||||
_name: Option<String>,
|
||||
_control_type: Option<String>,
|
||||
_automation_id: Option<String>,
|
||||
_window: Option<String>,
|
||||
_timeout_ms: u32,
|
||||
) -> UiaResponse {
|
||||
UiaResponse::Unavailable {
|
||||
reason: "UIA n'est disponible que sur Windows".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capture_tree(_max_depth: u32) -> UiaResponse {
|
||||
UiaResponse::Unavailable {
|
||||
reason: "UIA n'est disponible que sur Windows".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check() -> UiaResponse {
|
||||
UiaResponse::Unavailable {
|
||||
reason: "UIA n'est disponible que sur Windows".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Main
|
||||
// =========================================================================
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let response = match cli.command {
|
||||
Commands::Query {
|
||||
x,
|
||||
y,
|
||||
with_parents,
|
||||
} => uia_impl::query_at_point(x, y, with_parents),
|
||||
Commands::Find {
|
||||
name,
|
||||
control_type,
|
||||
automation_id,
|
||||
window,
|
||||
timeout_ms,
|
||||
} => uia_impl::find_element(name, control_type, automation_id, window, timeout_ms),
|
||||
Commands::Capture { max_depth } => uia_impl::capture_tree(max_depth),
|
||||
Commands::Health => uia_impl::health_check(),
|
||||
};
|
||||
|
||||
// Sortie JSON sur stdout
|
||||
match serde_json::to_string(&response) {
|
||||
Ok(json) => println!("{}", json),
|
||||
Err(e) => {
|
||||
eprintln!("{{\"status\":\"error\",\"message\":\"JSON serialization: {}\"}}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
agent_v0/.gitignore
vendored
Normal file
1
agent_v0/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea/
|
||||
1
agent_v0/__init__.py
Normal file
1
agent_v0/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# agent_v0 — Agent RPA Vision V3
|
||||
15
agent_v0/agent_config.json
Normal file
15
agent_v0/agent_config.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"user_id": "demo_user",
|
||||
"user_label": "Démo agent_v0",
|
||||
"customer": "Clinique Demo",
|
||||
"training_label": "Facturation_T2A_demo",
|
||||
"notes": "Session réelle avec clics + screenshots + key combos.",
|
||||
"mode": "enriched",
|
||||
"screenshot_mode": "crop",
|
||||
"screenshot_crop_width": 900,
|
||||
"screenshot_crop_height": 700,
|
||||
"capture_hover": true,
|
||||
"hover_min_idle_ms": 700,
|
||||
"capture_scroll": true,
|
||||
"network_save_path": ""
|
||||
}
|
||||
76
agent_v0/agent_v1/EVOLUTION_V1_README.md
Normal file
76
agent_v0/agent_v1/EVOLUTION_V1_README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Évolution Agent V1 - Système d'Apprentissage "Stagiaire Fibre"
|
||||
**Projet :** RPA Vision V3
|
||||
**Date :** 5 Mars 2026
|
||||
**Status :** 🚀 Prêt pour Test POC Clinique
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Philosophie : Le "Stagiaire" Apprenant
|
||||
|
||||
Le système n'est pas un automate rigide, mais un **stagiaire cognitif** qui apprend par imitation.
|
||||
1. **L'Expert (Humain) :** Travaille sur son PC (Windows/Mac/Linux) avec l'Agent V1.
|
||||
2. **Le Stagiaire (IA qwen3-vl) :** Observe l'expert via la fibre, analyse les images sur une RTX 5070 et construit un **Graphe d'Intention**.
|
||||
3. **L'Apprentissage :** Le stagiaire "réfléchit" en temps réel (Crops 400x400) et se corrige grâce aux interactions humaines.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Architecture Technique Agent V1
|
||||
|
||||
L'Agent V1 passe d'un mode "Enregistreur" (Batch) à un mode **"Capteur Intelligent" (Streaming)**.
|
||||
|
||||
### 1. Vision Duale & Ciblée (Optimisation qwen3-vl)
|
||||
- **Crops Contextuels :** Capture systématique d'une zone de **400x400 pixels** autour de chaque clic.
|
||||
- **Contexte Global :** Screenshots plein écran pour l'identification de l'environnement.
|
||||
- **Patience Post-Action :** Capture automatique 1s après chaque clic pour voir le résultat (animations, chargements).
|
||||
- **Heartbeat :** Capture contextuelle toutes les 5s pour voir le logiciel "vivre" entre les clics.
|
||||
|
||||
### 2. Conscience du Contexte UI
|
||||
- **Focus Change :** Détection proactive des changements de fenêtre/application.
|
||||
- **Métadonnées Sémantiques :** Capture systématique du titre de la fenêtre et du nom de l'exécutable.
|
||||
- **Anonymisation Sélective :** Capacité de floutage local (GaussianBlur) sur les zones de texte sensibles détectées.
|
||||
|
||||
### 3. Streaming Haute Performance (Fibre-Ready)
|
||||
- **Async Streaming :** Envoi asynchrone des événements JSON et des images via une file d'attente non-bloquante.
|
||||
- **Architecture Micro-Paquets :** Plus de gros fichiers ZIP. Le serveur reçoit les données au fil de l'eau sur le port 5002.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Architecture Serveur (Le Cerveau)
|
||||
|
||||
Le serveur (Machine Labo RTX 5070) a été adapté pour le flux temps réel :
|
||||
|
||||
### 1. API Stream (`server_v1/api_stream.py`)
|
||||
- **Endpoints Dédiés :** `/event` pour le JSON, `/image` pour les crops/full, `/finalize` pour clore la session.
|
||||
- **Live Sessions :** Stockage temporaire en format `.jsonl` (robuste aux crashs) avant consolidation finale.
|
||||
|
||||
### 2. Stream Worker (`server_v1/worker_stream.py`)
|
||||
- **Analyse au fil de l'eau :** Le worker surveille le dossier `live_sessions` et lance l'inférence `qwen3-vl` dès qu'un crop arrive.
|
||||
- **Construction de Graphe :** Le stagiaire commence à relier les points (actions) pour former un graphe de décision pendant que l'expert travaille encore.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Portabilité & Exécution Déportée
|
||||
|
||||
L'Agent V1 est conçu pour être porté sur **Windows** et **macOS** :
|
||||
- **Bibliothèques Cross-Plateforme :** `mss` (Vision), `pynput` (Events), `PyQt5` (UI).
|
||||
- **Exécution Déportée :** L'architecture prépare le terrain pour que le rejeu puisse se faire sur un PC Windows distant, piloté par les ordres envoyés par la machine Labo via Fibre/WebSockets.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist de Déploiement (Machine Labo)
|
||||
|
||||
1. **Installer les dépendances :** `pip install PyQt5 pystray Pillow mss requests psutil`
|
||||
2. **Lancer le Serveur de Streaming :** `python agent_v0/server_v1/api_stream.py` (Port 5002)
|
||||
3. **Lancer le Stream Worker :** `python agent_v0/server_v1/worker_stream.py`
|
||||
4. **Lancer l'Agent V1 :** `python run_agent_v1.py` sur le PC de test.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Interface Utilisateur "Sympa"
|
||||
L'Agent V1 n'est plus un outil technique froid :
|
||||
- **Tray Icon dynamique :** Gris (Repos), Rouge (Apprentissage), Bleu (Sync Fibre).
|
||||
- **Dialogues Humains :** Accueil personnalisé, compteur d'actions en temps réel et félicitations en fin de session.
|
||||
|
||||
---
|
||||
|
||||
*Document généré par l'Assistant pour RPA Vision V3 - Mars 2026*
|
||||
0
monitor_matching_health.py → agent_v0/agent_v1/__init__.py
Executable file → Normal file
0
monitor_matching_health.py → agent_v0/agent_v1/__init__.py
Executable file → Normal file
101
agent_v0/agent_v1/config.py
Normal file
101
agent_v0/agent_v1/config.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# agent_v1/config.py
|
||||
"""
|
||||
Configuration avancée pour Agent V1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
|
||||
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
|
||||
# travaillent en coordonnees physiques (pixels reels) au lieu de coordonnees logiques
|
||||
# (virtualisees par le DPI scaling).
|
||||
# Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 pour les API,
|
||||
# ce qui cause des erreurs de positionnement pendant le replay.
|
||||
# Sur Linux/Mac : no-op silencieux.
|
||||
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback pour Windows < 8.1 (API plus ancienne)
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
AGENT_VERSION = "1.0.0"
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS
|
||||
MACHINE_ID = os.environ.get(
|
||||
"RPA_MACHINE_ID",
|
||||
f"{socket.gethostname()}_{platform.system().lower()}",
|
||||
)
|
||||
|
||||
# Dossier racine de l'agent
|
||||
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", "")
|
||||
|
||||
# Paramètres de session
|
||||
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||
|
||||
# Paramètres Vision (Crops pour la résolution visuelle)
|
||||
# 80x80 : assez petit pour être discriminant (icônes), assez grand pour le contexte
|
||||
TARGETED_CROP_SIZE = (80, 80)
|
||||
SCREENSHOT_QUALITY = 85
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
# Floute les champs de saisie dans les screenshots AVANT stockage/envoi
|
||||
# Désactiver avec RPA_BLUR_SENSITIVE=false pour le développement/tests
|
||||
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
# Retention des logs — minimum 6 mois (180 jours) requis par le Reglement IA
|
||||
# (Article 12 — journalisation automatique, Article 26(6) — conservation minimum)
|
||||
# Configurable via variable d'environnement pour permettre l'ajustement
|
||||
LOG_RETENTION_DAYS = int(os.environ.get("RPA_LOG_RETENTION_DAYS", "180"))
|
||||
|
||||
# Monitoring
|
||||
PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
LOG_FILE = LOGS_DIR / "agent_v1.log"
|
||||
|
||||
# --- Métadonnées système (capturées au chargement du module) ---
|
||||
# Utilisées pour la bannière de démarrage et le diagnostic.
|
||||
# Import tardif pour éviter les dépendances circulaires.
|
||||
try:
|
||||
from .vision.system_info import get_dpi_scale, get_os_theme, get_monitor_info
|
||||
_monitor_index, _monitors = get_monitor_info()
|
||||
_primary = _monitors[0] if _monitors else {"width": 1920, "height": 1080}
|
||||
SCREEN_RESOLUTION = (_primary["width"], _primary["height"])
|
||||
DPI_SCALE = get_dpi_scale()
|
||||
OS_THEME = get_os_theme()
|
||||
except Exception:
|
||||
# Fallback silencieux si les métadonnées ne sont pas disponibles
|
||||
SCREEN_RESOLUTION = (1920, 1080)
|
||||
DPI_SCALE = 100
|
||||
OS_THEME = "unknown"
|
||||
|
||||
# Création des dossiers
|
||||
os.makedirs(SESSIONS_ROOT, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
612
agent_v0/agent_v1/core/captor.py
Normal file
612
agent_v0/agent_v1/core/captor.py
Normal file
@@ -0,0 +1,612 @@
|
||||
# agent_v1/core/captor.py
|
||||
"""
|
||||
Moteur de capture d'événements Agent V1.
|
||||
Capture enrichie avec focus sur le contexte UI pour le stagiaire.
|
||||
|
||||
Fonctionnalités :
|
||||
- Capture clics souris (simple et double-clic)
|
||||
- Capture scroll souris
|
||||
- Capture combos clavier (Ctrl+C, Alt+Tab, etc.)
|
||||
- Buffer de saisie texte : accumule les frappes et émet un événement
|
||||
text_input après 500ms d'inactivité clavier
|
||||
- Surveillance du focus fenêtre
|
||||
|
||||
NOTE DPI : Les coordonnees retournees par pynput dependent du DPI awareness
|
||||
du process. Quand SetProcessDpiAwareness(2) est appele (dans config.py),
|
||||
pynput retourne des coordonnees en pixels PHYSIQUES. Les metadonnees
|
||||
screen_metadata (resolution via mss) sont aussi en pixels physiques.
|
||||
Ceci garantit que la normalisation pos/resolution est coherente.
|
||||
Sans DPI awareness, pynput retourne des coordonnees LOGIQUES mais mss
|
||||
retourne des pixels physiques, ce qui cause une erreur de normalisation.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import platform
|
||||
from typing import Callable, Optional, List, Dict, Any, Tuple
|
||||
from pynput import mouse, keyboard
|
||||
from pynput.mouse import Button
|
||||
from pynput.keyboard import Key, KeyCode
|
||||
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
from ..vision.system_info import get_screen_metadata
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Détection Windows une seule fois au chargement du module
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# Délai d'inactivité avant flush du buffer texte (en secondes)
|
||||
TEXT_FLUSH_DELAY = 0.5
|
||||
# Délai max entre deux clics pour un double-clic (en secondes)
|
||||
DOUBLE_CLICK_DELAY = 0.3
|
||||
# Tolérance en pixels pour considérer deux clics au même endroit
|
||||
DOUBLE_CLICK_TOLERANCE = 10
|
||||
|
||||
|
||||
class EventCaptorV1:
|
||||
def __init__(self, on_event_callback: Callable[[Dict[str, Any]], None]):
|
||||
self.on_event = on_event_callback
|
||||
self.mouse_listener = None
|
||||
self.keyboard_listener = None
|
||||
self.running = False
|
||||
|
||||
# État des touches modificatrices
|
||||
self.modifiers = set()
|
||||
|
||||
# Tracking du focus fenêtre
|
||||
self.last_window = None
|
||||
self._focus_thread = None
|
||||
|
||||
# --- Buffer de saisie texte ---
|
||||
# Lock pour accès thread-safe au buffer (le listener pynput
|
||||
# tourne dans un thread séparé)
|
||||
self._text_lock = threading.Lock()
|
||||
self._text_buffer: list[str] = []
|
||||
# Position de la souris au moment de la première frappe du buffer
|
||||
self._text_start_pos: Optional[Tuple[int, int]] = None
|
||||
# Timer pour le flush après inactivité
|
||||
self._text_flush_timer: Optional[threading.Timer] = None
|
||||
# Compteur de génération pour éviter qu'un timer obsolète ne flush
|
||||
# un buffer en cours de remplissage (race condition). Incrémenté
|
||||
# à chaque reset du timer. Le timer ne flush que si la génération
|
||||
# n'a pas changé.
|
||||
self._text_flush_generation: int = 0
|
||||
# Dernière position connue de la souris (pour associer le texte
|
||||
# au champ dans lequel l'utilisateur tape)
|
||||
self._last_mouse_pos: Tuple[int, int] = (0, 0)
|
||||
|
||||
# --- Détection double-clic ---
|
||||
# Dernier clic : (x, y, timestamp, button)
|
||||
self._last_click: Optional[Tuple[int, int, float, str]] = None
|
||||
|
||||
# --- Buffer de raw_keys (press/release bruts avec vk codes) ---
|
||||
# Accumule chaque press/release pour le replay exact (solution AZERTY).
|
||||
# Vidé en même temps que le text_buffer ou à l'émission d'un key_combo.
|
||||
self._raw_key_buffer: List[Dict[str, Any]] = []
|
||||
|
||||
# --- Métadonnées système (DPI, résolution, moniteur, thème, langue) ---
|
||||
# Capturées au démarrage puis rafraîchies à chaque changement de focus.
|
||||
# Injectées dans chaque événement via le champ "screen_metadata".
|
||||
self._screen_metadata: Dict[str, Any] = {}
|
||||
self._screen_metadata_lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
self.mouse_listener = mouse.Listener(
|
||||
on_click=self._on_click,
|
||||
on_scroll=self._on_scroll,
|
||||
on_move=self._on_move
|
||||
)
|
||||
self.keyboard_listener = keyboard.Listener(
|
||||
on_press=self._on_press,
|
||||
on_release=self._on_release
|
||||
)
|
||||
|
||||
self.mouse_listener.start()
|
||||
self.keyboard_listener.start()
|
||||
|
||||
# Capture initiale des métadonnées système
|
||||
self._refresh_screen_metadata()
|
||||
|
||||
# Thread de surveillance du focus fenêtre (Proactif)
|
||||
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
|
||||
self._focus_thread.start()
|
||||
|
||||
logger.info("Agent V1 Captor démarré")
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
# Flush du buffer texte restant avant arrêt
|
||||
self._flush_text_buffer()
|
||||
# Annuler le timer s'il est en cours
|
||||
with self._text_lock:
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = None
|
||||
if self.mouse_listener: self.mouse_listener.stop()
|
||||
if self.keyboard_listener: self.keyboard_listener.stop()
|
||||
logger.info("Agent V1 Captor arrêté")
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Souris
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def _on_move(self, x, y):
|
||||
"""Mémorise la position souris pour l'associer aux événements texte."""
|
||||
self._last_mouse_pos = (x, y)
|
||||
|
||||
def _on_click(self, x, y, button, pressed):
|
||||
if not pressed:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
|
||||
# --- Flush du buffer texte : l'utilisateur a cliqué, donc
|
||||
# il change probablement de champ ---
|
||||
self._flush_text_buffer()
|
||||
|
||||
# --- Détection double-clic ---
|
||||
if self._last_click is not None:
|
||||
lx, ly, lt, lb = self._last_click
|
||||
# Même bouton, même zone, délai court → double-clic
|
||||
if (button.name == lb
|
||||
and abs(x - lx) <= DOUBLE_CLICK_TOLERANCE
|
||||
and abs(y - ly) <= DOUBLE_CLICK_TOLERANCE
|
||||
and (now - lt) <= DOUBLE_CLICK_DELAY):
|
||||
event = {
|
||||
"type": "double_click",
|
||||
"button": button.name,
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
# Réinitialiser pour éviter un triple-clic = 2 double-clics
|
||||
self._last_click = None
|
||||
return
|
||||
|
||||
# Clic simple — on le mémorise pour comparer au prochain
|
||||
self._last_click = (x, y, now, button.name)
|
||||
event = {
|
||||
"type": "mouse_click",
|
||||
"button": button.name,
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
# Capturer le snapshot UIA à la position du clic (si helper dispo)
|
||||
# Non-bloquant : si UIA échoue, l'event est enrichi uniquement
|
||||
# des données vision comme aujourd'hui.
|
||||
self._inject_uia_snapshot(event, x, y)
|
||||
self.on_event(event)
|
||||
|
||||
def _inject_uia_snapshot(self, event: dict, x: int, y: int) -> None:
|
||||
"""Ajouter un uia_snapshot à l'événement si le helper UIA est dispo.
|
||||
|
||||
Appelle lea_uia.exe query --x N --y N en ~10-20ms.
|
||||
Fallback silencieux si le helper n'est pas dispo ou échoue.
|
||||
"""
|
||||
try:
|
||||
from .uia_helper import get_shared_helper
|
||||
helper = get_shared_helper()
|
||||
if not helper.available:
|
||||
return
|
||||
element = helper.query_at(int(x), int(y), with_parents=True)
|
||||
if element is None:
|
||||
return
|
||||
event["uia_snapshot"] = {
|
||||
"name": element.name,
|
||||
"control_type": element.control_type,
|
||||
"class_name": element.class_name,
|
||||
"automation_id": element.automation_id,
|
||||
"bounding_rect": list(element.bounding_rect),
|
||||
"is_enabled": element.is_enabled,
|
||||
"is_offscreen": element.is_offscreen,
|
||||
"parent_path": element.parent_path,
|
||||
}
|
||||
except Exception as e:
|
||||
# Non bloquant — on continue sans UIA
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(f"UIA snapshot skip: {e}")
|
||||
|
||||
def _on_scroll(self, x, y, dx, dy):
|
||||
event = {
|
||||
"type": "mouse_scroll",
|
||||
"pos": (x, y),
|
||||
"delta": (dx, dy),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.on_event(event)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Clavier
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _get_key_name(key) -> Optional[str]:
|
||||
"""Convertit un objet pynput Key/KeyCode en nom lisible."""
|
||||
if isinstance(key, KeyCode):
|
||||
return key.char if key.char else None
|
||||
if isinstance(key, Key):
|
||||
return key.name
|
||||
return str(key)
|
||||
|
||||
# Ensemble des touches considérées comme modificateurs purs.
|
||||
# Utilisé pour ne PAS émettre de key_combo quand seuls des
|
||||
# modificateurs sont enfoncés (évite le bruit).
|
||||
_MODIFIER_KEYS = {
|
||||
Key.ctrl, Key.ctrl_l, Key.ctrl_r,
|
||||
Key.alt, Key.alt_l, Key.alt_r,
|
||||
Key.shift, Key.shift_l, Key.shift_r,
|
||||
Key.cmd, Key.cmd_l, Key.cmd_r,
|
||||
}
|
||||
_MODIFIER_KEY_NAMES = {
|
||||
"ctrl", "ctrl_l", "ctrl_r",
|
||||
"alt", "alt_l", "alt_r",
|
||||
"shift", "shift_l", "shift_r",
|
||||
"cmd", "cmd_l", "cmd_r",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _vk_to_char(vk_code: int) -> Optional[str]:
|
||||
"""Convertir un virtual key code en caractère réel (AZERTY-aware).
|
||||
|
||||
Utilise ToUnicodeEx avec le layout clavier actif pour obtenir
|
||||
le bon caractère même pour les touches AltGr, Shift+chiffres,
|
||||
et autres combinaisons spécifiques au layout (AZERTY, QWERTZ, etc.).
|
||||
|
||||
Ne fonctionne que sur Windows. Retourne None sur Linux/Mac.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
kbd_state = (ctypes.c_ubyte * 256)()
|
||||
user32.GetKeyboardState(kbd_state)
|
||||
|
||||
buf = (ctypes.c_wchar * 8)()
|
||||
scan = user32.MapVirtualKeyW(vk_code, 0)
|
||||
|
||||
# Layout du thread de la fenêtre active (gère AZERTY, QWERTZ, etc.)
|
||||
hwnd = user32.GetForegroundWindow()
|
||||
tid = user32.GetWindowThreadProcessId(hwnd, None)
|
||||
hkl = user32.GetKeyboardLayout(tid)
|
||||
|
||||
n = user32.ToUnicodeEx(vk_code, scan, kbd_state, buf, 8, 0, hkl)
|
||||
if n > 0:
|
||||
return buf[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _is_altgr_producing_char(self, key) -> Optional[str]:
|
||||
"""Détecte si la combinaison actuelle est AltGr+touche produisant un caractère.
|
||||
|
||||
Sur Windows AZERTY, AltGr est envoyé comme Ctrl+Alt par pynput.
|
||||
Cette méthode vérifie si Ctrl+Alt est enfoncé et que la touche
|
||||
produit un caractère imprimable via le layout clavier.
|
||||
Ex: AltGr+é → ~, AltGr+( → {, AltGr+à → @
|
||||
|
||||
Retourne le caractère produit ou None si ce n'est pas un AltGr valide.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return None
|
||||
# AltGr = Ctrl+Alt (sans Win) sur Windows
|
||||
if self.modifiers != {"ctrl", "alt"} and self.modifiers != {"ctrl", "alt", "shift"}:
|
||||
return None
|
||||
# Ne s'applique qu'aux touches non-modificatrices
|
||||
if key in self._MODIFIER_KEYS:
|
||||
return None
|
||||
# Essayer de résoudre le caractère via ToUnicodeEx
|
||||
# Le keyboard state inclut déjà Ctrl+Alt (= AltGr) grâce à GetKeyboardState
|
||||
vk = getattr(key, 'vk', None)
|
||||
if vk is not None:
|
||||
char = self._vk_to_char(vk)
|
||||
if char is not None and len(char) == 1 and (char.isprintable() and char != ' '):
|
||||
return char
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(key) -> Dict[str, Any]:
|
||||
"""Encode un objet pynput Key/KeyCode en dictionnaire sérialisable.
|
||||
|
||||
Utilisé pour constituer le buffer raw_keys (séquence press/release
|
||||
exacte avec virtual key codes) qui permet un replay fidèle
|
||||
indépendant du layout clavier (AZERTY, QWERTZ, etc.).
|
||||
"""
|
||||
if isinstance(key, KeyCode):
|
||||
return {"kind": "vk", "vk": key.vk, "char": key.char}
|
||||
if isinstance(key, Key):
|
||||
return {"kind": "key", "name": key.name}
|
||||
return {"kind": "unknown", "str": str(key)}
|
||||
|
||||
def _on_press(self, key):
|
||||
# TOUJOURS enregistrer le press brut dans le buffer raw_keys
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.append({
|
||||
"action": "press",
|
||||
**self._encode_key(key),
|
||||
})
|
||||
|
||||
# Gestion des touches modificatrices
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.add("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.add("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.add("shift")
|
||||
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
|
||||
self.modifiers.add("win")
|
||||
|
||||
# --- Combos avec modificateur (sauf Shift seul) ---
|
||||
# Shift seul n'est pas un « vrai » modificateur pour les combos :
|
||||
# Shift+a = 'A' = saisie texte, pas un raccourci.
|
||||
# On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
|
||||
if has_real_modifier:
|
||||
# --- Détection AltGr (Windows AZERTY) ---
|
||||
# Sur Windows, AltGr est envoyé comme Ctrl+Alt par le système.
|
||||
# Avant de traiter comme un key_combo, vérifier si c'est
|
||||
# AltGr qui produit un caractère imprimable (@, #, {, }, etc.)
|
||||
altgr_char = self._is_altgr_producing_char(key)
|
||||
if altgr_char is not None:
|
||||
# C'est un caractère AltGr → router vers le buffer texte
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(altgr_char)
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
key_name = self._get_key_name(key)
|
||||
# Ne PAS émettre de combo si c'est un modificateur seul
|
||||
# (ex: appui sur Ctrl sans autre touche = pas de combo)
|
||||
if key_name and key_name not in self._MODIFIER_KEY_NAMES:
|
||||
# Un combo interrompt la saisie texte en cours
|
||||
self._flush_text_buffer()
|
||||
# Attacher les raw_keys accumulés (press des modificateurs + press de la touche)
|
||||
with self._text_lock:
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
# NB: on ne clear pas encore — le release va suivre et sera
|
||||
# capturé pour le prochain buffer. On prend un snapshot.
|
||||
event = {
|
||||
"type": "key_combo",
|
||||
"keys": list(self.modifiers) + [key_name],
|
||||
"raw_keys": raw_keys,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
# Reset le buffer raw_keys après émission du combo
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.clear()
|
||||
return
|
||||
|
||||
# --- Saisie texte (pas de Ctrl/Alt/Win enfoncé) ---
|
||||
self._handle_text_key(key)
|
||||
|
||||
def _handle_text_key(self, key):
|
||||
"""Gère l'accumulation des frappes texte dans le buffer.
|
||||
|
||||
Touches spéciales :
|
||||
- Backspace : supprime le dernier caractère du buffer
|
||||
- Enter / Tab : flush immédiat + émission de l'événement
|
||||
- Escape : vide le buffer sans émettre
|
||||
"""
|
||||
with self._text_lock:
|
||||
# --- Touches spéciales ---
|
||||
if key == Key.backspace:
|
||||
if self._text_buffer:
|
||||
self._text_buffer.pop()
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
if key == Key.esc:
|
||||
# Annuler la saisie en cours
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
|
||||
if key in (Key.enter, Key.tab):
|
||||
# Flush immédiat — on relâche le lock avant d'appeler
|
||||
# _flush_text_buffer (qui prend aussi le lock)
|
||||
pass # on sort du with et on flush après
|
||||
|
||||
elif key == Key.space:
|
||||
# Espace = caractère normal
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(" ")
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
elif isinstance(key, KeyCode):
|
||||
# Caractère alphanumérique / ponctuation
|
||||
char = key.char
|
||||
|
||||
# AZERTY Windows : quand key.char est None (Shift+chiffres,
|
||||
# dead keys, etc.), utiliser ToUnicodeEx avec le layout clavier
|
||||
# actif pour obtenir le vrai caractère traduit par Windows.
|
||||
if char is None and IS_WINDOWS:
|
||||
vk = getattr(key, 'vk', None)
|
||||
if vk is not None:
|
||||
char = self._vk_to_char(vk)
|
||||
|
||||
if char is not None and len(char) == 1:
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(char)
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
# key.char None et pas de vk exploitable → ignorer
|
||||
return
|
||||
else:
|
||||
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
|
||||
return
|
||||
|
||||
# Si on arrive ici, c'est Enter ou Tab → flush le buffer en cours
|
||||
# puis émettre le caractère spécial comme text_input séparé
|
||||
self._flush_text_buffer()
|
||||
|
||||
# Émettre Enter comme "\n" et Tab comme "\t" pour ne pas perdre
|
||||
# les retours à la ligne dans la saisie.
|
||||
# Attacher les raw_keys restants (press de Enter/Tab, le release suivra)
|
||||
with self._text_lock:
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
self._raw_key_buffer.clear()
|
||||
special_char = "\n" if key == Key.enter else "\t"
|
||||
event = {
|
||||
"type": "text_input",
|
||||
"text": special_char,
|
||||
"pos": list(self._last_mouse_pos) if self._last_mouse_pos else [0, 0],
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if raw_keys:
|
||||
event["raw_keys"] = raw_keys
|
||||
self.on_event(event)
|
||||
|
||||
def _reset_flush_timer(self):
|
||||
"""Réarme le timer de flush après chaque frappe.
|
||||
|
||||
Doit être appelé avec self._text_lock déjà acquis.
|
||||
Utilise un compteur de génération pour garantir que seul le
|
||||
dernier timer programmé puisse effectivement flush le buffer.
|
||||
"""
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_generation += 1
|
||||
gen = self._text_flush_generation
|
||||
self._text_flush_timer = threading.Timer(
|
||||
TEXT_FLUSH_DELAY, self._flush_text_buffer_if_current, args=(gen,)
|
||||
)
|
||||
self._text_flush_timer.daemon = True
|
||||
self._text_flush_timer.start()
|
||||
|
||||
def _cancel_flush_timer(self):
|
||||
"""Annule le timer de flush sans émettre.
|
||||
|
||||
Doit être appelé avec self._text_lock déjà acquis.
|
||||
"""
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = None
|
||||
|
||||
def _flush_text_buffer_if_current(self, generation: int):
|
||||
"""Appelé par le timer. Ne flush que si la génération correspond
|
||||
à celle du timer en cours (= pas de frappe entre-temps)."""
|
||||
with self._text_lock:
|
||||
if generation != self._text_flush_generation:
|
||||
# Un timer plus récent a été programmé, celui-ci est obsolète
|
||||
return
|
||||
self._flush_text_buffer()
|
||||
|
||||
def _flush_text_buffer(self):
|
||||
"""Émet un événement text_input avec le contenu du buffer, puis
|
||||
le vide. Thread-safe — peut être appelé depuis le timer, le
|
||||
listener souris ou le listener clavier."""
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
# Rien à émettre — purger aussi les raw_keys orphelins
|
||||
self._raw_key_buffer.clear()
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
text = "".join(self._text_buffer)
|
||||
pos = self._text_start_pos or self._last_mouse_pos
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
|
||||
# Émission hors du lock pour éviter un deadlock si le callback
|
||||
# est lent ou prend d'autres locks
|
||||
event = {
|
||||
"type": "text_input",
|
||||
"text": text,
|
||||
"pos": pos,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
# Attacher les raw_keys pour le replay exact (solution AZERTY)
|
||||
if raw_keys:
|
||||
event["raw_keys"] = raw_keys
|
||||
self._inject_screen_metadata(event)
|
||||
logger.debug(f"text_input émis : {len(text)} caractères, {len(raw_keys)} raw_keys")
|
||||
self.on_event(event)
|
||||
|
||||
def _on_release(self, key):
|
||||
# TOUJOURS enregistrer le release brut dans le buffer raw_keys
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.append({
|
||||
"action": "release",
|
||||
**self._encode_key(key),
|
||||
})
|
||||
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.discard("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.discard("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.discard("shift")
|
||||
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
|
||||
self.modifiers.discard("win")
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Métadonnées système
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def _refresh_screen_metadata(self):
|
||||
"""Rafraîchit le cache des métadonnées système.
|
||||
|
||||
Appelé au démarrage et à chaque changement de focus fenêtre.
|
||||
Thread-safe — peut être appelé depuis le thread focus.
|
||||
"""
|
||||
try:
|
||||
metadata = get_screen_metadata()
|
||||
with self._screen_metadata_lock:
|
||||
self._screen_metadata = metadata
|
||||
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur refresh métadonnées système : {e}")
|
||||
|
||||
def _inject_screen_metadata(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Injecte les métadonnées système cachées dans un événement."""
|
||||
with self._screen_metadata_lock:
|
||||
if self._screen_metadata:
|
||||
event["screen_metadata"] = self._screen_metadata.copy()
|
||||
return event
|
||||
|
||||
def _watch_window_focus(self):
|
||||
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""
|
||||
# Importation relative simple
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
info = get_active_window_info()
|
||||
if info and info != self.last_window:
|
||||
# Rafraîchir les métadonnées (la fenêtre a peut-être
|
||||
# changé de moniteur, de taille, etc.)
|
||||
self._refresh_screen_metadata()
|
||||
|
||||
event = {
|
||||
"type": "window_focus_change",
|
||||
"from": self.last_window,
|
||||
"to": info,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.last_window = info
|
||||
self.on_event(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur focus window: {e}")
|
||||
time.sleep(0.5)
|
||||
2826
agent_v0/agent_v1/core/executor.py
Normal file
2826
agent_v0/agent_v1/core/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
291
agent_v0/agent_v1/core/grounding.py
Normal file
291
agent_v0/agent_v1/core/grounding.py
Normal file
@@ -0,0 +1,291 @@
|
||||
# agent_v1/core/grounding.py
|
||||
"""
|
||||
Module Grounding — localisation pure d'éléments UI sur l'écran.
|
||||
|
||||
Responsabilité unique : "Trouve l'élément X sur l'écran et retourne ses coordonnées."
|
||||
Ne prend AUCUNE décision. Si l'élément n'est pas trouvé → retourne NOT_FOUND.
|
||||
|
||||
Stratégies disponibles (cascade configurable) :
|
||||
1. Serveur SomEngine + VLM (GPU distant)
|
||||
2. Template matching local (CPU, ~10ms)
|
||||
3. VLM local direct (CPU/GPU local)
|
||||
|
||||
Séparé de Policy (qui décide quoi faire quand grounding échoue).
|
||||
Ref: docs/PLAN_ACTEUR_V1.md — Architecture MICRO (grounding + exécution)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundingResult:
|
||||
"""Résultat d'une tentative de localisation visuelle."""
|
||||
found: bool # L'élément a été trouvé
|
||||
x_pct: float = 0.0 # Position X en % (0.0-1.0)
|
||||
y_pct: float = 0.0 # Position Y en % (0.0-1.0)
|
||||
method: str = "" # Méthode utilisée (server_som, anchor_template, vlm_direct...)
|
||||
score: float = 0.0 # Confiance (0.0-1.0)
|
||||
elapsed_ms: float = 0.0 # Temps de résolution
|
||||
detail: str = "" # Info supplémentaire (label trouvé, raison échec)
|
||||
raw: Optional[Dict] = None # Données brutes du resolver (pour debug)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"found": self.found,
|
||||
"x_pct": self.x_pct,
|
||||
"y_pct": self.y_pct,
|
||||
"method": self.method,
|
||||
"score": round(self.score, 3),
|
||||
"elapsed_ms": round(self.elapsed_ms, 1),
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
# Résultat singleton pour "pas trouvé"
|
||||
NOT_FOUND = GroundingResult(found=False, detail="Aucune méthode n'a trouvé l'élément")
|
||||
|
||||
|
||||
class GroundingEngine:
|
||||
"""Moteur de localisation visuelle d'éléments UI.
|
||||
|
||||
Encapsule la cascade de résolution (serveur → template → VLM local)
|
||||
avec une interface unifiée. Ne prend aucune décision — c'est le rôle
|
||||
de PolicyEngine.
|
||||
|
||||
Usage :
|
||||
engine = GroundingEngine(executor)
|
||||
result = engine.locate(screenshot_b64, target_spec, screen_w, screen_h)
|
||||
if result.found:
|
||||
click(result.x_pct, result.y_pct)
|
||||
"""
|
||||
|
||||
def __init__(self, executor):
|
||||
"""
|
||||
Args:
|
||||
executor: ActionExecutorV1 — fournit les méthodes de résolution existantes.
|
||||
"""
|
||||
self._executor = executor
|
||||
|
||||
def locate(
|
||||
self,
|
||||
server_url: str,
|
||||
target_spec: Dict[str, Any],
|
||||
fallback_x: float,
|
||||
fallback_y: float,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
strategies: Optional[List[str]] = None,
|
||||
) -> GroundingResult:
|
||||
"""Localiser un élément UI sur l'écran.
|
||||
|
||||
Exécute la cascade de stratégies dans l'ordre et retourne
|
||||
dès qu'une stratégie trouve l'élément.
|
||||
|
||||
Args:
|
||||
server_url: URL du serveur (SomEngine + VLM GPU)
|
||||
target_spec: Spécification de la cible (by_text, anchor, vlm_description...)
|
||||
fallback_x, fallback_y: Coordonnées de fallback (enregistrement)
|
||||
screen_width, screen_height: Résolution écran
|
||||
strategies: Liste ordonnée de stratégies à essayer.
|
||||
Par défaut : ["server", "template", "vlm_local"]
|
||||
|
||||
Returns:
|
||||
GroundingResult avec found=True et coordonnées, ou NOT_FOUND
|
||||
"""
|
||||
if strategies is None:
|
||||
strategies = ["server", "template", "vlm_local"]
|
||||
|
||||
# ── Apprentissage : réordonner les stratégies selon l'historique ──
|
||||
# Si le Learning sait quelle méthode marche pour cette cible,
|
||||
# la mettre en premier. C'est la boucle d'apprentissage.
|
||||
learned = target_spec.get("_learned_strategy", "")
|
||||
if learned:
|
||||
strategy_map = {
|
||||
"som_text_match": "server",
|
||||
"grounding_vlm": "server",
|
||||
"server_som": "server",
|
||||
"anchor_template": "template",
|
||||
"template_matching": "template",
|
||||
"hybrid_text_direct": "vlm_local",
|
||||
"hybrid_vlm_text": "vlm_local",
|
||||
"vlm_direct": "vlm_local",
|
||||
}
|
||||
preferred = strategy_map.get(learned, "")
|
||||
if preferred and preferred in strategies:
|
||||
strategies = [preferred] + [s for s in strategies if s != preferred]
|
||||
logger.info(
|
||||
f"Grounding: stratégie réordonnée par l'apprentissage → "
|
||||
f"{strategies} (learned={learned})"
|
||||
)
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
# ── Capture contrainte à la fenêtre active ──
|
||||
# Le grounding ne voit QUE la fenêtre attendue — pas la taskbar,
|
||||
# pas le systray, pas les autres apps. Comme un humain qui regarde
|
||||
# l'application sur laquelle il travaille.
|
||||
window_rect = None
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_rect
|
||||
win_info = get_active_window_rect()
|
||||
if win_info and win_info.get("rect"):
|
||||
r = win_info["rect"] # [left, top, right, bottom]
|
||||
# Validation : fenêtre visible et pas minuscule
|
||||
w = r[2] - r[0]
|
||||
h = r[3] - r[1]
|
||||
if w > 50 and h > 50:
|
||||
window_rect = {
|
||||
"left": max(0, r[0]),
|
||||
"top": max(0, r[1]),
|
||||
"width": min(w, screen_width),
|
||||
"height": min(h, screen_height),
|
||||
}
|
||||
logger.info(
|
||||
f"Grounding contraint à la fenêtre : "
|
||||
f"{window_rect['width']}x{window_rect['height']} "
|
||||
f"à ({window_rect['left']}, {window_rect['top']})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Pas de window rect disponible : {e}")
|
||||
|
||||
screenshot_b64 = self._capture_window_or_screen(window_rect)
|
||||
if not screenshot_b64:
|
||||
return GroundingResult(
|
||||
found=False, detail="Capture screenshot échouée",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# Dimensions de la zone capturée (fenêtre ou écran entier)
|
||||
cap_w = window_rect["width"] if window_rect else screen_width
|
||||
cap_h = window_rect["height"] if window_rect else screen_height
|
||||
|
||||
for strategy in strategies:
|
||||
result = self._try_strategy(
|
||||
strategy, server_url, screenshot_b64, target_spec,
|
||||
fallback_x, fallback_y, cap_w, cap_h,
|
||||
)
|
||||
if result.found:
|
||||
# ── Conversion coords fenêtre → coords écran ──
|
||||
if window_rect:
|
||||
# Le grounding a retourné des coords relatives à la fenêtre
|
||||
# On les convertit en coords relatives à l'écran entier
|
||||
abs_x = window_rect["left"] + result.x_pct * cap_w
|
||||
abs_y = window_rect["top"] + result.y_pct * cap_h
|
||||
result.x_pct = abs_x / screen_width
|
||||
result.y_pct = abs_y / screen_height
|
||||
result.detail = f"{result.detail} [fenêtre {cap_w}x{cap_h}]"
|
||||
|
||||
result.elapsed_ms = (time.time() - t_start) * 1000
|
||||
return result
|
||||
|
||||
return GroundingResult(
|
||||
found=False,
|
||||
detail=f"Toutes les stratégies ont échoué ({', '.join(strategies)})",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
def _capture_window_or_screen(self, window_rect: Optional[Dict]) -> str:
|
||||
"""Capturer soit la fenêtre active (croppée), soit l'écran entier.
|
||||
|
||||
Si window_rect est fourni, capture uniquement cette zone.
|
||||
Sinon, capture l'écran entier (fallback).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
import mss as mss_lib
|
||||
|
||||
with mss_lib.mss() as local_sct:
|
||||
if window_rect:
|
||||
# Capture de la zone fenêtre uniquement
|
||||
region = {
|
||||
"left": window_rect["left"],
|
||||
"top": window_rect["top"],
|
||||
"width": window_rect["width"],
|
||||
"height": window_rect["height"],
|
||||
}
|
||||
raw = local_sct.grab(region)
|
||||
else:
|
||||
# Fallback écran entier
|
||||
raw = local_sct.grab(local_sct.monitors[1])
|
||||
|
||||
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=75)
|
||||
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Capture échouée : {e}")
|
||||
# Fallback sur la méthode existante de l'executor
|
||||
return self._executor._capture_screenshot_b64(max_width=0, quality=75)
|
||||
|
||||
def _try_strategy(
|
||||
self,
|
||||
strategy: str,
|
||||
server_url: str,
|
||||
screenshot_b64: str,
|
||||
target_spec: Dict[str, Any],
|
||||
fallback_x: float,
|
||||
fallback_y: float,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
) -> GroundingResult:
|
||||
"""Essayer une stratégie de grounding unique."""
|
||||
|
||||
if strategy == "server" and server_url:
|
||||
raw = self._executor._server_resolve_target(
|
||||
server_url, screenshot_b64, target_spec,
|
||||
fallback_x, fallback_y, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method=raw.get("method", "server"),
|
||||
score=raw.get("score", 0.0),
|
||||
detail=raw.get("matched_element", {}).get("label", ""),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
elif strategy == "template":
|
||||
anchor_b64 = target_spec.get("anchor_image_base64", "")
|
||||
if anchor_b64:
|
||||
raw = self._executor._template_match_anchor(
|
||||
screenshot_b64, anchor_b64, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method="anchor_template",
|
||||
score=raw.get("score", 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
elif strategy == "vlm_local":
|
||||
by_text = target_spec.get("by_text", "")
|
||||
vlm_desc = target_spec.get("vlm_description", "")
|
||||
if vlm_desc or by_text:
|
||||
raw = self._executor._hybrid_vlm_resolve(
|
||||
screenshot_b64, target_spec, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method=raw.get("method", "vlm_local"),
|
||||
score=raw.get("score", 0.0),
|
||||
detail=raw.get("matched_element", {}).get("label", ""),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
return GroundingResult(found=False, method=strategy, detail=f"{strategy}: pas trouvé")
|
||||
172
agent_v0/agent_v1/core/policy.py
Normal file
172
agent_v0/agent_v1/core/policy.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# agent_v1/core/policy.py
|
||||
"""
|
||||
Module Policy — décisions intelligentes quand le grounding échoue.
|
||||
|
||||
Responsabilité unique : "Le Grounding dit NOT_FOUND. Que fait-on ?"
|
||||
Ne localise AUCUN élément — c'est le rôle du Grounding.
|
||||
|
||||
Décisions possibles :
|
||||
- RETRY : re-tenter le grounding (après popup fermée, par exemple)
|
||||
- SKIP : l'action n'est plus nécessaire (état déjà atteint)
|
||||
- ABORT : arrêter le workflow (état incohérent)
|
||||
- SUPERVISE : rendre la main à l'utilisateur
|
||||
|
||||
Séparé de Grounding (qui localise les éléments).
|
||||
Ref: docs/PLAN_ACTEUR_V1.md — Architecture MÉSO (acteur intelligent)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Decision(Enum):
|
||||
"""Décisions possibles quand le grounding échoue."""
|
||||
RETRY = "retry" # Re-tenter (après correction : popup fermée, navigation...)
|
||||
SKIP = "skip" # Action inutile (état déjà atteint)
|
||||
ABORT = "abort" # Arrêter le workflow (état incohérent)
|
||||
SUPERVISE = "supervise" # Rendre la main à l'utilisateur (Léa dit "je bloque")
|
||||
CONTINUE = "continue" # Continuer malgré l'échec (action non critique)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyDecision:
|
||||
"""Résultat d'une décision Policy."""
|
||||
decision: Decision
|
||||
reason: str # Explication de la décision
|
||||
action_taken: str = "" # Action corrective effectuée (ex: "popup fermée")
|
||||
elapsed_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"decision": self.decision.value,
|
||||
"reason": self.reason,
|
||||
"action_taken": self.action_taken,
|
||||
"elapsed_ms": round(self.elapsed_ms, 1),
|
||||
}
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Moteur de décision quand le grounding échoue.
|
||||
|
||||
Cascade de décision :
|
||||
1. Popup détectée ? → fermer et RETRY
|
||||
2. Acteur gemma4 → SKIP / ABORT / SUPERVISE
|
||||
3. Fallback → SUPERVISE (rendre la main)
|
||||
|
||||
Usage :
|
||||
policy = PolicyEngine(executor)
|
||||
decision = policy.decide(action, target_spec, grounding_result)
|
||||
if decision.decision == Decision.RETRY:
|
||||
# re-tenter le grounding
|
||||
elif decision.decision == Decision.SKIP:
|
||||
# marquer comme réussi, passer à la suite
|
||||
"""
|
||||
|
||||
def __init__(self, executor):
|
||||
self._executor = executor
|
||||
|
||||
def decide(
|
||||
self,
|
||||
action: Dict[str, Any],
|
||||
target_spec: Dict[str, Any],
|
||||
retry_count: int = 0,
|
||||
max_retries: int = 1,
|
||||
) -> PolicyDecision:
|
||||
"""Décider quoi faire quand le grounding a échoué.
|
||||
|
||||
Cascade :
|
||||
1. Si c'est le premier essai → tenter de fermer une popup → RETRY
|
||||
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
|
||||
retry_count: Nombre de retries déjà faits
|
||||
max_retries: Maximum de retries autorisés
|
||||
"""
|
||||
t_start = time.time()
|
||||
|
||||
# ── É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,
|
||||
reason="Popup détectée et fermée, re-tentative",
|
||||
action_taken="popup_closed",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# ── Étape 2 : Max retries atteint → acteur gemma4 ──
|
||||
if retry_count >= max_retries:
|
||||
actor_decision = self._ask_actor(action, target_spec)
|
||||
|
||||
if actor_decision == "PASSER":
|
||||
return PolicyDecision(
|
||||
decision=Decision.SKIP,
|
||||
reason="Acteur gemma4 : l'état est déjà atteint",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
elif actor_decision == "STOPPER":
|
||||
return PolicyDecision(
|
||||
decision=Decision.ABORT,
|
||||
reason="Acteur gemma4 : état incohérent, arrêt",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
else:
|
||||
# EXECUTER ou inconnu → pause supervisée
|
||||
return PolicyDecision(
|
||||
decision=Decision.SUPERVISE,
|
||||
reason=f"Acteur gemma4 : {actor_decision}, pause supervisée",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# ── Étape 3 : Encore des retries disponibles → RETRY ──
|
||||
return PolicyDecision(
|
||||
decision=Decision.RETRY,
|
||||
reason=f"Retry {retry_count + 1}/{max_retries}",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
def _try_close_popup(self) -> bool:
|
||||
"""Tenter de fermer une popup via le handler VLM existant."""
|
||||
try:
|
||||
return self._executor._handle_popup_vlm()
|
||||
except Exception as e:
|
||||
logger.debug(f"Policy: popup handler échoué : {e}")
|
||||
return False
|
||||
|
||||
def _ask_actor(self, action: Dict, target_spec: Dict) -> str:
|
||||
"""Demander à gemma4 de décider (PASSER/EXECUTER/STOPPER)."""
|
||||
try:
|
||||
return self._executor._actor_decide(action, target_spec)
|
||||
except Exception as e:
|
||||
logger.debug(f"Policy: acteur gemma4 échoué : {e}")
|
||||
return "EXECUTER" # Fallback → supervisé
|
||||
215
agent_v0/agent_v1/core/recovery.py
Normal file
215
agent_v0/agent_v1/core/recovery.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# agent_v1/core/recovery.py
|
||||
"""
|
||||
Module Recovery — mécanisme de rollback quand une action échoue.
|
||||
|
||||
Responsabilité : "L'action a échoué ou produit un résultat inattendu.
|
||||
Comment revenir en arrière ?"
|
||||
|
||||
Stratégies de recovery :
|
||||
1. Ctrl+Z (undo natif) — pour les frappes et modifications
|
||||
2. Escape (fermer dialogue) — pour les popups/menus
|
||||
3. Alt+F4 (fermer fenêtre) — si mauvaise application ouverte
|
||||
4. Clic hors zone — fermer un menu déroulant
|
||||
5. Navigation retour — retourner à l'écran précédent
|
||||
|
||||
Le Recovery est appelé par le Policy quand le Critic détecte un
|
||||
résultat inattendu (pixel OK + sémantique NON = changement inattendu).
|
||||
|
||||
Ref: docs/VISION_RPA_INTELLIGENT.md — "Il se trompe" → correction
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecoveryAction(Enum):
|
||||
"""Actions de recovery possibles."""
|
||||
UNDO = "undo" # Ctrl+Z
|
||||
ESCAPE = "escape" # Echap (fermer dialogue/menu)
|
||||
CLOSE_WINDOW = "close" # Alt+F4
|
||||
CLICK_AWAY = "click_away" # Clic hors zone (fermer menu)
|
||||
NONE = "none" # Pas de recovery possible
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecoveryResult:
|
||||
"""Résultat d'une tentative de recovery."""
|
||||
action_taken: RecoveryAction
|
||||
success: bool
|
||||
detail: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"action_taken": self.action_taken.value,
|
||||
"success": self.success,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
class RecoveryEngine:
|
||||
"""Moteur de recovery — tente de revenir en arrière après un échec.
|
||||
|
||||
Choisit la stratégie de recovery en fonction du type d'action qui a échoué
|
||||
et de l'état actuel de l'écran.
|
||||
|
||||
Usage :
|
||||
recovery = RecoveryEngine(executor)
|
||||
result = recovery.attempt(failed_action, critic_result)
|
||||
if result.success:
|
||||
# re-tenter l'action
|
||||
"""
|
||||
|
||||
def __init__(self, executor):
|
||||
self._executor = executor
|
||||
|
||||
def attempt(
|
||||
self,
|
||||
failed_action: Dict[str, Any],
|
||||
critic_detail: str = "",
|
||||
) -> RecoveryResult:
|
||||
"""Tenter une recovery après un échec.
|
||||
|
||||
Sélectionne la stratégie appropriée selon le type d'action :
|
||||
- click qui ouvre la mauvaise chose → Escape ou Ctrl+Z
|
||||
- type qui tape au mauvais endroit → Ctrl+Z
|
||||
- key_combo inattendu → Ctrl+Z
|
||||
- popup apparue → Escape
|
||||
|
||||
Args:
|
||||
failed_action: L'action qui a échoué
|
||||
critic_detail: Détail du Critic (raison de l'échec sémantique)
|
||||
"""
|
||||
action_type = failed_action.get("type", "")
|
||||
detail_lower = critic_detail.lower()
|
||||
|
||||
# Choisir la stratégie de recovery
|
||||
strategy = self._select_strategy(action_type, detail_lower)
|
||||
|
||||
if strategy == RecoveryAction.NONE:
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.NONE,
|
||||
success=False,
|
||||
detail="Pas de stratégie de recovery applicable",
|
||||
)
|
||||
|
||||
return self._execute_recovery(strategy)
|
||||
|
||||
def _select_strategy(self, action_type: str, critic_detail: str) -> RecoveryAction:
|
||||
"""Sélectionner la meilleure stratégie de recovery.
|
||||
|
||||
Priorité : type d'action d'abord (frappe → undo), puis contexte.
|
||||
"""
|
||||
# Frappe ou modification incorrecte → toujours Ctrl+Z
|
||||
if action_type in ("type", "key_combo"):
|
||||
return RecoveryAction.UNDO
|
||||
|
||||
# Popup/dialogue détecté
|
||||
if any(w in critic_detail for w in ["popup", "dialog", "erreur", "error", "modal"]):
|
||||
return RecoveryAction.ESCAPE
|
||||
|
||||
# Menu ouvert par erreur
|
||||
if any(w in critic_detail for w in ["menu", "dropdown", "déroulant"]):
|
||||
return RecoveryAction.ESCAPE
|
||||
|
||||
# Mauvaise fenêtre ouverte
|
||||
if any(w in critic_detail for w in ["mauvaise fenêtre", "wrong window"]):
|
||||
return RecoveryAction.CLOSE_WINDOW
|
||||
|
||||
# Clic qui a produit un résultat inattendu
|
||||
if action_type == "click":
|
||||
return RecoveryAction.ESCAPE
|
||||
|
||||
return RecoveryAction.NONE
|
||||
|
||||
def _execute_recovery(self, strategy: RecoveryAction) -> RecoveryResult:
|
||||
"""Exécuter la stratégie de recovery choisie."""
|
||||
from pynput.keyboard import Controller as KeyboardController, Key
|
||||
|
||||
keyboard = self._executor.keyboard
|
||||
|
||||
try:
|
||||
if strategy == RecoveryAction.UNDO:
|
||||
# Ctrl+Z
|
||||
logger.info("Recovery : Ctrl+Z (undo)")
|
||||
print(" [RECOVERY] Ctrl+Z — annulation de la dernière action")
|
||||
keyboard.press(Key.ctrl)
|
||||
keyboard.press('z')
|
||||
keyboard.release('z')
|
||||
keyboard.release(Key.ctrl)
|
||||
time.sleep(0.5)
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.UNDO,
|
||||
success=True,
|
||||
detail="Ctrl+Z exécuté",
|
||||
)
|
||||
|
||||
elif strategy == RecoveryAction.ESCAPE:
|
||||
# Echap
|
||||
logger.info("Recovery : Escape (fermer dialogue)")
|
||||
print(" [RECOVERY] Escape — fermeture dialogue/menu")
|
||||
keyboard.press(Key.esc)
|
||||
keyboard.release(Key.esc)
|
||||
time.sleep(0.5)
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.ESCAPE,
|
||||
success=True,
|
||||
detail="Escape exécuté",
|
||||
)
|
||||
|
||||
elif strategy == RecoveryAction.CLOSE_WINDOW:
|
||||
# Alt+F4 — AVEC vérification fenêtre active
|
||||
# Sur un poste hospitalier, Alt+F4 sans vérif peut fermer le DPI patient
|
||||
try:
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
active = get_active_window_info()
|
||||
active_title = active.get("title", "")
|
||||
logger.info(f"Recovery : Alt+F4 sur '{active_title}'")
|
||||
print(f" [RECOVERY] Alt+F4 — fermeture de '{active_title}'")
|
||||
except Exception:
|
||||
logger.info("Recovery : Alt+F4 (fenêtre active inconnue)")
|
||||
print(" [RECOVERY] Alt+F4 — fermeture fenêtre indésirable")
|
||||
|
||||
keyboard.press(Key.alt)
|
||||
keyboard.press(Key.f4)
|
||||
keyboard.release(Key.f4)
|
||||
keyboard.release(Key.alt)
|
||||
time.sleep(1.0)
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.CLOSE_WINDOW,
|
||||
success=True,
|
||||
detail=f"Alt+F4 exécuté sur '{active_title if 'active_title' in dir() else '?'}'",
|
||||
)
|
||||
|
||||
elif strategy == RecoveryAction.CLICK_AWAY:
|
||||
# Clic au centre de l'écran (hors popup)
|
||||
logger.info("Recovery : clic hors zone")
|
||||
print(" [RECOVERY] Clic hors zone — fermeture menu")
|
||||
monitor = self._executor.sct.monitors[1]
|
||||
w, h = monitor["width"], monitor["height"]
|
||||
# Cliquer dans un coin neutre (10% depuis le haut-gauche)
|
||||
self._executor._click((int(w * 0.1), int(h * 0.1)), "left")
|
||||
time.sleep(0.5)
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.CLICK_AWAY,
|
||||
success=True,
|
||||
detail="Clic hors zone exécuté",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Recovery échoué ({strategy.value}) : {e}")
|
||||
return RecoveryResult(
|
||||
action_taken=strategy,
|
||||
success=False,
|
||||
detail=f"Erreur : {e}",
|
||||
)
|
||||
|
||||
return RecoveryResult(
|
||||
action_taken=RecoveryAction.NONE,
|
||||
success=False,
|
||||
detail="Stratégie non implémentée",
|
||||
)
|
||||
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",
|
||||
]
|
||||
294
agent_v0/agent_v1/core/uia_helper.py
Normal file
294
agent_v0/agent_v1/core/uia_helper.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# core/workflow/uia_helper.py
|
||||
"""
|
||||
UIAHelper — Wrapper Python pour lea_uia.exe (helper Rust UI Automation).
|
||||
|
||||
Expose une API Python simple pour interroger UIA via le binaire Rust.
|
||||
Communique via subprocess + stdin/stdout JSON.
|
||||
|
||||
Pourquoi un helper Rust ?
|
||||
- 5-10x plus rapide que pywinauto (10-20ms vs 50-200ms)
|
||||
- Binaire standalone ~500 Ko, aucune dépendance runtime
|
||||
- Pas de problèmes de threading COM en Python
|
||||
- Crash-safe (le crash du helper n'affecte pas l'agent Python)
|
||||
|
||||
Architecture :
|
||||
Python executor
|
||||
↓ subprocess.run
|
||||
lea_uia.exe query --x 812 --y 436
|
||||
↓ UIA API Windows
|
||||
JSON response
|
||||
↓ stdout
|
||||
Python executor parse JSON
|
||||
|
||||
Si lea_uia.exe n'est pas disponible (Linux, binaire absent, crash) :
|
||||
toutes les méthodes retournent None → fallback vision automatique.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout par défaut pour les appels UIA (en secondes)
|
||||
_DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
# Masquer la fenêtre console lors du spawn de lea_uia.exe sur Windows.
|
||||
# Sans ce flag, chaque appel (à chaque clic utilisateur pendant
|
||||
# l'enregistrement) fait apparaître une fenêtre cmd noire brièvement
|
||||
# visible à l'écran → ralentit la souris et pollue les screenshots
|
||||
# capturés (le VLM peut "voir" le chemin lea_uia.exe comme texte cliqué).
|
||||
#
|
||||
# La valeur 0x08000000 correspond à CREATE_NO_WINDOW défini dans
|
||||
# l'API Windows. Sur Linux/Mac, la valeur est 0 et `creationflags`
|
||||
# est ignoré. getattr() gère le cas où Python expose déjà la constante
|
||||
# sur Windows.
|
||||
if platform.system() == "Windows":
|
||||
_SUBPROCESS_CREATION_FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||
else:
|
||||
_SUBPROCESS_CREATION_FLAGS = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UiaElement:
|
||||
"""Représentation Python d'un élément UIA."""
|
||||
name: str = ""
|
||||
control_type: str = ""
|
||||
class_name: str = ""
|
||||
automation_id: str = ""
|
||||
bounding_rect: Tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
is_enabled: bool = False
|
||||
is_offscreen: bool = True
|
||||
parent_path: List[Dict[str, str]] = field(default_factory=list)
|
||||
process_name: str = ""
|
||||
|
||||
def center(self) -> Tuple[int, int]:
|
||||
"""Retourner le centre du rectangle (pixels)."""
|
||||
x1, y1, x2, y2 = self.bounding_rect
|
||||
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
def width(self) -> int:
|
||||
return self.bounding_rect[2] - self.bounding_rect[0]
|
||||
|
||||
def height(self) -> int:
|
||||
return self.bounding_rect[3] - self.bounding_rect[1]
|
||||
|
||||
def is_clickable(self) -> bool:
|
||||
"""Peut-on cliquer dessus ?"""
|
||||
return (
|
||||
self.is_enabled
|
||||
and not self.is_offscreen
|
||||
and self.width() > 0
|
||||
and self.height() > 0
|
||||
)
|
||||
|
||||
def path_signature(self) -> str:
|
||||
"""Signature du chemin parent (pour retrouver l'élément)."""
|
||||
parts = [f"{p['control_type']}[{p['name']}]" for p in self.parent_path if p.get("name")]
|
||||
parts.append(f"{self.control_type}[{self.name}]")
|
||||
return " > ".join(parts)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"control_type": self.control_type,
|
||||
"class_name": self.class_name,
|
||||
"automation_id": self.automation_id,
|
||||
"bounding_rect": list(self.bounding_rect),
|
||||
"is_enabled": self.is_enabled,
|
||||
"is_offscreen": self.is_offscreen,
|
||||
"parent_path": self.parent_path,
|
||||
"process_name": self.process_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "UiaElement":
|
||||
rect = d.get("bounding_rect", [0, 0, 0, 0])
|
||||
if isinstance(rect, list) and len(rect) >= 4:
|
||||
rect = tuple(rect[:4])
|
||||
else:
|
||||
rect = (0, 0, 0, 0)
|
||||
return cls(
|
||||
name=d.get("name", ""),
|
||||
control_type=d.get("control_type", ""),
|
||||
class_name=d.get("class_name", ""),
|
||||
automation_id=d.get("automation_id", ""),
|
||||
bounding_rect=rect,
|
||||
is_enabled=d.get("is_enabled", False),
|
||||
is_offscreen=d.get("is_offscreen", True),
|
||||
parent_path=d.get("parent_path", []),
|
||||
process_name=d.get("process_name", ""),
|
||||
)
|
||||
|
||||
|
||||
class UIAHelper:
|
||||
"""Wrapper Python pour lea_uia.exe."""
|
||||
|
||||
def __init__(self, helper_path: str = "", timeout: float = _DEFAULT_TIMEOUT):
|
||||
self._helper_path = helper_path or self._find_helper()
|
||||
self._timeout = timeout
|
||||
self._available = self._check_available()
|
||||
|
||||
def _find_helper(self) -> str:
|
||||
"""Trouver lea_uia.exe dans les emplacements standards."""
|
||||
candidates = [
|
||||
r"C:\Lea\helpers\lea_uia.exe",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..",
|
||||
"agent_rust", "lea_uia", "target",
|
||||
"x86_64-pc-windows-gnu", "release", "lea_uia.exe"),
|
||||
"./helpers/lea_uia.exe",
|
||||
"lea_uia.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return os.path.abspath(path)
|
||||
return ""
|
||||
|
||||
def _check_available(self) -> bool:
|
||||
"""Vérifier que le helper est utilisable (Windows + binaire + health OK)."""
|
||||
if platform.system() != "Windows":
|
||||
logger.debug("UIAHelper: Linux/Mac — helper désactivé")
|
||||
return False
|
||||
if not self._helper_path:
|
||||
logger.debug("UIAHelper: lea_uia.exe introuvable")
|
||||
return False
|
||||
if not os.path.isfile(self._helper_path):
|
||||
logger.debug(f"UIAHelper: chemin invalide {self._helper_path}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def helper_path(self) -> str:
|
||||
return self._helper_path
|
||||
|
||||
def _run(self, args: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Exécuter lea_uia.exe avec les arguments et parser le JSON."""
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._helper_path] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=_SUBPROCESS_CREATION_FLAGS,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug(
|
||||
f"UIAHelper: exit code {result.returncode}, "
|
||||
f"stderr: {result.stderr[:200]}"
|
||||
)
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return None
|
||||
return json.loads(output)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug(f"UIAHelper: timeout ({self._timeout}s) sur {args}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"UIAHelper: JSON invalide — {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"UIAHelper: erreur {e}")
|
||||
return None
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Vérifier que UIA répond."""
|
||||
data = self._run(["health"])
|
||||
return data is not None and data.get("status") == "ok"
|
||||
|
||||
def query_at(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
with_parents: bool = True,
|
||||
) -> Optional[UiaElement]:
|
||||
"""Récupérer l'élément UIA à une position écran.
|
||||
|
||||
Args:
|
||||
x, y: Coordonnées pixel absolues
|
||||
with_parents: Inclure la hiérarchie des parents
|
||||
|
||||
Returns:
|
||||
UiaElement si trouvé, None sinon (pas d'élément ou UIA indispo)
|
||||
"""
|
||||
args = ["query", "--x", str(x), "--y", str(y)]
|
||||
if not with_parents:
|
||||
args.append("--with-parents=false")
|
||||
|
||||
data = self._run(args)
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
def find_by_name(
|
||||
self,
|
||||
name: str,
|
||||
control_type: Optional[str] = None,
|
||||
automation_id: Optional[str] = None,
|
||||
window: Optional[str] = None,
|
||||
timeout_ms: int = 2000,
|
||||
) -> Optional[UiaElement]:
|
||||
"""Rechercher un élément par son nom (+ filtres optionnels).
|
||||
|
||||
Args:
|
||||
name: Nom exact de l'élément
|
||||
control_type: Type de contrôle (Button, Edit, MenuItem...)
|
||||
automation_id: ID d'automation
|
||||
window: Restreindre à une fenêtre spécifique
|
||||
timeout_ms: Timeout de recherche en millisecondes
|
||||
"""
|
||||
args = ["find", "--name", name, "--timeout-ms", str(timeout_ms)]
|
||||
if control_type:
|
||||
args.extend(["--control-type", control_type])
|
||||
if automation_id:
|
||||
args.extend(["--automation-id", automation_id])
|
||||
if window:
|
||||
args.extend(["--window", window])
|
||||
|
||||
data = self._run(args)
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
def capture_focused(self, max_depth: int = 3) -> Optional[UiaElement]:
|
||||
"""Capturer l'élément ayant le focus + son contexte."""
|
||||
data = self._run(["capture", "--max-depth", str(max_depth)])
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
|
||||
# Instance globale partagée (singleton léger)
|
||||
_SHARED_HELPER: Optional[UIAHelper] = None
|
||||
|
||||
|
||||
def get_shared_helper() -> UIAHelper:
|
||||
"""Retourner une instance partagée de UIAHelper."""
|
||||
global _SHARED_HELPER
|
||||
if _SHARED_HELPER is None:
|
||||
_SHARED_HELPER = UIAHelper()
|
||||
return _SHARED_HELPER
|
||||
547
agent_v0/agent_v1/main.py
Normal file
547
agent_v0/agent_v1/main.py
Normal file
@@ -0,0 +1,547 @@
|
||||
# agent_v1/main.py
|
||||
"""
|
||||
Point d'entree Agent V1 - Enrichi avec Intelligence de Contexte, Heartbeat et Replay.
|
||||
|
||||
Boucles paralleles (threads daemon) :
|
||||
- _heartbeat_loop : capture periodique toutes les 5s
|
||||
- _command_watchdog_loop : surveillance du fichier command.json (legacy)
|
||||
- _replay_poll_loop : polling du serveur pour les actions de replay (P0-5)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
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
|
||||
from .network.streamer import TraceStreamer
|
||||
from .ui.shared_state import AgentState
|
||||
from .ui.smart_tray import SmartTrayV1
|
||||
from .ui.chat_window import ChatWindow
|
||||
from .ui.capture_server import CaptureServer
|
||||
from .session.storage import SessionStorage
|
||||
from .vision.capturer import VisionCapturer
|
||||
|
||||
# Import optionnel du client serveur (pour le chat et les workflows)
|
||||
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
|
||||
try:
|
||||
from ..lea_ui.server_client import LeaServerClient
|
||||
except (ImportError, ValueError):
|
||||
try:
|
||||
from lea_ui.server_client import LeaServerClient
|
||||
except ImportError:
|
||||
LeaServerClient = None
|
||||
|
||||
# Configuration du logging — format structuré et lisible pour un TIM
|
||||
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
|
||||
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# Réduire le bruit de certaines libs
|
||||
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalle de polling replay (secondes)
|
||||
REPLAY_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
class AgentV1:
|
||||
def __init__(self, user_id="demo_user"):
|
||||
self.user_id = user_id
|
||||
self.machine_id = MACHINE_ID
|
||||
self.session_id = None
|
||||
self.session_dir = None
|
||||
|
||||
# Gestion du stockage local et nettoyage
|
||||
# Retention minimum 6 mois (Reglement IA, Article 12)
|
||||
self.storage = SessionStorage(SESSIONS_ROOT, retention_days=LOG_RETENTION_DAYS)
|
||||
threading.Thread(target=self._delayed_cleanup, daemon=True).start()
|
||||
|
||||
self.vision = None
|
||||
self.streamer = None
|
||||
self.captor = None
|
||||
self.shot_counter = 0
|
||||
self.running = False
|
||||
|
||||
# Executeur partage entre watchdog et replay
|
||||
self._executor = None
|
||||
# Flag pour indiquer qu'un replay est en cours (eviter les conflits)
|
||||
self._replay_active = False
|
||||
|
||||
# Etat partage entre systray et chat (source de verite unique)
|
||||
self._state = AgentState()
|
||||
self._state.set_on_start(self.start_session)
|
||||
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
|
||||
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 "localhost"
|
||||
)
|
||||
self._chat_window = ChatWindow(
|
||||
server_client=self._server_client,
|
||||
on_start_callback=self.start_session,
|
||||
server_host=server_host,
|
||||
chat_port=5004,
|
||||
shared_state=self._state,
|
||||
)
|
||||
|
||||
# Executeur pour le replay (doit exister avant le poll)
|
||||
self._executor = ActionExecutorV1()
|
||||
|
||||
# Boucles permanentes (pas besoin de session active)
|
||||
self.running = True
|
||||
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||
|
||||
# Mini-serveur HTTP pour captures a la demande (port 5006)
|
||||
self._capture_server = CaptureServer()
|
||||
self._capture_server.start()
|
||||
|
||||
# Bannière de démarrage avec métadonnées système
|
||||
logger.info(
|
||||
f"Agent V1 v{AGENT_VERSION} | Machine={self.machine_id} | "
|
||||
f"Ecran={SCREEN_RESOLUTION[0]}x{SCREEN_RESOLUTION[1]} | "
|
||||
f"DPI={DPI_SCALE}% | Theme={OS_THEME} | "
|
||||
f"Serveur={SERVER_URL}"
|
||||
)
|
||||
|
||||
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
|
||||
self.ui = SmartTrayV1(
|
||||
self.start_session,
|
||||
self.stop_session,
|
||||
server_client=self._server_client,
|
||||
chat_window=self._chat_window,
|
||||
machine_id=self.machine_id,
|
||||
shared_state=self._state,
|
||||
)
|
||||
|
||||
def _delayed_cleanup(self):
|
||||
"""Nettoyage en arrière-plan après 30s pour ne pas bloquer le démarrage."""
|
||||
time.sleep(30)
|
||||
self.storage.run_auto_cleanup()
|
||||
|
||||
def _auto_stop_loop(self):
|
||||
"""Auto-stop de l'enregistrement après MAX_SESSION_DURATION_S.
|
||||
|
||||
L'utilisateur peut oublier d'arrêter. On notifie à 50 min,
|
||||
puis on arrête automatiquement à 60 min (configurable).
|
||||
"""
|
||||
warn_before = 600 # Prévenir 10 min avant la fin
|
||||
warned = False
|
||||
|
||||
while self.running and self.session_id:
|
||||
elapsed = time.time() - self._session_start_time
|
||||
remaining = MAX_SESSION_DURATION_S - elapsed
|
||||
|
||||
# Notification 10 min avant la fin
|
||||
if not warned and remaining <= warn_before:
|
||||
warned = True
|
||||
mins = int(remaining / 60)
|
||||
logger.info(f"Auto-stop dans {mins} min")
|
||||
try:
|
||||
from .ui.notifications import NotificationManager
|
||||
NotificationManager().notify(
|
||||
"Léa",
|
||||
f"L'enregistrement s'arrêtera automatiquement dans {mins} minutes.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-stop
|
||||
if remaining <= 0:
|
||||
logger.info(
|
||||
f"Auto-stop : session {self.session_id} après "
|
||||
f"{int(elapsed)}s ({int(elapsed/60)} min)"
|
||||
)
|
||||
try:
|
||||
from .ui.notifications import NotificationManager
|
||||
NotificationManager().notify(
|
||||
"Léa",
|
||||
f"Enregistrement terminé automatiquement après "
|
||||
f"{int(elapsed/60)} minutes. Merci !",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Arrêter via l'état partagé (synchronise systray + chat)
|
||||
if self._state is not None:
|
||||
self._state.stop_recording()
|
||||
else:
|
||||
self.stop_session()
|
||||
break
|
||||
|
||||
time.sleep(30) # Vérifier toutes les 30s
|
||||
|
||||
def start_session(self, workflow_name):
|
||||
self.session_id = f"sess_{time.strftime('%Y%m%dT%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
self.session_dir = self.storage.get_session_dir(self.session_id)
|
||||
|
||||
self.vision = VisionCapturer(str(self.session_dir))
|
||||
|
||||
self.streamer = TraceStreamer(self.session_id, machine_id=self.machine_id)
|
||||
self.captor = EventCaptorV1(self._on_event_bridge)
|
||||
|
||||
# Initialiser l'executeur partage
|
||||
self._executor = ActionExecutorV1()
|
||||
|
||||
self.shot_counter = 0
|
||||
self.running = True
|
||||
self._replay_active = False
|
||||
self.streamer.start()
|
||||
self.captor.start()
|
||||
|
||||
# Heartbeat Contextuel (Toutes les 5s par defaut)
|
||||
threading.Thread(target=self._heartbeat_loop, daemon=True).start()
|
||||
|
||||
# Auto-stop : arrêter l'enregistrement après MAX_SESSION_DURATION_S
|
||||
# L'utilisateur peut oublier d'arrêter — on le fait automatiquement
|
||||
self._session_start_time = time.time()
|
||||
threading.Thread(target=self._auto_stop_loop, daemon=True).start()
|
||||
|
||||
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
||||
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
||||
|
||||
# Note: la boucle de polling replay est déjà lancée dans __init__ (ligne 102)
|
||||
# Ne PAS en relancer une ici — deux threads poll simultanés causent
|
||||
# une race condition où les actions sont consommées mais pas exécutées.
|
||||
|
||||
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
|
||||
|
||||
def _command_watchdog_loop(self):
|
||||
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
||||
import json
|
||||
import platform
|
||||
from .config import BASE_DIR
|
||||
|
||||
# Chemin du fichier de commande selon l'OS
|
||||
if platform.system() == "Windows":
|
||||
cmd_path = "C:\\rpa_vision\\command.json"
|
||||
else:
|
||||
cmd_path = str(BASE_DIR / "command.json")
|
||||
|
||||
while self.running and self.session_id:
|
||||
# Ne pas traiter les commandes fichier pendant un replay serveur
|
||||
if self._replay_active:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if os.path.exists(cmd_path):
|
||||
try:
|
||||
with open(cmd_path, "r") as f:
|
||||
order = json.load(f)
|
||||
os.remove(cmd_path) # On consomme l'ordre
|
||||
if self._executor:
|
||||
self._executor.execute_normalized_order(order)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Watchdog: {e}")
|
||||
time.sleep(1)
|
||||
|
||||
def _replay_poll_loop(self):
|
||||
"""
|
||||
Boucle de polling pour les actions de replay depuis le serveur (P0-5).
|
||||
|
||||
Tourne en parallele du heartbeat et du watchdog.
|
||||
Poll GET /replay/next toutes les REPLAY_POLL_INTERVAL secondes.
|
||||
Quand une action est recue, l'execute via l'executor et rapporte le resultat.
|
||||
"""
|
||||
msg = (
|
||||
f"[REPLAY] Boucle replay demarree — poll toutes les "
|
||||
f"{REPLAY_POLL_INTERVAL}s sur {SERVER_URL}"
|
||||
)
|
||||
print(msg)
|
||||
logger.info(msg)
|
||||
|
||||
poll_count = 0
|
||||
while self.running:
|
||||
if not self._executor:
|
||||
time.sleep(REPLAY_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# TOUJOURS utiliser un session_id stable pour le replay.
|
||||
# L'enregistrement et le replay sont indépendants : le serveur
|
||||
# envoie les actions sur agent_{user_id}, pas sur la session
|
||||
# d'enregistrement (sess_xxx).
|
||||
poll_session = f"agent_{self.user_id}"
|
||||
|
||||
# Log periodique pour confirmer que la boucle tourne (toutes les 60s)
|
||||
poll_count += 1
|
||||
if poll_count % int(60 / REPLAY_POLL_INTERVAL) == 0:
|
||||
print(
|
||||
f"[REPLAY] Poll #{poll_count} — session={poll_session} "
|
||||
f"— serveur={SERVER_URL}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Tenter de recuperer et executer une action
|
||||
had_action = self._executor.poll_and_execute(
|
||||
session_id=poll_session,
|
||||
server_url=SERVER_URL,
|
||||
machine_id=self.machine_id,
|
||||
)
|
||||
|
||||
if had_action:
|
||||
if not self._replay_active:
|
||||
self._replay_active = True
|
||||
self.ui.set_replay_active(True)
|
||||
self._state.set_replay_active(True)
|
||||
# Si une action a ete executee, poll plus rapidement
|
||||
# pour enchainer les actions du workflow
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
# Pas d'action en attente — utiliser le backoff de l'executor
|
||||
# (augmente si le serveur est indisponible, reset a 1s sinon)
|
||||
if self._replay_active:
|
||||
print("[REPLAY] Replay termine — retour en mode capture")
|
||||
logger.info("Replay termine — retour en mode capture")
|
||||
self._replay_active = False
|
||||
self.ui.set_replay_active(False)
|
||||
self._state.set_replay_active(False)
|
||||
poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL)
|
||||
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[REPLAY] ERREUR boucle replay : {e}")
|
||||
logger.error(f"Erreur replay poll loop : {e}")
|
||||
self._replay_active = False
|
||||
self._state.set_replay_active(False)
|
||||
poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL)
|
||||
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
|
||||
|
||||
_last_bg_hash: str = ""
|
||||
|
||||
def _background_heartbeat_loop(self):
|
||||
"""Heartbeat permanent — envoie un screenshot toutes les 5s au serveur.
|
||||
Tourne même sans session active, pour que le VWB puisse capturer Windows.
|
||||
"""
|
||||
import requests as req
|
||||
bg_session = f"bg_{self.machine_id}"
|
||||
logger.info(f"[HEARTBEAT] Boucle permanente démarrée (session={bg_session})")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Ne pas envoyer pendant un enregistrement (le heartbeat session s'en charge)
|
||||
if self.session_id:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
full_path = self._bg_vision.capture_full_context("heartbeat")
|
||||
if not full_path:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
# Dédup : skip si écran identique
|
||||
img_hash = self._quick_hash(full_path)
|
||||
if img_hash and img_hash == self._last_bg_hash:
|
||||
time.sleep(5)
|
||||
continue
|
||||
self._last_bg_hash = img_hash
|
||||
|
||||
# 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"{STREAMING_ENDPOINT}/image",
|
||||
params={
|
||||
"session_id": bg_session,
|
||||
"shot_id": f"heartbeat_{int(time.time())}",
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=headers,
|
||||
files={"file": ("screenshot.png", f, "image/png")},
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def stop_session(self):
|
||||
# Sauvegarder le session_id avant de l'annuler (pour les logs)
|
||||
ended_session_id = self.session_id
|
||||
|
||||
# Arrêter la capture d'abord (plus d'events entrants)
|
||||
if self.captor: self.captor.stop()
|
||||
|
||||
# Attendre que les events en cours de traitement dans _on_event_bridge
|
||||
# aient le temps d'être envoyés au streamer (capture duale + push)
|
||||
import time
|
||||
time.sleep(1.5)
|
||||
|
||||
# Maintenant arrêter le streamer (drain queue + finalize)
|
||||
if self.streamer: self.streamer.stop()
|
||||
logger.info(f"Session {ended_session_id} terminée.")
|
||||
|
||||
# Reset le session_id APRÈS le stop complet du streamer
|
||||
self.session_id = None
|
||||
|
||||
# Reset le backoff de l'executor pour reprendre le polling immédiatement
|
||||
if self._executor:
|
||||
self._executor._poll_backoff = self._executor._poll_backoff_min
|
||||
self._executor._server_available = True
|
||||
if hasattr(self._executor, '_last_conn_error_logged'):
|
||||
self._executor._last_conn_error_logged = False
|
||||
|
||||
# NE PAS mettre self.running = False ici !
|
||||
# self.running contrôle la boucle _replay_poll_loop (permanente).
|
||||
# Seule la sortie du programme doit le mettre à False.
|
||||
# Les boucles _heartbeat_loop et _command_watchdog_loop vérifieront
|
||||
# self.session_id pour savoir si elles doivent fonctionner.
|
||||
|
||||
logger.info(
|
||||
f"Session arrêtée — replay poll actif avec session="
|
||||
f"agent_{self.user_id}"
|
||||
)
|
||||
|
||||
_last_heartbeat_hash: str = ""
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
"""Capture périodique pour donner du contexte au stagiaire.
|
||||
Déduplication : n'envoie que si l'écran a changé.
|
||||
Tourne tant que session_id est défini (= enregistrement actif).
|
||||
Enrichi avec le titre de la fenêtre active pour contextualisation.
|
||||
"""
|
||||
while self.running and self.session_id:
|
||||
try:
|
||||
full_path = self.vision.capture_full_context("heartbeat")
|
||||
if full_path:
|
||||
# Hash rapide pour détecter les changements d'écran
|
||||
img_hash = self._quick_hash(full_path)
|
||||
if img_hash != self._last_heartbeat_hash:
|
||||
self._last_heartbeat_hash = img_hash
|
||||
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
|
||||
heartbeat_event = {
|
||||
"type": "heartbeat",
|
||||
"image": full_path,
|
||||
"timestamp": time.time(),
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
# Ajouter le titre de la fenêtre active (léger, pas de crop)
|
||||
window_title = self.vision.get_active_window_title()
|
||||
if window_title:
|
||||
heartbeat_event["active_window_title"] = window_title
|
||||
self.streamer.push_event(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
@staticmethod
|
||||
def _quick_hash(image_path: str) -> str:
|
||||
"""Hash perceptuel rapide (16x16 niveaux de gris)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
import hashlib
|
||||
img = Image.open(image_path).resize((16, 16)).convert('L')
|
||||
return hashlib.md5(img.tobytes()).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _on_event_bridge(self, event):
|
||||
"""Pont intelligent avec capture duale et post-action monitoring."""
|
||||
if not self.session_id:
|
||||
return
|
||||
|
||||
# Injecter l'identifiant machine dans chaque événement (multi-machine)
|
||||
event["machine_id"] = self.machine_id
|
||||
|
||||
# Injecter le contexte fenêtre dans chaque événement (nécessaire
|
||||
# pour que le serveur maintienne last_window_info)
|
||||
if self.captor and self.captor.last_window:
|
||||
event["window"] = self.captor.last_window
|
||||
|
||||
# Capture Proactive sur changement de fenêtre
|
||||
if event["type"] == "window_focus_change":
|
||||
full_path = self.vision.capture_full_context("focus_change")
|
||||
event["screenshot_context"] = full_path
|
||||
self.streamer.push_image(full_path, f"focus_{int(time.time())}")
|
||||
|
||||
# Capture Interactive (Dual + Fenêtre active)
|
||||
if event["type"] in ["mouse_click", "key_combo"]:
|
||||
self.shot_counter += 1
|
||||
shot_id = f"shot_{self.shot_counter:04d}"
|
||||
|
||||
pos = event.get("pos", (0, 0))
|
||||
capture_info = self.vision.capture_dual(pos[0], pos[1], shot_id)
|
||||
|
||||
event["screenshot_id"] = shot_id
|
||||
event["vision_info"] = capture_info
|
||||
|
||||
# Enrichir l'event avec les métadonnées de la fenêtre active
|
||||
# (titre, rect, coordonnées clic relatives, taille fenêtre)
|
||||
window_capture = capture_info.get("window_capture")
|
||||
if window_capture:
|
||||
event["window_capture"] = {
|
||||
"title": window_capture.get("window_title", ""),
|
||||
"app_name": window_capture.get("app_name", ""),
|
||||
"rect": window_capture.get("window_rect"),
|
||||
"click_relative": window_capture.get("click_in_window"),
|
||||
"window_size": window_capture.get("window_size"),
|
||||
"click_inside_window": window_capture.get("click_inside_window", True),
|
||||
}
|
||||
|
||||
self._stream_capture_info(capture_info, shot_id)
|
||||
|
||||
# POST-ACTION : Capture du résultat après 1s (pour voir le résultat du clic)
|
||||
threading.Timer(1.0, self._capture_result, args=(shot_id,)).start()
|
||||
|
||||
self.ui.update_stats(self.shot_counter)
|
||||
self._state.update_actions_count(self.shot_counter)
|
||||
print(f"📸 Action capturée : {event['type']}")
|
||||
self.streamer.push_event(event)
|
||||
|
||||
def _capture_result(self, base_shot_id: str):
|
||||
"""Capture l'état de l'écran 1s après l'action pour voir l'effet."""
|
||||
if not self.running: return
|
||||
res_path = self.vision.capture_full_context(f"result_of_{base_shot_id}")
|
||||
self.streamer.push_image(res_path, f"res_{base_shot_id}")
|
||||
self.streamer.push_event({"type": "action_result", "base_shot_id": base_shot_id, "image": res_path})
|
||||
|
||||
def _stream_capture_info(self, capture_info, shot_id):
|
||||
if "full" in capture_info:
|
||||
self.streamer.push_image(capture_info["full"], f"{shot_id}_full")
|
||||
if "crop" in capture_info:
|
||||
self.streamer.push_image(capture_info["crop"], f"{shot_id}_crop")
|
||||
# Streamer l'image de la fenêtre active si disponible
|
||||
window_capture = capture_info.get("window_capture")
|
||||
if window_capture and "window_image" in window_capture:
|
||||
self.streamer.push_image(
|
||||
window_capture["window_image"], f"{shot_id}_window"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.ui.run()
|
||||
|
||||
def main():
|
||||
agent = AgentV1()
|
||||
agent.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
agent_v0/agent_v1/monitoring/__init__.py
Normal file
0
agent_v0/agent_v1/monitoring/__init__.py
Normal file
0
agent_v0/agent_v1/network/__init__.py
Normal file
0
agent_v0/agent_v1/network/__init__.py
Normal file
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
|
||||
734
agent_v0/agent_v1/network/streamer.py
Normal file
734
agent_v0/agent_v1/network/streamer.py
Normal file
@@ -0,0 +1,734 @@
|
||||
# agent_v1/network/streamer.py
|
||||
"""
|
||||
Streaming temps réel pour Agent V1.
|
||||
Exploite la fibre pour envoyer les événements au fur et à mesure.
|
||||
|
||||
Endpoints serveur (api_stream.py, port 5005) :
|
||||
POST /api/v1/traces/stream/register — enregistrer la session
|
||||
POST /api/v1/traces/stream/event — événement temps réel
|
||||
POST /api/v1/traces/stream/image — screenshot (full ou crop)
|
||||
POST /api/v1/traces/stream/finalize — clôturer et construire le workflow
|
||||
|
||||
Robustesse (P0-2) :
|
||||
- Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives)
|
||||
- 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
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
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__)
|
||||
|
||||
# Paramètres de retry
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel
|
||||
|
||||
# Paramètres de health-check
|
||||
HEALTH_CHECK_INTERVAL_S = 30
|
||||
|
||||
# Paramètres de compression
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
# Taille max de la queue (backpressure)
|
||||
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"):
|
||||
self.session_id = session_id
|
||||
self.machine_id = machine_id # Identifiant machine pour le multi-machine
|
||||
self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
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."""
|
||||
if API_TOKEN:
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
return {}
|
||||
|
||||
def start(self):
|
||||
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
||||
self.running = True
|
||||
self._register_session()
|
||||
# Thread principal d'envoi
|
||||
self._thread = threading.Thread(target=self._stream_loop, daemon=True)
|
||||
self._thread.start()
|
||||
# Thread de health-check pour recovery
|
||||
self._health_thread = threading.Thread(
|
||||
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):
|
||||
"""Arrêter le streaming et finaliser la session côté serveur.
|
||||
|
||||
Attend que la queue se vide (max 30s) avant de finaliser,
|
||||
pour que toutes les images soient envoyées au serveur.
|
||||
"""
|
||||
self.running = False
|
||||
|
||||
# Attendre que la queue se vide (les images doivent être envoyées)
|
||||
if self._thread:
|
||||
drain_start = time.time()
|
||||
while not self.queue.empty() and (time.time() - drain_start) < 30:
|
||||
time.sleep(0.5)
|
||||
if not self.queue.empty():
|
||||
logger.warning(
|
||||
f"Queue non vide après 30s ({self.queue.qsize()} items restants)"
|
||||
)
|
||||
self._thread.join(timeout=5.0)
|
||||
|
||||
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é")
|
||||
|
||||
def push_event(self, event_data: dict):
|
||||
"""Enfile un événement pour envoi immédiat.
|
||||
|
||||
Si la queue est pleine (backpressure), les heartbeat sont droppés
|
||||
tandis que les événements utilisateur (click, key, scroll, action)
|
||||
et screenshots sont toujours conservés.
|
||||
"""
|
||||
self._enqueue_with_backpressure("event", event_data)
|
||||
|
||||
def push_image(self, image_path: str, screenshot_id: str):
|
||||
"""Enfile une image pour envoi asynchrone."""
|
||||
if not image_path:
|
||||
return # Ignorer les chemins vides (heartbeat sans changement)
|
||||
self._enqueue_with_backpressure("image", (image_path, screenshot_id))
|
||||
|
||||
# =========================================================================
|
||||
# Backpressure — gestion de la queue bornée
|
||||
# =========================================================================
|
||||
|
||||
def _enqueue_with_backpressure(self, item_type: str, data):
|
||||
"""Ajouter un item à la queue avec gestion du backpressure.
|
||||
|
||||
Quand la queue est pleine :
|
||||
- Les événements prioritaires (click, key, action, screenshot) sont
|
||||
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:
|
||||
if is_priority:
|
||||
# Événement prioritaire : on attend un peu pour l'ajouter
|
||||
try:
|
||||
self.queue.put((item_type, data), timeout=0.5)
|
||||
except queue.Full:
|
||||
# Persistance disque (ne JAMAIS dropper un prioritaire)
|
||||
persisted = self._persist_to_buffer(item_type, data)
|
||||
if persisted:
|
||||
logger.warning(
|
||||
f"Queue pleine — événement prioritaire 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(
|
||||
f"Queue pleine — heartbeat/non-prioritaire droppé "
|
||||
f"(type={item_type})"
|
||||
)
|
||||
|
||||
def _is_priority_item(self, item_type: str, data) -> bool:
|
||||
"""Vérifie si un item est prioritaire (ne doit pas être droppé).
|
||||
|
||||
Les images sont toujours prioritaires. Pour les événements,
|
||||
on regarde le type d'événement (click, key, scroll, action).
|
||||
"""
|
||||
if item_type == "image":
|
||||
return True
|
||||
if item_type == "event" and isinstance(data, dict):
|
||||
event_type = data.get("type", "").lower()
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
def _stream_loop(self):
|
||||
"""Boucle d'envoi asynchrone (thread daemon)."""
|
||||
consecutive_failures = 0
|
||||
while self.running or not self.queue.empty():
|
||||
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":
|
||||
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"
|
||||
)
|
||||
self._server_available = False
|
||||
consecutive_failures = 0
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Streaming Loop: {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Retry avec backoff exponentiel
|
||||
# =========================================================================
|
||||
|
||||
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 / 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)
|
||||
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):
|
||||
if not self.running:
|
||||
# On arrête les retries si le streamer est en cours d'arrêt
|
||||
break
|
||||
logger.debug(
|
||||
f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
result = send_fn(*args)
|
||||
if result is ImageSendResult.OK or result is True:
|
||||
logger.debug(f"Retry {attempt} réussi")
|
||||
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
|
||||
|
||||
# =========================================================================
|
||||
# Health-check périodique pour recovery
|
||||
# =========================================================================
|
||||
|
||||
def _health_check_loop(self):
|
||||
"""Vérifie périodiquement si le serveur est redevenu disponible.
|
||||
|
||||
Toutes les 30s, tente un GET /stats. Si le serveur répond,
|
||||
remet _server_available = True et ré-enregistre la session.
|
||||
"""
|
||||
while self.running:
|
||||
time.sleep(HEALTH_CHECK_INTERVAL_S)
|
||||
if not self.running:
|
||||
break
|
||||
if self._server_available:
|
||||
# Serveur déjà disponible, rien à faire
|
||||
continue
|
||||
# Tenter un health-check
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{STREAMING_ENDPOINT}/stats",
|
||||
headers=self._auth_headers(),
|
||||
timeout=3,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.info(
|
||||
"Health-check OK — serveur redevenu disponible, "
|
||||
"ré-enregistrement de la session"
|
||||
)
|
||||
self._server_available = True
|
||||
self._register_session()
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
def _compress_image_to_jpeg(self, path: str) -> tuple:
|
||||
"""Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire.
|
||||
|
||||
Retourne un tuple (bytes_io, content_type, filename_suffix).
|
||||
Si la compression échoue, renvoie le fichier original en PNG.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(path)
|
||||
# Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha)
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
buf.seek(0)
|
||||
return buf, "image/jpeg", ".jpg"
|
||||
except FileNotFoundError:
|
||||
# Fichier introuvable — propager l'erreur (pas de fallback possible)
|
||||
logger.warning(f"Fichier image introuvable pour compression : {path}")
|
||||
raise
|
||||
except Exception as e:
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
def _register_session(self):
|
||||
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/register"
|
||||
resp = requests.post(
|
||||
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 "
|
||||
f"(machine={self.machine_id})"
|
||||
)
|
||||
self._server_available = True
|
||||
else:
|
||||
logger.warning(f"Enregistrement session échoué: {resp.status_code}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Serveur indisponible pour register: {e}")
|
||||
self._server_available = False
|
||||
|
||||
def _finalize_session(self):
|
||||
"""Finaliser la session (construction du workflow côté serveur).
|
||||
|
||||
IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available.
|
||||
C'est la dernière chance de sauver les données de la session.
|
||||
"""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/finalize"
|
||||
resp = requests.post(
|
||||
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.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(),
|
||||
"event": event,
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
resp = requests.post(
|
||||
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):
|
||||
"""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 ImageSendResult.FAILED
|
||||
try:
|
||||
# Tenter la compression JPEG (réduction ~5-10x vs PNG)
|
||||
jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path)
|
||||
|
||||
params = {
|
||||
"session_id": self.session_id,
|
||||
"shot_id": shot_id,
|
||||
"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(
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
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:
|
||||
files = {
|
||||
"file": (f"{shot_id}.png", f, "image/png")
|
||||
}
|
||||
resp = requests.post(
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
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 ImageSendResult.FAILED
|
||||
16
agent_v0/agent_v1/requirements.txt
Normal file
16
agent_v0/agent_v1/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
# agent_v1/requirements.txt
|
||||
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
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
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)
|
||||
|
||||
# Windows spécifique
|
||||
pywin32>=306 ; sys_platform == 'win32'
|
||||
|
||||
# macOS spécifique
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == 'darwin'
|
||||
pyobjc-framework-Quartz>=10.0 ; sys_platform == 'darwin'
|
||||
0
agent_v0/agent_v1/session/__init__.py
Normal file
0
agent_v0/agent_v1/session/__init__.py
Normal file
74
agent_v0/agent_v1/session/storage.py
Normal file
74
agent_v0/agent_v1/session/storage.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# agent_v1/session/storage.py
|
||||
"""
|
||||
Gestionnaire de stockage local robuste pour Agent V1.
|
||||
Gère le chiffrement des données au repos et l'auto-nettoyage du disque.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger("session_storage")
|
||||
|
||||
class SessionStorage:
|
||||
def __init__(self, base_dir: Path, max_size_gb: int = 5, retention_days: int = 180):
|
||||
"""Gestionnaire de stockage local pour les sessions Agent V1.
|
||||
|
||||
Args:
|
||||
base_dir: Dossier racine de stockage des sessions.
|
||||
max_size_gb: Taille maximale du stockage local (Go).
|
||||
retention_days: Duree de retention en jours. Defaut = 180 (6 mois),
|
||||
minimum requis par le Reglement IA (Article 12 — journalisation
|
||||
automatique, Article 26(6) — conservation des logs).
|
||||
"""
|
||||
self.base_dir = base_dir
|
||||
self.max_size_bytes = max_size_gb * 1024 * 1024 * 1024
|
||||
self.retention_days = retention_days
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_session_dir(self, session_id: str) -> Path:
|
||||
"""Retourne et crée le dossier pour une session."""
|
||||
session_path = self.base_dir / session_id
|
||||
session_path.mkdir(exist_ok=True)
|
||||
(session_path / "shots").mkdir(exist_ok=True)
|
||||
return session_path
|
||||
|
||||
def run_auto_cleanup(self):
|
||||
"""Lance le nettoyage automatique basé sur l'âge et la taille."""
|
||||
logger.info("🧹 Lancement du nettoyage automatique du stockage local...")
|
||||
self._cleanup_by_age()
|
||||
self._cleanup_by_size()
|
||||
|
||||
def _cleanup_by_age(self):
|
||||
"""Supprime les sessions plus vieilles que retention_days."""
|
||||
threshold = datetime.now() - timedelta(days=self.retention_days)
|
||||
for session_path in self.base_dir.iterdir():
|
||||
if session_path.is_dir():
|
||||
mtime = datetime.fromtimestamp(session_path.stat().st_mtime)
|
||||
if mtime < threshold:
|
||||
logger.info(f"🗑️ Purge session ancienne : {session_path.name}")
|
||||
shutil.rmtree(session_path)
|
||||
|
||||
def _cleanup_by_size(self):
|
||||
"""Supprime les sessions les plus anciennes si la taille totale dépasse max_size_bytes."""
|
||||
sessions = []
|
||||
total_size = 0
|
||||
for session_path in self.base_dir.iterdir():
|
||||
if session_path.is_dir():
|
||||
size = sum(f.stat().st_size for f in session_path.rglob('*') if f.is_file())
|
||||
sessions.append((session_path, session_path.stat().st_mtime, size))
|
||||
total_size += size
|
||||
|
||||
if total_size > self.max_size_bytes:
|
||||
logger.warning(f"⚠️ Stockage saturé ({total_size/1e9:.2f} GB). Purge nécessaire.")
|
||||
# Trier par date de modif (plus ancien d'abord)
|
||||
sessions.sort(key=lambda x: x[1])
|
||||
for path, _, size in sessions:
|
||||
if total_size <= self.max_size_bytes * 0.8: # On libère jusqu'à 80% du max
|
||||
break
|
||||
logger.info(f"🗑️ Purge session pour libérer de l'espace : {path.name} ({size/1e6:.1f} MB)")
|
||||
shutil.rmtree(path)
|
||||
total_size -= size
|
||||
0
agent_v0/agent_v1/ui/__init__.py
Normal file
0
agent_v0/agent_v1/ui/__init__.py
Normal file
418
agent_v0/agent_v1/ui/activity_panel.py
Normal file
418
agent_v0/agent_v1/ui/activity_panel.py
Normal file
@@ -0,0 +1,418 @@
|
||||
# agent_v1/ui/activity_panel.py
|
||||
"""
|
||||
Panel d'activité temps réel de Léa.
|
||||
|
||||
Affiche à l'utilisateur ce que Léa fait *maintenant* :
|
||||
- État courant (Observe / Cherche / Agit / Vérifie / Bloquée)
|
||||
- Action en cours (ex: "Clic sur Rechercher")
|
||||
- Progression (ex: "3/15")
|
||||
- Temps écoulé depuis le début du workflow
|
||||
|
||||
Contraintes :
|
||||
- Fallback silencieux si tkinter absent (ne crash jamais)
|
||||
- Thread-safe (mises à jour depuis les threads de replay)
|
||||
- Pas de dépendance à PyQt5 (seulement tkinter, déjà utilisé par chat_window)
|
||||
|
||||
Utilisation :
|
||||
panel = ActivityPanel()
|
||||
panel.definir_workflow("Saisie patient", nb_etapes=15)
|
||||
panel.mettre_a_jour(etat=EtatLea.AGIT, action="Clic sur Valider", etape=3)
|
||||
panel.masquer()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EtatLea(Enum):
|
||||
"""États macroscopiques de Léa pendant un replay."""
|
||||
|
||||
INACTIVE = ("inactive", "Prête", "#808080") # Gris
|
||||
OBSERVE = ("observe", "Observe", "#4A90E2") # Bleu
|
||||
CHERCHE = ("cherche", "Cherche", "#F5A623") # Orange
|
||||
AGIT = ("agit", "Agit", "#7ED321") # Vert
|
||||
VERIFIE = ("verifie", "Vérifie", "#9013FE") # Violet
|
||||
BLOQUEE = ("bloquee", "Bloquée", "#D0021B") # Rouge
|
||||
TERMINE = ("termine", "Terminé", "#50E3C2") # Turquoise
|
||||
|
||||
def __init__(self, code: str, libelle: str, couleur: str) -> None:
|
||||
self.code = code
|
||||
self.libelle = libelle
|
||||
self.couleur = couleur
|
||||
|
||||
|
||||
@dataclass
|
||||
class EtatActivite:
|
||||
"""Instantané de l'activité courante de Léa.
|
||||
|
||||
Utilisé par le panel et exposé par `ActivityPanel.snapshot()` pour les
|
||||
tests (sans dépendre de tkinter).
|
||||
"""
|
||||
|
||||
etat: EtatLea = EtatLea.INACTIVE
|
||||
action_courante: str = ""
|
||||
nom_workflow: str = ""
|
||||
etape: int = 0
|
||||
nb_etapes: int = 0
|
||||
debut_timestamp: float = 0.0
|
||||
dernier_message: str = ""
|
||||
|
||||
def temps_ecoule_s(self) -> float:
|
||||
"""Temps écoulé depuis le début du workflow (secondes)."""
|
||||
if self.debut_timestamp <= 0:
|
||||
return 0.0
|
||||
return max(0.0, time.time() - self.debut_timestamp)
|
||||
|
||||
def progression_texte(self) -> str:
|
||||
"""Représentation textuelle de la progression (ex: '3/15')."""
|
||||
if self.nb_etapes <= 0:
|
||||
return ""
|
||||
return f"{self.etape}/{self.nb_etapes}"
|
||||
|
||||
def temps_ecoule_texte(self) -> str:
|
||||
"""Représentation humaine du temps écoulé (ex: '12s', '1m24s')."""
|
||||
s = int(self.temps_ecoule_s())
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
return f"{s // 60}m{s % 60:02d}s"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Sérialiser pour le logging et les tests."""
|
||||
return {
|
||||
"etat": self.etat.code,
|
||||
"etat_libelle": self.etat.libelle,
|
||||
"action_courante": self.action_courante,
|
||||
"nom_workflow": self.nom_workflow,
|
||||
"etape": self.etape,
|
||||
"nb_etapes": self.nb_etapes,
|
||||
"progression": self.progression_texte(),
|
||||
"temps_ecoule_s": round(self.temps_ecoule_s(), 1),
|
||||
"dernier_message": self.dernier_message,
|
||||
}
|
||||
|
||||
|
||||
class ActivityPanel:
|
||||
"""Panel d'activité de Léa.
|
||||
|
||||
Thread-safe. Le panel tkinter est créé à la demande (lazy) et uniquement
|
||||
si tkinter est disponible. Toutes les méthodes sont safe à appeler même
|
||||
si l'UI n'est pas dispo (fallback silencieux).
|
||||
"""
|
||||
|
||||
def __init__(self, activer_ui: bool = True) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._etat = EtatActivite()
|
||||
self._activer_ui = activer_ui
|
||||
# UI tkinter (créée à la demande dans le thread UI)
|
||||
self._tk_root = None
|
||||
self._tk_labels: dict = {}
|
||||
self._ui_disponible = None # Lazy : résolu au premier usage
|
||||
self._listeners = [] # Callbacks pour les changements d'état
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API publique (thread-safe)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def definir_workflow(self, nom: str, nb_etapes: int = 0) -> None:
|
||||
"""Démarrer le suivi d'un nouveau workflow."""
|
||||
with self._lock:
|
||||
self._etat = EtatActivite(
|
||||
etat=EtatLea.OBSERVE,
|
||||
nom_workflow=nom,
|
||||
nb_etapes=nb_etapes,
|
||||
debut_timestamp=time.time(),
|
||||
)
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
|
||||
|
||||
def mettre_a_jour(
|
||||
self,
|
||||
etat: Optional[EtatLea] = None,
|
||||
action: Optional[str] = None,
|
||||
etape: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Mettre à jour l'état affiché.
|
||||
|
||||
Tous les paramètres sont optionnels — on ne met à jour que ce qui est
|
||||
fourni. Les autres champs conservent leur valeur actuelle.
|
||||
"""
|
||||
with self._lock:
|
||||
if etat is not None:
|
||||
self._etat.etat = etat
|
||||
if action is not None:
|
||||
self._etat.action_courante = action
|
||||
if etape is not None:
|
||||
self._etat.etape = etape
|
||||
if message is not None:
|
||||
self._etat.dernier_message = message
|
||||
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
|
||||
def terminer(self, succes: bool = True) -> None:
|
||||
"""Marquer le workflow comme terminé."""
|
||||
with self._lock:
|
||||
self._etat.etat = EtatLea.TERMINE if succes else EtatLea.BLOQUEE
|
||||
if not succes:
|
||||
self._etat.dernier_message = (
|
||||
self._etat.dernier_message or "Léa a rendu la main"
|
||||
)
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
|
||||
def reinitialiser(self) -> None:
|
||||
"""Remettre le panel en état inactif."""
|
||||
with self._lock:
|
||||
self._etat = EtatActivite()
|
||||
self._notifier_changement()
|
||||
self._rafraichir_ui()
|
||||
|
||||
def snapshot(self) -> EtatActivite:
|
||||
"""Obtenir un instantané immuable de l'état courant (pour les tests)."""
|
||||
with self._lock:
|
||||
return EtatActivite(
|
||||
etat=self._etat.etat,
|
||||
action_courante=self._etat.action_courante,
|
||||
nom_workflow=self._etat.nom_workflow,
|
||||
etape=self._etat.etape,
|
||||
nb_etapes=self._etat.nb_etapes,
|
||||
debut_timestamp=self._etat.debut_timestamp,
|
||||
dernier_message=self._etat.dernier_message,
|
||||
)
|
||||
|
||||
def masquer(self) -> None:
|
||||
"""Masquer le panel UI si affiché."""
|
||||
if self._tk_root is not None:
|
||||
try:
|
||||
self._tk_root.withdraw()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def afficher(self) -> None:
|
||||
"""Afficher le panel UI si disponible."""
|
||||
self._creer_ui_si_besoin()
|
||||
if self._tk_root is not None:
|
||||
try:
|
||||
self._tk_root.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_change(self, callback) -> None:
|
||||
"""Enregistrer un listener appelé à chaque changement d'état."""
|
||||
with self._lock:
|
||||
self._listeners.append(callback)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gestion UI tkinter (lazy, fallback silencieux)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _creer_ui_si_besoin(self) -> None:
|
||||
"""Créer la fenêtre tkinter au premier usage (lazy)."""
|
||||
if not self._activer_ui:
|
||||
return
|
||||
if self._tk_root is not None:
|
||||
return
|
||||
if self._ui_disponible is False:
|
||||
return # Déjà testé et indisponible
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
except Exception as e:
|
||||
logger.debug(f"[ACTIVITY] tkinter indisponible : {e}")
|
||||
self._ui_disponible = False
|
||||
return
|
||||
|
||||
try:
|
||||
self._tk_root = tk.Toplevel() if _tk_root_existe() else tk.Tk()
|
||||
self._tk_root.title("Léa — Activité")
|
||||
self._tk_root.geometry("340x180+40+40")
|
||||
self._tk_root.attributes("-topmost", True)
|
||||
self._tk_root.resizable(False, False)
|
||||
self._tk_root.configure(bg="#1E1E1E")
|
||||
|
||||
titre = tk.Label(
|
||||
self._tk_root,
|
||||
text="Léa",
|
||||
font=("Segoe UI", 14, "bold"),
|
||||
fg="#FFFFFF",
|
||||
bg="#1E1E1E",
|
||||
)
|
||||
titre.pack(pady=(10, 2))
|
||||
|
||||
self._tk_labels["etat"] = tk.Label(
|
||||
self._tk_root,
|
||||
text="Prête",
|
||||
font=("Segoe UI", 11),
|
||||
fg="#808080",
|
||||
bg="#1E1E1E",
|
||||
)
|
||||
self._tk_labels["etat"].pack()
|
||||
|
||||
self._tk_labels["action"] = tk.Label(
|
||||
self._tk_root,
|
||||
text="",
|
||||
font=("Segoe UI", 10),
|
||||
fg="#FFFFFF",
|
||||
bg="#1E1E1E",
|
||||
wraplength=300,
|
||||
)
|
||||
self._tk_labels["action"].pack(pady=(8, 2))
|
||||
|
||||
self._tk_labels["progression"] = tk.Label(
|
||||
self._tk_root,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg="#B0B0B0",
|
||||
bg="#1E1E1E",
|
||||
)
|
||||
self._tk_labels["progression"].pack()
|
||||
|
||||
self._tk_labels["temps"] = tk.Label(
|
||||
self._tk_root,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg="#808080",
|
||||
bg="#1E1E1E",
|
||||
)
|
||||
self._tk_labels["temps"].pack(pady=(4, 0))
|
||||
|
||||
self._tk_labels["message"] = tk.Label(
|
||||
self._tk_root,
|
||||
text="",
|
||||
font=("Segoe UI", 9, "italic"),
|
||||
fg="#B0B0B0",
|
||||
bg="#1E1E1E",
|
||||
wraplength=300,
|
||||
)
|
||||
self._tk_labels["message"].pack(pady=(6, 10))
|
||||
|
||||
# Masquer par défaut : on affiche seulement pendant un workflow
|
||||
self._tk_root.withdraw()
|
||||
self._ui_disponible = True
|
||||
except Exception as e:
|
||||
logger.debug(f"[ACTIVITY] Impossible de créer l'UI : {e}")
|
||||
self._ui_disponible = False
|
||||
self._tk_root = None
|
||||
|
||||
def _rafraichir_ui(self) -> None:
|
||||
"""Mettre à jour les labels tkinter (safe si l'UI n'existe pas)."""
|
||||
if not self._activer_ui or self._ui_disponible is False:
|
||||
return
|
||||
self._creer_ui_si_besoin()
|
||||
if self._tk_root is None:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
snap = self.snapshot()
|
||||
|
||||
# Utiliser after(0) pour rester dans le thread UI tkinter
|
||||
def _update():
|
||||
try:
|
||||
self._tk_labels["etat"].config(
|
||||
text=snap.etat.libelle,
|
||||
fg=snap.etat.couleur,
|
||||
)
|
||||
if snap.action_courante:
|
||||
self._tk_labels["action"].config(text=snap.action_courante)
|
||||
else:
|
||||
self._tk_labels["action"].config(text="")
|
||||
|
||||
prog = snap.progression_texte()
|
||||
if prog and snap.nom_workflow:
|
||||
self._tk_labels["progression"].config(
|
||||
text=f"« {snap.nom_workflow} » — {prog}"
|
||||
)
|
||||
elif snap.nom_workflow:
|
||||
self._tk_labels["progression"].config(
|
||||
text=f"« {snap.nom_workflow} »"
|
||||
)
|
||||
else:
|
||||
self._tk_labels["progression"].config(text="")
|
||||
|
||||
if snap.debut_timestamp > 0:
|
||||
self._tk_labels["temps"].config(
|
||||
text=f"⏱ {snap.temps_ecoule_texte()}"
|
||||
)
|
||||
else:
|
||||
self._tk_labels["temps"].config(text="")
|
||||
|
||||
self._tk_labels["message"].config(text=snap.dernier_message)
|
||||
|
||||
# Afficher automatiquement si actif
|
||||
if snap.etat != EtatLea.INACTIVE:
|
||||
self._tk_root.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._tk_root.after(0, _update)
|
||||
except Exception:
|
||||
# Si le root a été détruit
|
||||
self._tk_root = None
|
||||
self._ui_disponible = False
|
||||
except Exception as e:
|
||||
logger.debug(f"[ACTIVITY] Erreur rafraîchissement UI : {e}")
|
||||
|
||||
def _notifier_changement(self) -> None:
|
||||
"""Notifier tous les listeners du changement d'état."""
|
||||
with self._lock:
|
||||
listeners = list(self._listeners)
|
||||
snap = self.snapshot()
|
||||
|
||||
for cb in listeners:
|
||||
try:
|
||||
cb(snap)
|
||||
except Exception as e:
|
||||
logger.debug(f"[ACTIVITY] Listener erreur : {e}")
|
||||
|
||||
|
||||
def _tk_root_existe() -> bool:
|
||||
"""Vérifier si un root tkinter existe déjà (pour créer un Toplevel)."""
|
||||
try:
|
||||
import tkinter as tk
|
||||
|
||||
default_root = getattr(tk, "_default_root", None)
|
||||
return default_root is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Singleton global (optionnel)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
_INSTANCE_GLOBALE: Optional[ActivityPanel] = None
|
||||
_LOCK_SINGLETON = threading.Lock()
|
||||
|
||||
|
||||
def get_activity_panel(activer_ui: bool = True) -> ActivityPanel:
|
||||
"""Obtenir l'instance globale du panel d'activité (lazy)."""
|
||||
global _INSTANCE_GLOBALE
|
||||
with _LOCK_SINGLETON:
|
||||
if _INSTANCE_GLOBALE is None:
|
||||
_INSTANCE_GLOBALE = ActivityPanel(activer_ui=activer_ui)
|
||||
return _INSTANCE_GLOBALE
|
||||
|
||||
|
||||
def reset_activity_panel() -> None:
|
||||
"""Réinitialiser le singleton (utile pour les tests)."""
|
||||
global _INSTANCE_GLOBALE
|
||||
with _LOCK_SINGLETON:
|
||||
if _INSTANCE_GLOBALE is not None:
|
||||
try:
|
||||
_INSTANCE_GLOBALE.masquer()
|
||||
except Exception:
|
||||
pass
|
||||
_INSTANCE_GLOBALE = None
|
||||
471
agent_v0/agent_v1/ui/capture_server.py
Normal file
471
agent_v0/agent_v1/ui/capture_server.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
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"} (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
|
||||
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")
|
||||
|
||||
|
||||
class CaptureHandler(BaseHTTPRequestHandler):
|
||||
"""Retourne un screenshot frais a chaque requete GET /capture.
|
||||
|
||||
Gere aussi les actions fichiers via POST /file-action.
|
||||
"""
|
||||
|
||||
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"})
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
|
||||
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)
|
||||
self._cors_headers()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_file_action(self):
|
||||
"""Execute une action fichier sur la machine Windows locale.
|
||||
|
||||
Body JSON attendu :
|
||||
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\..."}}
|
||||
"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
|
||||
action = data.get("action", "")
|
||||
params = data.get("params", {})
|
||||
|
||||
if not action:
|
||||
self._send_json(400, {"error": "Parametre 'action' requis"})
|
||||
return
|
||||
|
||||
handler = _FileActionHandlerLocal()
|
||||
result = handler.execute(action, params)
|
||||
code = 500 if "error" in result else 200
|
||||
self._send_json(code, result)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self._send_json(400, {"error": "JSON invalide"})
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur file-action : {e}")
|
||||
self._send_json(500, {"error": str(e)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_capture(self):
|
||||
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1] # ecran principal
|
||||
raw = sct.grab(monitor)
|
||||
|
||||
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
if BLUR_SENSITIVE:
|
||||
try:
|
||||
from ..vision.blur_sensitive import blur_sensitive_regions
|
||||
blur_sensitive_regions(img)
|
||||
except ImportError:
|
||||
logger.warning("Module blur_sensitive non disponible")
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=80)
|
||||
img_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
logger.info(f"Capture {img.width}x{img.height} en {elapsed_ms:.0f}ms")
|
||||
|
||||
self._send_json(200, {
|
||||
"image": img_b64,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"format": "jpeg",
|
||||
"source": "windows_live",
|
||||
"capture_ms": round(elapsed_ms),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur capture : {e}")
|
||||
self._send_json(500, {"error": str(e)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send_json(self, code: int, data: dict):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self._cors_headers()
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _cors_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Supprime les logs HTTP par defaut (trop verbeux)."""
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gestionnaire d'actions fichiers local (execute sur la machine Windows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Repertoires autorises sur Windows (securite anti-traversal)
|
||||
_WIN_ALLOWED_ROOTS = [
|
||||
"C:\\Users",
|
||||
"D:\\",
|
||||
"E:\\",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_win_path(path_str: str) -> str:
|
||||
"""Normalise un chemin Windows."""
|
||||
import ntpath
|
||||
return ntpath.normpath(path_str)
|
||||
|
||||
|
||||
def _is_safe_win_path(path_str: str) -> bool:
|
||||
"""Verifie qu'un chemin Windows est dans une zone autorisee."""
|
||||
if not path_str or not path_str.strip():
|
||||
return False
|
||||
norm = _normalize_win_path(path_str).upper()
|
||||
return any(norm.startswith(root.upper()) for root in _WIN_ALLOWED_ROOTS)
|
||||
|
||||
|
||||
class _FileActionHandlerLocal:
|
||||
"""Execute les operations fichiers sur la machine locale (Windows)."""
|
||||
|
||||
def execute(self, action_type: str, params: dict) -> dict:
|
||||
"""Dispatch vers la bonne methode selon le type d'action."""
|
||||
handlers = {
|
||||
"file_list_dir": self._list_dir,
|
||||
"file_create_dir": self._create_dir,
|
||||
"file_move": self._move_file,
|
||||
"file_copy": self._copy_file,
|
||||
"file_sort_by_ext": self._sort_by_extension,
|
||||
}
|
||||
handler = handlers.get(action_type)
|
||||
if not handler:
|
||||
return {"error": f"Action fichier inconnue : {action_type}"}
|
||||
try:
|
||||
return handler(params)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur action fichier '{action_type}' : {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _list_dir(self, params: dict) -> dict:
|
||||
"""Liste les fichiers d'un dossier."""
|
||||
import fnmatch as _fnmatch
|
||||
from pathlib import Path as _Path
|
||||
|
||||
path_str = params.get("path", "")
|
||||
pattern = params.get("pattern", "*")
|
||||
if not path_str:
|
||||
return {"error": "Parametre 'path' requis"}
|
||||
if not _is_safe_win_path(path_str):
|
||||
return {"error": f"Chemin non autorise : {path_str}"}
|
||||
|
||||
source = _Path(path_str)
|
||||
if not source.exists():
|
||||
return {"error": f"Dossier introuvable : {path_str}"}
|
||||
if not source.is_dir():
|
||||
return {"error": f"Pas un dossier : {path_str}"}
|
||||
|
||||
files = []
|
||||
extensions = {}
|
||||
for item in source.iterdir():
|
||||
if item.is_file() and _fnmatch.fnmatch(item.name, pattern):
|
||||
ext = item.suffix.lstrip(".").lower() or "sans_extension"
|
||||
files.append({
|
||||
"name": item.name,
|
||||
"extension": ext,
|
||||
"size": item.stat().st_size,
|
||||
"path": str(item),
|
||||
})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
|
||||
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
|
||||
|
||||
def _create_dir(self, params: dict) -> dict:
|
||||
"""Cree un dossier (parents inclus)."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
path_str = params.get("path", "")
|
||||
if not path_str:
|
||||
return {"error": "Parametre 'path' requis"}
|
||||
if not _is_safe_win_path(path_str):
|
||||
return {"error": f"Chemin non autorise : {path_str}"}
|
||||
|
||||
target = _Path(path_str)
|
||||
existed = target.exists()
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
|
||||
return {"created": not existed, "path": path_str, "already_existed": existed}
|
||||
|
||||
def _move_file(self, params: dict) -> dict:
|
||||
"""Deplace ou renomme un fichier."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
src = params.get("source", "")
|
||||
dst = params.get("destination", "")
|
||||
if not src or not dst:
|
||||
return {"error": "Parametres 'source' et 'destination' requis"}
|
||||
if not _is_safe_win_path(src):
|
||||
return {"error": f"Source non autorisee : {src}"}
|
||||
if not _is_safe_win_path(dst):
|
||||
return {"error": f"Destination non autorisee : {dst}"}
|
||||
|
||||
if not _Path(src).exists():
|
||||
return {"error": f"Fichier source introuvable : {src}"}
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.move(src, dst)
|
||||
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
|
||||
return {"moved": True, "source": src, "destination": dst}
|
||||
|
||||
def _copy_file(self, params: dict) -> dict:
|
||||
"""Copie un fichier."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
src = params.get("source", "")
|
||||
dst = params.get("destination", "")
|
||||
if not src or not dst:
|
||||
return {"error": "Parametres 'source' et 'destination' requis"}
|
||||
if not _is_safe_win_path(src):
|
||||
return {"error": f"Source non autorisee : {src}"}
|
||||
if not _is_safe_win_path(dst):
|
||||
return {"error": f"Destination non autorisee : {dst}"}
|
||||
|
||||
source = _Path(src)
|
||||
if not source.exists():
|
||||
return {"error": f"Fichier source introuvable : {src}"}
|
||||
|
||||
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
_shutil.copytree(src, dst)
|
||||
else:
|
||||
_shutil.copy2(src, dst)
|
||||
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
|
||||
return {"copied": True, "source": src, "destination": dst}
|
||||
|
||||
def _sort_by_extension(self, params: dict) -> dict:
|
||||
"""Classe les fichiers par extension dans des sous-dossiers."""
|
||||
import shutil as _shutil
|
||||
from pathlib import Path as _Path
|
||||
|
||||
source_dir_str = params.get("source_dir", "")
|
||||
create_subdirs = params.get("create_subdirs", True)
|
||||
|
||||
if not source_dir_str:
|
||||
return {"error": "Parametre 'source_dir' requis"}
|
||||
if not _is_safe_win_path(source_dir_str):
|
||||
return {"error": f"Chemin non autorise : {source_dir_str}"}
|
||||
|
||||
source = _Path(source_dir_str)
|
||||
if not source.exists():
|
||||
return {"error": f"Dossier introuvable : {source_dir_str}"}
|
||||
if not source.is_dir():
|
||||
return {"error": f"Pas un dossier : {source_dir_str}"}
|
||||
|
||||
moved = []
|
||||
extensions = {}
|
||||
|
||||
for f in source.iterdir():
|
||||
if f.is_file():
|
||||
ext = f.suffix.lstrip(".").lower() or "sans_extension"
|
||||
target_dir = source / ext
|
||||
|
||||
if create_subdirs:
|
||||
target_dir.mkdir(exist_ok=True)
|
||||
elif not target_dir.exists():
|
||||
continue
|
||||
|
||||
dest = target_dir / f.name
|
||||
# Eviter ecrasement
|
||||
if dest.exists():
|
||||
base = f.stem
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = target_dir / f"{base}_{counter}{f.suffix}"
|
||||
counter += 1
|
||||
|
||||
_shutil.move(str(f), str(dest))
|
||||
moved.append({"file": f.name, "to": ext, "destination": str(dest)})
|
||||
extensions[ext] = extensions.get(ext, 0) + 1
|
||||
|
||||
logger.info(
|
||||
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
|
||||
)
|
||||
return {
|
||||
"moved": moved,
|
||||
"count": len(moved),
|
||||
"extensions": extensions,
|
||||
"source_dir": source_dir_str,
|
||||
}
|
||||
|
||||
|
||||
class CaptureServer:
|
||||
"""Serveur de capture d'ecran en temps reel (thread daemon)."""
|
||||
|
||||
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.
|
||||
|
||||
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((self._bind, self._port), CaptureHandler)
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.serve_forever, daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
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}")
|
||||
|
||||
def stop(self):
|
||||
"""Arrete le serveur proprement."""
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
logger.info("Capture server arrete")
|
||||
1148
agent_v0/agent_v1/ui/chat_window.py
Normal file
1148
agent_v0/agent_v1/ui/chat_window.py
Normal file
File diff suppressed because it is too large
Load Diff
655
agent_v0/agent_v1/ui/messages.py
Normal file
655
agent_v0/agent_v1/ui/messages.py
Normal file
@@ -0,0 +1,655 @@
|
||||
# agent_v1/ui/messages.py
|
||||
"""
|
||||
Formatage des messages utilisateur pour Léa.
|
||||
|
||||
Convertit les codes d'erreur techniques (`target_not_found`, `no_screen_change`...)
|
||||
en phrases en français naturel, orientées action, adaptées à un utilisateur non
|
||||
technique (secrétaire médicale, TIM).
|
||||
|
||||
Trois niveaux de sévérité sont définis :
|
||||
- INFO — Léa fait son travail normalement
|
||||
- ATTENTION — Quelque chose de léger (ralentissement, retry)
|
||||
- BLOCAGE — Léa a besoin d'aide, elle rend la main
|
||||
|
||||
Le module est 100% pur (pas d'I/O, pas d'UI) : testable sans mocks lourds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Accès paresseux au DomainContext
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# On importe le module à l'appel pour éviter toute dépendance circulaire
|
||||
# avec `agent_v0.server_v1.domain_context` (qui ne doit pas importer l'UI).
|
||||
# Si l'import échoue (contexte client sans server_v1), on retombe sur None
|
||||
# et les formatters gardent leur comportement générique historique.
|
||||
|
||||
|
||||
def _get_domain_ctx(domain_id: Optional[str]):
|
||||
"""Récupérer un DomainContext si possible, sinon None (fallback)."""
|
||||
if not domain_id:
|
||||
return None
|
||||
try:
|
||||
from agent_v0.server_v1.domain_context import get_domain_context # lazy
|
||||
return get_domain_context(domain_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _friendly_target(description: str, domain_id: Optional[str] = None) -> str:
|
||||
"""Transformer une description technique en langage métier si possible.
|
||||
|
||||
Ex (tim_codage) : "DP" → "diagnostic principal"
|
||||
Ex (comptabilite) : "TVA" → "montant de TVA"
|
||||
Retombe sur la description nettoyée si aucun domaine ne matche.
|
||||
"""
|
||||
base = _nettoyer_description_cible(description)
|
||||
ctx = _get_domain_ctx(domain_id)
|
||||
if ctx is None or not base:
|
||||
return base
|
||||
try:
|
||||
return ctx._apply_synonyms(base)
|
||||
except Exception:
|
||||
return base
|
||||
|
||||
|
||||
class NiveauMessage(Enum):
|
||||
"""Niveaux hiérarchiques des messages affichés à l'utilisateur."""
|
||||
|
||||
INFO = "info" # Fond vert clair, disparaît tout seul, 3-5s
|
||||
ATTENTION = "attention" # Fond orange clair, disparaît tout seul, 7s
|
||||
BLOCAGE = "blocage" # Fond rouge clair, reste affiché, 15s+
|
||||
|
||||
|
||||
# Durée d'affichage par défaut (secondes), par niveau
|
||||
DUREE_PAR_NIVEAU: dict[NiveauMessage, int] = {
|
||||
NiveauMessage.INFO: 4,
|
||||
NiveauMessage.ATTENTION: 7,
|
||||
NiveauMessage.BLOCAGE: 15,
|
||||
}
|
||||
|
||||
# Icône textuelle par niveau (compatible plyer/Windows/Linux)
|
||||
ICONE_PAR_NIVEAU: dict[NiveauMessage, str] = {
|
||||
NiveauMessage.INFO: "i",
|
||||
NiveauMessage.ATTENTION: "!",
|
||||
NiveauMessage.BLOCAGE: "?",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageUtilisateur:
|
||||
"""Un message prêt à être affiché à l'utilisateur.
|
||||
|
||||
Attributes:
|
||||
niveau: Hiérarchie (info/attention/blocage)
|
||||
titre: Titre court de la notification (≤60 caractères)
|
||||
corps: Corps du message en français naturel
|
||||
duree_s: Durée d'affichage recommandée (secondes)
|
||||
persistent: Si True, l'utilisateur doit fermer manuellement
|
||||
"""
|
||||
|
||||
niveau: NiveauMessage
|
||||
titre: str
|
||||
corps: str
|
||||
duree_s: int
|
||||
persistent: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Sérialiser le message (utile pour les tests et le logging)."""
|
||||
return {
|
||||
"niveau": self.niveau.value,
|
||||
"titre": self.titre,
|
||||
"corps": self.corps,
|
||||
"duree_s": self.duree_s,
|
||||
"persistent": self.persistent,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers d'extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _extraire_nom_application(titre_fenetre: str) -> str:
|
||||
"""Extraire le nom de l'application à partir d'un titre de fenêtre.
|
||||
|
||||
Les titres Windows suivent généralement le format :
|
||||
"Document.txt – Bloc-notes"
|
||||
"Ma Page - Google Chrome"
|
||||
"Sans titre — Paint"
|
||||
|
||||
On retourne la partie après le dernier séparateur, ou le titre entier.
|
||||
"""
|
||||
if not titre_fenetre:
|
||||
return ""
|
||||
titre = titre_fenetre.strip()
|
||||
# Chercher le dernier séparateur parmi " – ", " — ", " - "
|
||||
for sep in (" – ", " — ", " - "):
|
||||
if sep in titre:
|
||||
return titre.rsplit(sep, 1)[-1].strip()
|
||||
return titre
|
||||
|
||||
|
||||
def _nettoyer_description_cible(description: str) -> str:
|
||||
"""Nettoyer la description technique d'une cible pour l'afficher.
|
||||
|
||||
Supprime les caractères techniques (guillemets inutiles, ':').
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
desc = description.strip()
|
||||
# Retirer les guillemets encapsulants
|
||||
desc = desc.strip("'\"`")
|
||||
# Limiter la longueur
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
return desc
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Formattage des messages techniques → humains
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def formatter_cible_non_trouvee(
|
||||
description_cible: str,
|
||||
titre_fenetre: Optional[str] = None,
|
||||
domain_id: Optional[str] = None,
|
||||
params: Optional[Mapping[str, Any]] = None,
|
||||
) -> MessageUtilisateur:
|
||||
"""Message quand Léa ne trouve pas un élément à cliquer.
|
||||
|
||||
Si un domaine métier est fourni, la description de la cible est
|
||||
transformée en langage métier via le DomainContext :
|
||||
- tim_codage + "DP" → "diagnostic principal"
|
||||
- comptabilite + "TVA" → "montant de TVA"
|
||||
|
||||
Exemple avant :
|
||||
target_not_found: 'bonjour' dans *bonjour, – Bloc-notes
|
||||
Exemple après :
|
||||
Léa a besoin d'aide
|
||||
Je ne trouve pas "bonjour" dans le Bloc-notes. Peux-tu cliquer
|
||||
dessus toi-même ? Je reprends ensuite.
|
||||
|
||||
Args:
|
||||
description_cible: Description brute de la cible.
|
||||
titre_fenetre: Titre de la fenêtre active (pour extraire l'app).
|
||||
domain_id: Domaine métier pour enrichir la sortie (optionnel).
|
||||
params: Paramètres du workflow (nom_patient, num_facture...)
|
||||
utilisés par les templates de clarification métier.
|
||||
"""
|
||||
cible = _friendly_target(description_cible, domain_id) or "l'élément"
|
||||
app = _extraire_nom_application(titre_fenetre or "")
|
||||
|
||||
# Si un domaine et un template de clarification existent, préférer la
|
||||
# question métier (plus pertinente que le message générique).
|
||||
ctx = _get_domain_ctx(domain_id)
|
||||
if ctx is not None and ctx.clarification_templates:
|
||||
try:
|
||||
corps = ctx.pose_clarification_question(
|
||||
{
|
||||
"blocked_on": "target_not_found",
|
||||
"target": description_cible or "",
|
||||
"app": app,
|
||||
"params": dict(params or {}),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
corps = ""
|
||||
if corps:
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa a besoin d'aide",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
if app:
|
||||
corps = (
|
||||
f"Je ne trouve pas « {cible} » dans {app}. "
|
||||
f"Peux-tu cliquer dessus toi-même ? Je reprends ensuite."
|
||||
)
|
||||
else:
|
||||
corps = (
|
||||
f"Je ne trouve pas « {cible} » à l'écran. "
|
||||
f"Peux-tu le faire toi-même ? Je reprends ensuite."
|
||||
)
|
||||
|
||||
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_fenetre_incorrecte(
|
||||
titre_actuel: str,
|
||||
titre_attendu: str,
|
||||
) -> MessageUtilisateur:
|
||||
"""Message quand la fenêtre active n'est pas celle attendue.
|
||||
|
||||
Exemple avant :
|
||||
Fenêtre incorrecte: 'Program Manager' (attendu: 'Lea : Explorateur de fichiers')
|
||||
Exemple après :
|
||||
Léa attend une fenêtre
|
||||
J'attends « Explorateur de fichiers » mais c'est « Program Manager »
|
||||
qui est affiché. Peux-tu ouvrir la bonne fenêtre ?
|
||||
"""
|
||||
app_actuelle = _extraire_nom_application(titre_actuel) or "une autre fenêtre"
|
||||
app_attendue = _extraire_nom_application(titre_attendu) or titre_attendu
|
||||
|
||||
corps = (
|
||||
f"J'attends « {app_attendue} » mais c'est « {app_actuelle} » "
|
||||
f"qui est affiché. Peux-tu ouvrir la bonne fenêtre ?"
|
||||
)
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa attend une fenêtre",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
|
||||
"""Message quand l'action n'a pas eu d'effet visible.
|
||||
|
||||
Exemple avant :
|
||||
Ecran inchange apres l'action
|
||||
Exemple après :
|
||||
Léa vérifie
|
||||
Mon clic n'a pas eu l'air de marcher. Je vais réessayer ou te
|
||||
rendre la main si ça ne passe pas.
|
||||
"""
|
||||
actions_fr = {
|
||||
"click": "Mon clic",
|
||||
"type": "Ma saisie",
|
||||
"key_combo": "Mon raccourci clavier",
|
||||
"scroll": "Mon défilement",
|
||||
}
|
||||
quoi = actions_fr.get(action_type, "Mon action")
|
||||
|
||||
corps = (
|
||||
f"{quoi} n'a pas eu l'air de marcher. Je vais réessayer, "
|
||||
f"ou te rendre la main si ça ne passe pas."
|
||||
)
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa vérifie",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Rassurant : on dit qu'on va réessayer automatiquement.
|
||||
"""
|
||||
corps = (
|
||||
"J'ai perdu le lien avec le serveur. Je retente automatiquement, "
|
||||
"pas besoin d'intervenir."
|
||||
)
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa est déconnectée",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
|
||||
def formatter_connexion_retablie() -> MessageUtilisateur:
|
||||
"""Message quand la connexion serveur est rétablie."""
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.INFO,
|
||||
titre="Léa",
|
||||
corps="C'est bon, la connexion est revenue. Je continue.",
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.INFO],
|
||||
)
|
||||
|
||||
|
||||
def formatter_debut_workflow(nom_workflow: str, nb_etapes: int = 0) -> MessageUtilisateur:
|
||||
"""Message au démarrage d'un workflow de replay."""
|
||||
if nb_etapes > 0:
|
||||
corps = (
|
||||
f"Je démarre « {nom_workflow} » ({nb_etapes} étapes). "
|
||||
f"Je t'indique mon avancement."
|
||||
)
|
||||
else:
|
||||
corps = f"Je démarre « {nom_workflow} ». Je t'indique mon avancement."
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.INFO,
|
||||
titre="Léa démarre",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.INFO],
|
||||
)
|
||||
|
||||
|
||||
def formatter_etape_workflow(
|
||||
etape_actuelle: int,
|
||||
nb_etapes: int,
|
||||
description: str = "",
|
||||
) -> MessageUtilisateur:
|
||||
"""Message pour la progression d'une étape."""
|
||||
if description:
|
||||
desc = _nettoyer_description_cible(description)
|
||||
corps = f"Étape {etape_actuelle}/{nb_etapes} — {desc}"
|
||||
else:
|
||||
corps = f"Étape {etape_actuelle}/{nb_etapes}"
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.INFO,
|
||||
titre="Léa avance",
|
||||
corps=corps,
|
||||
duree_s=3,
|
||||
)
|
||||
|
||||
|
||||
def formatter_retry(action_type: str = "", tentative: int = 2) -> MessageUtilisateur:
|
||||
"""Message quand Léa retente une action."""
|
||||
corps = (
|
||||
f"Je retente (tentative {tentative}). Ça arrive parfois, "
|
||||
f"l'écran était peut-être en cours de chargement."
|
||||
)
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa retente",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
|
||||
def formatter_ralentissement() -> MessageUtilisateur:
|
||||
"""Message quand Léa prend plus de temps que prévu."""
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa prend son temps",
|
||||
corps="Je vais plus lentement que prévu. L'écran met du temps à répondre.",
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
|
||||
def formatter_fin_workflow(
|
||||
succes: bool,
|
||||
nom_workflow: str = "",
|
||||
nb_etapes: int = 0,
|
||||
duree_s: float = 0.0,
|
||||
domain_id: Optional[str] = None,
|
||||
items_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
params: Optional[Mapping[str, Any]] = None,
|
||||
) -> MessageUtilisateur:
|
||||
"""Message à la fin d'un workflow.
|
||||
|
||||
Si un domaine métier est fourni (et qu'il expose des summary_templates),
|
||||
on utilise `DomainContext.describe_workflow_outcome` pour formuler un
|
||||
rapport en langage métier (ex: "J'ai codé 14 dossiers sur 15").
|
||||
|
||||
Args:
|
||||
succes: True si l'ensemble du workflow a réussi.
|
||||
nom_workflow: Nom du workflow.
|
||||
nb_etapes: Nombre d'étapes techniques (pour fallback générique).
|
||||
duree_s: Durée totale en secondes.
|
||||
domain_id: Domaine métier (optionnel).
|
||||
items_count: Nombre d'items métier traités (ex: 15 dossiers).
|
||||
failed_count: Nombre d'items en échec.
|
||||
params: Infos supplémentaires passées aux templates.
|
||||
"""
|
||||
ctx = _get_domain_ctx(domain_id)
|
||||
if ctx is not None and ctx.summary_templates:
|
||||
try:
|
||||
corps = ctx.describe_workflow_outcome(
|
||||
workflow_name=nom_workflow,
|
||||
success=succes,
|
||||
items_count=items_count or max(1, nb_etapes),
|
||||
failed_count=failed_count,
|
||||
elapsed_s=duree_s,
|
||||
extra=dict(params or {}),
|
||||
)
|
||||
except Exception:
|
||||
corps = ""
|
||||
if corps:
|
||||
if succes and failed_count == 0:
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.INFO,
|
||||
titre="Léa a terminé",
|
||||
corps=corps,
|
||||
duree_s=6,
|
||||
)
|
||||
if succes and failed_count > 0:
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa a terminé partiellement",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa s'arrête",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
if succes:
|
||||
if nom_workflow and nb_etapes > 0:
|
||||
corps = (
|
||||
f"C'est fait ! « {nom_workflow} » est terminé "
|
||||
f"({nb_etapes} étapes en {int(duree_s)}s)."
|
||||
)
|
||||
else:
|
||||
corps = "C'est fait ! Tout s'est bien passé."
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.INFO,
|
||||
titre="Léa a terminé",
|
||||
corps=corps,
|
||||
duree_s=6,
|
||||
)
|
||||
else:
|
||||
corps = (
|
||||
"Je n'ai pas pu terminer. Je te rends la main, "
|
||||
"tu peux continuer à partir de là où je me suis arrêtée."
|
||||
)
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa s'arrête",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
def formatter_erreur_generique(
|
||||
message_technique: str,
|
||||
domain_id: Optional[str] = None,
|
||||
params: Optional[Mapping[str, Any]] = None,
|
||||
) -> MessageUtilisateur:
|
||||
"""Formater un message d'erreur technique non catégorisé.
|
||||
|
||||
On essaie de détecter les motifs connus dans le message technique pour
|
||||
le router vers le bon formatter spécialisé, sinon on emballe le message.
|
||||
Si `domain_id` est fourni, il est propagé aux formatters spécialisés
|
||||
pour produire un message en langage métier.
|
||||
"""
|
||||
if not message_technique:
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa",
|
||||
corps="J'ai rencontré un petit souci. Je continue.",
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
msg_lower = message_technique.lower()
|
||||
|
||||
# target_not_found[:...]
|
||||
if "target_not_found" in msg_lower:
|
||||
# Essayer d'extraire la description après le ':'
|
||||
match = re.match(r"target_not_found[:\s]*(.*)", message_technique, re.IGNORECASE)
|
||||
desc = match.group(1).strip() if match else ""
|
||||
return formatter_cible_non_trouvee(desc, domain_id=domain_id, params=params)
|
||||
|
||||
# Fenêtre incorrecte: 'X' (attendu: 'Y')
|
||||
if "fenêtre incorrecte" in msg_lower or "fenetre incorrecte" in msg_lower:
|
||||
# Extraire actuel et attendu
|
||||
m_actuel = re.search(r"[:,]\s*['\"]([^'\"]+)['\"]", message_technique)
|
||||
m_attendu = re.search(r"attendu[:\s]*['\"]([^'\"]+)['\"]", message_technique)
|
||||
actuel = m_actuel.group(1) if m_actuel else ""
|
||||
attendu = m_attendu.group(1) if m_attendu else ""
|
||||
return formatter_fenetre_incorrecte(actuel, attendu)
|
||||
|
||||
# Ecran inchangé
|
||||
if "inchang" in msg_lower or "no_screen_change" in msg_lower:
|
||||
return formatter_ecran_inchange()
|
||||
|
||||
# Policy abort / supervise
|
||||
if "policy_abort" in msg_lower or "visual_resolve_failed" in msg_lower:
|
||||
return formatter_cible_non_trouvee(
|
||||
message_technique, domain_id=domain_id, params=params
|
||||
)
|
||||
|
||||
# Fallback : message technique tronqué
|
||||
msg_tronque = message_technique.strip()
|
||||
if len(msg_tronque) > 120:
|
||||
msg_tronque = msg_tronque[:117] + "..."
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.ATTENTION,
|
||||
titre="Léa",
|
||||
corps=f"J'ai rencontré un souci : {msg_tronque}",
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.ATTENTION],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Détection fenêtre Léa (utilisé par l'executor pour ignorer sa propre UI)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
# Motifs qui identifient une fenêtre appartenant à Léa (l'agent lui-même).
|
||||
# On utilise des regex avec \b pour éviter les faux positifs sur des noms
|
||||
# contenant "lea" (ex: "cléa.txt", "leapfrog", "replay").
|
||||
_MOTIFS_FENETRE_LEA_REGEX = (
|
||||
r"\bléa\b",
|
||||
r"\blea\b(?!p)", # "lea" mot entier, pas "leapfrog"
|
||||
r"lea\s*[—–\-:]", # "Lea —", "Lea -", "Lea :"
|
||||
r"léa\s*[—–\-:]",
|
||||
r"\bassistante ia\b",
|
||||
r"\bléa ia\b",
|
||||
r"\blea ia\b",
|
||||
)
|
||||
|
||||
|
||||
def est_fenetre_lea(titre_fenetre: str) -> bool:
|
||||
"""Détecter si un titre de fenêtre appartient à l'agent Léa lui-même.
|
||||
|
||||
Utilisé pour éviter que Léa ne se considère comme une fenêtre intrusive
|
||||
dans ses propres pré-vérifications.
|
||||
|
||||
Utilise des regex avec des word boundaries pour éviter les faux positifs
|
||||
sur des noms de fichiers contenant "lea" (ex: "cléa.txt", "replay.log").
|
||||
"""
|
||||
if not titre_fenetre:
|
||||
return False
|
||||
titre_lower = titre_fenetre.lower().strip()
|
||||
return any(re.search(motif, titre_lower) for motif in _MOTIFS_FENETRE_LEA_REGEX)
|
||||
|
||||
|
||||
# Fenêtres parasites Windows à ignorer dans les pré-vérifications.
|
||||
# Ce ne sont pas des fenêtres applicatives — c'est du bruit système
|
||||
# qui prend le focus de manière imprévisible.
|
||||
_FENETRES_BRUIT_SYSTEME = (
|
||||
"fenêtre de dépassement de capacité",
|
||||
"overflow", # version anglaise systray
|
||||
"program manager",
|
||||
"barre des tâches",
|
||||
"task bar",
|
||||
"cortana",
|
||||
"action center",
|
||||
"centre de notifications",
|
||||
)
|
||||
|
||||
|
||||
def est_fenetre_bruit(titre_fenetre: str) -> bool:
|
||||
"""Détecter si un titre de fenêtre est du bruit système Windows.
|
||||
|
||||
Ces fenêtres prennent le focus de manière imprévisible (systray overflow,
|
||||
taskbar, Program Manager) et ne sont jamais la cible d'une action utilisateur.
|
||||
"""
|
||||
if not titre_fenetre:
|
||||
return True # pas de titre = bruit
|
||||
titre_lower = titre_fenetre.lower().strip()
|
||||
if titre_lower == "unknown_window":
|
||||
return True
|
||||
return any(p in titre_lower for p in _FENETRES_BRUIT_SYSTEME)
|
||||
|
||||
|
||||
# Conservé pour rétro-compatibilité avec le code qui listait MOTIFS_FENETRE_LEA
|
||||
MOTIFS_FENETRE_LEA = (
|
||||
"léa",
|
||||
"lea —",
|
||||
"léa —",
|
||||
"lea -",
|
||||
"léa -",
|
||||
"lea assistante",
|
||||
"léa assistante",
|
||||
"lea : ",
|
||||
"léa : ",
|
||||
"assistante ia",
|
||||
)
|
||||
342
agent_v0/agent_v1/ui/notifications.py
Normal file
342
agent_v0/agent_v1/ui/notifications.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# agent_v1/ui/notifications.py
|
||||
"""
|
||||
Gestionnaire de notifications toast natives (Windows/Linux/macOS).
|
||||
Utilise plyer pour les notifications système, sans dépendance PyQt5.
|
||||
|
||||
Remplace les dialogues Qt par des toasts non-bloquants.
|
||||
Thread-safe avec rate limiting (1 notification / 2 secondes max).
|
||||
|
||||
Les messages utilisateur sont formatés via `agent_v1.ui.messages` qui convertit
|
||||
les codes techniques (target_not_found, etc.) en français naturel.
|
||||
|
||||
Hiérarchie des notifications (cf. messages.NiveauMessage) :
|
||||
- INFO : auto-dismiss en ~4s, rate-limité classique
|
||||
- ATTENTION : auto-dismiss en ~7s, rate-limité classique
|
||||
- BLOCAGE : persistant (15s+), bypass du rate limit
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .messages import (
|
||||
MessageUtilisateur,
|
||||
NiveauMessage,
|
||||
formatter_cible_non_trouvee,
|
||||
formatter_connexion_perdue,
|
||||
formatter_connexion_retablie,
|
||||
formatter_debut_workflow,
|
||||
formatter_ecran_inchange,
|
||||
formatter_erreur_generique,
|
||||
formatter_etape_workflow,
|
||||
formatter_fenetre_incorrecte,
|
||||
formatter_fin_workflow,
|
||||
formatter_mode_apprentissage,
|
||||
formatter_ralentissement,
|
||||
formatter_retry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import conditionnel de plyer — fallback silencieux si absent
|
||||
try:
|
||||
from plyer import notification as _plyer_notification
|
||||
_PLYER_AVAILABLE = True
|
||||
except ImportError:
|
||||
_plyer_notification = None
|
||||
_PLYER_AVAILABLE = False
|
||||
logger.warning(
|
||||
"plyer non installé — les notifications toast sont désactivées. "
|
||||
"Installer avec : pip install plyer"
|
||||
)
|
||||
|
||||
# Nom de l'application affiché dans les toasts
|
||||
APP_NAME = "Léa"
|
||||
|
||||
# Intervalle minimum entre deux notifications (secondes)
|
||||
RATE_LIMIT_SECONDS = 2
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""
|
||||
Gestionnaire centralisé de notifications toast.
|
||||
|
||||
Thread-safe : peut être appelé depuis n'importe quel thread.
|
||||
Rate limiting : une seule notification toutes les 2 secondes,
|
||||
les notifications excédentaires sont ignorées (pas de file d'attente
|
||||
pour éviter un flood différé).
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: Optional[str] = None):
|
||||
"""
|
||||
Initialise le gestionnaire.
|
||||
|
||||
Args:
|
||||
icon_path: Chemin vers l'icône (.ico/.png) pour les toasts.
|
||||
None = icône par défaut du système.
|
||||
"""
|
||||
self._icon_path = icon_path
|
||||
self._lock = threading.Lock()
|
||||
self._last_notification_time: float = 0.0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Méthode générique
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def notify(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
timeout: int = 5,
|
||||
bypass_rate_limit: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Affiche une notification toast.
|
||||
|
||||
Args:
|
||||
title: Titre de la notification.
|
||||
message: Corps du message.
|
||||
timeout: Durée d'affichage en secondes.
|
||||
bypass_rate_limit: Si True, ignore le rate limit (pour les blocages
|
||||
importants qui ne doivent pas être écrasés).
|
||||
|
||||
Returns:
|
||||
True si la notification a été envoyée, False sinon
|
||||
(plyer absent ou rate limit atteint).
|
||||
"""
|
||||
if not _PLYER_AVAILABLE:
|
||||
logger.debug("Notification ignorée (plyer absent) : %s", title)
|
||||
return False
|
||||
|
||||
if not bypass_rate_limit:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_notification_time
|
||||
if elapsed < RATE_LIMIT_SECONDS:
|
||||
logger.debug(
|
||||
"Notification ignorée (rate limit, %.1fs restantes) : %s",
|
||||
RATE_LIMIT_SECONDS - elapsed,
|
||||
title,
|
||||
)
|
||||
return False
|
||||
self._last_notification_time = now
|
||||
else:
|
||||
with self._lock:
|
||||
self._last_notification_time = time.monotonic()
|
||||
|
||||
# Envoi dans un thread dédié pour ne jamais bloquer l'appelant
|
||||
thread = threading.Thread(
|
||||
target=self._send,
|
||||
args=(title, message, timeout),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def notify_message(self, msg: MessageUtilisateur) -> bool:
|
||||
"""Envoyer un MessageUtilisateur structuré (niveau, titre, corps).
|
||||
|
||||
Les messages BLOCAGE bypass le rate limit pour garantir que
|
||||
l'utilisateur voit qu'on a besoin de lui.
|
||||
"""
|
||||
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,
|
||||
timeout=msg.duree_s,
|
||||
bypass_rate_limit=bypass,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _log_message(msg: MessageUtilisateur) -> None:
|
||||
"""Logger un message utilisateur avec le niveau approprié.
|
||||
|
||||
Les logs agents sont plus lisibles quand on route info → INFO,
|
||||
attention → WARNING, blocage → ERROR, avec un préfixe [LEA].
|
||||
"""
|
||||
prefix = f"[LEA] {msg.titre}: {msg.corps}"
|
||||
if msg.niveau == NiveauMessage.INFO:
|
||||
logger.info(prefix)
|
||||
elif msg.niveau == NiveauMessage.ATTENTION:
|
||||
logger.warning(prefix)
|
||||
elif msg.niveau == NiveauMessage.BLOCAGE:
|
||||
logger.error(prefix)
|
||||
else:
|
||||
logger.info(prefix)
|
||||
|
||||
def _send(self, title: str, message: str, timeout: int) -> None:
|
||||
"""Envoi effectif de la notification (exécuté dans un thread dédié)."""
|
||||
try:
|
||||
# Windows limite les balloon tips à 256 caractères
|
||||
if len(title) > 63:
|
||||
title = title[:60] + "..."
|
||||
if len(message) > 200:
|
||||
message = message[:197] + "..."
|
||||
_plyer_notification.notify(
|
||||
title=title,
|
||||
message=message,
|
||||
app_name=APP_NAME,
|
||||
app_icon=self._icon_path,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Erreur lors de l'envoi de la notification toast")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Méthodes métier
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def greet(self) -> bool:
|
||||
"""Notification de bienvenue au démarrage.
|
||||
|
||||
Inclut la divulgation IA obligatoire (Article 50, Règlement IA).
|
||||
"""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message=(
|
||||
"Bonjour ! Léa est prête. "
|
||||
"Je suis une assistante basée sur l'intelligence artificielle."
|
||||
),
|
||||
timeout=7,
|
||||
)
|
||||
|
||||
def session_started(self, workflow_name: str) -> bool:
|
||||
"""Notification de début de session."""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message="C'est parti ! Je regarde et je mémorise.",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def session_ended(self, action_count: int) -> bool:
|
||||
"""Notification de fin de session avec le nombre d'actions."""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message=f"C'est noté ! J'ai bien compris les {action_count} étapes.",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def workflow_learned(self, name: str) -> bool:
|
||||
"""Notification quand une tâche a été apprise."""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message=f"J'ai appris '{name}' ! Je peux la refaire quand vous voulez.",
|
||||
timeout=7,
|
||||
)
|
||||
|
||||
def replay_started(self, workflow_name: str, step_count: int) -> bool:
|
||||
"""Notification de début de replay.
|
||||
|
||||
Transparence obligatoire en mode autonome (Article 50, Règlement IA) :
|
||||
l'utilisateur doit savoir qu'un système d'IA agit sur son écran.
|
||||
"""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message=(
|
||||
f"Le système d'intelligence artificielle exécute la tâche "
|
||||
f"'{workflow_name}' sur votre écran."
|
||||
),
|
||||
timeout=7,
|
||||
)
|
||||
|
||||
def replay_step(self, current: int, total: int, description: str) -> bool:
|
||||
"""Notification de progression d'une étape de replay."""
|
||||
return self.notify(
|
||||
title=APP_NAME,
|
||||
message=f"Étape {current}/{total} : {description}",
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
def replay_target_not_found(
|
||||
self,
|
||||
target_description: str,
|
||||
window_title: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Notification quand un élément n'est pas trouvé pendant le replay.
|
||||
|
||||
Le replay est mis en pause et attend une intervention humaine.
|
||||
Utilise `messages.formatter_cible_non_trouvee` pour un message en
|
||||
français naturel.
|
||||
"""
|
||||
msg = formatter_cible_non_trouvee(target_description, window_title)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_wrong_window(self, current_title: str, expected_title: str) -> bool:
|
||||
"""Notification quand la fenêtre active n'est pas celle attendue."""
|
||||
msg = formatter_fenetre_incorrecte(current_title, expected_title)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_no_screen_change(self, action_type: str = "") -> bool:
|
||||
"""Notification quand une action n'a pas eu d'effet visible."""
|
||||
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)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_slow(self) -> bool:
|
||||
"""Notification quand Léa va plus lentement que prévu."""
|
||||
msg = formatter_ralentissement()
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_finished(
|
||||
self,
|
||||
success: bool,
|
||||
workflow_name: str,
|
||||
step_count: int = 0,
|
||||
duration_s: float = 0.0,
|
||||
) -> bool:
|
||||
"""Notification de fin de replay (succès ou échec)."""
|
||||
msg = formatter_fin_workflow(success, workflow_name, step_count, duration_s)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_workflow_started(self, workflow_name: str, step_count: int = 0) -> bool:
|
||||
"""Notification de début de workflow (remplace `replay_started`)."""
|
||||
msg = formatter_debut_workflow(workflow_name, step_count)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_step_progress(
|
||||
self,
|
||||
current: int,
|
||||
total: int,
|
||||
description: str = "",
|
||||
) -> bool:
|
||||
"""Notification de progression d'une étape (niveau INFO)."""
|
||||
msg = formatter_etape_workflow(current, total, description)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def connection_changed(self, connected: bool, server_host: str = "") -> bool:
|
||||
"""Notification de changement d'état de la connexion serveur."""
|
||||
if connected:
|
||||
msg = formatter_connexion_retablie()
|
||||
else:
|
||||
msg = formatter_connexion_perdue(server_host)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def error(self, message: str) -> bool:
|
||||
"""Notification d'erreur générique.
|
||||
|
||||
Essaie d'abord de détecter un motif technique connu et de formater
|
||||
correctement, sinon fallback sur un message générique aidant.
|
||||
"""
|
||||
msg = formatter_erreur_generique(message)
|
||||
return self.notify_message(msg)
|
||||
190
agent_v0/agent_v1/ui/shared_state.py
Normal file
190
agent_v0/agent_v1/ui/shared_state.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# agent_v1/ui/shared_state.py
|
||||
"""
|
||||
Etat partage entre le systray et le chat Lea. Thread-safe.
|
||||
|
||||
Point central de verite pour l'etat de l'agent :
|
||||
- Enregistrement en cours (oui/non, nom de la tache)
|
||||
- Replay en cours
|
||||
- Compteur d'actions
|
||||
|
||||
Les deux composants UI (SmartTrayV1 et ChatWindow) lisent et ecrivent
|
||||
dans cet objet. Chaque changement notifie tous les listeners enregistres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentState:
|
||||
"""Etat partage entre le systray et le chat Lea. Thread-safe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Etat d'enregistrement
|
||||
self._recording = False
|
||||
self._recording_name = ""
|
||||
self._actions_count = 0
|
||||
|
||||
# Etat de replay
|
||||
self._replay_active = False
|
||||
|
||||
# Callbacks de demarrage/arret de session (relies au moteur agent)
|
||||
self._on_start: Optional[Callable[[str], None]] = None
|
||||
self._on_stop: Optional[Callable[[], None]] = None
|
||||
|
||||
# Listeners notifies a chaque changement d'etat
|
||||
self._listeners: List[Callable[["AgentState"], None]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proprietes en lecture seule (thread-safe)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
with self._lock:
|
||||
return self._recording
|
||||
|
||||
@property
|
||||
def recording_name(self) -> str:
|
||||
with self._lock:
|
||||
return self._recording_name
|
||||
|
||||
@property
|
||||
def actions_count(self) -> int:
|
||||
with self._lock:
|
||||
return self._actions_count
|
||||
|
||||
@property
|
||||
def is_replay_active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._replay_active
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mutations (thread-safe, notifient les listeners)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_recording(self, name: str) -> None:
|
||||
"""Demarre un enregistrement (appele depuis systray OU chat).
|
||||
|
||||
Appelle le callback on_start si defini, puis notifie les listeners.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._recording:
|
||||
logger.warning("Enregistrement deja en cours, ignore")
|
||||
return
|
||||
self._recording = True
|
||||
self._recording_name = name
|
||||
self._actions_count = 0
|
||||
on_start = self._on_start
|
||||
|
||||
logger.info("Enregistrement demarre : %s", name)
|
||||
|
||||
# Appeler le callback moteur (hors du lock pour eviter deadlock)
|
||||
if on_start is not None:
|
||||
try:
|
||||
on_start(name)
|
||||
except Exception as e:
|
||||
logger.error("Erreur demarrage session : %s", e)
|
||||
# Annuler l'enregistrement si le moteur echoue
|
||||
with self._lock:
|
||||
self._recording = False
|
||||
self._recording_name = ""
|
||||
self._notify_listeners()
|
||||
raise
|
||||
|
||||
self._notify_listeners()
|
||||
|
||||
def stop_recording(self) -> None:
|
||||
"""Arrete l'enregistrement (appele depuis systray OU chat).
|
||||
|
||||
Appelle le callback on_stop si defini, puis notifie les listeners.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
logger.debug("Pas d'enregistrement en cours, ignore")
|
||||
return
|
||||
self._recording = False
|
||||
name = self._recording_name
|
||||
count = self._actions_count
|
||||
on_stop = self._on_stop
|
||||
|
||||
logger.info("Enregistrement arrete : %s (%d actions)", name, count)
|
||||
|
||||
# Appeler le callback moteur
|
||||
if on_stop is not None:
|
||||
try:
|
||||
on_stop()
|
||||
except Exception as e:
|
||||
logger.error("Erreur arret session : %s", e)
|
||||
|
||||
self._notify_listeners()
|
||||
|
||||
def update_actions_count(self, count: int) -> None:
|
||||
"""Met a jour le compteur d'actions (appele par le moteur agent)."""
|
||||
with self._lock:
|
||||
self._actions_count = count
|
||||
self._notify_listeners()
|
||||
|
||||
def set_replay_active(self, active: bool) -> None:
|
||||
"""Active ou desactive le mode replay."""
|
||||
with self._lock:
|
||||
if self._replay_active == active:
|
||||
return
|
||||
self._replay_active = active
|
||||
logger.info("Replay %s", "actif" if active else "termine")
|
||||
self._notify_listeners()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Enregistrement des callbacks et listeners
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_on_start(self, callback: Callable[[str], None]) -> None:
|
||||
"""Definit le callback appele quand un enregistrement demarre.
|
||||
|
||||
Ce callback est le pont vers le moteur agent (AgentV1.start_session).
|
||||
"""
|
||||
with self._lock:
|
||||
self._on_start = callback
|
||||
|
||||
def set_on_stop(self, callback: Callable[[], None]) -> None:
|
||||
"""Definit le callback appele quand un enregistrement s'arrete.
|
||||
|
||||
Ce callback est le pont vers le moteur agent (AgentV1.stop_session).
|
||||
"""
|
||||
with self._lock:
|
||||
self._on_stop = callback
|
||||
|
||||
def on_change(self, callback: Callable[["AgentState"], None]) -> None:
|
||||
"""Enregistre un listener notifie a chaque changement d'etat.
|
||||
|
||||
Les listeners sont appeles dans un thread separe pour ne pas
|
||||
bloquer l'appelant.
|
||||
"""
|
||||
with self._lock:
|
||||
self._listeners.append(callback)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notification interne
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _notify_listeners(self) -> None:
|
||||
"""Notifie tous les listeners enregistres du changement d'etat."""
|
||||
with self._lock:
|
||||
listeners = list(self._listeners)
|
||||
|
||||
for listener in listeners:
|
||||
try:
|
||||
# Appel dans un thread pour ne pas bloquer
|
||||
threading.Thread(
|
||||
target=listener,
|
||||
args=(self,),
|
||||
daemon=True,
|
||||
).start()
|
||||
except Exception as e:
|
||||
logger.error("Erreur notification listener : %s", e)
|
||||
781
agent_v0/agent_v1/ui/smart_tray.py
Normal file
781
agent_v0/agent_v1/ui/smart_tray.py
Normal file
@@ -0,0 +1,781 @@
|
||||
# agent_v1/ui/smart_tray.py
|
||||
"""
|
||||
Tray intelligent pour Agent V1 — remplace tray.py (plus de PyQt5).
|
||||
|
||||
Utilise pystray pour l'icone systray et tkinter (stdlib) pour les dialogues.
|
||||
Communication serveur via LeaServerClient (chat:5004, streaming:5005).
|
||||
Notifications via NotificationManager (module parallele).
|
||||
Fenetre de chat Lea integree via ChatWindow (pywebview).
|
||||
|
||||
Architecture de threads :
|
||||
- Thread principal : boucle pystray (icon.run)
|
||||
- Thread daemon : verification connexion serveur (toutes les 30s)
|
||||
- Thread daemon : rafraichissement cache workflows (toutes les 5 min)
|
||||
- Thread daemon : pywebview (fenetre de chat Lea)
|
||||
- Thread daemon : hotkey global Ctrl+Shift+L (si keyboard disponible)
|
||||
- Threads ephemeres : dialogues tkinter (chaque dialogue cree son propre Tk())
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import pystray
|
||||
from pystray import MenuItem as item
|
||||
|
||||
from .notifications import NotificationManager
|
||||
from .shared_state import AgentState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Intervalles (secondes)
|
||||
_CONNECTION_CHECK_INTERVAL = 30
|
||||
_WORKFLOW_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers tkinter (sans PyQt5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ask_string(title: str, prompt: str, default: str = "") -> Optional[str]:
|
||||
"""Dialogue de saisie texte via tkinter (sans PyQt5).
|
||||
|
||||
Cree une instance Tk() ephemere, affiche le dialogue, puis la detruit.
|
||||
Compatible avec la boucle pystray (pas de mainloop persistant).
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import simpledialog
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes('-topmost', True)
|
||||
result = simpledialog.askstring(title, prompt, initialvalue=default, parent=root)
|
||||
root.destroy()
|
||||
return result
|
||||
|
||||
|
||||
def _show_info(title: str, message: str) -> None:
|
||||
"""Affiche une boite d'information via tkinter (sans PyQt5)."""
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes('-topmost', True)
|
||||
messagebox.showinfo(title, message, parent=root)
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _ask_consent(title: str, message: str) -> bool:
|
||||
"""Dialogue de consentement Oui/Non via tkinter (sans PyQt5).
|
||||
|
||||
Utilise pour la notification prealable obligatoire (Articles 13/14,
|
||||
Reglement IA) avant tout enregistrement.
|
||||
"""
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes('-topmost', True)
|
||||
result = messagebox.askyesno(title, message, parent=root)
|
||||
root.destroy()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SmartTrayV1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SmartTrayV1:
|
||||
"""Tray systeme intelligent pour Agent V1.
|
||||
|
||||
Remplace TrayAppV1 (PyQt5) par pystray + tkinter.
|
||||
Meme interface constructeur pour compatibilite avec main.py.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_start_callback: Callable[[str], None],
|
||||
on_stop_callback: Callable[[], None],
|
||||
server_client: Optional[Any] = None,
|
||||
chat_window: Optional[Any] = None,
|
||||
machine_id: str = "default",
|
||||
shared_state: Optional[AgentState] = None,
|
||||
) -> None:
|
||||
self.on_start = on_start_callback
|
||||
self.on_stop = on_stop_callback
|
||||
self.server_client = server_client
|
||||
self.machine_id = machine_id # Identifiant machine (multi-machine)
|
||||
|
||||
# Fenetre de chat Lea (pywebview)
|
||||
self._chat_window = chat_window
|
||||
|
||||
# Etat partage avec le chat (source de verite unique)
|
||||
self._shared_state = shared_state
|
||||
|
||||
# Etat interne (synchronise avec shared_state si disponible)
|
||||
self.icon: Optional[pystray.Icon] = None
|
||||
self.is_recording = False
|
||||
self.actions_count = 0
|
||||
|
||||
# Etat connexion serveur
|
||||
self._connected = False
|
||||
self._replay_active = False
|
||||
|
||||
# Cache workflows
|
||||
self._workflows: List[Dict[str, Any]] = []
|
||||
self._workflows_lock = threading.Lock()
|
||||
self._workflows_last_fetch: float = 0.0
|
||||
|
||||
# Verrous
|
||||
self._state_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Notifications
|
||||
self._notifier = NotificationManager()
|
||||
|
||||
# Icones d'etat (cercles colores)
|
||||
self.icons = {
|
||||
"idle": self._create_circle_icon("gray"),
|
||||
"recording": self._create_circle_icon("red"),
|
||||
"connected": self._create_circle_icon("green"),
|
||||
"disconnected": self._create_circle_icon("orange"),
|
||||
"replay": self._create_circle_icon("blue"),
|
||||
}
|
||||
|
||||
# Enregistrer le callback de changement de connexion sur le client
|
||||
if self.server_client is not None:
|
||||
self.server_client.set_on_connection_change(self._on_connection_change)
|
||||
|
||||
# S'abonner aux changements de l'etat partage
|
||||
if self._shared_state is not None:
|
||||
self._shared_state.on_change(self._on_shared_state_change)
|
||||
|
||||
logger.info("SmartTrayV1 initialise")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Icones
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _create_circle_icon(color: str) -> Image.Image:
|
||||
"""Genere une icone circulaire simple mais propre."""
|
||||
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((4, 4, 60, 60), fill=color, outline="white", width=2)
|
||||
return img
|
||||
|
||||
def _current_icon(self) -> Image.Image:
|
||||
"""Retourne l'icone correspondant a l'etat courant."""
|
||||
if self._replay_active:
|
||||
return self.icons["replay"]
|
||||
if self.is_recording:
|
||||
return self.icons["recording"]
|
||||
if self._connected:
|
||||
return self.icons["connected"]
|
||||
if self.server_client is not None:
|
||||
return self.icons["disconnected"]
|
||||
return self.icons["idle"]
|
||||
|
||||
def _update_icon(self) -> None:
|
||||
"""Met a jour l'icone et le menu du tray."""
|
||||
if self.icon is not None:
|
||||
self.icon.icon = self._current_icon()
|
||||
self.icon.update_menu()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Menu dynamique
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_menu_items(self):
|
||||
"""Retourne les items du menu (appele a chaque ouverture du menu)."""
|
||||
# Ligne de statut (féminin : Léa est connectée/déconnectée)
|
||||
if self.is_recording:
|
||||
status_text = "\U0001f534 Apprentissage en cours..."
|
||||
elif self._connected:
|
||||
status_text = "\U0001f7e2 Connect\u00e9e"
|
||||
else:
|
||||
status_text = "\U0001f534 D\u00e9connect\u00e9e"
|
||||
|
||||
# Compteur d'actions (visible uniquement en enregistrement)
|
||||
actions_text = f"\U0001f4ca {self.actions_count} \u00e9tapes m\u00e9moris\u00e9es"
|
||||
|
||||
# Sous-menu workflows
|
||||
workflow_items = self._build_workflow_submenu()
|
||||
|
||||
# Ligne d'identification machine (toujours visible)
|
||||
machine_text = f"\U0001f4bb {self.machine_id}"
|
||||
|
||||
items = [
|
||||
# --- Identite machine ---
|
||||
item(machine_text, lambda: None, enabled=False),
|
||||
# --- Statut ---
|
||||
item(status_text, lambda: None, enabled=False),
|
||||
item(
|
||||
actions_text,
|
||||
lambda: None,
|
||||
enabled=False,
|
||||
visible=lambda _i: self.is_recording,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
# --- Actions session ---
|
||||
item(
|
||||
"\U0001f393 Apprenez-moi une t\u00e2che",
|
||||
self._on_start_session,
|
||||
visible=lambda _i: not self.is_recording,
|
||||
),
|
||||
item(
|
||||
"\u23f9\ufe0f C'est termin\u00e9",
|
||||
self._on_stop_session,
|
||||
visible=lambda _i: self.is_recording,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
# --- Workflows ---
|
||||
item(
|
||||
"\U0001f4cb Mes t\u00e2ches",
|
||||
pystray.Menu(*workflow_items) if workflow_items else pystray.Menu(
|
||||
item("(aucune t\u00e2che apprise)", lambda: None, enabled=False),
|
||||
),
|
||||
visible=lambda _i: self.server_client is not None,
|
||||
),
|
||||
item(
|
||||
"\U0001f504 Actualiser",
|
||||
self._on_refresh_workflows,
|
||||
visible=lambda _i: self.server_client is not None,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
# --- Chat ---
|
||||
item(
|
||||
"\U0001f4ac Discuter avec L\u00e9a",
|
||||
self._on_toggle_chat,
|
||||
visible=lambda _i: self._chat_window is not None,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
# --- Arret d'urgence (Article 14, Reglement IA — controle humain) ---
|
||||
# Toujours visible, quel que soit l'etat de l'agent
|
||||
item(
|
||||
"\u26d4 ARR\u00caT D'URGENCE",
|
||||
self._on_emergency_stop,
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
# --- Utilitaires ---
|
||||
item("\U0001f4c2 Mes fichiers", self._on_open_folder),
|
||||
item("\u274c Quitter L\u00e9a", self._on_quit),
|
||||
]
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _human_workflow_name(wf: Dict[str, Any]) -> str:
|
||||
"""Retourne un nom lisible pour un workflow.
|
||||
|
||||
Priorite :
|
||||
1. Champ 'display_name' (nom humain saisi par l'utilisateur)
|
||||
2. Champ 'name' ou 'workflow_name'
|
||||
3. Fallback : "Tache du <date>"
|
||||
"""
|
||||
# Nom humain explicite (nouveau champ)
|
||||
display = wf.get("display_name", "").strip()
|
||||
if display:
|
||||
return display
|
||||
|
||||
# Nom technique existant
|
||||
name = wf.get("name", wf.get("workflow_name", "")).strip()
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Fallback avec date de creation
|
||||
created = wf.get("created_at", wf.get("timestamp", ""))
|
||||
if created:
|
||||
# Extraire juste la date (format ISO ou timestamp)
|
||||
try:
|
||||
from datetime import datetime
|
||||
if isinstance(created, (int, float)):
|
||||
dt = datetime.fromtimestamp(created)
|
||||
else:
|
||||
dt = datetime.fromisoformat(str(created).replace("Z", "+00:00"))
|
||||
return f"T\u00e2che du {dt.strftime('%d %B')}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "T\u00e2che sans nom"
|
||||
|
||||
def _build_workflow_submenu(self) -> List[pystray.MenuItem]:
|
||||
"""Construit la liste des workflows comme items de sous-menu."""
|
||||
with self._workflows_lock:
|
||||
workflows = list(self._workflows)
|
||||
|
||||
if not workflows:
|
||||
return [item("(aucune t\u00e2che apprise)", lambda: None, enabled=False)]
|
||||
|
||||
items = []
|
||||
for wf in workflows:
|
||||
wf_name = self._human_workflow_name(wf)
|
||||
wf_id = wf.get("id", wf.get("workflow_id", ""))
|
||||
# Creer une closure avec les bonnes valeurs
|
||||
items.append(
|
||||
item(wf_name, self._make_replay_callback(wf_id, wf_name))
|
||||
)
|
||||
return items
|
||||
|
||||
def _make_replay_callback(
|
||||
self, workflow_id: str, workflow_name: str
|
||||
) -> Callable:
|
||||
"""Cree un callback de lancement de replay pour un workflow donne."""
|
||||
def _callback(_icon=None, _item=None):
|
||||
self._launch_replay(workflow_id, workflow_name)
|
||||
return _callback
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions utilisateur
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_shared_state_change(self, state: AgentState) -> None:
|
||||
"""Callback appele quand l'etat partage change (depuis le chat ou ailleurs).
|
||||
|
||||
Met a jour l'etat local du systray pour refleter le changement.
|
||||
"""
|
||||
with self._state_lock:
|
||||
self.is_recording = state.is_recording
|
||||
self.actions_count = state.actions_count
|
||||
self._replay_active = state.is_replay_active
|
||||
self._update_icon()
|
||||
|
||||
def _on_start_session(self, _icon=None, _item=None) -> None:
|
||||
"""Demande le consentement puis le nom de la tache et demarre la session.
|
||||
|
||||
Notification prealable obligatoire (Articles 13/14, Reglement IA) :
|
||||
l'utilisateur doit etre informe de ce qui sera capture AVANT le demarrage.
|
||||
"""
|
||||
# Dialogue tkinter dans un thread dedie
|
||||
def _dialog():
|
||||
# --- Consentement prealable (Articles 13/14, Reglement IA) ---
|
||||
if not _ask_consent(
|
||||
"Enregistrement — Information",
|
||||
"\u26a0\ufe0f L'enregistrement va capturer votre \u00e9cran, "
|
||||
"vos clics et vos frappes clavier pour apprendre cette t\u00e2che.\n\n"
|
||||
"Les donn\u00e9es sensibles seront automatiquement flout\u00e9es.\n\n"
|
||||
"Voulez-vous continuer ?",
|
||||
):
|
||||
return
|
||||
|
||||
name = _ask_string(
|
||||
"Nouvelle t\u00e2che",
|
||||
"D\u00e9crivez la t\u00e2che \u00e0 apprendre :",
|
||||
default="",
|
||||
)
|
||||
if name and name.strip():
|
||||
name = name.strip()
|
||||
# Utiliser l'etat partage si disponible
|
||||
if self._shared_state is not None:
|
||||
try:
|
||||
self._shared_state.start_recording(name)
|
||||
except Exception as e:
|
||||
self._notifier.notify("L\u00e9a", f"Oups : {e}")
|
||||
return
|
||||
else:
|
||||
# Fallback sans etat partage
|
||||
with self._state_lock:
|
||||
self.is_recording = True
|
||||
self.actions_count = 0
|
||||
self._update_icon()
|
||||
self.on_start(name)
|
||||
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
"C'est parti ! Montrez-moi comment faire.",
|
||||
)
|
||||
|
||||
threading.Thread(target=_dialog, daemon=True).start()
|
||||
|
||||
def _on_stop_session(self, _icon=None, _item=None) -> None:
|
||||
"""Termine la session en cours et envoie les donnees."""
|
||||
count = self.actions_count
|
||||
|
||||
# Utiliser l'etat partage si disponible
|
||||
if self._shared_state is not None:
|
||||
self._shared_state.stop_recording()
|
||||
else:
|
||||
with self._state_lock:
|
||||
self.is_recording = False
|
||||
self._update_icon()
|
||||
self.on_stop()
|
||||
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
f"Merci ! J'ai bien m\u00e9moris\u00e9 vos {count} actions.",
|
||||
)
|
||||
|
||||
def _on_refresh_workflows(self, _icon=None, _item=None) -> None:
|
||||
"""Rafraichit la liste des workflows depuis le serveur."""
|
||||
threading.Thread(target=self._fetch_workflows, daemon=True).start()
|
||||
|
||||
def _on_ask_server(self, _icon=None, _item=None) -> None:
|
||||
"""Envoie 'Que dois-je faire ?' au serveur et affiche la reponse."""
|
||||
def _ask():
|
||||
if self.server_client is None:
|
||||
return
|
||||
response = self.server_client.send_chat_message(
|
||||
"Que dois-je faire maintenant ?"
|
||||
)
|
||||
if response:
|
||||
# L'API renvoie {"response": {"message": "..."}} ou {"response": "..."}
|
||||
resp = response.get("response", {})
|
||||
if isinstance(resp, dict):
|
||||
text = resp.get("message", str(resp))
|
||||
else:
|
||||
text = str(resp)
|
||||
self._notifier.notify("Léa", text)
|
||||
else:
|
||||
self._notifier.notify(
|
||||
"Erreur",
|
||||
"Impossible de contacter le serveur.",
|
||||
)
|
||||
|
||||
threading.Thread(target=_ask, daemon=True).start()
|
||||
|
||||
def _on_toggle_chat(self, _icon=None, _item=None) -> None:
|
||||
"""Affiche ou masque la fenetre de chat Lea (pywebview)."""
|
||||
if self._chat_window is None:
|
||||
return
|
||||
|
||||
def _toggle():
|
||||
try:
|
||||
self._chat_window.toggle()
|
||||
except Exception as e:
|
||||
logger.error("Erreur toggle chat : %s", e)
|
||||
self._notifier.notify(
|
||||
"Erreur Chat",
|
||||
f"Impossible d'ouvrir le chat : {e}",
|
||||
)
|
||||
|
||||
threading.Thread(target=_toggle, daemon=True).start()
|
||||
|
||||
def _launch_replay(self, workflow_id: str, workflow_name: str) -> None:
|
||||
"""Lance le replay d'un workflow."""
|
||||
def _replay():
|
||||
if self.server_client is None:
|
||||
return
|
||||
|
||||
with self._state_lock:
|
||||
self._replay_active = True
|
||||
self._update_icon()
|
||||
# Transparence mode autonome (Article 50, Reglement IA)
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
f"Le syst\u00e8me d'intelligence artificielle ex\u00e9cute la "
|
||||
f"t\u00e2che '{workflow_name}' sur votre \u00e9cran.",
|
||||
)
|
||||
|
||||
try:
|
||||
import requests
|
||||
# Auth headers pour le streaming server (port 5005)
|
||||
auth_headers = {}
|
||||
if self.server_client is not None:
|
||||
auth_headers = self.server_client._auth_headers()
|
||||
resp = requests.post(
|
||||
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
||||
json={"workflow_id": workflow_id},
|
||||
headers=auth_headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.info("Replay demarre pour workflow %s", workflow_id)
|
||||
else:
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
"Hmm, le serveur a refus\u00e9. R\u00e9essayons plus tard.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Erreur lancement replay : %s", e)
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
f"Oups, un probl\u00e8me : {e}",
|
||||
)
|
||||
finally:
|
||||
with self._state_lock:
|
||||
self._replay_active = False
|
||||
self._update_icon()
|
||||
|
||||
threading.Thread(target=_replay, daemon=True).start()
|
||||
|
||||
def _on_emergency_stop(self, _icon=None, _item=None) -> None:
|
||||
"""Arret d'urgence — stoppe TOUTES les activites de l'agent immediatement.
|
||||
|
||||
Controle humain obligatoire (Article 14, Reglement IA).
|
||||
Arrete l'enregistrement, le replay ET le heartbeat d'un seul clic.
|
||||
Toujours accessible dans le menu, quel que soit l'etat de l'agent.
|
||||
"""
|
||||
logger.warning("ARRET D'URGENCE declenche par l'utilisateur")
|
||||
|
||||
# Arreter l'enregistrement si en cours
|
||||
if self._shared_state is not None:
|
||||
if self._shared_state.is_recording:
|
||||
try:
|
||||
self._shared_state.stop_recording()
|
||||
except Exception as e:
|
||||
logger.error("Erreur arret enregistrement d'urgence : %s", e)
|
||||
|
||||
# Arreter le replay si en cours
|
||||
if self._shared_state.is_replay_active:
|
||||
self._shared_state.set_replay_active(False)
|
||||
else:
|
||||
# Fallback sans etat partage
|
||||
if self.is_recording:
|
||||
try:
|
||||
self.on_stop()
|
||||
except Exception as e:
|
||||
logger.error("Erreur arret session d'urgence : %s", e)
|
||||
|
||||
# Forcer l'etat local a l'arret
|
||||
with self._state_lock:
|
||||
self.is_recording = False
|
||||
self.actions_count = 0
|
||||
self._replay_active = False
|
||||
self._update_icon()
|
||||
|
||||
# Notification
|
||||
self._notifier.notify(
|
||||
"\u26d4 Arr\u00eat d'urgence",
|
||||
"Toutes les activit\u00e9s ont \u00e9t\u00e9 arr\u00eat\u00e9es.",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
def _on_open_folder(self, _icon=None, _item=None) -> None:
|
||||
"""Ouvre le dossier des sessions dans l'explorateur de fichiers."""
|
||||
from ..config import SESSIONS_ROOT
|
||||
|
||||
sessions_path = str(SESSIONS_ROOT)
|
||||
if os.name == "nt":
|
||||
os.startfile(sessions_path)
|
||||
else:
|
||||
os.system(f'xdg-open "{sessions_path}"')
|
||||
|
||||
def _on_quit(self, _icon=None, _item=None) -> None:
|
||||
"""Arrete proprement l'agent et quitte."""
|
||||
logger.info("Arret demande par l'utilisateur")
|
||||
|
||||
# Arreter la session si en cours
|
||||
if self.is_recording:
|
||||
self.on_stop()
|
||||
|
||||
# Signaler l'arret aux threads de fond
|
||||
self._stop_event.set()
|
||||
|
||||
# Fermer la fenetre de chat si ouverte
|
||||
if self._chat_window is not None:
|
||||
try:
|
||||
self._chat_window.destroy()
|
||||
except Exception as e:
|
||||
logger.debug("Erreur fermeture chat : %s", e)
|
||||
|
||||
# Arreter le hotkey global si actif
|
||||
self._stop_hotkey()
|
||||
|
||||
# Arreter le client serveur si present
|
||||
if self.server_client is not None:
|
||||
self.server_client.shutdown()
|
||||
|
||||
# Arreter l'icone pystray
|
||||
if self.icon is not None:
|
||||
self.icon.stop()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Verification connexion serveur (thread daemon)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _connection_checker_loop(self) -> None:
|
||||
"""Verifie la connexion au serveur toutes les 30 secondes."""
|
||||
logger.info("Thread de verification connexion demarre")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
if self.server_client is not None:
|
||||
try:
|
||||
was_connected = self._connected
|
||||
self._connected = self.server_client.check_connection()
|
||||
|
||||
if self._connected != was_connected:
|
||||
self._update_icon()
|
||||
# La notification est geree par _on_connection_change
|
||||
except Exception as e:
|
||||
logger.error("Erreur verification connexion : %s", e)
|
||||
|
||||
self._stop_event.wait(timeout=_CONNECTION_CHECK_INTERVAL)
|
||||
|
||||
logger.info("Thread de verification connexion arrete")
|
||||
|
||||
def _on_connection_change(self, connected: bool) -> None:
|
||||
"""Callback appelee par LeaServerClient quand l'etat de connexion change."""
|
||||
with self._state_lock:
|
||||
self._connected = connected
|
||||
self._update_icon()
|
||||
|
||||
if connected:
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
"Connect\u00e9e au serveur.",
|
||||
)
|
||||
# Rafraichir les taches a la connexion
|
||||
threading.Thread(target=self._fetch_workflows, daemon=True).start()
|
||||
else:
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
"J'ai perdu la connexion avec le serveur.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache workflows (thread daemon)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _workflow_cache_loop(self) -> None:
|
||||
"""Rafraichit le cache des workflows toutes les 5 minutes."""
|
||||
logger.info("Thread de cache workflows demarre")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
if self.server_client is not None and self._connected:
|
||||
self._fetch_workflows()
|
||||
|
||||
self._stop_event.wait(timeout=_WORKFLOW_CACHE_TTL)
|
||||
|
||||
logger.info("Thread de cache workflows arrete")
|
||||
|
||||
def _fetch_workflows(self) -> None:
|
||||
"""Recupere la liste des workflows depuis le serveur."""
|
||||
if self.server_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
workflows = self.server_client.list_workflows()
|
||||
with self._workflows_lock:
|
||||
self._workflows = workflows
|
||||
self._workflows_last_fetch = time.time()
|
||||
logger.debug(
|
||||
"Cache workflows mis a jour : %d workflows", len(workflows)
|
||||
)
|
||||
# Forcer la reconstruction du menu
|
||||
self._update_icon()
|
||||
except Exception as e:
|
||||
logger.error("Erreur recuperation workflows : %s", e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mise a jour du compteur (compatibilite main.py)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_stats(self, count: int) -> None:
|
||||
"""Met a jour le compteur d'actions en temps reel dans le menu."""
|
||||
with self._state_lock:
|
||||
self.actions_count = count
|
||||
if self.icon is not None:
|
||||
self.icon.update_menu()
|
||||
|
||||
def set_replay_active(self, active: bool) -> None:
|
||||
"""Signale qu'un replay est en cours (appele depuis main.py)."""
|
||||
with self._state_lock:
|
||||
self._replay_active = active
|
||||
self._update_icon()
|
||||
|
||||
if active:
|
||||
# Transparence mode autonome (Article 50, Reglement IA)
|
||||
self._notifier.notify(
|
||||
"L\u00e9a",
|
||||
"Le syst\u00e8me d'intelligence artificielle ex\u00e9cute "
|
||||
"une t\u00e2che sur votre \u00e9cran.",
|
||||
)
|
||||
else:
|
||||
self._notifier.notify("L\u00e9a", "C'est fait !")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hotkey global Ctrl+Shift+L (toggle chat)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_hotkey_hook = None # reference pour pouvoir le retirer
|
||||
|
||||
def _start_hotkey(self) -> None:
|
||||
"""Enregistre le raccourci global Ctrl+Shift+L pour ouvrir le chat.
|
||||
|
||||
Utilise la librairie 'keyboard' si disponible.
|
||||
Silencieux si elle n'est pas installee (pas critique).
|
||||
"""
|
||||
if self._chat_window is None:
|
||||
return
|
||||
|
||||
try:
|
||||
import keyboard
|
||||
self._hotkey_hook = keyboard.add_hotkey(
|
||||
"ctrl+shift+l",
|
||||
self._on_toggle_chat,
|
||||
suppress=False,
|
||||
)
|
||||
logger.info("Hotkey Ctrl+Shift+L enregistre pour le chat Lea")
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"keyboard non installe — hotkey Ctrl+Shift+L desactive. "
|
||||
"Installer avec : pip install keyboard"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Impossible d'enregistrer le hotkey : %s", e)
|
||||
|
||||
def _stop_hotkey(self) -> None:
|
||||
"""Retire le raccourci global."""
|
||||
if self._hotkey_hook is not None:
|
||||
try:
|
||||
import keyboard
|
||||
keyboard.remove_hotkey(self._hotkey_hook)
|
||||
self._hotkey_hook = None
|
||||
logger.debug("Hotkey Ctrl+Shift+L retire")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Point d'entree
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
"""Demarre le tray, les threads de fond, et entre dans la boucle principale."""
|
||||
# Notification d'accueil — divulgation IA (Article 50, Reglement IA)
|
||||
self._notifier.greet()
|
||||
|
||||
# Enregistrer le hotkey global Ctrl+Shift+L (toggle chat)
|
||||
self._start_hotkey()
|
||||
|
||||
# Tooltip avec identifiant machine pour le multi-machine
|
||||
tray_title = f"Agent V1 - {self.machine_id}"
|
||||
|
||||
# Menu statique — reconstruit via _update_icon() quand l'état change
|
||||
self.icon = pystray.Icon(
|
||||
"AgentV1",
|
||||
self._current_icon(),
|
||||
tray_title,
|
||||
menu=pystray.Menu(*self._get_menu_items()),
|
||||
)
|
||||
|
||||
# Demarrer le thread de verification connexion
|
||||
if self.server_client is not None:
|
||||
conn_thread = threading.Thread(
|
||||
target=self._connection_checker_loop,
|
||||
daemon=True,
|
||||
name="smart-tray-conn-check",
|
||||
)
|
||||
conn_thread.start()
|
||||
|
||||
# Demarrer le thread de cache workflows
|
||||
wf_thread = threading.Thread(
|
||||
target=self._workflow_cache_loop,
|
||||
daemon=True,
|
||||
name="smart-tray-wf-cache",
|
||||
)
|
||||
wf_thread.start()
|
||||
|
||||
# Premiere verification immediate
|
||||
threading.Thread(
|
||||
target=self._fetch_workflows, daemon=True
|
||||
).start()
|
||||
|
||||
# Boucle principale pystray (bloquante)
|
||||
logger.info("SmartTrayV1 demarre — entree dans la boucle pystray")
|
||||
self.icon.run()
|
||||
0
agent_v0/agent_v1/vision/__init__.py
Normal file
0
agent_v0/agent_v1/vision/__init__.py
Normal file
203
agent_v0/agent_v1/vision/blur_sensitive.py
Normal file
203
agent_v0/agent_v1/vision/blur_sensitive.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# agent_v1/vision/blur_sensitive.py
|
||||
"""
|
||||
Floutage automatique des zones de texte sensible dans les screenshots.
|
||||
|
||||
Conformité AI Act : les screenshots utilisés pour l'apprentissage ne doivent
|
||||
pas contenir de données patient lisibles, mots de passe, etc.
|
||||
|
||||
Stratégie :
|
||||
- Détecte les champs de saisie (rectangles clairs avec du texte)
|
||||
- Floute leur CONTENU tout en gardant la structure UI visible
|
||||
- Rapide (<200ms) : uniquement des opérations OpenCV simples, pas de deep learning
|
||||
|
||||
Usage :
|
||||
from .blur_sensitive import blur_sensitive_regions
|
||||
blur_sensitive_regions(img) # modifie l'image PIL en place
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Seuils configurables pour la détection des champs de saisie
|
||||
_INPUT_FIELD_MIN_WIDTH = 50 # Largeur minimale en pixels
|
||||
_INPUT_FIELD_MIN_HEIGHT = 15 # Hauteur minimale
|
||||
_INPUT_FIELD_MAX_HEIGHT = 80 # Hauteur maximale (exclut les grandes zones)
|
||||
_INPUT_FIELD_MIN_ASPECT_RATIO = 2.0 # Ratio largeur/hauteur minimum
|
||||
_INPUT_FIELD_MIN_AREA = 1000 # Surface minimale en pixels²
|
||||
_INPUT_FIELD_BRIGHTNESS_THRESHOLD = 200 # Luminosité moyenne minimum (fond clair)
|
||||
|
||||
# Pour les zones de texte multi-lignes (textarea)
|
||||
_TEXTAREA_MIN_WIDTH = 100
|
||||
_TEXTAREA_MIN_HEIGHT = 60
|
||||
_TEXTAREA_MAX_HEIGHT = 500
|
||||
_TEXTAREA_MIN_AREA = 8000
|
||||
_TEXTAREA_MIN_ASPECT_RATIO = 1.2
|
||||
|
||||
# Paramètres du flou gaussien
|
||||
_BLUR_KERNEL_SIZE = (23, 23)
|
||||
_BLUR_SIGMA = 12
|
||||
_BLUR_MARGIN = 3 # Marge en pixels pour garder le bord du champ visible
|
||||
|
||||
|
||||
def blur_sensitive_regions(pil_image):
|
||||
"""Floute les zones de texte sensible dans une image PIL.
|
||||
|
||||
Modifie l'image en place et la retourne.
|
||||
Rapide : ~50-150ms selon la résolution.
|
||||
|
||||
Args:
|
||||
pil_image: Image PIL (mode RGB)
|
||||
|
||||
Returns:
|
||||
L'image PIL modifiée (même objet, modifié en place)
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
logger.warning("OpenCV non disponible — floutage désactivé")
|
||||
return pil_image
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Conversion PIL → OpenCV (sans copie disque)
|
||||
img_array = np.array(pil_image)
|
||||
# PIL est RGB, OpenCV attend BGR
|
||||
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
|
||||
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
blurred_count = 0
|
||||
|
||||
# --- Passe 1 : Champs de saisie classiques (input text) ---
|
||||
blurred_count += _blur_input_fields(img_bgr, gray)
|
||||
|
||||
# --- Passe 2 : Zones de texte multi-lignes (textarea, éditeurs) ---
|
||||
blurred_count += _blur_textareas(img_bgr, gray)
|
||||
|
||||
if blurred_count > 0:
|
||||
# Reconversion OpenCV → PIL en place
|
||||
from PIL import Image as _PILImage
|
||||
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
pil_image.paste(_PILImage.fromarray(img_rgb))
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
if blurred_count > 0:
|
||||
logger.debug(f"Floutage : {blurred_count} zones en {elapsed_ms:.0f}ms")
|
||||
|
||||
return pil_image
|
||||
|
||||
|
||||
def _blur_input_fields(img_bgr, gray):
|
||||
"""Détecte et floute les champs de saisie simples (input text).
|
||||
|
||||
Les champs de saisie sont typiquement des rectangles à fond clair
|
||||
(blanc ou gris très clair) avec du texte sombre dedans.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
count = 0
|
||||
|
||||
# Seuillage : zones quasi-blanches (fond des champs de saisie)
|
||||
_, white_mask = cv2.threshold(gray, 230, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# Nettoyage morphologique : fermer les petits trous dans les champs
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
||||
white_mask = cv2.morphologyEx(white_mask, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(white_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
aspect_ratio = w / max(h, 1)
|
||||
area = w * h
|
||||
|
||||
# Filtrage : forme typique d'un champ de saisie
|
||||
if (w < _INPUT_FIELD_MIN_WIDTH or
|
||||
h < _INPUT_FIELD_MIN_HEIGHT or
|
||||
h > _INPUT_FIELD_MAX_HEIGHT or
|
||||
aspect_ratio < _INPUT_FIELD_MIN_ASPECT_RATIO or
|
||||
area < _INPUT_FIELD_MIN_AREA):
|
||||
continue
|
||||
|
||||
# Vérifier la luminosité moyenne (les boutons ont souvent un fond coloré)
|
||||
roi = gray[y:y+h, x:x+w]
|
||||
mean_val = np.mean(roi)
|
||||
|
||||
if mean_val < _INPUT_FIELD_BRIGHTNESS_THRESHOLD:
|
||||
continue # Pas assez clair, probablement pas un champ de saisie
|
||||
|
||||
# Vérifier qu'il y a du contenu (variation de luminosité = texte présent)
|
||||
std_val = np.std(roi)
|
||||
if std_val < 5:
|
||||
continue # Zone uniformément blanche, pas de texte à flouter
|
||||
|
||||
# Appliquer le flou gaussien sur le contenu (garder le bord visible)
|
||||
m = _BLUR_MARGIN
|
||||
y1, y2 = y + m, y + h - m
|
||||
x1, x2 = x + m, x + w - m
|
||||
if y2 > y1 and x2 > x1:
|
||||
roi_color = img_bgr[y1:y2, x1:x2]
|
||||
if roi_color.size > 0:
|
||||
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
|
||||
img_bgr[y1:y2, x1:x2] = blurred
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def _blur_textareas(img_bgr, gray):
|
||||
"""Détecte et floute les zones de texte multi-lignes (textarea, éditeurs).
|
||||
|
||||
Ces zones sont plus grandes que les champs simples, avec un fond clair
|
||||
et beaucoup de texte.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
count = 0
|
||||
|
||||
# Seuillage un peu plus tolérant pour les textareas (parfois gris clair)
|
||||
_, light_mask = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# Nettoyage morphologique plus agressif pour les grandes zones
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 10))
|
||||
light_mask = cv2.morphologyEx(light_mask, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(light_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
aspect_ratio = w / max(h, 1)
|
||||
area = w * h
|
||||
|
||||
# Filtrage : forme typique d'une textarea
|
||||
if (w < _TEXTAREA_MIN_WIDTH or
|
||||
h < _TEXTAREA_MIN_HEIGHT or
|
||||
h > _TEXTAREA_MAX_HEIGHT or
|
||||
aspect_ratio < _TEXTAREA_MIN_ASPECT_RATIO or
|
||||
area < _TEXTAREA_MIN_AREA):
|
||||
continue
|
||||
|
||||
# Vérifier la luminosité et la présence de texte
|
||||
roi = gray[y:y+h, x:x+w]
|
||||
mean_val = np.mean(roi)
|
||||
std_val = np.std(roi)
|
||||
|
||||
if mean_val < 190 or std_val < 8:
|
||||
continue # Pas un textarea avec du contenu
|
||||
|
||||
# Flou sur le contenu
|
||||
m = _BLUR_MARGIN + 2 # Marge un peu plus grande pour les textarea
|
||||
y1, y2 = y + m, y + h - m
|
||||
x1, x2 = x + m, x + w - m
|
||||
if y2 > y1 and x2 > x1:
|
||||
roi_color = img_bgr[y1:y2, x1:x2]
|
||||
if roi_color.size > 0:
|
||||
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
|
||||
img_bgr[y1:y2, x1:x2] = blurred
|
||||
count += 1
|
||||
|
||||
return count
|
||||
243
agent_v0/agent_v1/vision/capturer.py
Normal file
243
agent_v0/agent_v1/vision/capturer.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# agent_v1/vision/capturer.py
|
||||
"""
|
||||
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, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
from .blur_sensitive import blur_sensitive_regions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OS courant (détecté une seule fois)
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
self.shots_dir = os.path.join(session_dir, "shots")
|
||||
os.makedirs(self.shots_dir, exist_ok=True)
|
||||
# On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows
|
||||
self.last_img_hash = None
|
||||
|
||||
def capture_full_context(self, name_suffix: str, force=False) -> str:
|
||||
"""
|
||||
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)
|
||||
if current_hash == self.last_img_hash:
|
||||
return "" # Pas de changement, on économise la fibre
|
||||
self.last_img_hash = current_hash
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(img)
|
||||
|
||||
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
|
||||
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
return path
|
||||
except Exception as e:
|
||||
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 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")
|
||||
monitor = sct.monitors[1]
|
||||
sct_img = sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
||||
|
||||
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
|
||||
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
|
||||
w, h = TARGETED_CROP_SIZE
|
||||
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))
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
if BLUR_SENSITIVE:
|
||||
blur_sensitive_regions(img)
|
||||
blur_sensitive_regions(crop_img)
|
||||
|
||||
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
|
||||
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
small_img = img.resize((64, 64), Image.NEAREST).convert("L")
|
||||
return hashlib.md5(small_img.tobytes()).hexdigest()
|
||||
195
agent_v0/agent_v1/vision/system_info.py
Normal file
195
agent_v0/agent_v1/vision/system_info.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# agent_v1/vision/system_info.py
|
||||
"""
|
||||
Capture des metadonnees systeme pour enrichir les evenements.
|
||||
|
||||
Collecte DPI, resolution, fenetre active, moniteur, theme OS et langue.
|
||||
Les fonctions Windows (ctypes.windll, winreg) ont des fallbacks gracieux
|
||||
pour Linux/Mac.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import locale
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache du systeme d'exploitation pour eviter les appels repetes
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
|
||||
def get_dpi_scale() -> int:
|
||||
"""Retourne le facteur DPI en % (100 = normal, 150 = haute resolution).
|
||||
|
||||
Windows : ctypes.windll.user32.GetDpiForSystem()
|
||||
Linux/Mac : fallback 100
|
||||
|
||||
NOTE : Le process DOIT deja etre DPI-aware (via SetProcessDpiAwareness(2)
|
||||
appele dans config.py) pour que GetDpiForSystem retourne le vrai DPI.
|
||||
"""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
dpi = ctypes.windll.user32.GetDpiForSystem()
|
||||
return round(dpi * 100 / 96) # 96 DPI = 100%
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire le DPI Windows : {e}")
|
||||
return 100
|
||||
return 100 # Linux/Mac fallback
|
||||
|
||||
|
||||
def get_window_bounds() -> Optional[List[int]]:
|
||||
"""Retourne [x, y, width, height] de la fenetre active.
|
||||
|
||||
Windows : ctypes GetWindowRect(GetForegroundWindow())
|
||||
Linux/Mac : fallback None
|
||||
"""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return None
|
||||
rect = ctypes.wintypes.RECT()
|
||||
ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||
return [
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right - rect.left,
|
||||
rect.bottom - rect.top,
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire les bounds fenetre : {e}")
|
||||
return None
|
||||
|
||||
# Linux : tentative via xdotool
|
||||
if _SYSTEM == "Linux":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
wid = subprocess.check_output(
|
||||
["xdotool", "getactivewindow"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode().strip()
|
||||
geom = subprocess.check_output(
|
||||
["xdotool", "getwindowgeometry", "--shell", wid],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode()
|
||||
# Parse "X=...\nY=...\nWIDTH=...\nHEIGHT=..."
|
||||
vals: Dict[str, int] = {}
|
||||
for line in geom.strip().splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
vals[k.strip()] = int(v.strip())
|
||||
if {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
|
||||
return [vals["X"], vals["Y"], vals["WIDTH"], vals["HEIGHT"]]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_monitor_info() -> Tuple[int, List[Dict[str, int]]]:
|
||||
"""Retourne (monitor_index, liste_moniteurs).
|
||||
|
||||
Chaque moniteur : {width, height, x, y}
|
||||
monitor_index : index du moniteur contenant la fenetre active
|
||||
"""
|
||||
monitors: List[Dict[str, int]] = []
|
||||
active_index = 0
|
||||
|
||||
try:
|
||||
import mss
|
||||
|
||||
with mss.mss() as sct:
|
||||
for mon in sct.monitors[1:]: # Skip le moniteur virtuel (index 0)
|
||||
monitors.append({
|
||||
"width": mon["width"],
|
||||
"height": mon["height"],
|
||||
"x": mon["left"],
|
||||
"y": mon["top"],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"mss indisponible, resolution par defaut : {e}")
|
||||
monitors = [{"width": 1920, "height": 1080, "x": 0, "y": 0}]
|
||||
|
||||
# Determiner quel moniteur contient la fenetre active
|
||||
bounds = get_window_bounds()
|
||||
if bounds and len(monitors) > 1:
|
||||
wx, wy = bounds[0], bounds[1]
|
||||
for i, mon in enumerate(monitors):
|
||||
if (mon["x"] <= wx < mon["x"] + mon["width"]
|
||||
and mon["y"] <= wy < mon["y"] + mon["height"]):
|
||||
active_index = i
|
||||
break
|
||||
|
||||
return active_index, monitors
|
||||
|
||||
|
||||
def get_os_theme() -> str:
|
||||
"""Retourne 'light', 'dark' ou 'unknown'."""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import winreg
|
||||
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
||||
)
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
winreg.CloseKey(key)
|
||||
return "light" if value == 1 else "dark"
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire le theme Windows : {e}")
|
||||
return "unknown"
|
||||
|
||||
# Linux : tentative via gsettings (GNOME)
|
||||
if _SYSTEM == "Linux":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.check_output(
|
||||
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode().strip().strip("'\"")
|
||||
if "dark" in result.lower():
|
||||
return "dark"
|
||||
elif "light" in result.lower() or "default" in result.lower():
|
||||
return "light"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_os_language() -> str:
|
||||
"""Retourne le code langue (fr, en, de, etc.)."""
|
||||
try:
|
||||
lang = locale.getdefaultlocale()[0] # ex: 'fr_FR'
|
||||
if lang:
|
||||
return lang[:2] # ex: 'fr'
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_screen_metadata() -> Dict[str, Any]:
|
||||
"""Capture toutes les metadonnees systeme en une fois.
|
||||
|
||||
Appelee une fois au demarrage + a chaque changement de focus.
|
||||
Resultat injecte dans les evenements envoyes au serveur.
|
||||
"""
|
||||
monitor_index, monitors = get_monitor_info()
|
||||
primary = monitors[0] if monitors else {"width": 1920, "height": 1080}
|
||||
|
||||
return {
|
||||
"dpi_scale": get_dpi_scale(),
|
||||
"monitor_index": monitor_index,
|
||||
"monitors": monitors,
|
||||
"screen_resolution": [primary["width"], primary["height"]],
|
||||
"window_bounds": get_window_bounds(),
|
||||
"os_theme": get_os_theme(),
|
||||
"os_language": get_os_language(),
|
||||
}
|
||||
55
agent_v0/agent_v1/window_info.py
Normal file
55
agent_v0/agent_v1/window_info.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# window_info.py
|
||||
"""
|
||||
Récupération des informations sur la fenêtre active (X11).
|
||||
|
||||
v0 :
|
||||
- utilise xdotool pour obtenir :
|
||||
- le titre de la fenêtre active
|
||||
- le PID de la fenêtre active, puis le nom du process via ps
|
||||
|
||||
Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
||||
try:
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
||||
return out.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_window_info() -> Dict[str, str]:
|
||||
"""
|
||||
Renvoie un dict :
|
||||
{
|
||||
"title": "...",
|
||||
"app_name": "..."
|
||||
}
|
||||
|
||||
Nécessite xdotool installé sur le système.
|
||||
"""
|
||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
||||
|
||||
app_name: Optional[str] = None
|
||||
if pid_str:
|
||||
pid_str = pid_str.strip()
|
||||
# On récupère le nom du binaire via ps
|
||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
||||
|
||||
if not title:
|
||||
title = "unknown_window"
|
||||
if not app_name:
|
||||
app_name = "unknown_app"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
}
|
||||
380
agent_v0/agent_v1/window_info_crossplatform.py
Normal file
380
agent_v0/agent_v1/window_info_crossplatform.py
Normal file
@@ -0,0 +1,380 @@
|
||||
# window_info_crossplatform.py
|
||||
"""
|
||||
Récupération des informations sur la fenêtre active - CROSS-PLATFORM
|
||||
|
||||
Supporte:
|
||||
- Linux (X11 via xdotool)
|
||||
- Windows (via pywin32)
|
||||
- macOS (via pyobjc)
|
||||
|
||||
Installation des dépendances:
|
||||
pip install pywin32 # Windows
|
||||
pip install pyobjc-framework-Cocoa # macOS
|
||||
pip install psutil # Tous OS
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
||||
try:
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
||||
return out.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_window_info() -> Dict[str, str]:
|
||||
"""
|
||||
Renvoie un dict :
|
||||
{
|
||||
"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":
|
||||
return _get_window_info_windows()
|
||||
elif system == "Darwin": # macOS
|
||||
return _get_window_info_macos()
|
||||
else:
|
||||
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)
|
||||
|
||||
Nécessite: sudo apt-get install xdotool
|
||||
"""
|
||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
||||
|
||||
app_name: Optional[str] = None
|
||||
if pid_str:
|
||||
pid_str = pid_str.strip()
|
||||
# On récupère le nom du binaire via ps
|
||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
||||
|
||||
if not title:
|
||||
title = "unknown_window"
|
||||
if not app_name:
|
||||
app_name = "unknown_app"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
}
|
||||
|
||||
|
||||
def _get_window_info_windows() -> Dict[str, str]:
|
||||
"""
|
||||
Windows: utilise pywin32 + psutil
|
||||
|
||||
Nécessite: pip install pywin32 psutil
|
||||
"""
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
import psutil
|
||||
|
||||
# Fenêtre au premier plan
|
||||
hwnd = win32gui.GetForegroundWindow()
|
||||
|
||||
# Titre de la fenêtre
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if not title:
|
||||
title = "unknown_window"
|
||||
|
||||
# PID du processus
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
|
||||
# Nom du processus
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
app_name = process.name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
app_name = "unknown_app"
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
# pywin32 ou psutil non installé
|
||||
return {
|
||||
"title": "unknown_window (pywin32 missing)",
|
||||
"app_name": "unknown_app (pywin32 missing)",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"title": f"error: {e}",
|
||||
"app_name": "unknown_app",
|
||||
}
|
||||
|
||||
|
||||
def _get_window_info_macos() -> Dict[str, str]:
|
||||
"""
|
||||
macOS: utilise pyobjc (AppKit)
|
||||
|
||||
Nécessite: pip install pyobjc-framework-Cocoa
|
||||
|
||||
Note: Nécessite les permissions "Accessibility" dans System Preferences
|
||||
"""
|
||||
try:
|
||||
from AppKit import NSWorkspace
|
||||
from Quartz import (
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID
|
||||
)
|
||||
|
||||
# Application active
|
||||
active_app = NSWorkspace.sharedWorkspace().activeApplication()
|
||||
app_name = active_app.get('NSApplicationName', 'unknown_app')
|
||||
|
||||
# Titre de la fenêtre (via Quartz)
|
||||
# On cherche la fenêtre de l'app active qui est au premier plan
|
||||
window_list = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID
|
||||
)
|
||||
|
||||
title = "unknown_window"
|
||||
for window in window_list:
|
||||
owner_name = window.get('kCGWindowOwnerName', '')
|
||||
if owner_name == app_name:
|
||||
window_title = window.get('kCGWindowName', '')
|
||||
if window_title:
|
||||
title = window_title
|
||||
break
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"app_name": app_name,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
# pyobjc non installé
|
||||
return {
|
||||
"title": "unknown_window (pyobjc missing)",
|
||||
"app_name": "unknown_app (pyobjc missing)",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"title": f"error: {e}",
|
||||
"app_name": "unknown_app",
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
58
agent_v0/config.py
Normal file
58
agent_v0/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# config.py
|
||||
"""
|
||||
Configuration de base pour agent_v0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_VERSION = "0.1.0"
|
||||
|
||||
# Dossier racine du projet (là où se trouve ce fichier)
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Chargement automatique de .env.local depuis le répertoire parent
|
||||
def load_env_file(env_path):
|
||||
"""Charge un fichier .env dans les variables d'environnement"""
|
||||
if not env_path.exists():
|
||||
return False
|
||||
|
||||
with open(env_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
return True
|
||||
|
||||
# Charger .env.local depuis le répertoire parent (racine du projet)
|
||||
env_local_path = BASE_DIR.parent / ".env.local"
|
||||
if load_env_file(env_local_path):
|
||||
print(f"[agent_v0] Variables d'environnement chargées depuis {env_local_path}")
|
||||
|
||||
|
||||
|
||||
# Endpoint du serveur RPA Vision V3
|
||||
# En développement local : http://localhost:8000/api/traces/upload
|
||||
# En production : configurer via variable d'environnement
|
||||
import os
|
||||
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:8000/api/traces/upload")
|
||||
|
||||
# Durée max d'une session en secondes (ex: 30 minutes)
|
||||
MAX_SESSION_DURATION_S = 30 * 60
|
||||
|
||||
# Dossier racine local où stocker les sessions (chemin ABSOLU)
|
||||
SESSIONS_ROOT = str(BASE_DIR / "sessions")
|
||||
|
||||
# Dossier et fichier de logs
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
LOG_FILE = LOGS_DIR / "agent_v0.log"
|
||||
|
||||
# Faut-il quitter l'application après un Stop session ?
|
||||
EXIT_AFTER_SESSION = True
|
||||
|
||||
# Création des dossiers si besoin
|
||||
os.makedirs(SESSIONS_ROOT, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
136
agent_v0/deploy/test_replay_diag.py
Normal file
136
agent_v0/deploy/test_replay_diag.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic pour le replay Agent V1 sur Windows.
|
||||
|
||||
Test en 3 etapes :
|
||||
1. Verifie que pynput fonctionne (souris + clavier)
|
||||
2. Verifie la connexion au serveur de replay
|
||||
3. Execute un poll_and_execute de test
|
||||
|
||||
Usage : python test_replay_diag.py
|
||||
(Depuis C:\rpa_vision : .venv\Scripts\python.exe test_replay_diag.py)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Charger .env si present
|
||||
env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')
|
||||
if os.path.exists(env_file):
|
||||
with open(env_file, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, val = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), val.strip())
|
||||
|
||||
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
|
||||
print("=" * 60)
|
||||
print(" DIAGNOSTIC REPLAY AGENT V1")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# ---- Test 1 : pynput ----
|
||||
print("[TEST 1] Verification pynput...")
|
||||
try:
|
||||
from pynput.mouse import Controller as MouseController
|
||||
from pynput.keyboard import Controller as KeyboardController
|
||||
mouse = MouseController()
|
||||
kb = KeyboardController()
|
||||
|
||||
pos = mouse.position
|
||||
print(f" Position souris actuelle : {pos}")
|
||||
if pos is None:
|
||||
print(" PROBLEME : mouse.position = None !")
|
||||
print(" -> pynput n'a pas acces a la session graphique.")
|
||||
print(" -> Le script doit etre lance DEPUIS le bureau Windows,")
|
||||
print(" pas via SSH.")
|
||||
else:
|
||||
print(f" OK : souris detectee a {pos}")
|
||||
|
||||
# Test deplacement souris (petit mouvement)
|
||||
print(" Test deplacement souris dans 2s...")
|
||||
time.sleep(2)
|
||||
old_pos = mouse.position
|
||||
if old_pos:
|
||||
# Deplacement de 50px a droite puis retour
|
||||
mouse.position = (old_pos[0] + 50, old_pos[1])
|
||||
time.sleep(0.3)
|
||||
new_pos = mouse.position
|
||||
mouse.position = old_pos # Retour
|
||||
print(f" Deplacement: {old_pos} -> {new_pos} -> retour")
|
||||
if new_pos and new_pos[0] != old_pos[0]:
|
||||
print(" OK : deplacement souris fonctionne !")
|
||||
else:
|
||||
print(" PROBLEME : la souris n'a pas bouge.")
|
||||
else:
|
||||
print(" SKIP : pas de position souris disponible.")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERREUR pynput : {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
|
||||
# ---- Test 2 : connexion serveur ----
|
||||
print(f"[TEST 2] Connexion au serveur : {SERVER_URL}")
|
||||
try:
|
||||
import requests
|
||||
url = f"{SERVER_URL}/traces/stream/replay/next"
|
||||
resp = requests.get(url, params={"session_id": "diag_test"}, timeout=5)
|
||||
print(f" HTTP {resp.status_code} : {resp.text[:200]}")
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
if data.get("action") is None:
|
||||
print(" OK : serveur accessible, pas d'action en attente.")
|
||||
else:
|
||||
print(f" OK : serveur accessible, ACTION RECUE : {data['action']}")
|
||||
else:
|
||||
print(f" PROBLEME : le serveur a repondu HTTP {resp.status_code}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f" ERREUR CONNEXION : {e}")
|
||||
print(f" -> Verifiez que le serveur tourne sur {SERVER_URL}")
|
||||
except Exception as e:
|
||||
print(f" ERREUR : {e}")
|
||||
|
||||
print()
|
||||
|
||||
# ---- Test 3 : mss (capture ecran) ----
|
||||
print("[TEST 3] Capture ecran (mss)...")
|
||||
try:
|
||||
import mss
|
||||
sct = mss.mss()
|
||||
monitor = sct.monitors[1]
|
||||
print(f" Moniteur principal : {monitor['width']}x{monitor['height']}")
|
||||
raw = sct.grab(monitor)
|
||||
print(f" Capture OK : {raw.size}")
|
||||
except Exception as e:
|
||||
print(f" ERREUR mss : {e}")
|
||||
|
||||
print()
|
||||
|
||||
# ---- Test 4 : typing test (5s delay) ----
|
||||
print("[TEST 4] Test de frappe clavier")
|
||||
print(" -> Ouvrez le Bloc-Notes et placez le curseur dedans.")
|
||||
print(" -> La frappe commencera dans 5 secondes...")
|
||||
time.sleep(5)
|
||||
|
||||
try:
|
||||
from pynput.keyboard import Controller as KeyboardController
|
||||
kb = KeyboardController()
|
||||
test_text = "Hello RPA!"
|
||||
print(f" Frappe de '{test_text}'...")
|
||||
kb.type(test_text)
|
||||
print(f" Frappe terminee. Verifiez si le texte apparait dans le Bloc-Notes.")
|
||||
except Exception as e:
|
||||
print(f" ERREUR frappe : {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" DIAGNOSTIC TERMINE")
|
||||
print("=" * 60)
|
||||
input("Appuyez sur Entree pour fermer...")
|
||||
17
agent_v0/deploy/windows_client/LISEZMOI.txt
Normal file
17
agent_v0/deploy/windows_client/LISEZMOI.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
=== Agent V1 — RPA Vision — Client Windows ===
|
||||
|
||||
Installation :
|
||||
1. Double-cliquer sur setup.bat
|
||||
2. Configurer le serveur : éditer agent_config.json
|
||||
ou définir la variable RPA_SERVER_HOST=192.168.1.x
|
||||
3. Lancer : python run_agent_v1.py
|
||||
|
||||
L'agent apparaît dans la zone de notification (systray).
|
||||
Clic droit pour accéder au menu : démarrer une session,
|
||||
lancer un replay, voir les workflows appris, etc.
|
||||
|
||||
Léa communique par des notifications toast sur votre écran.
|
||||
|
||||
Prérequis :
|
||||
- Python 3.10 ou plus récent
|
||||
- Connexion réseau vers le serveur Linux
|
||||
1
agent_v0/deploy/windows_client/__init__.py
Normal file
1
agent_v0/deploy/windows_client/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# agent_v0 — Agent RPA Vision V3
|
||||
15
agent_v0/deploy/windows_client/agent_config.json
Normal file
15
agent_v0/deploy/windows_client/agent_config.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"user_id": "demo_user",
|
||||
"user_label": "Démo agent_v0",
|
||||
"customer": "Clinique Demo",
|
||||
"training_label": "Facturation_T2A_demo",
|
||||
"notes": "Session réelle avec clics + screenshots + key combos.",
|
||||
"mode": "enriched",
|
||||
"screenshot_mode": "crop",
|
||||
"screenshot_crop_width": 900,
|
||||
"screenshot_crop_height": 700,
|
||||
"capture_hover": true,
|
||||
"hover_min_idle_ms": 700,
|
||||
"capture_scroll": true,
|
||||
"network_save_path": ""
|
||||
}
|
||||
0
agent_v0/deploy/windows_client/agent_v1/__init__.py
Normal file
0
agent_v0/deploy/windows_client/agent_v1/__init__.py
Normal file
64
agent_v0/deploy/windows_client/agent_v1/config.py
Normal file
64
agent_v0/deploy/windows_client/agent_v1/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# agent_v1/config.py
|
||||
"""
|
||||
Configuration avancée pour Agent V1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
|
||||
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
|
||||
# travaillent en coordonnees physiques (pixels reels) au lieu de coordonnees logiques
|
||||
# (virtualisees par le DPI scaling).
|
||||
# Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 pour les API,
|
||||
# ce qui cause des erreurs de positionnement pendant le replay.
|
||||
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
|
||||
except Exception:
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
AGENT_VERSION = "1.0.0"
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS
|
||||
MACHINE_ID = os.environ.get(
|
||||
"RPA_MACHINE_ID",
|
||||
f"{socket.gethostname()}_{platform.system().lower()}",
|
||||
)
|
||||
|
||||
# Dossier racine de l'agent
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Endpoint du serveur Streaming (port 5005)
|
||||
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
||||
|
||||
# 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", "")
|
||||
|
||||
# Paramètres de session
|
||||
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||
|
||||
# Paramètres Vision (Crops pour qwen3-vl)
|
||||
TARGETED_CROP_SIZE = (400, 400)
|
||||
SCREENSHOT_QUALITY = 85
|
||||
|
||||
# Monitoring
|
||||
PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
LOG_FILE = LOGS_DIR / "agent_v1.log"
|
||||
|
||||
# Création des dossiers
|
||||
os.makedirs(SESSIONS_ROOT, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
319
agent_v0/deploy/windows_client/agent_v1/core/captor.py
Normal file
319
agent_v0/deploy/windows_client/agent_v1/core/captor.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# agent_v1/core/captor.py
|
||||
"""
|
||||
Moteur de capture d'événements Agent V1.
|
||||
Capture enrichie avec focus sur le contexte UI pour le stagiaire.
|
||||
|
||||
Fonctionnalités :
|
||||
- Capture clics souris (simple et double-clic)
|
||||
- Capture scroll souris
|
||||
- Capture combos clavier (Ctrl+C, Alt+Tab, etc.)
|
||||
- Buffer de saisie texte : accumule les frappes et émet un événement
|
||||
text_input après 500ms d'inactivité clavier
|
||||
- Surveillance du focus fenêtre
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Callable, Optional, List, Dict, Any, Tuple
|
||||
from pynput import mouse, keyboard
|
||||
from pynput.mouse import Button
|
||||
from pynput.keyboard import Key, KeyCode
|
||||
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Délai d'inactivité avant flush du buffer texte (en secondes)
|
||||
TEXT_FLUSH_DELAY = 0.5
|
||||
# Délai max entre deux clics pour un double-clic (en secondes)
|
||||
DOUBLE_CLICK_DELAY = 0.3
|
||||
# Tolérance en pixels pour considérer deux clics au même endroit
|
||||
DOUBLE_CLICK_TOLERANCE = 10
|
||||
|
||||
|
||||
class EventCaptorV1:
|
||||
def __init__(self, on_event_callback: Callable[[Dict[str, Any]], None]):
|
||||
self.on_event = on_event_callback
|
||||
self.mouse_listener = None
|
||||
self.keyboard_listener = None
|
||||
self.running = False
|
||||
|
||||
# État des touches modificatrices
|
||||
self.modifiers = set()
|
||||
|
||||
# Tracking du focus fenêtre
|
||||
self.last_window = None
|
||||
self._focus_thread = None
|
||||
|
||||
# --- Buffer de saisie texte ---
|
||||
# Lock pour accès thread-safe au buffer (le listener pynput
|
||||
# tourne dans un thread séparé)
|
||||
self._text_lock = threading.Lock()
|
||||
self._text_buffer: list[str] = []
|
||||
# Position de la souris au moment de la première frappe du buffer
|
||||
self._text_start_pos: Optional[Tuple[int, int]] = None
|
||||
# Timer pour le flush après inactivité
|
||||
self._text_flush_timer: Optional[threading.Timer] = None
|
||||
# Dernière position connue de la souris (pour associer le texte
|
||||
# au champ dans lequel l'utilisateur tape)
|
||||
self._last_mouse_pos: Tuple[int, int] = (0, 0)
|
||||
|
||||
# --- Détection double-clic ---
|
||||
# Dernier clic : (x, y, timestamp, button)
|
||||
self._last_click: Optional[Tuple[int, int, float, str]] = None
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
self.mouse_listener = mouse.Listener(
|
||||
on_click=self._on_click,
|
||||
on_scroll=self._on_scroll,
|
||||
on_move=self._on_move
|
||||
)
|
||||
self.keyboard_listener = keyboard.Listener(
|
||||
on_press=self._on_press,
|
||||
on_release=self._on_release
|
||||
)
|
||||
|
||||
self.mouse_listener.start()
|
||||
self.keyboard_listener.start()
|
||||
|
||||
# Thread de surveillance du focus fenêtre (Proactif)
|
||||
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
|
||||
self._focus_thread.start()
|
||||
|
||||
logger.info("Agent V1 Captor démarré")
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
# Flush du buffer texte restant avant arrêt
|
||||
self._flush_text_buffer()
|
||||
# Annuler le timer s'il est en cours
|
||||
with self._text_lock:
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = None
|
||||
if self.mouse_listener: self.mouse_listener.stop()
|
||||
if self.keyboard_listener: self.keyboard_listener.stop()
|
||||
logger.info("Agent V1 Captor arrêté")
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Souris
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def _on_move(self, x, y):
|
||||
"""Mémorise la position souris pour l'associer aux événements texte."""
|
||||
self._last_mouse_pos = (x, y)
|
||||
|
||||
def _on_click(self, x, y, button, pressed):
|
||||
if not pressed:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
|
||||
# --- Flush du buffer texte : l'utilisateur a cliqué, donc
|
||||
# il change probablement de champ ---
|
||||
self._flush_text_buffer()
|
||||
|
||||
# --- Détection double-clic ---
|
||||
if self._last_click is not None:
|
||||
lx, ly, lt, lb = self._last_click
|
||||
# Même bouton, même zone, délai court → double-clic
|
||||
if (button.name == lb
|
||||
and abs(x - lx) <= DOUBLE_CLICK_TOLERANCE
|
||||
and abs(y - ly) <= DOUBLE_CLICK_TOLERANCE
|
||||
and (now - lt) <= DOUBLE_CLICK_DELAY):
|
||||
event = {
|
||||
"type": "double_click",
|
||||
"button": button.name,
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self.on_event(event)
|
||||
# Réinitialiser pour éviter un triple-clic = 2 double-clics
|
||||
self._last_click = None
|
||||
return
|
||||
|
||||
# Clic simple — on le mémorise pour comparer au prochain
|
||||
self._last_click = (x, y, now, button.name)
|
||||
event = {
|
||||
"type": "mouse_click",
|
||||
"button": button.name,
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self.on_event(event)
|
||||
|
||||
def _on_scroll(self, x, y, dx, dy):
|
||||
event = {
|
||||
"type": "mouse_scroll",
|
||||
"pos": (x, y),
|
||||
"delta": (dx, dy),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.on_event(event)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Clavier
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def _on_press(self, key):
|
||||
# Gestion des touches modificatrices
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.add("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.add("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.add("shift")
|
||||
|
||||
# --- Combos avec modificateur (sauf Shift seul) ---
|
||||
# Shift seul n'est pas un « vrai » modificateur pour les combos :
|
||||
# Shift+a = 'A' = saisie texte, pas un raccourci.
|
||||
# On considère un combo seulement si Ctrl ou Alt est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt"}
|
||||
if has_real_modifier:
|
||||
key_name = self._get_key_name(key)
|
||||
if key_name and key_name not in ("ctrl", "alt", "shift"):
|
||||
# Un combo interrompt la saisie texte en cours
|
||||
self._flush_text_buffer()
|
||||
event = {
|
||||
"type": "key_combo",
|
||||
"keys": list(self.modifiers) + [key_name],
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.on_event(event)
|
||||
return
|
||||
|
||||
# --- Saisie texte (pas de Ctrl/Alt enfoncé) ---
|
||||
self._handle_text_key(key)
|
||||
|
||||
def _handle_text_key(self, key):
|
||||
"""Gère l'accumulation des frappes texte dans le buffer.
|
||||
|
||||
Touches spéciales :
|
||||
- Backspace : supprime le dernier caractère du buffer
|
||||
- Enter / Tab : flush immédiat + émission de l'événement
|
||||
- Escape : vide le buffer sans émettre
|
||||
"""
|
||||
with self._text_lock:
|
||||
# --- Touches spéciales ---
|
||||
if key == Key.backspace:
|
||||
if self._text_buffer:
|
||||
self._text_buffer.pop()
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
if key == Key.escape:
|
||||
# Annuler la saisie en cours
|
||||
self._text_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
|
||||
if key in (Key.enter, Key.tab):
|
||||
# Flush immédiat — on relâche le lock avant d'appeler
|
||||
# _flush_text_buffer (qui prend aussi le lock)
|
||||
pass # on sort du with et on flush après
|
||||
|
||||
elif key == Key.space:
|
||||
# Espace = caractère normal
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(" ")
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
elif isinstance(key, KeyCode) and key.char is not None:
|
||||
# Caractère alphanumérique / ponctuation
|
||||
# pynput renvoie déjà le bon caractère selon le layout
|
||||
# (AZERTY inclus) — on ne convertit rien.
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(key.char)
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
else:
|
||||
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
|
||||
return
|
||||
|
||||
# Si on arrive ici, c'est Enter ou Tab → flush immédiat
|
||||
self._flush_text_buffer()
|
||||
|
||||
def _reset_flush_timer(self):
|
||||
"""Réarme le timer de flush après chaque frappe.
|
||||
|
||||
Doit être appelé avec self._text_lock déjà acquis.
|
||||
"""
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = threading.Timer(
|
||||
TEXT_FLUSH_DELAY, self._flush_text_buffer
|
||||
)
|
||||
self._text_flush_timer.daemon = True
|
||||
self._text_flush_timer.start()
|
||||
|
||||
def _cancel_flush_timer(self):
|
||||
"""Annule le timer de flush sans émettre.
|
||||
|
||||
Doit être appelé avec self._text_lock déjà acquis.
|
||||
"""
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = None
|
||||
|
||||
def _flush_text_buffer(self):
|
||||
"""Émet un événement text_input avec le contenu du buffer, puis
|
||||
le vide. Thread-safe — peut être appelé depuis le timer, le
|
||||
listener souris ou le listener clavier."""
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
# Rien à émettre
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
text = "".join(self._text_buffer)
|
||||
pos = self._text_start_pos or self._last_mouse_pos
|
||||
self._text_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
|
||||
# Émission hors du lock pour éviter un deadlock si le callback
|
||||
# est lent ou prend d'autres locks
|
||||
event = {
|
||||
"type": "text_input",
|
||||
"text": text,
|
||||
"pos": pos,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
logger.debug(f"text_input émis : {len(text)} caractères")
|
||||
self.on_event(event)
|
||||
|
||||
def _on_release(self, key):
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.discard("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.discard("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.discard("shift")
|
||||
|
||||
def _watch_window_focus(self):
|
||||
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""
|
||||
# Importation relative simple
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
info = get_active_window_info()
|
||||
if info and info != self.last_window:
|
||||
event = {
|
||||
"type": "window_focus_change",
|
||||
"from": self.last_window,
|
||||
"to": info,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.last_window = info
|
||||
self.on_event(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur focus window: {e}")
|
||||
time.sleep(0.5)
|
||||
2108
agent_v0/deploy/windows_client/agent_v1/core/executor.py
Normal file
2108
agent_v0/deploy/windows_client/agent_v1/core/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
214
agent_v0/deploy/windows_client/agent_v1/core/grounding.py
Normal file
214
agent_v0/deploy/windows_client/agent_v1/core/grounding.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# agent_v1/core/grounding.py
|
||||
"""
|
||||
Module Grounding — localisation pure d'éléments UI sur l'écran.
|
||||
|
||||
Responsabilité unique : "Trouve l'élément X sur l'écran et retourne ses coordonnées."
|
||||
Ne prend AUCUNE décision. Si l'élément n'est pas trouvé → retourne NOT_FOUND.
|
||||
|
||||
Stratégies disponibles (cascade configurable) :
|
||||
1. Serveur SomEngine + VLM (GPU distant)
|
||||
2. Template matching local (CPU, ~10ms)
|
||||
3. VLM local direct (CPU/GPU local)
|
||||
|
||||
Séparé de Policy (qui décide quoi faire quand grounding échoue).
|
||||
Ref: docs/PLAN_ACTEUR_V1.md — Architecture MICRO (grounding + exécution)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundingResult:
|
||||
"""Résultat d'une tentative de localisation visuelle."""
|
||||
found: bool # L'élément a été trouvé
|
||||
x_pct: float = 0.0 # Position X en % (0.0-1.0)
|
||||
y_pct: float = 0.0 # Position Y en % (0.0-1.0)
|
||||
method: str = "" # Méthode utilisée (server_som, anchor_template, vlm_direct...)
|
||||
score: float = 0.0 # Confiance (0.0-1.0)
|
||||
elapsed_ms: float = 0.0 # Temps de résolution
|
||||
detail: str = "" # Info supplémentaire (label trouvé, raison échec)
|
||||
raw: Optional[Dict] = None # Données brutes du resolver (pour debug)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"found": self.found,
|
||||
"x_pct": self.x_pct,
|
||||
"y_pct": self.y_pct,
|
||||
"method": self.method,
|
||||
"score": round(self.score, 3),
|
||||
"elapsed_ms": round(self.elapsed_ms, 1),
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
# Résultat singleton pour "pas trouvé"
|
||||
NOT_FOUND = GroundingResult(found=False, detail="Aucune méthode n'a trouvé l'élément")
|
||||
|
||||
|
||||
class GroundingEngine:
|
||||
"""Moteur de localisation visuelle d'éléments UI.
|
||||
|
||||
Encapsule la cascade de résolution (serveur → template → VLM local)
|
||||
avec une interface unifiée. Ne prend aucune décision — c'est le rôle
|
||||
de PolicyEngine.
|
||||
|
||||
Usage :
|
||||
engine = GroundingEngine(executor)
|
||||
result = engine.locate(screenshot_b64, target_spec, screen_w, screen_h)
|
||||
if result.found:
|
||||
click(result.x_pct, result.y_pct)
|
||||
"""
|
||||
|
||||
def __init__(self, executor):
|
||||
"""
|
||||
Args:
|
||||
executor: ActionExecutorV1 — fournit les méthodes de résolution existantes.
|
||||
"""
|
||||
self._executor = executor
|
||||
|
||||
def locate(
|
||||
self,
|
||||
server_url: str,
|
||||
target_spec: Dict[str, Any],
|
||||
fallback_x: float,
|
||||
fallback_y: float,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
strategies: Optional[List[str]] = None,
|
||||
) -> GroundingResult:
|
||||
"""Localiser un élément UI sur l'écran.
|
||||
|
||||
Exécute la cascade de stratégies dans l'ordre et retourne
|
||||
dès qu'une stratégie trouve l'élément.
|
||||
|
||||
Args:
|
||||
server_url: URL du serveur (SomEngine + VLM GPU)
|
||||
target_spec: Spécification de la cible (by_text, anchor, vlm_description...)
|
||||
fallback_x, fallback_y: Coordonnées de fallback (enregistrement)
|
||||
screen_width, screen_height: Résolution écran
|
||||
strategies: Liste ordonnée de stratégies à essayer.
|
||||
Par défaut : ["server", "template", "vlm_local"]
|
||||
|
||||
Returns:
|
||||
GroundingResult avec found=True et coordonnées, ou NOT_FOUND
|
||||
"""
|
||||
if strategies is None:
|
||||
strategies = ["server", "template", "vlm_local"]
|
||||
|
||||
# ── Apprentissage : réordonner les stratégies selon l'historique ──
|
||||
# Si le Learning sait quelle méthode marche pour cette cible,
|
||||
# la mettre en premier. C'est la boucle d'apprentissage.
|
||||
learned = target_spec.get("_learned_strategy", "")
|
||||
if learned:
|
||||
strategy_map = {
|
||||
"som_text_match": "server",
|
||||
"grounding_vlm": "server",
|
||||
"server_som": "server",
|
||||
"anchor_template": "template",
|
||||
"template_matching": "template",
|
||||
"hybrid_text_direct": "vlm_local",
|
||||
"hybrid_vlm_text": "vlm_local",
|
||||
"vlm_direct": "vlm_local",
|
||||
}
|
||||
preferred = strategy_map.get(learned, "")
|
||||
if preferred and preferred in strategies:
|
||||
strategies = [preferred] + [s for s in strategies if s != preferred]
|
||||
logger.info(
|
||||
f"Grounding: stratégie réordonnée par l'apprentissage → "
|
||||
f"{strategies} (learned={learned})"
|
||||
)
|
||||
|
||||
t_start = time.time()
|
||||
screenshot_b64 = self._executor._capture_screenshot_b64(max_width=0, quality=75)
|
||||
if not screenshot_b64:
|
||||
return GroundingResult(
|
||||
found=False, detail="Capture screenshot échouée",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
for strategy in strategies:
|
||||
result = self._try_strategy(
|
||||
strategy, server_url, screenshot_b64, target_spec,
|
||||
fallback_x, fallback_y, screen_width, screen_height,
|
||||
)
|
||||
if result.found:
|
||||
result.elapsed_ms = (time.time() - t_start) * 1000
|
||||
return result
|
||||
|
||||
return GroundingResult(
|
||||
found=False,
|
||||
detail=f"Toutes les stratégies ont échoué ({', '.join(strategies)})",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
def _try_strategy(
|
||||
self,
|
||||
strategy: str,
|
||||
server_url: str,
|
||||
screenshot_b64: str,
|
||||
target_spec: Dict[str, Any],
|
||||
fallback_x: float,
|
||||
fallback_y: float,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
) -> GroundingResult:
|
||||
"""Essayer une stratégie de grounding unique."""
|
||||
|
||||
if strategy == "server" and server_url:
|
||||
raw = self._executor._server_resolve_target(
|
||||
server_url, screenshot_b64, target_spec,
|
||||
fallback_x, fallback_y, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method=raw.get("method", "server"),
|
||||
score=raw.get("score", 0.0),
|
||||
detail=raw.get("matched_element", {}).get("label", ""),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
elif strategy == "template":
|
||||
anchor_b64 = target_spec.get("anchor_image_base64", "")
|
||||
if anchor_b64:
|
||||
raw = self._executor._template_match_anchor(
|
||||
screenshot_b64, anchor_b64, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method="anchor_template",
|
||||
score=raw.get("score", 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
elif strategy == "vlm_local":
|
||||
by_text = target_spec.get("by_text", "")
|
||||
vlm_desc = target_spec.get("vlm_description", "")
|
||||
if vlm_desc or by_text:
|
||||
raw = self._executor._hybrid_vlm_resolve(
|
||||
screenshot_b64, target_spec, screen_width, screen_height,
|
||||
)
|
||||
if raw and raw.get("resolved"):
|
||||
return GroundingResult(
|
||||
found=True,
|
||||
x_pct=raw["x_pct"],
|
||||
y_pct=raw["y_pct"],
|
||||
method=raw.get("method", "vlm_local"),
|
||||
score=raw.get("score", 0.0),
|
||||
detail=raw.get("matched_element", {}).get("label", ""),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
return GroundingResult(found=False, method=strategy, detail=f"{strategy}: pas trouvé")
|
||||
152
agent_v0/deploy/windows_client/agent_v1/core/policy.py
Normal file
152
agent_v0/deploy/windows_client/agent_v1/core/policy.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# agent_v1/core/policy.py
|
||||
"""
|
||||
Module Policy — décisions intelligentes quand le grounding échoue.
|
||||
|
||||
Responsabilité unique : "Le Grounding dit NOT_FOUND. Que fait-on ?"
|
||||
Ne localise AUCUN élément — c'est le rôle du Grounding.
|
||||
|
||||
Décisions possibles :
|
||||
- RETRY : re-tenter le grounding (après popup fermée, par exemple)
|
||||
- SKIP : l'action n'est plus nécessaire (état déjà atteint)
|
||||
- ABORT : arrêter le workflow (état incohérent)
|
||||
- SUPERVISE : rendre la main à l'utilisateur
|
||||
|
||||
Séparé de Grounding (qui localise les éléments).
|
||||
Ref: docs/PLAN_ACTEUR_V1.md — Architecture MÉSO (acteur intelligent)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Decision(Enum):
|
||||
"""Décisions possibles quand le grounding échoue."""
|
||||
RETRY = "retry" # Re-tenter (après correction : popup fermée, navigation...)
|
||||
SKIP = "skip" # Action inutile (état déjà atteint)
|
||||
ABORT = "abort" # Arrêter le workflow (état incohérent)
|
||||
SUPERVISE = "supervise" # Rendre la main à l'utilisateur (Léa dit "je bloque")
|
||||
CONTINUE = "continue" # Continuer malgré l'échec (action non critique)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyDecision:
|
||||
"""Résultat d'une décision Policy."""
|
||||
decision: Decision
|
||||
reason: str # Explication de la décision
|
||||
action_taken: str = "" # Action corrective effectuée (ex: "popup fermée")
|
||||
elapsed_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"decision": self.decision.value,
|
||||
"reason": self.reason,
|
||||
"action_taken": self.action_taken,
|
||||
"elapsed_ms": round(self.elapsed_ms, 1),
|
||||
}
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Moteur de décision quand le grounding échoue.
|
||||
|
||||
Cascade de décision :
|
||||
1. Popup détectée ? → fermer et RETRY
|
||||
2. Acteur gemma4 → SKIP / ABORT / SUPERVISE
|
||||
3. Fallback → SUPERVISE (rendre la main)
|
||||
|
||||
Usage :
|
||||
policy = PolicyEngine(executor)
|
||||
decision = policy.decide(action, target_spec, grounding_result)
|
||||
if decision.decision == Decision.RETRY:
|
||||
# re-tenter le grounding
|
||||
elif decision.decision == Decision.SKIP:
|
||||
# marquer comme réussi, passer à la suite
|
||||
"""
|
||||
|
||||
def __init__(self, executor):
|
||||
self._executor = executor
|
||||
|
||||
def decide(
|
||||
self,
|
||||
action: Dict[str, Any],
|
||||
target_spec: Dict[str, Any],
|
||||
retry_count: int = 0,
|
||||
max_retries: int = 1,
|
||||
) -> PolicyDecision:
|
||||
"""Décider quoi faire quand le grounding a échoué.
|
||||
|
||||
Cascade :
|
||||
1. Si c'est le premier essai → tenter de fermer une popup → RETRY
|
||||
2. Si retry déjà fait → demander à l'acteur gemma4
|
||||
3. Selon gemma4 : SKIP, ABORT, ou SUPERVISE
|
||||
|
||||
Args:
|
||||
action: L'action qui a échoué
|
||||
target_spec: La cible non trouvée
|
||||
retry_count: Nombre de retries déjà faits
|
||||
max_retries: Maximum de retries autorisés
|
||||
"""
|
||||
t_start = time.time()
|
||||
|
||||
# ── Étape 1 : Tentative de fermeture popup (premier essai) ──
|
||||
if retry_count == 0:
|
||||
popup_handled = self._try_close_popup()
|
||||
if popup_handled:
|
||||
return PolicyDecision(
|
||||
decision=Decision.RETRY,
|
||||
reason="Popup détectée et fermée, re-tentative",
|
||||
action_taken="popup_closed",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# ── Étape 2 : Max retries atteint → acteur gemma4 ──
|
||||
if retry_count >= max_retries:
|
||||
actor_decision = self._ask_actor(action, target_spec)
|
||||
|
||||
if actor_decision == "PASSER":
|
||||
return PolicyDecision(
|
||||
decision=Decision.SKIP,
|
||||
reason="Acteur gemma4 : l'état est déjà atteint",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
elif actor_decision == "STOPPER":
|
||||
return PolicyDecision(
|
||||
decision=Decision.ABORT,
|
||||
reason="Acteur gemma4 : état incohérent, arrêt",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
else:
|
||||
# EXECUTER ou inconnu → pause supervisée
|
||||
return PolicyDecision(
|
||||
decision=Decision.SUPERVISE,
|
||||
reason=f"Acteur gemma4 : {actor_decision}, pause supervisée",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# ── Étape 3 : Encore des retries disponibles → RETRY ──
|
||||
return PolicyDecision(
|
||||
decision=Decision.RETRY,
|
||||
reason=f"Retry {retry_count + 1}/{max_retries}",
|
||||
elapsed_ms=(time.time() - t_start) * 1000,
|
||||
)
|
||||
|
||||
def _try_close_popup(self) -> bool:
|
||||
"""Tenter de fermer une popup via le handler VLM existant."""
|
||||
try:
|
||||
return self._executor._handle_popup_vlm()
|
||||
except Exception as e:
|
||||
logger.debug(f"Policy: popup handler échoué : {e}")
|
||||
return False
|
||||
|
||||
def _ask_actor(self, action: Dict, target_spec: Dict) -> str:
|
||||
"""Demander à gemma4 de décider (PASSER/EXECUTER/STOPPER)."""
|
||||
try:
|
||||
return self._executor._actor_decide(action, target_spec)
|
||||
except Exception as e:
|
||||
logger.debug(f"Policy: acteur gemma4 échoué : {e}")
|
||||
return "EXECUTER" # Fallback → supervisé
|
||||
294
agent_v0/deploy/windows_client/agent_v1/core/uia_helper.py
Normal file
294
agent_v0/deploy/windows_client/agent_v1/core/uia_helper.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# core/workflow/uia_helper.py
|
||||
"""
|
||||
UIAHelper — Wrapper Python pour lea_uia.exe (helper Rust UI Automation).
|
||||
|
||||
Expose une API Python simple pour interroger UIA via le binaire Rust.
|
||||
Communique via subprocess + stdin/stdout JSON.
|
||||
|
||||
Pourquoi un helper Rust ?
|
||||
- 5-10x plus rapide que pywinauto (10-20ms vs 50-200ms)
|
||||
- Binaire standalone ~500 Ko, aucune dépendance runtime
|
||||
- Pas de problèmes de threading COM en Python
|
||||
- Crash-safe (le crash du helper n'affecte pas l'agent Python)
|
||||
|
||||
Architecture :
|
||||
Python executor
|
||||
↓ subprocess.run
|
||||
lea_uia.exe query --x 812 --y 436
|
||||
↓ UIA API Windows
|
||||
JSON response
|
||||
↓ stdout
|
||||
Python executor parse JSON
|
||||
|
||||
Si lea_uia.exe n'est pas disponible (Linux, binaire absent, crash) :
|
||||
toutes les méthodes retournent None → fallback vision automatique.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout par défaut pour les appels UIA (en secondes)
|
||||
_DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
# Masquer la fenêtre console lors du spawn de lea_uia.exe sur Windows.
|
||||
# Sans ce flag, chaque appel (à chaque clic utilisateur pendant
|
||||
# l'enregistrement) fait apparaître une fenêtre cmd noire brièvement
|
||||
# visible à l'écran → ralentit la souris et pollue les screenshots
|
||||
# capturés (le VLM peut "voir" le chemin lea_uia.exe comme texte cliqué).
|
||||
#
|
||||
# La valeur 0x08000000 correspond à CREATE_NO_WINDOW défini dans
|
||||
# l'API Windows. Sur Linux/Mac, la valeur est 0 et `creationflags`
|
||||
# est ignoré. getattr() gère le cas où Python expose déjà la constante
|
||||
# sur Windows.
|
||||
if platform.system() == "Windows":
|
||||
_SUBPROCESS_CREATION_FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||
else:
|
||||
_SUBPROCESS_CREATION_FLAGS = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UiaElement:
|
||||
"""Représentation Python d'un élément UIA."""
|
||||
name: str = ""
|
||||
control_type: str = ""
|
||||
class_name: str = ""
|
||||
automation_id: str = ""
|
||||
bounding_rect: Tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
is_enabled: bool = False
|
||||
is_offscreen: bool = True
|
||||
parent_path: List[Dict[str, str]] = field(default_factory=list)
|
||||
process_name: str = ""
|
||||
|
||||
def center(self) -> Tuple[int, int]:
|
||||
"""Retourner le centre du rectangle (pixels)."""
|
||||
x1, y1, x2, y2 = self.bounding_rect
|
||||
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
def width(self) -> int:
|
||||
return self.bounding_rect[2] - self.bounding_rect[0]
|
||||
|
||||
def height(self) -> int:
|
||||
return self.bounding_rect[3] - self.bounding_rect[1]
|
||||
|
||||
def is_clickable(self) -> bool:
|
||||
"""Peut-on cliquer dessus ?"""
|
||||
return (
|
||||
self.is_enabled
|
||||
and not self.is_offscreen
|
||||
and self.width() > 0
|
||||
and self.height() > 0
|
||||
)
|
||||
|
||||
def path_signature(self) -> str:
|
||||
"""Signature du chemin parent (pour retrouver l'élément)."""
|
||||
parts = [f"{p['control_type']}[{p['name']}]" for p in self.parent_path if p.get("name")]
|
||||
parts.append(f"{self.control_type}[{self.name}]")
|
||||
return " > ".join(parts)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"control_type": self.control_type,
|
||||
"class_name": self.class_name,
|
||||
"automation_id": self.automation_id,
|
||||
"bounding_rect": list(self.bounding_rect),
|
||||
"is_enabled": self.is_enabled,
|
||||
"is_offscreen": self.is_offscreen,
|
||||
"parent_path": self.parent_path,
|
||||
"process_name": self.process_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "UiaElement":
|
||||
rect = d.get("bounding_rect", [0, 0, 0, 0])
|
||||
if isinstance(rect, list) and len(rect) >= 4:
|
||||
rect = tuple(rect[:4])
|
||||
else:
|
||||
rect = (0, 0, 0, 0)
|
||||
return cls(
|
||||
name=d.get("name", ""),
|
||||
control_type=d.get("control_type", ""),
|
||||
class_name=d.get("class_name", ""),
|
||||
automation_id=d.get("automation_id", ""),
|
||||
bounding_rect=rect,
|
||||
is_enabled=d.get("is_enabled", False),
|
||||
is_offscreen=d.get("is_offscreen", True),
|
||||
parent_path=d.get("parent_path", []),
|
||||
process_name=d.get("process_name", ""),
|
||||
)
|
||||
|
||||
|
||||
class UIAHelper:
|
||||
"""Wrapper Python pour lea_uia.exe."""
|
||||
|
||||
def __init__(self, helper_path: str = "", timeout: float = _DEFAULT_TIMEOUT):
|
||||
self._helper_path = helper_path or self._find_helper()
|
||||
self._timeout = timeout
|
||||
self._available = self._check_available()
|
||||
|
||||
def _find_helper(self) -> str:
|
||||
"""Trouver lea_uia.exe dans les emplacements standards."""
|
||||
candidates = [
|
||||
r"C:\Lea\helpers\lea_uia.exe",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..",
|
||||
"agent_rust", "lea_uia", "target",
|
||||
"x86_64-pc-windows-gnu", "release", "lea_uia.exe"),
|
||||
"./helpers/lea_uia.exe",
|
||||
"lea_uia.exe",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return os.path.abspath(path)
|
||||
return ""
|
||||
|
||||
def _check_available(self) -> bool:
|
||||
"""Vérifier que le helper est utilisable (Windows + binaire + health OK)."""
|
||||
if platform.system() != "Windows":
|
||||
logger.debug("UIAHelper: Linux/Mac — helper désactivé")
|
||||
return False
|
||||
if not self._helper_path:
|
||||
logger.debug("UIAHelper: lea_uia.exe introuvable")
|
||||
return False
|
||||
if not os.path.isfile(self._helper_path):
|
||||
logger.debug(f"UIAHelper: chemin invalide {self._helper_path}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def helper_path(self) -> str:
|
||||
return self._helper_path
|
||||
|
||||
def _run(self, args: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Exécuter lea_uia.exe avec les arguments et parser le JSON."""
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._helper_path] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._timeout,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=_SUBPROCESS_CREATION_FLAGS,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug(
|
||||
f"UIAHelper: exit code {result.returncode}, "
|
||||
f"stderr: {result.stderr[:200]}"
|
||||
)
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return None
|
||||
return json.loads(output)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug(f"UIAHelper: timeout ({self._timeout}s) sur {args}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"UIAHelper: JSON invalide — {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"UIAHelper: erreur {e}")
|
||||
return None
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Vérifier que UIA répond."""
|
||||
data = self._run(["health"])
|
||||
return data is not None and data.get("status") == "ok"
|
||||
|
||||
def query_at(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
with_parents: bool = True,
|
||||
) -> Optional[UiaElement]:
|
||||
"""Récupérer l'élément UIA à une position écran.
|
||||
|
||||
Args:
|
||||
x, y: Coordonnées pixel absolues
|
||||
with_parents: Inclure la hiérarchie des parents
|
||||
|
||||
Returns:
|
||||
UiaElement si trouvé, None sinon (pas d'élément ou UIA indispo)
|
||||
"""
|
||||
args = ["query", "--x", str(x), "--y", str(y)]
|
||||
if not with_parents:
|
||||
args.append("--with-parents=false")
|
||||
|
||||
data = self._run(args)
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
def find_by_name(
|
||||
self,
|
||||
name: str,
|
||||
control_type: Optional[str] = None,
|
||||
automation_id: Optional[str] = None,
|
||||
window: Optional[str] = None,
|
||||
timeout_ms: int = 2000,
|
||||
) -> Optional[UiaElement]:
|
||||
"""Rechercher un élément par son nom (+ filtres optionnels).
|
||||
|
||||
Args:
|
||||
name: Nom exact de l'élément
|
||||
control_type: Type de contrôle (Button, Edit, MenuItem...)
|
||||
automation_id: ID d'automation
|
||||
window: Restreindre à une fenêtre spécifique
|
||||
timeout_ms: Timeout de recherche en millisecondes
|
||||
"""
|
||||
args = ["find", "--name", name, "--timeout-ms", str(timeout_ms)]
|
||||
if control_type:
|
||||
args.extend(["--control-type", control_type])
|
||||
if automation_id:
|
||||
args.extend(["--automation-id", automation_id])
|
||||
if window:
|
||||
args.extend(["--window", window])
|
||||
|
||||
data = self._run(args)
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
def capture_focused(self, max_depth: int = 3) -> Optional[UiaElement]:
|
||||
"""Capturer l'élément ayant le focus + son contexte."""
|
||||
data = self._run(["capture", "--max-depth", str(max_depth)])
|
||||
if not data or data.get("status") != "ok":
|
||||
return None
|
||||
|
||||
elem_data = data.get("element")
|
||||
if not elem_data:
|
||||
return None
|
||||
return UiaElement.from_dict(elem_data)
|
||||
|
||||
|
||||
# Instance globale partagée (singleton léger)
|
||||
_SHARED_HELPER: Optional[UIAHelper] = None
|
||||
|
||||
|
||||
def get_shared_helper() -> UIAHelper:
|
||||
"""Retourner une instance partagée de UIAHelper."""
|
||||
global _SHARED_HELPER
|
||||
if _SHARED_HELPER is None:
|
||||
_SHARED_HELPER = UIAHelper()
|
||||
return _SHARED_HELPER
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user