"""Decision qualification engine. Pure functions — no database, no I/O. Takes a QualificationInput + QualificationConfig and returns a QualificationResult. LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment. """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum class DecisionType(str, Enum): INDIVIDUAL = "individual" COLLECTIVE = "collective" # --------------------------------------------------------------------------- # Configuration (thresholds — stored as QualificationProtocol in DB) # --------------------------------------------------------------------------- @dataclass class QualificationConfig: """Configurable thresholds for the qualification engine. Seeded as a QualificationProtocol record so they can be adjusted through the admin interface without code changes. small_group_max: affected_count <= this → individual recommended, collective available collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required) affected_count must be >= 2 — decisions affecting only the author have no place in this tool. """ small_group_max: int = 5 collective_wot_min: int = 50 default_modalities: list[str] = field(default_factory=lambda: [ "vote_wot", "vote_smith", "consultation_avis", "election", ]) # --------------------------------------------------------------------------- # Input / Output # --------------------------------------------------------------------------- @dataclass class QualificationInput: within_mandate: bool = False 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 @dataclass class QualificationResult: decision_type: DecisionType process: str recommended_modalities: list[str] recommend_onchain: bool 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] # --------------------------------------------------------------------------- # LLM stub # --------------------------------------------------------------------------- def suggest_modalities_from_context( context: str, config: QualificationConfig, ) -> list[str]: """Suggest voting modalities based on a natural-language context description. 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 [] # --------------------------------------------------------------------------- # Core engine # --------------------------------------------------------------------------- def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult: """Qualify a decision and recommend a type, process, and modalities. Rules (in priority order): 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 recommended (WoT formula applicable) R7/R8 is_structural → recommend_onchain with reason """ reasons: list[str] = [] # ── R1/R2: mandate scope overrides everything ─────────────────────────── if inp.within_mandate: reasons.append("Décision dans le périmètre d'un mandat existant.") return QualificationResult( decision_type=DecisionType.INDIVIDUAL, 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, ) 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: reasons.append( f"{count} personnes concernées : décision individuelle recommandée, " "vote collectif possible." ) 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, ) # ── 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é.") confidence = "recommended" else: reasons.append( f"{count} personnes concernées : vote collectif recommandé " "(formule WoT applicable à cette échelle)." ) confidence = "recommended" if "vote_wot" not in modalities: modalities = ["vote_wot"] + modalities return QualificationResult( decision_type=DecisionType.COLLECTIVE, 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, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _onchain(inp: QualificationInput, reasons: list[str]) -> bool: if inp.is_structural: reasons.append( "Décision structurante : gravure on-chain recommandée " "(a force de loi ou déclenche une action machine)." ) return inp.is_structural def _onchain_reason(inp: QualificationInput) -> str | None: if not inp.is_structural: return None return ( "Cette décision est structurante : elle a valeur de loi au sein de la " "communauté ou déclenche une action machine (ex : runtime upgrade). " "La gravure on-chain (IPFS + system.remark) garantit son immuabilité " "et sa vérifiabilité publique." ) 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: llm_suggestions = suggest_modalities_from_context(inp.context_description, config) seen: set[str] = set() result: list[str] = [] for m in llm_suggestions + config.default_modalities: if m not in seen: seen.add(m) result.append(m) return result