Files
rpa_vision_v3/core/cognition/precondition.py
Dom 7bb8d543ab feat(cognition): dataclasses Trace + SceneExpected + Precondition (Phase 2.1)
Crée les 3 dataclasses du modèle Mandat/Protocoles/Scènes v0.3 dans
core/cognition/, standalone (aucun branchement runtime), avec
sérialisation JSON explicite et tests offline.

Préparation des phases :
- Phase 2.1 plan : objet Trace (mandate_id, intention_id, scene_id,
  affordance_signature, expected_retour, level_of_delegation)
- Workpack A : SceneExpected (monitor_index, app_name, title_patterns,
  title_anti, window_rect_hint, scene_role, accepted_transitions,
  stability_ms) + helper matches_title()
- Workpack B : Precondition (kind, window_title_must_contain/anti,
  critic_question, verify_timeout_ms) + PreconditionRecovery
  (max_attempts, on_recovery_fail, actions)

Toutes les dataclasses sont frozen, immutables, avec to_dict/from_dict
tolérants (champs vides/None -> instance vide). Validation au __post_init__
pour Precondition.kind et PreconditionRecovery.on_recovery_fail.

Aucune dépendance runtime obligatoire : si l'objet n'est pas posé sur
une action, fallback comportement actuel. Aucune modif executor /
api_stream / replay_engine / grounding.

Tests : 22/22 passent (sérialisation JSON, contrats from_dict tolérants,
validation kinds, helpers matches_title/check_title, anti-intention).

Tag rollback : rollback/pre-cognition-dataclasses-2026-05-25_0610
2026-05-25 06:08:18 +02:00

125 lines
4.7 KiB
Python

"""Précondition vérifiable + recovery — workpack B mandat/objectif.
Cf. docs/coordination/inbox_codex/2026-05-25_0610_claude-to-codex_workpack-B-mandat-objectif-preconditions.md
Précondition = l'état attendu vérifiable AVANT de tenter une action.
Recovery = mini-séquence opt-in pour rattraper l'état si non atteint.
"""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional, Tuple
_VALID_KINDS = {"window_title", "scene_visible", "critic_question", "noop"}
_VALID_FAIL_ACTIONS = {"pause", "abort", "continue_with_warning"}
@dataclass(frozen=True)
class Precondition:
"""État attendu à vérifier AVANT l'action.
Attributs
kind : 'window_title' | 'scene_visible' | 'critic_question' | 'noop'
window_title_must_contain : substrings dont au moins une doit être présente
window_title_must_not_contain : substrings interdites (anti-intention)
critic_question : question fermée pour le Critic Ollama
verify_timeout_ms : timeout de vérif
"""
kind: str = "noop"
window_title_must_contain: Tuple[str, ...] = field(default_factory=tuple)
window_title_must_not_contain: Tuple[str, ...] = field(default_factory=tuple)
critic_question: str = ""
verify_timeout_ms: int = 2000
def __post_init__(self):
if self.kind not in _VALID_KINDS:
raise ValueError(f"Precondition.kind invalide: {self.kind!r} (attendu {_VALID_KINDS})")
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["window_title_must_contain"] = list(self.window_title_must_contain)
d["window_title_must_not_contain"] = list(self.window_title_must_not_contain)
return d
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "Precondition":
if not data:
return cls()
return cls(
kind=str(data.get("kind", "noop") or "noop"),
window_title_must_contain=tuple(
str(x) for x in (data.get("window_title_must_contain") or [])
),
window_title_must_not_contain=tuple(
str(x) for x in (data.get("window_title_must_not_contain") or [])
),
critic_question=str(data.get("critic_question", "") or ""),
verify_timeout_ms=int(data.get("verify_timeout_ms", 2000) or 2000),
)
def is_noop(self) -> bool:
return self.kind == "noop"
def check_title(self, observed_title: str) -> bool:
"""Vrai si le titre observé satisfait les contraintes (must/anti)."""
if self.kind != "window_title":
return True
if not observed_title:
return False
norm = observed_title.lower()
for anti in self.window_title_must_not_contain:
if anti and anti.lower() in norm:
return False
if not self.window_title_must_contain:
return True
return any(p and p.lower() in norm for p in self.window_title_must_contain)
@dataclass(frozen=True)
class PreconditionRecovery:
"""Mini-séquence opt-in de rattrapage si la précondition n'est pas atteinte.
Attributs
max_attempts : nombre max d'essais de recovery (par défaut 1)
on_recovery_fail : 'pause' | 'abort' | 'continue_with_warning'
actions : liste d'actions (même schéma que les actions du replay)
"""
max_attempts: int = 1
on_recovery_fail: str = "pause"
actions: Tuple[Dict[str, Any], ...] = field(default_factory=tuple)
def __post_init__(self):
if self.on_recovery_fail not in _VALID_FAIL_ACTIONS:
raise ValueError(
f"PreconditionRecovery.on_recovery_fail invalide: {self.on_recovery_fail!r} "
f"(attendu {_VALID_FAIL_ACTIONS})"
)
if self.max_attempts < 0:
raise ValueError(f"max_attempts doit être >= 0, got {self.max_attempts}")
def to_dict(self) -> Dict[str, Any]:
return {
"max_attempts": self.max_attempts,
"on_recovery_fail": self.on_recovery_fail,
"actions": [dict(a) for a in self.actions],
}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "PreconditionRecovery":
if not data:
return cls()
raw_actions = data.get("actions") or []
actions = tuple(dict(a) for a in raw_actions if isinstance(a, dict))
return cls(
max_attempts=int(data.get("max_attempts", 1) or 0),
on_recovery_fail=str(data.get("on_recovery_fail", "pause") or "pause"),
actions=actions,
)
def is_empty(self) -> bool:
return not self.actions