feat(agent): add learn action flow and grounding guards

This commit is contained in:
Dom
2026-06-02 16:24:10 +02:00
parent 86b3c8f7e7
commit d38f0b0f2f
39 changed files with 5901 additions and 212 deletions

View File

@@ -43,6 +43,9 @@ class EventCaptorV1:
# État des touches modificatrices
self.modifiers = set()
self._pending_standalone_win = False
self._suppress_release_only_win_combo = False
self._raw_key_buffer: List[Dict[str, Any]] = []
# Tracking du focus fenêtre
self.last_window = None
@@ -91,6 +94,7 @@ class EventCaptorV1:
# Flush du buffer texte restant avant arrêt
self._flush_text_buffer()
# Annuler le timer s'il est en cours
emit_escape = False
with self._text_lock:
if self._text_flush_timer is not None:
self._text_flush_timer.cancel()
@@ -159,7 +163,80 @@ class EventCaptorV1:
# Clavier
# ----------------------------------------------------------------
@staticmethod
def _get_key_name(key) -> Optional[str]:
"""Convertit un objet pynput Key/KeyCode en nom lisible."""
if isinstance(key, KeyCode):
return key.char if key.char else None
if isinstance(key, Key):
return key.name
return str(key)
@staticmethod
def _encode_key(key) -> Dict[str, Any]:
if isinstance(key, KeyCode):
return {"kind": "vk", "vk": key.vk, "char": key.char}
if isinstance(key, Key):
return {"kind": "key", "name": key.name}
return {"kind": "unknown", "str": str(key)}
@staticmethod
def _raw_key_name(raw_key: Dict[str, Any]) -> Optional[str]:
if raw_key.get("kind") == "vk":
char = raw_key.get("char")
if char and len(str(char)) == 1:
return str(char).lower()
if raw_key.get("kind") == "key":
name = raw_key.get("name")
return str(name).lower() if name else None
return None
def _emit_release_only_windows_combo(self) -> bool:
"""Infère Win+<touche> quand seuls les releases sont capturés."""
with self._text_lock:
raw_keys = list(getattr(self, "_raw_key_buffer", []))
if len(raw_keys) < 2:
return False
cmd_names = {"cmd", "cmd_l", "cmd_r"}
last = raw_keys[-1]
if last.get("action") != "release" or self._raw_key_name(last) not in cmd_names:
return False
combo_key = None
modifier_names = {
"ctrl", "ctrl_l", "ctrl_r",
"alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"cmd", "cmd_l", "cmd_r",
}
for raw in reversed(raw_keys[:-1]):
if raw.get("action") != "release":
continue
name = self._raw_key_name(raw)
if name and name not in modifier_names:
combo_key = name
break
if not combo_key:
return False
self._raw_key_buffer.clear()
event = {
"type": "key_combo",
"keys": ["win", combo_key],
"raw_keys": raw_keys,
"timestamp": time.time(),
}
self.on_event(event)
return True
def _on_press(self, key):
with self._text_lock:
if not hasattr(self, "_raw_key_buffer"):
self._raw_key_buffer = []
self._raw_key_buffer.append({
"action": "press",
**self._encode_key(key),
})
# Gestion des touches modificatrices
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.add("ctrl")
@@ -167,15 +244,26 @@ class EventCaptorV1:
self.modifiers.add("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.add("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.add("win")
self._pending_standalone_win = True
# --- Combos avec modificateur (sauf Shift seul) ---
# Shift seul n'est pas un « vrai » modificateur pour les combos :
# Shift+a = 'A' = saisie texte, pas un raccourci.
# On considère un combo seulement si Ctrl ou Alt est enfoncé.
has_real_modifier = self.modifiers & {"ctrl", "alt"}
# On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
if has_real_modifier:
key_name = self._get_key_name(key)
if key_name and key_name not in ("ctrl", "alt", "shift"):
if key_name and key_name not in (
"ctrl", "ctrl_l", "ctrl_r",
"alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"cmd", "cmd_l", "cmd_r",
):
self._pending_standalone_win = False
if "win" in self.modifiers:
self._suppress_release_only_win_combo = True
# Un combo interrompt la saisie texte en cours
self._flush_text_buffer()
event = {
@@ -205,14 +293,18 @@ class EventCaptorV1:
self._reset_flush_timer()
return
if key == Key.escape:
escape_keys = [Key.esc]
key_escape = getattr(Key, "escape", None)
if key_escape is not None:
escape_keys.append(key_escape)
if key in escape_keys:
# Annuler la saisie en cours
self._text_buffer.clear()
self._text_start_pos = None
self._cancel_flush_timer()
return
emit_escape = True
if key in (Key.enter, Key.tab):
elif key in (Key.enter, Key.tab):
# Flush immédiat — on relâche le lock avant d'appeler
# _flush_text_buffer (qui prend aussi le lock)
pass # on sort du with et on flush après
@@ -238,6 +330,15 @@ class EventCaptorV1:
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
return
if emit_escape:
event = {
"type": "key_combo",
"keys": ["escape"],
"timestamp": time.time(),
}
self.on_event(event)
return
# Si on arrive ici, c'est Enter ou Tab → flush immédiat
self._flush_text_buffer()
@@ -290,12 +391,46 @@ class EventCaptorV1:
self.on_event(event)
def _on_release(self, key):
with self._text_lock:
self._raw_key_buffer.append({
"action": "release",
**self._encode_key(key),
})
if key in (Key.cmd, Key.cmd_l, Key.cmd_r) and self._suppress_release_only_win_combo:
with self._text_lock:
self._raw_key_buffer.clear()
self._pending_standalone_win = False
self._suppress_release_only_win_combo = False
self.modifiers.discard("win")
return
if key in (Key.cmd, Key.cmd_l, Key.cmd_r) and self._emit_release_only_windows_combo():
self._pending_standalone_win = False
self._suppress_release_only_win_combo = False
self.modifiers.discard("win")
return
if key in (Key.cmd, Key.cmd_l, Key.cmd_r) and self._pending_standalone_win:
event = {
"type": "key_combo",
"keys": ["win"],
"timestamp": time.time(),
}
self.on_event(event)
self._pending_standalone_win = False
self._suppress_release_only_win_combo = False
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.discard("ctrl")
elif key in (Key.alt, Key.alt_l, Key.alt_r):
self.modifiers.discard("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.discard("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.discard("win")
self._pending_standalone_win = False
self._suppress_release_only_win_combo = False
def _watch_window_focus(self):
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""