recommandations : un moteur de regles charge sans redeploiement (#116) #241

Merged
gabriel merged 4 commits from olivier/116-moteur-regles-generique into develop 2026-09-09 14:35:03 +00:00
Member

Ferme #116 (Portée/Post-jury) — la fusion est une décision d'équipe, pas un acquis.

L'écart à signaler d'abord

Ce ticket est hors fenêtre par la décision du 03/09 (docs/BACKLOG.md, réduction 1). Le développement a été fait le 09/09 quand même, à la demande. La décision n'est pas rouverte pour autant : le ticket garde son libellé, et le backlog comme le PRD portent l'écart en clair. Si le gel du 09 au soir s'applique, cette demande attend le 11.

Ce qui change

Les trois règles du #153 étaient trois classes Python, seuils en constantes de module : les recaler demandait un redéploiement. Elles deviennent des données, dans un fichier TOML versionné, modifiable sans redéploiement.

[[regle]]
identifiant = "r1"
forme = "ratio_avec_echeance"
numerateur = "prevision_kw"
denominateur = "capacite_kw"
seuil = 0.90

Des formes paramétrées, pas un langage d'expressions. Chaque forme est un raisonnement écrit une fois en Python — un seuil, un ratio soumis à une échéance, une série de N points consécutifs. Un langage d'expressions aurait couvert R2 aussi, mais il demande d'évaluer du texte venu d'un fichier que le ticket veut modifiable sans revue : eval sur cette entrée donne au fichier le pouvoir du processus qui le lit. Motif complet, et les deux alternatives écartées, dans l'ADR 0014.

Ajouter une règle du genre d'une forme existante ne coûte aucun Python : c'est le critère du ticket, et test_formes.py le démontre sur des règles d'un genre que le #153 n'a jamais décrit — comparateur inversé, pas au quart d'heure, gravité différente.

Ce qui garantit que rien n'a changé pour les exploitants

Le risque n'était pas qu'un test rougisse : c'est que tout passe et que les recommandations aient quand même changé — un arrondi, une garde perdue, un motif d'indécision devenu muet. Personne ne l'aurait vu avant qu'un exploitant reçoive un conseil faux.

tests/unit/rules/verdicts-de-reference.json porte les 84 verdicts produits par les trois classes sur 28 observations couvrant chaque branche de chaque règle, capturés avant leur retrait, puis confrontés aux mêmes classes extraites de develop : aucun écart. Rejouable sans rien débrancher :

git show develop:services/recommendations/recommendations/regles/r1_depassement_previsionnel.py

La comparaison est stricte : message, action, gravité, valeurs déclenchantes et motif d'indécision au caractère près. C'est pour tenir ce dernier point que les motifs sont des gabarits du fichier — une reformulation « équivalente » aurait été indistinguable d'une garde perdue.

Les décisions à relire en priorité

Décision
Un jeu invalide arrête la passe (code 5), sans repli job.py, ADR 0014
Le fichier vit dans /etc, root:deploy 0640 — modifier une règle demande donc sudo rôle app, ADR 0014
Ansible pose le fichier une seule fois (force: false) rôle app
La profondeur de lecture se dérive du jeu, non plus d'une constante formes.py, entrepot.py

Le force: false n'est pas une précaution : le déploiement continu joue --tags app,proxy,backup à chaque fusion, et une copie qui écrase effacerait à la fusion suivante toute règle ajoutée sur le serveur — le ticket se retournant contre lui-même.

Relecture faite, six défauts corrigés

Une relecture systématique a trouvé six défauts, tous corrigés dans 6a2416f, chacun avec son cas de test. Le plus grave était bloquant : la tâche Ansible validait le jeu avec un venv créé 220 lignes plus loin dans le même rôle. Sur un hôte neuf, validate échouait, le rôle s'arrêtait avant les migrations — dont la 0021 — et ce n'était pas auto-réparant.

Les cinq autres : une table écrite en liste faisait perdre le code 5 au profit d'une trace brute ; Template.get_identifiers ignore les placeholders invalides, donc un $ littéral n'explosait qu'au premier rendu — le jour où le site est en pointe ; profondeur_heures ajoutait un nombre de points à des heures et ignorait le pas ; trois appels lisaient /etc là où ils documentaient lire le dépôt ; le repli du lanceur confondait « absent » et « illisible ». Le témoin est inchangé après les six correctifs : aucun verdict n'a bougé.

Cette demande corrige aussi une affirmation que j'avais écrite et que la fusion de develop a rendue fausse : le code 5 est surveillé depuis le #228, par l'alerte « Tâche planifiée en retard » (trois heures de latence). L'ADR et le manuel le disent maintenant.

Vérifications

Rejouées sur le serveur, en Python 3.12, le venv du poste n'ayant aucune dépendance applicative :

  • 353 tests verts (tests/unit/rules, tests/unit/db, test_reglages_inference.py) ; +155 par rapport à develop
  • couverture 94 % sur services/recommendations/recommendations, palier des zones sensibles à 85
  • ruff, ruff format, mypy --strict, shellcheck, yamllint et ansible-lint propres (ansible-lint passe le profil production)
  • tests/ci/test-supervision.sh : le lanceur publie son état et garde la main
  • suite complète du dépôt : mêmes 5 échecs et 149 erreurs que develop avec le même venv, donc aucune régression

Migration 0021 jouée sur enervision_prod — 11 lignes réelles — dans une transaction annulée : UPDATE 11, les deux set not null passent, la contrainte est posée, puis ROLLBACK. Rejeu vérifié dans la même transaction : UPDATE 0, aucune contrainte dupliquée. Les lignes d'avant le #116 reçoivent avant-116, valeur qui se lit et se cherche, plutôt qu'une chaîne vide.

Le geste d'exploitation — modifier une règle sans redéploiement, la vérifier, lire un code 5 — est dans docs/runbooks/recommandations.md, et chaque commande y a été exécutée sur le serveur.

Ce qui reste à faire après fusion

Le rôle app doit être rejoué pour poser /etc/enervision/regles.toml. En attendant, le lanceur replie sur le jeu de référence du dépôt et le journalise : la passe tourne, avec les mêmes règles.

🤖 Generated with Claude Code

Ferme #116 (`Portée/Post-jury`) — **la fusion est une décision d'équipe, pas un acquis.** ## L'écart à signaler d'abord Ce ticket est hors fenêtre par la décision du 03/09 (`docs/BACKLOG.md`, réduction 1). Le développement a été fait le 09/09 quand même, à la demande. La décision n'est pas rouverte pour autant : le ticket garde son libellé, et le backlog comme le PRD portent l'écart en clair. **Si le gel du 09 au soir s'applique, cette demande attend le 11.** ## Ce qui change Les trois règles du #153 étaient trois classes Python, seuils en constantes de module : les recaler demandait un redéploiement. Elles deviennent des **données**, dans un fichier TOML versionné, modifiable sans redéploiement. ```toml [[regle]] identifiant = "r1" forme = "ratio_avec_echeance" numerateur = "prevision_kw" denominateur = "capacite_kw" seuil = 0.90 ``` **Des formes paramétrées, pas un langage d'expressions.** Chaque forme est un raisonnement écrit une fois en Python — un seuil, un ratio soumis à une échéance, une série de N points consécutifs. Un langage d'expressions aurait couvert R2 aussi, mais il demande d'évaluer du texte venu d'un fichier que le ticket veut modifiable sans revue : `eval` sur cette entrée donne au fichier le pouvoir du processus qui le lit. Motif complet, et les deux alternatives écartées, dans l'ADR 0014. Ajouter une règle du genre d'une forme existante ne coûte **aucun Python** : c'est le critère du ticket, et `test_formes.py` le démontre sur des règles d'un genre que le #153 n'a jamais décrit — comparateur inversé, pas au quart d'heure, gravité différente. ## Ce qui garantit que rien n'a changé pour les exploitants Le risque n'était pas qu'un test rougisse : c'est que **tout passe** et que les recommandations aient quand même changé — un arrondi, une garde perdue, un motif d'indécision devenu muet. Personne ne l'aurait vu avant qu'un exploitant reçoive un conseil faux. `tests/unit/rules/verdicts-de-reference.json` porte les **84 verdicts** produits par les trois classes sur 28 observations couvrant chaque branche de chaque règle, capturés avant leur retrait, puis confrontés aux mêmes classes extraites de `develop` : **aucun écart**. Rejouable sans rien débrancher : ```sh git show develop:services/recommendations/recommendations/regles/r1_depassement_previsionnel.py ``` La comparaison est stricte : message, action, gravité, valeurs déclenchantes **et motif d'indécision au caractère près**. C'est pour tenir ce dernier point que les motifs sont des gabarits du fichier — une reformulation « équivalente » aurait été indistinguable d'une garde perdue. ## Les décisions à relire en priorité | Décision | Où | |---|---| | Un jeu invalide **arrête la passe** (code 5), sans repli | `job.py`, ADR 0014 | | Le fichier vit dans `/etc`, `root:deploy 0640` — modifier une règle demande donc `sudo` | rôle `app`, ADR 0014 | | Ansible pose le fichier **une seule fois** (`force: false`) | rôle `app` | | La profondeur de lecture se dérive du jeu, non plus d'une constante | `formes.py`, `entrepot.py` | Le `force: false` n'est pas une précaution : le déploiement continu joue `--tags app,proxy,backup` à chaque fusion, et une copie qui écrase effacerait à la fusion suivante toute règle ajoutée sur le serveur — le ticket se retournant contre lui-même. ## Relecture faite, six défauts corrigés Une relecture systématique a trouvé six défauts, tous corrigés dans `6a2416f`, chacun avec son cas de test. Le plus grave était **bloquant** : la tâche Ansible validait le jeu avec un venv créé 220 lignes plus loin dans le même rôle. Sur un hôte neuf, `validate` échouait, le rôle s'arrêtait avant les migrations — dont la 0021 — et ce n'était pas auto-réparant. Les cinq autres : une table écrite en liste faisait perdre le code 5 au profit d'une trace brute ; `Template.get_identifiers` ignore les placeholders invalides, donc un `$` littéral n'explosait qu'au premier rendu — le jour où le site est en pointe ; `profondeur_heures` ajoutait un nombre de points à des heures et ignorait le pas ; trois appels lisaient `/etc` là où ils documentaient lire le dépôt ; le repli du lanceur confondait « absent » et « illisible ». **Le témoin est inchangé après les six correctifs : aucun verdict n'a bougé.** Cette demande corrige aussi une affirmation que j'avais écrite et que la fusion de `develop` a rendue fausse : le code 5 **est** surveillé depuis le #228, par l'alerte « Tâche planifiée en retard » (trois heures de latence). L'ADR et le manuel le disent maintenant. ## Vérifications Rejouées **sur le serveur, en Python 3.12**, le venv du poste n'ayant aucune dépendance applicative : - **353 tests** verts (`tests/unit/rules`, `tests/unit/db`, `test_reglages_inference.py`) ; +155 par rapport à `develop` - **couverture 94 %** sur `services/recommendations/recommendations`, palier des zones sensibles à 85 - ruff, `ruff format`, mypy `--strict`, shellcheck, yamllint et ansible-lint propres (ansible-lint passe le profil `production`) - `tests/ci/test-supervision.sh` : le lanceur publie son état et garde la main - suite complète du dépôt : mêmes 5 échecs et 149 erreurs que `develop` avec le même venv, donc aucune régression **Migration 0021** jouée sur `enervision_prod` — 11 lignes réelles — dans une transaction annulée : `UPDATE 11`, les deux `set not null` passent, la contrainte est posée, puis `ROLLBACK`. Rejeu vérifié dans la même transaction : `UPDATE 0`, aucune contrainte dupliquée. Les lignes d'avant le #116 reçoivent `avant-116`, valeur qui se lit et se cherche, plutôt qu'une chaîne vide. Le geste d'exploitation — modifier une règle sans redéploiement, la vérifier, lire un code 5 — est dans `docs/runbooks/recommandations.md`, et chaque commande y a été exécutée sur le serveur. ## Ce qui reste à faire après fusion Le rôle `app` doit être rejoué pour poser `/etc/enervision/regles.toml`. En attendant, le lanceur replie sur le jeu de référence du dépôt **et le journalise** : la passe tourne, avec les mêmes règles. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Les trois regles du #153 etaient trois classes Python, leurs seuils en
constantes de module : les changer demandait un redeploiement. Le #116 les
remplace par un jeu de regles charge d'un fichier, versionne et modifiable
sans redeploiement.

Des FORMES parametrees, pas un langage d'expressions. Chaque forme est un
raisonnement ecrit une fois en Python — un seuil, un ratio soumis a une
echeance, une serie de N points consecutifs — que le fichier instancie. Rien
de ce que le fichier contient n'est execute : il est modifiable sans revue,
donc c'est une entree non fiable. Motif complet dans l'ADR 0014.

CE QUI GARANTIT QUE RIEN N'A CHANGE. Le risque n'etait pas qu'un test
rougisse, mais que tout passe et que les recommandations aient quand meme
change. tests/unit/rules/verdicts-de-reference.json porte les 84 verdicts
produits par les trois classes sur 28 observations couvrant chaque branche,
captures avant leur retrait puis confrontes aux memes classes extraites de
develop : aucun ecart. Le test d'equivalence compare message, action, gravite,
valeurs declenchantes et motif d'indecision au caractere pres — c'est pour
tenir ce dernier point que les motifs sont des gabarits du fichier.

Un jeu invalide n'est pas rattrape : la passe sort en code 5 sans rien ecrire.
Le prix se paie en amont — `--verifier` a la main, `validate:` sur la tache
Ansible, et le chargement du jeu de reference par la chaine.

La profondeur de lecture de la zone or se derive maintenant du jeu, au lieu
d'etre une constante d'entrepot.py : une regle ajoutee a chaud ajuste la
fenetre lue. C'est le piege du #175, referme.

Ansible pose le fichier une seule fois (force: false) : le deploiement continu
joue --tags app a chaque fusion et aurait efface les regles ajoutees sur le
serveur.

Verifie en 3.12 sur le serveur : 345 tests, 94 % de couverture sur le paquet
(palier 85). Migration 0021 jouee sur enervision_prod en transaction annulee,
11 lignes existantes rattrapees en `avant-116`.

Le ticket reste Portee/Post-jury : la decision du 03/09 n'est pas rouverte par
ce developpement, la fusion est une decision d'equipe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'ADR 0014 et le manuel concluaient qu'aucune alerte ne regardait les passes de
recommandation, donc qu'une suite de codes 5 ne reveillerait personne. C'etait
vrai en l'ecrivant, et faux en fusionnant : le #228 a instrumente les sept
taches planifiees entre-temps.

Le lanceur publie `ev_ops_tache_dernier_code` et la date de la derniere
REUSSITE ; l'alerte « Tache planifiee en retard » se declenche a trois fois la
cadence, soit trois heures ici. Ce n'est pas immediat — les deux ou trois
premieres passes echouent en silence — d'ou la verification avant de quitter le
fichier, que le manuel place a l'etape 3.

Constate en fusionnant develop, pas suppose : les deux blocs du lanceur, le
mien et celui du #228, se fusionnent sans conflit et
tests/ci/test-supervision.sh passe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recommandations : six defauts trouves en relecture du moteur de regles
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 48s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 6m9s
6a2416f2a5
1. BLOQUANT — la tache Ansible validait le jeu avec {{ collector_venv }}/bin/
python, place 220 lignes AVANT la tache qui cree ce venv. Sur un hote neuf la
commande n'a pas d'interpreteur, `validate` echoue, et le role s'arrete avant
de demarrer les piles et d'appliquer les migrations — dont la 0021 dont
l'insertion depend. Ce n'etait pas auto-reparant : chaque relance s'arretait au
meme endroit. La tache passe apres l'installation des dependances et avant la
pose de la crontab, et le commentaire dit pourquoi les deux bornes comptent.

2. Une table ecrite en liste — `valeurs = ["seuil"]`, confusion de syntaxe TOML
facile a la main — levait AttributeError, que _construire ne rattrape pas : la
passe mourait sur une trace en code 1, quand le journal, le manuel et le
validate d'Ansible annoncent tous un code 5. Meme cas pour `motifs` et pour
`requises` donne en scalaire.

3. `Template.get_identifiers` IGNORE les placeholders invalides — seul
`is_valid` les refuse. Un « $ » litteral (« 30 $/MWh ») passait le chargement,
`--verifier` et le validate, puis faisait lever `substitute` au premier RENDU :
la regle tombait le jour ou le site etait en pointe, et nulle part avant. C'est
le mode de panne que cette validation existe pour supprimer.

4. `profondeur_heures` ajoutait un NOMBRE DE POINTS a des HEURES et ignorait le
pas : juste par coincidence au pas horaire du #153, faux des qu'une regle
declare autre chose. Au pas de trois heures, la regle avait besoin de douze
heures et n'en demandait que neuf — le #175 refermé d'un cote, reouvert de
l'autre, atteignable par une modification du fichier. Le jeu de reference reste
a 9 h : la production ne change pas.

5. Trois appels a `charger()` sans chemin honoraient
ENERVISION_REGLES_FICHIER alors qu'ils documentent lire le jeu du depot. Le
temoin d'equivalence aurait ete regenere depuis /etc — le fichier meme dont la
conception dit qu'il peut diverger.

6. Le repli du lanceur se declenchait sur `! -r`, qui couvre « illisible »
autant que « absent ». Un fichier reste en 0600 root:root apres une
modification a la main aurait fait tourner la passe sur les regles du DEPOT
sous une autre empreinte. Absent : repli, comme avant. Present et illisible :
code 2, comme le controle de postgres.env juste au-dessus.

Le temoin est inchange apres les six correctifs : aucun verdict n'a bouge.
292 tests sur le service, ruff, mypy strict, shellcheck, yamllint et
ansible-lint propres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gabriel approved these changes 2026-09-09 14:34:58 +00:00
gabriel merged commit e71d9f6bda into develop 2026-09-09 14:35:03 +00:00
gabriel deleted branch olivier/116-moteur-regles-generique 2026-09-09 14:35:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!241
No description provided.