From 66c14b27f1526fcde0d6c9a4f32eb0dbddfce112 Mon Sep 17 00:00:00 2001 From: camilleAND Date: Fri, 24 Oct 2025 15:21:35 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D(service-public):=20style=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/chat/clients/pydantic_ai.py | 38 ++-- src/backend/chat/tools/service_public.py | 31 +++- .../src/features/chat/components/Chat.tsx | 2 +- .../features/chat/components/InputChat.tsx | 5 + .../features/chat/components/SourceItem.tsx | 29 ++- .../chat/components/SourceItemList.tsx | 28 ++- .../features/chat/components/ToolSelector.tsx | 165 ++++++++++++++++++ 7 files changed, 260 insertions(+), 38 deletions(-) create mode 100644 src/frontend/apps/conversations/src/features/chat/components/ToolSelector.tsx diff --git a/src/backend/chat/clients/pydantic_ai.py b/src/backend/chat/clients/pydantic_ai.py index 2fd40b1..3d0ea9c 100644 --- a/src/backend/chat/clients/pydantic_ai.py +++ b/src/backend/chat/clients/pydantic_ai.py @@ -428,7 +428,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes "If the user request relates to French public services, laws or" " administrative topics, you MUST call the 'service_public' tool" " before answering. Use it to retrieve relevant passages and then" - " answer the user. When using this tool, end your answer with sources urls if present in the tool response." + " answer the user." ) conversation_has_documents = self._is_document_upload_enabled and ( @@ -691,19 +691,29 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes if event.result.metadata and ( sources := event.result.metadata.get("sources") ): - for source_url in sources: - url_source = LanguageModelV1Source( - sourceType="url", - id=str(uuid.uuid4()), - url=source_url, - providerMetadata={}, - ) - _new_source_ui = SourceUIPart( - type="source", source=url_source - ) - _ui_sources.append(_new_source_ui) - yield events_v4.SourcePart( - **_new_source_ui.source.model_dump() + for source_item in sources: + # Handle both old format (string) and new format (dict) + if isinstance(source_item, dict): + source_url = source_item.get("url", "") + source_title = source_item.get("title", "") + else: + # Fallback for old string format + source_url = source_item + source_title = "" + + if source_url: + url_source = LanguageModelV1Source( + sourceType="url", + id=str(uuid.uuid4()), + url=source_url, + providerMetadata={"title": source_title} if source_title else {}, + ) + _new_source_ui = SourceUIPart( + type="source", source=url_source + ) + _ui_sources.append(_new_source_ui) + yield events_v4.SourcePart( + **_new_source_ui.source.model_dump() ) yield events_v4.ToolResultPart( diff --git a/src/backend/chat/tools/service_public.py b/src/backend/chat/tools/service_public.py index 36b9718..4a40da7 100644 --- a/src/backend/chat/tools/service_public.py +++ b/src/backend/chat/tools/service_public.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) # Default curated collections (Albert IDs) DEFAULT_COLLECTION_IDS: List[int] = [784, 785] # travail-emploi, service-public -INSTRUCTIONS = "Voilà les informations trouvées, résume les pour répondre à la question de l'utilisateur, à la fin de ta réponse, ajoutes une section sources avec les urls des sources si présentes: \n" +INSTRUCTIONS = "Voilà les informations trouvées, résume les pour répondre à la question de l'utilisateur si c'est pertinent: \n" async def service_public(ctx: RunContext, query: str) -> ToolReturn: """Search curated Service-Public collections on Albert and return snippets. @@ -33,20 +33,29 @@ async def service_public(ctx: RunContext, query: str) -> ToolReturn: # Search in the curated collections rag_results = backend.search(query, collections=DEFAULT_COLLECTION_IDS) - # Convert to compact format for the model using your logic + # Convert to compact format for the model compact = [] sources = [] for result in rag_results.data: # AlbertRagBackend.search() returns RAGWebResult objects with {url, content, score, metadata} + document_name = result.metadata.get("document_name", "Document") + url = result.metadata.get("url", "") + compact.append( { - "title": result.metadata["document_name"], + "title": document_name, "snippet": result.content, - "url": result.metadata["url"], + "url": url, } ) - if result.metadata["url"]: - sources.append(result.metadata["url"]) + + # Create rich source with title and URL + if url: + source_info = { + "title": document_name, + "url": url + } + sources.append(source_info) # Update run usage ctx.usage += RunUsage( @@ -54,10 +63,18 @@ async def service_public(ctx: RunContext, query: str) -> ToolReturn: output_tokens=rag_results.usage.completion_tokens, ) + # Remove duplicate sources based on URL + unique_sources = [] + seen_urls = set() + for source in sources: + if source["url"] not in seen_urls: + unique_sources.append(source) + seen_urls.add(source["url"]) + return ToolReturn( return_value=INSTRUCTIONS + str(compact), content='', - metadata={"sources": list(set(sources))}, + metadata={"sources": unique_sources}, ) except Exception as exc: # pylint: disable=broad-except 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 5318e78..78974e0 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -659,7 +659,7 @@ export const Chat = ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars p: ({ node, ...props }) => ( )} + + + + = ({ url, metadata }) => { > {renderFavicon()} - {new URL(url).hostname} - - - {title} - + {title ? ( + + {title} + + ) : ( + <> + {new URL(url).hostname} + + {title} + + + )} ) : ( {url} diff --git a/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx b/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx index 9788d4a..aa38b36 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx @@ -36,13 +36,27 @@ const SourceItemListComponent: React.FC = ({ overflow: hidden; `} > - {parts.map((part) => ( - - ))} + {parts.map((part) => { + const metadata = getMetadata(part.source.url); + // Extract title from providerMetadata if available + const providerTitle = part.source.providerMetadata?.title; + + return ( + + ); + })} ); }; diff --git a/src/frontend/apps/conversations/src/features/chat/components/ToolSelector.tsx b/src/frontend/apps/conversations/src/features/chat/components/ToolSelector.tsx new file mode 100644 index 0000000..d737949 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/ToolSelector.tsx @@ -0,0 +1,165 @@ +import { Button } from '@openfun/cunningham-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Icon, Text } from '@/components'; +import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; + +interface ToolSelectorProps { + className?: string; +} + +// Define available tools with their display names +const AVAILABLE_TOOLS = [ + { + id: 'service_public', + name: 'Service Public', + icon: 'public', + }, +]; + +export const ToolSelector = ({ className }: ToolSelectorProps) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const { selectedTools, toggleSelectedTool } = useChatPreferencesStore(); + + const handleToolToggle = (toolId: string) => { + toggleSelectedTool(toolId); + }; + + const selectedToolsCount = selectedTools.length; + const hasSelectedTools = selectedToolsCount > 0; + + return ( + + + + + + {isOpen && ( + <> + {/* Backdrop to close the dropdown */} + setIsOpen(false)} + /> + + {/* Dropdown menu */} + + + + {AVAILABLE_TOOLS.map((tool) => { + const isSelected = selectedTools.includes(tool.id); + + return ( + handleToolToggle(tool.id)} + > +
+ + + + {tool.name} + +
+
+ ); + })} +
+
+
+ + )} +
+ ); +}; \ No newline at end of file