feat(p1): persist workflows and semantic learning artifacts
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user