📝(service-public): style tools

This commit is contained in:
camilleAND
2025-10-24 15:21:35 +02:00
parent dd9d760659
commit 66c14b27f1
7 changed files with 260 additions and 38 deletions
+24 -14
View File
@@ -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(
+24 -7
View File
@@ -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
@@ -659,7 +659,7 @@ export const Chat = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
$css="display: block"
$css="display: block; white-space: pre-wrap;"
$theme="greyscale"
$variation="850"
{...props}
@@ -14,6 +14,7 @@ import { AttachmentList } from './AttachmentList';
import { ModelSelector } from './ModelSelector';
import { ScrollDown } from './ScrollDown';
import { SendButton } from './SendButton';
import { ToolSelector } from './ToolSelector';
interface InputChatProps {
messagesLength: number;
@@ -593,6 +594,10 @@ export const InputChat = ({
</Button>
</Box>
)}
<Box $padding={{ horizontal: 'xs' }}>
<ToolSelector />
</Box>
</Box>
<Box
$direction="row"
@@ -179,15 +179,26 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
>
{renderFavicon()}
{new URL(url).hostname}
<Box
$padding={{ right: '4px' }}
$align="center"
style={styles.title}
>
{title}
</Box>
{title ? (
<Box
$padding={{ right: '4px' }}
$align="center"
style={styles.title}
>
{title}
</Box>
) : (
<>
{new URL(url).hostname}
<Box
$padding={{ right: '4px' }}
$align="center"
style={styles.title}
>
{title}
</Box>
</>
)}
</StyledLink>
) : (
<Box>{url}</Box>
@@ -36,13 +36,27 @@ const SourceItemListComponent: React.FC<SourceItemListProps> = ({
overflow: hidden;
`}
>
{parts.map((part) => (
<SourceItem
key={part.source.url}
url={part.source.url}
metadata={getMetadata(part.source.url)}
/>
))}
{parts.map((part) => {
const metadata = getMetadata(part.source.url);
// Extract title from providerMetadata if available
const providerTitle = part.source.providerMetadata?.title;
return (
<SourceItem
key={part.source.url}
url={part.source.url}
metadata={metadata ? {
...metadata,
title: providerTitle || metadata.title
} : providerTitle ? {
title: providerTitle,
favicon: null,
loading: false,
error: false
} : undefined}
/>
);
})}
</Box>
);
};
@@ -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 (
<Box
$position="relative"
className={className}
$css={`
display: inline-block;
z-index: ${isOpen ? 1000 : 'auto'};
`}
>
<Box
$css={`
${
hasSelectedTools
? `
.tool-selector-button {
background-color: var(--c--theme--colors--primary-100) !important;
}
`
: ''
}
`}
>
<Button
size="small"
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-label={t('More tools')}
className="c__button--neutral tool-selector-button"
icon={
<Icon
iconName="build"
$theme="greyscale"
$variation="550"
$size="16px"
$css={`
color: ${hasSelectedTools ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
`}
/>
}
>
<Text
$theme={hasSelectedTools ? 'primary' : 'greyscale'}
$variation="550"
>
{hasSelectedTools ? `${selectedToolsCount} outil${selectedToolsCount > 1 ? 's' : ''}` : t('More tools')}
</Text>
</Button>
</Box>
{isOpen && (
<>
{/* Backdrop to close the dropdown */}
<Box
$position="fixed"
$css={`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
`}
onClick={() => setIsOpen(false)}
/>
{/* Dropdown menu */}
<Box
$position="absolute"
$css={`
bottom: 100%;
left: 0;
margin-bottom: 6px;
background: white;
border: 1px solid var(--c--theme--colors--greyscale-200);
border-radius: 8px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 160px;
overflow: hidden;
`}
>
<Box $padding={{ all: 'xs' }}>
<Box $css="display: flex; flex-direction: column; gap: 1px;">
{AVAILABLE_TOOLS.map((tool) => {
const isSelected = selectedTools.includes(tool.id);
return (
<Box
key={tool.id}
$css={`
display: flex;
align-items: left;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
background-color: ${isSelected ? 'var(--c--theme--colors--primary-100)' : 'transparent'};
&:hover {
background-color: ${isSelected ? 'var(--c--theme--colors--primary-200)' : 'var(--c--theme--colors--greyscale-100)'};
}
`}
onClick={() => handleToolToggle(tool.id)}
>
<div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<Icon
iconName={tool.icon}
$theme="greyscale"
$variation="600"
$size="16px"
$css="margin-right: 8px;"
/>
<span style={{
fontSize: '12px',
fontWeight: '500',
color: isSelected ? 'var(--c--theme--colors--primary-600)' : 'var(--c--theme--colors--greyscale-600)',
whiteSpace: 'nowrap'
}}>
{tool.name}
</span>
</div>
</Box>
);
})}
</Box>
</Box>
</Box>
</>
)}
</Box>
);
};