From e2ae8b196ef2e61ba41145fc7b7137b0db1c4845 Mon Sep 17 00:00:00 2001 From: Yvv Date: Thu, 23 Apr 2026 19:44:00 +0200 Subject: [PATCH] =?UTF-8?q?Qualify=20:=20service=20IA=20+=20endpoint=20ai-?= =?UTF-8?q?chat=20+=20wizard=20D=C3=A9cider=20(3=20cercles=20+=20AI=202-ro?= =?UTF-8?q?unds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/routers/qualify.py | 76 ++ backend/app/services/qualify_ai_service.py | 205 +++++ backend/app/tests/test_qualifier_ai.py | 172 ++++ frontend/app/pages/decisions/new.vue | 977 ++++++++++++--------- 4 files changed, 1018 insertions(+), 412 deletions(-) create mode 100644 backend/app/services/qualify_ai_service.py create mode 100644 backend/app/tests/test_qualifier_ai.py diff --git a/backend/app/routers/qualify.py b/backend/app/routers/qualify.py index 2f5fb97..a01dca5 100644 --- a/backend/app/routers/qualify.py +++ b/backend/app/routers/qualify.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import asdict from fastapi import APIRouter, Depends +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,10 +19,59 @@ from app.schemas.qualification import ( QualifyResponse, ) from app.services.auth_service import get_current_identity +from app.services.qualify_ai_service import ( + AIFrameRequest, + AIFrameResponse, + AIMessage, + AIQuestion, + AIQualifyResult, + ai_frame, +) router = APIRouter() +# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ── + + +class AIMessagePayload(BaseModel): + role: str + content: str + + +class AIChatRequest(BaseModel): + within_mandate: bool = False + affected_count: int | None = None + is_structural: bool = False + context: str | None = None + messages: list[AIMessagePayload] = [] + + +class AIQuestionOut(BaseModel): + id: str + text: str + options: list[str] + + +class AIQualifyResultOut(BaseModel): + decision_type: str + process: str + recommended_modalities: list[str] + recommend_onchain: bool + onchain_reason: str | None + confidence: str + collective_available: bool + record_in_observatory: bool + reasons: list[str] + + +class AIChatResponse(BaseModel): + done: bool + questions: list[AIQuestionOut] = [] + result: AIQualifyResultOut | None = None + explanation: str | None = None + + async def _load_config(db: AsyncSession) -> QualificationConfig: """Load the active QualificationProtocol from DB, or fall back to defaults.""" result = await db.execute( @@ -61,6 +111,32 @@ async def qualify_decision( return QualifyResponse(**asdict(result)) +@router.post("/ai-chat", response_model=AIChatResponse) +async def ai_chat(payload: AIChatRequest) -> AIChatResponse: + """Run one round of AI-assisted qualification framing. + + Round 1 (messages=[]) → returns 2 clarifying questions. + Round 2 (messages set) → returns final qualification result. + + No auth required — advisory endpoint. + """ + req = AIFrameRequest( + within_mandate=payload.within_mandate, + affected_count=payload.affected_count, + is_structural=payload.is_structural, + context=payload.context, + messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages], + ) + resp = ai_frame(req) + + return AIChatResponse( + done=resp.done, + questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions], + result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None, + explanation=resp.explanation, + ) + + @router.get("/protocol", response_model=QualificationProtocolOut | None) async def get_active_protocol( db: AsyncSession = Depends(get_db), diff --git a/backend/app/services/qualify_ai_service.py b/backend/app/services/qualify_ai_service.py new file mode 100644 index 0000000..d6d2cf9 --- /dev/null +++ b/backend/app/services/qualify_ai_service.py @@ -0,0 +1,205 @@ +"""AI framing service for decision qualification. + +Orchestrates a 2-round conversation that clarifies reversibility and urgency +before producing a final QualificationResult. + +Currently a rule-based stub — will be replaced by Qwen3.6 (MacStudio) calls +once the local LLM endpoint is available. The interface is stable: callers +always receive AIFrameResponse; the underlying engine is swappable. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Schemas (dataclasses — no Pydantic dependency in the engine layer) +# --------------------------------------------------------------------------- + + +@dataclass +class AIMessage: + role: str # "user" | "assistant" + content: str + + +@dataclass +class AIQuestion: + id: str + text: str + options: list[str] + + +@dataclass +class AIQualifyResult: + decision_type: str + process: str + recommended_modalities: list[str] + recommend_onchain: bool + onchain_reason: str | None + confidence: str + collective_available: bool + record_in_observatory: bool + reasons: list[str] + + +@dataclass +class AIFrameRequest: + within_mandate: bool = False + affected_count: int | None = None + is_structural: bool = False + context: str | None = None + messages: list[AIMessage] | None = None + + def __post_init__(self): + if self.messages is None: + self.messages = [] + + +@dataclass +class AIFrameResponse: + done: bool + questions: list[AIQuestion] + result: AIQualifyResult | None + explanation: str | None + + +# --------------------------------------------------------------------------- +# Standard clarifying questions (stub — same regardless of context) +# Real Qwen integration will generate context-aware questions +# --------------------------------------------------------------------------- + + +_CLARIFYING_QUESTIONS: list[AIQuestion] = [ + AIQuestion( + id="reversibility", + text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?", + options=[ + "Oui, facilement", + "Difficilement", + "Non, c'est irréversible", + ], + ), + AIQuestion( + id="urgency", + text="Y a-t-il une contrainte temporelle sur cette décision ?", + options=[ + "Urgente (< 1 semaine)", + "Délai raisonnable (quelques semaines)", + "Pas d'urgence", + ], + ), +] + + +# --------------------------------------------------------------------------- +# Core function +# --------------------------------------------------------------------------- + + +def ai_frame(request: AIFrameRequest) -> AIFrameResponse: + """Run one round of AI framing. + + Round 1 (messages=[]) → return 2 clarifying questions, done=False + Round 2 (messages set) → parse answers, qualify, return result, done=True + """ + messages = request.messages or [] + + # ── Round 1: no conversation yet ──────────────────────────────────────── + if not messages: + return AIFrameResponse( + done=False, + questions=list(_CLARIFYING_QUESTIONS), + result=None, + explanation=None, + ) + + # ── Round 2: answers present → qualify ────────────────────────────────── + answers = _parse_answers(messages) + result = _build_result(request, answers) + explanation = _build_explanation(answers) + + return AIFrameResponse( + done=True, + questions=[], + result=result, + explanation=explanation, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_answers(messages: list[AIMessage]) -> dict[str, str]: + """Extract question answers from the last user message. + + Expected format: "reversibility:|urgency:" + Anything not matching is treated as free text for context. + """ + answers: dict[str, str] = {} + for msg in reversed(messages): + if msg.role == "user" and "|" in msg.content and ":" in msg.content: + for part in msg.content.split("|"): + if ":" in part: + key, _, val = part.partition(":") + answers[key.strip()] = val.strip() + break + return answers + + +def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult: + """Produce a qualification result enriched by the AI answers.""" + from app.engine.qualifier import ( + QualificationConfig, + QualificationInput, + qualify, + ) + + config = QualificationConfig() + inp = QualificationInput( + within_mandate=request.within_mandate, + affected_count=request.affected_count, + is_structural=request.is_structural, + context_description=request.context, + ) + base = qualify(inp, config) + + reasons = list(base.reasons) + + # Reversibility adjustment + reversibility = answers.get("reversibility", "") + if "irréversible" in reversibility.lower(): + reasons.append("Décision irréversible : consensus élevé recommandé.") + if not base.recommend_onchain and request.is_structural: + pass # already handled by engine + + # Urgency note + urgency = answers.get("urgency", "") + if "urgente" in urgency.lower() or "< 1" in urgency: + reasons.append("Urgence signalée : privilégier un protocole à délai court.") + + return AIQualifyResult( + decision_type=base.decision_type.value, + process=base.process, + recommended_modalities=base.recommended_modalities, + recommend_onchain=base.recommend_onchain, + onchain_reason=base.onchain_reason, + confidence=base.confidence, + collective_available=base.collective_available, + record_in_observatory=base.record_in_observatory, + reasons=reasons, + ) + + +def _build_explanation(answers: dict[str, str]) -> str: + parts = [] + rev = answers.get("reversibility", "") + urg = answers.get("urgency", "") + if rev: + parts.append(f"Réversibilité : {rev}.") + if urg: + parts.append(f"Urgence : {urg}.") + return " ".join(parts) if parts else "Qualification basée sur les éléments fournis." diff --git a/backend/app/tests/test_qualifier_ai.py b/backend/app/tests/test_qualifier_ai.py new file mode 100644 index 0000000..a2de561 --- /dev/null +++ b/backend/app/tests/test_qualifier_ai.py @@ -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 diff --git a/frontend/app/pages/decisions/new.vue b/frontend/app/pages/decisions/new.vue index 7dd0f2f..8118068 100644 --- a/frontend/app/pages/decisions/new.vue +++ b/frontend/app/pages/decisions/new.vue @@ -3,23 +3,34 @@ import type { DecisionCreate } from '~/stores/decisions' const decisions = useDecisionsStore() const protocols = useProtocolsStore() +const mandates = useMandatesStore() const { $api } = useApi() -// ── Wizard state ───────────────────────────────────────────────────────────── -type WizardStep = 'qualify' | 'result' | 'form' - +// ── Wizard steps ────────────────────────────────────────────────────────────── +type WizardStep = 'qualify' | 'ai-questions' | 'result' | 'form' const wizardStep = ref('qualify') -const qualifying = ref(false) -const submitting = ref(false) // ── Qualification inputs ────────────────────────────────────────────────────── const withinMandate = ref(null) -const affectedCount = ref(null) const isStructural = ref(false) const contextDescription = ref('') -// ── Qualification result (from /api/v1/qualify) ─────────────────────────────── -interface QualifyResult { +// Cercles de personnes concernées +const circle1 = ref('') // 1er cercle — nommés explicitement (requis hors mandat) +const circle2 = ref('') // 2e cercle — optionnel +const circle3Open = ref(true) // 3e cercle — toutes personnes se sentant concernées + +// Affected count derivé des cercles +const affectedCountFromCircles = computed(() => { + const c1 = circle1.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length + const c2 = circle2.value.split(/[,;\n]/).map(s => s.trim()).filter(Boolean).length + const base = c1 + c2 + return base >= 2 ? base : (base === 1 ? 2 : null) // minimum 2 +}) + +// ── AI conversation ─────────────────────────────────────────────────────────── +interface AIQuestion { id: string; text: string; options: string[] } +interface AIResult { decision_type: 'individual' | 'collective' process: string recommended_modalities: string[] @@ -30,12 +41,25 @@ interface QualifyResult { record_in_observatory: boolean reasons: string[] } +interface AIChatResponse { + done: boolean + questions: AIQuestion[] + result: AIResult | null + explanation: string | null +} -const qualifyResult = ref(null) +const aiQuestions = ref([]) +const aiAnswers = ref>({}) +const aiExplanation = ref(null) +const aiMessages = ref<{ role: string; content: string }[]>([]) + +const qualifying = ref(false) +const submitting = ref(false) const qualifyError = ref(null) - -// ── Decision form ───────────────────────────────────────────────────────────── +const qualifyResult = ref(null) const chosenType = ref<'individual' | 'collective' | null>(null) + +// ── Form ────────────────────────────────────────────────────────────────────── const formData = ref({ title: '', description: '', @@ -44,57 +68,124 @@ const formData = ref({ voting_protocol_id: null, }) -// ── Modality labels ─────────────────────────────────────────────────────────── -const MODALITY_LABELS: Record = { +// ── Mandates list (active) ──────────────────────────────────────────────────── +const activeMandates = computed(() => + mandates.list.filter(m => ['active', 'voting'].includes(m.status)) +) + +onMounted(() => { + mandates.fetchAll() +}) + +// ── Modality metadata ───────────────────────────────────────────────────────── +const MODALITY_META: Record = { vote_wot: { icon: 'i-lucide-users', title: 'Vote WoT', - desc: 'Seuil adaptatif selon la taille de la Toile de Confiance et la participation', + desc: 'Seuil adaptatif selon la taille de la Toile de Confiance et la participation réelle.', + color: 'var(--mood-accent)', }, vote_smith: { icon: 'i-lucide-hammer', title: 'Vote Smith', - desc: 'Implique le sous-ensemble des Forgerons (Smith criterion)', + desc: 'Implique le sous-ensemble des Forgerons (Smith criterion).', + color: 'var(--mood-secondary, var(--mood-accent))', }, consultation_avis: { icon: 'i-lucide-message-circle', title: 'Consultation d\'avis', - desc: 'Recueil d\'avis non contraignant — la décision reste individuelle', + desc: 'Recueil d\'avis non contraignant — la décision reste individuelle.', + color: 'var(--mood-tertiary, var(--mood-accent))', }, election: { icon: 'i-lucide-vote', title: 'Élection', - desc: 'Désignation d\'une personne par le collectif', + desc: 'Désignation d\'une personne ou d\'une option par le collectif.', + color: 'var(--mood-success)', }, } - -function modalityLabel(slug: string) { - return MODALITY_LABELS[slug] ?? { icon: 'i-lucide-circle', title: slug, desc: '' } +function modalityMeta(slug: string) { + return MODALITY_META[slug] ?? { icon: 'i-lucide-circle', title: slug, desc: '', color: 'var(--mood-accent)' } } -// ── Step 1 : Qualify ────────────────────────────────────────────────────────── -const canQualify = computed(() => withinMandate.value !== null) +// ── Validation ──────────────────────────────────────────────────────────────── +const canQualify = computed(() => { + if (withinMandate.value === null) return false + if (withinMandate.value === false) { + // Need at least one name in circle 1 + const c1 = circle1.value.trim() + return c1.length > 0 + } + return true +}) -async function runQualify() { +// ── Step 1 : lance la conversation AI ──────────────────────────────────────── +async function startQualify() { if (!canQualify.value) return qualifying.value = true qualifyError.value = null + aiMessages.value = [] + aiAnswers.value = {} try { - const payload: Record = { - within_mandate: withinMandate.value, - is_structural: isStructural.value, - } - if (withinMandate.value === false && affectedCount.value !== null) { - payload.affected_count = affectedCount.value - } - if (contextDescription.value.trim()) { - payload.context_description = contextDescription.value.trim() - } - qualifyResult.value = await $api('/qualify/', { + const resp = await $api('/qualify/ai-chat', { method: 'POST', - body: payload, + body: { + within_mandate: withinMandate.value, + affected_count: affectedCountFromCircles.value, + is_structural: isStructural.value, + context: contextDescription.value.trim() || null, + messages: [], + }, }) - chosenType.value = qualifyResult.value.decision_type + if (resp.done) { + qualifyResult.value = resp.result + aiExplanation.value = resp.explanation + chosenType.value = resp.result?.decision_type ?? null + wizardStep.value = 'result' + } else { + aiQuestions.value = resp.questions + // Pré-sélectionner la 1ère option pour chaque question + for (const q of resp.questions) { + if (q.options.length > 0) aiAnswers.value[q.id] = q.options[0] + } + wizardStep.value = 'ai-questions' + } + } catch (err: any) { + qualifyError.value = err?.message ?? 'Erreur lors de la qualification' + } finally { + qualifying.value = false + } +} + +// ── Step 2 : envoie les réponses AI ────────────────────────────────────────── +async function submitAiAnswers() { + qualifying.value = true + qualifyError.value = null + try { + // Build answer message + const answerContent = Object.entries(aiAnswers.value) + .map(([id, val]) => `${id}:${val}`) + .join('|') + const messages = [ + ...aiMessages.value, + ...aiQuestions.value.map(q => ({ role: 'assistant', content: q.text })), + { role: 'user', content: answerContent }, + ] + aiMessages.value = messages + + const resp = await $api('/qualify/ai-chat', { + method: 'POST', + body: { + within_mandate: withinMandate.value, + affected_count: affectedCountFromCircles.value, + is_structural: isStructural.value, + context: contextDescription.value.trim() || null, + messages, + }, + }) + qualifyResult.value = resp.result + aiExplanation.value = resp.explanation + chosenType.value = resp.result?.decision_type ?? null wizardStep.value = 'result' } catch (err: any) { qualifyError.value = err?.message ?? 'Erreur lors de la qualification' @@ -103,27 +194,34 @@ async function runQualify() { } } -// ── Step 2 : Result → user confirms or adjusts ─────────────────────────────── +// ── Choix de la modalité ────────────────────────────────────────────────────── function chooseType(type: 'individual' | 'collective') { chosenType.value = type - formData.value.decision_type = type === 'collective' ? 'other' : 'other' protocols.fetchProtocols() wizardStep.value = 'form' } -// ── Step 3 : Submit ─────────────────────────────────────────────────────────── +// ── Soumission de la décision ───────────────────────────────────────────────── async function onSubmit() { if (!formData.value.title.trim()) return submitting.value = true try { + const circlesContext = [ + circle1.value.trim() ? `Cercle 1 (directement concernés) : ${circle1.value.trim()}` : '', + circle2.value.trim() ? `Cercle 2 (indirectement concernés) : ${circle2.value.trim()}` : '', + circle3Open.value ? 'Cercle 3 : ouvert à toute personne se sentant concernée.' : '', + ].filter(Boolean).join('\n') + const decision = await decisions.create({ ...formData.value, - context: (contextDescription.value ? `[Contexte]\n${contextDescription.value}\n\n` : '') - + (formData.value.context ?? ''), + context: [ + contextDescription.value.trim(), + circlesContext, + formData.value.context ?? '', + ].filter(Boolean).join('\n\n'), }) if (decision) navigateTo(`/decisions/${decision.id}`) } catch { - // handled by store } finally { submitting.value = false } @@ -131,12 +229,9 @@ async function onSubmit() { // ── Navigation ──────────────────────────────────────────────────────────────── function goBack() { - if (wizardStep.value === 'form') { - wizardStep.value = 'result' - } else if (wizardStep.value === 'result') { - wizardStep.value = 'qualify' - qualifyResult.value = null - } + if (wizardStep.value === 'form') wizardStep.value = 'result' + else if (wizardStep.value === 'result') wizardStep.value = withinMandate.value ? 'qualify' : 'ai-questions' + else if (wizardStep.value === 'ai-questions') { wizardStep.value = 'qualify'; qualifyResult.value = null } } const confidenceLabel: Record = { @@ -162,11 +257,11 @@ const confidenceLabel: Record = { - +

Quelle décision prendre ?

-

Qualifiez d'abord la situation — le parcours s'adapte

+

Qualifiez la situation — l'IA détermine le parcours adapté

@@ -175,127 +270,220 @@ const confidenceLabel: Record = {

Cette décision entre-t-elle dans le périmètre d'un mandat existant ?

- -
- + -
-

Combien de personnes sont concernées ou impactées ?

-
- - personnes (minimum 2 — si seulement vous, cette décision sort du périmètre de l'outil) +
+

+ Sélectionnez le mandat concerné ou demandez-en un nouveau. +

+ + +
+ +
+ +
+
+ {{ m.title }} + {{ m.mandate_type }} +
+ +
-
- - - +

+ Aucun mandat actif pour l'espace de travail sélectionné. +

+ + + + Demander un mandat + +
+ + + + +
+ + +
+
+ 1 +

Premier cercle — personnes directement concernées *

+
+