Compare commits

...

2 Commits

Author SHA1 Message Date
camilleAND b00c26ee03 gitignore 2025-12-08 15:46:27 +01:00
camilleAND ca9abedbe0 test plan mode options 2025-12-08 15:34:07 +01:00
10 changed files with 351 additions and 16 deletions
+3
View File
@@ -79,3 +79,6 @@ db.sqlite3
# Docker compose override
compose.override.yml
# Local development files
src/backend/conversations/configuration/llm/default_dev.json
+34 -8
View File
@@ -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
+5
View File
@@ -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,
@@ -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.
+5 -4
View File
@@ -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(
@@ -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"
}
]
}
}
@@ -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()}`;
@@ -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<string | null>(null);
const [planAcceptPending, setPlanAcceptPending] = useState(false);
const [overlayLockedForUserId, setOverlayLockedForUserId] = useState<string | null>(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<HTMLTextAreaElement>);
// 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<HTMLFormElement>;
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;
`}
>
<Box
@@ -1002,6 +1075,78 @@ export const Chat = ({
/>
)}
</Box>
{showPlanOverlay && lastAssistantId && (
<Box
$css={`
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: ${isMobile ? '110px' : '140px'};
display: flex;
justify-content: center;
pointer-events: none;
z-index: 1400;
`}
>
<Box
$direction="row"
$align="center"
$gap="10px"
$padding={{ horizontal: 'md', vertical: 'xs' }}
$radius="12px"
$background="white"
$css={`
border: 1px solid var(--c--theme--colors--primary-200);
pointer-events: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
max-width: 360px;
width: fit-content;
justify-content: center;
margin: 0 auto;
`}
>
<Icon
iconName="fact_check"
$theme="primary"
$variation="600"
$size="22px"
/>
{!isMobile && (
<Text $theme="primary" $variation="700" $size="sm">
{t('Plan prêt — acceptez pour continuer')}
</Text>
)}
<Box
className="c__button--neutral action-chat-button"
$direction="row"
$align="center"
$gap="6px"
$padding={{ horizontal: 'sm', vertical: 'xs' }}
$css="cursor: pointer;"
onClick={handlePlanAccept}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlanAccept();
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="check"
$theme="primary"
$variation="700"
$size="18px"
/>
<Text $theme="primary" $variation="700">
{t('Accept')}
</Text>
</Box>
</Box>
</Box>
)}
<Box
$css={`
position: relative;
@@ -1028,6 +1173,8 @@ export const Chat = ({
onStop={handleStop}
forceWebSearch={forceWebSearch}
onToggleWebSearch={toggleWebSearch}
planModeEnabled={forcePlanMode}
onTogglePlanMode={togglePlanning}
selectedModel={selectedModel}
onModelSelect={handleModelSelect}
isUploadingFiles={isUploadingFiles}
@@ -28,6 +28,8 @@ interface InputChatProps {
containerRef?: React.RefObject<HTMLDivElement | null>;
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 = ({
)}
</Button>
{onTogglePlanMode && (
<Box
$margin={{ left: '4px' }}
$css={`
${
planModeEnabled
? `
.plan-mode-button {
background-color: var(--c--theme--colors--primary-100) !important;
}
`
: ''
}
`}
>
<Button
size="small"
type="button"
disabled={isUploadingFiles}
onClick={() => {
onTogglePlanMode();
textareaRef.current?.focus();
}}
aria-label={t('Plan tasks first')}
className="c__button--neutral plan-mode-button"
icon={
<Icon
iconName="fact_check"
$theme="greyscale"
$variation="550"
$css={`
color: ${planModeEnabled ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'};
`}
/>
}
>
{!isMobile && (
<Text
$theme={planModeEnabled ? 'primary' : 'greyscale'}
$variation="550"
>
{t('Plan first')}
</Text>
)}
{isMobile && planModeEnabled && (
<Box
$direction="row"
$align="space-between"
$gap="xs"
$css={`
display: flex;
align-items: center;
line-height: 1;
`}
>
<Text
$theme="primary"
$weight="500"
$css={`
display: flex;
align-items: center;
`}
>
{t('Plan')}
</Text>
<Icon
iconName="close"
$variation="text"
$theme="primary"
$size="md"
$css={`
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding-left: 4px;
`}
/>
</Box>
)}
</Button>
</Box>
)}
{onToggleWebSearch && (
<Box
$margin={{ left: '4px' }}
@@ -4,9 +4,11 @@ import { persist } from 'zustand/middleware';
interface ChatPreferencesState {
selectedModelHrid: string | null;
forceWebSearch: boolean;
forcePlanMode: boolean;
isPanelOpen: boolean;
setSelectedModelHrid: (hrid: string | null) => void;
toggleForceWebSearch: () => void;
toggleForcePlanMode: () => void;
setPanelOpen: (isOpen: boolean) => void;
togglePanel: () => void;
}
@@ -16,10 +18,13 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
(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 })),
}),