Files
decision/backend/app/tests/test_qualifier_ai.py
Yvv e2ae8b196e Qualify : service IA + endpoint ai-chat + wizard Décider (3 cercles + AI 2-rounds)
- qualify_ai_service.py : stub IA 2-allers-retours (réversibilité + urgence)
- qualify.py router : endpoint POST /ai-chat → AIChatRequest/AIChatResponse
- test_qualifier_ai.py : 11 tests A1-A7 (questions stables, done=True au 2e round)
- decisions/new.vue : wizard 4 étapes — branche mandat (liste + lien demande) /
  hors-mandat (3 cercles textarea), questions IA, résultat + boîte à outils,
  formulaire final

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:44:00 +02:00

173 lines
5.6 KiB
Python

"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
Invariants testés :
A1 Premier appel (messages=[]) → retourne toujours 2 questions, done=False
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
A3 Deuxième appel (messages=[q+réponse]) → done=True, résultat qualifié
A4 Réponse "irréversible" → recommend_onchain conservé si is_structural
A5 Réponse "urgente" → raison "urgence" présente dans le résultat
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
"""
from __future__ import annotations
import pytest
from app.services.qualify_ai_service import (
AIFrameRequest,
AIMessage,
ai_frame,
)
DEFAULT_REQUEST = AIFrameRequest(
context="Révision du règlement intérieur de l'association",
within_mandate=False,
affected_count=20,
is_structural=False,
messages=[],
)
# ---------------------------------------------------------------------------
# A1 — Premier appel → 2 questions, done=False
# ---------------------------------------------------------------------------
def test_a1_first_call_returns_questions():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.done is False
assert len(resp.questions) == 2
def test_a1_first_call_result_is_none():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.result is None
# ---------------------------------------------------------------------------
# A2 — Questions couvrent réversibilité et urgence
# ---------------------------------------------------------------------------
def test_a2_questions_have_stable_ids():
resp = ai_frame(DEFAULT_REQUEST)
ids = {q.id for q in resp.questions}
assert "reversibility" in ids
assert "urgency" in ids
def test_a2_questions_have_options():
resp = ai_frame(DEFAULT_REQUEST)
for q in resp.questions:
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
# ---------------------------------------------------------------------------
# A3 — Deuxième appel (avec réponses) → done=True + résultat
# ---------------------------------------------------------------------------
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
questions = ai_frame(DEFAULT_REQUEST).questions
messages = []
for q in questions:
messages.append(AIMessage(role="assistant", content=q.text))
# One user message bundling all answers
messages.append(AIMessage(
role="user",
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
))
return AIFrameRequest(
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
)
def test_a3_second_call_is_done():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.done is True
def test_a3_second_call_has_result():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type in ("individual", "collective")
# ---------------------------------------------------------------------------
# A4 — Irréversible + structurant → recommend_onchain
# ---------------------------------------------------------------------------
def test_a4_irreversible_structural_recommends_onchain():
req = _make_second_request(
"Non, c'est irréversible",
"Pas d'urgence",
is_structural=True,
)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.recommend_onchain is True
# ---------------------------------------------------------------------------
# A5 — Urgence → raison présente
# ---------------------------------------------------------------------------
def test_a5_urgent_adds_urgency_reason():
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
resp = ai_frame(req)
assert resp.result is not None
reasons_text = " ".join(resp.result.reasons).lower()
assert "urgence" in reasons_text or "urgent" in reasons_text
# ---------------------------------------------------------------------------
# A6 — Résultat respecte les règles du moteur
# ---------------------------------------------------------------------------
def test_a6_within_mandate_gives_individual():
req = AIFrameRequest(
within_mandate=True,
affected_count=None,
messages=[
AIMessage(role="assistant", content="q"),
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
],
)
resp = ai_frame(req)
assert resp.done is True
assert resp.result is not None
assert resp.result.decision_type == "individual"
assert resp.result.process == "consultation_avis"
def test_a6_large_group_gives_collective():
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type == "collective"
# ---------------------------------------------------------------------------
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
# ---------------------------------------------------------------------------
def test_a7_no_context_same_question_ids():
req_with = DEFAULT_REQUEST
req_without = AIFrameRequest(
context=None,
within_mandate=False,
affected_count=20,
messages=[],
)
ids_with = {q.id for q in ai_frame(req_with).questions}
ids_without = {q.id for q in ai_frame(req_without).questions}
assert ids_with == ids_without