feat(p1): persist workflows and semantic learning artifacts

This commit is contained in:
Dom
2026-06-02 16:20:38 +02:00
parent 7a1a5cb6fd
commit 86b3c8f7e7
21 changed files with 3816 additions and 31 deletions

View File

@@ -687,6 +687,7 @@ def _extract_required_apps_from_events(
- launch_result_target: dict optionnel (vrai clic SearchHost -> app)
"""
app_counts: Dict[str, int] = defaultdict(int)
app_titles: Dict[str, List[str]] = defaultdict(list)
first_app = None
first_window_title = None
@@ -702,6 +703,8 @@ def _extract_required_apps_from_events(
title = to_info.get("title", "")
if app_name:
app_counts[app_name] += 1
if title:
app_titles[app_name].append(title)
if first_app is None and app_name.lower() not in _SETUP_IGNORE_APPS:
first_app = app_name
first_window_title = title
@@ -741,6 +744,10 @@ def _extract_required_apps_from_events(
"primary_launch_cmd": primary_launch_cmd,
"first_window_title": first_window_title or "",
"apps": dict(app_counts),
"has_neutral_window_title": any(
_is_neutral_window_title(title)
for title in app_titles.get(primary_app, [])
),
}
if start_menu_target:
result["start_menu_target"] = start_menu_target
@@ -927,6 +934,9 @@ def _extract_required_apps_from_workflow(workflow) -> Dict[str, Any]:
"primary_launch_cmd": primary_launch_cmd,
"first_window_title": first_title,
"apps": {},
"has_neutral_window_title": any(
_is_neutral_window_title(title) for title in window_titles
),
"source_session_id": source_session_id,
"machine_id": machine_id,
}
@@ -1113,6 +1123,50 @@ def _generate_run_dialog_setup_actions(
},
]
needs_fresh_notepad_document = (
primary_app.lower() == "notepad.exe"
and (
bool(app_info.get("has_neutral_window_title"))
or _is_neutral_window_title(first_title)
)
)
if needs_fresh_notepad_document:
if title_patterns or first_title:
actions.append({
"action_id": f"act_{setup_id_prefix}_verify_before_fresh_document",
"type": "verify_screen",
"expected_node": "setup_initial_before_fresh_document",
"timeout_ms": 5000,
"_setup_phase": True,
"_setup_step": "verify_app_ready_before_fresh_document",
"_setup_strategy": "run_dialog",
"expected_window_title_contains": title_patterns or [first_title],
"intention": (
"vérifier que Bloc-notes est la scène active avant "
"d'ouvrir un document vierge"
),
})
actions.extend([
{
"action_id": f"act_{setup_id_prefix}_ensure_fresh_document",
"type": "key_combo",
"keys": ["ctrl", "n"],
"_setup_phase": True,
"_setup_step": "ensure_fresh_document",
"_setup_strategy": "run_dialog",
"expected_window_before": first_title,
"intention": "ouvrir un document Bloc-notes vierge non nommé",
},
{
"action_id": f"act_{setup_id_prefix}_wait_fresh_document",
"type": "wait",
"duration_ms": 400,
"_setup_phase": True,
"_setup_step": "wait_fresh_document",
"_setup_strategy": "run_dialog",
},
])
if title_patterns or first_title:
actions.append({
"action_id": f"act_{setup_id_prefix}_verify",
@@ -1688,6 +1742,63 @@ def _is_learned_workflow(workflow) -> bool:
return has_prototype
_TARGET_SEMANTIC_KEYS = (
"by_text",
"by_role",
"anchor_id",
"target_text",
"ocr_description",
"description",
"vlm_description",
"anchor_image_base64",
"by_text_source",
"window_title",
"anchor_bbox",
"original_size",
)
def _first_non_empty_text(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text and text.casefold() not in {"none", "null"}:
return text
return ""
def _target_attr(target: Any, key: str, default: Any = None) -> Any:
if isinstance(target, dict):
return target.get(key, default)
return getattr(target, key, default)
def _copy_semantic_target_fields(
target_spec: Dict[str, Any],
*sources: Optional[Dict[str, Any]],
) -> None:
for source in sources:
if not isinstance(source, dict):
continue
for key in _TARGET_SEMANTIC_KEYS:
value = source.get(key)
if value and not target_spec.get(key):
target_spec[key] = value
if not target_spec.get("by_text"):
target_text = _first_non_empty_text(target_spec.get("target_text"))
if target_text:
target_spec["by_text"] = target_text
target_spec.setdefault("by_text_source", "visual_anchor")
if not target_spec.get("vlm_description"):
description = _first_non_empty_text(
target_spec.get("description"),
target_spec.get("ocr_description"),
)
if description:
target_spec["vlm_description"] = description
def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Convertir un WorkflowEdge en liste d'actions normalisées pour l'Agent V1.
@@ -1705,8 +1816,9 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
# Extraire les coordonnées normalisées depuis TargetSpec.by_position
x_pct = 0.0
y_pct = 0.0
if target and target.by_position:
px, py = target.by_position
by_position = _target_attr(target, "by_position")
if target and by_position:
px, py = by_position
if px <= 1.0 and py <= 1.0:
x_pct = px
y_pct = py
@@ -1769,10 +1881,15 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
elif action_type == "extract_table":
normalized["type"] = "extract_table"
normalized["parameters"] = {
"output_var": action_params.get("output_var", "table_rows"),
"output_var": (
action_params.get("variable_name")
or action_params.get("output_var")
or "table_rows"
),
"pattern": action_params.get("pattern"),
"limit": action_params.get("limit"),
"region": action_params.get("region"),
"engine": action_params.get("engine", "easyocr"),
}
return [normalized]
@@ -1833,14 +1950,33 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
# Ajouter le target_spec complet pour la résolution visuelle
target_spec = {}
if target and target.by_role:
target_spec["by_role"] = target.by_role
normalized["target_role"] = target.by_role # Compat debug
if target and target.by_text:
target_spec["by_text"] = target.by_text
normalized["target_text"] = target.by_text # Compat debug
if target and hasattr(target, 'context_hints') and target.context_hints:
target_spec["context_hints"] = target.context_hints
by_role = _target_attr(target, "by_role", "")
by_text = _target_attr(target, "by_text", "")
context_hints = _target_attr(target, "context_hints", {}) or {}
if target and by_role:
target_spec["by_role"] = by_role
normalized["target_role"] = by_role # Compat debug
if target and by_text:
target_spec["by_text"] = by_text
normalized["target_text"] = by_text # Compat debug
if target and context_hints:
target_spec["context_hints"] = context_hints
_copy_semantic_target_fields(
target_spec,
action_params,
action_params.get("target_spec") if isinstance(action_params, dict) else None,
context_hints,
)
semantic_label = _first_non_empty_text(
target_spec.get("by_text"),
target_spec.get("target_text"),
target_spec.get("description"),
target_spec.get("ocr_description"),
target_spec.get("vlm_description"),
)
if semantic_label:
normalized.setdefault("target_text", target_spec.get("target_text") or semantic_label)
normalized.setdefault("target_description", semantic_label)
if target_spec:
normalized["target_spec"] = target_spec
normalized["visual_mode"] = True # Signal à l'agent d'utiliser la résolution visuelle
@@ -2004,6 +2140,7 @@ def _handle_extract_table_action(
output_var : nom de variable runtime (default "table_rows")
pattern : regex à matcher sur chaque token OCR (ex : r"^25\\d{6}$")
limit : nb max d'entrées à retourner
engine : easyocr (défaut) ou tesseract/digits/ipp pour chiffres
region : (x, y, w, h) en pixels pour cropper avant OCR
(None = image entière)
@@ -2014,6 +2151,7 @@ def _handle_extract_table_action(
output_var = (params.get("output_var") or params.get("variable_name") or "table_rows").strip()
pattern = params.get("pattern") or None
limit = params.get("limit")
engine = params.get("engine") or "easyocr"
region = params.get("region") or None
if isinstance(limit, str):
try:
@@ -2058,6 +2196,7 @@ def _handle_extract_table_action(
region=tuple(region) if region else None,
pattern=pattern,
limit=limit,
engine=engine,
)
except Exception as e:
logger.warning(
@@ -2071,8 +2210,8 @@ def _handle_extract_table_action(
replay_state.setdefault("variables", {})[output_var] = rows
logger.info(
"extract_table → variable '%s' (%d entrées, pattern=%r, limit=%s) replay %s",
output_var, len(rows), pattern, limit, replay_state.get("replay_id", "?"),
"extract_table → variable '%s' (%d entrées, pattern=%r, limit=%s, engine=%s) replay %s",
output_var, len(rows), pattern, limit, engine, replay_state.get("replay_id", "?"),
)
return bool(rows)
@@ -2410,6 +2549,29 @@ def _expand_compound_steps(
action["x_pct"] = step.get("x_pct", 0.0)
action["y_pct"] = step.get("y_pct", 0.0)
action["button"] = step.get("button", "left")
target_spec: Dict[str, Any] = {}
_copy_semantic_target_fields(
target_spec,
step,
step.get("target_spec") if isinstance(step, dict) else None,
step.get("visual_anchor") if isinstance(step, dict) else None,
)
semantic_label = _first_non_empty_text(
target_spec.get("by_text"),
target_spec.get("target_text"),
target_spec.get("description"),
target_spec.get("ocr_description"),
target_spec.get("vlm_description"),
)
if semantic_label:
action.setdefault(
"target_text",
target_spec.get("target_text") or semantic_label,
)
action.setdefault("target_description", semantic_label)
if target_spec:
action["target_spec"] = target_spec
action["visual_mode"] = True
else:
logger.debug(f"Step compound inconnu : {step_type}")
@@ -2659,6 +2821,8 @@ def _create_replay_state(
a_copy = {
"action_id": a.get("action_id"),
"type": a.get("type"),
"keys": a.get("keys"),
"button": a.get("button"),
"x_pct": a.get("x_pct"),
"y_pct": a.get("y_pct"),
# Contrôle strict des étapes (Dom, matin 10 avril 2026)
@@ -2667,6 +2831,9 @@ def _create_replay_state(
"expected_window_title": a.get("expected_window_title", ""),
# Contexte métier utile pour logs et apprentissage
"intention": a.get("intention", ""),
"target_text": a.get("target_text", ""),
"target_description": a.get("target_description", ""),
"description": a.get("description", ""),
}
ts = a.get("target_spec")
if isinstance(ts, dict):

View File

@@ -43,6 +43,22 @@ logger = logging.getLogger(__name__)
_MEMORY_SINGLETON: Optional[Any] = None
_MEMORY_DISABLED = False
_GENERIC_BUTTON_TEXTS = {
"annuler",
"cancel",
"enregistrer",
"non",
"no",
"ok",
"oui",
"ouvrir",
"open",
"remplacer",
"replace",
"save",
"yes",
}
def get_memory_store():
"""Retourne le `TargetMemoryStore` partagé, ou None si indisponible.
@@ -91,6 +107,44 @@ def _norm_text(s: str) -> str:
return " ".join(s.split())
def _memory_lookup_skip_reason(target_spec: Dict[str, Any]) -> str:
"""Retourne la raison pour laquelle la mémoire ne doit pas court-circuiter.
Les clics qui changent de fenêtre doivent être résolus visuellement à
l'instant T : une coordonnée apprise peut être une bonne piste, mais pas
une décision finale. Pour les boutons très génériques, on exige au moins
un contexte de fenêtre/interaction dans la clé mémoire afin d'éviter les
collisions entre « Enregistrer », « OK », « Oui », etc.
"""
if not isinstance(target_spec, dict):
return ""
hints = target_spec.get("context_hints") or {}
if bool(hints.get("requires_window_transition")):
return "window_transition_requires_visual_confirmation"
button_text = _norm_text(str(target_spec.get("by_text") or ""))
if button_text not in _GENERIC_BUTTON_TEXTS:
return ""
before = (
hints.get("expected_window_before")
or hints.get("button_expected_before_window")
or hints.get("window_title")
or target_spec.get("window_title")
)
after = (
hints.get("expected_window_after")
or hints.get("button_expected_after_window")
or hints.get("expected_after_window")
)
interaction = hints.get("interaction") or hints.get("foreground_dialog_id")
role = target_spec.get("by_role")
if not (before and role and (after or interaction)):
return "generic_button_missing_context"
return ""
def compute_screen_sig(window_title: str) -> str:
"""Calcule la signature d'écran V4 à partir du titre de fenêtre.
@@ -203,6 +257,11 @@ def memory_lookup(
(resolved, method, x_pct, y_pct, score, ...) si une entrée fiable
est trouvée. None sinon.
"""
skip_reason = _memory_lookup_skip_reason(target_spec)
if skip_reason:
logger.info("memory_lookup SKIP : %s", skip_reason)
return None
store = get_memory_store()
if store is None:
return None

View File

@@ -988,7 +988,9 @@ def _resolve_by_grounding(
{"role": "user", "content": prompt, "images": [shot_b64]},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 100},
# D5-v3a (2026-05-25) num_ctx=4096 explicite : éviter fuite 8192
# via Modelfile qwen2.5vl:7b-rpa (PARAMETER num_ctx 8192).
"options": {"temperature": 0.1, "num_predict": 100, "num_ctx": 4096},
}, timeout=60)
content = resp.json().get("message", {}).get("content", "")
except Exception as e:
@@ -1016,7 +1018,9 @@ def _resolve_by_grounding(
{"role": "user", "content": prompt_mi, "images": [shot_b64, anchor_b64]},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 50},
# D5-v3a (2026-05-25) num_ctx=4096 explicite : éviter fuite
# 8192 via Modelfile qwen2.5vl:7b-rpa.
"options": {"temperature": 0.1, "num_predict": 50, "num_ctx": 4096},
}, timeout=60)
content2 = resp2.json().get("message", {}).get("content", "")
elapsed = time.time() - t0
@@ -2482,10 +2486,15 @@ def _get_validation_ocr_reader():
if _VALIDATION_OCR_READER is None and not _VALIDATION_OCR_FAILED:
try:
import easyocr # type: ignore
from core.llm.ocr_extractor import easyocr_gpu_enabled
gpu = easyocr_gpu_enabled(default=False)
_VALIDATION_OCR_READER = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
['fr', 'en'], gpu=gpu, verbose=False
)
logger.info(
"[REPLAY] EasyOCR validator chargé (fr+en, %s)",
"GPU" if gpu else "CPU",
)
logger.info("[REPLAY] EasyOCR validator chargé (fr+en, GPU)")
except Exception as e:
logger.warning("[REPLAY] EasyOCR validator indisponible (%s) — pré-check désactivé", e)
_VALIDATION_OCR_FAILED = True
@@ -2507,8 +2516,15 @@ def _normalize_for_match(s: str) -> str:
def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.60) -> bool:
"""Match tolérant aux imperfections OCR.
1. Substring exacte → match.
2. Sinon : split en tokens ≥3 caractères, retourne True si au moins
1. Substring exacte (expected ⊂ observed) → match.
2. C-P1 (2026-05-25) : tolérance préfixe — observed est un préfixe
d'expected avec longueur ≥ 4 chars ET ≥ 50% de la longueur expected.
Couvre le cas OCR partiel "Enregi" / "Enregistrer" (6 chars sur 11
= 54%, préfixe strict) où l'OCR coupe une ligne longue. Garde-fous :
- len ≥ 4 évite "Sa" / "Save" (faux positif probable)
- 50% évite "Bo" / "Bouton" et "Enregi" / "Enregistrer sous" (qui
serait 37%, rejet correct).
3. Sinon : split en tokens ≥3 caractères, retourne True si au moins
`min_token_ratio` des tokens attendus apparaissent dans observed.
Ex : "Coller ou saisir le dossier patient" → tokens
['coller', 'saisir', 'dossier', 'patient'] ; si OCR voit "u saisir
@@ -2523,6 +2539,13 @@ def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.6
return True
if nexp in nobs:
return True
# C-P1 : tolérance préfixe sur OCR partiel
if (
len(nobs) >= 4
and len(nobs) * 2 >= len(nexp)
and nexp.startswith(nobs)
):
return True
tokens = [t for t in nexp.split() if len(t) >= 3]
if not tokens:
return False
@@ -3010,7 +3033,9 @@ def _locate_popup_button(
"model": "qwen2.5vl:7b",
"messages": [{"role": "user", "content": prompt, "images": [screenshot_b64]}],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 50},
# D5-v3a (2026-05-25) num_ctx=4096 explicite : éviter fuite 8192
# via Modelfile qwen2.5vl:7b/-rpa (PARAMETER num_ctx 8192).
"options": {"temperature": 0.1, "num_predict": 50, "num_ctx": 4096},
},
timeout=15,
)

View File

@@ -126,6 +126,25 @@ def build_workflow_replay(
"x_relative": "",
},
}
_merge_semantic_target_fields(
step_action["target_spec"],
target,
params,
step,
)
target_label = _first_non_empty_text(
step_action["target_spec"].get("by_text"),
step_action["target_spec"].get("target_text"),
step_action["target_spec"].get("description"),
step_action["target_spec"].get("ocr_description"),
step_action["target_spec"].get("vlm_description"),
)
if target_label:
step_action.setdefault(
"target_text",
step_action["target_spec"].get("target_text") or target_label,
)
step_action.setdefault("target_description", target_label)
# Ajouter le crop anchor si disponible
_attach_anchor(step_action, step, session_dir)
@@ -171,6 +190,58 @@ def _map_action_type(step_type: str) -> str:
return mapping.get(step_type, step_type)
_TARGET_SEMANTIC_KEYS = (
"by_text",
"by_role",
"anchor_id",
"target_text",
"ocr_description",
"description",
"vlm_description",
"by_text_source",
"anchor_bbox",
"original_size",
)
def _first_non_empty_text(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text and text.casefold() not in {"none", "null"}:
return text
return ""
def _merge_semantic_target_fields(
target_spec: Dict[str, Any],
*sources: Dict[str, Any],
) -> None:
for source in sources:
if not isinstance(source, dict):
continue
visual_anchor = source.get("visual_anchor") or {}
if isinstance(visual_anchor, dict):
_merge_semantic_target_fields(target_spec, visual_anchor)
for key in _TARGET_SEMANTIC_KEYS:
value = source.get(key)
if value and not target_spec.get(key):
target_spec[key] = value
if not target_spec.get("by_text"):
target_text = _first_non_empty_text(target_spec.get("target_text"))
if target_text:
target_spec["by_text"] = target_text
target_spec.setdefault("by_text_source", "visual_anchor")
if not target_spec.get("vlm_description"):
description = _first_non_empty_text(
target_spec.get("description"),
target_spec.get("ocr_description"),
)
if description:
target_spec["vlm_description"] = description
def _attach_anchor(action: dict, step: dict, session_dir: str) -> None:
"""Attacher le crop anchor au target_spec si disponible."""
import base64