feat(dialog): R2 MVP P0 — DialogResolver + catalogue 10 entrées (flag OFF default)
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
5
agent_v0/server_v1/core/__init__.py
Normal file
5
agent_v0/server_v1/core/__init__.py
Normal 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).
|
||||
"""
|
||||
36
agent_v0/server_v1/core/dialog/__init__.py
Normal file
36
agent_v0/server_v1/core/dialog/__init__.py
Normal 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",
|
||||
]
|
||||
262
agent_v0/server_v1/core/dialog/catalog.py
Normal file
262
agent_v0/server_v1/core/dialog/catalog.py
Normal 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)
|
||||
264
agent_v0/server_v1/core/dialog/resolver.py
Normal file
264
agent_v0/server_v1/core/dialog/resolver.py
Normal 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
|
||||
141
tests/integration/test_dialog_resolver_endpoint.py
Normal file
141
tests/integration/test_dialog_resolver_endpoint.py
Normal 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
|
||||
227
tests/unit/test_dialog_resolver.py
Normal file
227
tests/unit/test_dialog_resolver.py
Normal 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 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"
|
||||
Reference in New Issue
Block a user