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
101 lines
4.3 KiB
Python
101 lines
4.3 KiB
Python
"""Scène d'intention attendue — workpack A attention scope multi-écrans.
|
|
|
|
Cf. docs/coordination/inbox_codex/2026-05-25_0610_claude-to-codex_workpack-A-attention-scope-multi-ecrans.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SceneExpected:
|
|
"""Description du périmètre visuel attendu pour servir l'intention.
|
|
|
|
Construit au build serveur, transporté additif jusqu'au client, consommé
|
|
par une garde `_assert_scene_active()` avant tout geste — surtout les
|
|
raccourcis clavier qui partent sinon dans la fenêtre active globale.
|
|
|
|
Attributs
|
|
scene_id : ID stable de la scène
|
|
app_name : nom de l'application attendue (ex 'Notepad')
|
|
title_patterns : patterns de titre acceptables (substrings)
|
|
title_anti : patterns de titre interdits (anti-intention)
|
|
monitor_index : index du moniteur (1-based mss). None = quelconque
|
|
monitor_geometry : (left, top, width, height) en pixels. Optionnel.
|
|
window_rect_hint : (left, top, right, bottom) zone attendue. Optionnel.
|
|
scene_role : 'editor' | 'dialog' | 'menu' | 'browser_tab' | ...
|
|
required : True si le geste DOIT être bloqué si scène absente
|
|
stability_ms : durée min de stabilité avant le geste
|
|
accepted_transitions: scènes vers lesquelles transition est attendue
|
|
"""
|
|
|
|
scene_id: str = ""
|
|
app_name: str = ""
|
|
title_patterns: Tuple[str, ...] = field(default_factory=tuple)
|
|
title_anti: Tuple[str, ...] = field(default_factory=tuple)
|
|
monitor_index: Optional[int] = None
|
|
monitor_geometry: Optional[Tuple[int, int, int, int]] = None
|
|
window_rect_hint: Optional[Tuple[int, int, int, int]] = None
|
|
scene_role: str = ""
|
|
required: bool = True
|
|
stability_ms: int = 0
|
|
accepted_transitions: Tuple[str, ...] = field(default_factory=tuple)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
d = asdict(self)
|
|
d["title_patterns"] = list(self.title_patterns)
|
|
d["title_anti"] = list(self.title_anti)
|
|
d["accepted_transitions"] = list(self.accepted_transitions)
|
|
if self.monitor_geometry is not None:
|
|
d["monitor_geometry"] = list(self.monitor_geometry)
|
|
if self.window_rect_hint is not None:
|
|
d["window_rect_hint"] = list(self.window_rect_hint)
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "SceneExpected":
|
|
if not data:
|
|
return cls()
|
|
|
|
def _tuple_of_4(v):
|
|
if v is None:
|
|
return None
|
|
try:
|
|
lst = list(v)
|
|
if len(lst) != 4:
|
|
return None
|
|
return tuple(int(x) for x in lst)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
return cls(
|
|
scene_id=str(data.get("scene_id", "") or ""),
|
|
app_name=str(data.get("app_name", "") or ""),
|
|
title_patterns=tuple(str(x) for x in (data.get("title_patterns") or [])),
|
|
title_anti=tuple(str(x) for x in (data.get("title_anti") or [])),
|
|
monitor_index=(int(data["monitor_index"]) if data.get("monitor_index") is not None else None),
|
|
monitor_geometry=_tuple_of_4(data.get("monitor_geometry")),
|
|
window_rect_hint=_tuple_of_4(data.get("window_rect_hint")),
|
|
scene_role=str(data.get("scene_role", "") or ""),
|
|
required=bool(data.get("required", True)),
|
|
stability_ms=int(data.get("stability_ms", 0) or 0),
|
|
accepted_transitions=tuple(str(x) for x in (data.get("accepted_transitions") or [])),
|
|
)
|
|
|
|
def matches_title(self, observed_title: str) -> bool:
|
|
"""Vrai si le titre observé est cohérent avec la scène (patterns + anti)."""
|
|
if not observed_title:
|
|
return False
|
|
norm = observed_title.lower()
|
|
for anti in self.title_anti:
|
|
if anti and anti.lower() in norm:
|
|
return False
|
|
if not self.title_patterns:
|
|
return True
|
|
return any(p and p.lower() in norm for p in self.title_patterns)
|
|
|
|
def is_empty(self) -> bool:
|
|
return not (self.scene_id or self.app_name or self.title_patterns)
|