From 84d2d4a6671250efe26c14f8bac2ccfc3b7085d1 Mon Sep 17 00:00:00 2001 From: Dom Date: Sun, 24 May 2026 17:52:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(dialog):=20R2=20MVP=20P0=20=E2=80=94=20Dia?= =?UTF-8?q?logResolver=20+=20catalogue=2010=20entr=C3=A9es=20(flag=20OFF?= =?UTF-8?q?=20default)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent_v0/server_v1/core/dialog/ : catalogue compact + DialogResolver stateless (match titre + evidence, trichotomie stricte auto/pause/skip). - 10 entrées P0 : confirm-save-overwrite, notepad-unsaved-changes, windows-file-explorer (fallback replay 4c38dbb8), easily-save/overwrite/ confirm-action/clinical-warning, windows-uac, windows-hello-credui, edge-update. - Validateur déclaratif `system_modals_cannot_be_overridden` : rejette toute surcharge auto/skip sur modaux SYSTÈME (windows-/defender-). - Endpoint POST /api/v1/dialog/resolve derrière flag RPA_DIALOG_RESOLVER_ENABLED (OFF par défaut → 503). Aucun rebranchement côté agent_v1 (executor.py inchangé, P1 plus tard). - 25 tests pytest passants (19 unit + 6 intégration HTTP). Spec : docs/recherche/SPEC_POPUPS_CATALOGUE.md §2bis / §3. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent_v0/server_v1/api_stream.py | 60 ++++ agent_v0/server_v1/core/__init__.py | 5 + agent_v0/server_v1/core/dialog/__init__.py | 36 +++ agent_v0/server_v1/core/dialog/catalog.py | 262 +++++++++++++++++ agent_v0/server_v1/core/dialog/resolver.py | 264 ++++++++++++++++++ .../test_dialog_resolver_endpoint.py | 141 ++++++++++ tests/unit/test_dialog_resolver.py | 227 +++++++++++++++ 7 files changed, 995 insertions(+) create mode 100644 agent_v0/server_v1/core/__init__.py create mode 100644 agent_v0/server_v1/core/dialog/__init__.py create mode 100644 agent_v0/server_v1/core/dialog/catalog.py create mode 100644 agent_v0/server_v1/core/dialog/resolver.py create mode 100644 tests/integration/test_dialog_resolver_endpoint.py create mode 100644 tests/unit/test_dialog_resolver.py diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index 0044f6834..a33329380 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -5720,6 +5720,66 @@ async def agents_fleet(): } +# ========================================================================= +# R2 MVP P0 — DialogResolver (catalogue centralisé des modaux runtime) +# Flag OFF par défaut. Activer en posant RPA_DIALOG_RESOLVER_ENABLED=true. +# Le rebranchement côté agent_v1 (consommation de cet endpoint au runtime) +# vient en P1 — pour l'instant l'endpoint est exposé "prêt à consommer". +# ========================================================================= +from .core.dialog import DialogResolver as _DialogResolver # noqa: E402 + + +def _dialog_resolver_enabled() -> bool: + """Flag d'activation R2 — lu à chaque appel pour faciliter les tests.""" + return os.environ.get("RPA_DIALOG_RESOLVER_ENABLED", "").lower() in ( + "1", "true", "yes", "on", + ) + + +# Instance partagée — le résolveur est stateless (cf. resolver.py). +_dialog_resolver_singleton = _DialogResolver() + + +class DialogResolveRequest(BaseModel): + """Payload de l'endpoint ``/api/v1/dialog/resolve`` (P0).""" + + current_title: str + evidence_texts: List[str] = [] + machine_id: Optional[str] = None + + +@app.post("/api/v1/dialog/resolve") +async def dialog_resolve(payload: DialogResolveRequest): + """Renvoyer la résolution d'un modal pour un titre + evidences donnés. + + Réponse : + { + "matched": bool, + "dialog_id": str, + "policy": "auto" | "pause" | "skip", + "action": {...} | None + } + + Si le flag ``RPA_DIALOG_RESOLVER_ENABLED`` n'est pas positionné, + l'endpoint retourne 503 (désactivé par défaut — aucun risque de + régression sur le pipeline existant). + """ + if not _dialog_resolver_enabled(): + raise HTTPException( + status_code=503, + detail=( + "DialogResolver désactivé (flag RPA_DIALOG_RESOLVER_ENABLED). " + "P0 : endpoint exposé mais OFF par défaut." + ), + ) + + resolution = _dialog_resolver_singleton.resolve( + current_title=payload.current_title, + evidence_texts=payload.evidence_texts, + ) + return resolution.to_dict() + + if __name__ == "__main__": import uvicorn diff --git a/agent_v0/server_v1/core/__init__.py b/agent_v0/server_v1/core/__init__.py new file mode 100644 index 000000000..46275bfa8 --- /dev/null +++ b/agent_v0/server_v1/core/__init__.py @@ -0,0 +1,5 @@ +"""Sous-package `core` du serveur (server_v1). + +Sert de point de montage pour les composants serveur internes +(par ex. `dialog/` — DialogResolver MVP R2). +""" diff --git a/agent_v0/server_v1/core/dialog/__init__.py b/agent_v0/server_v1/core/dialog/__init__.py new file mode 100644 index 000000000..e25832630 --- /dev/null +++ b/agent_v0/server_v1/core/dialog/__init__.py @@ -0,0 +1,36 @@ +"""DialogResolver — R2 MVP P0. + +Centralise la résolution des modaux runtime côté serveur via un catalogue +``KNOWN_DIALOGS`` (10 entrées P0) + un ``DialogResolver`` qui renvoie une +politique stricte ``auto`` / ``pause`` / ``skip``. + +Spec source : ``docs/recherche/SPEC_POPUPS_CATALOGUE.md``. + +Périmètre P0 explicite : +- Catalogue minimal 10 entrées (Easily save/overwrite/confirm/clinical-warning, + Notepad unsaved, Windows save confirm, Windows file-explorer fallback, UAC, + Hello CredUI, browser update). +- Validateur déclaratif ``system_modals_cannot_be_overridden`` : refuse toute + surcharge ``auto`` / ``skip`` sur un modal SYSTÈME (`windows-` / `defender-`). +- Pas de modification d'``executor.py`` (rebranchement côté agent_v1 = P1). +""" + +from .catalog import KNOWN_DIALOGS, DialogPolicy, DialogSpec +from .resolver import ( + DialogResolution, + DialogResolver, + DeclarativeOverride, + SystemModalOverrideError, + system_modals_cannot_be_overridden, +) + +__all__ = [ + "KNOWN_DIALOGS", + "DialogPolicy", + "DialogSpec", + "DialogResolver", + "DialogResolution", + "DeclarativeOverride", + "SystemModalOverrideError", + "system_modals_cannot_be_overridden", +] diff --git a/agent_v0/server_v1/core/dialog/catalog.py b/agent_v0/server_v1/core/dialog/catalog.py new file mode 100644 index 000000000..c0ef6bf01 --- /dev/null +++ b/agent_v0/server_v1/core/dialog/catalog.py @@ -0,0 +1,262 @@ +"""Catalogue des modaux runtime connus — R2 MVP P0. + +Source de vérité unique (côté serveur) pour les 10 entrées P0. +Réutilise les patterns présents dans ``agent_v1/core/executor.py`` +(``_KNOWN_RUNTIME_DIALOGS``, ``_CONTEXTUAL_RUNTIME_DIALOGS``) sans les +dupliquer côté agent. + +Format compact : un ``DialogSpec`` par modal, avec : +- ``id`` — identifiant kebab-case stable (clé de ``KNOWN_DIALOGS``). +- ``title_patterns`` — patterns à matcher dans le titre fenêtre + (case/accent-insensitive, voir ``DialogResolver._normalize``). +- ``evidence_texts`` — patterns secondaires requis dans l'OCR/UIA + des textes visibles (utilisé quand le titre seul est ambigu, ex. + Bloc-notes). +- ``button_texts`` — labels cibles si ``policy=auto``. +- ``policy`` — politique par défaut, trichotomie stricte + (``auto`` / ``pause`` / ``skip``). +- ``declarative_override`` — autorise un workflow VWB à surcharger + ``policy`` via ``expected_modal`` ? Toujours ``False`` pour SYSTÈME. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Literal, Tuple + +# Trichotomie stricte des politiques. Tout autre valeur est interdite. +DialogPolicy = Literal["auto", "pause", "skip"] + + +@dataclass(frozen=True) +class DialogSpec: + """Description compacte d'un modal connu. + + Frozen pour éviter les mutations accidentelles (le catalogue est + une constante globale, partagée entre threads via ``DialogResolver``). + """ + + id: str + title_patterns: Tuple[str, ...] + evidence_texts: Tuple[str, ...] = field(default_factory=tuple) + button_texts: Tuple[str, ...] = field(default_factory=tuple) + policy: DialogPolicy = "pause" + declarative_override: bool = False + description: str = "" + + +# Préfixes d'IDs catalogue qui désignent des modaux SYSTÈME — politique +# ``pause`` STRICTE et non surchargeable par un workflow VWB +# (cf. SPEC_POPUPS_CATALOGUE.md §3 + validateur). +SYSTEM_DIALOG_ID_PREFIXES: Tuple[str, ...] = ("windows-", "defender-") + + +# --------------------------------------------------------------------------- +# 10 entrées P0 — démo Urgence_aiva + démo Bloc-notes (replay 4c38dbb8) +# --------------------------------------------------------------------------- +# +# Sémantique : +# - les `title_patterns` sont matchés en substring après normalisation +# case/accent-insensitive ; quand `evidence_texts` est non vide, AU MOINS +# UN pattern doit aussi se retrouver dans les textes fournis (utile pour +# Bloc-notes / Notepad dont le titre seul est trop générique). +# - `button_texts` n'est utilisé qu'avec `policy="auto"` ; il liste les +# labels acceptables (priorité = ordre dans le tuple). +# +# Important : `windows-file-explorer` est inclus comme *fallback transition* +# (replay 4c38dbb8 — clic "Enregistrer" → fenêtre observée +# "rpa_vision : Explorateur de fichiers" au lieu de Bloc-notes). On le marque +# `pause` pour laisser un humain trancher tant que le contextual matching +# côté agent n'a pas rebranché DialogResolver (P1). +KNOWN_DIALOGS: Dict[str, DialogSpec] = { + "confirm-save-overwrite": DialogSpec( + id="confirm-save-overwrite", + title_patterns=( + "confirmer l'enregistrement", + "confirm save as", + ), + button_texts=("Oui", "Yes", "Remplacer", "Replace"), + policy="auto", + declarative_override=True, + description=( + "Windows/Easily — confirmation d'écrasement de fichier " + "(`Voulez-vous le remplacer ?`)." + ), + ), + "notepad-unsaved-changes": DialogSpec( + id="notepad-unsaved-changes", + title_patterns=("bloc-notes", "notepad"), + evidence_texts=( + "ne pas enregistrer", + "don't save", + "voulez-vous enregistrer", + "do you want to save", + ), + button_texts=("Enregistrer", "Save"), + policy="auto", + declarative_override=True, + description=( + "Bloc-notes / Notepad — `Voulez-vous enregistrer les modifications ?` " + "Titre seul ambigu → exige une evidence visuelle." + ), + ), + "windows-file-explorer": DialogSpec( + id="windows-file-explorer", + title_patterns=( + "explorateur de fichiers", + "file explorer", + ), + # Pas de button_texts : aucune action auto en P0. + policy="pause", + declarative_override=True, + description=( + "Fenêtre Explorateur de fichiers détectée comme premier plan " + "alors qu'on attendait Bloc-notes (cas replay 4c38dbb8). " + "Fallback `pause` pour escalade humaine en attendant le " + "contextual matching côté agent_v1 (P1)." + ), + ), + "easily-save-unconfirmed": DialogSpec( + id="easily-save-unconfirmed", + title_patterns=( + "easily assure", + "easily assure - confirmation", + ), + evidence_texts=( + "voulez-vous enregistrer", + "enregistrer les modifications", + "do you want to save", + "unsaved changes", + ), + button_texts=("Enregistrer", "Save"), + policy="auto", + declarative_override=True, + description=( + "Easily Assure — Confirmation d'enregistrement avant fermeture " + "(placeholder : signature OCR à affiner sur capture réelle)." + ), + ), + "easily-overwrite-file": DialogSpec( + id="easily-overwrite-file", + title_patterns=( + "confirmer l'enregistrement", + "confirm save as", + ), + evidence_texts=( + "existe déjà", + "voulez-vous le remplacer", + "already exists", + "overwrite", + ), + button_texts=("Oui", "Yes"), + policy="auto", + declarative_override=True, + description=( + "Easily Assure — popup d'écrasement de fichier " + "(placeholder : signature OCR à affiner)." + ), + ), + "easily-confirm-action": DialogSpec( + id="easily-confirm-action", + title_patterns=("confirmer", "confirm"), + evidence_texts=( + "êtes-vous sûr", + "are you sure", + "confirmer l'enregistrement", + ), + button_texts=("Oui", "Yes"), + policy="auto", + declarative_override=True, + description=( + "Easily Assure — confirmation générique d'une action métier " + "(placeholder)." + ), + ), + "easily-clinical-warning": DialogSpec( + id="easily-clinical-warning", + title_patterns=( + "avertissement clinique", + "easily assure - avertissement", + "clinical alert", + ), + evidence_texts=( + "attention", + "avertissement clinique", + "allergie", + "contre-indication", + "warning", + ), + # Pas de button_texts : la décision est clinique, humaine, par design. + policy="pause", + declarative_override=False, + description=( + "Easily Assure — avertissement clinique (allergie, contre-indication). " + "Décision médicale OBLIGATOIRE — `pause` non surchargeable." + ), + ), + "windows-uac": DialogSpec( + id="windows-uac", + title_patterns=( + "contrôle de compte d'utilisateur", + "user account control", + ), + evidence_texts=( + "voulez-vous autoriser cette application", + "do you want to allow this app", + ), + policy="pause", + declarative_override=False, + description=( + "Windows UAC — élévation de privilèges. JAMAIS auto-accept en " + "healthtech. `pause` STRICT, non surchargeable par déclaratif workflow." + ), + ), + "windows-hello-credui": DialogSpec( + id="windows-hello-credui", + title_patterns=( + "sécurité windows", + "windows security", + ), + evidence_texts=( + "windows hello", + "saisissez votre code pin", + "enter your pin", + "touchez le capteur", + "fingerprint", + "connectez-vous à votre compte", + "sign in to your account", + ), + policy="pause", + declarative_override=False, + description=( + "Windows Hello / CredUI — identification physique requise par " + "construction (PIN, empreinte, MFA). `pause` STRICT." + ), + ), + "edge-update": DialogSpec( + id="edge-update", + title_patterns=( + "microsoft edge", + "microsoft edge a été mis à jour", + "google chrome", + ), + evidence_texts=( + "a été mis à jour", + "redémarrer", + "relancer", + "was updated", + "relaunch", + ), + policy="skip", + declarative_override=True, + description=( + "Edge / Chrome — bulle de mise à jour non bloquante " + "(ignore par défaut, ne casse pas le workflow)." + ), + ), +} + + +def is_system_dialog(modal_id: str) -> bool: + """Vrai si le modal appartient à la catégorie SYSTÈME (Windows/Defender).""" + return modal_id.startswith(SYSTEM_DIALOG_ID_PREFIXES) diff --git a/agent_v0/server_v1/core/dialog/resolver.py b/agent_v0/server_v1/core/dialog/resolver.py new file mode 100644 index 000000000..63916b653 --- /dev/null +++ b/agent_v0/server_v1/core/dialog/resolver.py @@ -0,0 +1,264 @@ +"""DialogResolver — R2 MVP P0. + +Match titre + evidence → ``DialogResolution`` (policy stricte + action). +Réutilise la normalisation case/accent-insensitive développée pour +``ActionExecutorV1._normalize_loose_text`` (executor.py). + +Pas de dépendance Windows : pur Python, testable hors VM. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, Mapping, Optional, Sequence + +from .catalog import ( + KNOWN_DIALOGS, + DialogPolicy, + DialogSpec, + SYSTEM_DIALOG_ID_PREFIXES, + is_system_dialog, +) + + +_TRANSLATION_TABLE = str.maketrans( + { + "’": "'", + "‘": "'", + "`": "'", + "´": "'", + "–": "-", + "—": "-", + "−": "-", + "\xa0": " ", + } +) + + +def _normalize(value: Optional[str]) -> str: + """Casefold + dé-ambiguïse apostrophes/tirets/non-breaking-space. + + Logique alignée sur ``ActionExecutorV1._normalize_loose_text`` + (agent_v1/core/executor.py) pour rester cohérent côté agent. + """ + if not value: + return "" + normalized = str(value).casefold().translate(_TRANSLATION_TABLE) + return " ".join(normalized.split()) + + +@dataclass(frozen=True) +class DialogResolution: + """Résultat d'une résolution. Sérialisable JSON via ``to_dict``. + + - ``matched`` : True si un modal du catalogue a été identifié. + - ``dialog_id`` : ID catalogue (``""`` si pas de match). + - ``policy`` : politique stricte appliquée (``"auto" | "pause" | "skip"``). + Quand aucun match : ``"pause"`` par défaut (politique conservative + healthtech, cf. SPEC §1.1 règle d'or n°4). + - ``action`` : dict décrivant le geste à effectuer si ``policy=="auto"``, + ``None`` sinon. + - ``reason`` : message FR court pour audit / bulle Léa. + """ + + matched: bool + dialog_id: str + policy: DialogPolicy + action: Optional[Dict[str, Any]] = None + reason: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "matched": self.matched, + "dialog_id": self.dialog_id, + "policy": self.policy, + "action": self.action, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class DeclarativeOverride: + """Surcharge déclarative remontée par un workflow VWB (``expected_modal``). + + Le ``DialogResolver`` ne consomme cette structure que si la spec de base + autorise ``declarative_override=True``. Les modaux SYSTÈME sont rejetés + en amont par :func:`system_modals_cannot_be_overridden`. + """ + + dialog_id: str + policy: DialogPolicy + button_label: Optional[str] = None + confirm: bool = False + + +class SystemModalOverrideError(ValueError): + """Lève en cas de tentative de surcharger un modal SYSTÈME en auto/skip.""" + + +def system_modals_cannot_be_overridden(override: DeclarativeOverride) -> DeclarativeOverride: + """Validateur déclaratif (à brancher côté VWB schema + côté API). + + Toute déclaration ``expected_modal`` qui cible un modal SYSTÈME + (préfixes ``windows-`` / ``defender-``) ET tente une politique + différente de ``"pause"`` est rejetée par construction. + + Cf. SPEC_POPUPS_CATALOGUE.md §3 — règle d'or n°1. + """ + if is_system_dialog(override.dialog_id) and override.policy != "pause": + raise SystemModalOverrideError( + f"expected_modal.policy='{override.policy}' interdit pour " + f"'{override.dialog_id}' (catégorie SYSTÈME — toujours 'pause' " + f"en healthtech)." + ) + return override + + +class DialogResolver: + """Résolveur de modaux runtime — P0. + + Stateless : peut être instancié une fois côté serveur et appelé en + concurrence. La méthode :meth:`resolve` n'effectue aucun I/O. + """ + + def __init__(self, catalog: Optional[Mapping[str, DialogSpec]] = None) -> None: + # Copie défensive — le caller peut injecter un sous-ensemble pour + # les tests sans muter ``KNOWN_DIALOGS``. + self._catalog: Dict[str, DialogSpec] = dict(catalog or KNOWN_DIALOGS) + + @property + def catalog(self) -> Mapping[str, DialogSpec]: + return self._catalog + + # ------------------------------------------------------------------ + # API publique + # ------------------------------------------------------------------ + + def resolve( + self, + current_title: str, + evidence_texts: Optional[Sequence[str]] = None, + declarative_override: Optional[DeclarativeOverride] = None, + ) -> DialogResolution: + """Identifier un modal et calculer sa politique effective. + + - ``current_title`` : titre fenêtre courante (Windows ``GetWindowText`` + / Linux ``xdotool getactivewindow getwindowname``). + - ``evidence_texts`` : tableau de textes secondaires (OCR/UIA) — sert + à lever l'ambiguïté quand le titre seul ne suffit pas (Bloc-notes). + - ``declarative_override`` : surcharge VWB. Doit avoir été validée + en amont par :func:`system_modals_cannot_be_overridden` ; on + le revalide ici par sécurité (défense en profondeur). + + Retourne toujours une ``DialogResolution`` (jamais ``None``). + Sans match, politique conservative ``pause``. + """ + norm_title = _normalize(current_title) + norm_evidences = tuple(_normalize(t) for t in (evidence_texts or ())) + + spec = self._find_matching_spec(norm_title, norm_evidences) + if spec is None: + return DialogResolution( + matched=False, + dialog_id="", + policy="pause", + action=None, + reason=( + "Aucun modal connu n'a matché ce titre/evidence — " + "pause conservative (healthtech)." + ), + ) + + effective_policy = spec.policy + applied_override = False + + if declarative_override and declarative_override.dialog_id == spec.id: + # Garde-fou systémique : on rejette toute surcharge SYSTÈME même + # si appelée directement sur ``resolve`` (défense en profondeur). + system_modals_cannot_be_overridden(declarative_override) + if spec.declarative_override: + effective_policy = declarative_override.policy + applied_override = True + + action = self._build_action(spec, effective_policy, declarative_override if applied_override else None) + reason = self._build_reason(spec, effective_policy, applied_override) + + return DialogResolution( + matched=True, + dialog_id=spec.id, + policy=effective_policy, + action=action, + reason=reason, + ) + + # ------------------------------------------------------------------ + # Internes + # ------------------------------------------------------------------ + + def _find_matching_spec( + self, + norm_title: str, + norm_evidences: Iterable[str], + ) -> Optional[DialogSpec]: + if not norm_title: + return None + evidences = tuple(norm_evidences) + for spec in self._catalog.values(): + if not self._title_matches(spec, norm_title): + continue + if spec.evidence_texts: + if not self._evidence_matches(spec, evidences): + continue + return spec + return None + + @staticmethod + def _title_matches(spec: DialogSpec, norm_title: str) -> bool: + for pattern in spec.title_patterns: + norm_pattern = _normalize(pattern) + if norm_pattern and norm_pattern in norm_title: + return True + return False + + @staticmethod + def _evidence_matches(spec: DialogSpec, norm_evidences: Sequence[str]) -> bool: + for pattern in spec.evidence_texts: + norm_pattern = _normalize(pattern) + if not norm_pattern: + continue + for ev in norm_evidences: + if norm_pattern in ev: + return True + return False + + @staticmethod + def _build_action( + spec: DialogSpec, + policy: DialogPolicy, + override: Optional[DeclarativeOverride], + ) -> Optional[Dict[str, Any]]: + if policy != "auto": + return None + # Bouton cible : surcharge déclarative > premier button_text catalogue. + button_label = None + if override and override.button_label: + button_label = override.button_label + elif spec.button_texts: + button_label = spec.button_texts[0] + + return { + "type": "click_button", + "button_label": button_label, + "fallback_button_labels": list(spec.button_texts), + } + + @staticmethod + def _build_reason( + spec: DialogSpec, + policy: DialogPolicy, + applied_override: bool, + ) -> str: + base = f"Modal '{spec.id}' identifié — policy={policy}" + if applied_override: + base += " (surcharge workflow)" + return base diff --git a/tests/integration/test_dialog_resolver_endpoint.py b/tests/integration/test_dialog_resolver_endpoint.py new file mode 100644 index 000000000..23feadd4b --- /dev/null +++ b/tests/integration/test_dialog_resolver_endpoint.py @@ -0,0 +1,141 @@ +"""Tests intégration HTTP de POST /api/v1/dialog/resolve — R2 MVP P0. + +Vérifie : +- flag OFF par défaut → 503. +- flag ON + match catalogue → 200 + payload conforme. +- flag ON + pas de match → 200 + matched=False + policy=pause. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +pytestmark = pytest.mark.integration + + +_TEST_API_TOKEN = "test_dialog_resolver_endpoint_token" + + +@pytest.fixture +def client(monkeypatch): + """TestClient FastAPI avec token. Le flag RPA_DIALOG_RESOLVER_ENABLED + est géré par chaque test (par défaut absent → 503).""" + monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN) + + from fastapi.testclient import TestClient + from agent_v0.server_v1 import api_stream + + monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN) + return TestClient(api_stream.app, raise_server_exceptions=False) + + +def _auth_headers(): + return {"Authorization": f"Bearer {_TEST_API_TOKEN}"} + + +class TestDialogResolveEndpointFlag: + """Flag OFF par défaut — protection anti-régression.""" + + def test_disabled_by_default_returns_503(self, client, monkeypatch): + # flag explicitement absent → 503 attendu. + monkeypatch.delenv("RPA_DIALOG_RESOLVER_ENABLED", raising=False) + resp = client.post( + "/api/v1/dialog/resolve", + json={"current_title": "Confirmer l'enregistrement", "evidence_texts": []}, + headers=_auth_headers(), + ) + assert resp.status_code == 503 + assert "RPA_DIALOG_RESOLVER_ENABLED" in resp.text + + +class TestDialogResolveEndpointEnabled: + """Flag ON : l'endpoint retourne une résolution conforme.""" + + @pytest.fixture(autouse=True) + def _enable_flag(self, monkeypatch): + monkeypatch.setenv("RPA_DIALOG_RESOLVER_ENABLED", "true") + + def test_match_confirm_save_overwrite(self, client): + resp = client.post( + "/api/v1/dialog/resolve", + json={ + "current_title": "Confirmer l'enregistrement", + "evidence_texts": [], + "machine_id": "pc-alpha", + }, + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["matched"] is True + assert body["dialog_id"] == "confirm-save-overwrite" + assert body["policy"] == "auto" + assert body["action"] is not None + assert body["action"]["button_label"] == "Oui" + + def test_match_notepad_with_evidence(self, client): + resp = client.post( + "/api/v1/dialog/resolve", + json={ + "current_title": "Bloc-notes", + "evidence_texts": ["Ne pas enregistrer"], + "machine_id": "pc-alpha", + }, + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["matched"] is True + assert body["dialog_id"] == "notepad-unsaved-changes" + assert body["policy"] == "auto" + + def test_no_match_returns_pause(self, client): + resp = client.post( + "/api/v1/dialog/resolve", + json={ + "current_title": "Fenêtre inconnue XYZ", + "evidence_texts": [], + "machine_id": "pc-alpha", + }, + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["matched"] is False + assert body["dialog_id"] == "" + assert body["policy"] == "pause" + assert body["action"] is None + + def test_match_windows_uac_returns_pause(self, client): + resp = client.post( + "/api/v1/dialog/resolve", + json={ + "current_title": "Contrôle de compte d'utilisateur", + "evidence_texts": ["Voulez-vous autoriser cette application"], + "machine_id": "pc-alpha", + }, + headers=_auth_headers(), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["matched"] is True + assert body["dialog_id"] == "windows-uac" + assert body["policy"] == "pause" + assert body["action"] is None + + def test_requires_auth(self, client): + resp = client.post( + "/api/v1/dialog/resolve", + json={"current_title": "Confirmer l'enregistrement", "evidence_texts": []}, + # pas de header → 401. + ) + assert resp.status_code == 401 diff --git a/tests/unit/test_dialog_resolver.py b/tests/unit/test_dialog_resolver.py new file mode 100644 index 000000000..4b3ee8699 --- /dev/null +++ b/tests/unit/test_dialog_resolver.py @@ -0,0 +1,227 @@ +"""Tests unitaires DialogResolver — R2 MVP P0. + +Couvre : +- match par titre seul (catalogue de base). +- match titre + evidence (Bloc-notes : titre ambigu). +- pas de match → policy conservative "pause". +- override déclaratif autorisé pour Easily/non-système. +- validateur sécurité : refuse `auto`/`skip` sur préfixes + `windows-` / `defender-` (modaux SYSTÈME). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +from agent_v0.server_v1.core.dialog import ( # noqa: E402 + DeclarativeOverride, + DialogResolver, + KNOWN_DIALOGS, + SystemModalOverrideError, + system_modals_cannot_be_overridden, +) + + +pytestmark = pytest.mark.unit + + +class TestCatalog: + """Le catalogue P0 contient au moins les 10 entrées requises.""" + + REQUIRED_IDS = { + "confirm-save-overwrite", + "notepad-unsaved-changes", + "windows-file-explorer", + "easily-save-unconfirmed", + "easily-overwrite-file", + "easily-confirm-action", + "easily-clinical-warning", + "windows-uac", + "windows-hello-credui", + "edge-update", + } + + def test_catalog_has_p0_entries(self): + assert self.REQUIRED_IDS.issubset(KNOWN_DIALOGS.keys()) + + def test_system_modals_not_overridable(self): + """UAC + Hello + file-explorer system : `declarative_override` False.""" + for modal_id in ("windows-uac", "windows-hello-credui"): + assert KNOWN_DIALOGS[modal_id].declarative_override is False + assert KNOWN_DIALOGS[modal_id].policy == "pause" + + def test_clinical_warning_not_overridable(self): + """Clinical warning : décision médicale, jamais auto.""" + spec = KNOWN_DIALOGS["easily-clinical-warning"] + assert spec.policy == "pause" + assert spec.declarative_override is False + + +class TestResolveByTitleOnly: + """Match sur titre seul, sans evidence.""" + + def setup_method(self): + self.resolver = DialogResolver() + + def test_confirm_save_overwrite_fr(self): + res = self.resolver.resolve("Confirmer l'enregistrement") + assert res.matched + assert res.dialog_id == "confirm-save-overwrite" + assert res.policy == "auto" + assert res.action is not None + assert res.action["button_label"] == "Oui" + + def test_confirm_save_overwrite_en(self): + res = self.resolver.resolve("Confirm Save As") + assert res.matched + assert res.dialog_id == "confirm-save-overwrite" + + def test_windows_uac_pause(self): + res = self.resolver.resolve( + "Contrôle de compte d'utilisateur", + evidence_texts=["Voulez-vous autoriser cette application"], + ) + assert res.matched + assert res.dialog_id == "windows-uac" + assert res.policy == "pause" + assert res.action is None + + def test_windows_file_explorer_fallback_pause(self): + """Cas replay 4c38dbb8 — fallback file-explorer → pause humaine.""" + res = self.resolver.resolve("rpa_vision : Explorateur de fichiers") + assert res.matched + assert res.dialog_id == "windows-file-explorer" + assert res.policy == "pause" + + def test_no_match_returns_pause(self): + res = self.resolver.resolve("Fenêtre métier inconnue 42") + assert res.matched is False + assert res.dialog_id == "" + assert res.policy == "pause" + + +class TestResolveByTitleAndEvidence: + """Match qui exige une evidence en plus du titre (Bloc-notes).""" + + def setup_method(self): + self.resolver = DialogResolver() + + def test_notepad_unsaved_changes_with_evidence(self): + res = self.resolver.resolve( + "Bloc-notes", + evidence_texts=[ + "Voulez-vous enregistrer les modifications ?", + "Ne pas enregistrer", + ], + ) + assert res.matched + assert res.dialog_id == "notepad-unsaved-changes" + assert res.policy == "auto" + assert res.action["button_label"] == "Enregistrer" + + def test_notepad_without_evidence_does_not_match(self): + """Titre `Bloc-notes` seul est trop générique : pas de match.""" + res = self.resolver.resolve("test.txt - Bloc-notes") + # Pas d'evidence → la spec evidence-required ne matche pas → pas matched. + assert res.matched is False + assert res.policy == "pause" + + def test_case_insensitive_and_accent_resilient(self): + """Normalisation : casse + apostrophes typographiques tolérées.""" + res = self.resolver.resolve("CONFIRMER L’ENREGISTREMENT") + assert res.matched + assert res.dialog_id == "confirm-save-overwrite" + + +class TestSecurityValidator: + """Validateur déclaratif : interdit auto/skip sur SYSTÈME.""" + + def test_uac_auto_override_raises(self): + override = DeclarativeOverride(dialog_id="windows-uac", policy="auto") + with pytest.raises(SystemModalOverrideError): + system_modals_cannot_be_overridden(override) + + def test_uac_skip_override_raises(self): + override = DeclarativeOverride(dialog_id="windows-uac", policy="skip") + with pytest.raises(SystemModalOverrideError): + system_modals_cannot_be_overridden(override) + + def test_defender_auto_override_raises(self): + override = DeclarativeOverride( + dialog_id="defender-smartscreen-app", policy="auto", + ) + with pytest.raises(SystemModalOverrideError): + system_modals_cannot_be_overridden(override) + + def test_uac_pause_override_passes(self): + """`pause` reste valide même sur SYSTÈME (no-op).""" + override = DeclarativeOverride(dialog_id="windows-uac", policy="pause") + assert system_modals_cannot_be_overridden(override) is override + + def test_easily_auto_override_passes(self): + """Easily n'est pas SYSTÈME → surcharge auto autorisée par le validateur.""" + override = DeclarativeOverride( + dialog_id="easily-save-unconfirmed", + policy="auto", + button_label="Enregistrer", + ) + assert system_modals_cannot_be_overridden(override) is override + + +class TestDeclarativeOverrideAtResolve: + """Le DialogResolver applique l'override quand la spec l'autorise.""" + + def setup_method(self): + self.resolver = DialogResolver() + + def test_override_applied_on_overridable_spec(self): + """edge-update : default `skip`, surchargeable en `auto`.""" + override = DeclarativeOverride( + dialog_id="edge-update", + policy="auto", + button_label="Plus tard", + ) + res = self.resolver.resolve( + "Microsoft Edge", + evidence_texts=["Microsoft Edge a été mis à jour"], + declarative_override=override, + ) + assert res.matched + assert res.policy == "auto" + assert res.action is not None + assert res.action["button_label"] == "Plus tard" + + def test_override_rejected_at_resolve_for_system_modal(self): + """Defense in depth : resolve() refuse aussi auto sur SYSTÈME.""" + override = DeclarativeOverride(dialog_id="windows-uac", policy="auto") + with pytest.raises(SystemModalOverrideError): + self.resolver.resolve( + "Contrôle de compte d'utilisateur", + evidence_texts=["Voulez-vous autoriser cette application"], + declarative_override=override, + ) + + def test_override_ignored_on_non_overridable_clinical_warning(self): + """Clinical warning : surcharge ignorée silencieusement (declarative_override=False).""" + override = DeclarativeOverride( + dialog_id="easily-clinical-warning", + policy="auto", + button_label="OK", + ) + res = self.resolver.resolve( + "Avertissement clinique", + evidence_texts=["Allergie connue"], + declarative_override=override, + ) + assert res.matched + # La spec déclare declarative_override=False → policy reste pause. + assert res.policy == "pause"