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>
This commit is contained in:
172
backend/app/tests/test_qualifier_ai.py
Normal file
172
backend/app/tests/test_qualifier_ai.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user