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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user