feat: process mining BPMN, détection changement écran pHash, OCR docTR
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 15s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped

Process Mining (core/analytics/process_mining_bridge.py) :
- Bridge PM4Py : conversion sessions Shadow → event log → BPMN XML + PNG
- KPIs automatiques : durée, variantes, goulots, distribution par app
- Support sessions JSONL brutes et workflows core JSON
- 42 tests (dont 1 sur données réelles)

Détection changement d'écran (core/analytics/screen_change_detector.py) :
- pHash (imagehash) : ~16ms par screenshot, seuils SAME/MINOR/MAJOR
- 8 tests sur screenshots réels

OCR docTR dans execute_extract_text :
- docTR par défaut pour lecture simple (rapide, CPU)
- Ollama VLM en fallback ou sur demande explicite (mode "vlm"/"ai")
- Dual-mode adaptatif selon extraction_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-18 13:07:56 +02:00
parent f5a672d7b9
commit 309dfd5287
6 changed files with 1684 additions and 36 deletions

View File

@@ -534,8 +534,11 @@ def execute_ai_analyze(params: dict) -> dict:
def execute_extract_text(params: dict) -> dict:
"""
Extrait du texte depuis l'écran via Ollama VLM.
Capture la zone de l'ancre (ou l'écran entier) et demande au VLM d'extraire le texte.
Extrait du texte depuis l'écran.
Stratégie : docTR (OCR local rapide) par défaut pour les modes simples
(full, lines, words, numbers). Fallback sur Ollama VLM pour le mode
"vlm"/"ai" ou si docTR échoue.
"""
import requests
import re
@@ -549,7 +552,9 @@ def execute_extract_text(params: dict) -> dict:
extraction_mode = params.get('extraction_mode', 'full')
text_filters = params.get('text_filters', [])
# --- 1. Capture de l'image ---
screenshot_base64 = anchor.get('screenshot') if anchor else None
pil_image = None # On garde l'image PIL pour docTR
if not screenshot_base64:
try:
@@ -562,56 +567,100 @@ def execute_extract_text(params: dict) -> dict:
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
print(f"📸 [OCR] Capture zone: ({x}, {y}) -> ({x+w}, {y+h})")
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
pil_image = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else:
print(f"📸 [OCR] Capture écran complet")
screenshot = ImageGrab.grab()
pil_image = ImageGrab.grab()
buffer = io.BytesIO()
screenshot.save(buffer, format='PNG')
pil_image.save(buffer, format='PNG')
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
except Exception as cap_err:
return {'success': False, 'error': f"Erreur capture: {cap_err}"}
else:
# Décoder le base64 en image PIL pour docTR
try:
import io
from PIL import Image as PILImage
img_bytes = base64.b64decode(screenshot_base64)
pil_image = PILImage.open(io.BytesIO(img_bytes))
except Exception:
pil_image = None
if not screenshot_base64:
return {'success': False, 'error': "Pas d'image à analyser"}
prompt_map = {
'full': "Extrais TOUT le texte visible dans cette image. Retourne uniquement le texte brut, sans commentaire.",
'numbers': "Extrais uniquement les nombres et chiffres visibles dans cette image. Retourne-les séparés par des espaces.",
'lines': "Extrais tout le texte visible ligne par ligne. Une ligne par ligne de texte visible.",
'words': "Extrais tous les mots visibles dans cette image, séparés par des espaces.",
}
prompt = prompt_map.get(extraction_mode, prompt_map['full'])
# --- 2. Modes docTR (rapide) vs VLM (raisonnement) ---
use_vlm = extraction_mode in ('vlm', 'ai')
extracted_text = None
engine_used = None
if 'qwen' in model.lower() and not prompt.startswith('/no_think'):
prompt = f"/no_think\n{prompt}"
if not use_vlm and pil_image is not None:
# Essayer docTR d'abord pour les modes simples
try:
from visual_workflow_builder.backend.services.ocr_service import (
ocr_extract_text,
)
print(f"📝 [OCR] Extraction texte via docTR (mode: {extraction_mode})...")
raw_text = ocr_extract_text(pil_image)
print(f"📝 [OCR] Extraction texte avec {model} (mode: {extraction_mode})...")
if raw_text and raw_text.strip():
extracted_text = raw_text.strip()
engine_used = "doctr"
print(f"✅ [OCR] docTR OK ({len(extracted_text)} caractères)")
else:
print("⚠️ [OCR] docTR n'a rien extrait, fallback Ollama VLM")
except ImportError:
print("⚠️ [OCR] docTR non disponible, fallback Ollama VLM")
except Exception as doctr_err:
print(f"⚠️ [OCR] Erreur docTR: {doctr_err}, fallback Ollama VLM")
ollama_url = params.get('ollama_url', 'http://localhost:11434')
payload = {
"model": model,
"prompt": prompt,
"images": [screenshot_base64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 4000}
}
# --- 3. Fallback Ollama VLM si docTR n'a rien donné ---
if extracted_text is None:
prompt_map = {
'full': "Extrais TOUT le texte visible dans cette image. Retourne uniquement le texte brut, sans commentaire.",
'numbers': "Extrais uniquement les nombres et chiffres visibles dans cette image. Retourne-les séparés par des espaces.",
'lines': "Extrais tout le texte visible ligne par ligne. Une ligne par ligne de texte visible.",
'words': "Extrais tous les mots visibles dans cette image, séparés par des espaces.",
'vlm': "Extrais TOUT le texte visible dans cette image. Retourne uniquement le texte brut, sans commentaire.",
'ai': "Extrais TOUT le texte visible dans cette image. Retourne uniquement le texte brut, sans commentaire.",
}
prompt = prompt_map.get(extraction_mode, prompt_map['full'])
response = requests.post(
f"{ollama_url}/api/generate",
json=payload,
timeout=timeout_ms / 1000
)
if 'qwen' in model.lower() and not prompt.startswith('/no_think'):
prompt = f"/no_think\n{prompt}"
if response.status_code != 200:
return {'success': False, 'error': f"Erreur Ollama: {response.status_code}"}
print(f"📝 [OCR] Extraction texte avec {model} (mode: {extraction_mode})...")
result = response.json()
extracted_text = result.get('response', '').strip()
ollama_url = params.get('ollama_url', 'http://localhost:11434')
payload = {
"model": model,
"prompt": prompt,
"images": [screenshot_base64],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 4000}
}
if not extracted_text and result.get('thinking'):
extracted_text = result.get('thinking', '').strip()
response = requests.post(
f"{ollama_url}/api/generate",
json=payload,
timeout=timeout_ms / 1000
)
if response.status_code != 200:
return {'success': False, 'error': f"Erreur Ollama: {response.status_code}"}
result = response.json()
extracted_text = result.get('response', '').strip()
if not extracted_text and result.get('thinking'):
extracted_text = result.get('thinking', '').strip()
engine_used = f"ollama/{model}"
# --- 4. Application des filtres ---
if extracted_text is None:
extracted_text = ""
for f in text_filters:
if f == 'digits_only':
@@ -625,7 +674,7 @@ def execute_extract_text(params: dict) -> dict:
elif f == 'lowercase':
extracted_text = extracted_text.lower()
print(f"✅ [OCR] Texte extrait ({len(extracted_text)} caractères)")
print(f"✅ [OCR] Texte extrait ({len(extracted_text)} caractères) via {engine_used}")
if extracted_text:
print(f" Résultat: {extracted_text[:150]}...")
@@ -639,7 +688,7 @@ def execute_extract_text(params: dict) -> dict:
'character_count': len(extracted_text),
'word_count': len(extracted_text.split()) if extracted_text else 0,
'mode': extraction_mode,
'model': model
'engine': engine_used
}
}