Compare commits

...

3 Commits

Author SHA1 Message Date
camilleAND 66c14b27f1 📝(service-public): style tools 2025-10-24 15:21:35 +02:00
camilleAND dd9d760659 📝(service-public): use existing RAG implementation and upgrade for metadata 2025-10-22 16:47:13 +02:00
camilleAND 3b664c2a44 🛰️(rag-agent) add service-public tool 2025-10-22 16:46:48 +02:00
17 changed files with 460 additions and 48 deletions
+1
View File
@@ -22,6 +22,7 @@ class RAGWebResult(BaseModel):
score: float = Field(
..., description="Relevance score of the web result, typically between 0 and 1."
)
metadata: dict = Field(..., description="Metadata of the web result.")
class RAGWebResults(BaseModel):
@@ -3,7 +3,7 @@
import json
import logging
from io import BytesIO
from typing import Optional
from typing import List, Optional
from urllib.parse import urljoin
from django.conf import settings
@@ -150,22 +150,30 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
def search(self, query, results_count: int = 4) -> RAGWebResults:
def search(self, query, results_count: int = 4, collections: Optional[List[int]] = None) -> RAGWebResults:
"""
Perform a search using the Albert API based on the provided query.
Args:
query (str): The search query.
results_count (int): The number of results to return.
collections (Optional[List[int]]): List of collection IDs to search in.
If None, uses the current collection_id.
Returns:
RAGWebResults: The search results.
"""
# Use provided collections or fall back to current collection_id
if collections is not None:
collection_list = collections
else:
collection_list = [int(self.collection_id)]
response = requests.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [int(self.collection_id)],
"collections": collection_list,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
@@ -182,6 +190,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
url=result.chunk.metadata["document_name"],
content=result.chunk.content,
score=result.score,
metadata=result.chunk.metadata,
)
for result in searches.data
],
@@ -103,6 +103,7 @@ class AlbertWebSearchManager(BaseWebSearchManager):
url=self._clean_url(result.chunk.metadata["document_name"]),
content=result.chunk.content,
score=result.score,
metadata=result.chunk.metadata,
)
for result in searches.data
],
+81 -22
View File
@@ -148,13 +148,29 @@ 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],
*,
selected_tools: list[str] | None = None,
force_web_search: 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, selected_tools=selected_tools, force_web_search=force_web_search)
)
def stream_data(self, messages: List[UIMessage], force_web_search: bool = False):
def stream_data(
self,
messages: List[UIMessage],
*,
selected_tools: list[str] | None = None,
force_web_search: 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, selected_tools=selected_tools, force_web_search=force_web_search)
)
def stop_streaming(self):
"""
@@ -169,7 +185,13 @@ 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],
*,
selected_tools: list[str] | None = None,
force_web_search: bool = False,
):
"""Return only the assistant text deltas (legacy text mode)."""
await self._clean()
with ExitStack() as stack:
@@ -177,18 +199,24 @@ 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, selected_tools=selected_tools, force_web_search=force_web_search):
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],
*,
selected_tools: list[str] | None = None,
force_web_search: bool = False,
):
"""Return Vercel-AI-SDK formatted events."""
await self._clean()
with ExitStack() as stack:
if self._store_analytics:
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, selected_tools=selected_tools, force_web_search=force_web_search):
if stream_data := self.event_encoder.encode(event):
yield stream_data
@@ -345,6 +373,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
async def _run_agent( # noqa: PLR0912, PLR0915 # pylint: disable=too-many-branches,too-many-statements, too-many-locals, too-many-return-statements
self,
messages: List[UIMessage],
*,
selected_tools: list[str] | None = None,
force_web_search: bool = False,
) -> events_v4.Event | events_v5.Event:
"""Run the Pydantic AI agent and stream events."""
@@ -381,6 +411,26 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
usage = {"promptTokens": 0, "completionTokens": 0}
# Inject selected tools hint regardless of document presence
if selected_tools:
@self.conversation_agent.system_prompt
def selected_tools_hint() -> str: # type: ignore[misc]
return (
"User wants you to use the following tools if relevant: "
+ ", ".join(selected_tools)
+ ". Prefer them when solving the task."
)
if "service_public" in selected_tools:
@self.conversation_agent.system_prompt
def enforce_service_public() -> str: # type: ignore[misc]
return (
"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."
)
conversation_has_documents = self._is_document_upload_enabled and (
bool(self.conversation.collection_id)
or bool(
@@ -528,7 +578,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
async with AsyncExitStack() as stack:
# MCP servers (if any) can be initialized here
mcp_servers = [await stack.enter_async_context(mcp) for mcp in get_mcp_servers()]
_final_output_from_tool = None
_ui_sources = []
@@ -642,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(
+4
View File
@@ -6,6 +6,7 @@ from .fake_current_weather import get_current_weather
from .web_seach_albert_rag import web_search_albert_rag
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
from .web_search_tavily import web_search_tavily
from .service_public import service_public
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
@@ -31,6 +32,9 @@ def get_pydantic_tools_by_name(name: str) -> Tool:
"web_search_albert_rag": Tool(
web_search_albert_rag, takes_ctx=True, prepare=only_if_web_search_enabled
),
"service_public": Tool(
service_public, takes_ctx=True, prepare=only_if_web_search_enabled
),
}
return tool_dict[name] # will raise on purpose if name is not found
+83
View File
@@ -0,0 +1,83 @@
"""Service Public RAG search tool using Albert API pre-defined collections.
This tool reuses the existing AlbertRagBackend to query curated collections
(e.g. Service-Public, Travail-Emploi) without creating temporary collections.
"""
import logging
from typing import List
from django.conf import settings
from django.utils.module_loading import import_string
from pydantic_ai import RunContext, RunUsage
from pydantic_ai.messages import ToolReturn
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 si c'est pertinent: \n"
async def service_public(ctx: RunContext, query: str) -> ToolReturn:
"""Search curated Service-Public collections on Albert and return snippets.
Args:
ctx: Run context (usage metering is updated here)
query: The user query to search within curated collections
"""
try:
# Use AlbertRagBackend to search in specific collections
backend_class = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
backend = backend_class()
# Search in the curated collections
rag_results = backend.search(query, collections=DEFAULT_COLLECTION_IDS)
# 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": document_name,
"snippet": result.content,
"url": 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(
input_tokens=rag_results.usage.prompt_tokens,
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": unique_sources},
)
except Exception as exc: # pylint: disable=broad-except
logger.exception("Albert Service Public search failed: %s", exc)
return ToolReturn(return_value=[], content="", metadata={"error": str(exc)})
+13 -2
View File
@@ -143,6 +143,9 @@ 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"]
# New: optional selected_tools query param (comma-separated)
raw_selected_tools = request.query_params.get("selected_tools", "")
selected_tools = [t.strip() for t in raw_selected_tools.split(",") if t.strip()]
model_hrid = query_params_serializer.validated_data["model_hrid"]
logger.info("Received messages: %s", request.data.get("messages", []))
@@ -179,9 +182,17 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
),
)
if protocol == "data":
streaming_content = ai_service.stream_data(messages, force_web_search=force_web_search)
streaming_content = ai_service.stream_data(
messages,
selected_tools=selected_tools or None,
force_web_search=False, # superseded by selected_tools
)
else: # Default to 'text' protocol
streaming_content = ai_service.stream_text(messages, force_web_search=force_web_search)
streaming_content = ai_service.stream_text(
messages,
selected_tools=selected_tools or None,
force_web_search=False,
)
response = StreamingHttpResponse(
streaming_content,
@@ -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"
}
]
}
+1 -1
View File
@@ -664,7 +664,7 @@ class Base(BraveSettings, Configuration):
# Tools
AI_AGENT_TOOLS = values.ListValue(
default=[],
default=["service_public", "web_search_brave_with_document_backend"],
environ_name="AI_AGENT_TOOLS",
environ_prefix=None,
)
@@ -17,7 +17,7 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
const searchParams = new URLSearchParams();
const { forceWebSearch, selectedModelHrid } =
const { forceWebSearch, selectedModelHrid, selectedTools } =
useChatPreferencesStore.getState();
if (forceWebSearch) {
@@ -28,6 +28,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
searchParams.append('model_hrid', selectedModelHrid);
}
if (selectedTools && selectedTools.length) {
searchParams.append('selected_tools', selectedTools.join(','));
}
if (searchParams.toString()) {
const separator = url.includes('?') ? '&' : '?';
url = `${url}${separator}${searchParams.toString()}`;
@@ -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>
);
};
@@ -5,10 +5,12 @@ interface ChatPreferencesState {
selectedModelHrid: string | null;
forceWebSearch: boolean;
isPanelOpen: boolean;
selectedTools: string[];
setSelectedModelHrid: (hrid: string | null) => void;
toggleForceWebSearch: () => void;
setPanelOpen: (isOpen: boolean) => void;
togglePanel: () => void;
toggleSelectedTool: (tool: string) => void;
}
export const useChatPreferencesStore = create<ChatPreferencesState>()(
@@ -17,11 +19,18 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
selectedModelHrid: null,
forceWebSearch: false,
isPanelOpen: false,
selectedTools: [],
setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }),
toggleForceWebSearch: () =>
set((state) => ({ forceWebSearch: !state.forceWebSearch })),
setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }),
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
toggleSelectedTool: (tool) =>
set((state) => ({
selectedTools: state.selectedTools.includes(tool)
? state.selectedTools.filter((t) => t !== tool)
: [...state.selectedTools, tool],
})),
}),
{
name: 'chat-preferences',
@@ -85,6 +85,8 @@
"Search for a chat": "Rechercher un chat",
"Search results": "Résultats de la recherche",
"Search...": "Recherche...",
"More tools": "Plus d'outils",
"Hide tools": "Masquer les outils",
"Select": "Sélectionner",
"Select model": "Sélectionner un modèle",
"Send": "Envoyer",