Compare commits

...

11 Commits

Author SHA1 Message Date
charles 4800ef442e 🚨(backend) fix tests
I am removing hard coded datetime.
2026-01-21 11:13:18 +01:00
charles e7fb73c53e 🚨(backend) various fixes
I fix linters and rabbit request change
2026-01-20 19:27:24 +01:00
charles 398f692dd1 (backend) handle deleting temporary collections
I handle deleting document in temporary collection
for web_search_brave_with_document_backend
2026-01-20 19:08:00 +01:00
charles aa898a8589 🚨(backend) various review fixes
I am doing various small review fixies
2026-01-20 19:08:00 +01:00
charles f88a80d93f 🚨(backend) various fixes
I am proofreading myself
2026-01-20 19:07:58 +01:00
charles 40d1d8cc24 ♻️(backend) refactor document parsers
I refactor document parsing by introducing
AlbertParser and BaseParser
2026-01-20 19:07:20 +01:00
charles 0abe12382a (backend) enhance Find API integration with user sub and tag
I enhance Find API integration with user
access control and configuration options
2026-01-20 19:07:20 +01:00
charles 713b34fdcd 🧪(backend) tests
I add tests to test the app.
2026-01-20 19:07:20 +01:00
charles b62fffc69d (backend) implement FindRagBackend
We want to be able to use Find api in rag tools.
I add a new rag backend class to do so.
2026-01-20 19:07:20 +01:00
Eléonore Voisin 3232da72c5 Revert "🐛(front) optimize chat"
This reverts commit 69bf2cab5d.
2026-01-20 19:07:01 +01:00
Eléonore Voisin 944d69aede 🐛(front) optimize chat
Simplified chat rendering
2026-01-20 19:06:41 +01:00
21 changed files with 518 additions and 120 deletions
+3
View File
@@ -44,6 +44,9 @@ env.d/development/*
!env.d/development/*.dist
env.d/terraform
# Configuration
**/conversations/configuration/llm/dev.json
# npm
node_modules
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) add FindRagBackend
### Removed
- 🔥(chat) consider PDF documents as other kind of documents #234
+11
View File
@@ -71,6 +71,9 @@ services:
- "host.docker.internal:host-gateway"
ports:
- "8071:8000"
networks:
- default
- lasuite
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -89,6 +92,9 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
- default
- lasuite
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -177,3 +183,8 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge
+3
View File
@@ -95,6 +95,9 @@ These are the environment variables you can set for the `conversations-backend`
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | conversations |
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/conversations/configuration/theme/default.json | BASE_DIR/conversations/configuration/theme/default.json |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| FIND_API_KEY | API key of Find | |
| FIND_API_URL | URL of Find | `https://app-find/api` |
| FIND_API_TIMEOUT | Find API timeout | 30 |
## conversations-frontend image
+3 -3
View File
@@ -244,9 +244,9 @@ For Mistral AI models using the Etalab platform:
{
"models": [
{
"hrid": "mistral-large",
"model_name": "mistral-large-latest",
"human_readable_name": "Mistral Large (Etalab)",
"hrid": "mistral-medium",
"model_name": "mistral-medium-2508",
"human_readable_name": "Mistral Medium (Etalab)",
"provider_name": "mistral-etalab",
"profile": null,
"settings": {
+1
View File
@@ -357,6 +357,7 @@ The RAG backend performs semantic search to find the most relevant content:
rag_results = document_store.search(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
**kwargs, # Additional search parameters like session with access_token
)
```
@@ -0,0 +1,66 @@
"""Document parsers for RAG backends."""
import logging
from io import BytesIO
from urllib.parse import urljoin
from django.conf import settings
import requests
from chat.agent_rag.document_converter.markitdown import DocumentConverter
logger = logging.getLogger(__name__)
class BaseParser:
"""Base class for document parsers."""
def parse_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
into a format suitable for storage.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
Returns:
str: The document content in Markdown format.
"""
raise NotImplementedError("Must be implemented in subclass.")
class AlbertParser(BaseParser):
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
"""Parse PDF document using Albert API."""
response = requests.post(
self.endpoint,
headers={
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
},
files={
"file": (name, content, content_type),
"output_format": (None, "markdown"),
},
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
)
response.raise_for_status()
return "\n\n".join(
document_page["content"] for document_page in response.json().get("data", [])
)
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
"""Parse document based on content type."""
if content_type == "application/pdf":
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
return DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
@@ -13,7 +13,7 @@ import requests
from chat.agent_rag.albert_api_constants import Searches
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
from chat.agent_rag.document_converter.markitdown import DocumentConverter
from chat.agent_rag.document_converter.parser import AlbertParser
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
logger = logging.getLogger(__name__)
@@ -26,9 +26,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
It provides methods to:
- Create a collection for the search operation.
- Parse documents and convert them to Markdown format:
+ Handle PDF parsing using the Albert API.
+ Use the DocumentConverter (markitdown) for other formats.
- Store parsed documents in the Albert collection.
- Perform a search operation using the Albert API.
"""
@@ -46,10 +43,9 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
}
self._collections_endpoint = urljoin(self._base_url, "/v1/collections")
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
self._pdf_parser_endpoint = urljoin(self._base_url, "/v1/parse-beta")
self._search_endpoint = urljoin(self._base_url, "/v1/search")
self._default_collection_description = "Temporary collection for RAG document search"
self.parser = AlbertParser()
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
@@ -91,7 +87,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
self.collection_id = str(response.json()["id"])
return self.collection_id
def delete_collection(self) -> None:
def delete_collection(self, **kwargs) -> None:
"""
Delete the current collection
"""
@@ -102,7 +98,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
)
response.raise_for_status()
async def adelete_collection(self) -> None:
async def adelete_collection(self, **kwargs) -> None:
"""
Asynchronously delete the current collection
"""
@@ -114,59 +110,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
)
response.raise_for_status()
def parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the PDF document content and return the text content.
This method should handle the logic to convert the PDF into
a format suitable for the Albert API.
"""
response = requests.post(
self._pdf_parser_endpoint,
headers=self._headers,
files={
"file": (
name,
content,
content_type,
), # Use the name as the filename in the request
"output_format": (None, "markdown"), # Specify the output format as Markdown,
},
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
)
response.raise_for_status()
return "\n\n".join(
document_page["content"] for document_page in response.json().get("data", [])
)
def parse_document(self, name: str, content_type: str, content: BytesIO):
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
into a format suitable for the Albert API.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
Returns:
str: The document content in Markdown format.
"""
# Implement the parsing logic here
if content_type == "application/pdf":
# Handle PDF parsing
markdown_content = self.parse_pdf_document(
name=name, content_type=content_type, content=content
)
else:
markdown_content = DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
return markdown_content
def store_document(self, name: str, content: str) -> None:
def store_document(self, name: str, content: str, **kwargs) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
@@ -174,6 +118,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments.
"""
response = requests.post(
urljoin(self._base_url, self._documents_endpoint),
@@ -188,7 +133,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
async def astore_document(self, name: str, content: str) -> None:
async def astore_document(self, name: str, content: str, **kwargs) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
@@ -196,6 +141,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments.
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
@@ -213,13 +159,14 @@ 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: str, results_count: int = 4, **kwargs) -> 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.
**kwargs: Additional arguments.
Returns:
RAGWebResults: The search results.
@@ -256,13 +203,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
),
)
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
async def asearch(self, query, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
Perform an asynchronous 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.
**kwargs: Additional arguments.
Returns:
RAGWebResults: The search results.
@@ -1,6 +1,7 @@
"""Implementation of the Albert API for RAG document search."""
import logging
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager, contextmanager
from io import BytesIO
from typing import List, Optional
@@ -8,11 +9,12 @@ from typing import List, Optional
from asgiref.sync import sync_to_async
from chat.agent_rag.constants import RAGWebResults
from chat.agent_rag.document_converter.parser import BaseParser
logger = logging.getLogger(__name__)
class BaseRagBackend:
class BaseRagBackend(ABC):
"""Base class for RAG backends."""
def __init__(
@@ -38,6 +40,7 @@ class BaseRagBackend:
self.collection_id = collection_id
self.read_only_collection_id = read_only_collection_id or []
self._default_collection_description = "Temporary collection for RAG document search"
self.parser: BaseParser = BaseParser()
def get_all_collection_ids(self) -> List[str]:
"""
@@ -53,13 +56,14 @@ class BaseRagBackend:
collection_ids = []
if self.collection_id:
collection_ids.append(int(self.collection_id))
collection_ids.append(self.collection_id)
if self.read_only_collection_id:
collection_ids.extend(
[int(collection_id) for collection_id in self.read_only_collection_id]
)
return collection_ids
@abstractmethod
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
@@ -88,9 +92,10 @@ class BaseRagBackend:
Returns:
str: The document content in Markdown format.
"""
raise NotImplementedError("Must be implemented in subclass.")
return self.parser.parse_document(name, content_type, content)
def store_document(self, name: str, content: str) -> None:
@abstractmethod
def store_document(self, name: str, content: str, **kwargs) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
@@ -98,10 +103,11 @@ class BaseRagBackend:
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def astore_document(self, name: str, content: str) -> None:
async def astore_document(self, name: str, content: str, **kwargs) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
@@ -109,10 +115,13 @@ class BaseRagBackend:
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
return await sync_to_async(self.store_document)(name=name, content=content)
return await sync_to_async(self.store_document)(name=name, content=content, **kwargs)
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
def parse_and_store_document(
self, name: str, content_type: str, content: BytesIO, **kwargs
) -> str:
"""
Parse the document and store it in the Albert collection.
@@ -120,39 +129,52 @@ class BaseRagBackend:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
if not self.collection_id:
raise RuntimeError("The RAG backend requires collection_id")
document_content = self.parse_document(name, content_type, content)
self.store_document(name, document_content)
self.store_document(name, document_content, **kwargs)
return document_content
def delete_collection(self) -> None:
@abstractmethod
def delete_collection(self, **kwargs) -> None:
"""
Delete the collection.
This method should handle the logic to delete the collection from the backend.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def adelete_collection(self) -> None:
async def adelete_collection(self, **kwargs) -> None:
"""
Delete the collection.
This method should handle the logic to delete the collection from the backend.
"""
return await sync_to_async(self.delete_collection)()
return await sync_to_async(self.delete_collection)(**kwargs)
def search(self, query, results_count: int = 4) -> RAGWebResults:
@abstractmethod
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
Search the collection for the given query.
Args:
query: The search query string.
results_count: Number of results to return.
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
async def asearch(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
Search the collection for the given query.
Search the collection for the given query asynchronously.
Args:
query: The search query string.
results_count: Number of results to return.
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
"""
return await sync_to_async(self.search)(query=query, results_count=results_count)
return await sync_to_async(self.search)(query=query, results_count=results_count, **kwargs)
@classmethod
@contextmanager
@@ -168,7 +190,9 @@ class BaseRagBackend:
@classmethod
@asynccontextmanager
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
async def temporary_collection_async(
cls, name: str, description: Optional[str] = None, **kwargs
):
"""Context manager for RAG backend with temporary collections."""
backend = cls()
@@ -176,4 +200,4 @@ class BaseRagBackend:
try:
yield backend
finally:
await backend.adelete_collection()
await backend.adelete_collection(**kwargs)
@@ -0,0 +1,163 @@
"""Implementation of the Find API for RAG document search."""
import logging
import uuid
from typing import List, Optional
from urllib.parse import urljoin
from uuid import uuid4
from django.conf import settings
from django.utils import timezone
import requests
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
from chat.agent_rag.document_converter.parser import AlbertParser
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
from utils.oidc import with_fresh_access_token
logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGE_CODES = ["en", "fr", "de", "nl"]
class FindRagBackend(BaseRagBackend):
"""
This class is a placeholder for the Find API implementation.
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
It provides methods to:
- Store parsed documents in the Find index.
- Perform a search operation using the Find API.
"""
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
# Initialize any necessary parameters or configurations here
super().__init__(collection_id, read_only_collection_id)
self.api_key = settings.FIND_API_KEY
self.search_endpoint = "api/v1.0/documents/search/"
self.indexing_endpoint = "api/v1.0/documents/index/"
self.deleting_endpoint = "api/v1.0/documents/delete/"
self.parser = AlbertParser() # Find Rag relies on Albert parser
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
init collection_id
"""
self.collection_id = self.collection_id or str(uuid.uuid4())
return self.collection_id
@with_fresh_access_token
def delete_collection(self, **kwargs) -> None:
"""
Delete the current collection
"""
response = requests.post(
urljoin(settings.FIND_API_URL, self.deleting_endpoint),
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
json={
"tags": [f"collection-{self.collection_id}"],
# "service": "conversations"
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
def store_document(self, name: str, content: str, **kwargs) -> None:
"""
index document in Find
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
user_sub (str): The user subject identifier for access control.
"""
logger.debug("index document '%s' in Find", name)
user_sub = kwargs.get("user_sub")
if not user_sub:
raise ValueError("user_sub is required to store document in FindRagBackend")
response = requests.post(
urljoin(settings.FIND_API_URL, self.indexing_endpoint),
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"id": str(uuid4()),
"title": str(name) or "",
"depth": 0,
"path": str(name) or "",
"numchild": 0,
"content": content or "",
"created_at": timezone.now().isoformat(),
"updated_at": timezone.now().isoformat(),
"tags": [f"collection-{self.collection_id}"],
"size": len(content.encode("utf-8")),
"users": [user_sub],
"groups": [],
"reach": "authenticated",
"is_active": True,
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
@with_fresh_access_token
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
Perform a search using the Find API.
Uses the user's OIDC token from the request session.
Args:
query: The search query.
results_count: Number of results to return.
**kwargs: Additional arguments. Expected: 'session' containing OIDC tokens,
Returns:
RAGWebResults: The search results.
"""
logger.debug("search documents in Find with query '%s'", query)
response = requests.post(
urljoin(settings.FIND_API_URL, self.search_endpoint),
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
json={
"q": query or "*",
"tags": [
f"collection-{collection_id}" for collection_id in self.get_all_collection_ids()
],
"k": results_count,
},
timeout=settings.FIND_API_TIMEOUT,
)
response.raise_for_status()
return RAGWebResults(
data=[
RAGWebResult(
url=get_language_value(result["_source"], "title"),
content=get_language_value(result["_source"], "content"),
score=result["_score"],
)
for result in response.json()
],
usage=RAGWebUsage(
prompt_tokens=0,
completion_tokens=0,
),
)
def get_language_value(source, language_field):
"""
extract the value of the language field with the correct language_code extension.
"title" and "content" have extensions like "title.en" or "title.fr".
get_language_value will return the value regardless of the extension.
"""
for language_code in SUPPORTED_LANGUAGE_CODES:
if f"{language_field}.{language_code}" in source:
return source[f"{language_field}.{language_code}"]
raise ValueError(f"No '{language_field}' field with any supported language code in object")
+12 -1
View File
@@ -91,6 +91,7 @@ class ContextDeps:
conversation: models.ChatConversation
user: User
session: Optional[Dict] = None
web_search_enabled: bool = False
@@ -105,7 +106,14 @@ def get_model_configuration(model_hrid: str):
class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Service class for AI-related operations (Pydantic-AI edition)."""
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None):
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
conversation: models.ChatConversation,
user,
session=None,
model_hrid=None,
language=None,
):
"""
Initialize the AI agent service.
@@ -135,6 +143,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self._context_deps = ContextDeps(
conversation=conversation,
user=user,
session=session,
web_search_enabled=self._is_web_search_enabled,
)
@@ -277,6 +286,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
name=document.identifier,
content_type=document.media_type,
content=document_data,
user_sub=self.user.sub,
)
else:
# Remote URL
@@ -286,6 +296,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
name=document.identifier,
content_type=document.media_type,
content=document.data,
user_sub=self.user.sub,
)
if not document.media_type.startswith("text/"):
@@ -38,9 +38,6 @@ def brave_settings(settings):
settings.BRAVE_SEARCH_EXTRA_SNIPPETS = True
settings.BRAVE_SUMMARIZATION_ENABLED = False
settings.BRAVE_CACHE_TTL = 3600
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
)
settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = 5
@@ -0,0 +1,17 @@
"""Common test fixtures for chat views tests."""
from unittest import mock
import pytest
@pytest.fixture(autouse=True)
def mock_process_request():
"""
Mock process_request to bypass OIDC authentication in tests.
"""
with mock.patch(
"lasuite.oidc_login.decorators.RefreshOIDCAccessToken.process_request"
) as mocked_process_request:
mocked_process_request.return_value = None
yield mocked_process_request
@@ -8,6 +8,7 @@ import logging
from io import BytesIO
from unittest import mock
from django.contrib.sessions.backends.cache import SessionStore
from django.utils import formats, timezone
import httpx
@@ -41,28 +42,49 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(autouse=True)
def ai_settings(settings):
@pytest.fixture(
autouse=True,
params=[
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
],
)
def ai_settings(request, settings):
"""Fixture to set AI service URLs for testing."""
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
# Enable Albert API for document search
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
)
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
settings.ALBERT_API_KEY = "albert-api-key"
# enable on rag document search tool
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
"Based on the following document contents:\n\n{search_results}\n\n"
"Please answer the user's question: {user_prompt}"
)
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
# Albert API settings
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
settings.ALBERT_API_KEY = "albert-api-key"
# Find API settings
settings.FIND_API_URL = "https://find.api.example.com"
settings.FIND_API_KEY = "find-api-key"
return settings
@pytest.fixture(autouse=True)
def mock_refresh_access_token():
"""Mock refresh_access_token to bypass token refresh in tests."""
with mock.patch("utils.oidc.refresh_access_token") as mocked_refresh_access_token:
session = SessionStore()
session["oidc_access_token"] = "mocked-access-token"
mocked_refresh_access_token.return_value = session
yield mocked_refresh_access_token
@pytest.fixture(name="sample_pdf_content")
def fixture_sample_pdf_content():
"""Create a dummy PDF content as BytesIO."""
@@ -81,10 +103,18 @@ def fixture_sample_pdf_content():
return BytesIO(pdf_data)
@pytest.fixture(name="mock_albert_api")
def fixture_mock_albert_api():
@pytest.fixture(name="mock_document_api")
def fixture_mock_document_api():
"""Fixture to mock the Albert API endpoints."""
# Mock collection creation
document_name = "sample.pdf"
document_content = "This is the content of the PDF."
prompt_tokens = 10
completion_tokens = 20
search_method = "semantic"
search_score = 0.9
responses.post(
"https://albert.api.etalab.gouv.fr/v1/collections",
json={"id": "123", "name": "test-collection"},
@@ -101,7 +131,7 @@ def fixture_mock_albert_api():
"metadata": {"document_name": "sample.pdf"},
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
},
status=status.HTTP_200_OK,
)
@@ -119,20 +149,42 @@ def fixture_mock_albert_api():
json={
"data": [
{
"method": "semantic",
"method": search_method,
"chunk": {
"id": 123,
"content": "This is the content of the PDF.",
"metadata": {"document_name": "sample.pdf"},
"content": document_content,
"metadata": {"document_name": document_name},
},
"score": 0.9,
"score": search_score,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
},
status=status.HTTP_200_OK,
)
# Mock document indexing (Find API)
responses.post(
"https://find.api.example.com/api/v1.0/documents/index/",
json={"id": "456", "status": "indexed"},
status=status.HTTP_200_OK,
)
# Mock document search (Find API)
responses.post(
"https://find.api.example.com/api/v1.0/documents/search/",
json=[
{
"_source": {
"title.fr": document_name,
"content.fr": document_content,
},
"_score": search_score,
}
],
status=status.HTTP_200_OK,
)
@pytest.fixture(name="mock_summarization_agent")
def fixture_mock_summarization_agent():
@@ -219,7 +271,7 @@ def fixture_mock_openai_stream():
def test_post_conversation_with_document_upload(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_albert_api, # pylint: disable=unused-argument
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_ai_agent_service,
@@ -548,7 +600,7 @@ def test_post_conversation_with_document_upload_feature_disabled(
@freeze_time()
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
api_client,
mock_albert_api, # pylint: disable=unused-argument
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_ai_agent_service,
@@ -37,11 +37,19 @@ from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(autouse=True)
def ai_settings(settings):
@pytest.fixture(
autouse=True,
params=[
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
],
)
def ai_settings(request, settings):
"""Fixture to set AI service URLs for testing."""
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.FIND_API_KEY = "find-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
return settings
@@ -85,6 +93,10 @@ def test_post_conversation_with_local_pdf_document_url(
json={"id": "document_id", "object": "document"},
status=200,
)
responses.post(
"https://app-find/api/v1.0/documents/index/",
status=200,
)
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
@@ -127,7 +139,7 @@ def test_post_conversation_with_local_pdf_document_url(
],
instructions=(
"You are a helpful test assistant :)\n\n"
"Today is Monday 19/01/2026.\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from attached "
"documents. Do NOT use it to summarize; for summaries, call the summarize "
@@ -801,6 +813,10 @@ def test_post_conversation_with_local_not_pdf_document_url(
json={"id": "document_id", "object": "document"},
status=200,
)
responses.post(
"https://app-find/api/v1.0/documents/index/",
status=200,
)
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
@@ -26,7 +26,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
document_store = document_store_backend(ctx.deps.conversation.collection_id)
rag_results = document_store.search(query)
rag_results = document_store.search(query, session=ctx.deps.session)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
+12 -5
View File
@@ -127,7 +127,7 @@ async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[st
return []
async def _fetch_and_store_async(url: str, document_store) -> None:
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
try:
@@ -136,7 +136,7 @@ async def _fetch_and_store_async(url: str, document_store) -> None:
logger.debug("Fetched document: %s", document)
if document:
await document_store.astore_document(url, document)
await document_store.astore_document(url, document, **kwargs)
except DocumentFetchError as e:
logger.warning("Failed to fetch and store %s: %s", url, e)
# Continue with other documents
@@ -307,19 +307,26 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
temp_collection_name = f"tmp-{uuid.uuid4()}"
try:
async with document_store_backend.temporary_collection_async(
temp_collection_name
temp_collection_name, session=ctx.deps.session
) as document_store:
# Fetch and store all documents concurrently
tasks = [
_fetch_and_store_async(result["url"], document_store)
_fetch_and_store_async(
result["url"],
document_store,
user_sub=ctx.deps.user.sub,
session=ctx.deps.session,
)
for result in raw_search_results
]
await asyncio.gather(*tasks, return_exceptions=True)
# Perform RAG search
rag_results = await document_store.asearch(
query,
query=query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
session=ctx.deps.session,
user_sub=ctx.deps.user.sub,
)
logger.info("RAG search returned: %s", rag_results)
+4
View File
@@ -7,11 +7,13 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
import langfuse
import magic
import posthog
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
from rest_framework.response import Response
@@ -123,6 +125,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
self.permission_classes = []
return super().get_permissions()
@method_decorator(refresh_oidc_access_token)
@decorators.action(
methods=["post"],
detail=True,
@@ -174,6 +177,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
ai_service = AIAgentService(
conversation=conversation,
user=self.request.user,
session=request.session,
model_hrid=model_hrid,
language=(
self.request.user.language
+1 -1
View File
@@ -22,7 +22,7 @@ def no_http_requests(monkeypatch):
Credits: https://blog.jerrycodes.com/no-http-requests/
"""
allowed_hosts = {"localhost", "minio", "minio:9000"}
allowed_hosts = {"localhost", "127.0.0.1", "minio", "minio:9000"}
original_urlopen = HTTPConnectionPool.urlopen
def urlopen_mock(self, method, url, *args, **kwargs):
+17
View File
@@ -841,6 +841,23 @@ USER QUESTION:
environ_prefix=None,
)
# Find
FIND_API_KEY = values.Value(
None,
environ_name="FIND_API_KEY",
environ_prefix=None,
)
FIND_API_URL = values.Value(
"https://app-find/api",
environ_name="FIND_API_URL",
environ_prefix=None,
)
FIND_API_TIMEOUT = values.PositiveIntegerValue(
default=30, # seconds
environ_name="FIND_API_TIMEOUT",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
+54
View File
@@ -0,0 +1,54 @@
"""Utility functions for OIDC token management."""
from functools import wraps
from django.conf import settings
import requests
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
from rest_framework.exceptions import AuthenticationFailed
def refresh_access_token(session):
"""Refresh the OIDC access token using the refresh token."""
refresh_token = get_oidc_refresh_token(session)
if not refresh_token:
raise AuthenticationFailed({"error": "Refresh token is missing from session"})
response = requests.post(
settings.OIDC_OP_TOKEN_ENDPOINT,
data={
"grant_type": "refresh_token",
"client_id": settings.OIDC_RP_CLIENT_ID,
"client_secret": settings.OIDC_RP_CLIENT_SECRET,
"refresh_token": refresh_token,
},
timeout=5,
)
response.raise_for_status()
token_info = response.json()
store_tokens(
session,
access_token=token_info.get("access_token"),
id_token=None,
refresh_token=token_info.get("refresh_token"),
)
return session
def with_fresh_access_token(func):
"""
Decorator to handle OIDC token refresh and extraction.
Expects 'session' in kwargs and update it with the fresh token.
"""
@wraps(func)
def wrapper(*args, **kwargs):
session = kwargs.pop("session", None)
if session is None:
raise AuthenticationFailed({"error": "Session is required but not provided"})
refreshed_session = refresh_access_token(session)
return func(*args, session=refreshed_session, **kwargs)
return wrapper