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>
271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
"""TDD — Moteur de qualification des décisions.
|
|
|
|
Source de vérité exécutable des règles métier du tunnel "Décider".
|
|
|
|
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
|
|
|
|
import pytest
|
|
|
|
from app.engine.qualifier import (
|
|
DecisionType,
|
|
QualificationConfig,
|
|
QualificationInput,
|
|
QualificationResult,
|
|
qualify,
|
|
)
|
|
|
|
DEFAULT_CONFIG = QualificationConfig()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R1 — within_mandate → individual + consultation_avis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r1_within_mandate_gives_individual():
|
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
|
|
|
|
|
def test_r1_within_mandate_gives_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."""
|
|
result = qualify(
|
|
QualificationInput(within_mandate=True, affected_count=500),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
|
assert result.process == "consultation_avis"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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 == []
|
|
|
|
|
|
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_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=10),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.record_in_observatory is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r4_small_group_recommends_individual():
|
|
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=count),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.decision_type == DecisionType.INDIVIDUAL, (
|
|
f"affected_count={count} devrait recommander individual"
|
|
)
|
|
|
|
|
|
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():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=3),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.confidence == "recommended"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r5_medium_group_recommends_collective():
|
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=mid),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.decision_type == DecisionType.COLLECTIVE
|
|
|
|
|
|
def test_r5_medium_group_confidence_is_recommended():
|
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=mid),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.confidence == "recommended"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r6_large_group_recommends_collective():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.decision_type == DecisionType.COLLECTIVE
|
|
|
|
|
|
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 == "recommended"
|
|
|
|
|
|
def test_r6_large_group_includes_vote_wot_modality():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert "vote_wot" in result.recommended_modalities
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R7 — is_structural=True → recommend_onchain + raison
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r7_structural_recommends_onchain():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.recommend_onchain is True
|
|
|
|
|
|
def test_r7_structural_provides_onchain_reason():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
|
|
|
|
|
|
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=True, is_structural=True),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.recommend_onchain is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R8 — is_structural=False → recommend_onchain=False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_r8_non_structural_never_recommends_onchain():
|
|
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} non structurant : on-chain ne doit pas être proposé"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GARDE-FOUS internes (régressions silencieuses)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_g1_decision_type_always_valid():
|
|
valid_types = set(DecisionType)
|
|
for inp in [
|
|
QualificationInput(within_mandate=True),
|
|
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)
|
|
if result.decision_type == DecisionType.INDIVIDUAL:
|
|
assert result.recommended_modalities == [], (
|
|
f"Individual ne doit pas exposer de modalités : {inp}"
|
|
)
|
|
|
|
|
|
def test_g3_collective_has_at_least_one_modality():
|
|
result = qualify(
|
|
QualificationInput(within_mandate=False, affected_count=20),
|
|
DEFAULT_CONFIG,
|
|
)
|
|
assert result.decision_type == DecisionType.COLLECTIVE
|
|
assert len(result.recommended_modalities) >= 1
|
|
|
|
|
|
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,
|
|
)
|
|
assert result.decision_type == DecisionType.COLLECTIVE
|
|
|
|
|
|
def test_g4_default_thresholds_are_stable():
|
|
cfg = QualificationConfig()
|
|
assert cfg.small_group_max == 5
|
|
assert cfg.collective_wot_min == 50
|