Qualifier : corrections R2/R6 + router + modèle DB + wizard frontend

Corrections moteur (TDD) :
- R2 : within_mandate → record_in_observatory=True (Observatoire des décisions)
- R6 : >50 personnes → collective recommandé, pas obligatoire (confidence=recommended)
- R3 supprimée : affected_count=1 hors périmètre de l'outil
- R9-R12 renommés G1-G4 (garde-fous internes)
- 23 tests, 213/213 verts

Étape 1 — Router /api/v1/qualify :
- POST / → qualify() avec config depuis DB ou defaults
- GET /protocol → protocole actif
- POST /protocol → créer/remplacer (auth requise)

Étape 2 — Modèle QualificationProtocol :
- Table qualification_protocols (seuils configurables via admin)
- Migration Alembic + seed du protocole par défaut

Étape 3 — Wizard frontend decisions/new.vue :
- Étape 1 : formulaire de qualification (mandat, affected_count, structurant, contexte)
- Étape 2 : résultat (type, raisons, modalités, observatoire, on-chain)
- Étape 3 : formulaire de décision (titre, description, protocole si collectif)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 19:12:01 +02:00
parent 428299c9c8
commit 5c51cffc93
11 changed files with 1060 additions and 519 deletions

View File

@@ -1,21 +1,21 @@
"""TDD — Moteur de qualification des décisions.
Ce fichier est la source de vérité exécutable des règles métier du tunnel "Décider".
Chaque test est nommé d'après la règle qu'il encode.
Source de vérité exécutable des règles métier du tunnel "Décider".
Règles implémentées dans ce fichier (RED → GREEN au fur et à mesure) :
R1 within_mandate → individual + consultation_avis
R2 within_mandate → aucune modalité de vote n'est proposée
R3 affected_count == 1 (hors mandat) → individual
R4 affected_count ≤ small_group_max → individual recommandé / collective possible
R5 small_group_max < affected_count collective_wot_min → collective recommandé
R6 affected_count > collective_wot_min → collective WoT obligatoire
R7 is_structural=True → recommend_onchain=True + raison explicite
R8 is_structural=False → recommend_onchain=False (on ne pollue pas avec l'option)
R9 decision_type est toujours dans l'enum autorisé
R10 individual n'expose jamais de modalités de vote
R11 collective expose au moins une modalité
R12 les seuils sont lus depuis QualificationConfig (configurables)
Règles testées :
R1 within_mandate → individual + consultation_avis
R2 within_mandate → aucune modalité de vote + consignation Observatoire
R4 2 ≤ affected_count ≤ small_group_max → individual recommandé, collectif disponible
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé
R6 affected_count > collective_wot_min → collective recommandé (WoT applicable, non obligatoire)
R7 is_structural → recommend_onchain + raison explicite
R8 is_structural=False → recommend_onchain=False
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
G1 decision_type est toujours dans l'enum autorisé
G2 individual n'expose jamais de modalités de vote
G3 collective expose au moins une modalité
G4 les seuils sont lus depuis QualificationConfig (configurables)
"""
from __future__ import annotations
@@ -30,11 +30,7 @@ from app.engine.qualifier import (
qualify,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = QualificationConfig() # valeurs par défaut
DEFAULT_CONFIG = QualificationConfig()
# ---------------------------------------------------------------------------
@@ -43,19 +39,17 @@ DEFAULT_CONFIG = QualificationConfig() # valeurs par défaut
def test_r1_within_mandate_gives_individual():
"""Une décision dans le périmètre d'un mandat est toujours individuelle."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r1_within_mandate_gives_consultation_avis():
"""La décision dans un mandat utilise le processus 'consultation_avis'."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.process == "consultation_avis"
def test_r1_within_mandate_overrides_large_affected_count():
"""Même si de nombreuses personnes sont concernées, un mandat impose 'individual'."""
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
result = qualify(
QualificationInput(within_mandate=True, affected_count=500),
DEFAULT_CONFIG,
@@ -65,35 +59,29 @@ def test_r1_within_mandate_overrides_large_affected_count():
# ---------------------------------------------------------------------------
# R2 — within_mandate → aucune modalité de vote
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
# ---------------------------------------------------------------------------
def test_r2_within_mandate_no_modalities():
def test_r2_within_mandate_no_vote_modalities():
"""Le mandataire décide seul après consultation — pas de vote collectif."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.recommended_modalities == []
# ---------------------------------------------------------------------------
# R3 — affected_count == 1 hors mandat → individual
# ---------------------------------------------------------------------------
def test_r2_within_mandate_records_in_observatory():
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.record_in_observatory is True
def test_r3_single_person_is_individual():
def test_r2_out_of_mandate_does_not_force_observatory():
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=1),
QualificationInput(within_mandate=False, affected_count=10),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r3_single_person_process_is_personal():
result = qualify(
QualificationInput(within_mandate=False, affected_count=1),
DEFAULT_CONFIG,
)
assert result.process == "personal"
assert result.record_in_observatory is False
# ---------------------------------------------------------------------------
@@ -102,7 +90,6 @@ def test_r3_single_person_process_is_personal():
def test_r4_small_group_recommends_individual():
"""2 à small_group_max personnes : individual recommandé, collective possible."""
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
result = qualify(
QualificationInput(within_mandate=False, affected_count=count),
@@ -111,9 +98,14 @@ def test_r4_small_group_recommends_individual():
assert result.decision_type == DecisionType.INDIVIDUAL, (
f"affected_count={count} devrait recommander individual"
)
assert result.collective_available is True, (
f"affected_count={count} : collective doit rester disponible"
)
def test_r4_small_group_collective_is_available():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.collective_available is True
def test_r4_small_group_confidence_is_recommended():
@@ -148,11 +140,11 @@ def test_r5_medium_group_confidence_is_recommended():
# ---------------------------------------------------------------------------
# R6 — affected_count > collective_wot_min → collective WoT requis
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
# ---------------------------------------------------------------------------
def test_r6_large_group_requires_collective():
def test_r6_large_group_recommends_collective():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
@@ -160,12 +152,13 @@ def test_r6_large_group_requires_collective():
assert result.decision_type == DecisionType.COLLECTIVE
def test_r6_large_group_confidence_is_required():
def test_r6_large_group_confidence_is_recommended_not_required():
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.confidence == "required"
assert result.confidence == "recommended"
def test_r6_large_group_includes_vote_wot_modality():
@@ -182,7 +175,6 @@ def test_r6_large_group_includes_vote_wot_modality():
def test_r7_structural_recommends_onchain():
"""Décision structurante (force de loi ou action machine) → on-chain proposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
@@ -195,14 +187,13 @@ def test_r7_structural_provides_onchain_reason():
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.onchain_reason is not None
assert len(result.onchain_reason) > 0
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
def test_r7_structural_individual_can_also_recommend_onchain():
"""Même une décision individuelle structurante peut être gravée on-chain."""
def test_r7_structural_within_mandate_can_also_recommend_onchain():
"""Même une décision dans un mandat peut être gravée si structurante."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=1, is_structural=True),
QualificationInput(within_mandate=True, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
@@ -214,48 +205,37 @@ def test_r7_structural_individual_can_also_recommend_onchain():
def test_r8_non_structural_never_recommends_onchain():
"""On ne pollue pas les décisions ordinaires avec l'option on-chain."""
for count in [1, 3, 10, 100]:
for count in [2, 3, 10, 100]:
result = qualify(
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is False, (
f"affected_count={count}, is_structural=False : on-chain ne doit pas être proposé"
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
)
# ---------------------------------------------------------------------------
# R9 — decision_type toujours dans l'enum autorisé
# GARDE-FOUS internes (régressions silencieuses)
# ---------------------------------------------------------------------------
def test_r9_decision_type_always_valid():
def test_g1_decision_type_always_valid():
valid_types = set(DecisionType)
inputs = [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=1),
QualificationInput(within_mandate=False, affected_count=3),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
]
for inp in inputs:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types, (
f"Type invalide '{result.decision_type}' pour input {inp}"
)
# ---------------------------------------------------------------------------
# R10 — individual n'expose jamais de modalités
# ---------------------------------------------------------------------------
def test_r10_individual_no_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=1),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
]:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types
def test_g2_individual_never_has_vote_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=3),
]:
result = qualify(inp, DEFAULT_CONFIG)
@@ -265,12 +245,7 @@ def test_r10_individual_no_modalities():
)
# ---------------------------------------------------------------------------
# R11 — collective expose au moins une modalité
# ---------------------------------------------------------------------------
def test_r11_collective_has_at_least_one_modality():
def test_g3_collective_has_at_least_one_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=20),
DEFAULT_CONFIG,
@@ -279,19 +254,9 @@ def test_r11_collective_has_at_least_one_modality():
assert len(result.recommended_modalities) >= 1
# ---------------------------------------------------------------------------
# R12 — les seuils sont lus depuis QualificationConfig
# ---------------------------------------------------------------------------
def test_r12_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig, pas de constantes hardcodées."""
custom = QualificationConfig(
individual_max=1,
small_group_max=2, # au lieu de 5
collective_wot_min=10,
)
# affected_count=3 est au-delà de small_group_max=2 → collective
def test_g4_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
custom,
@@ -299,9 +264,7 @@ def test_r12_custom_config_overrides_thresholds():
assert result.decision_type == DecisionType.COLLECTIVE
def test_r12_default_config_thresholds_are_expected():
"""Valeurs par défaut documentées et vérifiables."""
def test_g4_default_thresholds_are_stable():
cfg = QualificationConfig()
assert cfg.individual_max == 1
assert cfg.small_group_max == 5
assert cfg.collective_wot_min == 50