4 Commits

Author SHA1 Message Date
Dom
882e4e1f3a docs(design+audit): navigate coords consumption gaps + dead code C-MORT audit
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m51s
tests / Tests unitaires (sans GPU) (push) Has been cancelled
tests / Tests sécurité (critique) (push) Has been cancelled
DESIGN_NAVIGATE_COORDS_CONSUMPTION_2026-07-02.md: 3 structural gaps
with code evidence (Gap A/B/C), 2 resolution options with comparative
table, test rouge proposal.

AUDIT_CODE_MORT_2026-07-02.md: 8 C-MORT, 5 B-ORPHELIN, 4 duplicats,
3 QG-gated suppression lots (~1900 lines).
2026-07-02 13:02:04 +02:00
Dom
cac965cef9 test(coords+capture): coords write-only gap (10 tests) + capture I/O + image_chat_cli
test_coords_consumption_gap.py documents 3 structural gaps where
NavigateCoords are written but never consumed. test_capture_io.py and
test_image_chat_cli.py cover capture and chat CLI paths.
2026-07-02 13:01:49 +02:00
Dom
ebed4d7546 feat(vwb): pont R1 import idempotent core→DB par signature trajectoire
Add import_core_workflow_to_db() — create-or-update par signature de
trajectoire (décision produit Dom 23/06). Les workflows source='manual'
sont exclus du filtre de fusion. Inclut test TDD idempotent (ré-import
2× → toujours 1 seul workflow).
2026-07-02 13:01:33 +02:00
Dom
9a8242add5 chore(gitignore): ignore coordination ephemeral dirs + untrack workflows.db
- Add inbox_qwen/, inbox_codex/, inbox_claude/, active/ to gitignore
- Add .inbox_baseline.txt, .loop_log.txt to gitignore
- git rm --cached workflows.db (runtime data, already covered by **/instance/*.db rule)
2026-07-02 13:01:18 +02:00
10 changed files with 1368 additions and 0 deletions

7
.gitignore vendored
View File

@@ -126,7 +126,14 @@ tools/codex_windows_correction_rapport.py
docs/clients/ docs/clients/
.qw-baseline.log .qw-baseline.log
# Coordination ephemeral — inbox messages, active decisions, loop state
docs/coordination/.loop_state/ docs/coordination/.loop_state/
docs/coordination/.inbox_baseline.txt
docs/coordination/.loop_log.txt
docs/coordination/inbox_qwen/
docs/coordination/inbox_codex/
docs/coordination/inbox_claude/
docs/coordination/active/
# Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné) # Runtime Python embedded pour l'installateur Inno Setup (local, ~11M, non versionné)
deploy/installer/python-3.12-embed/ deploy/installer/python-3.12-embed/

View File

@@ -0,0 +1,106 @@
# Audit Code Mort — Classification A/B/C — 2026-07-02
**Auteur**: Qwen (vérifié par grep/glob/commandes réelles)
**Date**: 2026-07-02
**Méthode**: Parallel agent exploration + grep verification + graphify cross-check
---
## Méthodologie
- **A (WIRED/ACTIF)** : Code importé et appelé dans le runtime de production
- **B (ORPHAN/PROJECTION)** : Code avec lazy import ou projection future, pas appelé actuellement mais structuré pour activation
- **C (MORT/CONFIRMÉ)** : Code zero imports, zero callers, zero runtime activation — candidat suppression
**Règle**: C-MORT nécessite GO Dom avant suppression. B-ORPHELIN conserve. A-WIRED documenté.
---
## C-MORT Confirmé (8 items, ~843 lignes)
| # | Fichier/Zone | Lignes | Preuve C-MORT | Risque suppression |
|---|-------------|--------|---------------|-------------------|
| C1 | `agent_v0/deploy_windows.py` | ~244 | Comment "OBSOLETE avril 2026" + zero imports | LOW — standalone script |
| C2 | `core/config.py`: 7 deprecated config classes | ~160 | Zero prod imports, mirrorent SystemConfig | LOW — mais vérifier .env references |
| C3 | `core/detection/owl_detector.py`: 4 methods | ~90 | Zero callers dans prod | LOW — vérifier examples/ |
| C4 | `core/detection/ollama_client.py`: 5 old methods | ~150 | Remplacés par classify_element_complete() | LOW — vérifier examples/ |
| C5 | `ollama_client.py:check_ollama_available()` standalone | ~15 | 8/9 callers in examples/, 1 in VWB (duplicat D2) | LOW — VWB a sa propre copie |
| C6 | `agent_chat/app.py`: 2 Flask 410 endpoints | ~14 | Endpoints déprecated, retour 410 Gone | LOW — API contract check |
| C7 | `core/grounding/smart_resize.py` (77 lines) | 77 | Zero prod callers, DETTE-007 triple impl | LOW — 2 autres impls existent |
| C8 | PP-OCRv5 (paddleocr+paddlepaddle venv) | ~deps | 0 .py imports across entire project | LOW — venv deps uninstall |
**Total C-MORT**: ~843 lignes code + venv deps
---
## B-ORPHELIN (5 items, ~537 lignes)
| # | Fichier/Zone | Lignes | Preuve B | Action |
|---|-------------|--------|----------|--------|
| B1 | VWB ui_detection_service OmniParser path | ~70 | HARD-DISABILÉ `_omniparser_available = False # DÉSACTIVÉ` | Conserver, documenter activation condition |
| B2 | `fusion_engine.py:_fuse_concat_projection()` | ~15 | Stub, prévu pour future fusion modes | Conserver, marque PROJECTION |
| B3 | `omniparser_adapter.py` | ~429 | BRANCHABLE DORMANT, try/except import | Conserver, documenter activation condition |
| B4 | `CorrectionStatus.DEPRECATED` enum value | ~3 | Enum value, pas supprimable sans break | Conserver, marque DEPRECATED |
| B5 | `catalog_routes_v2_vlm.py:check_ollama_available()` | ~20 | Duplicat de ollama_client.py (D2) | DÉCISION Dom : unifier ou garder 2 impls |
---
## Duplicats Identifiés (4)
| # | Item | Impl 1 | Impl 2 | Statut |
|---|------|--------|--------|--------|
| D1 | smart_resize | smart_resize.py (C7) | ui_detection_service.py resize | C-MORT vs WIRED |
| D2 | check_ollama_available | ollama_client.py (C5) | catalog_routes_v2_vlm.py (B5) | C-MORT vs B-ORPHELIN |
| D3 | ground_element | seeclick_adapter.py (B→provenance?) | ollama_client.py old method | B vs C4 |
| D4 | 7 deprecated config classes | core/config.py (C2) | SystemConfig (WIRED) | C-MORT vs A-WIRED |
---
## Classification Updates (C→A upgrades confirmés)
| Item | Prior Status | Current Status | Preuve upgrade |
|------|-------------|---------------|---------------|
| autonomous_planner.py | C | **A** | Migrated to agent_chat/, wired by app.py |
| seeclick_adapter.py | C | **B** | Lazy re-export, `_seeclick_available` never consulted mais impl ground_element indépendante |
| grounding/server.py | C | **A** | HTTP service port 8200, standalone Flask |
| get_grounding_profile() | C | **A** | Wired via ollama_client.py:303-304 lazy import |
---
## OmniParser — Classification 7 Zones
| # | Zone | Statut | Activation | Fallback |
|---|------|---------|-----------|----------|
| 1 | SoM engine (som_engine.py) | **A-WIRED** | YOLO weights direct | docTR OCR |
| 2 | resolve_engine (_get_omniparser) | **B-DORMANT** | Lazy Optional[bool] | None → skipped |
| 3 | phase25_analyzer (_OmniParserSafeWrapper) | **B-DORMANT** | Lazy import + healthcheck | docTR-only |
| 4 | api_stream healthcheck | **A-WIRED** | Always 200 omniparser_available:bool | degraded:true |
| 5 | omniparser_adapter.py | **B-DORMANT** | Import phase25 & resolve | empty list |
| 6 | VWB ui_detection_service.py | **B-HARD-DISABILÉ** | `_omniparser_available = False # DÉSACTIVÉ` | ui-detr-1 only |
| 7 | VWB catalog_routes_v2_vlm.py | **B-DORMANT** | try/except, flips True si installé | VLM fallback |
---
## QG-Gated Lots (proposé, nécessite GO Dom)
### Lot 1 — C-MORT Low Risk (suppression directe après GO Dom)
- C1 deploy_windows.py
- C7 smart_resize.py
- C6 agent_chat 410 endpoints
- C8 PP-OCRv5 venv deps uninstall
### Lot 2 — C-MORT Medium Risk (vérification examples/ avant suppression)
- C2 7 deprecated config classes (vérifier .env)
- C3 owl_detector 4 methods (vérifier examples/)
- C4 ollama_client 5 old methods (vérifier examples/)
- C5 check_ollama_available standalone (vérifier VWB duplicat)
### Lot 3 — Duplicats Unification (décision Dom)
- D1 smart_resize: unifier ou garder 2 impls
- D2 check_ollama_available: unifier VWB vs core
- D3 ground_element: unifier seeclick vs ollama
- D4 config classes: supprimer deprecated vs garder compat
---
**Prochaine étape**: Dom review → GO/NOGO par lot → exécution séquentielle avec tests verification après chaque lot.

View File

@@ -0,0 +1,218 @@
# Design Note — NavigateCoords Consumption Gap (Write-Only)
**Auteur**: Qwen
**Date**: 2026-07-02
**Statut**: DESIGN NOTE — pas de câblage sans GO Dom
**Référence**: `tests/unit/test_coords_consumption_gap.py` (10 tests PASSING documenting the gap)
---
## Executive Summary
Le module navigation (`core/navigation`) produit des coords normalisés (`NavigateCoords`) via OCR/VLM, les stocke dans `replay_state["variables"]`, mais **aucun consommateur** dans le runtime n'utilise ces coords. Le résultat est un pattern **write-only** : coords générés mais jamais consommés par les actions suivantes (click/type).
Trois gaps structurels confirmés par code lecture :
---
## Gap A — Compiler Produces Literals, Not Templates
**Localisation**: `replay_engine.py:1832-1846` (`_edge_to_normalized_actions`)
**Problème**: Pour `mouse_click`, le compiler bake `x_pct` et `y_pct` comme **floats littéraux** depuis `by_position` :
```python
# replay_engine.py:1843-1846
normalized["type"] = "click"
normalized["x_pct"] = x_pct # float littéral (ex: 0.15)
normalized["y_pct"] = y_pct # float littéral (ex: 0.07)
```
Ces floats sont **hardcodés** dans le step definition. Il n'existe pas de mécanisme pour référencer les coords navigate via templates comme `{{navigate_login_coords.x_pct}}`.
**La substitution existante ne couvre pas ce cas** :
- `_substitute_variables()``${var}` → appliqué uniquement à `text_input.text`
- `_RUNTIME_VAR_PATTERN``{{var.field}}` → compilé regex, **jamais appliqué à `x_pct/y_pct`**
**Conséquence**: Un navigate step qui résolve coords login à (0.15, 0.07) ne peut PAS injecter ces coords dans un click step suivant, car le click step a ses propres `x_pct/y_pct` hardcodés.
---
## Gap B — Zero Consumers in Runtime
**Localisation**: `core/navigation/__init__.py:43-113` (`_handle_navigate_action`)
**Problème**: `_handle_navigate_action` stocke coords dans `replay_state["variables"]` :
```python
# core/navigation/__init__.py:100-105
if result.login_coords:
variables[login_var] = result.login_coords.to_dict()
# → {"x_pct": 0.15, "y_pct": 0.07, "method": "ocr_anchor"}
```
**Zéro consommateur** : aucun action handler (click, type, double_click, right_click) lit `variables["navigate_login_coords"]` pour résoudre ses propres coords. Chaque action utilise exclusivement `by_position` depuis son edge definition.
**Preuve par grep** : `navigate_login_coords|navigate_password_coords|navigate_submit_coords` apparaît uniquement dans :
- `core/navigation/__init__.py` (write)
- `tests/unit/test_*.py` (test verification)
- **0 occurrences** dans `replay_engine.py` action dispatch ou `api_stream.py` action handlers
---
## Gap C — Navigate Edge → Empty Actions List
**Localisation**: `replay_engine.py:1806-1955` (`_edge_to_normalized_actions`)
**Problème**: Le type `navigate` est dans `_ALLOWED_ACTION_TYPES` (ligne 44) et possède un handler câblé dans `api_stream.py` (ligne 4459-4463 via `_handle_navigate_action`). Mais `_edge_to_normalized_actions` **n'a pas de branche** pour `navigate` :
```python
# replay_engine.py:1954-1955 (else branch)
else:
logger.warning(f"Type d'action inconnu : {action_type}")
return []
```
**Conséquence** : Quand le BFS traverse un edge navigate, `_edge_to_normalized_actions(edge, params)` retourne `[]`. L'action navigate est **skippée** dans le path. Le handler existe dans `api_stream.py` mais est **inaccessible** car le normalized action dict n'est jamais produit.
**Paradoxe** : Le navigate handler est câblé et fonctionnel, mais le pipeline edge→action le bloque à l'entrée.
---
## Options de Résolution
### Option 1 — Compiler Injection (modifier `_edge_to_normalized_actions`)
**Approche**: Ajouter une branche `navigate` dans `_edge_to_normalized_actions` qui produit un normalized action dict. Modifier les actions click/type pour permettre des template refs `{{navigate_login_coords.x_pct}}` dans `x_pct/y_pct`, avec résolution runtime.
```python
# Option 1 — Branch navigate dans _edge_to_normalized_actions
elif action_type == "navigate":
normalized["type"] = "navigate"
normalized["parameters"] = {
"action": action_params.get("action", "login"),
"login_coords_var": action_params.get("login_coords_var", "navigate_login_coords"),
"password_coords_var": action_params.get("password_coords_var", "navigate_password_coords"),
"submit_coords_var": action_params.get("submit_coords_var", "navigate_submit_coords"),
}
return [normalized]
```
**+ Avantages** :
- Minimal change — 1 branche ajoutée + template resolution dans click/type
- Compatible avec handler existant (`_handle_navigate_action`)
- BFS path inclut navigate → handler appelé → coords stockés → consommés
** Risques** :
- Template resolution dans `x_pct/y_pct` nécessite modification de click/type dispatch
- Float vs string : `{{navigate_login_coords.x_pct}}` résout en `"0.15"` (string), pas `0.15` (float) — nécessite conversion
- Ordonnancement : navigate doit s'exécuter AVANT les actions click/type qui consomment ses coords — scheduling implication
### Option 2 — Declarative YAML Templates (step definitions avec coords_template)
**Approche**: Ajouter un champ `coords_template` dans les step YAML definitions. Au runtime, le template est résolu par substitution des variables navigate.
```yaml
# Option 2 — YAML step definition avec coords_template
steps:
- action: navigate
parameters:
action: login
login_coords_var: navigate_login_coords
- action: mouse_click
coords_template: "{{navigate_login_coords}}"
# Au runtime : x_pct/y_pct résolus depuis navigate_login_coords dict
```
**+ Avantages** :
- Déclaratif — coords templates dans YAML, pas hardcoded
- Séparation compiler/runtime : compiler produit templates, runtime résout
- Extensible à autres types de coords (search, dossier)
** Risques** :
- Plus de changement : schema YAML + template resolver + compiler modifications
- Retro-compatibilité : workflows existants sans coords_template doivent continuer à fonctionner (fallback by_position)
- Validation : templates malformés → runtime errors subtiles
---
## Table Comparative
| Critère | Option 1 (Compiler Injection) | Option 2 (YAML Templates) |
|---------|-------------------------------|---------------------------|
| Changement code | Small — 1 branch + template resolve | Medium — schema + resolver + compiler |
| Retro-compat | Full — by_position fallback intact | Full — fallback by_position si pas de template |
| Ordonnancement | Navigate avant click (BFS order) | Navigate avant click (step order) |
| Extensibilité | Navigate-specific | General — coords_template applicable à tout |
| Risque runtime | Float/string conversion | Template validation errors |
| Tests impact | 1-3 nouveaux tests | 5-8 nouveaux tests (schema + resolver) |
| GO Dom needed | YES | YES |
| Timeline | ~2h implementation | ~4h implementation + schema design |
---
## Test Rouge Proposal
**Objectif**: Démontrer Gap C avec 1 test unitaire qui montre qu'un edge navigate produit une empty action list.
```python
# tests/unit/test_coords_consumption_gap.py — ajout proposé
def test_gap_c_navigate_edge_produces_empty_actions():
"""Gap C: _edge_to_normalized_actions returns [] for navigate edge.
Prove: navigate is in _ALLOWED_ACTION_TYPES but has no branch
in _edge_to_normalized_actions → falls into else → empty list.
"""
from agent_v0.server_v1.replay_engine import _edge_to_normalized_actions
# Minimal mock edge with navigate action type
edge = MockEdge(
edge_id="e1",
from_node="start",
to_node="login",
action=MockAction(
type="navigate",
target=None,
parameters={"action": "login"},
),
)
result = _edge_to_normalized_actions(edge, {})
# GAP: navigate edge produces zero actions
assert result == [], f"Expected empty list, got {result}"
# This proves the handler in api_stream.py is unreachable
```
**Note**: Ce test est un **red flag** — il doit FAIL quand le gap est résolu (navigate branch ajoutée → result ≠ []). Il sert de guardrail : si quelqu'un câble navigate sans résoudre les gaps A+B, le test rouge continue à signaler le problème.
---
## Decision Required from Dom
**⚠️ PAS DE CÂBLAGE SANS GO DOM**
Ce design note documente les gaps et propose des options. La décision appartient à Dom :
1. **Option préférée** : 1 (compiler injection) ou 2 (YAML templates) ?
2. **Timeline** : implémenter maintenant (POC phase) ou post-POC ?
3. **Scope** : navigate login only, ou general coords template system ?
4. **Test rouge** : ajouter le test gap C maintenant (documentation) ou attendre GO ?
---
## Appendix — Code References
| Fichier | Lignes | Rôle |
|---------|--------|------|
| `replay_engine.py:44` | `_ALLOWED_ACTION_TYPES` includes "navigate" | Allowlist |
| `replay_engine.py:1806-1955` | `_edge_to_normalized_actions` — no navigate branch | Gap C |
| `replay_engine.py:1843-1846` | mouse_click bakes literal x_pct/y_pct | Gap A |
| `core/navigation/__init__.py:43-113` | `_handle_navigate_action` — writes coords to variables | Gap B (write) |
| `core/navigation/action_resolver.py:47-62` | `NavigateCoords` dataclass definition | Data model |
| `api_stream.py:4459-4463` | navigate handler dispatch | Wired but unreachable |
| `tests/unit/test_coords_consumption_gap.py` | 10 tests documenting write-only gap | Evidence |
---
*Qwen — design note, pas wiring. GO Dom required.*

View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
Chat interactif en ligne de commande avec gemma4:26b via Ollama.
Usage interactif :
python tests/test_image_chat_cli.py
# puis taper des questions sur l'image fournie
Usage one-shot :
python tests/test_image_chat_cli.py /chemin/vers/image.png "Que vois-tu ?"
Usage avec modèle différent :
python tests/test_image_chat_cli.py --model qwen3-vl:8b image.png
Le script utilise l'API Ollama directement (via la lib `ollama` du projet,
`ollama==0.6.1` dans requirements.txt).
"""
import argparse
import base64
import sys
from pathlib import Path
try:
import ollama
except ImportError:
print("ERREUR : la librairie 'ollama' n'est pas installée.")
print("Installez-la avec : pip install ollama")
sys.exit(1)
DEFAULT_MODEL = "gemma4:26b"
def encode_image(image_path: str) -> str:
"""Encode une image en base64 pour l'API Ollama."""
path = Path(image_path)
if not path.exists():
print(f"ERREUR : le fichier '{image_path}' n'existe pas.")
sys.exit(1)
if not path.is_file():
print(f"ERREUR : '{image_path}' n'est pas un fichier.")
sys.exit(1)
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def get_client(host: str):
"""Renvoie un client Ollama configuré pour l'hôte donné."""
return ollama.Client(host=host)
def check_ollama_running(host: str = "http://localhost:11434") -> bool:
"""Vérifie que le serveur Ollama est accessible."""
try:
client = get_client(host)
client.list()
return True
except Exception as e:
print(f"ERREUR : impossible de joindre Ollama sur {host}")
print(f"Détail : {e}")
print()
print("Assurez-vous qu'Ollama est lancé :")
print(" ollama serve")
return False
def check_model_available(model: str, host: str = "http://localhost:11434") -> bool:
"""Vérifie que le modèle est disponible dans Ollama."""
try:
client = get_client(host)
tags = client.list()
# ollama.list() retourne un ListResponse avec un attribut 'models'
models = getattr(tags, "models", [])
model_names = []
for m in models:
if isinstance(m, dict):
model_names.append(m.get("name", ""))
else:
model_names.append(getattr(m, "name", str(m)))
# Correspondance exacte ou préfixe
matched = [name for name in model_names if model in name]
if matched:
return True
else:
print(f"AVERTISSEMENT : modèle '{model}' non trouvé dans Ollama.")
print(f"Modèles disponibles : {', '.join(model_names) or '(aucun)'}")
print()
print(f"Pour le télécharger :")
print(f" ollama pull {model}")
return False
except Exception as e:
print(f"ERREUR : impossible de lister les modèles : {e}")
return False
def chat_with_image(image_path: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode interactif : charge l'image une fois, puis pose des questions."""
client = get_client(host)
image_b64 = encode_image(image_path)
print(f"🖼️ Image chargée : {image_path}")
print(f"🤖 Modèle : {model}")
print(f"🔗 Ollama : {host}")
print()
print("Mode interactif — tapez vos questions (ou 'exit'/'quit' pour sortir)")
print("Tapez '/image /chemin/nouvelle.png' pour changer d'image")
print("-" * 60)
# Historique de conversation (sans l'image à chaque fois pour économiser la mémoire)
messages = []
while True:
try:
question = input("\nVous > ").strip()
except (EOFError, KeyboardInterrupt):
print("\n👋 Au revoir !")
break
if not question:
continue
if question.lower() in ("exit", "quit", "q"):
print("👋 Au revoir !")
break
# Changement d'image
if question.startswith("/image "):
new_path = question[len("/image "):].strip()
try:
image_b64 = encode_image(new_path)
image_path = new_path
# Réinitialiser l'historique car image différente
messages = []
print(f"🖼️ Nouvelle image : {new_path}")
except SystemExit:
pass
continue
# Construire le message user avec l'image au premier tour
# Ensuite, l'image n'est ré-envoyée que si l'historique est vide
has_image_in_context = any(
isinstance(m.get("images"), list) and len(m["images"]) > 0
for m in messages
)
user_msg = {"role": "user", "content": question}
if not has_image_in_context:
# Première question ou image changée — inclure l'image
user_msg["images"] = [image_b64]
messages.append(user_msg)
print(f"🤖 Réponse ({model})...", end=" ", flush=True)
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
full_response = ""
print() # nouvelle ligne après le "..."
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
full_response += content
print() # retour à la ligne après la réponse
messages.append({"role": "assistant", "content": full_response})
except Exception as e:
print(f"\n❌ Erreur : {e}")
# Retirer le dernier message user en cas d'erreur
messages.pop()
def one_shot(image_path: str, question: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode one-shot : une question, une réponse."""
client = get_client(host)
image_b64 = encode_image(image_path)
messages = [
{"role": "user", "content": question, "images": [image_b64]},
]
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
print(f"🤖 {model}'{question}'\n")
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
print()
except Exception as e:
print(f"❌ Erreur : {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Chat interactif avec une image via Ollama (gemma4:26b par défaut)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Exemples :
# Mode interactif avec une image
python tests/test_image_chat_cli.py screenshot.png
# Mode one-shot (question directe)
python tests/test_image_chat_cli.py screenshot.png "Quels boutons vois-tu ?"
# Avec un autre modèle
python tests/test_image_chat_cli.py --model qwen3-vl:8b screenshot.png
# Ollama sur une machine distante
python tests/test_image_chat_cli.py --host http://dgx:11434 screenshot.png
""",
)
parser.add_argument(
"image",
nargs="?",
help="Chemin vers l'image à analyser",
)
parser.add_argument(
"question",
nargs="?",
default=None,
help="Question one-shot (si absent → mode interactif)",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Modèle Ollama à utiliser (défaut: {DEFAULT_MODEL})",
)
parser.add_argument(
"--host",
default="http://localhost:11434",
help="URL du serveur Ollama (défaut: http://localhost:11434)",
)
args = parser.parse_args()
# Vérifications préalables
if not check_ollama_running(args.host):
sys.exit(1)
if not check_model_available(args.model, args.host):
sys.exit(1)
if not args.image:
print("Utilisation interactive — veuillez fournir le chemin d'une image.")
print()
print("Usage :")
print(f" python {sys.argv[0]} /chemin/vers/image.png")
print(f" python {sys.argv[0]} /chemin/vers/image.png \"Votre question\"")
print()
parser.print_help()
sys.exit(1)
if args.question:
# Mode one-shot
one_shot(args.image, args.question, args.model, args.host)
else:
# Mode interactif
chat_with_image(args.image, args.model, args.host)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,155 @@
"""Tests unitaires de la politique de sauvegarde des captures (agent_v1).
Objectif : réduire le poids disque des captures (90 Go / 13 sessions = trop)
sans casser la précision du grounding. La politique distingue le *type* de
shot :
- ``crop`` → PNG lossless (cible de grounding qwen3-vl, précision pixel) ;
- ``full`` / ``window`` / ``context`` → JPEG ``optimize=True`` (vue humaine /
contexte, compression ~5-10x acceptable) ;
- ``heartbeat`` → JPEG **downscalé** (liveness, pas de grounding → on peut
réduire la résolution).
La fonction ``save_capture`` retourne le chemin RÉELLEMENT écrit (extension
ajustée selon le format), pour que l'appelant streame le bon fichier.
Branche feat/push-log-dgx — réduction du poids de capture (unité testée,
non encore câblée dans capturer.py).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PIL import Image
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def _noisy_image(width: int, height: int) -> Image.Image:
"""Image RGB avec du bruit réel.
Un aplat uni se compresse à quasi-zéro en PNG comme en JPEG : la
comparaison de poids serait truquée. On injecte du bruit pour que la
différence PNG/JPEG soit représentative d'un vrai screenshot.
"""
return Image.frombytes("RGB", (width, height), os.urandom(width * height * 3))
def test_crop_reste_png_et_dimensions_identiques(tmp_path):
"""Un crop est sauvé en PNG lossless, dimensions inchangées."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(80, 80)
base = str(tmp_path / "shot_0001_crop")
out_path = save_capture(img, base, kind="crop")
assert out_path.endswith(".png"), f"crop doit rester PNG, obtenu {out_path}"
assert os.path.exists(out_path)
reread = Image.open(out_path)
assert reread.size == (80, 80)
# PNG lossless : les pixels doivent être identiques au bruit d'origine.
assert list(reread.convert("RGB").getdata()) == list(img.getdata())
def test_full_est_jpeg(tmp_path):
"""Un full est sauvé en JPEG (.jpg)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(640, 480)
base = str(tmp_path / "shot_0001_full")
out_path = save_capture(img, base, kind="full")
assert out_path.endswith(".jpg"), f"full doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_full_jpeg_significativement_plus_leger_que_png(tmp_path):
"""Le JPEG full doit peser nettement moins que le PNG équivalent.
On génère une image bruitée plein écran (2560×1600) et on compare le
poids du JPEG produit par la politique au poids d'un PNG lossless du
même contenu. Le gain doit être substantiel (au moins 2x plus léger).
"""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
jpeg_path = save_capture(img, str(tmp_path / "full_jpeg"), kind="full")
png_ref = tmp_path / "full_ref.png"
img.save(png_ref, "PNG")
jpeg_size = os.path.getsize(jpeg_path)
png_size = os.path.getsize(png_ref)
assert jpeg_size < png_size / 2, (
f"JPEG ({jpeg_size}o) doit peser < moitié du PNG ({png_size}o)"
)
def test_context_et_window_sont_jpeg(tmp_path):
"""context et window suivent la même politique JPEG que full."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(320, 240)
for kind in ("context", "window"):
out_path = save_capture(img, str(tmp_path / f"x_{kind}"), kind=kind)
assert out_path.endswith(".jpg"), f"{kind} doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_heartbeat_est_downscale(tmp_path):
"""Un heartbeat est downscalé (largeur réduite) et reste JPEG."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
out_path = save_capture(img, str(tmp_path / "heartbeat_1234"), kind="heartbeat")
assert out_path.endswith(".jpg"), f"heartbeat doit être JPEG, obtenu {out_path}"
reread = Image.open(out_path)
assert reread.width < 2560, "heartbeat doit être downscalé en largeur"
# Ratio préservé (16:10 → la hauteur doit suivre la largeur réduite).
ratio_src = 2560 / 1600
ratio_out = reread.width / reread.height
assert abs(ratio_src - ratio_out) < 0.02, "le ratio doit être préservé"
def test_heartbeat_plus_leger_que_full_jpeg(tmp_path):
"""Le downscale du heartbeat le rend plus léger que le full JPEG plein res."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
hb = save_capture(img, str(tmp_path / "heartbeat_5678"), kind="heartbeat")
full = save_capture(img, str(tmp_path / "shot_9999_full"), kind="full")
assert os.path.getsize(hb) < os.path.getsize(full), (
"le heartbeat downscalé doit peser moins que le full JPEG plein res"
)
def test_kind_inconnu_leve_erreur(tmp_path):
"""Un kind non reconnu doit échouer explicitement (fail-closed)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(40, 40)
try:
save_capture(img, str(tmp_path / "x"), kind="inexistant")
except ValueError:
return
raise AssertionError("un kind inconnu doit lever ValueError")
def test_rgba_converti_pour_jpeg(tmp_path):
"""Une image RGBA doit être convertie avant l'encodage JPEG (pas d'alpha)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = Image.new("RGBA", (64, 64), (10, 20, 30, 128))
out_path = save_capture(img, str(tmp_path / "shot_rgba_full"), kind="full")
assert out_path.endswith(".jpg")
assert os.path.exists(out_path)

View File

@@ -0,0 +1,202 @@
"""Tests documenting the coords consumption gap: write-only navigate coords.
Test 1 (POSITIVE): _resolve_runtime_vars mechanism works — template strings
like {{navigate_login_coords.x_pct}} resolve correctly when variables dict
contains the stored coords.
Test 2 (NEGATIVE): _edge_to_normalized_actions bakes coords as literal floats,
never producing template strings — so runtime variable resolution is never
triggered for navigate coords, proving the write-only gap.
These tests are evidence, not regression guards. Test 2 documents a known
structural gap; when the gap is fixed, Test 2 should be updated to assert
templates ARE produced.
"""
import os
import re
from types import SimpleNamespace
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
from agent_v0.server_v1.replay_engine import (
_edge_to_normalized_actions,
_resolve_runtime_vars,
_resolve_runtime_vars_in_str,
)
# ── Fake fixtures (minimal, per test_visual_anchor_semantics.py pattern) ──
class _FakeAction:
def __init__(self, type_, target=None, parameters=None):
self.type = type_
self.target = target
self.parameters = parameters or {}
class _FakeEdge:
def __init__(self, action):
self.edge_id = "edge_coords_gap"
self.from_node = "node_src"
self.to_node = "node_dst"
self.action = action
# ── Test 1: resolve mechanism is viable ──────────────────────────────────
class TestResolveRuntimeVarsViable:
"""Prove _resolve_runtime_vars infrastructure works with template strings."""
VARIABLES = {
"navigate_login_coords": {
"x_pct": 0.15,
"y_pct": 0.07,
"method": "ocr_anchor",
}
}
def test_resolve_in_str_dot_path(self):
"""{{navigate_login_coords.x_pct}} → "0.15" (string, not float)."""
result = _resolve_runtime_vars_in_str(
"{{navigate_login_coords.x_pct}}", self.VARIABLES
)
assert result == "0.15"
def test_resolve_in_str_y_pct(self):
"""{{navigate_login_coords.y_pct}} → "0.07"."""
result = _resolve_runtime_vars_in_str(
"{{navigate_login_coords.y_pct}}", self.VARIABLES
)
assert result == "0.07"
def test_resolve_dict_with_templates(self):
"""_resolve_runtime_vars substitutes templates inside dict values."""
action = {
"type": "click",
"x_pct": "{{navigate_login_coords.x_pct}}",
"y_pct": "{{navigate_login_coords.y_pct}}",
}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
assert resolved["x_pct"] == "0.15"
assert resolved["y_pct"] == "0.07"
assert resolved["type"] == "click" # no-template strings unchanged
def test_resolve_nested_dict(self):
"""_resolve_runtime_vars handles nested dicts with templates."""
action = {
"parameters": {
"coords": "{{navigate_login_coords.x_pct}}",
},
}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
assert resolved["parameters"]["coords"] == "0.15"
def test_resolve_missing_var_leaves_template_intact(self):
"""Missing variable: template string stays unchanged."""
result = _resolve_runtime_vars_in_str(
"{{navigate_password_coords.x_pct}}", self.VARIABLES
)
assert "{{navigate_password_coords.x_pct}}" in result
def test_resolve_float_passthrough(self):
"""_resolve_runtime_vars returns non-str values unchanged — floats pass through."""
action = {"x_pct": 0.15, "y_pct": 0.07}
resolved = _resolve_runtime_vars(action, self.VARIABLES)
# Floats are NOT substituted — they're not strings containing {{...}}
assert resolved["x_pct"] == 0.15 # literal float, unchanged
assert resolved["y_pct"] == 0.07
# ── Test 2: compiler gap — literals not templates ────────────────────────
class TestCompilerGapLiteralFloats:
"""Document that _edge_to_normalized_actions produces literal floats,
never template strings — so navigate coords are write-only.
This is the STRUCTURAL GAP: the compiler bakes coords as floats,
_resolve_runtime_vars only operates on strings, so stored navigate
variables are never consumed downstream.
"""
def test_mouse_click_produces_literal_floats(self):
"""mouse_click edge: x_pct/y_pct are literal floats, not templates."""
target = SimpleNamespace(
by_position=(0.15, 0.07),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
)
actions = _edge_to_normalized_actions(edge, params={})
assert len(actions) == 1
action = actions[0]
# GAP: coords are literal floats, not template strings
assert isinstance(action["x_pct"], float)
assert isinstance(action["y_pct"], float)
assert action["x_pct"] == 0.15
assert action["y_pct"] == 0.07
# Proof: no template string is ever produced by the compiler
assert not isinstance(action["x_pct"], str)
assert not isinstance(action["y_pct"], str)
def test_literal_floats_not_resolved(self):
"""Literal floats pass through _resolve_runtime_vars unchanged —
proving navigate coords stored in variables are NEVER consumed."""
target = SimpleNamespace(
by_position=(0.15, 0.07),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
)
actions = _edge_to_normalized_actions(edge, params={})
action = actions[0]
# Simulate variables from a prior navigate_login step
different_coords = {
"navigate_login_coords": {"x_pct": 0.20, "y_pct": 0.10}
}
resolved = _resolve_runtime_vars(action, different_coords)
# Coords REMAIN the original literal floats — no substitution
assert resolved["x_pct"] == 0.15 # NOT 0.20 (no substitution)
assert resolved["y_pct"] == 0.07 # NOT 0.10 (no substitution)
def test_text_input_produces_literal_floats(self):
"""text_input edge: same literal float pattern for click target."""
target = SimpleNamespace(
by_position=(0.30, 0.50),
by_role=None,
by_text=None,
context_hints={},
)
edge = _FakeEdge(
_FakeAction("text_input", target=target, parameters={"text": "admin"})
)
actions = _edge_to_normalized_actions(edge, params={})
assert len(actions) == 1
action = actions[0]
assert isinstance(action["x_pct"], float)
assert isinstance(action["y_pct"], float)
assert action["x_pct"] == 0.30
assert action["y_pct"] == 0.50
def test_navigate_action_type_unknown(self):
"""navigate action type is NOT handled by _edge_to_normalized_actions —
falls into the else branch logging "Type d'action inconnu"."""
edge = _FakeEdge(_FakeAction("navigate", parameters={"target": "login"}))
actions = _edge_to_normalized_actions(edge, params={})
# navigate produces empty actions — not compiled at all
assert actions == []

View File

@@ -295,6 +295,175 @@ def convert_learned_to_vwb_steps(
return workflow_meta, steps, warnings return workflow_meta, steps, warnings
# ---------------------------------------------------------------------------
# Pont R1 — import IDEMPOTENT d'un workflow core en DB VWB (create-or-update)
# ---------------------------------------------------------------------------
# Marqueur stable de signature de trajectoire embarqué dans `Workflow.description`.
# Le modèle `Workflow` n'a PAS (encore) de colonne dédiée ; on réutilise donc le
# même mécanisme que la route GET /learned-workflows existante, qui détecte les
# imports via `description.contains(...)`. La clé d'idempotence est la SIGNATURE
# DE TRAJECTOIRE (cf. core.execution.trajectory_signature), pas le workflow_id de
# session (qui change à chaque ré-apprentissage du même parcours).
_TRAJ_SIG_MARKER = "[traj_sig:"
def _trajectory_signature_marker(signature: str) -> str:
"""Marqueur texte stable à embarquer dans la description."""
return f"{_TRAJ_SIG_MARKER}{signature}]"
def _find_existing_learned_workflow(db_session, signature: str):
"""Cherche un Workflow `source='learned_import'` de MÊME signature de trajectoire.
Ne considère QUE les imports appris : les workflows `source='manual'`
(démo Urgence_aiva, etc.) sont volontairement exclus du filtre et donc
jamais candidats à la mise à jour.
"""
from db.models import Workflow # import paresseux (modèles liés au runtime VWB)
marker = _trajectory_signature_marker(signature)
return (
db_session.query(Workflow)
.filter(
Workflow.source == "learned_import",
Workflow.description.contains(marker),
)
.first()
)
def import_core_workflow_to_db(
core_dict: Dict[str, Any],
*,
machine_id: str,
source_session_id: str,
db_session,
) -> Dict[str, Any]:
"""Importe un workflow core (JSON appris par Léa) en DB VWB, de façon IDEMPOTENTE.
Fusion par **signature de trajectoire** (décision produit Dom 23/06) :
1. calcule `sig = workflow_trajectory_signature(core_dict)` ;
2. cherche un `Workflow` `source='learned_import'` de même signature ;
3. si trouvé → **skip** (pas de doublon, le workflow existant fait foi) ;
sinon → crée `Workflow` + `Step`(s) via `convert_learned_to_vwb_steps`.
Le nouveau workflow est marqué `source='learned_import'`,
`review_status='pending_review'`. Les workflows `source='manual'` ne sont
JAMAIS touchés (cf. `_find_existing_learned_workflow`).
Args:
core_dict: workflow core (dict JSON) tel qu'appris/sauvegardé.
machine_id: poste d'origine (traçabilité, stocké en tag/description).
source_session_id: session ayant produit ce workflow (traçabilité).
db_session: session SQLAlchemy (l'app appelante détient le contexte).
Returns:
dict {created: bool, workflow_id: str, signature: str, warnings: list}.
`created=False` quand un workflow de même trajectoire existait déjà.
Note (non-wiring) : cette unité n'est PAS branchée au worker live ni à la
route HTTP existante ; voir le rapport de câblage R1.
"""
# Imports paresseux : garde le module léger et évite un import core/DB au load.
from core.execution.trajectory_signature import workflow_trajectory_signature
from db.models import Workflow, Step
signature = workflow_trajectory_signature(core_dict)
# --- Idempotence : même trajectoire déjà importée ? → skip (pas de doublon) ---
existing = _find_existing_learned_workflow(db_session, signature)
if existing is not None:
logger.info(
"Workflow appris déjà présent (signature %s…) → import ignoré, "
"réutilisation de %s",
signature[:12],
existing.id,
)
return {
"created": False,
"workflow_id": existing.id,
"signature": signature,
"warnings": [],
}
# --- Création : conversion core → steps VWB, puis écriture DB ---
wf_meta, steps_list, warnings = convert_learned_to_vwb_steps(core_dict)
current_name = (wf_meta.get("name") or "").strip()
if current_name.lower() in {"", "unnamed workflow", "workflow importé"}:
# Réutilise la dérivation de nom de la route HTTP si disponible.
try:
from api_v3.learned_workflows import _derive_default_name
wf_meta["name"] = _derive_default_name(core_dict)
except Exception: # pragma: no cover - fallback minimal
wf_meta["name"] = f"Léa import — {datetime.now():%Y-%m-%d %H:%M}"
wf_id = f"wf_{uuid.uuid4().hex[:12]}"
# La signature est embarquée dans la description (clé d'idempotence) + une
# ligne de traçabilité (workflow core d'origine).
base_desc = (wf_meta.get("description") or "").strip()
description = "\n\n".join(
part
for part in (
base_desc,
f"[Importé depuis workflow appris: {core_dict.get('workflow_id', '')}]",
_trajectory_signature_marker(signature),
)
if part
)
workflow = Workflow(
id=wf_id,
name=wf_meta["name"],
description=description,
source="learned_import",
review_status="pending_review",
)
# Tags : conserver ceux du workflow + traçabilité machine/session.
tags = list(wf_meta.get("tags") or [])
tags.extend([f"machine:{machine_id}", f"session:{source_session_id}"])
workflow.tags = tags
db_session.add(workflow)
for step_data in steps_list:
step = Step(
id=f"step_{uuid.uuid4().hex[:12]}",
workflow_id=wf_id,
action_type=step_data["action_type"],
order=step_data["order"],
position_x=step_data.get("position_x", 0),
position_y=step_data.get("position_y", 0),
label=step_data.get("label", step_data["action_type"]),
)
params = dict(step_data.get("parameters", {}))
# L'image d'ancre (_anchor_image_base64) est laissée dans params : la
# persistance d'ancre (VisualAnchor + fichier) reste pilotée par la route
# HTTP existante. Cette unité se concentre sur l'idempotence Workflow/Step.
step.parameters = params
db_session.add(step)
db_session.commit()
logger.info(
"Workflow appris importé (R1) : %s (signature %s…, %d étapes, machine %s)",
wf_id,
signature[:12],
len(steps_list),
machine_id,
)
return {
"created": True,
"workflow_id": wf_id,
"signature": signature,
"warnings": warnings,
}
def _convert_compound_substep( def _convert_compound_substep(
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any] sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
) -> Tuple[str, Dict[str, Any]]: ) -> Tuple[str, Dict[str, Any]]:

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
Test TDD — pont R1 : `import_core_workflow_to_db` IDEMPOTENT.
Objectif chantier R1 : une session auto-apprise (workflow core JSON) doit pouvoir
être (ré)importée en DB VWB **sans créer de doublon**. La fusion se fait par
**signature de trajectoire** (cf. `core.execution.trajectory_signature`) — décision
produit Dom 23/06 : create-or-update, pas create-only.
Coeur du test (b) : ré-importer le MÊME core_dict 2× → toujours UN seul workflow.
Ce module est volontairement isolé du chemin live :
- il ne démarre PAS l'app Flask complète (`app.py`) ;
- il lie le `db` partagé (`db.models.db`) à une SQLite **en mémoire** via une
app Flask minimale, même pattern que `tests/conftest.py` mais sans dépendances
lourdes (pas de socketio, pas de blueprints).
"""
import sys
from pathlib import Path
import pytest
from flask import Flask
# --- Chemins : racine projet (pour core.*) + backend (pour db.models, services.*) ---
_BACKEND = Path(__file__).resolve().parent.parent.parent # .../visual_workflow_builder/backend
_ROOT = _BACKEND.parent.parent # .../rpa_vision_v3
for p in (str(_ROOT), str(_BACKEND)):
if p not in sys.path:
sys.path.insert(0, p)
from db.models import db, Workflow, Step # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures DB en mémoire (app Flask minimale, db partagé)
# ---------------------------------------------------------------------------
@pytest.fixture
def db_app():
"""App Flask minimale liée à une SQLite en mémoire, schéma créé."""
app = Flask("test_import_core_workflow")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
# ---------------------------------------------------------------------------
# Fixtures de workflows core (format JSON appris par Léa)
# ---------------------------------------------------------------------------
def _core_workflow_bloc_notes() -> dict:
"""Workflow core minimal : ouvrir Bloc-notes et saisir du texte."""
return {
"workflow_id": "wf_sess_bloc_notes_001",
"name": "Léa Bloc-notes",
"entry_nodes": ["n1"],
"nodes": [
{"node_id": "n1", "name": "Bureau"},
{"node_id": "n2", "name": "Bloc-notes ouvert"},
],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {"by_text": "Bloc-notes", "by_role": "ocr"},
"parameters": {"button": "left"},
},
},
{
"edge_id": "e2",
"from_node": "n2",
"to_node": "n2",
"action": {
"type": "text_input",
"target": {"by_text": "zone de saisie"},
"parameters": {"text": "bonjour"},
},
},
],
}
def _core_workflow_calculatrice() -> dict:
"""Workflow core d'une trajectoire DIFFÉRENTE (calculatrice)."""
return {
"workflow_id": "wf_sess_calc_002",
"name": "Léa Calculatrice",
"entry_nodes": ["n1"],
"nodes": [
{"node_id": "n1", "name": "Bureau"},
{"node_id": "n2", "name": "Calculatrice"},
],
"edges": [
{
"edge_id": "e1",
"from_node": "n1",
"to_node": "n2",
"action": {
"type": "mouse_click",
"target": {"by_text": "Calculatrice", "by_role": "ocr"},
"parameters": {"button": "left"},
},
},
],
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_import_creates_workflow_with_steps(db_app):
"""(a) Un core_dict → 1 workflow VWB créé, avec ses steps."""
from services.learned_workflow_bridge import import_core_workflow_to_db
with db_app.app_context():
result = import_core_workflow_to_db(
_core_workflow_bloc_notes(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_bloc_notes_001",
db_session=db.session,
)
assert result["created"] is True
wf_id = result["workflow_id"]
assert wf_id
wf = Workflow.query.get(wf_id)
assert wf is not None
assert wf.source == "learned_import"
assert wf.review_status == "pending_review"
steps = Step.query.filter_by(workflow_id=wf_id).all()
assert len(steps) >= 1, "le workflow importé doit avoir au moins une étape"
def test_reimport_same_workflow_is_idempotent(db_app):
"""(b) COEUR — ré-importer le MÊME core_dict 2× → toujours 1 seul workflow."""
from services.learned_workflow_bridge import import_core_workflow_to_db
with db_app.app_context():
first = import_core_workflow_to_db(
_core_workflow_bloc_notes(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_bloc_notes_001",
db_session=db.session,
)
second = import_core_workflow_to_db(
_core_workflow_bloc_notes(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_bloc_notes_001_rerun",
db_session=db.session,
)
# UN seul workflow en DB malgré deux imports
assert Workflow.query.count() == 1, "ré-import du même parcours = pas de doublon"
# Le second pointe vers le même workflow, marqué non-créé
assert first["workflow_id"] == second["workflow_id"]
assert second["created"] is False
def test_different_trajectories_create_two_workflows(db_app):
"""(c) Deux trajectoires différentes → 2 workflows distincts."""
from services.learned_workflow_bridge import import_core_workflow_to_db
with db_app.app_context():
r1 = import_core_workflow_to_db(
_core_workflow_bloc_notes(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_a",
db_session=db.session,
)
r2 = import_core_workflow_to_db(
_core_workflow_calculatrice(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_b",
db_session=db.session,
)
assert Workflow.query.count() == 2
assert r1["workflow_id"] != r2["workflow_id"]
def test_manual_workflow_is_never_touched(db_app):
"""(d) Un workflow source='manual' préexistant n'est jamais modifié.
Même si, par construction, il partageait la signature d'un parcours importé,
la fonction ne doit cibler QUE les workflows source='learned_import'.
"""
from services.learned_workflow_bridge import import_core_workflow_to_db
with db_app.app_context():
# Workflow manuel préexistant (démo Urgence_aiva) — intouchable
manual = Workflow(
id="wf_manual_demo",
name="Urgence_aiva_demo",
description="Démo manuelle critique",
source="manual",
review_status="approved",
)
db.session.add(manual)
db.session.commit()
manual_name_before = manual.name
manual_review_before = manual.review_status
import_core_workflow_to_db(
_core_workflow_bloc_notes(),
machine_id="DESKTOP-TEST_windows",
source_session_id="sess_x",
db_session=db.session,
)
manual_after = Workflow.query.get("wf_manual_demo")
assert manual_after.name == manual_name_before
assert manual_after.review_status == manual_review_before
assert manual_after.source == "manual"