diff --git a/config/admin_rules.default.yml b/config/admin_rules.default.yml new file mode 100644 index 0000000..ce5bca0 --- /dev/null +++ b/config/admin_rules.default.yml @@ -0,0 +1,163 @@ +# Template versionne des regles administrables. +# Ce fichier decrit un contrat cible pour le futur moteur de regles d'administration. +# Il n'est pas encore branche automatiquement dans le pipeline. +version: 1 +defaults: + review_required_for_activation: true + environments: + - test + - prod + sections: + - narrative + - structured + - table +rules: + - id: rule_chcb_exact_mask + label: Masquer le sigle CHCB + description: Sigle local a masquer dans tous les contextes documentaires. + type: exact_term + action: mask + placeholder: "[MASK]" + status: active + match: + exact_value: CHCB + normalization: + case_insensitive: true + whole_word: true + multiline: false + scope: + document_families: + - all + environments: + - test + - prod + sections: + - narrative + - structured + - table + governance: + owner: qualite + justification: Sigle local considere comme identifiant d'etablissement a masquer. + created_at: "2026-04-21" + review_required_for_activation: true + approved_by: responsable_qualite + tests: + required_case_ids: + - 005_force_mask_default_term + - 001_crh_hospitalisation_complete + + - id: rule_identifier_1234567 + label: Identifier normalise 1234567 + description: Exemple de regle couvrant les variantes N°, No et Numero. + type: normalized_identifier + action: mask + placeholder: "[NDA]" + status: candidate + match: + canonical_value: "1234567" + normalization: + case_insensitive: true + whole_word: true + multiline: true + allow_bare_value: true + accepted_prefixes: + - "N°" + - "No" + - "Numero" + prefix_value_separators: + - "" + - " " + - ":" + - " : " + scope: + document_families: + - compte_rendu + - imagerie + environments: + - test + sections: + - narrative + - structured + - table + governance: + owner: qualite + justification: Cas type demande pour les numeros administratifs variables. + created_at: "2026-04-21" + review_required_for_activation: true + approved_by: null + tests: + required_case_ids: + - 003_multiline_venue_number + - 001_crh_hospitalisation_complete + + - id: rule_ipp_context_abc12345 + label: IPP contextuel ABC12345 + description: Exemple de valeur a masquer seulement en contexte de label IPP. + type: contextual_identifier + action: mask + placeholder: "[IPP]" + status: draft + match: + canonical_value: ABC12345 + context_prefixes: + - IPP + - I.P.P. + - "N° Ipp" + context_separators: + - ":" + - " : " + - "\n" + normalization: + case_insensitive: true + whole_word: true + multiline: true + scope: + document_families: + - all + environments: + - test + sections: + - structured + - table + governance: + owner: qualite + justification: Prototype de regle contextuelle pour identifiants structures. + created_at: "2026-04-21" + review_required_for_activation: true + approved_by: null + tests: + required_case_ids: + - 004_structured_admin_complete + + - id: rule_preserve_classification_internationale + label: Preserver classification internationale + description: Protection explicite d'une formulation metier. + type: preserve_phrase + action: preserve + status: active + match: + exact_value: classification internationale + normalization: + case_insensitive: true + whole_word: false + multiline: false + scope: + document_families: + - all + environments: + - test + - prod + sections: + - narrative + - structured + governance: + owner: metier + justification: La formulation doit rester visible pour l'usage controle. + created_at: "2026-04-21" + review_required_for_activation: true + approved_by: responsable_qualite + tests: + required_case_ids: + - 006_whitelist_phrases_preserved + - 001_crh_hospitalisation_complete + - 002_imagerie_complete diff --git a/docs/fiche-validation-humaine-modele.md b/docs/fiche-validation-humaine-modele.md new file mode 100644 index 0000000..05d10bc --- /dev/null +++ b/docs/fiche-validation-humaine-modele.md @@ -0,0 +1,71 @@ +# Fiche de validation humaine - modele + +## 1. Identification + +- Version / commit : +- Date : +- Relecteur metier : +- Operateur : +- Responsable qualite : +- Portee : + +## 2. Perimetre relu + +- Familles documentaires : +- Cas synthetiques relus : +- Documents reels relus : +- Regles ajoutees / modifiees : + +## 3. Resultats automatiques + +- Tests unitaires : OK / NOK +- Suite synthetique rapide : OK / NOK +- Corpus synthetique complet : OK / NOK +- Corpus reel annote : OK / NOK / NA +- Scanner de fuite : OK / NOK + +## 4. Checklist dossier par dossier + +### Cas / document : + +- Source : +- Sortie relue : +- Diff relu : + +#### Fuites + +- Nom / prenom patient : OK / NOK +- Date de naissance : OK / NOK +- Adresse / code postal / ville : OK / NOK +- Telephone / email : OK / NOK +- Identifiants administratifs : OK / NOK +- Tableaux / en-tetes / pieds de page : OK / NOK + +#### Preservation + +- Services / actes / structures utiles conserves : OK / NOK +- Formulations metier preservees : OK / NOK +- Document exploitable pour le controle : OK / NOK + +#### Observations + +- Commentaires : +- Type d'anomalie : BLOQUANT / MAJEUR / MINEUR / AUCUNE + +## 5. Decision globale + +- Decision : + - ACCEPTE + - ACCEPTE_AVEC_RESERVE + - REFUSE + - A_CORRIGER_PUIS_REVOIR + +- Motif : +- Actions demandees : +- Delai : + +## 6. Trace de validation + +- Nom validateur final : +- Date : +- Signature / attribution : diff --git a/docs/protocole-validation-humaine.md b/docs/protocole-validation-humaine.md new file mode 100644 index 0000000..a3e6871 --- /dev/null +++ b/docs/protocole-validation-humaine.md @@ -0,0 +1,263 @@ +# Protocole de validation humaine + +## 1. Objet + +Ce protocole sert a valider la fonction d'anonymisation avant diffusion ou activation +d'une nouvelle version du moteur, d'une nouvelle regle ou d'une nouvelle famille de +documents. + +Il ne remplace pas les tests automatiques. Il les complete sur le point qui compte +le plus pour le projet : **le document anonymise est-il conforme et exploitable ?** + +## 2. Quand la validation humaine est obligatoire + +La validation humaine est obligatoire si l'un des points suivants est vrai : + +- modification du coeur d'anonymisation ; +- ajout ou modification d'une regle de masquage globale ; +- ajout ou modification d'une regle de preservation ; +- changement sur les noms patients, dates de naissance ou identifiants structurants ; +- nouvelle famille documentaire ; +- ecart constate sur le corpus synthetique complet ou le corpus reel annote ; +- demande explicite du responsable qualite ou du metier. + +## 3. Entrees minimales requises + +Avant la revue humaine, il faut disposer de : + +- l'identifiant de version ou de commit ; +- la liste des changements ; +- les resultats des tests unitaires ; +- les resultats de la suite `tests/synthetic_regression/` ; +- les resultats de la suite `tests/synthetic_review/` ; +- les resultats du corpus reel annote si la modification est large ; +- pour chaque cas relu, le triplet : + - source ; + - attendu ; + - produit reel. + +## 4. Roles + +### 4.1 Operateur de revue + +Prepare les elements de preuve : + +- sorties texte ; +- diffs ; +- rapport d'audit ; +- resume des ecarts ; +- liste des regles impactees. + +### 4.2 Relecteur metier + +Verifie que : + +- les donnees sensibles ne fuient pas ; +- le document reste lisible ; +- l'information utile au controle est preservee. + +### 4.3 Responsable qualite / referent + +Prend la decision finale : + +- accepte ; +- accepte avec reserve ; +- refuse ; +- renvoie en correction. + +## 5. Corpus a relire + +### 5.1 Revue minimale + +Pour une correction locale : + +- les cas synthetiques impactes ; +- les cas complets de `tests/synthetic_review/` impactes ; +- au moins 2 documents reels proches du probleme corrige. + +### 5.2 Revue standard + +Pour une evolution de regles : + +- tous les cas complets de `tests/synthetic_review/` ; +- un sous-ensemble reel annote representatif ; +- un echantillon de documents non annotes mais proches de la production. + +### 5.3 Revue renforcee + +Pour une release importante : + +- toutes les familles documentaires critiques ; +- un echantillon reel relu manuellement pour chaque famille ; +- une analyse des nouveaux faux positifs et faux negatifs. + +## 6. Support de revue + +### 6.1 Pour les cas synthetiques complets + +Utiliser : + +- `tests/synthetic_review/cases//test.txt` +- `tests/synthetic_review/cases//expected.txt` +- `tests/synthetic_review/actual//actual.txt` +- `tests/synthetic_review/actual//diff.txt` + +### 6.2 Pour les cas reels + +Utiliser : + +- le document source autorise ; +- le texte pseudonymise ; +- le PDF de sortie si disponible ; +- l'audit JSON/JSONL ; +- le rapport de fuite ; +- le rapport d'evaluation si le document est annote. + +## 7. Checklist de revue dossier par dossier + +### 7.1 Fuites interdites + +Verifier l'absence de : + +- nom et prenom du patient ; +- date de naissance ; +- adresse, code postal, ville de residence ; +- telephone et email ; +- IPP, NDA, RPPS, FINESS, OGC, numero d'examen, numero interne ; +- identifiants visibles en en-tete, pied de page, tableau ou bloc structure ; +- identifiants coupes sur plusieurs lignes ; +- variantes compactes ou prefixees type `N°1234567`. + +### 7.2 Preservation fonctionnelle + +Verifier que restent lisibles : + +- la structure du document ; +- les services, actes et formulations metier legitimes ; +- les dates non sensibles si elles doivent etre conservees ; +- les libelles utiles au controle ; +- les phrases metier explicitement preservees. + +### 7.3 Exploitabilite + +Verifier que le document reste : + +- comprehensible ; +- utilisable par le controle ; +- pas sur-caviarde au point de perdre sa valeur. + +## 8. Classification des anomalies + +### 8.1 Bloquant + +- fuite de PII patient ; +- fuite d'un identifiant administratif critique ; +- regression majeure sur plusieurs familles ; +- regle active sans preuve de validation. + +Decision : + +- pas de release ; +- correction obligatoire. + +### 8.2 Majeur + +- faux positif important qui rend le document difficilement exploitable ; +- preservation non respectee sur un bloc metier cle ; +- comportement instable ou non explicable. + +Decision : + +- correction avant activation large ; +- ou activation limitee a un perimetre test. + +### 8.3 Mineur + +- ecart de forme sans perte de conformite ; +- difference de rendu mineure ; +- bruit d'audit sans impact document. + +Decision : + +- peut etre accepte si documente. + +## 9. Decision de validation + +Chaque revue doit se conclure par une decision explicite : + +- `ACCEPTE` +- `ACCEPTE_AVEC_RESERVE` +- `REFUSE` +- `A_CORRIGER_PUIS_REVOIR` + +La decision doit citer : + +- la version revue ; +- le nom du relecteur ; +- la date ; +- les cas relus ; +- les anomalies ouvertes ; +- la portee de la decision. + +## 10. Preuves a conserver + +Conserver a minima : + +- identifiant de commit ; +- resultat des tests automatiques ; +- liste des cas relus ; +- fiche de validation humaine ; +- rapport de diff ; +- decision signee ou attribuee nominativement. + +## 11. Workflow operationnel recommande + +### Etape 1 + +Executer les tests automatiques : + +- `pytest ...` +- `python3 tools/run_synthetic_review_corpus.py` + +### Etape 2 + +Preparer le lot de revue : + +- cas impactes ; +- textes attendus ; +- textes reels ; +- synthese des ecarts. + +### Etape 3 + +Faire la revue humaine avec la fiche standard. + +### Etape 4 + +Classer chaque anomalie : + +- bloquant ; +- majeur ; +- mineur. + +### Etape 5 + +Prendre une decision de version : + +- go ; +- go limite ; +- no go. + +## 12. Regle simple de gouvernance + +Une regle nouvelle n'est **jamais** activee directement en production si : + +- elle n'a pas de cas de test associe ; +- elle n'a pas ete simulee ; +- personne n'a valide ses effets visibles. + +## 13. Fichier associe + +Le modele de fiche a remplir est dans : + +- [fiche-validation-humaine-modele.md](/home/dom/ai/anonymisation/docs/fiche-validation-humaine-modele.md:1) diff --git a/docs/spec-regles-administration.md b/docs/spec-regles-administration.md new file mode 100644 index 0000000..a644db9 --- /dev/null +++ b/docs/spec-regles-administration.md @@ -0,0 +1,307 @@ +# Specification MVP - regles d'administration + +## 1. Objet + +Cette specification decrit un MVP pour administrer des regles d'anonymisation +sans modifier directement le code du moteur. + +Le but n'est pas de donner un acces libre a des regex dangereuses. +Le but est de fournir un **moteur de regles gouverne**, testable et reversible. + +## 2. Objectifs fonctionnels + +Le MVP doit permettre de : + +- creer une regle ; +- visualiser sa forme normalisee ; +- simuler son effet sur un corpus cible ; +- l'activer ou la desactiver ; +- garder une trace complete des changements ; +- lier chaque regle a des cas de test. + +## 3. Hors perimetre MVP + +Le MVP ne cherche pas encore a : + +- couvrir tous les cas regex complexes ; +- gerer des workflows d'approbation multi-niveaux ; +- remplacer toute la configuration coeur existante ; +- editer directement les heuristiques internes du pipeline. + +## 4. Types de regles supportes + +### 4.1 `exact_term` + +Usage : + +- masquer un terme exact. + +Exemples : + +- `CHCB` +- `LOCAL_SIGLE` + +Parametres principaux : + +- `match.exact_value` +- `normalization.case_insensitive` +- `normalization.whole_word` + +### 4.2 `normalized_identifier` + +Usage : + +- masquer une valeur canonique et ses variantes normalisees. + +Exemple : + +- valeur canonique : `1234567` + +Variantes attendues selon la configuration : + +- `1234567` +- `N°1234567` +- `N° 1234567` +- `No1234567` +- `Numero 1234567` + +Parametres principaux : + +- `match.canonical_value` +- `normalization.accepted_prefixes` +- `normalization.prefix_value_separators` +- `normalization.allow_bare_value` +- `normalization.multiline` + +### 4.3 `contextual_identifier` + +Usage : + +- masquer un identifiant seulement dans un contexte structure donne. + +Exemples : + +- `IPP : ABC12345` +- `N° venue : 1234567` +- `N° examen : 23L35781` + +Parametres principaux : + +- `match.canonical_value` +- `match.context_prefixes` +- `match.context_separators` +- `normalization.multiline` + +### 4.4 `preserve_phrase` + +Usage : + +- garantir qu'une phrase ou formulation metier ne soit jamais masquee. + +Exemples : + +- `classification internationale` +- `prise en charge` + +Parametres principaux : + +- `match.exact_value` +- `normalization.case_insensitive` + +## 5. Structure des regles + +Le schema de reference est fourni ici : + +- [admin_rules.schema.json](/home/dom/ai/anonymisation/schemas/admin_rules.schema.json:1) + +Le template versionne est ici : + +- [admin_rules.default.yml](/home/dom/ai/anonymisation/config/admin_rules.default.yml:1) + +## 6. Champs minimaux par regle + +Chaque regle doit porter : + +- `id` +- `label` +- `type` +- `action` +- `status` +- `match` +- `scope` +- `governance` + +Et, si `action = mask` : + +- `placeholder` + +## 7. Etats de cycle de vie + +### `draft` + +- regle redigee ; +- jamais activee ; +- pas encore validee. + +### `candidate` + +- regle complete ; +- simulation en cours ; +- revue humaine a faire. + +### `approved` + +- regle validee fonctionnellement ; +- prete a etre activee. + +### `active` + +- regle exploitable en environnement autorise. + +### `disabled` + +- regle desactivee temporairement. + +### `retired` + +- regle retiree, conservee pour audit. + +## 8. Portee d'une regle + +Une regle ne doit pas etre globalisee par defaut. + +Le MVP doit permettre de preciser : + +- familles documentaires ; +- environnements ; +- zones documentaires : + - `narrative` + - `structured` + - `table` + - `header` + - `footer` + +## 9. Gouvernance minimale + +Chaque regle doit indiquer : + +- `owner` +- `justification` +- `created_at` +- `review_required_for_activation` +- `tests.required_case_ids` + +Pour une regle active, l'absence de cas de test associe doit etre interdite. + +## 10. Ecrans MVP recommandes + +### 10.1 Liste des regles + +Colonnes minimales : + +- id ; +- label ; +- type ; +- status ; +- scope ; +- auteur ; +- date ; +- dernier test ; +- dernier verdict. + +Actions : + +- filtrer ; +- dupliquer ; +- desactiver ; +- ouvrir ; +- comparer deux versions. + +### 10.2 Creation / edition d'une regle + +Blocs de formulaire : + +- identification ; +- type ; +- valeur source ; +- normalisation ; +- portee ; +- gouvernance ; +- rattachement des cas de test. + +### 10.3 Simulation + +Avant activation, afficher : + +- variantes generees ; +- cas touches ; +- extrait avant / apres ; +- nouveaux masquages ; +- pertes de preservation ; +- verdict automatique. + +### 10.4 Validation / activation + +Le panneau d'activation doit afficher : + +- resultat du validateur de schema ; +- resultat des tests lies ; +- resultat du corpus de simulation ; +- presence ou non d'une revue humaine ; +- bouton `Activer` ou blocage. + +### 10.5 Historique + +Chaque changement doit laisser une trace : + +- qui ; +- quand ; +- quoi ; +- pourquoi ; +- sur quel perimetre ; +- avec quel resultat. + +## 11. Garde-fous obligatoires + +Le MVP doit bloquer : + +- une regle active sans cas de test ; +- une regle `preserve_phrase` avec action `mask` ; +- une regle trop large sans justification ; +- une activation sans simulation ; +- une regle syntaxiquement invalide. + +## 12. Commande de validation hors interface + +Le depot fournit un validateur CLI : + +- [validate_admin_rules.py](/home/dom/ai/anonymisation/tools/validate_admin_rules.py:1) + +Exemple : + +```bash +python3 tools/validate_admin_rules.py --config config/admin_rules.default.yml --show-variants +``` + +## 13. Recommandation d'implementation + +Ordre recommande : + +1. schema et validateur ; +2. stockage YAML versionne ; +3. simulation hors interface ; +4. ecran d'edition ; +5. ecran de simulation ; +6. activation gouvernee. + +## 14. Point important + +Ces artefacts definissent le **contrat de regles** et le **MVP d'administration**. +Ils ne branchent pas encore ces regles dans le moteur de production. + +La prochaine etape technique sera : + +- chargement de `admin_rules` ; +- compilation ; +- simulation ; +- puis application controlee dans le pipeline. diff --git a/schemas/admin_rules.schema.json b/schemas/admin_rules.schema.json new file mode 100644 index 0000000..b1a495e --- /dev/null +++ b/schemas/admin_rules.schema.json @@ -0,0 +1,390 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://local.anonymisation/admin-rules.schema.json", + "title": "Admin rules configuration", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "rules" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "review_required_for_activation": { + "type": "boolean" + }, + "environments": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "test", + "staging", + "prod" + ] + }, + "uniqueItems": true + }, + "sections": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "narrative", + "structured", + "table", + "header", + "footer" + ] + }, + "uniqueItems": true + } + } + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + } + }, + "$defs": { + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "document_families", + "environments", + "sections" + ], + "properties": { + "document_families": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "environments": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "test", + "staging", + "prod" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "sections": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "narrative", + "structured", + "table", + "header", + "footer" + ] + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "tests": { + "type": "object", + "additionalProperties": false, + "required": [ + "required_case_ids" + ], + "properties": { + "required_case_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "governance": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "justification", + "created_at", + "review_required_for_activation", + "tests" + ], + "properties": { + "owner": { + "type": "string", + "minLength": 2 + }, + "justification": { + "type": "string", + "minLength": 8 + }, + "created_at": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "review_required_for_activation": { + "type": "boolean" + }, + "approved_by": { + "type": [ + "string", + "null" + ] + }, + "ticket": { + "type": "string" + }, + "tests": { + "$ref": "#/$defs/tests" + } + } + }, + "normalization": { + "type": "object", + "additionalProperties": false, + "properties": { + "case_insensitive": { + "type": "boolean" + }, + "whole_word": { + "type": "boolean" + }, + "multiline": { + "type": "boolean" + }, + "allow_bare_value": { + "type": "boolean" + }, + "accepted_prefixes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "prefix_value_separators": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "match": { + "type": "object", + "additionalProperties": false, + "properties": { + "exact_value": { + "type": "string", + "minLength": 1 + }, + "canonical_value": { + "type": "string", + "minLength": 1 + }, + "context_prefixes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "context_separators": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + }, + "rule": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label", + "type", + "action", + "status", + "match", + "scope", + "governance" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9_][a-z0-9_-]{2,63}$" + }, + "label": { + "type": "string", + "minLength": 3 + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exact_term", + "normalized_identifier", + "contextual_identifier", + "preserve_phrase" + ] + }, + "action": { + "type": "string", + "enum": [ + "mask", + "preserve" + ] + }, + "placeholder": { + "type": "string", + "pattern": "^\\[[A-Z_]+\\]$" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "candidate", + "approved", + "active", + "disabled", + "retired" + ] + }, + "match": { + "$ref": "#/$defs/match" + }, + "normalization": { + "$ref": "#/$defs/normalization" + }, + "scope": { + "$ref": "#/$defs/scope" + }, + "governance": { + "$ref": "#/$defs/governance" + } + }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "mask" + } + } + }, + "then": { + "required": [ + "placeholder" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "exact_term" + } + } + }, + "then": { + "properties": { + "match": { + "required": [ + "exact_value" + ] + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "preserve_phrase" + } + } + }, + "then": { + "properties": { + "action": { + "const": "preserve" + }, + "match": { + "required": [ + "exact_value" + ] + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "normalized_identifier" + } + } + }, + "then": { + "properties": { + "match": { + "required": [ + "canonical_value" + ] + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "contextual_identifier" + } + } + }, + "then": { + "properties": { + "match": { + "required": [ + "canonical_value", + "context_prefixes" + ] + } + } + } + } + ] + } + } +} diff --git a/tests/unit/test_admin_rules_validator.py b/tests/unit/test_admin_rules_validator.py new file mode 100644 index 0000000..9645557 --- /dev/null +++ b/tests/unit/test_admin_rules_validator.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Tests de non-regression pour le contrat des regles d'administration. +""" +from pathlib import Path + +from tools.validate_admin_rules import ( + generate_rule_variants, + load_rules_config, + validate_rules_config, +) + + +def test_default_admin_rules_template_is_valid(): + path = Path("config/admin_rules.default.yml") + + data = load_rules_config(path) + errors = validate_rules_config(data) + + assert errors == [] + + +def test_normalized_identifier_variants_cover_requested_forms(): + rule = { + "type": "normalized_identifier", + "match": { + "canonical_value": "1234567", + }, + "normalization": { + "allow_bare_value": True, + "multiline": True, + "accepted_prefixes": ["N°", "No"], + "prefix_value_separators": ["", " "], + }, + } + + variants = generate_rule_variants(rule, limit=20) + + assert "1234567" in variants + assert "N°1234567" in variants + assert "N° 1234567" in variants + assert "No1234567" in variants + assert "No 1234567" in variants + + +def test_preserve_phrase_must_use_preserve_action(): + data = { + "version": 1, + "rules": [ + { + "id": "rule_bad_preserve", + "label": "Bad preserve", + "type": "preserve_phrase", + "action": "mask", + "status": "draft", + "match": { + "exact_value": "classification internationale", + }, + "scope": { + "document_families": ["all"], + "environments": ["test"], + "sections": ["narrative"], + }, + "governance": { + "owner": "qualite", + "justification": "test de contrat", + "created_at": "2026-04-21", + "review_required_for_activation": True, + "approved_by": None, + "tests": { + "required_case_ids": ["006_whitelist_phrases_preserved"], + }, + }, + } + ], + } + + errors = validate_rules_config(data) + + assert any("preserve_phrase" in error for error in errors) diff --git a/tools/validate_admin_rules.py b/tools/validate_admin_rules.py new file mode 100644 index 0000000..9b73cd5 --- /dev/null +++ b/tools/validate_admin_rules.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Validation semantique des regles d'administration. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +import yaml + + +VALID_TYPES = { + "exact_term", + "normalized_identifier", + "contextual_identifier", + "preserve_phrase", +} +VALID_ACTIONS = {"mask", "preserve"} +VALID_STATUSES = {"draft", "candidate", "approved", "active", "disabled", "retired"} +VALID_ENVIRONMENTS = {"test", "staging", "prod"} +VALID_SECTIONS = {"narrative", "structured", "table", "header", "footer"} + + +def load_rules_config(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError("Le fichier doit contenir un mapping YAML en racine.") + return data + + +def _is_non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def generate_rule_variants(rule: dict[str, Any], limit: int = 12) -> list[str]: + rule_type = rule.get("type") + match = rule.get("match") or {} + normalization = rule.get("normalization") or {} + variants: list[str] = [] + + if rule_type in {"exact_term", "preserve_phrase"}: + exact_value = str(match.get("exact_value", "")).strip() + return [exact_value] if exact_value else [] + + if rule_type == "normalized_identifier": + canonical = str(match.get("canonical_value", "")).strip() + prefixes = normalization.get("accepted_prefixes") or [] + separators = normalization.get("prefix_value_separators") or [" "] + if normalization.get("allow_bare_value", False) and canonical: + variants.append(canonical) + for prefix in prefixes: + for separator in separators: + variants.append(f"{prefix}{separator}{canonical}") + if normalization.get("multiline", False): + variants.append(f"{prefix}\n{canonical}") + return _dedupe_keep_order(variants)[:limit] + + if rule_type == "contextual_identifier": + canonical = str(match.get("canonical_value", "")).strip() + prefixes = match.get("context_prefixes") or [] + separators = match.get("context_separators") or [": ", ":"] + for prefix in prefixes: + for separator in separators: + variants.append(f"{prefix}{separator}{canonical}") + if (rule.get("normalization") or {}).get("multiline", False): + variants.append(f"{prefix}\n{canonical}") + variants.append(f"{prefix} :\n{canonical}") + return _dedupe_keep_order(variants)[:limit] + + return [] + + +def _dedupe_keep_order(values: list[str]) -> list[str]: + seen: set[str] = set() + output: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + output.append(value) + return output + + +def validate_rules_config(data: dict[str, Any]) -> list[str]: + errors: list[str] = [] + + version = data.get("version") + if not isinstance(version, int) or version < 1: + errors.append("`version` doit etre un entier >= 1.") + + rules = data.get("rules") + if not isinstance(rules, list): + errors.append("`rules` doit etre une liste.") + return errors + + seen_ids: set[str] = set() + for index, rule in enumerate(rules): + prefix = f"rules[{index}]" + if not isinstance(rule, dict): + errors.append(f"{prefix}: chaque regle doit etre un mapping.") + continue + + rule_id = rule.get("id") + if not _is_non_empty_string(rule_id): + errors.append(f"{prefix}: `id` est obligatoire.") + elif rule_id in seen_ids: + errors.append(f"{prefix}: `id` duplique `{rule_id}`.") + else: + seen_ids.add(rule_id) + + if not _is_non_empty_string(rule.get("label")): + errors.append(f"{prefix}: `label` est obligatoire.") + + rule_type = rule.get("type") + if rule_type not in VALID_TYPES: + errors.append(f"{prefix}: `type` invalide.") + + action = rule.get("action") + if action not in VALID_ACTIONS: + errors.append(f"{prefix}: `action` invalide.") + + status = rule.get("status") + if status not in VALID_STATUSES: + errors.append(f"{prefix}: `status` invalide.") + + if action == "mask" and not _is_non_empty_string(rule.get("placeholder")): + errors.append(f"{prefix}: `placeholder` est obligatoire pour une regle de masquage.") + + match = rule.get("match") + if not isinstance(match, dict): + errors.append(f"{prefix}: `match` doit etre un mapping.") + match = {} + + normalization = rule.get("normalization") or {} + if normalization and not isinstance(normalization, dict): + errors.append(f"{prefix}: `normalization` doit etre un mapping.") + normalization = {} + + scope = rule.get("scope") + if not isinstance(scope, dict): + errors.append(f"{prefix}: `scope` doit etre un mapping.") + scope = {} + + governance = rule.get("governance") + if not isinstance(governance, dict): + errors.append(f"{prefix}: `governance` doit etre un mapping.") + governance = {} + + document_families = scope.get("document_families") + if not isinstance(document_families, list) or not document_families: + errors.append(f"{prefix}: `scope.document_families` doit etre une liste non vide.") + + environments = scope.get("environments") + if not isinstance(environments, list) or not environments: + errors.append(f"{prefix}: `scope.environments` doit etre une liste non vide.") + else: + invalid_envs = [value for value in environments if value not in VALID_ENVIRONMENTS] + if invalid_envs: + errors.append(f"{prefix}: environnements invalides: {', '.join(invalid_envs)}.") + + sections = scope.get("sections") + if not isinstance(sections, list) or not sections: + errors.append(f"{prefix}: `scope.sections` doit etre une liste non vide.") + else: + invalid_sections = [value for value in sections if value not in VALID_SECTIONS] + if invalid_sections: + errors.append(f"{prefix}: sections invalides: {', '.join(invalid_sections)}.") + + if not _is_non_empty_string(governance.get("owner")): + errors.append(f"{prefix}: `governance.owner` est obligatoire.") + if not _is_non_empty_string(governance.get("justification")): + errors.append(f"{prefix}: `governance.justification` est obligatoire.") + if not _is_non_empty_string(governance.get("created_at")): + errors.append(f"{prefix}: `governance.created_at` est obligatoire.") + + tests = governance.get("tests") + if not isinstance(tests, dict): + errors.append(f"{prefix}: `governance.tests` doit etre un mapping.") + tests = {} + required_case_ids = tests.get("required_case_ids") + if not isinstance(required_case_ids, list) or not required_case_ids: + errors.append(f"{prefix}: `governance.tests.required_case_ids` doit etre une liste non vide.") + + if rule_type == "exact_term": + if not _is_non_empty_string(match.get("exact_value")): + errors.append(f"{prefix}: `match.exact_value` est obligatoire pour `exact_term`.") + + if rule_type == "preserve_phrase": + if action != "preserve": + errors.append(f"{prefix}: `preserve_phrase` doit utiliser `action: preserve`.") + if not _is_non_empty_string(match.get("exact_value")): + errors.append(f"{prefix}: `match.exact_value` est obligatoire pour `preserve_phrase`.") + + if rule_type == "normalized_identifier": + if not _is_non_empty_string(match.get("canonical_value")): + errors.append(f"{prefix}: `match.canonical_value` est obligatoire pour `normalized_identifier`.") + prefixes = normalization.get("accepted_prefixes", []) + if prefixes and not isinstance(prefixes, list): + errors.append(f"{prefix}: `normalization.accepted_prefixes` doit etre une liste.") + separators = normalization.get("prefix_value_separators", []) + if separators and not isinstance(separators, list): + errors.append(f"{prefix}: `normalization.prefix_value_separators` doit etre une liste.") + + if rule_type == "contextual_identifier": + if not _is_non_empty_string(match.get("canonical_value")): + errors.append(f"{prefix}: `match.canonical_value` est obligatoire pour `contextual_identifier`.") + context_prefixes = match.get("context_prefixes") + if not isinstance(context_prefixes, list) or not context_prefixes: + errors.append(f"{prefix}: `match.context_prefixes` doit etre une liste non vide.") + + if status == "active" and governance.get("review_required_for_activation", False): + if not _is_non_empty_string(governance.get("approved_by")): + errors.append(f"{prefix}: `governance.approved_by` est obligatoire pour une regle active.") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Valider les regles d'administration") + parser.add_argument( + "--config", + default="config/admin_rules.default.yml", + help="Chemin vers le fichier YAML a valider.", + ) + parser.add_argument( + "--show-variants", + action="store_true", + help="Afficher un apercu des variantes generees pour les regles de type identifiant.", + ) + args = parser.parse_args() + + config_path = Path(args.config) + data = load_rules_config(config_path) + errors = validate_rules_config(data) + + if errors: + print("Configuration invalide:") + for error in errors: + print(f"- {error}") + return 1 + + rules = data.get("rules", []) + print(f"Configuration valide: {config_path} ({len(rules)} regle(s))") + for rule in rules: + print(f"- {rule['id']} [{rule['status']}] {rule['type']}") + if args.show_variants: + variants = generate_rule_variants(rule) + if variants: + print(" Variantes:") + for value in variants: + print(f" - {value}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())