merge(R2): DialogResolver MVP P0 (worktree a86565d0)

This commit is contained in:
Dom
2026-05-24 17:53:35 +02:00
7 changed files with 995 additions and 0 deletions

View File

@@ -5790,6 +5790,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

View File

@@ -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).
"""

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 LENREGISTREMENT")
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"