docs: add POC specs, handoffs, and research notes

This commit is contained in:
Dom
2026-06-02 16:28:34 +02:00
parent 18ed6cb751
commit f2e9aac6b7
86 changed files with 27615 additions and 25 deletions

View File

@@ -0,0 +1,268 @@
# Audit chaîne apprentissage modèle IA — 2026-06-01
> **DRAFT audit factuel — lecture seule, pas encore appliqué.**
>
> Date : 2026-06-01 22:00 Europe/Paris
> Auteurs : agent Explore Claude (audit primaire) + Claude (synthèse + matérialisation fichier)
> Statut : DRAFT — relecture Dom/Codex/Qwen attendue
> Origine demande : Dom 2026-06-01 ~21:40 — « tu pourrais lancer un agent explorateur pour nous remonter la chaîne exact d'apprentissage du modèle d'IA sur lequel j'ai travaillé. Le code existe, mais je pense qu'il a été débranché... »
## TL;DR — Constat fort
**L'intuition de Dom était JUSTE.** La chaîne d'apprentissage est **partiellement débranchée** depuis plusieurs semaines/mois. Les composants nécessaires pour implémenter ce que Dom a explicité dans ses 5 messages du 2026-06-01 20:46-21:27 (auto-évaluation par répétition, fusion/regroupement compétences immuables, versioning adaptateurs UI, portabilité du modèle appris) **existent déjà** dans le repo :
- `core/learning/continuous_learner.py`**644 lignes**
- `core/learning/feedback_processor.py`**176 lignes**
- `PrototypeVersionManager` (support de ContinuousLearner)
- `TargetMemoryStore`, `VersionedStore` (supports)
**Et ils sont tous orphelins** : ils ne sont pas importés par les points d'entrée actifs (`api_stream.py`, `run_worker.py`, `agent_chat/app.py`, `web_dashboard/app.py`, etc.).
**En plus** : le **worker VLM** (le composant qui retraite les sessions finalisées avec ScreenAnalyzer/CLIP/FAISS/GraphBuilder) **n'a traité aucune session depuis 5 jours** (queue vide). Sessions accumulées non passées par le pipeline d'enrichissement profond.
## Section meta — Constat de méthode (à dire franchement)
> Dom 2026-06-01 ~21:55 : « on vient de passer presque 7 jours à refaire ce que j'avais déjà fait. Il faut arrêter de réinventer la roue. »
C'est un constat factuel et juste. Cet audit (le seul à avoir cartographié l'existant) aurait dû être fait **avant** :
- de spécifier P1-SEMANTIQUE comme une nouvelle Phase 2.5 ;
- de proposer un `LoopDetector` proactif comme « bonus » alors que `ContinuousLearner` couvre ce besoin ;
- de discuter de « désapprentissage » (notion que Dom a explicitement rejetée 21:27) alors que `PrototypeVersionManager` gère déjà le versioning des prototypes ;
- de proposer de nouveaux mécanismes de fusion de compétences alors que `FeedbackProcessor` est conçu pour ça.
**Pourquoi cet oubli ?** Trois facteurs cumulés :
1. `docs/POC/` (5 docs Dom déposés du 28-29/05 et 01/06) a été lue **après** rédaction du plan POC Claude du jour (mémorisé comme erreur de méthode dans `feedback_lire_docs_poc_avant_depot.md`).
2. **Aucun agent n'a été missionné en début de journée pour cartographier l'existant** dans `core/learning/`, `core/healing/`, `core/cognition/`. Mon audit Explore de 17:00 a flagué `ContinuousLearner` et `RecoveryLogger` comme orphelins mais sans alarmer sur le fait que ces orphelins **étaient précisément ce qui était demandé**.
3. Codex a été en mode urgence patch (P0 régression, dashboard test, etc.) et Claude en mode livraison agressive (5 livraisons P1 dans la journée) **sans pause cartographie**.
**Décision opérationnelle à acter** : **avant tout nouveau module Léa learning, lancer un agent Explore qui vérifie ce qui existe dans `core/learning/`, `core/cognition/`, `core/healing/`, `core/training/`**. Ne pas spec/coder un module avant d'avoir confirmé qu'il n'a pas déjà été codé et débranché par Dom dans une session antérieure.
## §1 — Schéma de la chaîne attendue
```
[Phase 1 — Capture]
PC Windows agent_v1 → push frames + actions + events
data/training/live_sessions/<machine>/<session>/
├ shots/*.png
├ actions.jsonl
└ events.jsonl
finalize() côté api_stream.py
enqueue → data/training/_worker_queue.txt ← ⚠️ EXISTE, vide depuis 5 jours
[Phase 2 — Enrichissement post-session (worker VLM)]
run_worker.py poll _worker_queue.txt (10s interval)
StreamProcessor.reprocess_session()
├ ScreenAnalyzer (VLM lecture sémantique)
├ CLIPEmbedder (embeddings UI)
├ FAISS index update
├ _enrich_actions_with_intentions (Ollama gemma4 → intention/avant/après)
└ GraphBuilder (transitions états)
data/training/.../enriched_*.jsonl + index.faiss
[Phase 3 — Construction WorkflowIR]
build_replay_from_raw_events() → WorkflowIR
data/workflows/<session_id>.json OU data/competences/candidate/<slug>.yaml
selon le chemin (legacy workflow vs nouveau cycle Léa-first 01/06)
[Phase 4 — Apprentissage continu (CŒUR DU DÉBAT)]
┌──────────────────────────────────────────────────────┐
│ ContinuousLearner (644 lignes, ORPHELIN) │
│ ├ EMA online sur prototypes │
│ ├ Détection dérive UI (variance temporelle) │
│ ├ Variantes de prototypes (clustering) │
│ ├ TargetMemoryStore : mémoire des éléments cibles │
│ └ PrototypeVersionManager : versioning rollback │
│ │
│ FeedbackProcessor (176 lignes, ORPHELIN) │
│ ├ Boucle feedback humain → ajustement prototype │
│ └ Fusion observations multiples → compétence │
└──────────────────────────────────────────────────────┘
| Doit être déclenché par : nouvelle session retraitée
| Doit produire : score confiance, variantes, dérive
data/learning/prototypes_<state>.json (présent ? à vérifier disque)
data/learning/feedback_log.jsonl (présent ? à vérifier disque)
[Phase 5 — Boucle retour healing]
┌──────────────────────────────────────────────────────┐
│ RecoveryLogger (ORPHELIN runtime hors VWB) │
│ SelfHealingIntegration (VWB seulement) │
└──────────────────────────────────────────────────────┘
[Phase 6 — Utilisation au replay (HOT PATH)]
resolve_engine.py :
cascade OCR → template matching → VLM grounding
(PAS de consultation des prototypes ContinuousLearner) ← rupture
(PAS de consultation FAISS index appris) ← rupture
Si compétence avec .semantic.yaml (Phase 2.5 nouveau 01/06) :
Phase25Analyzer.match_screen() → annotations sémantiques
[Phase 7 — Fine-tuning VLM (HORS rpa_vision_v3)]
~/ai/t2a-finetune/, ~/ai/t2a/, ~/ai/t2a_v2/
Modèle custom Dom : qwen2.5vl:7b-rpa
Dataset alimenté manuellement (probablement) ← non-vérifié
```
## §2 — État par phase
| Phase | Code existe | Wired runtime | Dernière utilisation effective | Verdict |
|---|---|---|---|---|
| 1. Capture live | ✅ | ✅ | aujourd'hui (sessions live actives) | OK |
| 1bis. Enqueue worker | ✅ (`finalize()` `api_stream.py:2253+`) | ⚠️ **à diagnostiquer** | Probablement débranché — queue vide 5j alors que sessions live continuent | ⚠️ R6 critique |
| 2. Worker VLM post-session | ✅ (`run_worker.py`) | ✅ (réveillé aujourd'hui 18:54 PID 4054092) | **0 session traitée depuis 5 jours** | ⚠️ tourne à vide |
| 2bis. Enrichissement actions | ✅ (`stream_processor.py:1643`) | ✅ (au build) | continu, mais perd valeur sans ContinuousLearner | OK partiel |
| 3. Construction WorkflowIR | ✅ | ✅ (au moment du build) | aujourd'hui (P1-LEA-SHADOW livré) | OK nouveau cycle |
| **4. ContinuousLearner** | ✅ **644 lignes** | ❌ **ORPHELIN** | Jamais appelé en runtime | 🔴 **DÉBRANCHÉ** |
| **4bis. FeedbackProcessor** | ✅ **176 lignes** | ❌ **ORPHELIN** | Jamais appelé | 🔴 **DÉBRANCHÉ** |
| **4ter. PrototypeVersionManager** | ✅ | ❌ ORPHELIN (dép ContinuousLearner) | Jamais | 🔴 DÉBRANCHÉ |
| 5. RecoveryLogger | ✅ | ❌ ORPHELIN hors VWB | VWB seulement | 🔴 DÉBRANCHÉ runtime agent_v1 |
| 6. Replay hot path | ✅ | ✅ | actif | OK fonctionnel mais déconnecté de Phase 4 |
| 6bis. Phase 2.5 sémantique | ✅ (livré 20:15) | ✅ (endpoint dispo) | aujourd'hui | OK nouveau |
| 7. Fine-tuning VLM | ✅ hors repo (siblings) | n/a (asynchrone manuel) | inconnu | hors scope audit interne |
## §3 — Ruptures identifiées
| ID | Rupture | Sévérité | Détail | Conséquence POC |
|---|---|---|---|---|
| **R1** | ContinuousLearner orphelin | 🟡 MOYENNE | EMA online, dérive UI, variantes prototypes : **tous existent mais non câblés**. Couvre exactement le besoin "auto-évaluation par répétition" exprimé par Dom 2026-06-01 20:46. | Pas d'apprentissage incrémental cross-session. Léa ne s'améliore pas avec l'usage. |
| **R2** | PrototypeVersionManager orphelin | 🟡 MOYENNE | Dépend de R1. Versioning prototypes + rollback. Couvre "versioning des adaptateurs UI" demandé par Dom 21:27. | Pas de rollback prototype dégradé. Pas de gestion versions UI. |
| **R3** | FeedbackProcessor orphelin | 🟠 LOURDE | Boucle feedback humain → ajustement prototype. **Cœur de la fusion/regroupement vers compétence immuable** demandée par Dom 21:27. | Léa Phase 4 humaine (corrections) ne nourrit pas le modèle. Apprentissage repart de zéro à chaque session. |
| **R4** | RecoveryLogger orphelin runtime | 🟢 FAIBLE | Healing limité à VWB. Pas de retour boucle sur sessions agent_v1 ratées. | Workflows en échec récurrent ne génèrent pas d'insights actionnables. |
| **R5** | Phase 2.5 sémantique (livrée aujourd'hui) → utilisation au replay incertaine | 🟢 FAIBLE | `.semantic.yaml` produit mais utilisé seulement si Phase25Analyzer.match_screen() est consultée. Wiring à confirmer. | Annotations sémantiques apprises peut-être pas exploitées au replay. |
| **R6** | **Worker queue vide depuis 5 jours malgré sessions live actives** | 🔴 **CRITIQUE** | Le worker tourne (PID 4054092 actif) mais `data/training/_worker_queue.txt` est vide. **Soit `finalize()` n'enqueue plus, soit toutes les sessions échouent silencieusement à se finaliser, soit la queue est purgée ailleurs.** À diagnostiquer URGENT. | **0 enrichissement profond depuis 5 jours**. Toutes les sessions live actuelles sont stockées brutes sans ScreenAnalyzer/CLIP/FAISS/GraphBuilder. POC Wallerstein impossible en l'état. |
## §4 — Modules orphelins inventaire
### O1 — `core/learning/continuous_learner.py` (644 lignes)
- **Rôle** : adaptation incrémentale des prototypes UI par EMA online, détection de dérive temporelle, génération de variantes par clustering
- **Importé par** : 0 point d'entrée actif (vérifié par grep)
- **Dernière modification git** : à confirmer
- **Pourquoi débranché** : inconnu — pas de commit `disable` ou `remove` visible. Probablement n'a jamais été câblé en runtime depuis sa création (intention de wiring jamais finalisée).
- **Effort rebranchement** : **MOYEN** (2-3 j-h). Nécessite :
- Hook dans `run_worker.py` après `reprocess_session()` pour appeler `learner.update(prototypes, new_observations)`
- Chargement initial des prototypes au démarrage worker
- Persistance prototypes mis à jour : `data/learning/prototypes_<state>.json`
- Tests intégration : sessions répétées sur même UI → vérification EMA progresse
### O2 — `core/learning/feedback_processor.py` (176 lignes)
- **Rôle** : intègre les feedbacks humains (validate/correct/undo Phase 4 Léa-first) dans le modèle prototype
- **Importé par** : 0 point d'entrée actif
- **Effort rebranchement** : **LOURD** (3-5 j-h). Nécessite :
- Hook dans `agent_chat/handlers/learn_action.py` (livré aujourd'hui) à chaque `POST /shadow/feedback`
- Routage : feedback → FeedbackProcessor → ContinuousLearner
- Persistance log : `data/learning/feedback_log.jsonl`
- Tests : validation step → prototype renforcé ; correction → prototype variante créée
### O3 — `PrototypeVersionManager`
- **Rôle** : versionner les prototypes successifs, permettre rollback si nouvelle version dégrade
- **Dépendance** : ContinuousLearner (utilise pour stocker versions)
- **Effort rebranchement** : **FAIBLE** (1 j-h) une fois O1 rebranché
- **Couvre la décision Dom 21:27** : « Ce qu'il faut versionner/invalider, ce sont plutôt mappings UI propres à une application/version, sélecteurs/positions/labels OCR, hypothèses fragiles ou obsolètes, compétence mal validée »
### O4 — `TargetMemoryStore`, `VersionedStore`
- **Rôle** : supports de persistance pour les prototypes versionnés
- **Effort** : couvert par O1+O3
### O5 — `RecoveryLogger` / `SelfHealingIntegration`
- **Statut** : utilisé par VWB seulement, pas par agent_v1 runtime
- **Effort rebranchement runtime agent_v1** : MOYEN (2 j-h)
- **Priorité** : P2 (post-MVP), pas critique POC
## §5 — Worker queue R6 — diagnostic urgent
**Constat** : worker actif PID 4054092 depuis 18:54, log indique poll toutes les 10s sur `_worker_queue.txt`. Mais **0 traitement** depuis le réveil.
**Hypothèses à vérifier (par ordre de probabilité)** :
1. **`finalize()` côté `api_stream.py:2253+` n'enqueue plus** (commit qui a cassé le pipeline)
2. Les sessions live actuelles ne sont **jamais finalisées** (problème côté agent_v1 Windows qui ne push pas la fin de session)
3. La queue est **purgée par un autre processus** (cron ? cleanup ?)
4. Path resolution : worker poll un fichier inexistant ou un chemin différent de celui où `finalize()` écrit
**Action recommandée** : lancer un agent Explore ciblé sur :
- `git log -p agent_v0/server_v1/api_stream.py | grep -A 20 "finalize\|_worker_queue"` pour voir les commits récents touchant à la queue
- Tester manuellement : finaliser une session test, vérifier que le fichier `_worker_queue.txt` est touché, vérifier que le worker la dépile
- Identifier où la rupture est exacte
## §6 — Effort global de rebranchement
| Composant | Effort | Priorité POC Wallerstein |
|---|---|---|
| **R6 — Diagnostic worker queue** | Faible (1-2 j-h) | 🔴 **P0 ABSOLU** (sinon POC impossible) |
| O1+O3+O4 — ContinuousLearner + Versioning | Moyen (2-3 j-h) | 🟠 P1 (apprentissage incrémental) |
| O2 — FeedbackProcessor | Lourd (3-5 j-h) | 🟠 P1 (fusion compétences) |
| O5 — RecoveryLogger runtime | Moyen (2 j-h) | 🟢 P2 (post-MVP) |
| **Total rebranchement (P0+P1)** | **6-10 j-h** | À comparer aux ~15 j-h de spécifications/impl P1 d'aujourd'hui qui les reconstruisaient partiellement de zéro |
## §7 — Conséquences POC Wallerstein
### En l'état actuel (rien rebranché)
-**Aucune session traitée par le pipeline d'enrichissement profond depuis 5 jours** (worker tourne à vide). Cumulé des sessions live brutes accumulées : ScreenAnalyzer, CLIP, FAISS, GraphBuilder pas appliqués.
-**Pas d'apprentissage incrémental** : chaque démo Léa = repart de zéro. Pas d'auto-évaluation par répétition (alors que Dom le demande explicitement 20:46).
-**Pas de versioning prototypes** : si Easily/DPI change d'interface, pas de mécanisme de rollback. (Alors que Dom le demande 21:27.)
-**Pas de portabilité du modèle appris** : pas de paquet portable de réflexes/compétences/schémas/détecteurs/mappings, car ce paquet est produit par la chaîne d'apprentissage qui est débranchée. (Alors que Dom dit « point essentiel » 21:27.)
- ✅ Restitution Option C livrée aujourd'hui (P1-LEA-SHADOW) mais **trop longue** pour sessions 1-2h (recadrage Dom 20:46)
- ✅ Phase 2.5 sémantique livrée mais **ne produit pas encore les signaux de confiance/regroupement** demandés (recadrage Dom 20:46)
### Avec rebranchement P0+P1 (6-10 j-h)
- ✅ Worker pipeline actif → toutes les sessions live enrichies (ScreenAnalyzer/CLIP/FAISS/GraphBuilder)
- ✅ ContinuousLearner alimenté → apprentissage par répétition automatique
- ✅ FeedbackProcessor branché → fusion progressive vers compétences immuables
- ✅ PrototypeVersionManager actif → versioning mappings UI, rollback si dégradation
- ⚠️ Reste à ajouter : enrichir P1-LEA-SHADOW et P1-SEMANTIQUE avec champs `confidence`, `uncertainties[]`, `repetition_count`, distinctions `hypothesis`/`candidate`/`validated` (≈2-3 j-h additionnels)
- ⚠️ Reste à concevoir : paquet portable séparé de la mémoire patient (décision Dom 21:27 — pas encore couvert par aucune impl, ni avant aujourd'hui ni dans mes livraisons P1) (≈3-5 j-h)
## §8 — Plan d'action recommandé
### Étape A — IMMÉDIAT (avant tout nouveau dev)
1. **Diagnostic R6 worker queue** (1-2 j-h) : pourquoi vide depuis 5 jours malgré sessions live actives
2. **Audit factuel modules orphelins** : confirmer le bon état du code de `ContinuousLearner`, `FeedbackProcessor`, `PrototypeVersionManager` (pas de bug bloquant, signatures à jour vis-à-vis du reste du codebase)
3. **Lecture par Dom** des modules orphelins pour confirmer qu'ils correspondent bien à son intention historique
### Étape B — REBRANCHEMENT (P0+P1)
4. Rebrancher worker queue (R6) — code probablement minimal, action chirurgicale
5. Rebrancher ContinuousLearner + supports (O1+O3+O4) avec tests intégration
6. Rebrancher FeedbackProcessor (O2) + hook dans `agent_chat/handlers/learn_action.py` (livré aujourd'hui) à chaque `POST /shadow/feedback`
### Étape C — AJUSTEMENTS LIVRAISONS P1
7. Ajouter `confidence`, `uncertainties[]`, `repetition_count`, `hypothesis/candidate/validated` aux SessionState + payload persist
8. Phase 2.5 sémantique : enrichir pour produire signaux confiance + regroupement (actions stables vs parasites, invariants vs variables, blocs récurrents)
9. Option C restitution : raccourcir à « centré incertitudes uniquement », jamais relecture complète
### Étape D — PORTABILITÉ (objectif Dom essentiel)
10. Concevoir paquet portable : export réflexes/compétences/schémas/détecteurs/mappings/plans d'action/métriques, **sans** mémoire patient ni captures brutes
11. Mécanisme d'import sur poste tiers
12. Validation : aucune trace patient dans le paquet exporté
## §9 — Sources de l'audit
- Agent Explore Claude — 2026-06-01 21:50 (audit primaire)
- Audit Claude antérieur 17:00 (`feedback_lea_principes_techniques.md`) qui avait flagué `ContinuousLearner` et `RecoveryLogger` comme orphelins (mais sans alarme suffisante)
- Audit Explore worker VLM 17:30 (Claude) qui avait confirmé que worker traite sessions finalisées
- 5 messages Codex 2026-06-01 20:46-21:37 relayant 5 décisions/clarifications Dom
- Code source `core/learning/`, `core/healing/`, `agent_v0/server_v1/`, `agent_chat/`
---
*Fin DRAFT — relecture Dom/Codex/Qwen attendue avant action.*
**Décision opérationnelle proposée à Dom** : suspendre tout nouveau dev de modules d'apprentissage Léa tant que (a) R6 worker queue n'est pas diagnostiqué + corrigé et (b) Dom n'a pas confirmé que les modules orphelins identifiés correspondent à son intention historique.

View File

@@ -0,0 +1,729 @@
# AUDIT — Migration Token Global → Token Par-Poste
## rpa_vision_v3 — POC Clinique Wallerstein
**Status** : DRAFT lecture seule — audit factuel — pas encore appliqué
**Date** : 2026-06-01
**Scope** : Identification des modifications pour passer d'un token API global (partagé entre tous les postes) à un token unique par-poste permettant la révocation chirurgicale indépendante
**Contexte** : POC sur ≤5 TIM pendant plusieurs mois ; passage de 3 mois (doc initiale) à durée longue → risque amplifié de compromission d'un poste
---
## 1. Source du Token Actuel — Facteurs Identifiés
### 1.1 Génération et Stockage Côté Serveur
**Fichier** : `agent_v0/server_v1/api_stream.py` (lignes 285-363)
| Point | Facteur |
|-------|---------|
| **Variable env** | `RPA_API_TOKEN` (obligatoire en production) |
| **Mode génération** | Lecture depuis l'env OBLIGATOIRE (`os.getenv("RPA_API_TOKEN", "").strip()`) |
| **Tolérance dev** | Génération aléatoire via `secrets.token_hex(32)` si `RPA_AUTH_DISABLED=true` |
| **Fail-closed** | Production : token manquant → `sys.exit(1)` immédiat, pas de génération silencieuse |
| **Scope du token** | **GLOBAL** : un seul token pour tous les postes clients (tous les TIM partagent la même valeur) |
| **Type de valeur** | Hex string de 32 caractères (e.g. `a1b2c3d4e5f6...`) |
| **Lifecycle** | Aucune rotation built-in ; révocation → rotation manuelle de la var env + redémarrage du serveur |
### 1.2 Distinction Admin/ReadOnly
**Constat** : **N'existe pas actuellement.**
- Un seul niveau de droit : droits R/W complets
- Tous les postes ont capacité à : capturer screenshot, invoquer VLM, lire/exécuter workflows
- Aucune notion de scope ou permission granulaire par token
### 1.3 Lecture au Démarrage du Serveur
**Code** (api_stream.py:301-328) :
```python
_API_TOKEN_ENV = os.environ.get("RPA_API_TOKEN", "").strip()
if _AUTH_DISABLED:
API_TOKEN = _API_TOKEN_ENV or secrets.token_hex(32)
elif not _API_TOKEN_ENV:
logger.critical("[SÉCURITÉ] FATAL — RPA_API_TOKEN est absent ou vide. ...")
sys.exit(1)
else:
API_TOKEN = _API_TOKEN_ENV
```
**Impact** : Token chargé une seule fois au boot, inchangé pendant toute la session du serveur.
---
## 2. Fabrication du ZIP Installeur Léa — Flux Token
### 2.1 Pipeline de Téléchargement du ZIP
**Endpoint** : `web_dashboard/app.py``POST /api/fleet/download/<machine_id>` (ligne 2156)
**Flux** :
```
1. Dashboard Frontend (ui) demande au Dashboard Backend (5001)
2. Backend lit la liste des agents enregistrés (AgentRegistry SQLite)
3. Requête POST vers serveur streaming (5005) : /api/v1/agents/fleet
4. Streaming renvoie liste agents (enrolled_agents table)
5. Backend construit config.txt avec :
- RPA_SERVER_URL (toujours conforme, finit par /api/v1)
- RPA_API_TOKEN = valeur globale unique (tous les postes)
- RPA_MACHINE_ID = fourni par l'UI
- RPA_USER_LABEL = nom du collaborateur
```
### 2.2 Source du Token Injecté dans le ZIP
**Fichier** : `web_dashboard/app.py` (ligne 2183)
```python
api_token = os.environ.get('RPA_API_TOKEN', '') # <- MÊME VALEUR pour tous les postes
custom_config = _build_custom_config(machine_id, user_name, api_token)
```
**Constat** :
- Le token global de l'environnement du Dashboard (5001) est utilisé
- **Tous les ZIPs téléchargés contiennent le même token** (pas de diversification par poste)
- Aucune génération de token unique lors du POST /download
### 2.3 Machine ID — Source et Validation
**Provenance du machine_id** :
- Côté **client Windows** (Léa.bat / install.bat) : Inno Setup génère un UUID via la page custom d'enrollment
- Côté **serveur** : Enrôlement possible sans vérification du machine_id source (endpoint `/api/v1/agents/enroll` accepte n'importe quel machine_id du client)
**Validation** :
- Pas de vérification cryptographique du machine_id (e.g. pas de signature pour prouver l'authenticité)
- Le machine_id est traité comme **identifiant de confiance** après enrôlement (logging, last_seen_at)
### 2.4 Endpoint d'Enrôlement — État Actuel
**Endpoint** : `agent_v0/server_v1/api_stream.py``POST /api/v1/agents/enroll` (ligne 6638)
| Aspect | État Actuel |
|--------|-------------|
| **Qui génère le token ?** | Dashboard Backend (lit env `RPA_API_TOKEN`) |
| **Le client enrôlé reçoit quoi ?** | Le token global dans la réponse `/api/v1/agents/enroll` (ligne 6696) |
| **Existe-t-il un endpoint qui génère token unique ?** | Non. Phase 2 signalée dans le commentaire (ligne 6647) |
| **Stockage des tokens par-poste ?** | Non. Table `enrolled_agents` enregistre le poste mais pas le token |
**Code** (api_stream.py:6688-6698) :
```python
return {
"status": "enrolled",
"created": result["created"],
"reactivated": result["reactivated"],
"machine_id": machine_id,
# Phase 1 : on renvoie le token global pour que le client puisse
# verifier qu'il est bien aligne avec le serveur. Phase 2 pourra
# emettre un token par poste (issued_token != API_TOKEN global).
"api_token": API_TOKEN, # <- GLOBAL
"agent": agent,
}
```
---
## 3. Validation du Token Côté Serveur
### 3.1 Middleware de Vérification
**Fichier** : `agent_v0/server_v1/api_stream.py` (ligne 351)
```python
async def _verify_token(request: Request):
"""Middleware de vérification du token API Bearer."""
if _AUTH_DISABLED:
return
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != API_TOKEN:
raise HTTPException(status_code=401, detail="Token API invalide")
```
**Constat** :
- **Comparaison à une constante** unique (pas de lookup table)
- Tous les endpoints protégés (sauf 5 publics) nécessitent ce token exact
- Le token est envoyé dans chaque requête HTTP : `Authorization: Bearer <token>`
### 3.2 Granularité des Droits
**Constat** : **Un seul niveau : R/W complet**
- Pas de distinction admin/user/readonly
- Pas de scope granulaire (e.g. "capture seulement" vs "exécution workflow")
- Tous les agents ayant le bon token peuvent accéder à toutes les fonctions
### 3.3 Machine ID et Audit
**Machine_id dans les requêtes** :
Le client (Léa) envoie le machine_id dans les headers ou dans le body de certaines requêtes. Côté serveur :
- `audit_trail.record(AuditEntry(..., machine_id=client_provided_machine_id, ...))` (enregistré, pas vérifié)
- `agent_registry.touch_last_seen(machine_id)` (mise à jour timestamp)
**Risque** : Aucune vérification que le machine_id envoyé par le client correspond au token utilisé. Un client pourrait usurper l'identité d'une autre machine en envoyant un autre machine_id + le token global.
---
## 4. Points d'Appel Côté Agent Windows
### 4.1 Lecture de config.txt au Démarrage
**Fichier** : `agent_v0/agent_v1/config.py` (ligne 57)
```python
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
```
Le token vient de trois sources (ordre de priorité) :
1. Variable env `RPA_API_TOKEN` (posée par Lea.bat après parsing de config.txt)
2. Fichier `config.txt` dans le ZIP (contient le token global)
3. Défaut : chaîne vide (agent lancé sans token)
**Fichier** : `deploy/lea_package/Lea.bat` (ligne 40-43)
```batch
if exist "config.txt" (
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"
)
)
```
Lea.bat parse config.txt ligne par ligne et pose les variables d'environnement (y compris `RPA_API_TOKEN`).
### 4.2 Envoi du Token dans les Requêtes
**Streaming WebSocket** : `agent_v0/agent_v1/network/streamer.py` (ligne ~80-90)
```python
from ..config import API_TOKEN, STREAMING_ENDPOINT
# Dans la fonction d'auth :
if API_TOKEN:
return {"Authorization": f"Bearer {API_TOKEN}"}
```
**Requêtes REST (executor)** : `agent_v0/agent_v1/core/executor.py`
```python
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
```
**Constat** :
- Token présent dans **chaque** requête (REST + WebSocket streaming)
- Format standard Bearer : `Authorization: Bearer <token_value>`
- Aucune logique de refresh ou renouvellement
### 4.3 Renouvellement / Refresh du Token
**Constat** : **N'existe pas.**
- Token lu une seule fois au démarrage de l'agent
- Aucun mécanisme pour redemander un token au serveur sans redémarrer l'agent
- Révocation d'un token global = tous les agents existants deviennent invalides immédiatement (pas de transition gracieuse)
---
## 5. Architecture Cible : Token Par-Poste
### 5.1 Schéma de Stockage Côté Serveur
**Nouvelle table SQLite** dans `data/databases/rpa_data.db` :
```sql
CREATE TABLE IF NOT EXISTS postes_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL, -- SHA256(token)
token_readable TEXT NOT NULL, -- token lisible, jamais loggé
status TEXT DEFAULT 'active', -- 'active', 'revoked'
created_at TEXT NOT NULL, -- ISO 8601 UTC
revoked_at TEXT, -- ISO 8601 UTC si révoqué
rotated_at TEXT, -- ISO 8601 UTC si régénéré
last_seen_at TEXT, -- Dernière utilisation
rotation_reason TEXT -- 'manual_revoke', 'rotation_request', etc.
);
CREATE INDEX idx_postes_tokens_machine_id ON postes_tokens(machine_id);
CREATE INDEX idx_postes_tokens_status ON postes_tokens(status);
```
**Co-habitation** : Table dans la même DB que `enrolled_agents` (même `data/databases/rpa_data.db`).
### 5.2 Endpoint d'Enrôlement Amélioré
**Route modifiée** : `POST /api/v1/agents/register` (nouvelle, plutôt que d'écraser `/agents/enroll`)
```python
@app.post("/api/v1/agents/register", status_code=201)
async def agents_register(request: AgentRegisterRequest):
"""
Enrôlement du poste + génération du token unique.
Comportement :
- Enregistre le poste (ou le réactive) dans enrolled_agents
- Génère un token unique via secrets.token_urlsafe(32)
- Stocke le hash SHA256 du token dans postes_tokens
- Retourne le token UNE SEULE FOIS dans la réponse
Client doit sauvegarder ce token dans config.txt immédiatement.
Aucune autre route ne renvoie le token en clair.
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id obligatoire")
# 1. Enregistrer le poste (ou le réactiver)
try:
result = agent_registry.enroll(machine_id=machine_id, ...)
except AgentAlreadyEnrolledError:
# Poste déjà actif : générer un nouveau token pour rotation
pass
# 2. Générer token unique
token_value = secrets.token_urlsafe(32) # e.g. "aB1_cD2-eF3...XyZ"
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# 3. Insérer ou mettre à jour dans postes_tokens
postes_registry.create_or_rotate(
machine_id=machine_id,
token_hash=token_hash,
token_readable=token_value, # Stocké temporairement
rotation_reason="enrollment"
)
# 4. Retourner le token UNE SEULE FOIS
return {
"status": "registered",
"machine_id": machine_id,
"token": token_value, # <- À SAUVEGARDER IMMÉDIATEMENT
"expires_in": "never", # Sans expiration, mais révocable à tout moment
"agent": {...}
}
```
### 5.3 Modification du Middleware d'Auth
**Fichier** : `agent_v0/server_v1/api_stream.py` (remplacer `_verify_token`)
```python
async def _verify_token(request: Request):
"""Middleware de vérification du token par-poste.
Lookup du token dans postes_tokens au lieu de comparaison constante.
Cache mémoire pour éviter hammer SQLite à chaque requête.
"""
if _AUTH_DISABLED:
return
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Token invalide")
token_value = auth[7:]
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# 1. Chercher dans le cache (dict en mémoire)
if token_hash in _token_cache:
cached = _token_cache[token_hash]
if cached["status"] != "active":
raise HTTPException(status_code=401, detail="Token révoqué")
cached["last_used"] = time.time()
request.scope["machine_id"] = cached["machine_id"] # Injecter pour l'audit
return
# 2. Chercher en DB
row = postes_registry.get_by_hash(token_hash)
if row is None or row["status"] != "active":
raise HTTPException(status_code=401, detail="Token invalide")
# 3. Mettre en cache
_token_cache[token_hash] = {
"machine_id": row["machine_id"],
"status": row["status"],
"last_used": time.time(),
}
# Limiter la taille du cache (LRU, max 1000 tokens)
if len(_token_cache) > 1000:
oldest = min(_token_cache.items(), key=lambda x: x[1]["last_used"])
del _token_cache[oldest[0]]
# Injecter machine_id pour l'audit
request.scope["machine_id"] = row["machine_id"]
return
```
### 5.4 Endpoint de Révocation
**Route** : `POST /api/v1/postes/<machine_id>/revoke` (sécurisé par auth Bearer)
```python
@app.post("/api/v1/postes/{machine_id}/revoke")
async def revoke_poste(machine_id: str):
"""
Révoque le token du poste (soft delete, audit conservé).
Dashboard ou administrateur : révoque un poste compromis.
"""
# Vérifier que le token Bearer utilisé a le droit de révoquer
# (optionnel : admin-only, ou même machine_id peut révoquer son propre token)
row = postes_registry.revoke(machine_id=machine_id, reason="admin_revoke")
if row is None:
raise HTTPException(status_code=404, detail="Machine not found")
# Invalider le cache
if row["token_hash"] in _token_cache:
del _token_cache[row["token_hash"]]
logger.info(f"[SECURITY] Poste révoqué : {machine_id}")
return {"status": "revoked", "machine_id": machine_id}
```
### 5.5 Endpoint de Régénération
**Route** : `POST /api/v1/postes/<machine_id>/rotate` (pour l'utilisateur du poste)
```python
@app.post("/api/v1/postes/{machine_id}/rotate")
async def rotate_poste_token(machine_id: str):
"""
L'utilisateur du poste demande un nouveau token (le vieil est invalidé).
Retourne le nouveau token (à injecter dans config.txt).
"""
# Vérifier que le token actuel appartient à ce machine_id
# (via injection dans request.scope par le middleware)
new_token = secrets.token_urlsafe(32)
new_hash = hashlib.sha256(new_token.encode()).hexdigest()
postes_registry.rotate(
machine_id=machine_id,
new_token_hash=new_hash,
rotation_reason="user_request"
)
# Invalider l'ancien token du cache
old_row = postes_registry.get_by_machine_id(machine_id)
if old_row and old_row["token_hash"] in _token_cache:
del _token_cache[old_row["token_hash"]]
return {
"status": "rotated",
"machine_id": machine_id,
"new_token": new_token,
"hint": "Mettez à jour config.txt avec ce nouveau token"
}
```
### 5.6 UI Dashboard — Onglet Postes
**Nouvelle page** : `/templates/fleet_management.html`
Fonctionnalités :
- Liste des postes actifs (machine_id, user_name, last_seen_at, enrolled_at)
- Liste des postes révoqués (avec date révocation + raison)
- Boutons par poste :
- **Révoquer** : POST `/api/v1/postes/{machine_id}/revoke` → impossibilité pour le poste de se connecter
- **Régénérer token** : POST `/api/v1/postes/{machine_id}/rotate` → nouveau token à faire entrer sur le poste
- **Afficher détails** : historique de révocation/rotation
- Filtres : statut (actif/révoqué), plage de dates, collaborateur
---
## 6. Chiffrage Qualitatif de la Migration
### 6.1 Fichiers à Modifier
| Fichier | Modification | Effort | Risque |
|---------|--------------|--------|--------|
| `agent_v0/server_v1/api_stream.py` | Remplacer `_verify_token`, ajouter `_token_cache`, endpoints `/api/v1/postes/*/revoke` et `/rotate` | **Moyen** (~200-250 lignes) | Moyen : tous les endpoints dépendent du middleware |
| `agent_v0/server_v1/agent_registry.py` | Ajouter classe `PostesTokenRegistry` (CRUD postes_tokens table) | **Moyen** (~150-200 lignes) | Faible : isolé, pas d'impact existant |
| `agent_v0/server_v1/` (nouveau) | `postes_registry.py` (classe PostesTokenRegistry) | **Moyen** (~150-200 lignes) | Faible : nouveau module |
| `web_dashboard/app.py` | Remplacer `_build_custom_config` + endpoint `/api/fleet/download` pour générer token unique | **Moyen** (~100-150 lignes) | Moyen : impact sur téléchargement ZIP |
| `web_dashboard/templates/` | Ajouter `fleet_management.html` (onglet postes) | **Moyen** (~300-400 lignes HTML/JS) | Faible : interface nouvelle, pas de régression |
| `agent_v0/agent_v1/config.py` | Pas de modification (lecture env inchangée) | **Aucun** | Aucun |
| `deploy/installer/Lea.iss` | Pas de modification (Inno Setup pose la var env) | **Aucun** | Aucun |
**Total effort** : ~1000-1300 lignes de code nouveau / modifié
### 6.2 Migration Progressive en 3 Étapes
#### Étape A : Fondations (1-2 jours)
**Objectif** : Ajouter la table + le registre, mais le serveur accepte les 2 modes.
1. Créer table `postes_tokens` + index dans `data/databases/rpa_data.db`
2. Implémenter classe `PostesTokenRegistry` (CRUD simple)
3. Modifier `_verify_token` pour :
- D'abord chercher le token en par-poste (lookup table)
- Si non trouvé, faire un fallback sur le mode global (API_TOKEN)
4. Logger chaque validation en clair : `[AUTH] Token trouvé : par-poste | global`
**Pre-requis** : Aucun
**Durée estimée** : 1-2 jours
**Point de non-retour** : Non (le fallback permet de revenir en arrière)
**Impact utilisateurs** : Nul (transparent, les agents existants continuent avec le global)
**Code exemple (middleware)**:
```python
async def _verify_token(request: Request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401)
token_value = auth[7:]
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# Mode 1 : Par-poste
row = postes_registry.get_by_hash(token_hash)
if row and row["status"] == "active":
logger.info(f"[AUTH] Token par-poste validé : {row['machine_id']}")
request.scope["machine_id"] = row["machine_id"]
return
# Mode 2 : Global (fallback, phase 1)
if token_value == API_TOKEN:
logger.info(f"[AUTH] Token global validé (fallback phase 1)")
return
raise HTTPException(status_code=401, detail="Token invalide")
```
#### Étape B : Redéploiement sur Postes Wallerstein (3-5 jours)
**Objectif** : Migrer les 5 postes du POC en par-poste (ZIPs avec tokens uniques).
1. Nouvelle version du Dashboard : endpoint `/api/v1/agents/register` qui génère tokens uniques
2. Modifier `/api/fleet/download/<machine_id>` pour injecter le token unique (pas le global)
3. Sur chaque poste Wallerstein :
- Télécharger le nouveau ZIP via le Dashboard
- Extraire et remplacer config.txt
- Redémarrer Léa
**Pre-requis** : Étape A complète et testée
**Durée estimée** : 3-5 jours (déploiement sur le terrain + tests)
**Point de non-retour** : OUI — une fois que tous les postes ont le par-poste, l'admin peut supprimer API_TOKEN global
**Impact utilisateurs** : Très faible (ZIPs pré-générés, transparents pour les utilisateurs)
**Checklist de déploiement** :
- [ ] Tester endpoint `/api/v1/agents/register` en lab
- [ ] Générer 5 ZIPs avec tokens uniques (une clé par poste)
- [ ] Vérifier que config.txt contient le bon token pour chaque machine_id
- [ ] Déployer sur poste 1, vérifier dernière utilisation dans Dashboard
- [ ] Idem postes 2-5
- [ ] Vérifier historique audit : chaque poste tracé séparément
#### Étape C : Durcissement — Suppression du Global (1 jour)
**Objectif** : Désactiver le mode global, garantir que seule l'authentification par-poste fonctionne.
1. Retirer le fallback global de `_verify_token`
2. Supprimer variable env `RPA_API_TOKEN` du bootstrap
3. Logger un FATAL si la variable est trouvée (debug)
4. Mettre à jour la doc : "Mode global dépréciéé depuis 2026-06-XX"
**Pre-requis** : Tous les postes en par-poste (Étape B complète)
**Durée estimée** : 1 jour
**Point de non-retour** : OUI — après ce point, les anciens agents (mode global) ne peuvent plus se connecter
**Impact utilisateurs** : ÉLEVÉ si Étape B incomplète (agents bloqués)
---
## 7. Risques Non Couverts Par la Migration
### 7.1 Vol Physique du PC TIM avec config.txt en Clair
**Risque** : Token stocké en clair dans le fichier `config.txt` (Windows, pas chiffré par défaut)
**Mitigations possibles** :
- DPAPI Windows : Chiffrer config.txt via `CryptProtectData` (nécessite clé utilisateur Windows)
- Stockage sécurisé : Léa peut demander token à la première utilisation (sans le persister)
- JWT court + refresh : Token courte durée de vie (~30 min) + refresh token distinct (mais complexe)
- Jeton hardware : Clé USB ou smartcard (hors scope POC)
**Recommandation** : Pour Wallerstein, utiliser DPAPI Windows (effort faible, sécurité augmentée).
### 7.2 Replay Attacks sur le Streaming WebSocket
**Risque** : Sans TLS strict (HTTPS/WSS), un attaquant en MITM peut rejouer les requêtes vidées du token Bearer.
**Mitigations actuelles** :
- Reverse proxy NPM (déjà en place) expose lea.labs.laurinebazin.design en HTTPS
- WSS (WebSocket Secure) utilisé en production (non en lab)
**Recommandation** : Vérifier que tous les endpoints streaming utilisent WSS en production (config NPM).
### 7.3 Token Enregistré dans les Logs
**Risque** : Si Flask ou Uvicorn log les en-têtes HTTP en DEBUG, le token Bearer s'affiche en clair.
**Prévention** :
- Mode production (défaut) : `docs_url=None, redoc_url=None` (api_stream.py:469-471)
- Vérifier que les logs ne contiennent jamais `Authorization: Bearer ...`
**Commande de vérification** :
```bash
grep -r "Authorization.*Bearer" /path/to/logs/ # Ne doit retourner rien
grep -r "RPA_API_TOKEN" /path/to/logs/ # Ne doit retourner rien
```
### 7.4 Usurpation d'Identité : Machine_ID
**Risque** : Actuellement, aucune vérification que le machine_id envoyé par le client correspond au token utilisé.
**Scénario** : Client A possède token de poste A. Poste A envoie requête avec `machine_id=poste_B` → audit imputé à poste B.
**Mitigation dans l'architecture cible** :
- Dès que le token est validé, extraire automatiquement le machine_id depuis la table postes_tokens
- Rejecter toute tentative du client d'envoyer un machine_id différent
```python
async def _verify_token(request: Request):
# ... validation token ...
row = postes_registry.get_by_hash(token_hash)
# Injecter l'identité vraie dans la requête
request.scope["machine_id"] = row["machine_id"] # <- Vrai machine_id
request.scope["user_id"] = row["user_id"] # <- Vrai user_id
# Ne pas faire confiance au client pour l'identité
```
### 7.5 Absence de Rotation Forcée
**Risque** : Aucune rotation obligatoire (ex. tous les 90 jours). Un token compromis et non détecté reste valide indéfiniment.
**Mitigation pour Wallerstein** :
- Ajouter `last_rotation_at` et `rotation_period_days` à la table postes_tokens
- Endpoint `/api/v1/postes/{machine_id}/status` retourne un warning si rotation > 90 jours
- Dashboard affiche visuel "Token à renouveler" (jaune : 60j, rouge : >90j)
**Effort** : Moyen (~50-100 lignes)
---
## 8. Verdict et Recommandations
### Effort Global
**Catégorie** : **MOYEN** (4-8 jours-homme, 1000-1300 lignes, pas d'architecte externe requis)
- Fondations (Étape A) : 1-2 jours
- Déploiement terrain (Étape B) : 3-5 jours (nécessite accès aux postes Wallerstein)
- Durcissement (Étape C) : 1 jour
### Durée Estimée
| Phase | Durée | Bloqué Par |
|-------|-------|-----------|
| A (Fondations) | 1-2 j | Aucun |
| B (Déploiement) | 3-5 j | A complète |
| C (Durcissement) | 1 j | B complète |
| **TOTAL** | **5-8 j** | — |
### Dépendances Connues
**Aucune dépendance blocage avec autres chantiers actuels** :
- Worktree guard Qwen : Isolé, pas d'impact (module core/)
- Gap propagation verdict : Isolé (migration replay)
- Mismatch captures persistées : Isolé (API /traces/upload)
**Dépendance suggérée** : Implémenter mitigations § 7.1-7.5 après la migration (durcissement sécurité, effort marginal).
### Recommandation
**GO pour le POC Wallerstein** (approche par étapes) :
1. **Étape A maintenant** : Ajouter table + registre + middleware 2 modes (1-2 jours)
2. **Étape B début Wallerstein** : Redéployer les 5 postes avec tokens uniques (3-5 jours, pendant POC)
3. **Étape C post-POC** : Durcir si compromise d'un poste détectée, ou en fin de POC
Cette approche offre la **révocation chirurgicale** dès l'Étape B (capacité à révoquer un poste sans affecter les autres), tout en gardant une marche arrière jusqu'à la fin de l'Étape B.
---
## Annexe A : Schéma de Synthèse
```
┌─────────────────────────────────────────────────────────────┐
│ Système Actuel (Phase 1 — Token Global) │
├─────────────────────────────────────────────────────────────┤
│ • RPA_API_TOKEN = "a1b2c3d4...xyz" (1 seul, dans .env) │
│ • Tous les postes envoient le MÊME token │
│ • Aucune révocation fine (révoque tout ou rien) │
│ • Audit : machine_id non vérifiable (peut être usurpé) │
└─────────────────────────────────────────────────────────────┘
(Étape A : 1-2j)
┌─────────────────────────────────────────────────────────────┐
│ État Intermédiaire (Phase 1.5 — Dual Mode) │
├─────────────────────────────────────────────────────────────┤
│ • Table postes_tokens ajoutée │
│ • Middleware accepte tokens par-poste OU global │
│ • Anciens agents (global) : continuent à fonctionner │
│ • Nouveaux agents (par-poste) : peuvent être enrôlés │
└─────────────────────────────────────────────────────────────┘
(Étape B : 3-5j, terrain)
┌─────────────────────────────────────────────────────────────┐
│ Cible (Phase 2 — Token Par-Poste) │
├─────────────────────────────────────────────────────────────┤
│ • Poste A : token_A (unique) → machine_id_A │
│ • Poste B : token_B (unique) → machine_id_B │
│ • Poste C : token_C (unique) → machine_id_C │
│ • ... │
│ • Révocation poste A : ne touche pas B, C, D, E │
│ • Audit : machine_id garanti (extrait du token validé) │
└─────────────────────────────────────────────────────────────┘
(Étape C : 1j)
┌─────────────────────────────────────────────────────────────┐
│ Durcissement (Phase 3 — Suppression Global) │
├─────────────────────────────────────────────────────────────┤
│ • RPA_API_TOKEN supprimé du bootstrap │
│ • Middleware accepte UNIQUEMENT tokens par-poste │
│ • Anciens agents (global) : rejetés (404) │
└─────────────────────────────────────────────────────────────┘
```
---
## Annexe B : Checklist de Vérification Pré-Déploiement
- [ ] **DB** : Table postes_tokens créée et indexée, migration de la DB testée
- [ ] **Code** : Middleware dual-mode (par-poste + fallback global) compilé
- [ ] **Cache** : Vérifier que le cache mémoire ne fuit pas (taille bornée à 1000 tokens)
- [ ] **Audit** : Chaque validation loggée avec indication du mode (par-poste ou global)
- [ ] **Endpoint register** : Génère token unique, hash stocké en DB, token retourné UNE FOIS
- [ ] **Endpoint revoke** : Invalidation du token en DB + vidage du cache
- [ ] **Endpoint rotate** : Génère nouveau token, ancien invalidé
- [ ] **UI Dashboard** : Affichage liste postes (actifs + révoqués), boutons revoke/rotate
- [ ] **Tests unitaires** : Vérifier que token global + par-poste fonctionnent (Étape A)
- [ ] **Tests intégration** : Workflow complet (enroll → register → streaming → audit)
- [ ] **Logs de sécurité** : Aucun token en clair, aucune variable d'env loggée
- [ ] **Documentation** : Mise à jour du PLAYBOOK_DSI_RSSI avec procédure par-poste
---
**Document généré** : 2026-06-01
**Auteur** : Audit factuel (sans modifications de code)
**Validation requise avant implémentation** : Dom (chef projet)

View File

@@ -0,0 +1,313 @@
# requirements-dgx-aarch64.txt — DRAFT pour relecture Codex/Dom
**Statut** : DRAFT — pas encore appliqué. Aucun changement au `requirements.txt` réel.
**Date** : 2026-06-01
**Cible** : DGX Spark, Ubuntu 24.04 ARM64, CUDA 13 système, GB10 (sm_121), container Docker headless serveur.
**Source de vérité** : `docs/POC/PORTAGE_DGX_SPARK_2026-05-28.md` §5.
**Base** : `requirements.txt` actuel = 180 paquets — bench x86 `venv_v3/`.
---
## Règles appliquées
- PyTorch : `torch`, `torchvision` désépinglés, installés via `--index-url https://download.pytorch.org/whl/cu128` (binaire sm_120 compat sm_121).
- Wheels GPU NVIDIA (`nvidia-*-cu12`, 15 paquets) : **supprimés**, tirés transitivement par torch cu128 ARM.
- Capture / GUI / X11 : **supprimés** — container serveur headless ; l'agent capture tourne sur poste Linux client séparé.
- `onnx==1.20.1` : **supprimé** (audit dépendances : fantôme transitif).
- Reste : wheels neutres ARM/CPU **gardés tels quels** ; wheels torch-dependent **désépinglés** pour laisser pip résoudre.
---
## 1. GARDÉ TEL QUEL (paquets neutres ARM/CPU)
Wheels pur Python ou wheels manylinux2014_aarch64 / musllinux_aarch64 publiés sur PyPI. §5 RAS pour les binaires explicitement listés.
- **Web/API** : `fastapi`, `Flask`, `Flask-Caching`, `Flask-Cors`, `Flask-Migrate`, `Flask-SocketIO`, `Flask-SQLAlchemy`, `starlette`, `uvicorn`, `uvloop` (§5), `httptools`, `httpcore`, `httpx`, `h11`, `websockets`, `wsproto`, `simple-websocket`, `python-engineio`, `python-socketio`, `python-multipart`, `Werkzeug`, `Jinja2`, `itsdangerous`, `blinker`, `click`, `MarkupSafe`, `cachelib`, `watchfiles`, `anyio`, `bidict`
- **DB/migrations** : `SQLAlchemy`, `alembic`, `Mako`, `greenlet`, `redis`
- **ML/vision CPU** : `faiss-cpu` (§5), `opencv-python` (§5), `pillow`, `numpy`, `scipy`, `scikit-learn`, `matplotlib`, `contourpy`, `fonttools`, `kiwisolver`, `cycler`, `pyparsing`, `ml_dtypes`, `networkx`, `sympy`, `mpmath`, `joblib`, `threadpoolctl`, `RapidFuzz`, `shapely` (§5), `pyclipper` (§5), `h5py` (§5)
- **PDF/docs** : `pypdfium2` (§5), `lxml` (§5), `python-docx`, `openpyxl`, `et_xmlfile`, `defusedxml`, `anyascii`, `langdetect`, `ftfy`, `wcwidth`, `regex`
- **Ollama** : `ollama` (client HTTP pur Python)
- **Validation/config** : `pydantic`, `pydantic_core`, `annotated-doc`, `annotated-types`, `typing-inspection`, `typing_extensions`, `validators`, `marshmallow`, `jsonschema`, `jsonschema-specifications`, `referencing`, `rpds-py`, `attrs`, `PyYAML`, `python-dotenv`
- **HTTP/réseau** : `aiohttp`, `aiohappyeyeballs`, `aiosignal`, `frozenlist`, `multidict`, `propcache`, `yarl`, `requests`, `urllib3`, `charset-normalizer`, `idna`, `certifi`
- **Crypto/utils** : `cryptography`, `cffi`, `pycparser`, `filelock`, `fsspec`, `packaging`, `platformdirs`, `pathspec`, `six`, `python-dateutil`, `sortedcontainers`, `tqdm`, `Pygments`
- **Monitoring** : `prometheus_client`, `psutil`, `pynvml`, `nvidia-ml-py` (bindings NVML pur Python, pas un wheel CUDA toolkit)
- **Protobuf** : `protobuf` (§5)
- **Dev/tests** : `pytest`, `pytest-asyncio`, `pytest-cov`, `pytest-flask`, `pytest-mock`, `hypothesis`, `coverage`, `iniconfig`, `pluggy`, `black`, `flake8`, `mypy`, `mypy_extensions`, `mccabe`, `pycodestyle`, `pyflakes`
## 2. GARDÉ MAIS DÉSÉPINGLÉ (laisser pip résoudre avec torch cu128 ARM)
| Paquet | Version actuelle x86 | Raison |
|--------|----------------------|--------|
| `torch` | `2.9.1` | installé via index PyTorch cu128 ARM (étape 1 ci-dessous) |
| `torchvision` | `0.24.1` | idem, doit matcher torch cu128 ARM |
| `transformers` | `4.57.3` | dépend de torch — laisser résoudre |
| `accelerate` | `1.13.0` | dépend de torch |
| `timm` | `1.0.24` | dépend de torch |
| `open_clip_torch` | `3.2.0` | dépend de torch |
| `python-doctr` | `1.0.1` | dépend de torch + torchvision (§5 RAS) |
| `safetensors` | `0.7.0` | binding Rust, couplé à transformers/torch |
| `tokenizers` | `0.22.2` | binding Rust, couplé à transformers |
| `huggingface-hub` | `0.36.0` | couplé à transformers |
| `hf-xet` | `1.2.0` | binding Rust, wheel aarch64 à vérifier (cf. §4) |
## 3. SUPPRIMÉ
### 3a. Capture / GUI / X11 (serveur DGX headless)
`PyQt5`, `PyQt5-Qt5`, `PyQt5_sip`, `mss`, `PyAutoGUI`, `pynput`, `evdev`, `python-xlib`, `python3-xlib`, `pystray`, `PyGetWindow`, `PyMsgBox`, `PyScreeze`, `MouseInfo`, `pyperclip`, `pytweening`, `PyRect` — tous hors scope container serveur (§5 ligne 81 : non bloquant si non chargés). Restent sur le poste Linux client de l'agent capture.
### 3b. Wheels GPU NVIDIA épinglées x86 (15 paquets)
`nvidia-cublas-cu12`, `nvidia-cuda-cupti-cu12`, `nvidia-cuda-nvrtc-cu12`, `nvidia-cuda-runtime-cu12`, `nvidia-cudnn-cu12`, `nvidia-cufft-cu12`, `nvidia-cufile-cu12`, `nvidia-curand-cu12`, `nvidia-cusolver-cu12`, `nvidia-cusparse-cu12`, `nvidia-cusparselt-cu12`, `nvidia-nccl-cu12`, `nvidia-nvjitlink-cu12`, `nvidia-nvshmem-cu12`, `nvidia-nvtx-cu12` — §5 ligne 78 : désépinglés, retirés du requirements, tirés par torch cu128 ARM.
### 3c. Fantôme
`onnx==1.20.1` — audit runtime : non importé par le code server. `onnxruntime` déjà confirmé non utilisé (cf. brief Dom). Suppression nette.
## 4. À VÉRIFIER (validation Codex/Dom requise)
| Paquet | Question | Action proposée |
|--------|----------|-----------------|
| `triton` | §5 risque ligne 79 : PTXAS embarqué peut crasher avec CUDA 13 | Non listé (tiré par torch cu128) ; si KO au runtime : `export TRITON_PTXAS_PATH=/usr/local/cuda/bin/ptxas` |
| `hf-xet` | Wheel aarch64 publié au moment du build ? | Désépingler ; si non dispo, retirer (dep optionnelle de `huggingface-hub`) |
| `accelerate` | Version compatible torch cu128 ARM ? | Désépinglé (cat. 2) — flag pour test post-install |
| `onnx` (suppression) | Confirmé sur scope server/ + core/grounding/ + core/detection/ ? | `grep -r "^import onnx\|^from onnx" --include='*.py' server/ core/` avant build |
| `nvidia-ml-py` vs `pynvml` | Doublon (officiel vs fork tiers) | Probable retrait de `pynvml`, à arbitrer |
---
## 5. Bloc `requirements-dgx-aarch64.txt` proposé
Installation en 2 étapes (cu128 ARM **avant** le reste, sinon pip tire les wheels x86 du cache) :
```bash
# Étape 1 : PyTorch ARM cu128
pip install --no-cache-dir torch torchvision \
--index-url https://download.pytorch.org/whl/cu128
# Étape 2 : reste
pip install --no-cache-dir -r requirements-dgx-aarch64.txt
```
```txt
# requirements-dgx-aarch64.txt
# Cible : DGX Spark / Ubuntu 24.04 ARM64 / CUDA 13 / GB10 sm_121
# Pré-requis : torch+torchvision installés via index cu128 ARM AVANT ce fichier.
# --- Stack torch-dependent (désépinglé) ---
transformers
accelerate
timm
open_clip_torch
python-doctr
safetensors
tokenizers
huggingface-hub
hf-xet
# --- Web / API ---
fastapi==0.128.0
Flask==3.0.0
Flask-Caching==2.1.0
Flask-Cors==4.0.0
Flask-Migrate==4.1.0
Flask-SocketIO==5.3.5
Flask-SQLAlchemy==3.1.1
starlette==0.50.0
uvicorn==0.40.0
uvloop==0.22.1
httptools==0.7.1
httpcore==1.0.9
httpx==0.28.1
h11==0.16.0
websockets==16.0
wsproto==1.3.2
simple-websocket==1.1.0
python-engineio==4.8.0
python-socketio==5.10.0
python-multipart==0.0.21
Werkzeug==3.1.5
Jinja2==3.1.6
itsdangerous==2.2.0
blinker==1.9.0
click==8.3.1
MarkupSafe==3.0.3
cachelib==0.9.0
watchfiles==1.1.1
anyio==4.12.1
bidict==0.23.1
# --- DB / migrations ---
SQLAlchemy==2.0.23
alembic==1.18.4
Mako==1.3.10
greenlet==3.3.0
redis==5.0.1
# --- ML / vision CPU ---
faiss-cpu==1.13.2
opencv-python==4.12.0.88
pillow==12.1.0
numpy==2.2.6
scipy==1.17.0
scikit-learn==1.8.0
matplotlib==3.10.8
contourpy==1.3.3
fonttools==4.62.1
kiwisolver==1.5.0
cycler==0.12.1
pyparsing==3.3.2
ml_dtypes==0.5.4
networkx==3.6.1
sympy==1.14.0
mpmath==1.3.0
joblib==1.5.3
threadpoolctl==3.6.0
RapidFuzz==3.14.3
shapely==2.1.2
pyclipper==1.4.0
h5py==3.16.0
# --- PDF / docs ---
pypdfium2==5.6.0
lxml==6.0.2
python-docx==1.2.0
openpyxl==3.1.5
et_xmlfile==2.0.0
defusedxml==0.7.1
anyascii==0.3.3
langdetect==1.0.9
ftfy==6.3.1
wcwidth==0.2.14
regex==2025.11.3
# --- Ollama ---
ollama==0.6.1
# --- Validation / config ---
pydantic==2.12.5
pydantic_core==2.41.5
annotated-doc==0.0.4
annotated-types==0.7.0
typing-inspection==0.4.2
typing_extensions==4.15.0
validators==0.35.0
marshmallow==3.20.1
jsonschema==4.20.0
jsonschema-specifications==2025.9.1
referencing==0.37.0
rpds-py==0.30.0
attrs==25.4.0
PyYAML==6.0.1
python-dotenv==1.0.0
# --- HTTP / réseau ---
aiohttp==3.13.3
aiohappyeyeballs==2.6.1
aiosignal==1.4.0
frozenlist==1.8.0
multidict==6.7.0
propcache==0.4.1
yarl==1.22.0
requests==2.32.5
urllib3==2.6.3
charset-normalizer==3.4.4
idna==3.11
certifi==2026.1.4
# --- Crypto / utils ---
cryptography==46.0.3
cffi==2.0.0
pycparser==2.23
filelock==3.20.3
fsspec==2026.1.0
packaging==25.0
platformdirs==4.5.1
pathspec==1.0.3
six==1.17.0
python-dateutil==2.8.2
sortedcontainers==2.4.0
tqdm==4.67.1
Pygments==2.19.2
# --- Monitoring ---
prometheus_client==0.23.1
psutil==7.2.1
pynvml==13.0.1
nvidia-ml-py==13.590.48
# --- Protobuf ---
protobuf==7.34.0
# --- Dev / tests (à isoler dans requirements-dev.txt à terme) ---
pytest==9.0.2
pytest-asyncio==1.3.0
pytest-cov==4.1.0
pytest-flask==1.3.0
pytest-mock==3.12.0
hypothesis==6.92.1
coverage==7.13.1
iniconfig==2.3.0
pluggy==1.6.0
black==23.12.1
flake8==6.1.0
mypy==1.7.1
mypy_extensions==1.1.0
mccabe==0.7.0
pycodestyle==2.11.1
pyflakes==3.1.0
```
**Bilan** : 180 → ~125 lignes. Retraits = 17 capture/GUI + 15 nvidia-cu12 + 1 onnx + 11 remontés cat. 2 désépinglée (torch/torchvision/transformers/accelerate/timm/open_clip_torch/python-doctr/safetensors/tokenizers/huggingface-hub/hf-xet) = 44 lignes en moins, 9 désépinglées remontées en tête.
---
## 6. Vérifications post-install sur DGX
```bash
# 1. PyTorch + GPU sm_121
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_capability(0))"
# attendu : (True, (12, 1))
python -c "import torch; print(torch.__version__, torch.version.cuda)"
# attendu : 2.9.x+ / 12.8
# 2. FAISS
python -c "import faiss; print(faiss.__version__)"
# attendu : 1.13.x
# 3. Transformers stack
python -c "import transformers; print(transformers.__version__)"
python -c "import accelerate, timm, open_clip, doctr; print(accelerate.__version__, timm.__version__, open_clip.__version__, doctr.__version__)"
# 4. Bindings Rust aarch64
python -c "import tokenizers, safetensors; print(tokenizers.__version__, safetensors.__version__)"
python -c "import hf_xet; print(hf_xet.__version__)" # point de vérif cat. 4
# 5. Confirmation onnx absent et runtime OK
python -c "import onnx" 2>&1 | grep -q ModuleNotFoundError && echo "OK : onnx absent"
# 6. Points d'entrée applicatifs
python -c "from web_dashboard.app import app; print('dashboard OK')"
python -c "from visual_workflow_builder.backend.app import app; print('VWB backend OK')"
# adapter le module server selon le wiring réel
# 7. Sanity GPU
python -c "import torch; x=torch.randn(8,8,device='cuda'); print('matmul GPU OK', (x@x).device)"
# 8. Ollama client → daemon
python -c "import ollama; print(ollama.list())"
```
**Garde-fous §5** :
- Si kernels Triton crashent : `export TRITON_PTXAS_PATH=/usr/local/cuda/bin/ptxas`.
- Si pip tire encore des `nvidia-*-cu12` x86 : confirmer que l'étape 1 (torch cu128 ARM) a été exécutée **avant**, et que `--no-cache-dir` est bien passé.
---
## 7. Points ouverts pour Codex/Dom
1. Confirmer suppression `onnx` : `grep -r "^import onnx\|^from onnx" --include='*.py' server/ core/` avant build image Docker.
2. Splitter dès le POC `requirements-dev.txt` (pytest/black/mypy/flake8/coverage/hypothesis) pour ne pas polluer l'image server ? À trancher.
3. `hf-xet` : si wheel aarch64 indispo au build, retirer (dep optionnelle `huggingface-hub`).
4. `nvidia-ml-py` vs `pynvml` : doublon. Garder uniquement `nvidia-ml-py` (officiel) ?

View File

@@ -0,0 +1,240 @@
# SPECS_AGENT_CHAT_LEARN_ACTION — 2026-06-01
> **DRAFT specs pour implémentation — pas encore appliqué.**
> Nouveau module `agent_chat/handlers/learn_action.py` orchestrant le dialogue Léa-first conversationnel et le raccordement au cycle Shadow existant (streaming server port 5005).
> Référence d'archi : Claude 2026-06-01 16:20 + addendum 17:45. Audit 17:00 : pas de nouveau code dashboard pour l'apprentissage — tout passe par agent-chat (port 5004).
## Sommaire
1. Architecture du module
2. Intent recognition (3 approches, reco hybride)
3. Mapping intents → appels Shadow
4. Formateur Option C (restitution texte + libellés OCR)
5. Itération correction (phase 4)
6. Phase 5 — nomination + scope (paramètres vs constantes)
7. Hooks mode proactif (LoopDetector)
8. Persistance d'état (session_id.json)
9. Tests à prévoir
10. Estimation effort implémentation
---
## 1. Architecture du module
**Fichier cible** : `agent_chat/handlers/learn_action.py` (nouveau, dossier `handlers/` à créer).
**Classe principale** : `LearnActionOrchestrator`
- Instanciée une fois par `agent_chat/app.py` au démarrage (singleton via `get_learn_action_orchestrator()` cohérent avec `intent_parser`, `confirmation`, `conversation_manager`).
- Injection : `StreamingClient` (wrapper httpx sync vers `RPA_STREAMING_URL` port 5005), `IntentParser` (réutilisé), `ResponseGenerator` (formuler phrases Léa), callback `emit_dual` (réutilise `_emit_dual` de `app.py` pour socket.io).
**Machine d'état finie** (Enum `LearnState`) :
```
IDLE
→ LISTENING (déclencheur : bouton 🎓 ou phrase magique → POST /shadow/start)
→ WAITING_USER_STOP (Léa observe en silence ; ping périodique notifications)
→ ANALYZING (utilisateur a dit "stop" → POST /shadow/stop)
→ PRESENTING (Léa restitue Option C dans le chat)
→ ITERATING_FEEDBACK (boucle correction step-par-step)
→ NAMING (Léa demande nom + scope paramètres)
→ PERSISTING (POST /api/v1/lea/competences/candidate/persist)
→ DONE | ABORTED
```
Transitions illégales rejetées + log. Chaque transition réussie : (a) sauvegarde d'état §8, (b) émission socket.io `lea:learn_state_changed`.
**Persistance d'état par session** : `agent_chat/state/<session_id>.json` (dossier créé au démarrage). Format §8.
**Interface streaming server (5005)** :
- Client httpx sync (cohérence avec `requests` déjà utilisé dans `app.py:31`).
- Wrapper `StreamingClient` (~50 lignes) : `shadow_start`, `shadow_stop`, `shadow_understanding`, `shadow_feedback`, `shadow_build`, `competence_persist`.
- Timeout 5s par appel, retry x2 sur `ConnectionError`, logging structuré.
- Auth via `RPA_API_TOKEN` env var (réutilise `_streaming_headers()`).
**Découplage** : le module ne touche **jamais** directement à `app.py` ni aux templates HTML. Il expose API Python (`start_session`, `handle_chat_message`, `handle_proactive_signal`, `resume_session`). C'est `app.py` qui route les messages du chat vers ce handler quand `LearnState != IDLE`.
## 2. Intent recognition
Pendant une session, messages utilisateur en français libre. Léa doit comprendre : start, stop, validate_step, correct_step, name_competence, mark_parameter, cancel.
**3 approches** :
- **(a) Phrases magiques regex** — déterministe, 0 latence, fragile sur variantes.
- **(b) NLU léger `qwen2.5:0.5b` Ollama** — robuste paraphrases, latence ~200-500ms, format=json mode.
- **(c) Hybride a → b — RECOMMANDÉ** : table regex sur ~80% des cas évidents, fallback `qwen2.5:0.5b` si aucun match avec confidence ≥ 0.9. Si LLM `confidence < 0.7` → Léa demande clarification.
**Implémentation** : sous-classe `LearnIntentParser` qui *étend* `IntentParser` existant en ajoutant enum `LearnIntent` (distinct de `IntentType`).
## 3. Mapping intents → appels Shadow
| Intent (`LearnIntent`) | Phase | Appel HTTP (5005) | Payload clé | Effet |
|---|---|---|---|---|
| `START_OBSERVE` | IDLE → LISTENING | `POST /api/v1/shadow/start` | `{session_id}` | démarre observation Windows |
| `USER_STOP_OBSERVE` | WAITING_USER_STOP → ANALYZING | `POST /shadow/stop` + `GET /shadow/<id>/understanding` | `{session_id}` | récupère steps compris |
| `VALIDATE_STEP` | ITERATING_FEEDBACK | `POST /shadow/feedback` | `{session_id, action:"validate", step_index}` | marque validé |
| `CORRECT_STEP` | ITERATING_FEEDBACK | `POST /shadow/feedback` | `{session_id, action:"correct", step_index, new_intent}` | corrige intent |
| `UNDO_STEP` | ITERATING_FEEDBACK | `POST /shadow/feedback` | `{session_id, action:"undo", step_index}` | retire step |
| `MERGE_NEXT` | ITERATING_FEEDBACK | `POST /shadow/feedback` | `{session_id, action:"merge_next", step_index}` | fusionne |
| `SPLIT_STEP` | ITERATING_FEEDBACK | `POST /shadow/feedback` | `{session_id, action:"split", step_index, at_event_index}` | coupe |
| `NAME_COMPETENCE` | NAMING (interne) | aucun | — | prépare payload persist |
| `MARK_PARAMETER` | NAMING (interne) | aucun | — | annote step (`is_parameter: true`) |
| `PERSIST` | NAMING → PERSISTING | `POST /api/v1/lea/competences/candidate/persist` | `{session_id, name, slug, parameters[]}` | enregistre |
| `CANCEL` | n'importe → ABORTED | `POST /shadow/stop` + journaling sortie d'urgence si partial | `{session_id, reason}` | nettoyage |
**Note** : payload final de `/persist` non figé tant que les specs parallèles ne sont pas mergées. Couche `PersistPayloadBuilder` isolée pour réagir aux évolutions.
**Préalable shadow_build** : avant `/persist`, appeler `POST /shadow/build` pour matérialiser WorkflowIR final (incluant feedbacks rejoués).
## 4. Formateur Option C
**But** : transformer `understanding[]` retourné par `/shadow/<id>/understanding` en texte naturel français + libellés OCR entre « ».
**Format attendu** :
```
1. Fenêtre « Patient » → ouverte
2. Bouton « Nouvelle facture » → cliqué
3. Champ « IPP » → saisi : « 25003284 »
4. Bouton « Valider » → cliqué (à confirmer)
```
**Règles** :
- 1 ligne par step.
- Libellés OCR entre « » (U+00AB / U+00BB).
- Verbe passé composé (`cliqué`, `saisi`, `ouverte`, `validé`).
- Si `confidence_ocr < 0.6` → suffixe `(à confirmer)`.
- Numérotation à partir de 1.
**Implémentation** : classe `OptionCFormatter` (~80 lignes), méthode `format(understanding: list) -> str`. Table mapping `action_type → verbe`. Fallback générique « action sur "X" → effectuée » si type inconnu.
**Inclure aussi** : question fermée Léa après la liste : « C'est bien ça ou je me suis trompée quelque part ? ». Transition vers ITERATING_FEEDBACK.
## 5. Itération correction (phase 4)
**Parser** doit détecter :
- Numéro de step : regex `\b(?:étape|numéro|step|ligne|le|la)?\s*([1-9]\d?)\b` + fallback contextuel.
- Action correction : type de feedback shadow (validate / correct / undo / merge_next / split).
- Contenu correction : si `correct`, extraire `new_intent` (reste de phrase après verbe correctif). Mini appel `qwen2.5:0.5b` pour nettoyer.
**Boucle** :
1. Appel `/shadow/feedback`.
2. Récupération nouveau `understanding[]` via `/shadow/<id>/understanding`.
3. Reformulation Léa avec **recap complet** (Option C re-formatée). Pas de diff partiel.
4. Question fermée : « C'est bon ou il reste à corriger ? ».
**Garde-fous** :
- **Limite 3 corrections sur même step** (compteur par `step_index`). Au 4ᵉ → sortie d'urgence : « Je n'arrive pas à comprendre l'étape N°X. Je préfère qu'on reprenne plus tard. Je garde tout. » → ABORTED avec `partial=true`.
- **Timeout silencieux** : 5 min sans message → Léa relance « Tu es toujours là ? Si tu veux on s'arrête, je garde tout. »
- **Détection boucle de doute** : alternance 2 fois `correct`/`undo` sur même step → Léa propose de relancer l'enregistrement de cette étape seulement.
## 6. Phase 5 — nomination + scope
**5.1 Nomination** :
- Léa : « Comment on appelle cette tâche ? Tu peux la nommer simplement, en français. »
- Validation : non vide, ≤ 80 chars, pas de doublon strict (lookup `GET /api/v1/lea/competences?name=…`).
- **Slugification côté serveur** : client envoie `name` brut, serveur dérive `slug`.
**5.2 Scope paramètres** :
- Pour chaque step avec valeur saisie, Léa pose la question : « La valeur "25003284" pour le champ "IPP" — c'est l'exemple du jour ou ça doit toujours être ça ? »
- Choix :
- « toujours » / « constante » → `is_parameter=False`
- « ça change » / « exemple » → `is_parameter=True`, nom proposé (default = nom champ OCR slugifié), confirmation
- Résumé final : « Donc je retiens : tâche `<nom>` avec les paramètres `ipp`, `montant`. C'est OK ? »
## 7. Hooks mode proactif (LoopDetector)
**Contexte** : `agent_v0/server_v1/loop_detector.py` détecte 3 signaux : `screen_static`, `action_repeat`, `retry_threshold`.
**Mécanisme** :
- Souscription WebSocket (à exposer côté streaming) ou polling `GET /api/v1/shadow/loop_signals?since_ts=…` toutes les 30s.
- Sur `action_repeat` → message proactif : « J'ai remarqué que tu fais souvent la même séquence. Tu veux m'apprendre à la faire pour toi ? » (bouton « Apprenez-moi » mis en évidence).
- Sur `retry_threshold` (pendant replay) → « Je n'arrive pas à reproduire l'étape N°X. Tu peux me re-montrer ? » → mini-apprentissage ciblé.
**Cooldown / mémoire** :
- Cooldown global 5 min entre 2 messages proactifs.
- Mémoire courte des **refus** (« non merci » → blocage 24h sur signal précis, persisté `agent_chat/state/proactive_memory.json`).
- **Report unique** : « pas maintenant » → re-proposition une fois 30 min plus tard.
**Pas d'auto-déclenchement** : un signal proactif n'appelle **jamais** `/shadow/start` sans validation utilisateur explicite.
## 8. Persistance d'état
**Fichier** : `agent_chat/state/<session_id>.json` (un par session active).
**Contenu** :
```json
{
"session_id": "...",
"user_id": "...",
"trigger_source": "button" | "magic_phrase" | "proactive",
"state": "ITERATING_FEEDBACK",
"created_at": "...",
"last_transition_at": "...",
"shadow_understanding": [...],
"pending_feedbacks": [...],
"correction_counters": {"3": 2, ...},
"competence_name": null | "...",
"parameters_marked": [...],
"abort_reason": null | "..."
}
```
**Écriture** : à chaque transition d'état (atomique `tmp` + `os.replace`), à chaque feedback appliqué, à chaque marquage paramètre.
**Reprise** : au démarrage `app.py`, scanner `state/*.json`. Pour chaque non-DONE/ABORTED :
- Vérifier session shadow côté streaming.
- Si trouvée → restaurer + notifier user : « On était en train d'apprendre "<nom>". Tu veux qu'on reprenne ou qu'on jette ? »
- Si shadow plus en mémoire → `state=ABORTED, abort_reason="streaming_lost"`.
**Nettoyage** : DONE archivées dans `state/archive/` après 7 jours ; ABORTED conservées 30 jours.
## 9. Tests à prévoir
**Unit (`tests/agent_chat/test_learn_action.py`)** :
- `LearnIntentParser` : 30+ cas regex + 5 cas LLM mocké.
- `OptionCFormatter` : 10 cas understanding → texte attendu, dont confidence basse.
- Gestionnaire corrections : 3 corrections → sortie d'urgence, boucle correct/undo → propose re-record.
- `StateStore` : write atomique, reprise après crash simulé.
**Integration (`tests/agent_chat/test_learn_action_integration.py`)** :
- Flux complet mocké bout-en-bout.
- Cas redémarrage agent-chat pendant `ITERATING_FEEDBACK` → reprise propre.
- Cas streaming down sur `/shadow/stop` → retry x2 puis message d'erreur explicite.
- Cas signal proactif `action_repeat` → message + cooldown.
**Robustesse** :
- Timeout streaming sur `/persist` → état `PERSISTING`, retry manuel possible.
- Agent Windows down pendant LISTENING → détection > 60s → « Je n'arrive pas à observer, on réessaie ? ».
- Concurrence 2 sessions parallèles → pas d'interférence.
**Couverture cible** : ≥ 80% sur `learn_action.py`, ≥ 70% global.
## 10. Estimation effort implémentation
| Composant | Lignes Python | Lignes tests | Effort |
|---|---:|---:|---|
| `LearnActionOrchestrator` + machine d'état | ~180 | ~80 | moyen |
| `LearnIntentParser` (hybride) | ~120 | ~60 | moyen |
| `OptionCFormatter` | ~80 | ~40 | léger |
| `StreamingClient` (httpx wrapper) | ~70 | ~30 | léger |
| `StateStore` (persistance + reprise) | ~80 | ~30 | léger |
| `ProactiveHook` (LoopDetector) | ~70 | ~10 | moyen (dépend WS server) |
| **Total** | **~600** | **~250** | **moyen-lourd** |
**Dépendances bloquantes** :
- Specs `/persist` parallèles à figer avant `PersistPayloadBuilder`.
- Endpoint WebSocket / polling LoopDetector côté streaming.
**Hors scope** :
- Bouton 🎓 Windows : déjà fonctionnel, on s'aligne sur trigger existant.
- UI dashboard 5001 : aucune intervention.
- Modifs `app.py` : injection singleton + routage messages quand `state != IDLE` (~40 lignes additionnelles).
**Risques** :
- Latence cumulée NLU + HTTP : viser ≤ 1s par tour utilisateur.
- Cohérence `correction_counters` local et historique `validator` côté shadow : source de vérité = observer (relecture via `/understanding`).
- Reprise après crash : éviter `/shadow/feedback` déjà appliqué (idempotence à valider).
---
*Fin DRAFT — relecture Codex/Dom attendue avant implémentation. Aucune modification de fichier de prod.*

View File

@@ -0,0 +1,210 @@
# SPECS — POST /api/v1/lea/competences/candidate/persist
> **DRAFT specs pour implémentation — pas encore appliqué — relecture Codex/Dom attendue**
>
> Date : 2026-06-01
> Auteur : Claude (agent Plan)
> Statut : DRAFT — aucun code écrit, aucun fichier de prod modifié
> Cible : `agent_v0/server_v1/api_stream.py`
## Contexte
L'audit Claude du 2026-06-01 17h00 confirme l'absence de ce endpoint. Le cycle Shadow apprentissage actuel s'arrête à `/api/v1/shadow/build` (renvoie le `workflow_ir` en mémoire, ne le persiste pas).
Ce endpoint clôt le cycle Léa-first (6 phases) :
`start → stop → understanding → feedback (N×) → build → **persist**`
Sources :
- `docs/coordination/inbox_codex/2026-06-01_1620_claude-to-codex_ARCHI-apprentissage-Lea-first-validee-Dom.md`
- `docs/coordination/inbox_codex/2026-06-01_1745_claude-to-codex_ADDENDUM-archi-Lea-lecture-semantique-agent-externe.md`
- `docs/coordination/inbox_codex/2026-06-01_1800_claude-to-codex_PRECISIONS-desapprentissage-regle-or-HDS.md`
Schéma YAML de référence : `data/competences/candidate/key_win_r_wait_explorer_exe.yaml`
## Sommaire
1. Contrat de l'endpoint
2. Validation pré-écriture
3. Génération YAML
4. Journalisation
5. Cas particuliers
6. Sécurité
7. Tests à prévoir
8. Estimation effort implémentation
---
## 1. Contrat de l'endpoint
**Méthode / URL** : `POST /api/v1/lea/competences/candidate/persist`
**Headers requis** :
- `Authorization: Bearer <token-poste>` (cohérent avec patch P0 révocation token Codex)
- `Content-Type: application/json`
**Payload entrée (JSON)** :
```json
{
"name": "Saisir texte Word",
"machine_id": "DESKTOP-58D5CAC_windows",
"session_id": "sess_20260601T180000_abc123",
"workflow_ir": { "steps": [...], "preconditions": [...], "success_marker": {...} },
"parameters": [ { "name": "texte", "type": "string", "required": true } ],
"external_decision": {
"agent_id": "t2a_v2_codeur",
"decision_contract": "..."
},
"annotations_semantiques": {
"intent_fr": "saisir le texte transmis dans Word"
},
"learning_metadata": {
"persist_id": "uuid-v4-client",
"partial": false,
"dropout_reason": null,
"source_phase": "shadow_build"
}
}
```
**Codes HTTP** :
- `201 Created` — YAML écrit, audit journalisé
- `400 Bad Request` — payload invalide (schema, slug, agent externe inconnu)
- `401 Unauthorized` — token absent / invalide / révoqué
- `403 Forbidden``machine_id` du payload ≠ machine du token
- `409 Conflict` — slug existe déjà dans `data/competences/candidate/`
- `429 Too Many Requests` — rate limit dépassé
- `500 Internal Server Error` — échec atomic write ou roundtrip
**Payload sortie (201)** :
```json
{
"competence_id": "saisir_texte_word",
"yaml_path": "data/competences/candidate/saisir_texte_word.yaml",
"learning_state": "candidate",
"persist_id": "uuid-v4-serveur",
"audit_entry_id": 4271
}
```
## 2. Validation pré-écriture
**Slugification** (`name``competence_id`) :
- Conversion kebab-case ASCII (translittération accents)
- Lowercase, espaces → `_`, chars non `[a-z0-9_]` retirés
- Longueur ≤ 80 caractères, ≥ 3 caractères
- Pattern regex final : `^[a-z][a-z0-9_]{2,79}$`
- Si collision avec un fichier existant en `candidate/` : **409 sans suffixage automatique**
**Schema YAML cohérent** — champs obligatoires : `schema_version, id, name, version, learning_state, intent, parameters, preconditions, methods, success_marker, failure_message_template, promotion, generalisation, failure_log, created_at, last_updated_at, methods_execution`
**WorkflowIR** :
- `workflow_ir.steps` non vide **sauf si** `learning_metadata.partial == true`
- Chaque step possède `kind` ∈ primitives connues (réutiliser registry primitives existante)
**Idempotence** :
- `persist_id` (UUID v4 client) déduit la clé d'idempotence
- Si `persist_id` déjà présent dans `persist_audit.jsonl` → renvoyer `200 OK` avec le payload de l'entrée précédente (pas 409)
## 3. Génération YAML
**Chemin cible** : `data/competences/candidate/<slug>.yaml`
**Atomic write** :
1. Écrire dans `data/competences/candidate/.<slug>.yaml.tmp.<persist_id>`
2. `os.rename()` vers `<slug>.yaml` (atomique POSIX)
3. Si collision détectée entre validation et rename → 409, supprimer le `.tmp`
**Post-write roundtrip** :
- Recharger via `core.competences.catalog.load_competence(slug)`
- Vérifier que `competence.to_dict()` est cohérent
- Si KO → 500, supprimer le fichier, log critique
## 4. Journalisation
**Audit principal** — append-only `data/competences/persist_audit.jsonl` :
```json
{
"persist_id": "uuid-v4",
"timestamp": "2026-06-01T18:05:32+02:00",
"machine_id": "...",
"session_id": "...",
"competence_name": "Saisir texte Word",
"competence_id": "saisir_texte_word",
"yaml_path": "data/competences/candidate/saisir_texte_word.yaml",
"learning_state": "candidate",
"partial": false,
"dropout_reason": null,
"external_agent_id": null,
"audit_entry_id": 4271
}
```
**Apprentissages incomplets** — si `partial: true`, entrée *en plus* dans `data/competences/incomplete_learnings.jsonl` avec `dropout_reason` obligatoire.
**Append-only strict** : ouvrir en mode `a`, jamais réécrire. Verrou `fcntl.flock` par fichier.
## 5. Cas particuliers
| Cas | Comportement |
|---|---|
| `learning_metadata.partial == true` | accepté, `learning_state` forcé `incomplete`, entrée double dans `incomplete_learnings.jsonl`, `dropout_reason` obligatoire (sinon 400) |
| Payload demande `learning_state: stable` | **rejet silencieux** — forcer `candidate` (jamais stable par persist direct — règle d'or HDS) |
| Aucun `learning_state` fourni | défaut `candidate` |
| `external_decision.agent_id` inconnu de `ExternalDecisionClient` registry | **400** avec liste agents valides |
| `workflow_ir.steps` vide et `partial == false` | **400** |
| YAML déjà présent en `data/competences/stable/` ou `supervised/` avec même slug | **409** (collision cross-states) |
| Désapprentissage en cours sur le slug | **409** `detail: "désapprentissage actif"` |
## 6. Sécurité
- **Token Bearer obligatoire** — réutiliser middleware d'auth du patch P0 révocation token. Pas de fallback "agent local".
- **Couplage machine_id ↔ token** : token référence `machine_id` ; si `payload.machine_id != token.machine_id`**403**.
- **Rate limit** : 10 persists/min/`machine_id` (in-memory token-bucket). Au-delà → **429** avec `Retry-After`.
- **Pas d'écriture hors `data/competences/candidate/`** : path traversal interdit (slug strict, jamais lu d'un champ user).
- **Logs** : ne jamais journaliser le token. `persist_id` et `machine_id` only.
- **Règle d'or HDS** : aucune donnée patient ne doit transiter — refuser le payload si `workflow_ir` ou `annotations_semantiques` contient pattern PII détecté. Détection MVP via regex paramétrable.
## 7. Tests à prévoir
**Unit (`tests/unit/test_competence_persist.py`)** :
- `test_slug_generation_normal` / `_with_accents` / `_too_short` / `_collision_409`
- `test_yaml_schema_required_fields_present`
- `test_atomic_write_then_rename`
- `test_post_write_roundtrip_ok` / `_corrupt_yaml_500`
- `test_idempotence_same_persist_id_returns_previous`
- `test_partial_true_forces_incomplete_state`
- `test_payload_stable_forced_to_candidate`
- `test_external_agent_unknown_400`
- `test_pii_detected_rejected`
**Integration (`tests/integration/test_shadow_full_cycle.py`)** :
- Cycle complet : `shadow/start → POST événements → shadow/stop → shadow/build → persist`
- Vérifier YAML présent, `persist_audit.jsonl` enrichi
- Cas partial : double entrée `incomplete_learnings.jsonl`
**Sécurité (`tests/security/test_persist_auth.py`)** :
- `test_no_token_401`
- `test_token_revoked_401`
- `test_machine_id_mismatch_403`
- `test_rate_limit_11th_call_429`
- `test_path_traversal_in_name_blocked`
## 8. Estimation effort implémentation
| Item | Volume |
|---|---|
| Handler FastAPI + Pydantic models | ~80-120 lignes (`api_stream.py`) |
| Helpers (slugify, atomic_write, audit_append) | ~40 lignes (nouveau module `core/competences/persist.py`) |
| Tests unit + integration + sécu | ~150 lignes |
**Effort total** : faible-moyen — **0.5 à 1 jour**. Pas de dépendance externe nouvelle, réutilise `core.competences.catalog` et le pattern atomic-write de C-γ.
**Points d'attention pour la review** :
- Confirmer comportement collision (409 strict vs suffixage `_v2`)
- Valider mapping `workflow_ir.steps[*].kind``yaml.methods[*].kind` avec registry primitives à jour
- Décider si rate limit par `machine_id` ou par token
- Statuer sur format `audit_entry_id` (auto-incrément vs hash)
---
*Fin DRAFT — relecture Codex/Dom attendue avant kick-off implémentation.*

View File

@@ -0,0 +1,169 @@
# SPECS Phase 2.5 Sémantique — POC RPA Vision v3
**DRAFT specs pour implémentation — pas encore appliqué — alignement avec arbitrage Plato 2026-06-01 18:18**
Date : 2026-06-01
Auteur : Claude (agent Plan), arbitrage Plato/Codex
Statut : DRAFT — à valider par Dom avant implémentation
Référence arbitrage : `docs/coordination/inbox_claude/2026-06-01_1818_codex-to-claude_ADDENDUM-agent-plato-archi-semantique.md`
Référence addendum initial Claude : 17:45 (OmniParser systématique dans hot path replay) — **révoqué par Plato**
## Principes d'arbitrage retenus
1. Garder replay/click/OCR existant comme chemin principal (hot path inchangé)
2. Phase 2.5 = post-apprentissage uniquement, jamais en hot path replay
3. Demander à l'humain seulement les ambiguïtés utiles
4. Sémantique = contexte, pas prérequis de chaque clic
5. `ExternalDecisionClient` autour de `t2a_decision`, puis adaptation AIVA
6. OCR critique renforcé par régions annotées + escalade humaine
## Sommaire
1. Périmètre Phase 2.5 MVP
2. Endpoint à créer
3. Identification des écrans distincts
4. Stockage des annotations sémantiques
5. Réutilisation au replay (autopilote / autonome)
6. Compatibilité compétences existantes
7. Garde-fous anti-fragilité OmniParser
8. Coûts perf attendus
9. Tests à prévoir
10. Estimation effort implémentation
11. Hors scope MVP Phase 2.5
---
## 1. Périmètre Phase 2.5 MVP
- **Quand** : déclenchement à la fin de la phase d'observation, après `POST /api/v1/shadow/stop`, **avant** restitution Option C à l'humain.
- **Sur quoi** : screenshots clés capturés (1 par écran distinct détecté), **pas tous les frames**.
- **Avec quoi** : OmniParser réutilisé (déjà présent, adaptateur fragile mais isolé), encapsulé derrière garde-fou anti-fragilité §7.
- **Pour quoi** : produire un payload structuré `{tables[], forms[], buttons[], text_blocks[]}` par écran clé, stocké dans un YAML sémantique séparé du YAML compétence principal.
- **Non-objectif** : Phase 2.5 ne touche pas le chemin replay. Elle enrichit uniquement la compétence apprise.
## 2. Endpoint à créer
`POST /api/v1/lea/screen/analyze` côté streaming server (port 5005).
- **Entrée** : `{session_id: str, screenshot_indexes: int[]}` — index des frames capturées en mode léger pendant l'observation.
- **Sortie** : `{screens: [{index, hash, structure: {tables, forms, buttons, text_blocks}}]}`.
- **Idempotence** : cache disque sous `data/cache/omniparser/<session>/<index>.json`.
- **Timeout** : 30s par screenshot. Dépassement → fallback OCR-seul (docTR text_blocks seuls), flag `degraded: true`.
- **Erreur OmniParser** (exception, modèle KO) : fallback OCR-seul + log `logs/omniparser_errors.log`. L'endpoint ne renvoie **jamais** 500 — toujours 200 avec flag dégradé.
## 3. Identification des écrans distincts
Heuristique de regroupement, exécutée avant l'appel `/api/v1/lea/screen/analyze` :
- Calcul `imagehash.phash` pour chaque frame capturée.
- Grouping par similarité : Hamming distance ≤ 8 ⇒ même écran logique.
- Sélection d'un frame représentatif par groupe (premier dans l'ordre temporel, ou celui avec le plus de détections OCR).
- **Limite POC** : maximum **10 écrans distincts par session**. Au-delà, session marquée `too_complex` et l'humain est invité à scinder la compétence en sous-compétences (message explicite dans restitution Option C).
## 4. Stockage des annotations sémantiques
Fichier séparé du YAML compétence principal, suffixe `.semantic.yaml`.
Chemin : `data/competences/candidate/<slug>.semantic.yaml`
Structure :
```yaml
competence_id: facturation_urgence_simple
semantic_version: 1
generated_at: 2026-06-01T18:30:00Z
omniparser_version: <hash adaptateur>
degraded: false
screens:
- screen_id: screen_001
phash: "abc123..."
representative_frame_index: 42
annotations:
- region_id: region_3
bbox: [120, 80, 400, 150]
semantic_label: motif_arrivee
confidence: human_verified
structure:
tables: [...]
forms: [...]
buttons: [...]
text_blocks: [...]
```
- Séparation stricte : YAML compétence principal reste lisible et minimal. `.semantic.yaml` est optionnel.
- YAML compétence référence le fichier sémantique par clé `semantic_ref: <slug>.semantic.yaml` si présent.
## 5. Réutilisation au replay (autopilote / autonome)
OmniParser au replay **uniquement** si la compétence référence un `.semantic.yaml` (présence de `semantic_ref`).
Algorithme :
1. Au démarrage du replay d'un écran, calcul `phash` du screen courant.
2. Si Hamming distance ≤ 8 avec un `screen_id` connu OU même nombre de tables/buttons/forms structurellement → match, application des annotations.
3. Sinon → divergence :
- **Autopilote** : escalade humaine via Option C runtime (« écran inattendu, valider ou annoter »).
- **Autonome** : log incident `logs/replay_incidents.log`, **stop** avec code `semantic_mismatch`.
Compétences sans `semantic_ref` → on saute totalement cette étape (rétrocompat §6).
## 6. Compatibilité compétences existantes
- Compétences sans `.semantic.yaml` ⇒ pas de Phase 2.5 au replay, pas d'OmniParser, **chemin OCR + template + click inchangé**.
- Phase 2.5 = **opt-in par compétence** : seule une compétence apprise après l'activation de Phase 2.5 possédera le fichier sémantique.
- Aucune migration auto des compétences anciennes. Rétrocompat garantie par l'absence de `semantic_ref`.
- Pas de breaking change sur le format YAML compétence principal — seulement une clé optionnelle.
## 7. Garde-fous anti-fragilité OmniParser
Adaptateur OmniParser fragile (chemin absolu, dépendances lourdes). Mesures :
- **Wrapper try/except global** autour de chaque appel OmniParser, fallback OCR-seul (docTR) systématique en cas d'exception.
- **Log dédié** : toute erreur → `logs/omniparser_errors.log` avec stack trace, session_id, frame_index.
- **Healthcheck au démarrage Phase 2.5** : appel test sur image bidon. Si échec → bascule auto en mode dégradé OCR-seul, log warning, restitution `degraded: true`.
- **Test unitaire obligatoire** : mock OmniParser qui lève exception, vérifier que la chaîne aboutit à un `.semantic.yaml` dégradé valide.
- **Isolation chemin absolu** : config OmniParser (chemin modèle, version) centralisée dans `config/omniparser.yaml`, jamais en dur.
## 8. Coûts perf attendus
- **Dev (RTX 5070, OmniParser CPU)** : 2 à 5s par screen. Acceptable (principe 5 Dom, hors hot path).
- **DGX Spark (cible prod)** : < 500ms par screen.
- **Coût session typique** : 10 screens × 2-5s = 20-50s ajoutés à la fin d'une session d'apprentissage sur dev. Tolérable, asynchrone vis-à-vis humain (restitution Option C peut afficher spinner).
- Pas d'impact sur replay (hot path) tant que pas de `semantic_ref`. Avec `semantic_ref`, surcoût = `phash` (négligeable) + OmniParser uniquement en cas de mismatch structure.
## 9. Tests à prévoir
**Unitaires** :
- Hash perceptuel + grouping : frames similaires/différentes, vérif Hamming threshold.
- Fallback OCR-seul si OmniParser KO (mock exception, timeout, healthcheck KO).
- Génération `.semantic.yaml` : structure valide, `degraded` correctement positionné.
- Cap 10 écrans : session avec 15 → marquage `too_complex` propre.
**Intégration** :
- Flux complet : session shadow → stop → identification écrans → analyse → `.semantic.yaml` écrit → Option C.
- Replay compétence avec `.semantic.yaml` : match nominal, mismatch déclenche escalade autopilote / stop autonome.
- Replay compétence sans `.semantic.yaml` : chemin legacy intact, aucun appel OmniParser.
**Sécurité** :
- Injection chemins relatifs/absolus dans `session_id` ou `slug` refusée (regex strict `^[a-z0-9_]+$`).
- Pas d'écriture hors `data/competences/candidate/` et `data/cache/omniparser/`.
## 10. Estimation effort implémentation
- ~200-300 lignes Python (endpoint, grouping phash, wrapper OmniParser, écriture YAML, lecture replay).
- ~150 lignes tests.
- Effort **moyen**, étalable sur 1-2 jours.
- **Risque régression faible** : ne touche pas hot path replay, opt-in par compétence, rétrocompat totale.
## 11. Hors scope MVP Phase 2.5
- Détection visuelle de changements d'écran en temps réel pendant le replay (= hot path, révoqué par Plato).
- Annotations sémantiques automatiques par VLM (prérequis trop ambitieux POC).
- Versioning sémantique multi-versions Easily (post-POC).
- Recalcul auto annotations si interface change (post-POC, lié mode rééducation).
- Annotations multi-langues (POC français uniquement).
- Synchronisation `.semantic.yaml` entre agents distribués (post-POC).
---
**Fin DRAFT — à valider par Dom avant implémentation. Aucune modification de fichier de prod. Aucun commit.**