Add human review protocol and admin rules contract

This commit is contained in:
2026-04-21 10:59:02 +02:00
parent da718eb41d
commit e9dccdfad6
7 changed files with 1534 additions and 0 deletions

View File

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

View File

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

View File

@@ -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/<cas>/test.txt`
- `tests/synthetic_review/cases/<cas>/expected.txt`
- `tests/synthetic_review/actual/<cas>/actual.txt`
- `tests/synthetic_review/actual/<cas>/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)

View File

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

View File

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

View File

@@ -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": ["", "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)

View File

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