"""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