From ca9abedbe004b788fd0cda2bf97f5ab9f2c1487d Mon Sep 17 00:00:00 2001 From: camilleAND Date: Mon, 8 Dec 2025 15:34:07 +0100 Subject: [PATCH] test plan mode options --- src/backend/chat/clients/pydantic_ai.py | 42 ++++- src/backend/chat/serializers.py | 5 + ...st_chat_conversation_request_serializer.py | 22 +++ src/backend/chat/views.py | 9 +- .../configuration/llm/default.json | 40 ++++- .../src/features/chat/api/useChat.tsx | 6 +- .../src/features/chat/components/Chat.tsx | 147 ++++++++++++++++++ .../features/chat/components/InputChat.tsx | 88 +++++++++++ .../chat/stores/useChatPreferencesStore.ts | 5 + 9 files changed, 348 insertions(+), 16 deletions(-) diff --git a/src/backend/chat/clients/pydantic_ai.py b/src/backend/chat/clients/pydantic_ai.py index 0d4163f..7b35cc2 100644 --- a/src/backend/chat/clients/pydantic_ai.py +++ b/src/backend/chat/clients/pydantic_ai.py @@ -157,13 +157,21 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes # Public streaming API (unchanged signatures) # --------------------------------------------------------------------- # - def stream_text(self, messages: List[UIMessage], force_web_search: bool = False): + def stream_text( + self, messages: List[UIMessage], force_web_search: bool = False, force_plan: bool = False + ): """Return only the assistant text deltas (legacy text mode).""" - return convert_async_generator_to_sync(self.stream_text_async(messages, force_web_search)) + return convert_async_generator_to_sync( + self.stream_text_async(messages, force_web_search, force_plan) + ) - def stream_data(self, messages: List[UIMessage], force_web_search: bool = False): + def stream_data( + self, messages: List[UIMessage], force_web_search: bool = False, force_plan: bool = False + ): """Return Vercel-AI-SDK formatted events.""" - return convert_async_generator_to_sync(self.stream_data_async(messages, force_web_search)) + return convert_async_generator_to_sync( + self.stream_data_async(messages, force_web_search, force_plan) + ) def stop_streaming(self): """ @@ -178,7 +186,9 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes # Async internals # --------------------------------------------------------------------- # - async def stream_text_async(self, messages: List[UIMessage], force_web_search: bool = False): + async def stream_text_async( + self, messages: List[UIMessage], force_web_search: bool = False, force_plan: bool = False + ): """Return only the assistant text deltas (legacy text mode).""" await self._clean() with ExitStack() as stack: @@ -186,18 +196,20 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes span = stack.enter_context(get_client().start_as_current_span(name="conversation")) span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk)) - async for event in self._run_agent(messages, force_web_search): + async for event in self._run_agent(messages, force_web_search, force_plan): if stream_text := self.event_encoder.encode_text(event): yield stream_text - async def stream_data_async(self, messages: List[UIMessage], force_web_search: bool = False): + async def stream_data_async( + self, messages: List[UIMessage], force_web_search: bool = False, force_plan: bool = False + ): """Return Vercel-AI-SDK formatted events.""" await self._clean() with ExitStack() as stack: if self._langfuse_available: span = stack.enter_context(get_client().start_as_current_span(name="conversation")) span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk)) - async for event in self._run_agent(messages, force_web_search): + async for event in self._run_agent(messages, force_web_search, force_plan): if stream_data := self.event_encoder.encode(event): yield stream_data @@ -355,6 +367,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes self, messages: List[UIMessage], force_web_search: bool = False, + force_plan: bool = False, ) -> events_v4.Event | events_v5.Event: """Run the Pydantic AI agent and stream events.""" if messages[-1].role != "user": @@ -465,6 +478,19 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes "before answering the user request." ) + if force_plan: + + @self.conversation_agent.system_prompt + def force_plan_prompt() -> str: + """Dynamic system prompt function to force upfront planning.""" + return ( + "Planning mode is enabled for this request. Start by proposing a concise " + "numbered plan (maximum 8 steps) tailored to the user's goal. If you plan " + "to use tools, tell the user which tools without using explicitly the tools names but tell the user the arguments you plan to use. Ask the user " + "to confirm or adjust the plan, and do not proceed with any execution until " + "the plan is approved. After approval, follow the agreed steps." + ) + _tool_is_streaming = False _model_response_message_id = None diff --git a/src/backend/chat/serializers.py b/src/backend/chat/serializers.py index add4e5f..e098fce 100644 --- a/src/backend/chat/serializers.py +++ b/src/backend/chat/serializers.py @@ -72,6 +72,11 @@ class ChatConversationRequestSerializer(serializers.Serializer): default=False, help_text="Force web search.", ) + force_plan = serializers.BooleanField( + required=False, + default=False, + help_text="Force the model to propose a plan before answering.", + ) model_hrid = serializers.CharField( required=False, default=None, diff --git a/src/backend/chat/tests/serializers/test_chat_conversation_request_serializer.py b/src/backend/chat/tests/serializers/test_chat_conversation_request_serializer.py index 92f6294..3783785 100644 --- a/src/backend/chat/tests/serializers/test_chat_conversation_request_serializer.py +++ b/src/backend/chat/tests/serializers/test_chat_conversation_request_serializer.py @@ -37,6 +37,7 @@ def test_chat_conversation_request_serializer_default(): assert serializer.is_valid() assert serializer.validated_data == { "force_web_search": False, + "force_plan": False, "model_hrid": None, "protocol": "data", } @@ -93,6 +94,27 @@ def test_chat_conversation_request_serializer_force_web_search_invalid(): } +@pytest.mark.parametrize("force_plan", [True, False]) +def test_chat_conversation_request_serializer_force_plan_valid(force_plan): + """ + Test that the serializer accepts valid boolean values for force_plan. + """ + serializer = serializers.ChatConversationRequestSerializer(data={"force_plan": force_plan}) + assert serializer.is_valid() + assert serializer.validated_data["force_plan"] == force_plan + + +def test_chat_conversation_request_serializer_force_plan_invalid(): + """ + Test that the serializer rejects non-boolean values for force_plan. + """ + serializer = serializers.ChatConversationRequestSerializer(data={"force_plan": "invalid"}) + assert not serializer.is_valid() + assert serializer.errors == { + "force_plan": [ErrorDetail(string="Must be a valid boolean.", code="invalid")] + } + + def test_chat_conversation_request_serializer_model_hrid_valid(llm_configuration): # pylint: disable=unused-argument """ Test that the serializer accepts a valid model_hrid. diff --git a/src/backend/chat/views.py b/src/backend/chat/views.py index 0437237..ecb02e2 100644 --- a/src/backend/chat/views.py +++ b/src/backend/chat/views.py @@ -144,6 +144,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method query_params_serializer.is_valid(raise_exception=True) protocol = query_params_serializer.validated_data["protocol"] force_web_search = query_params_serializer.validated_data["force_web_search"] + force_plan = query_params_serializer.validated_data["force_plan"] model_hrid = query_params_serializer.validated_data["model_hrid"] logger.info("Received messages: %s", request.data.get("messages", [])) @@ -189,21 +190,21 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method logger.debug("Using ASYNC streaming for chat conversation.") if protocol == "data": streaming_content = ai_service.stream_data_async( - messages, force_web_search=force_web_search + messages, force_web_search=force_web_search, force_plan=force_plan ) else: # Default to 'text' protocol streaming_content = ai_service.stream_text_async( - messages, force_web_search=force_web_search + messages, force_web_search=force_web_search, force_plan=force_plan ) else: logger.debug("Using SYNC streaming for chat conversation.") if protocol == "data": streaming_content = ai_service.stream_data( - messages, force_web_search=force_web_search + messages, force_web_search=force_web_search, force_plan=force_plan ) else: # Default to 'text' protocol streaming_content = ai_service.stream_text( - messages, force_web_search=force_web_search + messages, force_web_search=force_web_search, force_plan=force_plan ) response = StreamingHttpResponse( diff --git a/src/backend/conversations/configuration/llm/default.json b/src/backend/conversations/configuration/llm/default.json index d77bc4b..3db19c6 100644 --- a/src/backend/conversations/configuration/llm/default.json +++ b/src/backend/conversations/configuration/llm/default.json @@ -5,7 +5,11 @@ "model_name": "settings.AI_MODEL", "human_readable_name": "Default Model", "provider_name": "default-provider", - "profile": null, + "profile": { + "openai_supports_strict_tool_definition": false, + "openai_supports_tool_choice_required": false + }, + "supports_streaming": false, "settings": {}, "is_active": true, "icon": [ @@ -24,12 +28,36 @@ "model_name": "settings.AI_MODEL", "human_readable_name": "Default Summarization Model", "provider_name": "default-provider", - "profile": null, + "profile": { + "openai_supports_strict_tool_definition": false, + "openai_supports_tool_choice_required": false + }, + "supports_streaming": false, "settings": {}, "is_active": true, "icon": null, "system_prompt": "settings.SUMMARIZATION_SYSTEM_PROMPT", "tools": [] + }, + { + "hrid": "etalab-plateform-mistral-medium-2508", + "model_name": "mistral-medium-2508", + "human_readable_name": "Mistral Medium 2508 (Plateforme Etalab)", + "provider_name": "mistral-plateform-etalab", + "profile": null, + "supports_streaming": false, + "settings": {}, + "is_active": true, + "icon": [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAz", + "iABUAGIWbpsTwq7HhAAAAI4dle7DrdX4AJohRaaboXWj7+/zn6On5//9NZaT29vfoWmVHYKDoUl/k5OUAIYddc6vpbHYCM47Y3+v53+LiFCUA", + "HIWnsckYPJHi6PL77O7jJjW3wdf1w8jre4QgQ5TZ2txwg7Pr3+I8WZ6OnsTuoamClL7tlZ5xz5y8AAAAzUlEQVR4AZ3RRQKDQBBEUSTu7h5c4", + "vc/W6Yp3KG2Dz4ynDdeEBvOmq12xx2E1u0B+4NOEocj4DgNJ1PgLAvni8WyBq5Yc71ubFJx23C2q4P7dRYejg1xzvCUgvz5guz11k7gXYKF/1", + "8oyiYuvHAYeVkhXCzolVStHcGDjiQzNmMQxsMI5rEJRdQSPZvbpE2E8aY6gC6Z+2Hg4dFA0Yb4YedNL/v4Fk8WJuwiGhrChJNXI210rnib9Fs", + "JlXRUC/HwTscPIXf/iklq/tjb/gHAdxkCUjAg2QAAAABJRU5ErkJggg==" + ], + "system_prompt": "settings.AI_AGENT_INSTRUCTIONS", + "tools": "settings.AI_AGENT_TOOLS" } ], "providers": [ @@ -38,6 +66,12 @@ "base_url": "settings.AI_BASE_URL", "api_key": "settings.AI_API_KEY", "kind": "openai" + }, + { + "hrid": "mistral-plateform-etalab", + "base_url": "https://api.mistral.etalab.gouv.fr/", + "api_key": "environ.MISTRAL_ETALAB_API_KEY", + "kind": "mistral" } ] -} +} \ No newline at end of file diff --git a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx index cf3dfb2..3cc14e2 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useChat.tsx @@ -17,7 +17,7 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => { const searchParams = new URLSearchParams(); - const { forceWebSearch, selectedModelHrid } = + const { forceWebSearch, selectedModelHrid, forcePlanMode } = useChatPreferencesStore.getState(); if (forceWebSearch) { @@ -28,6 +28,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => { searchParams.append('model_hrid', selectedModelHrid); } + if (forcePlanMode) { + searchParams.append('force_plan', 'true'); + } + if (searchParams.toString()) { const separator = url.includes('?') ? '&' : '?'; url = `${url}${separator}${searchParams.toString()}`; diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index fa3674a..b47b10c 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -62,6 +62,8 @@ export const Chat = ({ const { forceWebSearch, toggleForceWebSearch, + forcePlanMode, + toggleForcePlanMode, selectedModelHrid, setSelectedModelHrid, } = useChatPreferencesStore(); @@ -229,6 +231,32 @@ export const Chat = ({ onError: onErrorChat, }); + const [dismissedPlanForAssistantId, setDismissedPlanForAssistantId] = useState(null); + const [planAcceptPending, setPlanAcceptPending] = useState(false); + const [overlayLockedForUserId, setOverlayLockedForUserId] = useState(null); + const [planAcceptedOnce, setPlanAcceptedOnce] = useState(false); + const lastMessage = messages[messages.length - 1]; + const lastAssistantId = + messages + .slice() + .reverse() + .find((m) => m.role === 'assistant')?.id || null; + const lastAssistantIndex = messages.findLastIndex((m) => m.role === 'assistant'); + const lastUserIndex = messages.findLastIndex((m) => m.role === 'user'); + const lastUserId = + messages + .slice() + .reverse() + .find((m) => m.role === 'user')?.id || null; + const canShowForUser = lastUserId && overlayLockedForUserId !== lastUserId; + const showPlanOverlay = + forcePlanMode && + lastAssistantId !== null && + lastAssistantIndex > lastUserIndex && + dismissedPlanForAssistantId !== lastAssistantId && + canShowForUser && + !planAcceptedOnce; + const stopGeneration = async () => { stopChat(); @@ -248,6 +276,10 @@ export const Chat = ({ toggleForceWebSearch(); }; + const togglePlanning = () => { + toggleForcePlanMode(); + }; + const handleStop = () => { void stopGeneration(); }; @@ -280,6 +312,46 @@ export const Chat = ({ }; // Précharger les métadonnées des sources dès que les messages arrivent + const handlePlanAccept = () => { + if (lastAssistantId) { + setDismissedPlanForAssistantId(lastAssistantId); + } + if (lastUserId) { + setOverlayLockedForUserId(lastUserId); + } + setPlanAcceptedOnce(true); + handleInputChange({ + target: { value: t('Plan accepté') }, + } as ChangeEvent); + // Laisser React mettre à jour l'input puis déclencher l'envoi dès que le statut est prêt. + setPlanAcceptPending(true); + }; + + useEffect(() => { + if (planAcceptPending && status === 'ready') { + const form = document.createElement('form'); + const syntheticFormEvent = { + preventDefault: () => {}, + target: form, + currentTarget: form, + } as unknown as FormEvent; + void handleSubmitWrapper(syntheticFormEvent); + setPlanAcceptPending(false); + if (lastAssistantId) { + setDismissedPlanForAssistantId(lastAssistantId); + } + } + }, [planAcceptPending, status, handleSubmitWrapper, lastAssistantId]); + + useEffect(() => { + // When a new user message arrives, re-authorize overlay for that turn. + if (lastMessage?.role === 'user' && lastUserId) { + setOverlayLockedForUserId(null); + setDismissedPlanForAssistantId(null); + setPlanAcceptPending(false); + } + }, [lastMessage, lastUserId]); + useEffect(() => { messages.forEach((message) => { if (message.parts) { @@ -625,6 +697,7 @@ export const Chat = ({ flex-basis: auto; height: 100%; flex-grow: 1; + position: relative; `} > )} + + {showPlanOverlay && lastAssistantId && ( + + + + {!isMobile && ( + + {t('Plan prêt — acceptez pour continuer')} + + )} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePlanAccept(); + } + }} + role="button" + tabIndex={0} + > + + + {t('Accept')} + + + + + )} ; forceWebSearch?: boolean; onToggleWebSearch?: () => void; + planModeEnabled?: boolean; + onTogglePlanMode?: () => void; onStop?: () => void; selectedModel?: LLMModel | null; onModelSelect?: (model: LLMModel) => void; @@ -46,6 +48,8 @@ export const InputChat = ({ containerRef, forceWebSearch = false, onToggleWebSearch, + planModeEnabled = false, + onTogglePlanMode, onStop, selectedModel, onModelSelect, @@ -602,6 +606,90 @@ export const InputChat = ({ )} + {onTogglePlanMode && ( + + + + )} + {onToggleWebSearch && ( void; toggleForceWebSearch: () => void; + toggleForcePlanMode: () => void; setPanelOpen: (isOpen: boolean) => void; togglePanel: () => void; } @@ -16,10 +18,13 @@ export const useChatPreferencesStore = create()( (set) => ({ selectedModelHrid: null, forceWebSearch: false, + forcePlanMode: false, isPanelOpen: false, setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }), toggleForceWebSearch: () => set((state) => ({ forceWebSearch: !state.forceWebSearch })), + toggleForcePlanMode: () => + set((state) => ({ forcePlanMode: !state.forcePlanMode })), setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }), togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), }),