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

@@ -18,7 +18,7 @@ class DecisionType(str, Enum):
# ---------------------------------------------------------------------------
# Configuration (thresholds — stored as a QualificationProtocol in DB)
# Configuration (thresholds — stored as QualificationProtocol in DB)
# ---------------------------------------------------------------------------
@@ -26,16 +26,15 @@ class DecisionType(str, Enum):
class QualificationConfig:
"""Configurable thresholds for the qualification engine.
These defaults will be seeded as a QualificationProtocol record so they
can be adjusted through the admin interface without code changes.
Seeded as a QualificationProtocol record so they can be adjusted
through the admin interface without code changes.
individual_max: affected_count <= this → always individual
small_group_max: affected_count <= this → individual recommended, collective available
collective_wot_min: affected_count > this → collective required (WoT formula applies)
small_group_max: affected_count <= this → individual recommended, collective available
collective_wot_min: affected_count > this WoT formula applicable (still recommended, not required)
Default modalities shown when collective is chosen (ordered by relevance).
affected_count must be >= 2 — decisions affecting only the author
have no place in this tool.
"""
individual_max: int = 1
small_group_max: int = 5
collective_wot_min: int = 50
@@ -55,7 +54,7 @@ class QualificationConfig:
@dataclass
class QualificationInput:
within_mandate: bool = False
affected_count: int | None = None
affected_count: int | None = None # must be >= 2 when within_mandate=False
is_structural: bool = False
context_description: str | None = None # reserved for LLM suggestion
@@ -66,10 +65,11 @@ class QualificationResult:
process: str
recommended_modalities: list[str]
recommend_onchain: bool
confidence: str # "required" | "recommended" | "optional"
onchain_reason: str | None
confidence: str # "required" | "recommended" | "optional"
collective_available: bool
record_in_observatory: bool # True → decision must be logged in Observatoire
reasons: list[str]
onchain_reason: str | None = None
# ---------------------------------------------------------------------------
@@ -83,9 +83,9 @@ def suggest_modalities_from_context(
) -> list[str]:
"""Suggest voting modalities based on a natural-language context description.
Stub — returns empty list until local Qwen (qwen3.6) is integrated.
When implemented, this will call the LLM API and return an ordered list
of modality slugs from config.default_modalities.
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
When implemented, will call the LLM API and return an ordered subset of
config.default_modalities ranked by contextual relevance.
"""
return []
@@ -99,11 +99,11 @@ def qualify(inp: QualificationInput, config: QualificationConfig) -> Qualificati
"""Qualify a decision and recommend a type, process, and modalities.
Rules (in priority order):
R1/R2 within_mandate → individual + consultation_avis, no modalities
R3 affected_count == 1 → individual + personal
R4 affected_count ≤ small_group_max → individual recommended, collective available
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
decision must be recorded in Observatoire des décisions
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
R6 affected_count > collective_wot_min → collective required (WoT)
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
R7/R8 is_structural → recommend_onchain with reason
"""
reasons: list[str] = []
@@ -116,27 +116,14 @@ def qualify(inp: QualificationInput, config: QualificationConfig) -> Qualificati
process="consultation_avis",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="required",
collective_available=False,
record_in_observatory=True,
reasons=reasons,
onchain_reason=_onchain_reason(inp),
)
count = inp.affected_count if inp.affected_count is not None else 1
# ── R3: single person ───────────────────────────────────────────────────
if count <= config.individual_max:
reasons.append("Une seule personne concernée.")
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="personal",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
confidence="required",
collective_available=False,
reasons=reasons,
onchain_reason=_onchain_reason(inp),
)
count = inp.affected_count if inp.affected_count is not None else 2
# ── R4: small group → individual recommended, collective available ───────
if count <= config.small_group_max:
@@ -144,32 +131,30 @@ def qualify(inp: QualificationInput, config: QualificationConfig) -> Qualificati
f"{count} personnes concernées : décision individuelle recommandée, "
"vote collectif possible."
)
modalities = _collect_modalities(inp, config)
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="personal",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="recommended",
collective_available=True,
record_in_observatory=False,
reasons=reasons,
onchain_reason=_onchain_reason(inp),
)
# ── R5/R6: medium or large group → collective ────────────────────────────
modalities = _collect_modalities(inp, config)
if count <= config.collective_wot_min:
reasons.append(
f"{count} personnes concernées : vote collectif recommandé."
)
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
confidence = "recommended"
else:
reasons.append(
f"{count} personnes concernées : vote collectif obligatoire "
"(formule WoT applicable)."
f"{count} personnes concernées : vote collectif recommandé "
"(formule WoT applicable à cette échelle)."
)
confidence = "required"
confidence = "recommended"
if "vote_wot" not in modalities:
modalities = ["vote_wot"] + modalities
@@ -178,10 +163,11 @@ def qualify(inp: QualificationInput, config: QualificationConfig) -> Qualificati
process="vote_collective",
recommended_modalities=modalities,
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence=confidence,
collective_available=True,
record_in_observatory=False,
reasons=reasons,
onchain_reason=_onchain_reason(inp),
)
@@ -210,10 +196,7 @@ def _onchain_reason(inp: QualificationInput) -> str | None:
)
def _collect_modalities(
inp: QualificationInput,
config: QualificationConfig,
) -> list[str]:
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
"""Combine default modalities with any LLM suggestions (stub for now)."""
llm_suggestions = []
if inp.context_description: