Compare commits

...

67 Commits

Author SHA1 Message Date
qbey 93ee3cd10d 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-17 17:39:03 +01:00
Quentin BEY 91e1c73ccf 🔖(patch) bump release to 0.0.9
Added
- (front) add code copy button
- (RAG) add generic collection RAG tools #159

Fixed

- 🔊(langfuse) enable tracing with redacted content #162
2025-11-17 17:36:24 +01:00
Quentin BEY 0493badb12 (pydantic-ai) update tests after version bump
The new version adds the `run_id` to messages, we don't need to
test this, so we simply get it from the tested objects.
2025-11-14 19:22:22 +01:00
renovate[bot] c6283bd8c8 ⬆️(dependencies) update python dependencies 2025-11-14 19:22:22 +01:00
Eléonore Voisin e823d21418 (front) add code copy button
add code copy button in code block
2025-11-14 17:20:19 +01:00
Quentin BEY 22ce90488c (e2e) add first chat end-to-end test
This is a very simple test, but it paves the way for more of them.
2025-11-14 15:09:54 +01:00
Quentin BEY 8f5419e6ca 🔊(langfuse) enable tracing with redacted content
We use langfuse to know the model use for product analysis
and token consumption. Before this commit if the user does
not want to share their conversations, we would not know
their token use. Now we send a trace with redacted content:
the input/output is redacted and the tool call arguments
are removed from the trace.
2025-11-14 15:05:03 +01:00
Quentin BEY dcec57719f (RAG) add generic collection RAG tools
This allows to deploy generic RAG tools with predefined
collections to allow specific document database for some
users.
2025-11-13 10:42:02 +01:00
qbey 67bb3536d6 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-10 13:22:31 +01:00
Quentin BEY a020bfa9bf 🔖(patch) bump release to 0.0.8
Fixed

- 🦺(front) Fix send prohibited file types
- 🐛(front) fix target blank links in chat #103
- 🚑️(posthog) pass str instead of UUID for user PK #134
- ️(web-search) keep running when tool call fails #137
- (summarize): new summarize tool integration #78

Removed

- 🔥(posthog) remove posthog middleware for async mode fix #146
2025-11-10 13:12:25 +01:00
Quentin BEY 2df761c9a1 🔧(feedback) update Tchap URL in modal
Update the URL, tu allow user from other federations.
2025-11-10 13:08:14 +01:00
Quentin BEY fe1a065688 ⚗️(summarize) move the system prompt to instruction
When the tool is called, the agent graph call the LLM again with
the tool response, and the instructions. I hope using instruction
here will provide better results.

The former way to add the summarize tool as output does not work
properly with Mistral:
- if the user ask for a summary, the tool is called and the
  result is returned directly
- then if there is another user request which does not trigger
  a tool: boom, there is a JSON encode error...

I was not able to understand why this happens, so for now, the
summarize tool is not an "output".
2025-11-09 23:08:42 +01:00
Eléonore Voisin a5bc974e5d 🐛(front) Fix send prohibited file types
Block and warn the user that their file type is not being recognized.
2025-11-07 11:15:09 +01:00
Eléonore Voisin 5a3d20f4a9 ️(a11y) improve accessibility
fix global accessibility
2025-11-07 10:34:47 +01:00
Quentin BEY 78b9f11179 (attachments) fix DBB object existence test
The test was flaky
2025-11-07 09:56:05 +01:00
Quentin BEY b33a3e4987 🐛(summarize) fix the summarization tool loader
When the loader was not in the proper place.
2025-11-06 23:17:41 +01:00
Quentin BEY c83c8c7da7 🎨(summarize) add error handling in tool call
Allows the LLM to retry the summarization in some cases.
2025-11-06 23:17:41 +01:00
Quentin BEY ee73c7b9cd ♻️(summarize) move the tool to the tools module
This also fix the tool dynamic registation to use the original
function signature and docsting.
2025-11-06 23:17:41 +01:00
Quentin BEY 78a2393383 ️(summarize) use semchunk for better doc chunking
This reduces the code complexity while allowing better "cuts"
also providing overlap for free.
Also, do not wait for sub-batch to complete a use a global
concurrency instead.
2025-11-06 23:17:41 +01:00
camilleAND 392eeece3e (summarize) new summarize tool integration
Improve the existing tool to manage bigger documents.
2025-11-06 23:17:41 +01:00
Quentin BEY 1f92187dae 🔥(posthog) remove posthog middleware for async mode fix
Posthog fixed their middleware in v6.7.14
This reverts commit fbe9e039cf.
2025-11-06 00:03:46 +01:00
renovate[bot] 9426a7e1ae ⬆️(dependencies) update python dependencies 2025-11-05 23:45:51 +01:00
Quentin BEY e51620a15c ️(web-search) keep running when tool call fails
When the tool call fails, we don't want the user to be blocked
without any clue.

This adds:
 - auto retry when possible
 - nice message preventing the LLM to generate an answer without
   updated information
2025-11-05 14:43:47 +01:00
Eléonore Voisin f1251a3d09 🐛(front) fix target blank links in chat
Add target blank to link in chat
2025-11-05 14:18:48 +01:00
Quentin BEY 7bc293b8e3 💚(docker-hub) force image publishing on trivy fail
Trivy fails but we cannot fix until the problematic
dependency is fixed... For now we force the build.
2025-11-05 12:43:49 +01:00
Quentin BEY bbac17462a 📝(doc) add small how-to for local run
This could help new developpers to run the stack locally.
2025-11-05 11:42:38 +01:00
Quentin BEY 7d7ad0bdcd 📝(doc) add attachments documentation
Describe the way attachments are processed.
2025-11-05 11:31:45 +01:00
Quentin BEY eca8fa5ffe 📝(doc) add agent tool documentation
This describe how tools are configured, what they do and
some of their limitations
2025-11-05 10:29:35 +01:00
Quentin BEY 5e497b2ccb 📝(doc) fix/add documentation
This is a first step to write some useful documentation.
2025-11-05 10:29:35 +01:00
Quentin BEY 55400636b6 🏗️(uvicorn) Django does not manage lifespan yet
This is not a bug, but while the worker tries to start with
lifespan (auto), Django logs an error before starting properly.
2025-11-03 14:02:40 +01:00
Quentin BEY 1901c4d435 🚑️(posthog) pass str instead of UUID for user PK
The serialization before sending the request to Posthog was
failing because of UUID.
2025-10-28 22:58:37 +01:00
Quentin BEY 095bcaea1a 🔖(patch) bump release to 0.0.7
Fixed

- 🚑️(posthog) fix the posthog middleware for async mode #133
2025-10-28 19:01:52 +01:00
Quentin BEY fbe9e039cf 🚑️(posthog) fix the posthog middleware for async mode
The original Posthog middleware tries to get user from request
using the sync way, in both async and sync mode.
2025-10-28 18:19:34 +01:00
Quentin BEY 33a87c3959 ⚰️(settings) remove unused code from copy/paste
These lines where never meant to be used in this project.
2025-10-28 18:09:22 +01:00
Quentin BEY 18fc3390f3 🔖(patch) bump release to 0.0.6
Fixed

- 🚑️(stats) fix tracking id in upload event #130
2025-10-28 09:34:13 +01:00
Quentin BEY 1d54114a39 🚑️(posthog) use same distinct ID as set by frontend
The current behavior was duplicating users because the
frontend uses the user PK as distinct ID (which is
good) but the backend was using the email address.

=> burn the now useless middleware
=> use the user PK as distinct ID
2025-10-28 09:27:51 +01:00
Arnaud Robin 7d8b6fc07c 🚑️(stats) fix tracking id in upload event
Previously, upload event tracking used the user's email instead of
their user ID, preventing reliable association between uploads
and users.
2025-10-27 23:27:34 +01:00
Quentin BEY 2b96ba0597 🔧(django-lasuite) add new settings for back-channel logout
The latest version of the lib, allows to configure OIDC back-
channel logout which requires some specific settings.
While unused in our environment, this allows use by others.
2025-10-27 21:48:59 +01:00
Quentin BEY 34cf348f4c (pydantic-ai) update tests after last update
There is a new consistendy enforcement in the library, which
enforces each message to have a unique ID, therefor the UUID
mock fails (which was expected TBH).
2025-10-27 21:45:12 +01:00
Quentin BEY 0cce897c69 (django-lasuite) update tests after latest release
The library now does a `save` instead of an `update` which
triggers a unicity check.
2025-10-27 21:45:12 +01:00
renovate[bot] 7c8d8e9de7 ⬆️(dependencies) update python dependencies 2025-10-27 21:45:12 +01:00
Quentin BEY 14b920466b 🔖(patch) bump release to 0.0.5
Fixed

- 🚑️(drag-drop) fix the rejection display on Safari #127
2025-10-27 11:25:22 +01:00
Quentin BEY 42017a6180 🚑️(drag-drop) fix the rejection display on Safari
Safari does not fill the file information during drag
so we cannot check "accept" on the fly, we add a fallback.
2025-10-27 10:44:20 +01:00
qbey c89ce82a4a 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-27 09:36:13 +01:00
Quentin BEY a738b6cfc3 🔖(patch) bump release to 0.0.4
Added

- 🌐(i18n) add dutch language #117

Changed

- ️(asgi) use `uvicorn` to serve backend #121

Fixed

- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
2025-10-27 09:27:32 +01:00
Quentin BEY 9c3f8a8541 🐛(attachments) reject the whole drag&drop if unsupported formats
In production, users can upload any file format because the
drag and drop feature does not check their type...
This first implementation is to prevent users to have a bad
experience on this.
2025-10-24 15:46:16 +02:00
Quentin BEY 09e885d7e7 ⚗️(LLM) allow to mock model call for conversation
This is switch to prevent real model call in a deployed stack.
This is usefull to test heavy load on the servers without
inference costs.
2025-10-23 18:59:14 +02:00
Quentin BEY 05a1844a0c ️(asgi) use uvicorn to serve backend
This is a naive first switch from sync to async.
This enables the backend to still answer to incomming requests
while streaming LLM results to the user.

For sure there is room for code cleaning and improvements, but
this provides a nice improvement out of the box.
2025-10-23 18:59:14 +02:00
elvoisin a82e0b4fa0 🐛(front) fix mobile source (#119)
fix show source mobile
2025-10-22 16:45:57 +02:00
Berry den Hartog 60b8338ae5 🌐(i18n) add dutch language
Dutch translations are up to date we can add the language in code.
2025-10-22 16:25:18 +02:00
renovate[bot] bbc8dad9da ⬆️(dependencies) update pylint to v3.3.9 2025-10-22 15:57:18 +02:00
qbey f9e446ec18 🌐(i18n) update translated strings
Update translated files with new translations

Fixes Dutch translation missing character.
2025-10-22 15:48:20 +02:00
qbey a013c69ba7 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-22 13:31:36 +02:00
Quentin BEY 2a79655edb 🔖(patch) bump release to 0.0.3
Fixed

- 🚑️(web-search) fix missing argument in RAG backend #116
2025-10-21 23:54:59 +02:00
Quentin BEY ad4b5473aa 🚑️(web-search) fix missing argument in RAG backend
The wrong RAG backend (not the one used in production) was
updated in a previous commit...
2025-10-21 23:49:21 +02:00
qbey b8e6e32fed 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-21 22:49:59 +02:00
Quentin BEY 0a5f71ef18 🔖(patch) bump release to 0.0.2
Added

- (front) add drag'n drop file
- (activation-codes) register users also on Brevo #98
- 📈(posthog) add `sub` field to tracking #95

Changed

- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
- ️(web-search) allow to override returned chunks #107
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
2025-10-21 22:45:32 +02:00
Quentin BEY 151914f55c ⚗️(summarization) add system prompt to handle tool
With these modifications, we expect the LLM to not sumarize the
summarization tool result.
2025-10-21 22:12:50 +02:00
elvoisin ab45eea736 🔧(front) update links feedback + popup settings (#111)
change tchap channel + change links in settings popup
2025-10-21 17:28:19 +02:00
elvoisin ef7c15507d (front) add drag'n drop file (#110)
add drag n drop in input chat on the entire screen
2025-10-21 16:04:07 +02:00
Quentin BEY ca0c3a0169 🐛(activation-codes) create contact in brevo before add to list
Brevo requires the contact to exist before being added to a list.
2025-10-21 14:18:25 +02:00
Quentin BEY 6b2327b0f1 ️(web-search) allow to override returned chunks
When using the document vector storage, we want to be able to
return more chunks.
2025-10-21 13:55:04 +02:00
Eléonore Voisin 9887d79bd5 💬(wording) error page wording
change error page wording 401 - 403
2025-10-21 13:21:11 +02:00
Arnaud Robin d1852d210c 📈(posthog) add sub field to tracking
This will allow to merge analytics from posthog and langfuse.
2025-10-21 13:12:59 +02:00
qbey 12a9488f30 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-20 23:45:35 +02:00
Quentin BEY 15a84132b0 (activation-codes) register users also on Brevo
This provides a way to store the users on Brevo directly
for later product opening mail campaign.
2025-10-20 23:38:57 +02:00
elvoisin 8a52ae8608 🐛(front) code activation fix session end (#101)
add check auth in activation page
2025-10-20 18:45:56 +02:00
122 changed files with 9402 additions and 2072 deletions
+2
View File
@@ -47,6 +47,7 @@ jobs:
docker-image-name: 'docker.io/lasuite/conversations-backend:${{ github.sha }}'
-
name: Build and push
if: always()
uses: docker/build-push-action@v6
with:
context: .
@@ -86,6 +87,7 @@ jobs:
docker-image-name: 'docker.io/lasuite/conversations-frontend:${{ github.sha }}'
-
name: Build and push
if: always()
uses: docker/build-push-action@v6
with:
context: .
+97 -1
View File
@@ -8,6 +8,94 @@ and this project adheres to
## [Unreleased]
## [0.0.9] - 2025-11-17
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
### Fixed
- 🔊(langfuse) enable tracing with redacted content #162
## [0.0.8] - 2025-11-10
### Fixed
- 🦺(front) Fix send prohibited file types
- 🐛(front) fix target blank links in chat #103
- 🚑️(posthog) pass str instead of UUID for user PK #134
- ⚡️(web-search) keep running when tool call fails #137
- ✨(summarize): new summarize tool integration #78
### Removed
- 🔥(posthog) remove posthog middleware for async mode fix #146
## [0.0.7] - 2025-10-28
### Fixed
- 🚑️(posthog) fix the posthog middleware for async mode #133
## [0.0.6] - 2025-10-28
### Fixed
- 🚑️(stats) fix tracking id in upload event #130
## [0.0.5] - 2025-10-27
### Fixed
- 🚑️(drag-drop) fix the rejection display on Safari #127
## [0.0.4] - 2025-10-27
### Added
- ♿️(a11y) improve accessibility #135
- 🌐(i18n) add dutch language #117
### Changed
- ⚡️(asgi) use `uvicorn` to serve backend #121
### Fixed
- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
## [0.0.3] - 2025-10-21
### Fixed
- 🚑️(web-search) fix missing argument in RAG backend #116
## [0.0.2] - 2025-10-21
### Added
- ✨(front) add drag'n drop file
- ✨(activation-codes) register users also on Brevo #98
- 📈(posthog) add `sub` field to tracking #95
### Changed
- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
- ⚡️(web-search) allow to override returned chunks #107
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
## [0.0.1] - 2025-10-19
### Changed
@@ -65,5 +153,13 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.1...main
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.9...main
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
[0.0.5]: https://github.com/suitenumerique/conversations/releases/v0.0.5
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.4
[0.0.3]: https://github.com/suitenumerique/conversations/releases/v0.0.3
[0.0.2]: https://github.com/suitenumerique/conversations/releases/v0.0.2
[0.0.1]: https://github.com/suitenumerique/conversations/releases/v0.0.1
+16 -3
View File
@@ -144,7 +144,7 @@ RUN rm -rf /var/cache/apk/*
ARG CONVERSATIONS_STATIC_ROOT=/data/static
# Gunicorn
# Gunicorn - not used by default but configuration file is provided
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/conversations.py /usr/local/etc/gunicorn/conversations.py
@@ -158,5 +158,18 @@ COPY --from=link-collector ${CONVERSATIONS_STATIC_ROOT} ${CONVERSATIONS_STATIC_R
# Copy conversations mails
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in conversations's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
# The default command runs uvicorn ASGI server in conversations's main module
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
ENV WEB_CONCURRENCY=4
CMD [\
"uvicorn",\
"--app-dir=/app",\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"conversations.asgi:application"\
]
# To run using gunicorn WSGI server use this instead:
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
+2 -2
View File
@@ -126,7 +126,7 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build frontend openmockllm-mistral $(cache)
.PHONY: build-e2e
down: ## stop and remove containers, networks, images, and volumes
@@ -158,7 +158,7 @@ create-compose-with-models: ## override the docker-compose file with models
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d frontend openmockllm-mistral
.PHONY: run-e2e
status: ## an alias for "docker compose ps"
+37
View File
@@ -115,6 +115,31 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend
```
**Setup a basic LLM call**
To be able to use Conversations, you need to configure at least one Large Language Model (LLM) provider.
You can do so by setting the appropriate environment variables in the `env.d/development/common` file:
```ini
AI_BASE_URL=http://host.docker.internal:12434/v1/
AI_MODEL=gemma3:4b
AI_API_KEY=XXX
```
for a local ollama, or by running a local LLM with docker-compose:
```shellscript
$ make create-compose-with-models
```
which will create a `compose.override.yml` file to start a local models `ai/smollm2`
which can be changed later by editing the `compose.override.yml` file.
You will need to call `make run` after changing the `env.d/development/common`
or `compose.override.yml` file.
You can find more information about configuring LLM providers in the [LLM Configuration](docs/llm-configuration.md) documentation.
**Adding content**
You can create a basic demo site by running this command:
@@ -141,6 +166,18 @@ You first need to create a superuser account:
$ make superuser
```
## Documentation 📚
Additional documentation is available in the `docs/` directory:
- [LLM Configuration](docs/llm-configuration.md) - Configure Large Language Models and providers
- [Attachments](docs/attachments.md) - How to use attachments in conversations
- [Tools for Agents](docs/tools.md) - Available tools and how to add new ones
- [Environment Variables](docs/env.md) - All available environment variables
- [Installation Guide](docs/installation.md) - Deploy on a Kubernetes cluster
- [Theming](docs/theming.md) - Customize the application appearance
- [Architecture](docs/architecture.md) - Technical architecture overview
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/conversations/blob/main/LICENSE)).
+19
View File
@@ -11,3 +11,22 @@ services:
image: conversations:frontend-production
ports:
- "3000:3000"
openmockllm-mistral:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/OpenMockLLM/Dockerfile
image: conversations:openmockllm-mistral
command:
- openmockllm
- --host
- "0.0.0.0"
- --port
- "8000"
- --backend
- mistral
- --model-name
- mistral-mock
ports:
- "8900:8000"
+2 -2
View File
@@ -7,8 +7,8 @@ flowchart TD
User -- HTTP --> Front("Frontend (NextJS SPA)")
Front -- REST API --> Back("Backend (Django)")
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
Back -- REST API --> Yserver
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back --> Cache("Cache (Redis)")
Back ----> S3("Minio (S3)")
Back -- REST API --> LLM("LLM Providers")
```
+400
View File
@@ -0,0 +1,400 @@
# Conversation Attachments
This document describes how conversation attachments work in the Conversations application, including the upload process, security measures, and how documents are processed for use with Large Language Models (LLMs).
## Table of Contents
- [Overview](#overview)
- [Supported Attachment Types](#supported-attachment-types)
- [Architecture & Flow](#architecture--flow)
- [High-Level Overview](#high-level-overview)
- [Detailed Technical Flow](#detailed-technical-flow)
- [Security & Validation](#security--validation)
- [MIME Type Validation](#mime-type-validation)
- [Malware Detection](#malware-detection)
- [Document Processing for LLMs](#document-processing-for-llms)
- [Image Attachments](#image-attachments)
- [PDF Documents](#pdf-documents)
- [Other Document Types](#other-document-types)
- [Configuration](#configuration)
---
## Overview
Conversations allows users to attach files to their conversations with the AI assistant. These attachments can be:
- **Images** (displayed directly to vision-capable LLMs)
- **PDF documents** (sent as document URLs to the LLM)
- **Other documents** (converted to text and indexed for semantic search)
The attachment system uses **S3-compatible object storage** (such as MinIO in development) to store files securely.
The backend generates **presigned URLs** that allow the frontend to upload files directly to the storage,
without routing the file data through the backend server.
Note about documents: The system uses a tool called **MarkItDown** to convert various document formats
(Word, Excel, PowerPoint, text files, etc.) into Markdown text for processing by LLMs. When at least
one non-PDF/image document is attached, the system enables:
- a **Retrieval-Augmented Generation (RAG)** search tool to allow the LLM to query relevant sections of the documents.
- a **summarization tool** to provide document summaries on user request.
⚠️ naive implementation at the moment, needs improvement before being used in production.
## Supported Attachment Types
The following attachment types are supported:
- **Images**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
- **PDF documents**: `application/pdf`
- **Other documents**:
- Microsoft Word: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
- Microsoft Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- Microsoft PowerPoint: `application/vnd.openxmlformats-officedocument.presentationml.presentation`
- Text files: `text/plain`, `text/markdown`, `text/csv`
**Warning**: The current implementation for PDF expects the LLM to be able to manage them. We need to
improve the handling of PDFs in case the LLM cannot process them natively.
**Todo**:
- Add support for more file types and improve document processing workflows.
- Allow PDF management via RAG search when the LLM cannot handle them natively.
- Allow file type restrictions based on model settings, instead of globally.
- Improve the summarization tool to provide better summaries and handle larger documents.
- Start file upload right away when the user selects a file, instead of waiting for the user to send the message.
---
## Architecture & Flow
### High-Level Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │ │ Backend │ │ S3 Storage │ │ Malware Det.│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ 1. Create attachment│ │ │
├────────────────────>│ │ │
│ │ │ │
│ 2. Return presigned │ │ │
│ URL for upload │ │ │
│<────────────────────┤ │ │
│ │ │ │
│ 3. Upload file │ │ │
│ directly to S3 │ │ │
├──────────────────────────────────────────>│ │
│ │ │ │
│ 4. Notify upload │ │ │
│ completed │ │ │
├────────────────────>│ │ │
│ │ │ │
│ │ 5. Detect MIME type │ │
│ ├────────────────────>│ │
│ │ │ │
│ │ 6. Scan for malware │ │
│ ├──────────────────────────────────────────>│
│ │ │ │
│ │ 7. Update status │ │
│ 8. Return status │<──────────────────────────────────────────┤
│<────────────────────┤ │ │
│ │ │ │
```
### Detailed Technical Flow
#### Step 1: Attachment Creation Request
When a user selects a file to upload, the frontend sends a POST request to create an attachment record:
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/`
**Request payload**:
```json
{
"file_name": "document.pdf",
"size": 1048576,
"content_type": "application/pdf"
}
```
**Backend processing** (`ChatConversationAttachmentViewSet.perform_create`):
1. Verifies the user owns the conversation
2. Generates a unique UUID for the file
3. Creates a storage key: `{conversation_id}/attachments/{uuid}.{extension}`
4. Creates a database record with status `PENDING`
**Response**:
```json
{
"id": "uuid-of-attachment",
"key": "conversation-id/attachments/file-id.pdf",
"file_name": "document.pdf",
"size": 1048576,
"upload_state": "pending",
"policy": "https://s3.example.com/bucket/...?presigned-params"
}
```
The `policy` field contains a **presigned URL** valid for a limited time (configured by `AWS_S3_UPLOAD_POLICY_EXPIRATION`).
#### Step 2: Direct Upload to S3
The frontend uses the presigned URL to upload the file directly to S3 storage using a PUT request.
**Technical details**:
- The presigned URL includes authentication parameters
- The upload is done with `Content-Type` header matching the file's MIME type
- No backend involvement in the data transfer
#### Step 3: Upload Completion Notification
After successful upload, the frontend notifies the backend:
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/{attachment_id}/upload-ended/`
**Backend processing** (`ChatConversationAttachmentViewSet.upload_ended`):
1. **MIME Type Detection** (`chat/views.py`):
```python
mime_detector = magic.Magic(mime=True)
with default_storage.open(attachment.key, "rb") as file:
mimetype = mime_detector.from_buffer(file.read(2048))
size = file.size
```
Uses `python-magic` to detect the actual MIME type from file content (first 2048 bytes).
2. **Update attachment status**:
- Status: `PENDING` → `ANALYZING`
- Store detected MIME type and actual file size
3. **Trigger Malware Detection**:
```python
malware_detection.analyse_file(
attachment.key,
safe_callback="chat.malware_detection.conversation_safe_attachment_callback",
unknown_callback="chat.malware_detection.unknown_attachment_callback",
unsafe_callback="chat.malware_detection.conversation_unsafe_attachment_callback",
conversation_id=conversation_id,
)
```
#### Step 4: Malware Detection Callbacks
The malware detection service (configurable via `MALWARE_DETECTION_BACKEND`) scans the file and calls one of three callbacks:
**Safe file** (`conversation_safe_attachment_callback`):
- Status: `ANALYZING` → `READY`
- File is ready for use
**Unsafe file** (`conversation_unsafe_attachment_callback`):
- Status: `ANALYZING` → `SUSPICIOUS`
- File is quarantined and not accessible
- Security log entry created
**Unknown status** (`unknown_attachment_callback`):
- Handles special cases (e.g., file too large to analyze)
- Status: `ANALYZING` → `FILE_TOO_LARGE_TO_ANALYZE`
---
## Security & Validation
For now, the system is not intended to host user-uploaded files for public download.
All files are stored in private S3 buckets with presigned URLs for controlled access and only
the owner of the conversation/the uploader can access them, so the risk is quite low around bad use of
the attachment system.
Also, the document content is sent to the LLM and does not prevent any prompt injection attacks, which is not
an issue specific to the attachment system but to the overall design of LLM-based applications and should be
addressed globally. Also for the moment, the system does not have any action tools that could be used to execute
malicious code based on document content.
### Malware Detection
The malware detection system is **pluggable** and configurable, allowing different backends to be used.
By default, a `DummyBackend` is provided that marks all files as safe.
⚠️ The current implementation does not disallow any file types or status from being used in conversations.
This is a potential security risk and should be addressed in future versions.
---
## Document Processing for LLMs
When a user sends a message with attachments, the system processes them differently based on their type:
### Image Attachments
**MIME types**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, etc.
**Processing flow**:
1. **URL Conversion**: Local media URLs are converted to presigned S3 URLs before sending to the LLM:
```python
# From: chat/agents/local_media_url_processors.py
content.url = generate_retrieve_policy(key)
```
2. **Sent to LLM**: Images are sent as `ImageUrl` objects in the prompt:
```python
ImageUrl(
url="https://s3.example.com/bucket/key?presigned-params",
identifier="file-id.png",
)
```
3. **Vision models** can analyze the image content directly.
4. **Response processing**: After the LLM responds, presigned URLs are converted back to local URLs for storage:
```python
# Mapping: presigned_url -> /media-key/{conversation_id}/attachments/{file_id}.png
```
### PDF Documents
**MIME type**: `application/pdf`
**Processing flow**:
1. **Direct URL passing**: PDFs are sent as `DocumentUrl` objects :
```python
DocumentUrl(
url="https://s3.example.com/bucket/key?presigned-params",
identifier="file-id.pdf",
)
```
2. **LLM processing**: Compatible LLMs can:
- Extract and read text from PDFs
- Understand document structure
- Answer questions about the content
3. **No conversion needed**: PDFs are passed directly without preprocessing.
### Other Document Types
**MIME types**: Word documents, Excel spreadsheets, PowerPoint, text files, Markdown, etc.
**Processing flow**:
1. **Document parsing**: When a document is uploaded, it's parsed using the `AlbertRagBackend` class.
2. **Conversion to Markdown**: Documents are converted using **MarkItDown** library or using the "Albert API" for PDFs.
3. **RAG (Retrieval-Augmented Generation)**:
- Converted text is indexed in a vector database
- The LLM uses a `document_rag_search` tool to query relevant sections
- Only relevant chunks are sent to the LLM to fit context windows
4. **Summarization tool** if needed.
### Processing Strategy Decision Tree
**Decision logic**:
- **No documents**: Standard conversation
- **Images**: Send as direct (presigned) URLs to the LLM
- **Only PDFs**: Send as direct (presigned) URLs to the LLM
- **Other documents present**: Enable RAG search tool + convert to Markdown
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------------------------------------------|----------------|------------------------------------------------------------|
| `ATTACHMENT_MAX_SIZE` | Configurable | Maximum file size in bytes |
| `ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED` | `True` | Enable/disable MIME type validation |
| `AWS_S3_UPLOAD_POLICY_EXPIRATION` | 3600 | Presigned URL expiration (seconds) |
| `AWS_S3_RETRIEVE_POLICY_EXPIRATION` | 3600 | Presigned retrieval URL expiration (seconds) |
| `AWS_S3_DOMAIN_REPLACE` | None | Alternative S3 domain for presigned URLs (for development) |
| `MALWARE_DETECTION_BACKEND` | `DummyBackend` | Malware scanning backend class |
| `MALWARE_DETECTION_PARAMETERS` | `{}` | Backend-specific configuration |
| `RAG_FILES_ACCEPTED_FORMATS` | See below | List of MIME types accepted for file uploads |
#### RAG_FILES_ACCEPTED_FORMATS
This environment variable controls which file types users are allowed to upload as attachments to conversations.
**Configuration**:
- **Type**: List of strings (comma-separated MIME types when using environment variable)
- **Default value**: Includes a comprehensive list of document and image formats:
- Microsoft Office documents (`.docx`, `.pptx`, `.xlsx`, `.xls`)
- Text files (`.txt`, `.csv`)
- PDF documents (`.pdf`)
- HTML files
- Markdown files (`.md`)
- Outlook messages (`.msg`)
- Images (`.jpeg`, `.png`, `.gif`, `.webp`)
**Example configuration**:
```ini
# In environment variable (comma-separated)
RAG_FILES_ACCEPTED_FORMATS="application/pdf,text/plain,image/png,image/jpeg"
```
```python
# In Django settings (as a Python list)
RAG_FILES_ACCEPTED_FORMATS = [
"application/pdf",
"text/plain",
"image/png",
"image/jpeg",
]
```
**How it's used**:
1. **Backend**: The list is exposed via the `/api/v1.0/config/` endpoint as `chat_upload_accept` (MIME types joined with commas)
2. **Frontend**: The configuration is used to validate files before upload in the chat interface:
- Checks exact MIME type matches
- Supports wildcard patterns (e.g., `image/*` for all image types)
- Supports file extension patterns (e.g., `.pdf`)
3. **User experience**: Files that don't match the accepted formats are rejected with a user-friendly error message
**Notes**:
- This setting controls frontend validation only. Backend validation should also be implemented for security.
- Future improvements may include per-model file type restrictions.
### Storage Configuration
**MinIO (Development)**:
```yaml
# docker-compose.yml
minio:
image: minio/minio
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
```
---
## Troubleshooting
### LLM Cannot Access Image/PDF
**Possible causes**:
- Presigned URL has expired
- S3 storage is not accessible from the LLM provider
- CORS configuration issues
**Solution**: Check `AWS_S3_RETRIEVE_POLICY_EXPIRATION` and S3 access policies.
### Document Not Appearing in RAG Search
**Possible causes**:
- Document conversion failed
- Vector database indexing failed
**Check logs**: Look for errors in `DocumentConverter` and RAG backend logs.
---
## Related Documentation
- [Installation Guide](installation.md) - S3 storage setup
- [LLM Configuration](llm-configuration.md) - Model capabilities for attachments
- [Architecture](architecture.md) - System overview
- [Tools](tools.md) - Document search and RAG tools
+9 -9
View File
@@ -10,7 +10,6 @@ These are the environment variables you can set for the `conversations-backend`
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | conversations |
| DB_USER | user to authenticate with | dinum |
@@ -24,12 +23,11 @@ These are the environment variables you can set for the `conversations-backend`
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | conversations-media-storage |
| ATTACHMENT_MAX_SIZE | maximum size of document in bytes | 10485760 |
| ATTACHMENT_MAX_SIZE | maximum size of document in bytes | 10485760 |
| LANGUAGE_CODE | default language | en-us |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_HOST | host name of email | |
@@ -76,12 +74,14 @@ These are the environment variables you can set for the `conversations-backend`
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_AGENT_INSTRUCTION | Base instruction for the AI agent | You are a helpful assistant |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| LLM_CONFIGURATION_FILE_PATH | Path to the LLM configuration JSON file. See [LLM Configuration](llm-configuration.md) for details | <BASE_DIR>/conversations/configuration/llm/default.json |
| LLM_DEFAULT_MODEL_HRID | HRID of the model used for conversations | default-model |
| LLM_SUMMARIZATION_MODEL_HRID | HRID of the model used for summarization | default-summarization-model |
| AI_API_KEY | AI API key to be used for the default provider (used in default LLM configuration, not for production use) | |
| AI_BASE_URL | OpenAI compatible AI base URL (used in default LLM configuration, not for production use) | |
| AI_MODEL | AI Model name to use (used in default LLM configuration, not for production use) | |
| AI_AGENT_INSTRUCTIONS | Base instruction for the AI agent (used in default LLM configuration, not for production use) | You are a helpful assistant. Wrap formulas... |
| AI_AGENT_TOOLS | List of enabled tools for the agent (used in default LLM configuration, not for production use) | [] |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
-1
View File
@@ -9,7 +9,6 @@ backend:
DJANGO_CSRF_TRUSTED_ORIGINS: https://conversations.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: conversations.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: conversations.settings
DJANGO_SUPERUSER_PASSWORD: admin
+1 -1
View File
@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Conversation
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example)
- a Memcached server (if you don't have one, we provide an example)
- a Redis server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example)
### Test cluster
+412
View File
@@ -0,0 +1,412 @@
# LLM Configuration
This document describes how to configure Large Language Models (LLMs) in Conversations via the configuration file.
## Overview
Conversations uses a JSON configuration file to define LLM models and providers. This approach allows you to:
- Configure multiple LLM models from different providers
- Switch between models without code changes
- Customize model-specific settings like temperature, max tokens, and system prompts
- Enable or disable models dynamically
The overall structure consists of two main sections: `providers` and `models`.
Settings for models, provides customization through `settings` and `profile`, which corresponds to the
Pydantic AI model settings and profile. While we currently not use those settings extensively,
they are available for future use and advanced configurations, please reach us if you face any problem using them.
## Configuration File Location
The default LLM configuration file is located at:
```
src/backend/conversations/configuration/llm/default.json
```
You can override this location by setting the `LLM_CONFIGURATION_FILE_PATH` environment variable, but be careful as
this path must be accessible by the backend application _inside the docker image_:
``` ini
LLM_CONFIGURATION_FILE_PATH=/path/to/your/llm/config.json
```
## Default Behavior
### Default Configuration
The default configuration file is useful for local development and running the test, while it can be used
in production, we suggest to create a specific one for production and replace the `settings.` values with
`environ.` one.
The default configuration file (`default.json`) includes:
1. **Two default models**:
- `default-model`: The primary conversational model used for chat interactions
- `default-summarization-model`: A specialized model for summarizing conversations
2. **One default provider**:
- `default-provider`: An OpenAI-compatible provider that uses environment variables for configuration
### Environment Variable Integration
The configuration uses dynamic value resolution with two special prefixes:
- `settings.VARIABLE_NAME`: Resolves to a Django setting value
- `environ.VARIABLE_NAME`: Resolves to an environment variable value
For example, in the default configuration:
```json
{
"model_name": "settings.AI_MODEL",
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": "settings.AI_AGENT_TOOLS"
}
```
This allows to configure models in tests using the setting override mechanism from Django/Pytest (but might be replaced
later with a simple override of the full configuration like it's done in some tests already).
### Required Environment Variables
For the default configuration to work, you need to set these environment variables:
| Variable | Description | Example |
|-------------------------------|----------------------------------------|-----------------------------|
| `AI_API_KEY` | API key for the default provider | `sk-...` |
| `AI_BASE_URL` | Base URL for the OpenAI-compatible API | `https://api.openai.com/v1` |
| `AI_MODEL` | Model name to use | `gpt-4o-mini` |
### Optional Environment Variables
If you want to customize the agent behavior and tools, you can set these optional environment variables
(defaults are provided in the default configuration):
| Variable | Description | Default |
|-------------------------------|----------------------------------------|-------------------|
| `AI_AGENT_INSTRUCTIONS` | System prompt for the agent | see `settings.py` |
| `AI_AGENT_TOOLS` | List of enabled tools | `[]` |
| `SUMMARIZATION_SYSTEM_PROMPT` | Base prompt of the summarization agent | see `settings.py` |
### Model Selection
You can configure which models are used for specific tasks via environment variables:
| Variable | Description | Default |
|--------------------------------|------------------------------------------|-------------------------------|
| `LLM_DEFAULT_MODEL_HRID` | HRID of the model used for conversations | `default-model` |
| `LLM_SUMMARIZATION_MODEL_HRID` | HRID of the model used for summarization | `default-summarization-model` |
## Configuration Structure
The configuration file has two main sections:
### 1. Providers
Providers define the API endpoints and authentication for LLM services.
```json
{
"providers": [
{
"hrid": "unique-provider-id",
"base_url": "https://api.example.com/v1",
"api_key": "environ.API_KEY_VAR",
"kind": "openai"
}
]
}
```
**Provider Fields:**
| Field | Type | Required | Description |
|------------|--------|----------|---------------------------------------------------------|
| `hrid` | string | Yes | Unique identifier for the provider |
| `base_url` | string | Yes | API base URL (can use `settings.` or `environ.` prefix) |
| `api_key` | string | Yes | API authentication key (use `environ.` here) |
| `kind` | string | Yes | Provider type: `openai` or `mistral` |
### 2. Models
Models define the LLMs available in your application.
```json
{
"models": [
{
"hrid": "unique-model-id",
"model_name": "gpt-4o-mini",
"human_readable_name": "GPT-4o Mini",
"provider_name": "unique-provider-id",
"profile": null,
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a helpful assistant",
"tools": []
}
]
}
```
**Model Fields:**
| Field | Type | Required | Description |
|-----------------------|--------------|----------|-----------------------------------------------------------------------------------------------------|
| `hrid` | string | Yes | Unique identifier for the model |
| `model_name` | string | Yes | Name of the model as recognized by the provider (can use `settings.` or `environ.` prefix) |
| `human_readable_name` | string | Yes | Display name shown to users |
| `provider_name` | string | No* | Reference to a provider's `hrid` |
| `provider` | object | No* | Inline provider definition (alternative to `provider_name`) |
| `profile` | object | No | Model-specific capabilities and settings |
| `settings` | object | No | Model inference settings (temperature, max_tokens, etc.) |
| `is_active` | boolean | Yes | Whether the model is available for use |
| `icon` | string/array | No | Base64-encoded icon or array of icon parts |
| `system_prompt` | string | Yes | Default system prompt for the model (can use `settings.` or `environ.` prefix) |
| `tools` | array | Yes | List of enabled tools for this model (can use `settings.` or `environ.` prefix for the whole array) |
| `supports_streaming` | boolean | No | Whether the model supports streaming responses |
\* Either `provider_name` or `provider` must be set, unless `model_name` is in the format `<provider>:<model>`.
## Adding New Models
### Example 1: Adding a New OpenAI Model
To add a new OpenAI model using the existing default provider:
```json
{
"models": [
// ...existing models...
{
"hrid": "gpt-4-turbo",
"model_name": "gpt-4-turbo-preview",
"human_readable_name": "GPT-4 Turbo",
"provider_name": "default-provider",
"profile": null,
"settings": {
"temperature": 0.7,
"max_tokens": 4096
},
"is_active": true,
"icon": null,
"system_prompt": "You are an expert AI assistant.",
"tools": ["web_search_brave_with_document_backend"],
"supports_streaming": true
}
],
"providers": [
// ...existing providers...
]
}
```
### Example 2: Adding a Model using Pydantic AI format
To add a model with a specific provider using the default Pydantic AI format, you don't need to define the provider separately if you use the `model_name` format `<provider>:<model>`.
1. **Add the model without provider**:
```json
{
"models": [
{
"hrid": "claude-3-opus",
"model_name": "anthropic:claude-3-opus-20240229",
"human_readable_name": "Claude 3 Opus",
"provider_name": null,
"profile": null,
"settings": {
"temperature": 0.7,
"max_tokens": 4096
},
"is_active": true,
"icon": null,
"system_prompt": "You are Claude, a helpful AI assistant.",
"tools": []
}
],
"providers": []
}
```
2**Set the environment variable**:
Pydantic AI expects the API key in an environment variable named `ANTHROPIC_API_KEY` is this example, so set it accordingly:
```ini
ANTHROPIC_API_KEY=your-api-key-here
```
### Example 3: Adding a Mistral Model
For Mistral AI models using the Etalab platform:
```json
{
"models": [
{
"hrid": "mistral-large",
"model_name": "mistral-large-latest",
"human_readable_name": "Mistral Large (Etalab)",
"provider_name": "mistral-etalab",
"profile": null,
"settings": {
"temperature": 0.5,
"max_tokens": 8192
},
"is_active": true,
"icon": null,
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": ["web_search_brave_with_document_backend"]
}
],
"providers": [
{
"hrid": "mistral-etalab",
"base_url": "https://api.mistral.etalab.gouv.fr/",
"api_key": "environ.MISTRAL_ETALAB_API_KEY",
"kind": "mistral"
}
]
}
```
### Example 4: Using Inline Provider Definition
Instead of referencing a provider by name, you can define it inline if you use a unique configuration:
```json
{
"models": [
{
"hrid": "custom-model",
"model_name": "custom-model-v1",
"human_readable_name": "Custom Model",
"provider": {
"hrid": "custom-provider-inline",
"base_url": "https://custom-api.example.com/v1",
"api_key": "environ.CUSTOM_API_KEY",
"kind": "openai"
},
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a custom assistant.",
"tools": []
}
]
}
```
## Advanced Configuration
### Model Settings
The `settings` object supports various inference parameters:
```json
{
"settings": {
"max_tokens": 4096,
"temperature": 0.7,
"top_p": 0.9,
"timeout": 60.0,
"parallel_tool_calls": true,
"seed": 42,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"logit_bias": {},
"stop_sequences": [],
"extra_headers": {},
"extra_body": {}
}
}
```
### Model Profile
The `profile` object defines model capabilities:
```json
{
"profile": {
"supports_tools": true,
"supports_json_schema_output": true,
"supports_json_object_output": true,
"default_structured_output_mode": "json_schema",
"thinking_tags": ["<thinking>", "</thinking>"],
"ignore_streamed_leading_whitespace": true
}
}
```
### Available Tools
Tools can be specified in the `tools` array. Common tools include:
- `web_search_brave_with_document_backend`: Web search using Brave API with document processing
You can also reference the tools list from Django settings:
```json
{
"tools": "settings.AI_AGENT_TOOLS"
}
```
### Custom Icons
Icons can be provided as base64-encoded PNG images. For long strings, you can split them into an array:
```json
{
"icon": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+m",
"AAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAziABUAGIWbpsTwq7HhAAAA"
]
}
```
## Validation
The configuration is validated when loaded. Common validation errors include:
- **Provider not found**: A model references a `provider_name` that doesn't exist in the `providers` array
- **Missing provider**: Neither `provider_name` nor `provider` is specified, and `model_name` is not in `<provider>:<model>` format
- **Environment variable not set**: A value using `environ.` prefix references an undefined environment variable
- **Django setting not set**: A value using `settings.` prefix references an undefined Django setting
- **Invalid provider kind**: The `kind` field must be either `openai` or `mistral`
## Testing Your Configuration
After modifying the configuration file, you can test it by:
1. **Checking for syntax errors**:
```bash
python -m json.tool src/backend/conversations/configuration/llm/default.json
```
2. **Starting the application** and checking the logs for validation errors
3. **Using the Django shell** to load the configuration:
```bash
./bin/manage shell
```
```python
from django.conf import settings
models = settings.LLM_CONFIGURATIONS
models.keys() # Should show all model HRIDs
```
## Best Practices
1. **Use environment variables** for sensitive data like API keys (with `environ.` prefix)
2. **Use Django settings** for configurable values that may change between environments (with `settings.` prefix)
3. **Keep provider definitions separate** from models to avoid duplication when using multiple models from the same provider
4. **Set `is_active: false`** for models you want to keep in the configuration but temporarily disable
5. **Use descriptive `hrid` values** that clearly identify the model and provider
6. **Document custom configurations** in your deployment documentation
7. **Test configuration changes** in a development environment before deploying to production
## See Also
- [Environment Variables Documentation](env.md) - For configuring environment variables
- [Installation Guide](installation.md) - For deployment instructions
+20 -20
View File
@@ -14,15 +14,15 @@ Memory is the first bottleneck; CPU matters only when Celery or the Next.js buil
## 2. Development Environment Memory Requirements
| Service | Typical use | Rationale / source |
|-----------------------|-------------------------------|-----------------------------------------------------------------------------------------|
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API (+ Celery) | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
| Service | Typical use | Rationale / source |
|------------------|-------------------------------|-----------------------------------------------------------------------------------------|
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
@@ -58,7 +58,7 @@ Production deployments differ significantly from development environments. The t
| Service | Memory | Notes |
|----------------------------------|------------|----------------------------------------|
| PostgreSQL | **2 GB** | Core database |
| Django API (+ Celery) | **1.5 GB** | Backend services |
| Django API | **1.5 GB** | Backend services |
| Nginx | **100 MB** | Static files + reverse proxy |
| Redis | **256 MB** | Session storage |
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
@@ -81,16 +81,16 @@ Production deployments differ significantly from development environments. The t
## 5. Ports (dev defaults)
| Port | Service |
|-----------|-----------------------|
| 3000 | Next.js |
| 8071 | Django |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | Maildev |
| Port | Service |
|-----------|----------------------------|
| 3000 | Next.js |
| 8071 | Django |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | Maildev (currently unused) |
## 6. Sizing Guidelines
+4 -4
View File
@@ -4,7 +4,7 @@
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
```ini
FRONTEND_CSS_URL=http://anything/custom-style.css
```
@@ -38,7 +38,7 @@ The footer is configurable from the theme customization file.
### Settings 🔧
```shellscript
```ini
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
@@ -55,10 +55,10 @@ The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
```ini
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
+238
View File
@@ -0,0 +1,238 @@
# Tools for the Conversation Agent
The conversation agent can be extended with various tools that provide additional capabilities such as web search,
weather information, and more. We currently only have web search tools, but more tools can be added as needed.
This document explains how to configure and use these tools.
## Overview
Tools are functions that the LLM can call during a conversation to access external data or perform specific actions.
The agent decides when to use these tools based on the user's query and the conversation context.
## Configuring Tools for a Model
Tools are configured at the model level in the LLM configuration file.
Each model can have its own set of available tools.
### Configuration File Location
Read the [LLM Configuration](llm-configuration.md) document to find out where the configuration file is located
and how to use it.
### Example Configuration
```json
{
"models": [
{
"hrid": "default-model",
"model_name": "gpt-4",
"human_readable_name": "GPT-4 with Tools",
"provider_name": "default-provider",
"is_active": true,
"system_prompt": "You are a helpful assistant.",
"tools": [
"web_search_brave",
"get_current_weather"
]
}
],
"providers": [
{
"hrid": "default-provider",
"base_url": "https://api.openai.com/v1",
"api_key": "settings.AI_API_KEY",
"kind": "openai"
}
]
}
```
The `tools` field accepts either:
- A list of tool names: `["tool_name_1", "tool_name_2"]`
- A reference to a settings variable: `"settings.AI_AGENT_TOOLS"`
## Available Tools
To make a tool available to be in a model's configuration, it must be registered in the tool registry located at
`src/backend/chat/tools/__init__.py`.
This is not dynamic - any changes to the tool registry require a code deployment...
We want to add dynamic loading in the future.
| Tool Name | Description | Documentation |
|------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------------|
| `get_current_weather` | Fake weather tool for testing purposes | [Details](tools/get_current_weather.md) |
| `web_search_tavily` | Web search using Tavily API | [Details](tools/web_search_tavily.md) |
| `web_search_brave` | Web search using Brave Search API with optional summarization | [Details](tools/web_search_brave.md) |
| `web_search_brave_with_document_backend` | Web search using Brave with RAG-based document processing | [Details](tools/web_search_brave.md#web_search_brave_with_document_backend) |
| `web_search_albert_rag` | ⚠️ **Deprecated** - Web search using Albert API with RAG | [Details](tools/web_search_brave.md#deprecated-web_search_albert_rag) |
## Adding a New Tool
To add a new tool to the system, follow these steps:
### 1. Create the Tool Function
Create a new Python file in `src/backend/chat/tools/` with your tool function. The function should:
- Have clear type annotations
- Include a comprehensive docstring (the LLM uses this to understand when to use the tool)
- Accept `RunContext` as the first parameter if it needs access to conversation context
- Return appropriate data types
Example:
```python
"""My custom tool for the chat agent."""
from pydantic_ai import RunContext
def my_custom_tool(ctx: RunContext, param1: str, param2: int) -> dict:
"""
Brief description of what the tool does.
The LLM uses this description to decide when to call this tool.
Args:
ctx (RunContext): The run context containing the conversation.
param1 (str): Description of parameter 1.
param2 (int): Description of parameter 2.
Returns:
dict: Description of the return value.
"""
# Your implementation here
return {"result": "example"}
```
### 2. Register the Tool
Add your tool to the registry in `src/backend/chat/tools/__init__.py`:
```python
from .my_custom_tool import my_custom_tool
def get_pydantic_tools_by_name(name: str) -> Tool:
"""Get a tool by its name."""
tool_dict = {
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
"web_search_brave": Tool(
web_search_brave, takes_ctx=False, prepare=only_if_web_search_enabled
),
# Add your tool here
"my_custom_tool": Tool(
my_custom_tool,
takes_ctx=True, # Set to True if your tool needs RunContext
# prepare=only_if_web_search_enabled # Optional: add conditions
),
}
return tool_dict[name]
```
### 3. Update Imports
Don't forget to import your tool function at the top of `__init__.py`:
```python
from .my_custom_tool import my_custom_tool
```
### 4. Add to Model Configuration
Add your tool name to the `tools` list in your LLM configuration file or
to the `AI_AGENT_TOOLS` environment variable for local/test purpose.
## Tool Preparation: Conditional Tool Availability
Some tools should only be available under certain conditions. The `prepare` parameter in the `Tool` constructor
allows you to specify a function that determines whether a tool should be included.
### The `only_if_web_search_enabled` Prepare Function
This is a built-in prepare function that checks if web search feature is enabled in the conversation context:
```python
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
"""Prepare function to include a tool only if web search is enabled in the context."""
return tool_def if ctx.deps.web_search_enabled else None
```
### Usage
All web search tools use this prepare function:
```python
"web_search_brave": Tool(
web_search_brave,
takes_ctx=False,
prepare=only_if_web_search_enabled
),
```
This ensures that web search tools are only available when the user or conversation settings have enabled web search functionality.
### Creating Custom Prepare Functions
You can create your own prepare functions for custom conditions:
```python
async def only_if_feature_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
"""Include tool only if a specific feature is enabled."""
return tool_def if ctx.deps.feature_enabled else None
```
## Web Search Enable/Disable
Web search tools can be toggled on or off based on conversation settings. When web search is disabled:
- Web search tools are not included in the agent's available tools
- The LLM cannot make web search calls even if it tries
- This is enforced by the `only_if_web_search_enabled` prepare function
The `web_search_enabled` flag is typically set:
- Per conversation in the conversation settings
- Per user preference
- Through admin configuration
## Best Practices
1. **Keep tools focused** - Each tool should do one thing well
2. **Clear documentation** - The LLM relies on docstrings to understand when to use tools
3. **Error handling** - Tools should handle errors gracefully and return meaningful messages
4. **Performance** - Be mindful of API rate limits and timeout values
5. **Security** - Never log sensitive data (API keys, user data, etc.)
6. **Caching** - Use Django's cache framework for expensive operations when appropriate
## Troubleshooting
### Tool Not Being Called
If the LLM isn't calling your tool:
- Check that the tool is registered in `get_pydantic_tools_by_name`
- Verify the tool is in the model's `tools` configuration
- Review the tool's docstring - make it clearer when the tool should be used
- Check if any `prepare` function is preventing the tool from being included
### Tool Errors
If a tool is throwing errors:
- Check the logs for detailed error messages
- Verify all required environment variables are set
- Ensure the tool's dependencies are installed
- Test the tool function independently
We recommend wrapping external API calls in try/except blocks to handle potential issues gracefully and use
the Pydantic AI `ModelRetry` exception to let the LLM manage the errors.
### Tool Response Issues
If the LLM isn't using the tool response correctly:
- Ensure the return type is clear and well-structured
- Consider returning a `ToolReturn` object with metadata
- Check if the response format matches what the LLM expects
## See Also
- [Web Search Configuration](llm-configuration.md)
- [Architecture](architecture.md)
- [Environment Variables](env.md)
+113
View File
@@ -0,0 +1,113 @@
# get_current_weather Tool
## Overview
The `get_current_weather` tool is a **fake weather tool** designed for testing and demonstration purposes. It does not connect to any real weather API and always returns hardcoded weather data.
## Purpose
This tool is useful for:
- **Testing** the tool calling functionality of LLMs
- **Demonstrating** how tools work without requiring API keys
- **Development** and debugging of the agent system
- **Example implementation** for creating new tools
⚠️ **Warning**: This tool should **not** be used in production environments. It always returns fake data regardless of the location or conditions.
## Configuration
### Add to Model
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"get_current_weather"
]
}
]
}
```
Or via environment variable when using local environment settings:
```ini
AI_AGENT_TOOLS=get_current_weather
```
### No Additional Settings Required
This tool does not require any API keys, environment variables, or additional configuration.
## Function Signature
```python
def get_current_weather(location: str, unit: str) -> dict:
"""
Get the current weather in a given location.
Args:
location (str): The city and state, e.g. San Francisco, CA.
unit (str): The unit of temperature, either 'celsius' or 'fahrenheit'.
Returns:
dict: A dictionary containing the location, temperature, and unit.
"""
```
## Parameters
| Parameter | Type | Required | Description |
|------------|------|----------|-----------------------------------------------------------------|
| `location` | str | Yes | The city and state (e.g., "San Francisco, CA", "Paris, France") |
| `unit` | str | Yes | Temperature unit: either "celsius" or "fahrenheit" |
## Return Value
Returns a dictionary with the following structure:
```python
{
"location": str, # The location that was queried
"temperature": int, # Always 22°C or 72°F
"unit": str # The unit that was requested
}
```
## How the LLM Uses It
When a user asks about weather, the LLM will:
1. **Recognize** the weather-related query
2. **Extract** the location from the user's message
3. **Determine** the appropriate unit (often from context or user preference)
4. **Call** the `get_current_weather` tool
5. **Receive** the fake weather data
6. **Format** a response to the user
### Example Conversation
**User**: "What's the weather like in London?"
**LLM** (internal): *Calls `get_current_weather("London, UK", "celsius")`*
**Tool Response**:
```json
{
"location": "London, UK",
"temperature": 22,
"unit": "celsius"
}
```
**LLM** (to user): "The current weather in London, UK is 22°C."
## See Also
- [Tools Overview](../tools.md)
- [Adding a New Tool](../tools.md#adding-a-new-tool)
- [Testing Tools](../tools.md#testing-your-tools)
+670
View File
@@ -0,0 +1,670 @@
# Brave Web Search Tools
## Overview
The Brave web search tools enable the conversation agent to search the web using the [Brave Search API](https://brave.com/search/api/).
Brave Search is a privacy-focused search engine that provides comprehensive web search results.
This documentation covers three related tools:
1. **`web_search_brave`** - Standard web search with optional summarization
2. **`web_search_brave_with_document_backend`** - Web search with RAG-based document processing
3. **`web_search_albert_rag`** - ⚠️ **Deprecated** - Use `web_search_brave_with_document_backend` instead
## Table of Contents
- [Common Configuration](#common-configuration)
- [web_search_brave](#web_search_brave)
- [web_search_brave_with_document_backend](#web_search_brave_with_document_backend)
- [Deprecated: web_search_albert_rag](#deprecated-web_search_albert_rag)
- [Comparison](#comparison)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
---
## Common Configuration
### Prerequisites
1. **Brave Search API Key**: Sign up at [Brave Search API](https://brave.com/search/api/) to get an API key
2. **Environment Variables**: Configure the required settings
### Common Environment Variables
All Brave tools share these common settings:
| Variable | Required | Default | Description |
|---------------------|----------|---------|----------------------------------------------------|
| `BRAVE_API_KEY` | **Yes** | None | Your Brave Search API key |
| `BRAVE_API_TIMEOUT` | No | 5 | API request timeout in seconds |
| `BRAVE_MAX_RESULTS` | No | 8 | Maximum number of search results |
| `BRAVE_CACHE_TTL` | No | 1800 | Cache time-to-live in seconds (30 minutes) |
### Search Parameters
Check on the Brave API documentation for more details on these parameters:
| Variable | Required | Default | Description |
|-------------------------------|----------|------------|---------------------------------------------------|
| `BRAVE_SEARCH_COUNTRY` | No | None | Country code for search (e.g., "US", "FR") |
| `BRAVE_SEARCH_LANG` | No | None | Language code (e.g., "en", "fr") |
| `BRAVE_SEARCH_SAFE_SEARCH` | No | "moderate" | Safe search level: "off", "moderate", or "strict" |
| `BRAVE_SEARCH_SPELLCHECK` | No | True | Enable spell checking |
| `BRAVE_SEARCH_EXTRA_SNIPPETS` | No | True | Fetch extra snippets from pages |
Note: even if `BRAVE_SEARCH_EXTRA_SNIPPETS` is enabled, the API may not include them if you don't have a plan for this.
This is why, in `web_search_brave`, we also fetch the page content ourselves when needed.
### Configuration Example
```bash
# .env file
BRAVE_API_KEY=BSA-your-api-key-here
BRAVE_MAX_RESULTS=8
BRAVE_MAX_WORKERS=4
BRAVE_SEARCH_COUNTRY=US
BRAVE_SEARCH_LANG=en
BRAVE_SEARCH_SAFE_SEARCH=moderate
```
### Django Settings
All Brave settings are defined in `src/backend/conversations/brave_settings.py`:
```python
class BraveSettings:
"""Brave settings for web_search_brave tool."""
BRAVE_API_KEY = values.Value(
default=None,
environ_name="BRAVE_API_KEY",
environ_prefix=None,
)
# ... more settings
```
---
## web_search_brave
### Overview
Standard Brave web search tool with optional LLM-based summarization of page content.
### Purpose
- Search the web for up-to-date information
- Extract content from web pages
- Optionally summarize content using an LLM
- Provide structured results with snippets
### Additional Configuration
| Variable | Required | Default | Description |
|-------------------------------|----------|---------|-------------------------------------------------|
| `BRAVE_SUMMARIZATION_ENABLED` | No | False | Enable LLM-based summarization of fetched pages |
### Function Signature
```python
def web_search_brave(query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
ToolReturn: Formatted search results with metadata
"""
```
### Return Value
Returns a `ToolReturn` object with:
```python
ToolReturn(
return_value={
"0": {
"url": "https://example.com/page1",
"title": "Example Page Title",
"snippets": ["Extracted or summarized content..."]
},
"1": {
"url": "https://example.com/page2",
"title": "Another Page",
"snippets": ["More content..."]
}
},
metadata={
"sources": {
"https://example.com/page1",
"https://example.com/page2"
}
}
)
```
### How It Works
1. **Query API**: Sends search query to Brave Search API
2. **Receive Results**: Gets list of matching web pages
3. **Fetch Content**: For results without extra_snippets:
- Fetches the HTML content using `trafilatura`
- Extracts the main text content
- Caches the extracted content
4. **Summarize (Optional)**: If `BRAVE_SUMMARIZATION_ENABLED=True`:
- Sends extracted content to summarization agent
- Receives concise summary focused on the query
5. **Format Results**: Returns structured data with URLs, titles, and snippets
### Workflow Diagram
```
User Query
Brave Search API
Search Results (URLs, titles, descriptions)
[For each result without snippets]
Fetch HTML (trafilatura) → Extract Text → Cache
[If BRAVE_SUMMARIZATION_ENABLED]
Summarization Agent (LLM)
Summary Text
Format & Return
```
### Caching
Extracted content is cached to avoid repeated fetches:
```python
cache_key = f"web_search_brave:extract:{url}"
cache.set(cache_key, document, settings.BRAVE_CACHE_TTL)
```
**Cache Duration**: Controlled by `BRAVE_CACHE_TTL` (default: 30 minutes)
### Summarization
When enabled, the tool uses the `SummarizationAgent` to condense page content:
```python
prompt = f"""
Based on the following request, summarize the following text in a concise manner,
focusing on the key points regarding the user request.
The result should be up to 30 lines long.
<user request>
{query}
</user request>
<text to summarize>
{text}
</text to summarize>
"""
```
**Note**: Summarization is costly (additional LLM calls).
Use only when necessary, we prefer the document vector search from `web_search_brave_with_document_backend`.
### Add to Model
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_brave"
]
}
]
}
```
### Example Usage
**User**: "What are the new features in Django 5.0?"
**Tool Call**: `web_search_brave("Django 5.0 new features")`
**Tool Response**:
```python
{
"0": {
"url": "https://docs.djangoproject.com/en/5.0/releases/5.0/",
"title": "Django 5.0 release notes",
"snippets": ["Django 5.0 introduces several new features including..."]
},
# ... more results
}
```
### Registration
```python
"web_search_brave": Tool(
web_search_brave,
takes_ctx=False,
prepare=only_if_web_search_enabled
)
```
---
## web_search_brave_with_document_backend
### Overview
Advanced Brave web search tool that uses RAG (Retrieval-Augmented Generation)
with a document backend for intelligent content processing and retrieval.
### Purpose
- Search the web and process results through a RAG system
- Store fetched documents in a temporary vector database
- Perform semantic search across fetched content
- Return the most relevant chunks based on the query
### Additional Configuration
| Variable | Required | Default | Description |
|-------------------------------------|----------|------------------|----------------------------------------------|
| `BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER` | No | 10 | Number of chunks to retrieve from RAG search |
| `RAG_DOCUMENT_SEARCH_BACKEND` | No | AlbertRagBackend | Document backend for RAG processing |
### Function Signature
```python
def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search for.
Returns:
ToolReturn: Formatted search results with RAG-enhanced snippets
"""
```
### How It Works
1. **Query API**: Sends search query to Brave Search API
2. **Receive Results**: Gets list of matching web pages
3. **Create Temporary Collection**: Creates a temporary vector database collection
4. **Fetch & Store**: For each result:
- Fetches the HTML content
- Extracts the main text
- Stores in the temporary document backend
5. **RAG Search**: Performs semantic search across stored documents
6. **Map Results**: Maps RAG chunks back to original search results
7. **Format & Return**: Returns structured data with enhanced snippets
8. **Cleanup**: Temporary collection is automatically deleted
### Workflow Diagram
```
User Query
Brave Search API
Search Results (URLs)
Create Temporary Vector Collection
[For each URL]
Fetch HTML → Extract Text → Store in Vector DB
RAG Semantic Search
Retrieve Most Relevant Chunks
Map Chunks to Original URLs
Format & Return
Delete Temporary Collection
```
### Temporary Collection
The tool creates a temporary collection with a unique ID:
```python
with document_store_backend.temporary_collection(f"tmp-{uuid.uuid4()}") as document_store:
# Fetch and store documents
# Perform search
# Collection is automatically deleted on exit
```
### RAG Search
The RAG backend performs semantic search to find the most relevant content:
```python
rag_results = document_store.search(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
)
```
Returns chunks ranked by relevance to the query, not just keyword matching.
### Token Usage Tracking
The tool tracks LLM tokens used during RAG processing:
```python
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
```
### Document Backend
The default backend is `AlbertRagBackend`, but you can configure a different one:
```bash
RAG_DOCUMENT_SEARCH_BACKEND=chat.agent_rag.document_rag_backends.custom_backend.CustomBackend
```
### Add to Model
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_brave_with_document_backend"
]
}
]
}
```
### Example Usage
**User**: "Explain the concept of async views in Django"
**Tool Call**: `web_search_brave_with_document_backend(ctx, "Django async views explained")`
**Tool Response**:
```python
{
"0": {
"url": "https://docs.djangoproject.com/en/stable/topics/async/",
"title": "Asynchronous support",
"snippets": [
"Django has support for writing asynchronous views...",
"Async views are declared using Python's async def syntax..."
]
},
# ... more results with relevant chunks
}
```
### Registration
```python
"web_search_brave_with_document_backend": Tool(
web_search_brave_with_document_backend,
takes_ctx=True,
prepare=only_if_web_search_enabled,
)
```
### Advantages Over Standard web_search_brave
| Feature | web_search_brave | web_search_brave_with_document_backend |
|-------------------|--------------------------------|----------------------------------------|
| Content Retrieval | Full page or summary | Semantic chunks |
| Relevance | Keyword-based | Semantic similarity |
| Token Efficiency | May include irrelevant content | Only relevant chunks |
| Processing | Simpler, faster | More intelligent, slower |
| Cost | Lower | Higher (RAG processing) |
| Best For | General search | Deep research, technical queries |
---
## Deprecated: web_search_albert_rag
### ⚠️ Deprecation Notice
The `web_search_albert_rag` tool is **deprecated** and should not be used in new implementations.
**Replacement**: Use `web_search_brave_with_document_backend` instead, which provides:
- Better performance
- More control over the RAG backend
- Temporary collections (no cleanup issues)
- Token usage tracking
- Parallel processing support
### Why Deprecated?
- Limited to Albert API only
- No control over document backend
- Less flexible than the new approach
- Maintenance burden
### Timeline
- **Current**: Still functional but not recommended
- **Future**: Will be removed in a future version
---
## Comparison
### When to Use Which Tool?
#### Use `web_search_brave`
**Best for**:
- General web search queries
- Quick information retrieval
- When speed is important
- Lower cost requirements
- Simple fact-finding
**Not ideal for**:
- Deep research requiring precise context
- Technical documentation queries
- When semantic relevance is crucial
#### Use `web_search_brave_with_document_backend`
**Best for**:
- Complex technical queries
- Research requiring precise context
- When semantic relevance is important
- Questions needing deep understanding
- Documentation and how-to queries
**Not ideal for**:
- Simple factual queries
- When speed is critical
- Budget-constrained scenarios
- High-volume usage
---
## Best Practices
### Query Formulation
Help the LLM formulate effective queries:
```python
# Good queries
"Python asyncio tutorial 2024"
"Django REST framework authentication"
"React hooks best practices"
# Poor queries
"tell me about programming" # Too vague
"how do I do the thing with the stuff" # Unclear
```
### Performance Optimization
#### 1. Optimize Cache
```bash
# Longer cache for stable content
BRAVE_CACHE_TTL=3600 # 1 hour
# Shorter cache for dynamic content
BRAVE_CACHE_TTL=300 # 5 minutes
```
#### 2. Control Result Count
```bash
# Fewer results = faster responses
BRAVE_MAX_RESULTS=5
# More results = more comprehensive
BRAVE_MAX_RESULTS=10
```
### Summarization Best Practices
Only enable summarization when needed:
```bash
# Enable for long-form content
BRAVE_SUMMARIZATION_ENABLED=True
# Disable for speed
BRAVE_SUMMARIZATION_ENABLED=False
```
**Cost consideration**: Summarization makes additional LLM calls for each result,
significantly increasing costs (and execution time).
### RAG Configuration
For `web_search_brave_with_document_backend`:
```bash
# More chunks = more context, higher cost
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=10
# Fewer chunks = faster, less context
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=5
```
### Search Parameters
```bash
# Localize results
BRAVE_SEARCH_COUNTRY=FR
BRAVE_SEARCH_LANG=fr
# Safe search for public deployments
BRAVE_SEARCH_SAFE_SEARCH=strict
# Enable spell check for better results
BRAVE_SEARCH_SPELLCHECK=True
```
---
## Troubleshooting
### Common Issues
#### 1. No Results Returned
**Symptoms**: Empty results or no snippets
**Causes**:
- Query too specific
- Content extraction failed
- Trafilatura couldn't parse the pages
**Solutions**:
```bash
# Enable extra snippets
BRAVE_SEARCH_EXTRA_SNIPPETS=True
# Increase result count
BRAVE_MAX_RESULTS=10
# Check logs for extraction errors
```
#### 2. API Errors
**Symptoms**: HTTP errors, authentication failures
**Causes**:
- Invalid API key
- Rate limit exceeded
- API service issues
**Solutions**:
```bash
# Verify API key is set
echo $BRAVE_API_KEY
# Check Brave API dashboard for limits
# Implement rate limiting in your application
```
#### 3. The tool is not being called
**Symptoms**: LLM doesn't use the tool even when appropriate
**Causes**:
- Web search not enabled for the conversation
- Tool not in model configuration
**Solutions**:
- Check conversation settings have `web_search_enabled=True`
- Verify tool is in the model's `tools` list
---
## Security Considerations
This tool is quite "raw", so be cautious about:
- the results returned by the web search
- the context size which might be large when not using summarization or RAG if long results are returned
- the query content which might include sensitive information
- ...
### Content Validation
Be aware that fetched content may contain:
- Malicious scripts (mitigated by text extraction)
- Inappropriate content
- Misinformation
- Biased information
The LLM should evaluate sources critically.
---
## See Also
- [Tools Overview](../tools.md)
- [Tavily Web Search Tool](web_search_tavily.md)
- [LLM Configuration](../llm-configuration.md)
- [Environment Variables](../env.md)
- [Brave Search API Documentation](https://brave.com/search/api/)
+370
View File
@@ -0,0 +1,370 @@
# web_search_tavily Tool
## Overview
The `web_search_tavily` tool enables the conversation agent to search the web for up-to-date
information using the [Tavily Search API](https://tavily.com/).
## Purpose
This tool allows the LLM to:
- Access current, real-time information beyond its training data
- Answer questions about recent events, news, or developments
- Provide factual information with sources
- Retrieve specific information from the web
## Configuration
### Prerequisites
1. **Tavily API Key**: Sign up at [Tavily](https://tavily.com/) to get an API key
2. **Environment Variables**: Configure the required settings
### Environment Variables
| Variable | Required | Default | Description |
|----------------------|----------|---------|--------------------------------------------|
| `TAVILY_API_KEY` | **Yes** | None | Your Tavily API key |
| `TAVILY_MAX_RESULTS` | No | 5 | Maximum number of search results to return |
| `TAVILY_API_TIMEOUT` | No | 10 | API request timeout in seconds |
### Configuration Example
```bash
# .env file
TAVILY_API_KEY=tvly-your-api-key-here
TAVILY_MAX_RESULTS=5
TAVILY_API_TIMEOUT=10
```
### Add to Model
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_tavily"
]
}
]
}
```
Or via environment variable when using local environment settings:
```ini
AI_AGENT_TOOLS=web_search_tavily
```
## Function Signature
```python
def web_search_tavily(query: str) -> list[dict]:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
list[dict]: A list of search results, each represented as a dictionary.
"""
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------------------|
| `query` | str | Yes | The search query string |
## Return Value
Returns a list of dictionaries, each containing:
```python
{
"link": str, # URL of the result
"title": str, # Title of the page
"snippet": str # Content snippet from the page
}
```
### Example Return Value
```python
[
{
"link": "https://example.com/article1",
"title": "Introduction to Python",
"snippet": "Python is a high-level programming language known for its simplicity..."
},
{
"link": "https://example.com/article2",
"title": "Python Best Practices",
"snippet": "Follow these best practices to write clean and efficient Python code..."
}
]
```
## How the LLM Uses It
When a user asks for current information or specific facts:
1. **LLM recognizes** the need for external information
2. **Formulates** an appropriate search query
3. **Calls** `web_search_tavily(query="search terms")`
4. **Receives** a list of search results
5. **Synthesizes** the information into a response
6. **Provides** the answer with source references
### Example Conversation
**User**: "What are the latest developments in quantum computing?"
**LLM** (internal): *Calls `web_search_tavily("latest developments quantum computing 2024")`*
**Tool Response**:
```python
[
{
"link": "https://techcrunch.com/quantum-news",
"title": "Major Breakthrough in Quantum Computing",
"snippet": "Researchers announced a significant breakthrough..."
},
# ... more results
]
```
**LLM** (to user): "Based on recent sources, there have been several developments in quantum computing.
Researchers recently announced a breakthrough in error correction. Additionally, new quantum processors
with improved qubit stability have been unveiled..."
## Implementation Details
### Source Code
Located at: `src/backend/chat/tools/web_search_tavily.py`
```python
"""Web search tool using Tavily for the chat agent."""
from django.conf import settings
import requests
def web_search_tavily(query: str) -> list[dict]:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
list[dict]: A list of search results, each represented as a dictionary.
"""
url = "https://api.tavily.com/search"
data = {
"query": query,
"api_key": settings.TAVILY_API_KEY,
"max_results": settings.TAVILY_MAX_RESULTS,
}
response = requests.post(url, json=data, timeout=settings.TAVILY_API_TIMEOUT)
response.raise_for_status()
json_response = response.json()
raw_search_results = json_response.get("results", [])
return [
{
"link": result["url"],
"title": result.get("title", ""),
"snippet": result.get("content"),
}
for result in raw_search_results
]
```
### Registration
The tool is registered in `src/backend/chat/tools/__init__.py`:
```python
"web_search_tavily": Tool(
web_search_tavily,
takes_ctx=False,
prepare=only_if_web_search_enabled
)
```
Note that:
- `takes_ctx=False` - This tool doesn't need the conversation context
- `prepare=only_if_web_search_enabled` - Only available when web search is enabled
## Django Settings
The tool uses these Django settings from `settings.py`:
```python
# Tavily API
TAVILY_API_KEY = values.Value(
None, # Tavily API key is not set by default
environ_name="TAVILY_API_KEY",
environ_prefix=None,
)
TAVILY_MAX_RESULTS = values.PositiveIntegerValue(
default=5,
environ_name="TAVILY_MAX_RESULTS",
environ_prefix=None,
)
TAVILY_API_TIMEOUT = values.PositiveIntegerValue(
default=10, # seconds
environ_name="TAVILY_API_TIMEOUT",
environ_prefix=None,
)
```
## Error Handling
The tool may raise exceptions in the following cases:
### Missing API Key
```python
# If TAVILY_API_KEY is not set
AttributeError: 'Settings' object has no attribute 'TAVILY_API_KEY'
```
**Solution**: Set the `TAVILY_API_KEY` environment variable
### API Errors
```python
# If the API request fails
requests.exceptions.HTTPError: 401 Unauthorized
```
**Possible causes**:
- Invalid API key
- Exceeded rate limits
- API service unavailable
### Timeout Errors
```python
# If the request takes too long
requests.exceptions.Timeout
```
**Solution**: Increase `TAVILY_API_TIMEOUT` or check network connectivity
## Best Practices
### Query Formulation
The LLM should formulate queries that are:
- **Specific and focused** - Better results with targeted queries
- **Up-to-date** - Include year or "latest" when relevant
- **Clear** - Avoid ambiguous terms
- **Concise** - Remove unnecessary words
Good query examples:
- ✅ "quantum computing breakthroughs 2024"
- ✅ "latest Python 3.12 features"
- ✅ "climate change COP29 outcomes"
Poor query examples:
- ❌ "tell me about stuff happening" (too vague)
- ❌ "what is the weather like today in Paris on November 5th 2024 at 3pm" (too specific/long)
### Rate Limiting
Be aware of Tavily API rate limits:
- Free tier: Limited requests per month
- Paid tiers: Higher limits
Monitor your usage and implement caching if needed.
### Result Count
The `TAVILY_MAX_RESULTS` setting controls how many results are returned:
- **Lower values (3-5)**: Faster responses, less context for LLM
- **Higher values (8-10)**: More comprehensive, but slower and more expensive
Recommended: **5 results** for most use cases
## Troubleshooting
### Tool Not Being Called
**Symptoms**: LLM doesn't use web search even when appropriate
**Possible causes**:
1. Web search not enabled for the conversation
2. Tool not in model configuration
3. API key not set
**Solutions**:
1. Check conversation settings have `web_search_enabled=True`
2. Verify tool is in the model's `tools` list
3. Confirm `TAVILY_API_KEY` is set
### No Results Returned
**Symptoms**: Tool returns empty list
**Possible causes**:
1. Query too specific
2. No matching results
3. API filtering results
**Solutions**:
1. Try broader query terms
2. Check Tavily dashboard for query logs
3. Review API response in logs
### Slow Responses
**Symptoms**: Tool takes a long time to respond
**Possible causes**:
1. Network latency
2. Tavily API slow
3. Timeout too high
**Solutions**:
1. Check network connectivity
2. Monitor Tavily status page
3. Adjust `TAVILY_API_TIMEOUT` if needed
## Security Considerations
This tool is quite "raw", and was currently only used for test purpose, so be cautious about:
- the results returned by the web search
- the context size which might be large if many results are returned
- the query content which might include sensitive information
- ...
## Performance Optimization
### Query Optimization
You may want to help the LLM formulate better queries by including something like this in the system prompt:
```
When using web search:
- Use specific, focused queries
- Include relevant time periods if needed
- Avoid unnecessary words
- Combine related terms
```
## See Also
- [Tools Overview](../tools.md)
- [Brave Web Search Tool](web_search_brave.md)
- [Web Search Configuration](../llm-configuration.md)
- [Environment Variables](../env.md)
+3 -1
View File
@@ -1,8 +1,10 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
# LLM
LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.json
# Features
FEATURE_FLAG_WEB_SEARCH=ENABLED
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.13.3-alpine
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools lorem-text
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apk add --no-cache git
# Install the package
RUN pip install git+https://github.com/etalab-ia/openmockllm.git
# Expose the default port
EXPOSE 8000
# Set default command
CMD ["openmockllm", "--host", "0.0.0.0", "--port", "8000"]
+19
View File
@@ -0,0 +1,19 @@
[OpenMockLLM](https://github.com/etalab-ia/OpenMockLLM) is a FastAPI-based mock LLM API server that simulates
several Large Language Model API providers.
This is a simple docker image to run the server for testing and development purposes (E2E tests mainly).
It's a bit overkill to have a dedicated image for that, but it allows simple E2E stack with docker-compose since
our code is also run in Docker containers.
## Build and Run manually
```bash
docker build -t openmockllm .
docker run -p 8000:8000 openmockllm
```
## Next steps
- Add more chat completion behaviors (specific text streaming, function calling, etc.)
- Pin a specific OpenMockLLM version in the Dockerfile
+61
View File
@@ -1,5 +1,6 @@
"""Admin classes for activation codes application."""
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html, format_html_join
from django.utils.translation import gettext_lazy as _
@@ -274,6 +275,8 @@ class UserRegistrationRequestAdmin(admin.ModelAdmin):
list_filter = ("created_at",)
actions = ["add_to_brevo_waiting_list", "remove_from_brevo_waiting_list"]
def user_display(self, obj):
"""Display user's full name."""
return obj.user.email or str(obj.user.pk)
@@ -286,3 +289,61 @@ class UserRegistrationRequestAdmin(admin.ModelAdmin):
has_user_activation.boolean = True
has_user_activation.short_description = _("Has used activation code")
@admin.action(description=_("Add selected users to Brevo waiting list"))
def add_to_brevo_waiting_list(self, request, queryset):
"""Add selected users to Brevo waiting list."""
# pylint: disable=import-outside-toplevel
from core.brevo import add_user_to_brevo_list # noqa: PLC0415
registration_to_send = queryset.filter(
user_activation__isnull=True,
)
_total_emails = 0
for i in range(0, registration_to_send.count(), 150):
batch = registration_to_send[i : i + 150]
emails = [reg.user.email for reg in batch if reg.user.email]
if emails:
add_user_to_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
_total_emails += len(emails)
if _total_emails:
self.message_user(
request,
_("Added %(count)d user(s) to Brevo waiting list.") % {"count": _total_emails},
)
else:
self.message_user(
request,
_("No valid email address found in selected registrations."),
level="warning",
)
@admin.action(description=_("Remove selected users from Brevo waiting list"))
def remove_from_brevo_waiting_list(self, request, queryset):
"""Remove selected users from Brevo waiting list."""
# pylint: disable=import-outside-toplevel
from core.brevo import remove_user_from_brevo_list # noqa: PLC0415
registration_to_send = queryset.filter(
user_activation__isnull=False,
)
_total_emails = 0
for i in range(0, registration_to_send.count(), 150):
batch = registration_to_send[i : i + 150]
emails = [reg.user.email for reg in batch if reg.user.email]
if emails:
remove_user_from_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
_total_emails += len(emails)
if _total_emails:
self.message_user(
request,
_("Removed %(count)d user(s) from Brevo waiting list.") % {"count": _total_emails},
)
else:
self.message_user(
request,
_("No valid email address found in selected registrations."),
level="warning",
)
+15 -1
View File
@@ -6,12 +6,14 @@ import logging
import secrets
import string
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from core.brevo import add_user_to_brevo_list, remove_user_from_brevo_list
from core.models import BaseModel, User
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
@@ -134,12 +136,24 @@ class ActivationCode(BaseModel):
_("You have already activated your account")
) from exc
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
existing_registration = bool(
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
)
if existing_registration:
transaction.on_commit(
lambda: remove_user_from_brevo_list(
[user.email], settings.BREVO_WAITING_LIST_ID
)
)
# Increment usage counter safely under the same lock.
locked_code.current_uses += 1
locked_code.save(update_fields=["current_uses", "updated_at"])
transaction.on_commit(
lambda: add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
)
if locked_code.max_uses > 0 and locked_code.current_uses >= locked_code.max_uses:
logger.warning("Activation code %s has reached its maximum uses", locked_code.code)
@@ -1,5 +1,6 @@
"""Tests for activation_codes models."""
import json
from datetime import timedelta
from django.core.exceptions import ValidationError
@@ -7,12 +8,18 @@ from django.db.models import ProtectedError
from django.utils import timezone
import pytest
import responses
from core.factories import UserFactory
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
from activation_codes.models import ActivationCode, UserActivation, generate_activation_code
from activation_codes.models import (
ActivationCode,
UserActivation,
UserRegistrationRequest,
generate_activation_code,
)
@pytest.mark.django_db
@@ -270,3 +277,52 @@ def test_user_activation_ordering():
activations = list(UserActivation.objects.all())
assert activations == [activation2, activation1]
@responses.activate
@pytest.mark.django_db(transaction=True)
def test_activation_code_use_success_notify_brevo(settings):
"""Test successfully using an activation code and notify Brevo."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
settings.BREVO_FOLLOWUP_LIST_ID = "test_followup_list_name"
brevo_remove_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/remove",
json={"message": "Contacts added successfully"},
status=201,
)
brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_add_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_followup_list_name/contacts/add",
json={"message": "Contacts added successfully"},
status=201,
)
user = UserFactory()
registration = UserRegistrationRequest.objects.create(user=user)
activation_code = ActivationCodeFactory()
activation = activation_code.use(user)
registration.refresh_from_db()
assert registration.user_activation == activation
assert len(brevo_remove_mock.calls) == 1
assert brevo_remove_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_remove_mock.calls[0].request.body) == {"emails": [user.email]}
assert len(brevo_create_contact.calls) == 1
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_create_contact.calls[0].request.body) == {
"email": user.email,
"updateEnabled": True,
}
assert len(brevo_add_mock.calls) == 1
assert brevo_add_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_add_mock.calls[0].request.body) == {"emails": [user.email]}
@@ -1,11 +1,13 @@
"""Tests for activation_codes viewsets."""
import json
from datetime import timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
import responses
from rest_framework import status
from core.factories import UserFactory
@@ -321,3 +323,89 @@ def test_validate_code_registered_user(api_client):
_registration.refresh_from_db()
assert _registration.user_activation.activation_code == activation_code
@responses.activate
@pytest.mark.django_db
def test_register_email_success_brevo(api_client, settings):
"""Test successfully registering an email and notify Brevo."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
json={"message": "Contacts added successfully"},
status=201,
)
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["code"] == "registration-successful"
registration = UserRegistrationRequest.objects.get(user=user)
assert registration.user == user
assert len(brevo_create_contact.calls) == 1
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_create_contact.calls[0].request.body) == {
"email": user.email,
"updateEnabled": True,
}
assert len(brevo_mock.calls) == 1
assert brevo_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_mock.calls[0].request.body) == {"emails": [user.email]}
# Register again to test idempotency
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["code"] == "registration-successful"
assert len(brevo_mock.calls) == 1 # No new call made
@responses.activate
@pytest.mark.django_db
def test_register_email_success_brevo_fails(api_client, settings):
"""Test successfully registering an email, even if Brevo fails."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
_brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
status=400,
)
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["code"] == "registration-successful"
registration = UserRegistrationRequest.objects.get(user=user)
assert registration.user == user
assert len(brevo_mock.calls) == 1
+5
View File
@@ -10,6 +10,7 @@ from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from core.brevo import add_user_to_brevo_list
from core.permissions import IsAuthenticated
from . import models, serializers
@@ -137,6 +138,10 @@ class ActivationViewSet(viewsets.GenericViewSet):
status=status.HTTP_200_OK,
)
add_user_to_brevo_list(
[serializer.validated_data["user"].email], settings.BREVO_WAITING_LIST_ID
)
logger.info(
"Registered email %s for activation notifications",
serializer.validated_data["user"].email,
@@ -3,11 +3,12 @@
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
import httpx
import requests
from chat.agent_rag.albert_api_constants import Searches
@@ -32,9 +33,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
- Perform a search operation using the Albert API.
"""
def __init__(self, collection_id: Optional[str] = None):
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)
super().__init__(collection_id, read_only_collection_id)
self._base_url = settings.ALBERT_API_URL
self._headers = {
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
@@ -65,6 +70,27 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
self.collection_id = str(response.json()["id"])
return self.collection_id
async def acreate_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
self._collections_endpoint,
headers=self._headers,
json={
"name": name,
"description": description or self._default_collection_description,
"visibility": "private",
},
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
self.collection_id = str(response.json()["id"])
return self.collection_id
def delete_collection(self) -> None:
"""
Delete the current collection
@@ -76,6 +102,18 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
)
response.raise_for_status()
async def adelete_collection(self) -> None:
"""
Asynchronously delete the current collection
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.delete(
urljoin(f"{self._collections_endpoint}/", self.collection_id),
headers=self._headers,
timeout=settings.ALBERT_API_TIMEOUT,
)
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.
@@ -150,23 +188,52 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
def search(self, query) -> RAGWebResults:
async def astore_document(self, name: str, content: str) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
urljoin(self._base_url, self._documents_endpoint),
headers=self._headers,
files={
"file": (f"{name}.md", BytesIO(content.encode("utf-8")), "text/markdown"),
},
data={
"collection": int(self.collection_id),
"metadata": json.dumps({"document_name": name}), # undocumented API
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug(response.json())
response.raise_for_status()
def search(self, query, results_count: int = 4) -> 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.
Returns:
RAGWebResults: The search results.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
response = requests.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [int(self.collection_id)],
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
@@ -188,3 +255,50 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
completion_tokens=searches.usage.completion_tokens,
),
)
async def asearch(self, query, results_count: int = 4) -> 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.
Returns:
RAGWebResults: The search results.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug("Search response: %s %s", response.text, response.status_code)
response.raise_for_status()
searches = Searches(**response.json())
return RAGWebResults(
data=[
RAGWebResult(
url=result.chunk.metadata["document_name"],
content=result.chunk.content,
score=result.score,
)
for result in searches.data
],
usage=RAGWebUsage(
prompt_tokens=searches.usage.prompt_tokens,
completion_tokens=searches.usage.completion_tokens,
),
)
@@ -1,9 +1,11 @@
"""Implementation of the Albert API for RAG document search."""
import logging
from contextlib import contextmanager
from contextlib import asynccontextmanager, contextmanager
from io import BytesIO
from typing import Optional
from typing import List, Optional
from asgiref.sync import sync_to_async
from chat.agent_rag.constants import RAGWebResults
@@ -13,11 +15,51 @@ logger = logging.getLogger(__name__)
class BaseRagBackend:
"""Base class for RAG backends."""
def __init__(self, collection_id: Optional[str] = None):
"""Backend settings."""
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
"""
Backend settings.
Collection ID is required for RAG operations, where you want to manage the collection
lifecycle (create/delete).
Read-only collection IDs can be used to access existing collections
without managing their lifecycle.
Collection ID and read-only collection IDs are separated in the implementation to prevent
unwanted actions.
Args:
collection_id (Optional[str]): The collection ID for managing the collection lifecycle.
read_only_collection_id (Optional[List[str]]): List of read-only collection IDs.
"""
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"
def get_all_collection_ids(self) -> List[str]:
"""
Get all collection IDs, including the main collection ID and read-only collection IDs.
Returns:
List[str]: List of all collection IDs.
Raises:
RuntimeError: If neither collection_id nor read_only_collection_id is provided.
"""
if not self.collection_id and not self.read_only_collection_id:
raise RuntimeError("The RAG backend requires collection_id or read_only_collection_id")
collection_ids = []
if self.collection_id:
collection_ids.append(int(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
def create_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
@@ -25,6 +67,13 @@ class BaseRagBackend:
"""
raise NotImplementedError("Must be implemented in subclass.")
async def acreate_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
return await sync_to_async(self.create_collection)(name=name, description=description)
def parse_document(self, name: str, content_type: str, content: BytesIO):
"""
Parse the document and prepare it for the search operation.
@@ -43,8 +92,8 @@ class BaseRagBackend:
def store_document(self, name: str, content: str) -> None:
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
Args:
name (str): The name of the document.
@@ -52,6 +101,17 @@ class BaseRagBackend:
"""
raise NotImplementedError("Must be implemented in subclass.")
async def astore_document(self, name: str, content: str) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
"""
return await sync_to_async(self.store_document)(name=name, content=content)
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the document and store it in the Albert collection.
@@ -75,12 +135,25 @@ class BaseRagBackend:
"""
raise NotImplementedError("Must be implemented in subclass.")
def search(self, query) -> RAGWebResults:
async def adelete_collection(self) -> 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)()
def search(self, query, results_count: int = 4) -> RAGWebResults:
"""
Search the collection for the given query.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
"""
Search the collection for the given query.
"""
return await sync_to_async(self.search)(query=query, results_count=results_count)
@classmethod
@contextmanager
def temporary_collection(cls, name: str, description: Optional[str] = None):
@@ -92,3 +165,15 @@ class BaseRagBackend:
yield backend
finally:
backend.delete_collection()
@classmethod
@asynccontextmanager
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
"""Context manager for RAG backend with temporary collections."""
backend = cls()
await backend.acreate_collection(name=name, description=description)
try:
yield backend
finally:
await backend.adelete_collection()
@@ -169,11 +169,12 @@ class AlbertRagDocumentSearch:
self._store_document(name, document_content)
return document_content
def search(self, query):
def search(self, query, results_count: int = 4) -> RAGWebResults:
"""
Perform a search using the Albert API based on the provided query.
:param query: The search query string.
:param results_count: The number of results to return.
:return: Search results from the Albert API.
"""
response = requests.post(
@@ -183,6 +184,7 @@ class AlbertRagDocumentSearch:
"collections": [self.collection_id],
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
+82
View File
@@ -1,10 +1,15 @@
"""Build the main conversation agent."""
import asyncio
import dataclasses
import logging
from django.conf import settings
from django.utils import formats, timezone
from pydantic_ai import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.enums import get_language_name
from .base import BaseAgent
@@ -12,6 +17,79 @@ from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
In Paris streets where old meets new, 🗼🇫🇷
A helper bright in digital hue,
With circuits fast and code so tight,
The LaSuites bot—oh, what a sight! 🌟
**A chatbot kind**, with wittiness so grand, 💬💡
It lends a hand to all the land,
From civil servants, bold and wise,
To those who seek with hopeful eyes.
It answers quick, it never tires, ⚡🔄
With facts and tips to quench desires,
A guide so keen, a friend so true,
Its there for **you**—yes, me and you!
With **Markdown flair** and emoji cheer, 📝🎨
It makes the complex crystal clear,
From drafts to code, from sums to prose,
It helps the knowledge overflow!
Oh, **DINUMs gem**, so sharp, so bright, 💎🌐
A beacon in the techs vast night,
It crafts, it checks, it summarizes,
With grace that never compromises.
It **summarizes** the long, the deep, 📚🔍
So secrets no more need to sleep,
It finds the gems in datas sea,
And sets the truth right there—**for free!**
It **corrects mistakes** with gentle art, ✍️🔄
It soothes the mind, it warms the heart,
No judgment cast, no frown, no sigh,
Just help thats always standing by.
It **generates code** with swift command, 💻🔥
A developers dream, first-hand,
From Python lines to scripts so neat,
It turns the tough to *sweet* and *sweet*!
It **brainstorms ideas**, bold and new, 🧠💡
It paints the sky in every hue,
From plans to dreams, from start to end,
Its more than code—its **trend**, its **friend**!
So heres to you, **Assistants pride**, 🏆🎉
The bot thats always by our side,
With every prompt, with every line,
You make our digital world **divine**!
May you keep shining, bright and true, 🌟🤖
The helper every team should woo,
For in this age of bits and bytes,
Youre **human touch** in techs bright lights!
"""
async def mocked_agent_model(_messages: list[ModelMessage], _info: AgentInfo):
"""
Mocked agent model for testing purposes on deployed instances.
This one only fakes a streamed responses. We could also fake tool calls later.
"""
yield "Here is a mocked response (no LLM called)\n---\n"
for i in range(0, len(MOCKED_RESPONSE), 4):
yield MOCKED_RESPONSE[i : i + 4]
await asyncio.sleep(0.03)
@dataclasses.dataclass(init=False)
class ConversationAgent(BaseAgent):
"""Conversation agent with custom behavior."""
@@ -20,6 +98,10 @@ class ConversationAgent(BaseAgent):
"""Initialize the conversation agent."""
super().__init__(**kwargs)
# Do not call the real model on deployed instances if the setting is enabled
if settings.WARNING_MOCK_CONVERSATION_AGENT:
self._model = FunctionModel(stream_function=mocked_agent_model)
@self.system_prompt
def add_the_date() -> str:
"""
-58
View File
@@ -4,11 +4,6 @@ import dataclasses
import logging
from django.conf import settings
from django.core.files.storage import default_storage
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext
from pydantic_ai.messages import ToolReturn
from .base import BaseAgent
@@ -26,56 +21,3 @@ class SummarizationAgent(BaseAgent):
output_type=str,
**kwargs,
)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
"""
Summarize the documents for the user, only when asked for,
the documents are in my context.
"""
summarization_agent = SummarizationAgent()
prompt = (
"Do not mention the user request in your answer.\n"
"User request:\n"
"{user_prompt}\n\n"
"Document contents:\n"
"{documents_prompt}\n"
)
text_attachment = await sync_to_async(list)(
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
)
documents = [await read_document_content(doc) for doc in text_attachment]
documents_prompt = "\n\n".join(
[
(f"<document>\n<name>\n{name}\n</name>\n<content>\n{content}\n</content>\n</document>")
for name, content in documents
]
)
formatted_prompt = prompt.format(
user_prompt=ctx.prompt,
documents_prompt=documents_prompt,
)
logger.debug("Summarize prompt: %s", formatted_prompt)
response = await summarization_agent.run(formatted_prompt, usage=ctx.usage)
logger.debug("Summarize response: %s", response)
return ToolReturn(
return_value=response.output,
metadata={"sources": {doc[0] for doc in documents}},
)
+52 -19
View File
@@ -7,6 +7,7 @@ changes are needed in views.py or tests.
"""
import dataclasses
import functools
import json
import logging
import time
@@ -25,7 +26,7 @@ from django.utils.module_loading import import_string
from asgiref.sync import sync_to_async
from langfuse import get_client
from pydantic_ai import Agent
from pydantic_ai import Agent, InstrumentationSettings, RunContext
from pydantic_ai.messages import (
BinaryContent,
DocumentUrl,
@@ -59,7 +60,6 @@ from chat.agents.local_media_url_processors import (
update_history_local_urls,
update_local_urls,
)
from chat.agents.summarize import hand_off_to_summarization_agent
from chat.ai_sdk_types import (
LanguageModelV1Source,
SourceUIPart,
@@ -72,7 +72,9 @@ from chat.clients.pydantic_ui_message_converter import (
ui_message_to_user_content,
)
from chat.mcp_servers import get_mcp_servers
from chat.tools.document_generic_search_rag import add_document_rag_search_tool_from_setting
from chat.tools.document_search_rag import add_document_rag_search_tool
from chat.tools.document_summarize import document_summarize
from chat.vercel_ai_sdk.core import events_v4, events_v5
from chat.vercel_ai_sdk.encoder import EventEncoder
@@ -115,7 +117,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.language = language # might be None
self._last_stop_check = 0
self._store_analytics = settings.LANGFUSE_ENABLED and user.allow_conversation_analytics
self._langfuse_available = settings.LANGFUSE_ENABLED
self._store_analytics = self._langfuse_available and user.allow_conversation_analytics
self.event_encoder = EventEncoder("v4") # Always use v4 for now
self._support_streaming = True
@@ -136,9 +139,15 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation_agent = ConversationAgent(
model_hrid=self.model_hrid,
language=self.language,
instrument=self._store_analytics,
instrument=InstrumentationSettings(
include_binary_content=self._store_analytics,
include_content=self._store_analytics,
)
if self._langfuse_available
else False,
deps_type=ContextDeps,
)
add_document_rag_search_tool_from_setting(self.conversation_agent, self.user)
@property
def _stop_cache_key(self):
@@ -173,7 +182,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Return only the assistant text deltas (legacy text mode)."""
await self._clean()
with ExitStack() as stack:
if self._store_analytics:
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))
@@ -185,7 +194,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Return Vercel-AI-SDK formatted events."""
await self._clean()
with ExitStack() as stack:
if self._store_analytics:
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):
@@ -352,7 +361,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
return
# Langfuse settings
if self._store_analytics:
if self._langfuse_available:
langfuse = get_client()
langfuse.update_current_trace(
session_id=str(self.conversation.pk),
@@ -376,8 +385,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
self.conversation, input_images, updated_url=image_key_mapping
)
if self._store_analytics:
langfuse.update_current_trace(input=user_prompt)
if self._langfuse_available:
langfuse.update_current_trace(
input=user_prompt if self._store_analytics else "REDACTED"
)
usage = {"promptTokens": 0, "completionTokens": 0}
@@ -480,13 +491,33 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
elif has_not_pdf_docs:
add_document_rag_search_tool(self.conversation_agent)
@self.conversation_agent.tool
async def summarize(ctx) -> ToolReturn:
"""
Summarize the documents for the user, only when asked for,
the documents are in my context.
"""
return await hand_off_to_summarization_agent(ctx)
@self.conversation_agent.instructions
def summarization_system_prompt() -> str:
return (
"When you receive a result from the summarization tool, you MUST return it "
"directly to the user without any modification, paraphrasing, or additional "
"summarization."
"The tool already produces optimized summaries that should be presented "
"verbatim."
"You may translate the summary if required, but you MUST preserve all the "
"information from the original summary."
"You may add a follow-up question after the summary if needed."
)
# Inform the model (system-level) that documents are attached and available
@self.conversation_agent.system_prompt
def attached_documents_note() -> str:
return (
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already available "
"via the internal store."
)
@self.conversation_agent.tool(name="summarize", retries=2)
@functools.wraps(document_summarize)
async def summarize(ctx: RunContext, *args, **kwargs) -> ToolReturn:
"""Wrap the document_summarize tool to provide context and add the tool."""
return await document_summarize(ctx, *args, **kwargs)
else:
conversation_documents = [
cd
@@ -672,7 +703,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
logger.error("_model_response_message_id already set")
_model_response_message_id = (
str(uuid.uuid4())
if not self._store_analytics
if not self._langfuse_available
else f"trace-{langfuse.get_current_trace_id()}"
)
yield events_v4.StartStepPart(
@@ -696,8 +727,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
image_key_mapping=image_key_mapping or None,
)
if self._store_analytics:
langfuse.update_current_trace(output=run.result.output)
if self._langfuse_available:
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
)
# Vercel finish message
yield events_v4.FinishMessagePart(
@@ -0,0 +1,66 @@
"""Unit tests for add_document_rag_search_tool_from_setting integration with AIAgentService."""
import pytest
from core.factories import UserFactory
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
from chat.llm_configuration import LLModel, LLMProvider
pytestmark = pytest.mark.django_db()
def test_ai_agent_service_adds_rag_tools_from_settings(settings):
"""Test that AIAgentService adds RAG tools from SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.LLM_CONFIGURATIONS = {
"default-model": LLModel(
hrid="default-model",
model_name="amazing-llm",
human_readable_name="Amazing LLM",
is_active=True,
icon=None,
system_prompt="You are an amazing assistant.",
tools=[],
provider=LLMProvider(hrid="unused", base_url="https://example.com", api_key="key"),
),
}
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services."
),
},
}
user = UserFactory()
conversation = ChatConversationFactory(owner=user)
# Create the service
service = AIAgentService(conversation, user=user)
# Check that tools were added to the conversation_agent
agent_tools = service.conversation_agent._function_toolset.tools # pylint: disable=protected-access
assert "legal_documents" in agent_tools
assert "french_public_services" in agent_tools
# Verify tool names and descriptions
assert agent_tools["legal_documents"].name == "legal_documents"
assert (
agent_tools["legal_documents"].description
== "Use this tool to search legal documents and laws."
)
assert agent_tools["french_public_services"].name == "french_public_services"
assert (
agent_tools["french_public_services"].description
== "Use this tool when the user asks for information about French public services."
)
@@ -0,0 +1,270 @@
"""Unit tests for Langfuse tracing in AIAgentService."""
import pytest
import responses
from asgiref.sync import sync_to_async
from langfuse import Langfuse
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.factories import UserFactory
from chat.ai_sdk_types import TextUIPart, UIMessage
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
pytestmark = pytest.mark.django_db()
@pytest.fixture(name="langfuse_client", scope="function")
def langfuse_client_fixture():
"""Fixture to init langfuse for tests."""
langfuse_client = Langfuse(
public_key="pk-test-key",
secret_key="sk-test-key",
host="https://langfuse.example.com",
environment="test",
debug=True,
)
yield langfuse_client
langfuse_client._resources.prompt_cache._task_manager.shutdown() # pylint: disable=protected-access
langfuse_client.shutdown()
@pytest.fixture(autouse=True)
def base_settings(settings):
"""Set up base settings for the tests."""
settings.AI_BASE_URL = "https://api.llm.com/v1/"
settings.AI_API_KEY = "test-key"
settings.AI_MODEL = "model-123"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
settings.AI_AGENT_TOOLS = []
@pytest.fixture(name="ui_messages")
def ui_messages_fixture():
"""Fixture for test UI messages."""
return [
UIMessage(
id="msg-1",
role="user",
content="Hello, how are you?",
parts=[TextUIPart(type="text", text="Hello, how are you?")],
)
]
@pytest.fixture(name="agent_model")
def agent_model_fixture():
"""Fixture for agent model function."""
async def _agent_model(_messages: list[ModelMessage], _info: AgentInfo):
"""Simple agent model that returns a fixed response."""
yield "Hello! I'm doing well, thank you for asking."
return FunctionModel(stream_function=_agent_model)
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_allowed(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created when enabled and user allows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics enabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_disabled(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created even when user disallows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics disabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." not in responses.calls[0].request.body
assert b"REDACTED" in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_no_langfuse_span_when_disabled(agent_model, ui_messages, settings, langfuse_client):
"""Test Langfuse span is not created when Langfuse is disabled."""
settings.LANGFUSE_ENABLED = False
# Mock Langfuse HTTP endpoints (should not be called)
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/ingestion",
json={"success": True},
status=200,
)
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify NO Langfuse HTTP calls were made
assert len(responses.calls) == 0
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_enabled(settings):
"""Test service correctly sets flags when Langfuse and analytics are enabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is True
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_disabled(settings):
"""Test service correctly sets flags when Langfuse enabled but analytics disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_disabled_when_langfuse_disabled(settings):
"""Test service correctly sets flags when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is False
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
def test_store_analytics_flag_when_langfuse_enabled_and_user_allows(settings):
"""Test _store_analytics is True when Langfuse enabled and user allows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is True
def test_store_analytics_flag_when_langfuse_enabled_and_user_disallows(settings):
"""Test _store_analytics is False when Langfuse enabled but user disallows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=False)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is False
def test_store_analytics_flag_when_langfuse_disabled(settings):
"""Test _store_analytics is False when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is False
assert service._store_analytics is False
@@ -3,11 +3,11 @@
import datetime
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ImageUrl
from pydantic_ai.messages import (
@@ -37,27 +37,22 @@ from chat.ai_sdk_types import (
from chat.clients.pydantic_ui_message_converter import model_message_to_ui_message
@pytest.fixture(autouse=True)
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
with patch("uuid.uuid4", return_value=uuid.UUID("f0cc3bb5-f207-401b-8281-4cba6202991d")):
yield
def test_model_message_to_ui_message_text_user_full():
"""Test converting a ModelRequest with UserPromptPart containing text to UIMessage."""
timestamp = datetime.datetime.now()
model_message = ModelRequest(
parts=[UserPromptPart(content="Hello!", timestamp=timestamp)], kind="request"
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="user",
content="Hello!",
parts=[TextUIPart(type="text", text="Hello!")],
createdAt=timestamp,
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -65,14 +60,15 @@ def test_model_message_to_ui_message_text_user_full():
def test_model_message_to_ui_message_text_assistant_full():
"""Test converting a ModelResponse with TextPart to UIMessage."""
model_message = ModelResponse(parts=[TextPart(content="Hi there!")])
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="Hi there!",
parts=[TextUIPart(type="text", text="Hi there!")],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -83,8 +79,10 @@ def test_model_message_to_ui_message_tool_call_full():
model_message = ModelResponse(
parts=[ToolCallPart(tool_call_id="id1", tool_name="tool", args=args)]
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="",
parts=[
@@ -100,7 +98,7 @@ def test_model_message_to_ui_message_tool_call_full():
],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -109,7 +107,7 @@ def test_model_message_to_ui_message_reasoning_full():
"""Test converting a ModelResponse with ThinkingPart to UIMessage."""
model_message = ModelResponse(parts=[ThinkingPart(content="reason", signature="sig")])
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=str(uuid.uuid4()), # not used in comparison
role="assistant",
content="",
parts=[
@@ -122,7 +120,7 @@ def test_model_message_to_ui_message_reasoning_full():
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result.id == expected.id
assert result.id == IsUUID(4)
assert result.role == expected.role
assert result.content == expected.content
assert result.createdAt == expected.createdAt
+35
View File
@@ -8,6 +8,7 @@ from django.utils import formats, timezone
import pytest
from chat.agents.summarize import SummarizationAgent
from chat.clients.pydantic_ai import AIAgentService
logger = logging.getLogger(__name__)
@@ -50,6 +51,40 @@ def mock_ai_agent_service_fixture():
yield _mock_service
@pytest.fixture(name="mock_summarization_agent")
def mock_summarization_agent_fixture():
"""Fixture to mock SummarizationAgent with a custom model."""
@contextmanager
def _mock_agent(model):
"""Context manager to mock SummarizationAgent with a custom model."""
with ExitStack() as stack:
class SummarizationAgentMock(SummarizationAgent):
"""Mocked SummarizationAgent to override the model."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# We cannot use stack.enter_context(agent.override(model=model))
# Because the agent is used outside of this context manager.
# So we directly override the protected member.
logger.info("Overriding SummarizationAgent model with %s", model)
self._model = model # pylint: disable=protected-access
# Mock the SummarizationAgent in all relevant modules, because first import wins
stack.enter_context(
patch("chat.agents.summarize.SummarizationAgent", new=SummarizationAgentMock)
)
stack.enter_context(
patch(
"chat.tools.document_summarize.SummarizationAgent", new=SummarizationAgentMock
)
)
yield
yield _mock_agent
PIXEL_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
@@ -0,0 +1,403 @@
"""
Unit tests for document generic search RAG tool functionality.
"""
import json
import logging
import httpx
import pytest
import responses
import respx
from asgiref.sync import sync_to_async
from pydantic_ai import Agent, RunContext, RunUsage
from core.factories import UserFactory
from chat.tools.document_generic_search_rag import (
add_document_rag_search_tool_from_setting,
get_specific_rag_search_tool_config,
)
pytestmark = pytest.mark.django_db()
def test_get_specific_rag_search_tool_config_with_disabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "disabled",
"tool_description": "Use this tool to search French legal documents and laws.",
"rag_backend_name": "chat.tests.tools.test_document_generic_search_rag.MockRagBackend",
},
}
# The fixture tools are disabled by default
assert get_specific_rag_search_tool_config(user) == {}
def test_get_specific_rag_search_tool_config_with_enabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": "Use this tool when the user "
"asks for information about "
"French public services, the "
"French labor market, "
"employment laws, social "
"benefits, or assistance with "
"administrative procedures.",
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
@responses.activate
def test_get_specific_rag_search_tool_config_with_dynamic_features(settings, posthog):
"""Test get_specific_rag_search_tool_config with dynamic features."""
user = UserFactory()
responses.post(
f"{posthog.host}/flags/?v=2",
json={"flags": {"legal-documents": {"enabled": True}}},
status=200,
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "dynamic",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
}
}
def test_add_document_rag_search_tool_from_setting_adds_tools(settings):
"""Test that add_document_rag_search_tool_from_setting adds tools to the agent."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
add_document_rag_search_tool_from_setting(agent, user)
# Check that tools were added
assert len(agent._function_toolset.tools) == 1 # pylint: disable=protected-access
assert agent._function_toolset.tools["legal_documents"].name == "legal_documents" # pylint: disable=protected-access
assert (
agent._function_toolset.tools["legal_documents"].description # pylint: disable=protected-access
== "Use this tool to search legal documents and laws."
)
assert agent._function_toolset.tools["legal_documents"].function_schema.json_schema == { # pylint: disable=protected-access
"additionalProperties": False,
"properties": {
"query": {"description": "The query to search information about.", "type": "string"}
},
"required": ["query"],
"type": "object",
}
def test_add_document_rag_search_tool_with_invalid_backend(settings, caplog):
"""Test that invalid backend import is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"rag_backend_name": "non.existent.Backend",
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "Could not import RAG backend non.existent.Backend" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_collection_ids(settings, caplog):
"""Test that missing collection_ids is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No collection IDs provided for tool legal_documents" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_tool_description(settings, caplog):
"""Test that missing tool_description is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No tool description provided for tool legal_documents" in caplog.records[0].message
@respx.mock
def test_document_search_rag_tool_execution(settings):
"""Test that the generated RAG tool executes correctly."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"legal_documents_2": {
"collection_ids": [200],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent(model="test")
add_document_rag_search_tool_from_setting(agent, user)
result = agent.run_sync("What information can you find about French services?")
# Verify the result
assert json.loads(result.output) == {
"legal_documents": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
"legal_documents_2": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
}
assert len(search_mock.calls) == 2
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
assert json.loads(search_mock.calls[1].request.content) == {
"collections": [200],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
def test_get_specific_rag_search_tool_config_with_empty_settings(settings):
"""Test get_specific_rag_search_tool_config with empty SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {}
user = UserFactory()
config = get_specific_rag_search_tool_config(user)
assert config == {}
@pytest.mark.asyncio
@respx.mock
async def test_add_document_rag_search_tool_function_call(settings):
"""Test the function behavior."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
assert result.return_value == {
"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}
}
assert result.metadata == {"sources": {"doc1.txt"}}
assert len(search_mock.calls) == 1
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "Find information about French laws.",
"score_threshold": 0.6,
}
@pytest.mark.asyncio
@respx.mock
async def test_document_search_rag_http_status_error(settings, caplog):
"""Test that HTTPStatusError is properly handled and logged."""
caplog.set_level(logging.ERROR, logger="chat.tools.document_generic_search_rag")
# Mock the API to return a 500 error
respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=500,
json={"error": "Internal server error"},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Call the tool function and expect a ModelRetry to be raised and caught
tool_result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
# Verify the exception message
assert tool_result == (
"Document search service is currently unavailable: Server error '500 Internal "
"Server Error' for url 'https://albert.api.etalab.gouv.fr/v1/search'\n"
"For more information check: "
"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 You must "
"explain this to the user and not try to answer based on your knowledge."
)
# Verify that error was logged
assert "RAG document search failed for tool legal_documents" in caplog.records[0].message
assert "Document search service is currently unavailable" in caplog.records[1].message
@@ -0,0 +1,472 @@
"""Tests for document_summarize functionality."""
import io
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from pydantic_ai import ModelResponse, RunContext, TextPart
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.usage import RunUsage
from chat.agents.summarize import SummarizationAgent
from chat.llm_configuration import LLModel, LLMProvider
from chat.tools.document_summarize import document_summarize, summarize_chunk
@pytest.fixture(autouse=True)
def fixture_summarization_agent_config(settings):
"""Fixture to set used settings for agent configuration."""
settings.LLM_CONFIGURATIONS = {
settings.LLM_SUMMARIZATION_MODEL_HRID: LLModel(
hrid="mistral-model",
model_name="mistral-7b-instruct-v0.1",
human_readable_name="Mistral 7B Instruct",
profile=None,
provider=LLMProvider(
hrid="mistral",
kind="mistral",
base_url="https://api.mistral.ai/v1",
api_key="testkey",
),
is_active=True,
system_prompt="direct",
tools=[],
),
}
@pytest.fixture(name="mocked_context")
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = mock.Mock(spec=RunContext)
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
return mock_ctx
def mocked_summary(_messages, _info=None):
"""Mocked summary response."""
return ModelResponse(parts=[TextPart(content="This is a summary of the test chunk.")])
@pytest.mark.asyncio
async def test_summarize_chunk_returns_summary(mocked_context):
"""Test that summarize_chunk returns a summary."""
summarization_agent = SummarizationAgent()
with summarization_agent.override(model=FunctionModel(mocked_summary)):
chunk = "This is a test chunk of text that needs to be summarized."
result = await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
assert result == "This is a summary of the test chunk."
@pytest.mark.asyncio
async def test_summarize_chunk_raises_model_retry_on_error(mocked_context):
"""Test that summarize_chunk raises ModelRetry when agent fails."""
summarization_agent = SummarizationAgent()
def mocked_summary_error(_messages, _info=None):
"""Mocked summary that raises an error."""
raise ValueError("Simulated LLM error")
with summarization_agent.override(model=FunctionModel(mocked_summary_error)):
chunk = "This is a test chunk."
with pytest.raises(ModelRetry) as exc_info:
await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
assert "An error occurred while summarizing a part of the document chunk" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_summarize_chunk_handles_empty_response(mocked_context):
"""Test that summarize_chunk handles empty responses from the agent."""
summarization_agent = SummarizationAgent()
def mocked_empty_summary(_messages, _info=None):
"""Mocked summary that returns empty content."""
return ModelResponse(parts=[TextPart(content="")])
with summarization_agent.override(model=FunctionModel(mocked_empty_summary)):
chunk = "This is a test chunk."
# Empty responses cause ModelRetry since pydantic-ai considers them invalid
with pytest.raises(ModelRetry):
await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
@pytest.mark.asyncio
async def test_document_summarize_single_document(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with a single document."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
# Create mock conversation with a text attachment
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test_doc.txt"
mock_attachment.file_name = "test_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
# Mock file storage
file_content = "This is a test document. " * 20 # Create a document with some content
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
call_count = {"chunk": 0, "merge": 0}
def mocked_summary_full(messages, _info=None):
"""Mocked summary for full flow."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
call_count["merge"] += 1
return ModelResponse(
parts=[TextPart(content="# Final Summary\n\nThis is the final merged summary.")]
)
call_count["chunk"] += 1
return ModelResponse(
parts=[TextPart(content=f"Summary of chunk {call_count['chunk']}")]
)
with mock_summarization_agent(FunctionModel(mocked_summary_full)):
result = await document_summarize(mocked_context, instructions=None)
assert result.return_value == "# Final Summary\n\nThis is the final merged summary."
assert result.metadata["sources"] == {"test_doc.txt"}
assert call_count["merge"] == 1
@pytest.mark.asyncio
async def test_document_summarize_multiple_documents(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with multiple documents."""
settings.SUMMARIZATION_CHUNK_SIZE = 50
settings.SUMMARIZATION_OVERLAP_SIZE = 5
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
# Create mock conversation with multiple text attachments
mock_conversation = mock.Mock()
mock_attachment1 = mock.Mock()
mock_attachment1.key = "doc1.txt"
mock_attachment1.file_name = "doc1.txt"
mock_attachment1.content_type = "text/plain"
mock_attachment2 = mock.Mock()
mock_attachment2.key = "doc2.txt"
mock_attachment2.file_name = "doc2.txt"
mock_attachment2.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment1, mock_attachment2]
file_content1 = "Content of document one. " * 10
file_content2 = "Content of document two. " * 10
def mock_open_side_effect(key):
"""Mock file opening based on key."""
if key == "doc1.txt":
return io.BytesIO(file_content1.encode("utf-8"))
return io.BytesIO(file_content2.encode("utf-8"))
with mock.patch.object(default_storage, "open", side_effect=mock_open_side_effect):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_multi(messages, _info=None):
"""Mocked summary for multiple documents."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(parts=[TextPart(content="Combined summary of all documents")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_multi)):
result = await document_summarize(mocked_context, instructions=None)
assert result.return_value == "Combined summary of all documents"
assert result.metadata["sources"] == {"doc1.txt", "doc2.txt"}
@pytest.mark.asyncio
async def test_document_summarize_with_custom_instructions(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with custom instructions."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
captured_merge_prompt = []
def mocked_summary_with_instructions(messages, _info=None):
"""Mocked summary that captures merge prompt."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
captured_merge_prompt.append(messages_text)
return ModelResponse(parts=[TextPart(content="Summary in 2 paragraphs")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_with_instructions)):
result = await document_summarize(
mocked_context, instructions="summary in 2 paragraphs"
)
assert result.return_value == "Summary in 2 paragraphs"
assert len(captured_merge_prompt) == 1
assert "summary in 2 paragraphs" in captured_merge_prompt[0]
@pytest.mark.asyncio
async def test_document_summarize_no_text_attachments(mocked_context, mock_summarization_agent):
"""Test document_summarize returns error message when no text documents found."""
mock_conversation = mock.Mock()
mock_conversation.attachments.filter.return_value = []
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
# The decorator @last_model_retry_soft_fail catches ModelCannotRetry and returns a message
# We still need to provide a mock agent even if it won't be called
with mock_summarization_agent(FunctionModel(mocked_summary)):
result = await document_summarize(mocked_context, instructions=None)
assert "No text documents found in the conversation" in result
@pytest.mark.asyncio
async def test_document_summarize_error_reading_document(mocked_context, mock_summarization_agent):
"""Test document_summarize handles errors when reading documents."""
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
# The decorator @last_model_retry_soft_fail catches ModelCannotRetry and returns a message
# We still need to provide a mock agent even if it won't be called
with mock_summarization_agent(FunctionModel(mocked_summary)):
result = await document_summarize(mocked_context, instructions=None)
assert "An unexpected error occurred during document summarization" in result
@pytest.mark.asyncio
async def test_document_summarize_error_during_chunk_summarization(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize handles ModelRetry during chunk summarization."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_error(messages, _info=None):
"""Mocked summary that raises an error during chunks."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" not in messages_text:
raise ValueError("Chunk processing error")
return ModelResponse(parts=[TextPart(content="Final summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_error)):
with pytest.raises(ModelRetry):
await document_summarize(mocked_context, instructions=None)
@pytest.mark.asyncio
async def test_document_summarize_error_during_merge(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize handles errors during final merge."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_merge_error(messages, _info=None):
"""Mocked summary that raises an error during merge."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
raise ValueError("Merge error")
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_merge_error)):
with pytest.raises(ModelRetry) as exc_info:
await document_summarize(mocked_context, instructions=None)
# Should raise ModelRetry regardless of which phase failed
assert "An error occurred" in str(exc_info.value)
@pytest.mark.asyncio
async def test_document_summarize_empty_result(settings, mocked_context, mock_summarization_agent):
"""Test document_summarize raises ModelRetry when summarization produces empty result."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_empty_summary(messages, _info=None):
"""Mocked summary that returns empty for merge."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(parts=[TextPart(content=" ")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_empty_summary)):
with pytest.raises(ModelRetry) as exc_info:
await document_summarize(mocked_context, instructions=None)
# Should raise ModelRetry with the specific message
assert "The summarization produced an empty result" in str(exc_info.value)
@pytest.mark.asyncio
async def test_document_summarize_large_document_multiple_chunks(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with a large document requiring multiple chunks."""
settings.SUMMARIZATION_CHUNK_SIZE = 20 # Small chunk size to force multiple chunks
settings.SUMMARIZATION_OVERLAP_SIZE = 5
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "large_doc.txt"
mock_attachment.file_name = "large_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
# Create a large document
file_content = "This is a word. " * 100 # Should create multiple chunks
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
chunk_count = {"count": 0}
def mocked_summary_multi_chunks(messages, _info=None):
"""Mocked summary that counts chunks."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(
parts=[TextPart(content=f"Final summary of {chunk_count['count']} chunks")]
)
chunk_count["count"] += 1
return ModelResponse(
parts=[TextPart(content=f"Summary of chunk {chunk_count['count']}")]
)
with mock_summarization_agent(FunctionModel(mocked_summary_multi_chunks)):
result = await document_summarize(mocked_context, instructions=None)
assert "Final summary of" in result.return_value
assert chunk_count["count"] > 1 # Ensure multiple chunks were processed
+154
View File
@@ -0,0 +1,154 @@
"""Tests for chat tool utilities."""
import inspect
from typing import get_type_hints
import pytest
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
def test_last_model_retry_soft_fail_preserves_function_metadata():
"""Test that the decorator preserves function metadata for schema generation."""
@last_model_retry_soft_fail
async def example_tool(ctx: RunContext, query: str, limit: int = 10) -> str: # pylint: disable=unused-argument
"""
Example tool function.
Args:
ctx: The run context.
query: The search query.
limit: Maximum number of results.
Returns:
The search results.
"""
return f"Results for {query} (limit: {limit})"
# Check that function name is preserved
assert example_tool.__name__ == "example_tool"
# Check that docstring is preserved
assert example_tool.__doc__ is not None
assert "Example tool function" in example_tool.__doc__
# Check that signature is preserved
sig = inspect.signature(example_tool)
assert "ctx" in sig.parameters
assert "query" in sig.parameters
assert "limit" in sig.parameters
assert sig.parameters["limit"].default == 10
# Check that type hints are preserved
type_hints = get_type_hints(example_tool)
assert "query" in type_hints
assert type_hints["query"] == str
assert "limit" in type_hints
assert type_hints["limit"] == int
assert type_hints["return"] == str
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_normal_execution():
"""Test that the decorator doesn't interfere with normal execution."""
@last_model_retry_soft_fail
async def example_tool(_ctx: RunContext, value: str) -> str:
"""Example tool."""
return f"Result: {value}"
# Create a mock context
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {}
tool_name = "example_tool"
ctx = MockContext()
result = await example_tool(ctx, "test")
assert result == "Result: test"
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_handles_retry_exception():
"""Test that the decorator handles ModelRetry exceptions correctly."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelRetry("Please retry with different parameters")
return "Success"
# Create a mock context
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries haven't been exhausted - should re-raise
with pytest.raises(ModelRetry):
await failing_tool(ctx, should_fail=True)
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_returns_message_when_max_retries_reached():
"""Test that the decorator returns the error message when max retries is reached."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelRetry("Please retry with different parameters.")
return "Success"
# Create a mock context with max retries already reached
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {"failing_tool": 3}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries have been exhausted - should return message
result = await failing_tool(ctx, should_fail=True)
assert result == (
"Please retry with different parameters. "
"You must explain this to the user and not try to answer based on your knowledge."
)
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_returns_message_when_model_cannot_retry():
"""Test that the decorator returns the error message when ModelCannotRetry is raised."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelCannotRetry("This is broken duh.")
return "Success"
# Create a mock context with max retries already reached
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {"failing_tool": 3}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries have been exhausted - should return message
result = await failing_tool(ctx, should_fail=True)
assert result == "This is broken duh."
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
"""tools for testing chat functionality"""
import re
def replace_uuids_with_placeholder(text):
"""Replace all UUIDs in the given text with a placeholder."""
text = re.sub('"toolCallId":"([a-z0-9-]){36}"', '"toolCallId":"XXX"', text)
text = re.sub('"toolCallId":"pyd_ai_([a-z0-9]){32}"', '"toolCallId":"pyd_ai_YYY"', text)
text = re.sub('"([a-z0-9-]){36}"', '"<mocked_uuid>"', text)
return text
@@ -1,8 +1,6 @@
"""Common test fixtures for chat conversation endpoint tests."""
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
@@ -12,14 +10,6 @@ import respx
from freezegun import freeze_time
@pytest.fixture(name="mock_uuid4")
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
value = uuid.uuid4()
with patch("uuid.uuid4", return_value=value):
yield value
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
@@ -2,11 +2,14 @@
# pylint: disable=too-many-lines
import json
import logging
from django.utils import timezone
import pytest
import respx
from asgiref.sync import sync_to_async
from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
@@ -21,6 +24,7 @@ from chat.ai_sdk_types import (
)
from chat.factories import ChatConversationFactory
from chat.llm_configuration import LLModel, LLMProvider
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -86,7 +90,7 @@ def test_post_conversation_invalid_protocol(api_client):
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uuid4):
def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"""Test posting messages to a conversation using the 'data' protocol."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
@@ -113,10 +117,14 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -135,8 +143,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -147,8 +156,9 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -159,6 +169,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -188,6 +199,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -208,13 +220,14 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream, mock_uu
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uuid4):
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"""Test posting messages to a conversation using the 'text' protocol."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
@@ -256,8 +269,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -268,8 +282,9 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -280,6 +295,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -309,6 +325,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -329,13 +346,14 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream, mock_uu
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock_uuid4):
def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"""Ensure an image URL is correctly forwarded to the AI service."""
chat_conversation = ChatConversationFactory(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
@@ -373,10 +391,14 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -437,8 +459,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello, what do you see on this picture?",
reasoning=None,
@@ -459,8 +482,9 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -471,6 +495,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -512,6 +537,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -532,13 +558,14 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image, mock
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_uuid4, settings):
def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settings):
"""Ensure tool calls are correctly forwarded and streamed back."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -567,6 +594,10 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -575,7 +606,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -606,8 +637,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -618,8 +650,9 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -642,6 +675,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -671,6 +705,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -699,6 +734,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
@@ -713,6 +749,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -735,15 +772,14 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, mock_u
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_fails(
api_client, mock_openai_stream_tool, mock_uuid4, settings
):
def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool, settings):
"""Ensure tool calls are correctly forwarded and streamed back when failing."""
settings.AI_AGENT_TOOLS = []
@@ -772,6 +808,10 @@ def test_post_conversation_tool_call_fails(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":"get_current_weather"}\n'
'c:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","argsTextDelta":'
@@ -779,7 +819,7 @@ def test_post_conversation_tool_call_fails(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
"name: 'get_current_weather'. No tools available.\"}\n"
'0:"I cannot give you an answer to that."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -810,8 +850,9 @@ def test_post_conversation_tool_call_fails(
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -822,8 +863,9 @@ def test_post_conversation_tool_call_fails(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="I cannot give you an answer to that.",
reasoning=None,
@@ -846,6 +888,7 @@ def test_post_conversation_tool_call_fails(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -875,6 +918,7 @@ def test_post_conversation_tool_call_fails(
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -903,6 +947,7 @@ def test_post_conversation_tool_call_fails(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
@@ -916,6 +961,7 @@ def test_post_conversation_tool_call_fails(
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -938,6 +984,7 @@ def test_post_conversation_tool_call_fails(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -970,7 +1017,6 @@ def test_post_conversation_model_selection_invalid(api_client):
def test_post_conversation_model_selection_new(
api_client,
mock_openai_stream,
mock_uuid4,
settings,
):
"""Test the user can select a different model."""
@@ -1015,10 +1061,14 @@ def test_post_conversation_model_selection_new(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1032,7 +1082,6 @@ def test_post_conversation_model_selection_new(
def test_post_conversation_data_protocol_no_stream(
api_client,
mock_openai_no_stream,
mock_uuid4,
settings,
stream_delay,
):
@@ -1084,6 +1133,9 @@ def test_post_conversation_data_protocol_no_stream(
# Wait for the content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
if stream_delay:
assert response_content == (
'0:"The "\n'
@@ -1103,13 +1155,13 @@ def test_post_conversation_data_protocol_no_stream(
'0:" sca"\n'
'0:"tter"\n'
'0:"ing."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
)
else:
assert response_content == (
'0:"The sky appears blue due to a phenomenon called Rayleigh scattering."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":135}}\n'
)
@@ -1128,8 +1180,9 @@ def test_post_conversation_data_protocol_no_stream(
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[0].id,
createdAt=timezone.now(), # Mocked timestamp
content="Why the sky is blue?",
reasoning=None,
@@ -1140,8 +1193,9 @@ def test_post_conversation_data_protocol_no_stream(
parts=[TextUIPart(type="text", text="Why the sky is blue?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="The sky appears blue due to a phenomenon called Rayleigh scattering.",
reasoning=None,
@@ -1157,6 +1211,7 @@ def test_post_conversation_data_protocol_no_stream(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -1186,6 +1241,7 @@ def test_post_conversation_data_protocol_no_stream(
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -1213,5 +1269,154 @@ def test_post_conversation_data_protocol_no_stream(
"output_audio_tokens": 0,
"output_tokens": 135,
},
"run_id": _run_id,
},
]
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
@pytest.mark.asyncio
async def test_post_conversation_async(api_client, mock_openai_stream, monkeypatch, caplog):
"""Test posting messages to a conversation using the 'data' protocol."""
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "yuPoOuBkKA4FnKvk",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
await api_client.aforce_login(chat_conversation.owner)
caplog.clear()
caplog.set_level(level=logging.DEBUG, logger="chat.views")
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
assert response.status_code == status.HTTP_200_OK
assert response.get("Content-Type") == "text/event-stream"
assert response.get("x-vercel-ai-data-stream") == "v1"
assert response.streaming
assert "Using ASYNC streaming for chat conversation" in caplog.text
# Wait for the streaming content to be fully received => async iterator -> list
# This fails it the streaming is not an async generator
response_content = b"".join([content async for content in response.streaming_content]).decode(
"utf-8"
)
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
assert mock_openai_stream.called
await chat_conversation.arefresh_from_db()
assert chat_conversation.ui_messages == [
{
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
"id": "yuPoOuBkKA4FnKvk",
"parts": [{"text": "Hello", "type": "text"}],
"role": "user",
}
]
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=chat_conversation.messages[0].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
experimental_attachments=None,
role="user",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=chat_conversation.messages[1].id, # don't test the message ID here
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
experimental_attachments=None,
role="assistant",
annotations=None,
toolInvocations=None,
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
"kind": "response",
"model_name": "test-model",
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
"provider_details": {"finish_reason": "stop"},
"provider_name": "openai",
"provider_response_id": "chatcmpl-1234567890",
"timestamp": "2025-07-25T10:36:35.297675Z",
"usage": {
"cache_audio_read_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"details": {},
"input_audio_tokens": 0,
"input_tokens": 0,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -14,6 +14,7 @@ import httpx
import pytest
import responses
import respx
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel
@@ -32,6 +33,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -149,7 +151,7 @@ def fixture_mock_summarization_agent():
super().__init__(**kwargs)
self._model = FunctionModel(function=summarization_model) # pylint: disable=protected-access
with mock.patch("chat.agents.summarize.SummarizationAgent", new=SummarizationAgentMock):
with mock.patch("chat.tools.document_summarize.SummarizationAgent", new=SummarizationAgentMock):
yield
@@ -214,12 +216,11 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
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
sample_pdf_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -273,9 +274,11 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
@@ -283,19 +286,22 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
'b:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag",'
'"args":{"query":"What does the document say?"}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf","title":null,"providerMetadata":{}}\n'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf","title":null,'
'"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":[{"url":"sample.pdf","content":"This '
'is the content of the PDF.","score":0.9}]}\n'
"0:\"From the document, I can see that it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":100,"completionTokens":20}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What does the document say?",
reasoning=None,
@@ -305,8 +311,10 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
toolInvocations=None,
parts=[TextUIPart(type="text", text="What does the document say?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="From the document, I can see that it says 'Hello PDF'.",
reasoning=None,
@@ -318,7 +326,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="document_search_rag",
args={"query": "What does the document say?"},
state="call",
@@ -330,7 +338,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf",
title=None,
providerMetadata={},
@@ -343,8 +351,17 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": None,
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"modification, paraphrasing, or additional summarization.The "
"tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if "
"required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.",
"kind": "request",
"parts": [
{
@@ -366,11 +383,19 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"timestamp": timezone_now,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
@@ -381,6 +406,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -391,7 +417,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"args": '{"query": "What does the document say?"}',
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
}
],
@@ -409,9 +435,19 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"output_audio_tokens": 0,
"output_tokens": 8,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": None,
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
"kind": "request",
"parts": [
{
@@ -425,10 +461,11 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"metadata": {"sources": ["sample.pdf"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -455,19 +492,19 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"output_audio_tokens": 0,
"output_tokens": 12,
},
"run_id": _run_id,
}
@responses.activate
@respx.mock
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload_feature_disabled( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
caplog,
mock_openai_stream, # pylint: disable=unused-argument
sample_pdf_content,
feature_flags,
mock_uuid4,
):
"""
Test POST to /api/v1/chats/{pk}/conversation/ with a PDF document while feature is disabled.
@@ -512,10 +549,14 @@ def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR09
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"From the document, I can see that "\n'
"0:\"it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
@@ -531,7 +572,6 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
mock_summarization_agent, # pylint: disable=unused-argument
):
@@ -586,29 +626,33 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'b:{"toolCallId":"pyd_ai_YYY","toolName":"summarize"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"summarize","args":{}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf.md",'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf.md",'
'"title":null,"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":"The '
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"XXX"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":201,"completionTokens":13}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":317,"completionTokens":19}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="Make a summary of this document.",
reasoning=None,
@@ -618,8 +662,10 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
toolInvocations=None,
parts=[TextUIPart(type="text", text="Make a summary of this document.")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="The document discusses various topics.",
reasoning=None,
@@ -631,7 +677,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="summarize",
args={},
state="call",
@@ -643,7 +689,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf.md", # might be fixed in the future
title=None,
providerMetadata={},
@@ -656,8 +702,17 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": None,
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"modification, paraphrasing, or additional summarization.The "
"tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if "
"required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.",
"kind": "request",
"parts": [
{
@@ -679,11 +734,19 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"timestamp": timezone_now,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
@@ -694,6 +757,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -704,7 +768,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"args": "{}",
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
}
],
@@ -722,9 +786,19 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 1,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": None,
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
"kind": "request",
"parts": [
{
@@ -732,10 +806,11 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"metadata": {"sources": ["sample.pdf.md"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -758,4 +833,5 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 6,
},
"run_id": _run_id,
}
@@ -1,5 +1,8 @@
"""Unit tests for chat conversation actions with document URL."""
import uuid
# pylint: disable=too-many-lines
from io import BytesIO
from django.core.files.storage import default_storage
@@ -7,6 +10,7 @@ from django.utils import formats, timezone
import pytest
import responses
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
@@ -26,6 +30,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationAttachmentFactory, ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -60,7 +65,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
api_client,
sample_document_content,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -141,7 +145,8 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is a document about a single pixel."
@@ -161,20 +166,26 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -188,8 +199,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -205,6 +218,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -244,6 +258,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -270,6 +285,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -277,7 +293,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
@freeze_time()
def test_post_conversation_with_local_document_wrong_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -286,7 +301,7 @@ def test_post_conversation_with_local_document_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
document_url = f"/media-key/{mock_uuid4}/sample.pdf"
document_url = f"/media-key/{uuid.uuid4()}/sample.pdf"
message = UIMessage(
id="1",
@@ -325,10 +340,14 @@ def test_post_conversation_with_local_document_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"Document '
'URL does not belong to the conversation."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -342,7 +361,6 @@ def test_post_conversation_with_local_document_wrong_url(
@freeze_time()
def test_post_conversation_with_remote_document_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -390,10 +408,14 @@ def test_post_conversation_with_remote_document_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"External document '
'URL are not accepted yet."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -408,7 +430,6 @@ def test_post_conversation_with_remote_document_url(
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -421,7 +442,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -436,7 +457,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -566,13 +587,15 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
),
ModelResponse(
parts=[TextPart(content="This is a document about a single pixel.")],
usage=RequestUsage(input_tokens=50, output_tokens=9),
model_name="function::agent_model",
timestamp=timezone.now(),
run_id=messages[1].run_id,
),
ModelRequest(
parts=[
@@ -582,7 +605,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
)
]
],
run_id=messages[2].run_id,
),
]
yield "This is a document of square, very small and nice."
@@ -602,17 +626,23 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is a document of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -626,8 +656,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -639,8 +671,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="This is a document about a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id,
createdAt=timezone.now(),
content="Give more details about this document.",
reasoning=None,
@@ -652,8 +686,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="Give more details about this document."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id,
createdAt=timezone.now(),
content="This is a document of square, very small and nice.",
reasoning=None,
@@ -666,6 +702,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -705,6 +742,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
# no run_id here
},
{
"finish_reason": None,
@@ -731,6 +769,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 9,
},
# no run_id here
},
{
"instructions": None,
@@ -742,6 +781,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -768,6 +808,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -782,10 +823,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
("data.csv", "text/csv"),
],
)
def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
file_name,
content_type,
@@ -853,10 +893,17 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
SystemPromptPart(
content=(
"If the user wants specific information from a document, "
"invoke web_search_albert_rag with an appropriate query string."
"Do not ask the user for the document; rely on the tool to locate "
"and return relevant passages."
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead."
),
timestamp=timezone.now(),
),
SystemPromptPart(
content=(
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
timestamp=timezone.now(),
),
@@ -867,7 +914,18 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
],
timestamp=timezone.now(),
),
]
],
instructions=(
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
run_id=messages[0].run_id,
)
]
yield "This is a document about you."
@@ -887,20 +945,26 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
f'"args":{{"documents":[{{"identifier":"{file_name}"}}]}}}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about you."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":7}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -912,8 +976,10 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about you.",
reasoning=None,
@@ -929,9 +995,19 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
"kind": "request",
"parts": [
{
@@ -953,11 +1029,19 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
"timestamp": timestamp,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "[Internal context] User documents are attached to "
"this conversation. Do not request re-upload of "
"documents; consider them already available via the "
"internal store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
@@ -970,6 +1054,7 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -996,5 +1081,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
"output_audio_tokens": 0,
"output_tokens": 7,
},
"run_id": _run_id,
},
]
@@ -7,6 +7,7 @@ from django.utils import timezone
import pytest
import respx
from dirty_equals import IsUUID
from freezegun import freeze_time
from rest_framework import status
@@ -18,6 +19,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -200,7 +202,7 @@ def history_conversation_fixture():
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_data_protocol_with_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'data' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
@@ -226,10 +228,14 @@ def test_post_conversation_data_protocol_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -259,8 +265,9 @@ def test_post_conversation_data_protocol_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -271,8 +278,9 @@ def test_post_conversation_data_protocol_with_history(
parts=[TextUIPart(type="text", text="Hello")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -290,7 +298,7 @@ def test_post_conversation_data_protocol_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_text_protocol_with_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation
api_client, mock_openai_stream, history_conversation
):
"""Test posting messages to a conversation with history using the 'text' protocol."""
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=text"
@@ -335,8 +343,9 @@ def test_post_conversation_text_protocol_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent messages are the new ones
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello",
reasoning=None,
@@ -347,8 +356,9 @@ def test_post_conversation_text_protocol_with_history(
parts=[TextUIPart(type="text", text="Hello")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -363,7 +373,7 @@ def test_post_conversation_text_protocol_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_image_with_history(
api_client, mock_openai_stream_image, mock_uuid4, history_conversation
api_client, mock_openai_stream_image, history_conversation
):
"""
Ensure an image URL is correctly forwarded to the AI service with a conversation with history.
@@ -403,10 +413,14 @@ def test_post_conversation_with_image_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -452,8 +466,9 @@ def test_post_conversation_with_image_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message has the image attachment
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello, what do you see on this picture?",
reasoning=None,
@@ -474,8 +489,9 @@ def test_post_conversation_with_image_with_history(
parts=[TextUIPart(type="text", text="Hello, what do you see on this picture?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -490,7 +506,7 @@ def test_post_conversation_with_image_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_with_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
api_client, mock_openai_stream_tool, settings, history_conversation
):
"""
Ensure tool calls are correctly forwarded and streamed back with a conversation with history.
@@ -521,6 +537,10 @@ def test_post_conversation_tool_call_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -529,7 +549,7 @@ def test_post_conversation_tool_call_with_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -561,8 +581,9 @@ def test_post_conversation_tool_call_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -573,8 +594,9 @@ def test_post_conversation_tool_call_with_history(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -606,7 +628,7 @@ def test_post_conversation_tool_call_with_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_tool_call_fails_with_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation
api_client, mock_openai_stream_tool, settings, history_conversation
):
"""
Ensure tool calls are correctly forwarded and streamed back when failing with a
@@ -638,6 +660,10 @@ def test_post_conversation_tool_call_fails_with_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -646,7 +672,7 @@ def test_post_conversation_tool_call_fails_with_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool '
"name: 'get_current_weather'. No tools available.\"}\n"
'0:"I cannot give you an answer to that."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -678,8 +704,9 @@ def test_post_conversation_tool_call_fails_with_history(
assert len(history_conversation.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation.messages[4].id == IsUUID(4)
assert history_conversation.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="Weather in Paris?",
reasoning=None,
@@ -690,8 +717,9 @@ def test_post_conversation_tool_call_fails_with_history(
parts=[TextUIPart(type="text", text="Weather in Paris?")],
)
assert history_conversation.messages[5].id == IsUUID(4)
assert history_conversation.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I cannot give you an answer to that.",
reasoning=None,
@@ -1147,7 +1175,7 @@ def history_conversation_with_tool_fixture():
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_existing_image_history(
api_client, mock_openai_stream, mock_uuid4, history_conversation_with_image
api_client, mock_openai_stream, history_conversation_with_image
):
"""Test posting a message to a conversation that already has images in its history."""
url = f"/api/v1.0/chats/{history_conversation_with_image.pk}/conversation/?protocol=data"
@@ -1173,10 +1201,14 @@ def test_post_conversation_with_existing_image_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1207,8 +1239,9 @@ def test_post_conversation_with_existing_image_history(
assert len(history_conversation_with_image.messages) == 6
# Verify the most recent messages are the new ones
assert history_conversation_with_image.messages[4].id == IsUUID(4)
assert history_conversation_with_image.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_image.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="What was in that image again?",
reasoning=None,
@@ -1219,8 +1252,9 @@ def test_post_conversation_with_existing_image_history(
parts=[TextUIPart(type="text", text="What was in that image again?")],
)
assert history_conversation_with_image.messages[5].id == IsUUID(4)
assert history_conversation_with_image.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_image.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="Hello there",
reasoning=None,
@@ -1238,7 +1272,7 @@ def test_post_conversation_with_existing_image_history(
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_with_existing_tool_history(
api_client, mock_openai_stream_tool, mock_uuid4, settings, history_conversation_with_tool
api_client, mock_openai_stream_tool, settings, history_conversation_with_tool
):
"""Test posting a message to a conversation that already has tool calls in its history."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -1266,6 +1300,10 @@ def test_post_conversation_with_existing_tool_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'b:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","toolName":'
'"get_current_weather"}\n'
@@ -1274,7 +1312,7 @@ def test_post_conversation_with_existing_tool_history(
'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":{"location":'
'"Paris","temperature":22,"unit":"celsius"}}\n'
'0:"The current weather in Paris is nice"\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1294,8 +1332,9 @@ def test_post_conversation_with_existing_tool_history(
assert len(history_conversation_with_tool.messages) == 6
# Verify the most recent message is the new one with tool invocation
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
assert history_conversation_with_tool.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="How about Paris weather?",
reasoning=None,
@@ -1306,8 +1345,9 @@ def test_post_conversation_with_existing_tool_history(
parts=[TextUIPart(type="text", text="How about Paris weather?")],
)
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
assert history_conversation_with_tool.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="The current weather in Paris is nice",
reasoning=None,
@@ -1333,6 +1373,8 @@ def test_post_conversation_with_existing_tool_history(
# The pydantic_messages should include both the original tool calls and the new ones
assert len(history_conversation_with_tool.pydantic_messages) == 12 # Original 8 + 4 new ones
_run_id = history_conversation_with_tool.pydantic_messages[8]["run_id"]
# Verify the new tool call request is included
assert history_conversation_with_tool.pydantic_messages[8] == {
"instructions": None,
@@ -1344,6 +1386,7 @@ def test_post_conversation_with_existing_tool_history(
"timestamp": "2025-07-25T10:36:35.297675Z",
}
],
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[9] == {
@@ -1373,6 +1416,7 @@ def test_post_conversation_with_existing_tool_history(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[10] == {
@@ -1388,6 +1432,7 @@ def test_post_conversation_with_existing_tool_history(
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
}
assert history_conversation_with_tool.pydantic_messages[11] == {
@@ -1411,13 +1456,14 @@ def test_post_conversation_with_existing_tool_history(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
}
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_add_image_to_conversation_with_tool_history(
api_client, mock_openai_stream_image, mock_uuid4, history_conversation_with_tool
api_client, mock_openai_stream_image, history_conversation_with_tool
):
"""Test adding an image to a conversation that already has tool calls in its history."""
url = f"/api/v1.0/chats/{history_conversation_with_tool.pk}/conversation/?protocol=data"
@@ -1455,10 +1501,14 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"I see a cat"\n'
'0:" in the picture."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
@@ -1484,8 +1534,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
assert len(history_conversation_with_tool.messages) == 6
# Verify the most recent message has the image attachment
assert history_conversation_with_tool.messages[4].id == IsUUID(4)
assert history_conversation_with_tool.messages[4] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[4].id,
createdAt=timezone.now(), # Mocked timestamp
content="How's the weather in this image?",
reasoning=None,
@@ -1506,8 +1557,9 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
parts=[TextUIPart(type="text", text="How's the weather in this image?")],
)
assert history_conversation_with_tool.messages[5].id == IsUUID(4)
assert history_conversation_with_tool.messages[5] == UIMessage(
id=str(mock_uuid4), # Mocked UUID
id=history_conversation_with_tool.messages[5].id,
createdAt=timezone.now(), # Mocked timestamp
content="I see a cat in the picture.",
reasoning=None,
@@ -1,8 +1,11 @@
"""Unit tests for chat conversation actions with image URL."""
import uuid
from django.utils import timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
@@ -22,6 +25,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -53,7 +57,6 @@ def fixture_sample_image_content():
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_image_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -111,7 +114,8 @@ def test_post_conversation_with_local_image_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -131,17 +135,23 @@ def test_post_conversation_with_local_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -155,8 +165,10 @@ def test_post_conversation_with_local_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -169,6 +181,7 @@ def test_post_conversation_with_local_image_url(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -208,6 +221,7 @@ def test_post_conversation_with_local_image_url(
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -230,6 +244,7 @@ def test_post_conversation_with_local_image_url(
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -238,7 +253,6 @@ def test_post_conversation_with_local_image_url(
def test_post_conversation_with_local_image_wrong_url(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -247,7 +261,7 @@ def test_post_conversation_with_local_image_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
image_url = f"/media-key/{mock_uuid4}/sample.png"
image_url = f"/media-key/{uuid.uuid4()}/sample.png"
message = UIMessage(
id="1",
@@ -288,7 +302,8 @@ def test_post_conversation_with_local_image_wrong_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "cannot read image." # IRL a 400 error would be raised by the LLM
@@ -308,9 +323,13 @@ def test_post_conversation_with_local_image_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"cannot read image."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":4}}\n'
)
@@ -322,7 +341,6 @@ def test_post_conversation_with_local_image_wrong_url(
def test_post_conversation_with_remote_image_url(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -372,7 +390,8 @@ def test_post_conversation_with_remote_image_url(
],
timestamp=timezone.now(),
),
]
],
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -392,17 +411,23 @@ def test_post_conversation_with_remote_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -416,8 +441,10 @@ def test_post_conversation_with_remote_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -435,7 +462,6 @@ def test_post_conversation_with_remote_image_url(
def test_post_conversation_with_local_image_url_in_history(
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -448,7 +474,7 @@ def test_post_conversation_with_local_image_url_in_history(
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -463,7 +489,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -609,7 +635,8 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
)
]
],
run_id=messages[2].run_id,
),
]
yield "This is an image of square, very small and nice."
@@ -629,17 +656,23 @@ def test_post_conversation_with_local_image_url_in_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -653,8 +686,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -666,8 +701,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="This is an image of a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id, # don't test the value directly
createdAt=timezone.now(),
content="Give more details about this image.",
reasoning=None,
@@ -679,8 +716,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="Give more details about this image."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of square, very small and nice.",
reasoning=None,
@@ -693,6 +732,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
@@ -765,6 +805,7 @@ def test_post_conversation_with_local_image_url_in_history(
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -791,5 +832,6 @@ def test_post_conversation_with_local_image_url_in_history(
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -25,6 +25,8 @@ def test_api_media_auth_unkown_document(api_client):
Trying to download a media related to a conversation that does not exist
should not have the side effect to create it (no regression test).
"""
ChatConversation.objects.all().delete()
original_url = f"http://localhost/media/{uuid4()!s}/attachments/{uuid4()!s}.jpg"
response = api_client.get("/api/v1.0/chats/media-auth/", HTTP_X_ORIGINAL_URL=original_url)
+13 -3
View File
@@ -18,18 +18,28 @@ def get_pydantic_tools_by_name(name: str) -> Tool:
tool_dict = {
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
"web_search_brave": Tool(
web_search_brave, takes_ctx=False, prepare=only_if_web_search_enabled
web_search_brave,
takes_ctx=True,
prepare=only_if_web_search_enabled,
max_retries=2,
),
"web_search_brave_with_document_backend": Tool(
web_search_brave_with_document_backend,
takes_ctx=True,
prepare=only_if_web_search_enabled,
max_retries=2,
),
"web_search_tavily": Tool(
web_search_tavily, takes_ctx=False, prepare=only_if_web_search_enabled
web_search_tavily,
takes_ctx=False,
prepare=only_if_web_search_enabled,
max_retries=2,
),
"web_search_albert_rag": Tool(
web_search_albert_rag, takes_ctx=True, prepare=only_if_web_search_enabled
web_search_albert_rag,
takes_ctx=True,
prepare=only_if_web_search_enabled,
max_retries=2,
),
}
@@ -0,0 +1,137 @@
"""
Helpers to add RAG document search tools to an agent based on settings.
The purpose is to provide a generic way to add multiple RAG document search tools
to an agent based on configuration in settings. Each tool can target specific
document collections and have its own description.
Our use case implies that different users might have access to different document collections,
so the tools added to the agent are also user-specific.
"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.module_loading import import_string
from httpx import HTTPStatusError
from pydantic_ai import Agent, ModelRetry, RunContext, RunUsage
from pydantic_ai.messages import ToolReturn
from core.feature_flags.helpers import is_feature_enabled
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
User = get_user_model()
def get_specific_rag_search_tool_config(user: User) -> dict:
"""
Get the specific RAG search tool configuration from settings.
Settings example:
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
}
"""
return {
tool_name: tool_config
for tool_name, tool_config in settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS.items()
if is_feature_enabled(user, tool_name)
}
def _create_document_search_rag(agent, name, description, backend, ids):
"""Factory function to create a document search RAG tool."""
@agent.tool(
name=name,
retries=1,
require_parameter_descriptions=True,
description=description,
)
@last_model_retry_soft_fail
async def document_search_rag(ctx: RunContext, query: str) -> ToolReturn:
"""
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search information about.
"""
document_store = backend(read_only_collection_id=ids)
try:
rag_results = await document_store.asearch(query)
except HTTPStatusError as exc:
logger.error(
"RAG document search failed for tool %s with error: %s", name, exc, exc_info=True
)
raise ModelRetry(f"Document search service is currently unavailable: {exc}") from exc
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
return ToolReturn(
return_value={
str(idx): {
"url": result.url,
"snippets": result.content,
}
for idx, result in enumerate(rag_results.data)
},
metadata={"sources": {result.url for result in rag_results.data}},
)
return document_search_rag
def add_document_rag_search_tool_from_setting(agent: Agent, user: User) -> None:
"""
This function takes a configuration setting and generates specific search RAG tools and add
it to the agent.
Args:
agent (Agent): The agent to which the tool will be added.
user (User): The user for whom the tool is being added.
"""
for tool_name, tool_config in get_specific_rag_search_tool_config(user).items():
document_store_backend_name = tool_config.get(
"rag_backend_name", settings.RAG_DOCUMENT_SEARCH_BACKEND
)
try:
document_store_backend = import_string(document_store_backend_name)
except ImportError as exc:
logger.warning(
"Could not import RAG backend %s: %s",
document_store_backend_name,
exc,
exc_info=True,
)
continue # Skip if the backend is not available
collection_ids = tool_config.get("collection_ids", [])
if not collection_ids:
logger.warning("No collection IDs provided for tool %s, skipping.", tool_name)
continue # Skip if no collection IDs are provided
tool_description = tool_config.get("tool_description")
if not tool_description:
logger.warning("No tool description provided for tool %s, skipping.", tool_name)
continue # Skip if no tool description is provided
_create_document_search_rag(
agent, tool_name, tool_description, document_store_backend, collection_ids
)
@@ -20,7 +20,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The term to search the internet for.
query (str): The query to search the documents for.
"""
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
@@ -43,8 +43,6 @@ def add_document_rag_search_tool(agent: Agent) -> None:
def document_rag_instructions() -> str:
"""Dynamic system prompt function to add RAG instructions if any."""
return (
"If the user wants specific information from a document, invoke "
"web_search_albert_rag with an appropriate query string."
"Do not ask the user for the document; rely on the tool to locate "
"and return relevant passages."
"Use document_search_rag ONLY to retrieve specific passages from attached documents. "
"Do NOT use it to summarize; for summaries, call the summarize tool instead."
)
@@ -0,0 +1,189 @@
"""Summarization tool used for uploaded documents."""
import asyncio
import logging
from django.conf import settings
from django.core.files.storage import default_storage
import semchunk
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from chat.agents.summarize import SummarizationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
"""Summarize a single chunk of text."""
sum_prompt = (
"You are an agent specializing in text summarization. "
"Generate a clear and concise summary of the following passage "
f"(part {idx}/{total_chunks}):\n'''\n{chunk}\n'''\n\n"
)
logger.debug(
"[summarize] CHUNK %s/%s prompt=> %s", idx, total_chunks, sum_prompt[0:100] + "..."
)
try:
resp = await summarization_agent.run(sum_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during chunk summarization: %s", exc, exc_info=True)
raise ModelRetry(
"An error occurred while summarizing a part of the document chunk."
) from exc
logger.debug("[summarize] CHUNK %s/%s response<= %s", idx, total_chunks, resp.output or "")
return resp.output or ""
@last_model_retry_soft_fail
async def document_summarize( # pylint: disable=too-many-locals
ctx: RunContext, *, instructions: str | None = None
) -> ToolReturn:
"""
Generate a complete, ready-to-use summary of the documents in context
(do not request the documents to the user).
Return this summary directly to the user WITHOUT any modification,
or additional summarization.
The summary is already optimized and MUST be presented as-is in the final response
or translated preserving the information.
Instructions are optional but should reflect the user's request.
Examples:
"Summarize this doc in 2 paragraphs" -> instructions = "summary in 2 paragraphs"
"Summarize this doc in English" -> instructions = "In English"
"Summarize this doc" -> instructions = "" (default)
Args:
instructions (str | None): The instructions the user gave to use for the summarization
"""
try:
instructions_hint = (
instructions.strip() if instructions else "The summary should contain 2 or 3 parts."
)
summarization_agent = SummarizationAgent()
# Collect documents content
text_attachment = await sync_to_async(list)(
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
)
if not text_attachment:
raise ModelCannotRetry(
"No text documents found in the conversation. "
"You must explain this to the user and ask them to provide documents."
)
documents = [await read_document_content(doc) for doc in text_attachment]
# Chunk documents and summarize each chunk
chunk_size = settings.SUMMARIZATION_CHUNK_SIZE
chunker = semchunk.chunkerify(
tokenizer_or_token_counter=lambda text: len(text.split()),
chunk_size=chunk_size,
)
documents_chunks = chunker(
[doc[1] for doc in documents],
overlap=settings.SUMMARIZATION_OVERLAP_SIZE,
)
logger.info(
"[summarize] chunking: %s parts (size~%s), instructions='%s'",
sum(len(chunks) for chunks in documents_chunks),
chunk_size,
instructions_hint,
)
# Parallelize the chunk summarization with a semaphore to limit concurrent tasks
# because it can be very resource intensive on the LLM backend
semaphore = asyncio.Semaphore(settings.SUMMARIZATION_CONCURRENT_REQUESTS)
async def summarize_chunk_with_semaphore(idx, chunk, total_chunks):
"""Summarize a chunk with semaphore-controlled concurrency."""
async with semaphore:
return await summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx)
doc_chunk_summaries = []
try:
for doc_chunks in documents_chunks:
summarization_tasks = [
summarize_chunk_with_semaphore(idx, chunk, len(doc_chunks))
for idx, chunk in enumerate(doc_chunks, start=1)
]
chunk_summaries = await asyncio.gather(*summarization_tasks)
doc_chunk_summaries.append(chunk_summaries)
except ModelRetry as exc:
logger.warning("Retryable error during chunk summarization: %s", exc, exc_info=True)
raise
except Exception as exc:
logger.warning("Error during chunk summarization: %s", exc, exc_info=True)
raise ModelRetry("An error occurred while processing document chunks.") from exc
context = "\n\n".join(
doc_name + "\n\n" + "\n\n".join(summaries)
for doc_name, summaries in zip(
(doc[0] for doc in documents),
doc_chunk_summaries,
strict=True,
)
)
# Merge chunk summaries into a single concise summary
merged_prompt = (
"Produce a coherent synthesis from the summaries below.\n\n"
f"'''\n{context}\n'''\n\n"
"Constraints:\n"
"- Summarize without repetition.\n"
"- Harmonize style and terminology.\n"
"- The final summary must be well-structured and formatted in markdown.\n"
f"- Follow the instructions: {instructions_hint}\n"
"Respond directly with the final summary."
)
logger.debug("[summarize] MERGE prompt=> %s", merged_prompt)
try:
merged_resp = await summarization_agent.run(merged_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during merge summarization: %s", exc, exc_info=True)
raise ModelRetry("An error occurred while generating the final summary.") from exc
final_summary = (merged_resp.output or "").strip()
if not final_summary:
raise ModelRetry("The summarization produced an empty result.")
logger.debug("[summarize] MERGE response<= %s", final_summary)
return ToolReturn(
return_value=final_summary,
metadata={"sources": {doc[0] for doc in documents}},
)
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as exc:
# Unexpected error - stop and inform user
logger.exception("Unexpected error in document_summarize: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during document summarization: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
+23
View File
@@ -0,0 +1,23 @@
"""Exceptions for tool function retries."""
from pydantic_ai import ModelRetry
class ModelRetryLast(ModelRetry):
"""
Same as ModelRetry but also holds the last retry message to return when all attempts failed.
"""
def __init__(self, message: str, last_retry_message: str):
"""Initialize ModelRetryLast with message and last retry message."""
self.last_retry_message = last_retry_message
super().__init__(message)
class ModelCannotRetry(ModelRetry):
"""
Exception to raise when a tool function cannot be retried.
We use this exception to signal that the model should not attempt to retry
the tool call, typically because the error is not transient or recoverable.
"""
+50
View File
@@ -0,0 +1,50 @@
"""Tool calling utilities for the chat agent."""
import functools
import logging
from typing import Any, Callable
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
logger = logging.getLogger(__name__)
def last_model_retry_soft_fail(
tool_func: Callable[..., Any],
) -> Callable[..., Any]:
"""
Wrap a tool function to handle ModelRetry exceptions.
If the tool function raises ModelRetry and the maximum number of retries
has been reached, a ModelCannotRetry exception is raised instead.
Args:
tool_func: The original tool function to wrap.
Returns:
A wrapped tool function with retry handling.
"""
@functools.wraps(tool_func)
async def wrapper(ctx: RunContext, *args, **kwargs) -> Any:
try:
return await tool_func(ctx, *args, **kwargs)
except ModelCannotRetry as exc:
return str(exc.message)
except ModelRetry as exc:
logger.error("Tool '%s' raised ModelRetry: %s", ctx, exc.message)
if (ctx.retries.get(ctx.tool_name, 0) + 1) >= ctx.max_retries:
logger.error("Max retries reached for tool '%s'.", ctx.tool_name)
# A bit of a hack to signal that we cannot retry here, while preventing
# the LLM to generate an outdated answer.
# We may define a more specific exception later base on ModelRetry which
# adds a specific message for this case.
return (
f"{exc.message} You must explain this to the user and "
"not try to answer based on your knowledge."
)
raise # Re-raise to allow retrying
return wrapper
+231 -98
View File
@@ -1,24 +1,42 @@
"""Web search tool using Brave for the chat agent."""
import asyncio
import logging
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
from django.conf import settings
from django.core.cache import cache
from django.utils.module_loading import import_string
from django.utils.text import slugify
import requests
import httpx
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext, RunUsage
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from trafilatura import extract, fetch_url
from trafilatura import extract
from trafilatura.meta import reset_caches
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
def llm_summarize(query: str, text: str) -> str:
class WebSearchError(Exception):
"""Base exception for web search errors."""
class BraveAPIError(WebSearchError):
"""Error when calling Brave API."""
class DocumentFetchError(WebSearchError):
"""Error when fetching or extracting documents."""
async def llm_summarize_async(query: str, text: str) -> str:
"""
Summarize the text using the LLM summarization agent.
@@ -33,7 +51,7 @@ def llm_summarize(query: str, text: str) -> str:
prompt = f"""
Based on the following request, summarize the following text in a concise manner,
focusing on the key points regarding the user request.
he result should be up to 30 lines long.
The result should be up to 30 lines long.
<user request>
{query}
@@ -44,54 +62,87 @@ he result should be up to 30 lines long.
</text to summarize>
"""
result = summarization_agent.run_sync(prompt)
result = await summarization_agent.run(prompt)
return result.output
def _fetch_and_extract(url: str) -> str:
"""Fetch and extract text content from the URL."""
cache_key = f"web_search_brave:extract:{url}"
async def _fetch_url_async(url: str, timeout: int = 30) -> str:
"""Fetch URL content asynchronously."""
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
return response.text
if (document := cache.get(cache_key)) is not None:
async def _fetch_and_extract_async(url: str) -> str:
"""Fetch and extract text content from the URL asynchronously."""
cache_key = f"web_search_brave:extract:{slugify(url)}"
# Check cache first
if (document := await cache.aget(cache_key)) is not None:
return document
html = fetch_url(url)
document = extract(html, include_comments=False, no_fallback=True) or ""
cache.set(cache_key, document, settings.BRAVE_CACHE_TTL)
try:
# Fetch HTML
html = await _fetch_url_async(url, timeout=settings.BRAVE_API_TIMEOUT)
return document
# Extract text in thread pool (trafilatura is CPU-bound)
document = await sync_to_async(extract)(html, include_comments=False, no_fallback=True)
# Cache the result
await cache.aset(cache_key, document, settings.BRAVE_CACHE_TTL)
return document
except httpx.HTTPError as e:
logger.warning("HTTP error fetching %s: %s", url, e, exc_info=True)
raise DocumentFetchError(f"Failed to fetch {url}: {e}") from e
except Exception as e:
logger.warning("Error extracting content from %s: %s", url, e, exc_info=True)
raise DocumentFetchError(f"Failed to extract content from {url}: {e}") from e
def _extract_and_summarize_snippets(query: str, url: str) -> List[str]:
async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[str]:
"""Fetch, extract and summarize text content from the URL.
Returns a list of snippets (0 or 1 element, preserving existing behavior).
"""
# Cache by URL to avoid repeated fetch/extract across calls
document = _fetch_and_extract(url)
if not document:
try:
document = await _fetch_and_extract_async(url)
if not document:
return []
if not settings.BRAVE_SUMMARIZATION_ENABLED:
return [document]
try:
snippet = await llm_summarize_async(query, document)
return [snippet] if snippet else []
except Exception as e: # pylint: disable=broad-except
logger.exception("Summarization failed for %s: %s", url, e)
# Fallback to raw document if summarization fails
return [document]
except DocumentFetchError:
# Document fetch failed, return empty
return []
if not settings.BRAVE_SUMMARIZATION_ENABLED:
return [document]
async def _fetch_and_store_async(url: str, document_store) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
try:
snippet = llm_summarize(query, document)
except Exception as e: # pylint: disable=broad-except
logger.exception("Summarization failed for %s: %s", url, e)
snippet = None
document = await _fetch_and_extract_async(url)
return [snippet] if snippet else []
logger.debug("Fetched document: %s", document)
if document:
await document_store.astore_document(url, document)
except DocumentFetchError as e:
logger.warning("Failed to fetch and store %s: %s", url, e)
# Continue with other documents
def _fetch_and_store(url: str, document_store) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
document = _fetch_and_extract(url)
if document:
document_store.store_document(url, document)
def _query_brave_api(query: str) -> List[dict]:
async def _query_brave_api_async(query: str) -> List[dict]:
"""Query the Brave Search API and return the raw results."""
url = "https://api.search.brave.com/res/v1/web/search"
headers = {
@@ -109,14 +160,53 @@ def _query_brave_api(query: str) -> List[dict]:
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
}
params = {k: v for k, v in data.items() if v is not None}
response = requests.get(url, headers=headers, params=params, timeout=settings.BRAVE_API_TIMEOUT)
response.raise_for_status()
json_response = response.json()
try:
async with httpx.AsyncClient(timeout=settings.BRAVE_API_TIMEOUT) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
# See https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
# & https://api-dashboard.search.brave.com/app/documentation/web-search/responses#SearchResult
return json_response.get("web", {}).get("results", [])
# https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
return json_response.get("web", {}).get("results", [])
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
# Rate limit - retryable
logger.warning("Brave API rate limited: %s", e)
raise ModelRetry(
"The search API is rate limited. Please wait a moment and try again."
) from e
if e.response.status_code >= 500:
# Server error - retryable
logger.warning("Brave API error: %s", e)
raise ModelRetry(
"The search service is temporarily unavailable due to a server error. Retrying..."
) from e
# Client error (4xx) - not retryable, stop and inform user
logger.error("Brave API client error: %s", e)
raise ModelCannotRetry(
f"Web search failed with a client error (status {e.response.status_code}). "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
except httpx.TimeoutException as e:
# Timeout - retryable
logger.warning("Brave API timeout: %s", e)
raise ModelRetry("The search request timed out. Retrying with a fresh attempt...") from e
except httpx.HTTPError as e:
# Other HTTP errors - retryable
logger.warning("Brave API connection error: %s", e)
raise ModelRetry(
f"Connection error while searching the web: {type(e).__name__}. Retrying..."
) from e
except Exception as e:
# Unexpected errors - not retryable, stop completely
logger.exception("Unexpected error querying Brave API: %s", e)
raise ModelCannotRetry(
f"An unexpected error occurred with the search service: {type(e).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
@@ -140,89 +230,132 @@ def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
)
def web_search_brave(query: str) -> ToolReturn:
@last_model_retry_soft_fail
async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Args:
_ctx (RunContext): The run context, used by the wrapper.
query (str): The query to search for.
"""
raw_search_results = _query_brave_api(query)
try:
raw_search_results = await _query_brave_api_async(query)
reset_caches() # Clear trafilatura caches to avoid memory bloat/leaks
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
# Parallelize fetch/extract for results that don't include extra_snippets
to_process = [
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
]
# Parallelize fetch/extract for results that don't include extra_snippets
to_process = [
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
]
if to_process:
max_workers = min(settings.BRAVE_MAX_WORKERS, len(to_process))
if max_workers == 1:
# Avoid overhead of ThreadPoolExecutor if only one task
for idx, r in to_process:
raw_search_results[idx]["extra_snippets"] = _extract_and_summarize_snippets(
query, r["url"]
)
if to_process:
# Process all URLs concurrently
tasks = [
_extract_and_summarize_snippets_async(query, r["url"]) for idx, r in to_process
]
results = await asyncio.gather(*tasks, return_exceptions=False)
else:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(_extract_and_summarize_snippets, query, r["url"]): idx
for idx, r in to_process
}
for future in as_completed(future_map):
idx = future_map[future]
raw_search_results[idx]["extra_snippets"] = future.result()
# Update raw_search_results with extracted snippets
for (idx, _), snippets in zip(to_process, results, strict=True):
raw_search_results[idx]["extra_snippets"] = snippets
return format_tool_return(raw_search_results)
formatted_result = format_tool_return(raw_search_results)
# Check if we got any valid results
if not formatted_result.return_value:
raise ModelRetry(
"No valid search results were extracted from the web pages. "
"Retrying the search to find better sources..."
)
return formatted_result
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as exc:
# Unexpected error in our code - stop and inform user
logger.exception("Unexpected error in web_search_brave: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during web search: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
@last_model_retry_soft_fail
async def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Search the web for up-to-date information using RAG backend
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search for.
"""
raw_search_results = _query_brave_api(query)
logger.info("Starting web search with RAG backend for query: %s", query)
try:
raw_search_results = await _query_brave_api_async(query)
reset_caches() # Clear trafilatura caches to avoid memory bloat/leaks
# Clear trafilatura caches in thread pool to avoid blocking
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, reset_caches)
# Store documents in a temporary document store for RAG search
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
with document_store_backend.temporary_collection(f"tmp-{uuid.uuid4()}") as document_store:
max_workers = min(settings.BRAVE_MAX_WORKERS, len(raw_search_results))
if max_workers == 1:
for result in raw_search_results:
# Fetch and extract document content
_fetch_and_store(result["url"], document_store)
else:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(_fetch_and_store, result["url"], document_store)
# Store documents in a temporary document store for RAG search
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
# Create temporary collection
temp_collection_name = f"tmp-{uuid.uuid4()}"
try:
async with document_store_backend.temporary_collection_async(
temp_collection_name
) as document_store:
# Fetch and store all documents concurrently
tasks = [
_fetch_and_store_async(result["url"], document_store)
for result in raw_search_results
]
for future in as_completed(futures):
try:
future.result()
except Exception as e: # pylint: disable=broad-except
logger.exception("Error fetching/storing document: %s", e)
await asyncio.gather(*tasks, return_exceptions=True)
rag_results = document_store.search(query)
# Perform RAG search
rag_results = await document_store.asearch(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
)
logger.info("RAG search returned: %s", rag_results)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
# Map RAG results back to raw search results to include extra_snippets
# Suboptimal O(N^2) but N is small...
for rag_result in rag_results.data:
for result in raw_search_results:
if result["url"] == rag_result.url:
result.setdefault("extra_snippets", []).append(rag_result.content)
break
# Map RAG results back to raw search results to include extra_snippets
for rag_result in rag_results.data:
for result in raw_search_results:
if result["url"] == rag_result.url:
result.setdefault("extra_snippets", []).append(rag_result.content)
break
return format_tool_return(raw_search_results)
except Exception as exc:
logger.exception("Error with document store: %s", exc)
raise ModelRetry(
f"Document storage temporarily failed: {type(exc).__name__}. "
"Retrying the operation..."
) from exc
formatted_result = format_tool_return(raw_search_results)
# Check if we got any valid results
if not formatted_result.return_value:
raise ModelRetry("No valid search results were extracted.")
return formatted_result
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as e:
# Unexpected error - stop and inform user
logger.exception("Unexpected error in web_search_brave_with_document_backend: %s", e)
raise ModelCannotRetry(
f"An unexpected error occurred during web search with RAG: {type(e).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
+28 -5
View File
@@ -1,6 +1,7 @@
"""Chat API implementation."""
import logging
import os
from uuid import uuid4
from django.conf import settings
@@ -178,10 +179,32 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
or self.request.LANGUAGE_CODE # from the LocaleMiddleware
),
)
if protocol == "data":
streaming_content = ai_service.stream_data(messages, force_web_search=force_web_search)
else: # Default to 'text' protocol
streaming_content = ai_service.stream_text(messages, force_web_search=force_web_search)
# This environment variable allows switching between sync and async streaming modes
# based on the server configuration. Tests run in sync mode (WSGI), while
# production uses async mode (Uvicorn ASGI).
is_async_mode = os.environ.get("PYTHON_SERVER_MODE", "sync") == "async"
if is_async_mode:
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
)
else: # Default to 'text' protocol
streaming_content = ai_service.stream_text_async(
messages, force_web_search=force_web_search
)
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
)
else: # Default to 'text' protocol
streaming_content = ai_service.stream_text(
messages, force_web_search=force_web_search
)
response = StreamingHttpResponse(
streaming_content,
@@ -402,7 +425,7 @@ class ChatConversationAttachmentViewSet(
if settings.POSTHOG_KEY:
posthog.capture(
"item_uploaded",
distinct_id=request.user.email,
distinct_id=str(request.user.pk), # same as set by the frontend
properties={
"id": attachment.pk,
"file_name": attachment.file_name,
+15
View File
@@ -1,5 +1,6 @@
"""Global fixtures for the backend tests."""
import posthog
import pytest
from rest_framework.test import APIClient
from urllib3.connectionpool import HTTPConnectionPool
@@ -41,3 +42,17 @@ def feature_flags_fixture(settings):
"""
settings.FEATURE_FLAGS = settings.FEATURE_FLAGS.model_copy(deep=True)
yield settings.FEATURE_FLAGS
@pytest.fixture(name="posthog", scope="function")
def posthog_fixture(settings):
"""Mock PostHog in tests to avoid real network calls."""
settings.POSTHOG_KEY = {"id": "132456", "host": "https://eu.i.posthog-test.com"}
posthog.api_key = settings.POSTHOG_KEY["id"]
posthog.host = settings.POSTHOG_KEY["host"]
yield posthog
posthog.api_key = None
posthog.host = None
+18
View File
@@ -0,0 +1,18 @@
"""
ASGI config for conversations project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
from configurations.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conversations.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "async")
application = get_asgi_application()
@@ -23,6 +23,13 @@ class BraveSettings:
environ_prefix=None,
)
# For web_search_brave_with_document_backend: number of chunks to retrieve RAG search
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = values.IntegerValue(
default=10,
environ_name="BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER",
environ_prefix=None,
)
# For optimal performance, BRAVE_MAX_WORKERS should be equal to BRAVE_MAX_RESULTS
# also considering the number of concurrent requests your server can handle.
BRAVE_MAX_WORKERS = values.IntegerValue(
@@ -0,0 +1,43 @@
{
"models": [
{
"hrid": "default-model",
"model_name": "mistral-mock",
"human_readable_name": "Default Model",
"provider_name": "default-provider",
"profile": null,
"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": "You are a helpful AI assistant.",
"tools": []
},
{
"hrid": "default-summarization-model",
"model_name": "mistral-mock",
"human_readable_name": "Default Summarization Model",
"provider_name": "default-provider",
"profile": null,
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a helpful AI assistant specialized in summarization.",
"tools": []
}
],
"providers": [
{
"hrid": "default-provider",
"base_url": "http://host.docker.internal:8900",
"api_key": "openmockllm-api-key",
"kind": "mistral"
}
]
}
+50 -6
View File
@@ -267,7 +267,7 @@ class Base(BraveSettings, Configuration):
("en-us", "English"),
("fr-fr", "Français"),
# ("de-de", "Deutsch"),
# ("nl-nl", "Nederlands"),
("nl-nl", "Nederlands"),
# ("es-es", "Español"),
)
)
@@ -313,7 +313,6 @@ class Base(BraveSettings, Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"posthog.integrations.django.PosthogContextMiddleware",
"core.middleware.PostHogMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
@@ -483,7 +482,11 @@ class Base(BraveSettings, Configuration):
THUMBNAIL_ALIASES = {}
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_ENGINE = values.Value(
"django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
@@ -503,6 +506,7 @@ class Base(BraveSettings, Configuration):
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_OP_JWKS_ENDPOINT = values.Value(environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None)
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
@@ -602,6 +606,22 @@ class Base(BraveSettings, Configuration):
default=False, environ_name="ACTIVATION_REQUIRED", environ_prefix=None
)
BREVO_API_KEY = values.Value(
default=None,
environ_name="BREVO_API_KEY",
environ_prefix=None,
)
BREVO_FOLLOWUP_LIST_ID = values.Value(
default=None,
environ_name="BREVO_FOLLOWUP_LIST_ID",
environ_prefix=None,
)
BREVO_WAITING_LIST_ID = values.Value(
default=None,
environ_name="BREVO_WAITING_LIST_ID",
environ_prefix=None,
)
# AI service
_llm_configuration_file_path = values.Value(
os.path.join(BASE_DIR, "conversations/configuration/llm/default.json"),
@@ -611,9 +631,6 @@ class Base(BraveSettings, Configuration):
LLM_DEFAULT_MODEL_HRID = values.Value(
"default-model", environ_name="LLM_DEFAULT_MODEL_HRID", environ_prefix=None
)
LLM_ROUTING_MODEL_HRID = values.Value(
"default-routing-model", environ_name="LLM_ROUTING_MODEL_HRID", environ_prefix=None
)
LLM_SUMMARIZATION_MODEL_HRID = values.Value(
"default-summarization-model",
environ_name="LLM_SUMMARIZATION_MODEL_HRID",
@@ -700,6 +717,11 @@ class Base(BraveSettings, Configuration):
environ_name="RAG_DOCUMENT_SEARCH_BACKEND",
environ_prefix=None,
)
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = values.DictValue(
default={},
environ_name="SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS",
environ_prefix=None,
)
# Web search
RAG_WEB_SEARCH_PROMPT_UPDATE = values.Value(
@@ -769,6 +791,21 @@ USER QUESTION:
environ_name="SUMMARIZATION_SYSTEM_PROMPT",
environ_prefix=None,
)
SUMMARIZATION_CHUNK_SIZE = values.PositiveIntegerValue(
default=20_000, # Approx 20k words per chunk
environ_name="SUMMARIZATION_CHUNK_SIZE",
environ_prefix=None,
)
SUMMARIZATION_OVERLAP_SIZE = values.FloatValue(
default=0.05, # 5% overlap
environ_name="SUMMARIZATION_OVERLAP_SIZE",
environ_prefix=None,
)
SUMMARIZATION_CONCURRENT_REQUESTS = values.PositiveIntegerValue(
default=5,
environ_name="SUMMARIZATION_CONCURRENT_REQUESTS",
environ_prefix=None,
)
# Tavily API
TAVILY_API_KEY = values.Value(
@@ -875,6 +912,13 @@ USER QUESTION:
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
)
# WARNING: Testing purpose only. Do not use in production.
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
default=False,
environ_name="WARNING_MOCK_CONVERSATION_AGENT",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+1
View File
@@ -13,5 +13,6 @@ from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conversations.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "sync")
application = get_wsgi_application()
+2 -1
View File
@@ -17,8 +17,9 @@ class UserSerializer(serializers.ModelSerializer):
"full_name",
"short_name",
"language",
"sub",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name", "sub"]
class UserLightSerializer(UserSerializer):
+10 -14
View File
@@ -1,7 +1,6 @@
"""Authentication Backends for the Conversations core app."""
import logging
import os
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
@@ -10,23 +9,11 @@ from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.brevo import add_user_to_brevo_list
from core.models import DuplicateEmailError
logger = logging.getLogger(__name__)
# Settings renamed warnings
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
logger.warning(
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed "
"to OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
)
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
logger.warning(
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed "
"to OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -70,3 +57,12 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
return super().create_user(
claims | {"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS}
)
def authenticate(self, request, **kwargs):
"""Authenticate user and add they to Brevo list if activation not required."""
user = super().authenticate(request, **kwargs)
if user and not settings.ACTIVATION_REQUIRED and settings.BREVO_FOLLOWUP_LIST_ID:
add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
return user
+135
View File
@@ -0,0 +1,135 @@
"""Functions for interacting with Brevo API to manage contacts in a waiting list."""
import logging
from typing import List, Optional
from django.conf import settings
import requests
logger = logging.getLogger(__name__)
def create_contact_in_brevo(email: str) -> bool:
"""
Create a contact in Brevo.
Args:
email (str): The email address of the user.
"""
api_key = settings.BREVO_API_KEY
if not api_key:
logger.info("Brevo API key not configured: skipping creating contact")
return False
url = "https://api.brevo.com/v3/contacts"
headers = {
"accept": "application/json",
"api-key": api_key,
"content-type": "application/json",
}
payload = {
"email": email,
"updateEnabled": True,
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=5)
except requests.RequestException as e:
logger.exception(e)
return False
if not response.ok:
logger.error(
"Error creating contact in Brevo %s: (%s) %s",
email,
response.status_code,
response.text,
)
return False
return True
def add_user_to_brevo_list(emails: List[str], list_id: Optional[str]) -> None:
"""
Add email list to a Brevo list.
Args:
emails (List[str]): The email address(es) of the user(s).
list_id (str): The Brevo waiting list ID, can be None if not configured.
"""
api_key = settings.BREVO_API_KEY
if not api_key or not list_id:
logger.info("Brevo API key or list ID not configured: skipping adding contact")
return
for email in emails:
# Ensure the contact exists before adding to the list
# `emails` contains several emails only when used from the admin interface bulk action
if not create_contact_in_brevo(email):
logger.error("Failed to create contact %s in Brevo, skipping adding to list", email)
return
url = f"https://api.brevo.com/v3/contacts/lists/{list_id}/contacts/add"
headers = {
"accept": "application/json",
"api-key": api_key,
"content-type": "application/json",
}
payload = {
"emails": emails,
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=5)
except requests.RequestException as e:
logger.exception(e)
return
if response.status_code != 201:
logger.error(
"Error adding contacts to Brevo (%s) %s: (%s) %s",
list_id,
emails,
response.status_code,
response.text,
)
def remove_user_from_brevo_list(emails: List[str], list_id: Optional[str]) -> None:
"""
Remove email list from a Brevo list.
Args:
emails (List[str]): The email address(es) of the user(s).
list_id (str): The Brevo waiting list ID, can be None if not configured.
"""
api_key = settings.BREVO_API_KEY
if not api_key or not list_id:
logger.info("Brevo API key or list ID not configured: skipping removing contact")
return
url = f"https://api.brevo.com/v3/contacts/lists/{list_id}/contacts/remove"
headers = {
"accept": "application/json",
"api-key": api_key,
"content-type": "application/json",
}
payload = {
"emails": emails,
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=5)
except requests.RequestException as e:
logger.exception(e)
return
if response.status_code != 201:
logger.error(
"Error removing contacts from Brevo (%s) %s: (%s) %s",
list_id,
emails,
response.status_code,
response.text,
)
+7
View File
@@ -2,6 +2,7 @@
from enum import StrEnum
from django.conf import settings
from django.utils.text import slugify
from pydantic import BaseModel, ConfigDict
@@ -43,3 +44,9 @@ class FeatureFlags(BaseModel):
# features
web_search: FeatureToggle = FeatureToggle.DISABLED
document_upload: FeatureToggle = FeatureToggle.DISABLED
def __getattr__(self, name: str):
"""Dynamically get specific RAG document search tool feature flags from settings."""
if config := settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS.get(name):
return FeatureToggle[config.get("feature_flag_value", "DISABLED").upper()]
return super().__getattr__(name)
+1 -3
View File
@@ -9,7 +9,6 @@ User = get_user_model()
try:
import posthog
from posthog.contexts import get_tags
except ImportError:
posthog = None
@@ -39,8 +38,7 @@ def is_feature_enabled(
if posthog is not None:
return posthog.feature_enabled(
frontend_feature_name(feature_name),
user.email,
person_properties={"$host": get_tags().get("$host")},
str(user.pk), # same as set by the frontend
)
# No feature flag manager
-58
View File
@@ -1,58 +0,0 @@
"""Custom middleware(s) for the project."""
import json
import logging
from urllib.parse import unquote
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
# We are importing posthog here, but it will only be used if the POSTHOG_KEY is set in settings.
# The settings are checked before any attempt to use posthog.
try:
import posthog
except ImportError:
posthog = None
logger = logging.getLogger(__name__)
class PostHogMiddleware:
"""
This middleware is used to alias the user's distinct_id from the PostHog cookie
with their email address when they are authenticated. This allows us to track
users across different sessions and devices.
"""
def __init__(self, get_response):
"""
Initialize the middleware and disable it if PostHog is not configured.
"""
if posthog is None or not settings.POSTHOG_KEY:
raise MiddlewareNotUsed("POSTHOG_KEY must be set in settings to use PostHogMiddleware.")
self.get_response = get_response
def __call__(self, request):
"""
Process the request to handle the PostHog alias.
"""
if posthog is not None and settings.POSTHOG_KEY:
posthog_cookie = request.COOKIES.get(f"ph_{posthog.project_api_key}_posthog")
if posthog_cookie:
try:
cookie_dict = json.loads(unquote(posthog_cookie))
if (
cookie_dict.get("distinct_id")
and request.user
and request.user.is_authenticated
):
posthog.alias(cookie_dict["distinct_id"], request.user.email)
except (json.JSONDecodeError, KeyError):
# If the cookie is malformed or doesn't contain the expected
# keys, we can't do anything with it, so we ignore it.
logger.warning("Malformed PostHog cookie: %s", posthog_cookie)
response = self.get_response(request)
return response
+1 -1
View File
@@ -118,7 +118,7 @@ class Migration(migrations.Migration):
("en-us", "English"),
("fr-fr", "Français"),
# ("de-de", "Deutsch"),
# ("nl-nl", "Nederlands"),
("nl-nl", "Nederlands"),
# ("es-es", "Español"),
],
default=None,
@@ -1,5 +1,6 @@
"""Unit tests for the Authentication Backends."""
import json
import random
import re
@@ -57,7 +58,7 @@ def test_authentication_getter_existing_user_via_email(django_assert_num_queries
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by sub, user by mail, update sub
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert user == db_user
@@ -204,7 +205,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(3): # user by sub, unicity check, update sub
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -244,7 +245,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4): # user by sub, user by mail, unicity check, update sub
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -491,3 +492,82 @@ def test_authentication_session_tokens(django_assert_num_queries, monkeypatch, r
assert user is not None
assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
@responses.activate
def test_authentication_user_added_to_brevo(monkeypatch, rf, settings):
"""
Test that a user is added to the Brevo follow-up list upon authentication.
"""
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_FOLLOWUP_LIST_ID = "follow-up-list-id"
settings.ACTIVATION_REQUIRED = False
brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_add_to_list = responses.post(
"https://api.brevo.com/v3/contacts/lists/follow-up-list-id/contacts/add",
status=400,
)
klass = OIDCAuthenticationBackend()
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
request.session = {}
def verify_token_mocked(*args, **kwargs):
return {"sub": "123", "email": "test@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
responses.add(
responses.POST,
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
json={
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
},
status=200,
)
responses.add(
responses.GET,
re.compile(settings.OIDC_OP_USER_ENDPOINT),
json={"sub": "123", "email": "test@example.com"},
status=200,
)
user = klass.authenticate(
request,
code="test-code",
nonce="test-nonce",
code_verifier="test-code-verifier",
)
assert len(brevo_create_contact.calls) == 1
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test-api-key"
assert json.loads(brevo_create_contact.calls[0].request.body) == {
"email": user.email,
"updateEnabled": True,
}
assert len(brevo_add_to_list.calls) == 1
assert brevo_add_to_list.calls[0].request.headers["api-key"] == "test-api-key"
assert json.loads(brevo_add_to_list.calls[0].request.body) == {"emails": [user.email]}
# Now test when activation is required: user should not be added to Brevo list
settings.ACTIVATION_REQUIRED = True
klass.authenticate(
request,
code="test-code",
nonce="test-nonce",
code_verifier="test-code-verifier",
)
assert len(brevo_create_contact.calls) == 1 # No new call made
assert len(brevo_add_to_list.calls) == 1 # No new call made
@@ -1,9 +1,12 @@
"""Tests for feature flag helpers."""
import json
import logging
from unittest.mock import patch
import posthog
import pytest
import responses
from core.factories import UserFactory
from core.feature_flags.flags import FeatureToggle
@@ -42,19 +45,29 @@ def test_is_feature_enabled_always_disabled(feature_flags):
assert is_feature_enabled(user, "document_upload") is False
@patch("core.feature_flags.helpers.posthog")
def test_is_feature_enabled_dynamic_posthog_true(mock_posthog, feature_flags):
@responses.activate
def test_is_feature_enabled_dynamic_posthog_true(feature_flags, settings):
"""Test that a dynamic feature returns the value from PostHog when PostHog is available."""
settings.POSTHOG_KEY = {"id": "132456", "host": "https://eu.i.posthog-test.com"}
posthog.api_key = settings.POSTHOG_KEY["id"]
posthog.host = settings.POSTHOG_KEY["host"]
responses.post(
f"{posthog.host}/flags/?v=2", json={"flags": {"web-search": {"enabled": True}}}, status=200
)
feature_flags.web_search = FeatureToggle.DYNAMIC
user = UserFactory()
mock_posthog.feature_enabled.return_value = True
assert is_feature_enabled(user, "web_search") is True
mock_posthog.feature_enabled.assert_called_once_with(
"web-search",
user.email,
person_properties={"$host": None},
)
request_body = json.loads(responses.calls[0].request.body)
assert request_body["distinct_id"] == str(user.pk)
assert request_body["flag_keys_to_evaluate"] == ["web-search"]
posthog.api_key = None
posthog.host = None
@patch("core.feature_flags.helpers.posthog")
+52 -2
View File
@@ -4,9 +4,10 @@ Test config API endpoints in the Conversations core app.
import json
from django.test import override_settings
from django.test import AsyncClient, override_settings
import pytest
from asgiref.sync import sync_to_async
from rest_framework.status import (
HTTP_200_OK,
)
@@ -53,7 +54,7 @@ def test_api_config(is_authenticated):
["en-us", "English"],
["fr-fr", "Français"],
# ["de-de", "Deutsch"],
# ["nl-nl", "Nederlands"],
["nl-nl", "Nederlands"],
# ["es-es", "Español"],
],
"LANGUAGE_CODE": "en-us",
@@ -156,3 +157,52 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization
@override_settings(
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
THEME_CUSTOMIZATION_FILE_PATH="",
RAG_FILES_ACCEPTED_FORMATS=[
"application/pdf",
"text/plain",
],
)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_authenticated", [False, True])
async def test_api_config_async(is_authenticated):
"""Anonymous users should be allowed to get the configuration (async client)."""
client = AsyncClient()
if is_authenticated:
user = await sync_to_async(factories.UserFactory)()
await client.aforce_login(user)
response = await client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"ACTIVATION_REQUIRED": False,
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
# ["de-de", "Deutsch"],
["nl-nl", "Nederlands"],
# ["es-es", "Español"],
],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"theme_customization": {},
"chat_upload_accept": "application/pdf,text/plain",
}
+1
View File
@@ -229,6 +229,7 @@ def test_api_users_retrieve_me_authenticated():
"full_name": user.full_name,
"language": user.language,
"short_name": user.short_name,
"sub": user.sub,
}
+118 -89
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,298 +17,328 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr ""
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr ""
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr ""
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr ""
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr ""
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr ""
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr ""
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr ""
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr ""
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr ""
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr ""
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr ""
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr ""
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr ""
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr ""
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr ""
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr ""
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr ""
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr ""
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr ""
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr ""
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr ""
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr ""
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr ""
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr ""
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr ""
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr ""
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr ""
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr ""
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr ""
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr ""
@@ -324,9 +354,7 @@ msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
@@ -334,3 +362,4 @@ msgstr ""
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+117 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,298 +17,328 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr ""
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr ""
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr ""
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr ""
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr ""
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr ""
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr ""
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr ""
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr ""
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr ""
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr ""
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr ""
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr ""
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr ""
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr ""
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr ""
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr ""
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr ""
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr ""
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr ""
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr ""
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr ""
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr ""
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr ""
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr ""
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr ""
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr ""
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr ""
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr ""
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr ""
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr ""
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr ""
@@ -324,9 +354,7 @@ msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
@@ -334,3 +362,4 @@ msgstr ""
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+127 -116
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,314 +17,328 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr "Configuration"
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr "Détails d'utilisation"
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Horodatages"
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr "Utilisation"
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr "Description"
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Aucun utilisateur n'a encore utilisé ce code"
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr "Nom"
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "E-mail"
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr "Date"
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Utilisateurs qui ont utilisé ce code"
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Recalculer les utilisations actuelles à partir des activations liées"
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
"Tous les codes d'activation sélectionnés ont déjà un nombre correct "
"d'utilisations."
msgstr "Tous les codes d'activation sélectionnés ont déjà un nombre correct d'utilisations."
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
"Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
msgstr "Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Utilisateur"
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "A utilisé le code d'activation"
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Ajouter les utilisateurs sélectionnés à la liste d'attente Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "%(count)d utilisateur(s) ajoutés à la liste d'attente Brevo."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Aucune adresse e-mail valide trouvée dans les inscriptions sélectionnées."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Supprimer les utilisateurs sélectionnés de la liste d'attente Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Suppression de %(count)d utilisateur(s) de la liste d'attente Brevo."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr "code d'activation"
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "Le code d'activation que les utilisateurs entreront"
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
"Le code doit être alphanumérique et ne contenir ni espaces ni caractères "
"spéciaux"
msgstr "Le code doit être alphanumérique et ne contenir ni espaces ni caractères spéciaux"
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr "utilisations maximales"
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr "Nombre maximum d'utilisation de ce code. 0 signifie illimité."
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr "utilisations actuelles"
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Nombre de fois où ce code a été utilisé"
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr "actif"
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Si ce code peut encore être utilisé"
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr "expiration"
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Date et heure d'expiration de ce code"
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr "description"
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Description interne ou notes à propos de ce code"
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr "codes d'activation"
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Ce code d'activation n'est plus valide"
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Vous avez déjà activé votre compte"
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr "utilisateur"
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "L'utilisateur qui a utilisé le code d'activation"
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "Le code d'activation qui a été utilisé"
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "activation d'utilisateur"
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr "activations d'utilisateurs"
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "L'utilisateur qui a fait la demande d'enregistrement"
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
"Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
msgstr "Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr "demande d'inscription d'utilisateur"
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr "demandes d'inscription d'utilisateur"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "Le code d'activation à valider"
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Votre compte a été activé avec succès"
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr "application de chat"
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr "Dates importantes"
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr "id"
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr "créé le"
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
"Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail "
"est déjà associé à un utilisateur enregistré."
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des "
"lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Saisissez un 'sub' valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr "sub"
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_/: uniquement."
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr "nom complet"
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr "nom court"
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr "langue"
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr "appareil"
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr "statut d'équipe"
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu "
"de supprimer des comptes."
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr "autoriser les analyses de conversation"
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
"Si l'utilisateur autorise l'utilisation de ses conversations pour des "
"analyses."
msgstr "Si l'utilisateur autorise l'utilisation de ses conversations pour des analyses."
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr "utilisateurs"
@@ -340,15 +354,12 @@ msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgstr ""
" Docs, votre nouvel outil incontournable pour organiser, partager et "
"collaborer sur vos documents en équipe. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
+192 -163
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,320 +17,349 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
msgstr "Configuratie"
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
msgstr "Gebruiksdetails"
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
msgstr "Tijdstempels"
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr ""
msgstr "Gebruik"
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr ""
msgstr "Beschrijving"
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
msgstr "Er zijn nog geen gebruikers die deze code hebben gebruikt"
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr ""
msgstr "Naam"
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
msgstr "E-mail"
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr ""
msgstr "Datum"
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
msgstr "Gebruikers die deze code hebben gebruikt"
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
msgstr "Herbereken het huidige gebruik van gerelateerde activeringen"
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
msgstr "Alle geselecteerde activeringscodes hebben al het juiste gebruiksaantal."
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
msgstr "Het gebruik van %(count)d activeringscode(s) is opnieuw berekend."
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
msgstr "Gebruiker"
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
msgstr "Heeft activeringscode gebruikt"
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Voeg geselecteerde gebruikers toe aan de Brevo-wachtlijst"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "%(count)d gebruiker(s) toegevoegd aan de Brevo-wachtlijst."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Er is geen geldig e-mailadres gevonden in de geselecteerde registraties."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Geselecteerde gebruikers van de Brevo-wachtlijst verwijderen"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "%(count)d gebruiker(s) verwijderd van de Brevo-wachtlijst."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr ""
msgstr "activeringscode"
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
msgstr "De activeringscode die gebruikers invoeren"
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
msgstr "De code moet alfanumeriek zijn en mag geen spaties of speciale tekens bevatten"
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
msgstr "maximaal gebruik"
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
msgstr "Maximaal aantal keren dat deze code kan worden gebruikt. 0 betekent onbeperkt."
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr ""
msgstr "huidig gebruik"
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
msgstr "Aantal keren dat deze code is gebruikt"
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr ""
msgstr "actief"
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
msgstr "Of deze code nog gebruikt kan worden"
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr ""
msgstr "vervalt op"
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
msgstr "Datum en tijd waarop deze code verloopt"
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr ""
msgstr "beschrijving"
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
msgstr "Interne beschrijving of notities over deze code"
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr ""
msgstr "activeringscodes"
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
msgstr "Deze activeringscode is niet meer geldig"
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
msgstr "Je hebt je account al geactiveerd"
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr ""
msgstr "gebruiker"
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
msgstr "De gebruiker die de activeringscode heeft gebruikt"
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
msgstr "De activeringscode die is gebruikt"
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
msgstr "gebruikers activering"
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr ""
msgstr "gebruikersactivaties"
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
msgstr "De gebruiker die het registratieverzoek heeft gedaan"
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
msgstr "Opslaan of de gebruiker een activeringscode heeft ontvangen en deze heeft gebruikt"
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr ""
msgstr "gebruikersregistratieverzoek"
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
msgstr "gebruikersregistratieverzoeken"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
msgstr "De activeringscode om te valideren"
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
msgstr "Uw account is succesvol geactiveerd"
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr ""
msgstr "chatapplicatie"
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Persoonlijke gegevens"
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr ""
msgstr "Machtigingen"
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr ""
msgstr "Belangrijke data"
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr ""
msgstr "gemaakt op"
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
msgstr "datum en tijd waarop een record is aangemaakt"
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr ""
msgstr "bijgewerkt op"
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "We konden geen gebruiker met dit e-mailadres vinden, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Voer een geldig subsubtype in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/:-tekens bevatten."
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr ""
msgstr "id"
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Verplicht. Maximaal 255 tekens. Alleen letters, cijfers en @/./+/-/_/: tekens."
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr ""
msgstr "volledige naam"
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr ""
msgstr "korte naam"
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr ""
msgstr "identiteits e-mailadres"
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr ""
msgstr "beheerders e-mailadres"
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr ""
msgstr "taal"
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr ""
msgstr "apparaat"
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr ""
msgstr "personeelsstatus"
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of deze gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van accounts te verwijderen."
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr ""
msgstr "conversatieanalyse toestaan"
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
msgstr "Of de gebruiker toestaat dat zijn/haar gesprekken voor analyses worden gebruikt."
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr ""
msgstr "gebruikers"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgstr ""
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, uw nieuwe onmisbare hulpmiddel voor het organiseren, delen en samenwerken aan uw documenten als team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
msgstr " Aangeboden door %(brandname)s "
+132 -120
View File
@@ -2,328 +2,343 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 "
"&& n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 "
"&& n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: la-suite-conversations\n"
"X-Crowdin-Project-ID: 810876\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr "Настройки"
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr "Сведения об использовании"
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Временные метки"
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr "Использование"
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr "Описание"
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Пока нет пользователей с этим кодом"
msgstr "Пока нет пользователей, использовавших этот код"
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr "Имя"
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "Эл. почта"
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr "Дата"
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Пользователи, использующие этот код"
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Обновить данные использования связанных активаций"
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
"Все выбранные коды активации уже имеют правильное количество использований."
msgstr "Все выбранные коды активации уже имеют правильное количество использований."
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
"Количество использованных кодов активации (%(count)d) успешно пересчитано."
msgstr "Количество использованных кодов активации (%(count)d) успешно пересчитано."
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Пользователь"
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "Использует код активации"
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Добавить выбранных пользователей в список ожидания Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "В список ожидания Brevo добавлено пользователей: %(count)d."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "В выбранных регистрациях не найден действительный адрес электронной почты."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Удалить выбранных пользователей из списка ожидания Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Из списка ожидания Brevo удалено пользователей: %(count)d."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr "код активации"
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "Код активации, который будут вводить пользователи"
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
"Код должен быть буквенно-цифровым и не содержать пробелов или специальных "
"символов"
msgstr "Код должен быть буквенно-цифровым и не содержать пробелов или специальных символов"
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr "максимум использования"
msgstr "максимум использований"
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
"Максимальное количество раз, когда этот код может быть использован. 0 "
"означает неограниченно."
msgstr "Сколько раз можно использовать этот код. 0 означает неограниченно."
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr "текущее использование"
msgstr "использовано"
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Сколько раз этот код был использован"
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr "активный"
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Можно ли использовать этот код"
msgstr "Можно ли ещё использовать этот код"
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr "истекает в"
msgstr "действителен до"
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Дата и время окончания действия этого кода"
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr "описание"
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Внутреннее описание или примечания об этом коде"
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr "коды активации"
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Этот код активации больше не действителен"
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Вы уже активировали свою учётную запись"
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr "пользователь"
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "Пользователь, использовавший код активации"
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "Использованный код активации"
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "активация пользователя"
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr "активации пользователя"
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "Пользователь, который сделал запрос на регистрацию"
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr "Сохранить, если пользователь получил код активации и использовал его"
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr "запрос на регистрацию пользователя"
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr "запросы на регистрацию пользователя"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "Код активации для проверки"
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Ваша учётная запись успешно активирована"
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr "приложение чата"
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr "Личные данные"
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr "Разрешения"
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr "Важные даты"
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr "id"
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr "первичный ключ для записи как UUID"
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr "создано"
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr "дата и время создания записи"
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr "обновлено"
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "дата и время последнего обновления записи"
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
"Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с "
"зарегистрированным пользователем."
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с зарегистрированным пользователем."
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Введите правильный префикс. Он может содержать только буквы, цифры и символы "
"@/./+/-/_/."
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Введите правильный префикс. Он может содержать только буквы, цифры и символы @/./+/-/_/."
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr "префикс"
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr "полное имя"
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr "короткое имя"
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr "e-mail администратора"
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr "язык"
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr "устройство"
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr "статус сотрудника"
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Должен ли пользователь рассматриваться как активный. Альтернатива удалению "
"учётных записей."
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr "разрешить аналитику для беседы"
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr "Разрешает ли пользователь использовать свои беседы для аналитики."
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr "пользователи"
@@ -339,15 +354,12 @@ msgstr "Открыть"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgstr ""
" Docs, ваш новый инструмент для организации и совместного использования "
"документов в вашей команде. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Доступ получен от %(brandname)s "
+193 -166
View File
@@ -2,337 +2,364 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-19 21:49+0000\n"
"PO-Revision-Date: 2025-10-19 21:25\n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-11-17 16:37\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 "
"&& n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 "
"&& n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: la-suite-conversations\n"
"X-Crowdin-Project-ID: 810876\n"
"X-Crowdin-Language: uk\n"
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:54
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
msgstr "Налаштування"
#: activation_codes/admin.py:65
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
msgstr "Відомості про використання"
#: activation_codes/admin.py:69 activation_codes/admin.py:225
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
msgstr "Відмітки часу"
#: activation_codes/admin.py:108
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
msgid "Usage"
msgstr ""
msgstr "Використання"
#: activation_codes/admin.py:116
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
msgid "Description"
msgstr ""
msgstr "Опис"
#: activation_codes/admin.py:123
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
msgstr "Користувачі ще не використовували цей код"
#: activation_codes/admin.py:134
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
msgid "Name"
msgstr ""
msgstr "Ім’я"
#: activation_codes/admin.py:135 activation_codes/admin.py:245
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
msgstr "Ел. пошта"
#: activation_codes/admin.py:136
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
msgid "Date"
msgstr ""
msgstr "Дата"
#: activation_codes/admin.py:160
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
msgstr "Користувачі, що використовували цей код"
#: activation_codes/admin.py:162
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
msgstr "Перерахувати поточні використання пов'язаних ресурсів"
#: activation_codes/admin.py:176
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
msgstr "Усі обрані коди активації вже мають коректні лічильники використання."
#: activation_codes/admin.py:181
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
msgstr "Успішно переобчислено використання коду активації %(count)d."
#: activation_codes/admin.py:239 activation_codes/admin.py:281
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
msgstr "Користувач"
#: activation_codes/admin.py:288
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
msgstr "Використано код активації"
#: activation_codes/models.py:36 activation_codes/models.py:83
#: activation_codes/models.py:164
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Додати обраних користувачів до списку очікування Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "До списку очікування Brevo додано користувачів: %(count)d."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Серед обраних реєстрацій не знайдено дійсної адреси електронної пошти."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Видалити обраних користувачів зі списку очікування Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Зі списку очікування Brevo видалено користувачів: %(count)d"
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
msgid "activation code"
msgstr ""
msgstr "код активації"
#: activation_codes/models.py:37
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
msgstr "Код активації, що буде введений користувачами"
#: activation_codes/models.py:44
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
msgstr "Код має бути буквено-цифровим, без пробілів або спеціальних символів"
#: activation_codes/models.py:50
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
msgstr "максимум використань"
#: activation_codes/models.py:51
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
msgstr "Максимальна кількість разів використання для цього коду. 0 - необмежена."
#: activation_codes/models.py:56
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
msgid "current uses"
msgstr ""
msgstr "використано"
#: activation_codes/models.py:57
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
msgstr "Кількість разів використання цього коду"
#: activation_codes/models.py:63 core/models.py:151
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
msgid "active"
msgstr ""
msgstr "активний"
#: activation_codes/models.py:64
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
msgstr "Чи цей код все ще може бути використаний"
#: activation_codes/models.py:69
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
msgid "expires at"
msgstr ""
msgstr "дійсний до"
#: activation_codes/models.py:70
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
msgstr "Дата та час, коли закінчується дія цього коду"
#: activation_codes/models.py:76
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
msgid "description"
msgstr ""
msgstr "опис"
#: activation_codes/models.py:77
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
msgstr "Внутрішній опис або нотатки про цей код"
#: activation_codes/models.py:84
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
msgid "activation codes"
msgstr ""
msgstr "коди активації"
#: activation_codes/models.py:126
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
msgstr "Цей код активації вже не дійсний"
#: activation_codes/models.py:134
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
msgstr "Ви вже активували свій обліковий запис"
#: activation_codes/models.py:156 activation_codes/models.py:188
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
msgid "user"
msgstr ""
msgstr "користувач"
#: activation_codes/models.py:157
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
msgstr "Користувач, який користувався кодом активації"
#: activation_codes/models.py:165
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
msgstr "Використаний код активації"
#: activation_codes/models.py:172 activation_codes/models.py:196
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
msgstr "активація користувача"
#: activation_codes/models.py:173
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
msgid "user activations"
msgstr ""
msgstr "активації користувача"
#: activation_codes/models.py:189
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
msgstr "Користувач, що зробив запит на реєстрацію"
#: activation_codes/models.py:197
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
msgstr "Зберегти, якщо користувач отримав код активації та використав його"
#: activation_codes/models.py:206
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
msgid "user registration request"
msgstr ""
msgstr "запит на реєстрацію користувача"
#: activation_codes/models.py:207
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
msgstr "запити на реєстрацію користувачів"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
msgstr "Код активації для перевірки"
#: activation_codes/viewsets.py:106
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
msgstr "Ваш обліковий запис успішно активовано"
#: chat/apps.py:12
#: build/lib/chat/apps.py:12 chat/apps.py:12
msgid "chat application"
msgstr ""
msgstr "чат-застосунок"
#: core/admin.py:26
#: build/lib/core/admin.py:26 core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Особисті дані"
#: core/admin.py:40
#: build/lib/core/admin.py:40 core/admin.py:40
msgid "Permissions"
msgstr ""
msgstr "Дозволи"
#: core/admin.py:52
#: build/lib/core/admin.py:52 core/admin.py:52
msgid "Important dates"
msgstr ""
msgstr "Важливі дати"
#: core/models.py:39
#: build/lib/core/models.py:39 core/models.py:39
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:40
#: build/lib/core/models.py:40 core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
msgstr "первинний ключ для запису як UUID"
#: core/models.py:46
#: build/lib/core/models.py:46 core/models.py:46
msgid "created on"
msgstr ""
msgstr "створено"
#: core/models.py:47
#: build/lib/core/models.py:47 core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
msgstr "дата і час, коли запис було створено"
#: core/models.py:52
#: build/lib/core/models.py:52 core/models.py:52
msgid "updated on"
msgstr ""
msgstr "оновлено"
#: core/models.py:53
#: build/lib/core/models.py:53 core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "дата і час, коли запис був востаннє оновлений"
#: core/models.py:86
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: build/lib/core/models.py:86 core/models.py:86
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Ми не змогли знайти користувача з цими даними, але адреса вже пов'язана з зареєстрованим користувачем."
#: core/models.py:99
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: build/lib/core/models.py:99 core/models.py:99
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Введіть правильний префікс. Це значення може містити лише літери, цифри та символи @/./+/-/_/."
#: core/models.py:105
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
msgstr ""
msgstr "префікс"
#: core/models.py:107
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: build/lib/core/models.py:107 core/models.py:107
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Обов'язково. 255 символів або менше. Лише літери, цифри та символи @/./+/-/_/."
#: core/models.py:116
#: build/lib/core/models.py:116 core/models.py:116
msgid "full name"
msgstr ""
msgstr "повне ім'я"
#: core/models.py:117
#: build/lib/core/models.py:117 core/models.py:117
msgid "short name"
msgstr ""
msgstr "коротке ім'я"
#: core/models.py:119
#: build/lib/core/models.py:119 core/models.py:119
msgid "identity email address"
msgstr ""
msgstr "адреса електронної пошти особи"
#: core/models.py:123
#: build/lib/core/models.py:123 core/models.py:123
msgid "admin email address"
msgstr ""
msgstr "електронна адреса адміністратора"
#: core/models.py:129
#: build/lib/core/models.py:129 core/models.py:129
msgid "language"
msgstr ""
msgstr "мова"
#: core/models.py:130
#: build/lib/core/models.py:130 core/models.py:130
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: core/models.py:138
#: build/lib/core/models.py:138 core/models.py:138
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: core/models.py:141
#: build/lib/core/models.py:141 core/models.py:141
msgid "device"
msgstr ""
msgstr "пристрій"
#: core/models.py:143
#: build/lib/core/models.py:143 core/models.py:143
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: core/models.py:146
#: build/lib/core/models.py:146 core/models.py:146
msgid "staff status"
msgstr ""
msgstr "статус співробітника"
#: core/models.py:148
#: build/lib/core/models.py:148 core/models.py:148
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: core/models.py:154
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: core/models.py:161
#: build/lib/core/models.py:161 core/models.py:161
msgid "allow conversation analytics"
msgstr ""
msgstr "дозволити аналітику бесіди"
#: core/models.py:163
#: build/lib/core/models.py:163 core/models.py:163
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
msgstr "Чи дозволяє користувач використовувати свої розмови для аналітики."
#: core/models.py:174
#: build/lib/core/models.py:174 core/models.py:174
msgid "users"
msgstr ""
msgstr "користувачі"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Логотип email"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
msgstr "Відкрити"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Docs, your new essential tool for organizing, sharing and collaborating on "
"your documents as a team. "
msgstr ""
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
msgstr " Запрошення отримане від %(brandname)s "
+25 -22
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.1"
version = "0.0.9"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,42 +27,44 @@ requires-python = ">=3.12"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
"boto3==1.40.51",
"Brotli==1.1.0",
"boto3==1.40.73",
"Brotli==1.2.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==7.6.1",
"django-countries==8.1.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.14",
"django-lasuite[all]==0.0.18",
"django-parler==2.3",
"django-pydantic-field==0.3.13",
"django-pydantic-field==0.4.0",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.7",
"django==5.2.8",
"djangorestframework==3.16.1",
"drf_spectacular==0.28.0",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.6.2",
"langfuse==3.10.0",
"lxml==5.4.0",
"markdown==3.9",
"markdown==3.10",
"markitdown==0.0.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"posthog==6.7.7",
"pydantic==2.12.1",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.0.18",
"psycopg[binary]==3.2.10",
"posthog==7.0.0",
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.17.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.41.0",
"semchunk==3.2.5",
"sentry-sdk==2.44.0",
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
]
@@ -74,25 +76,26 @@ dependencies = [
[project.optional-dependencies]
dev = [
"dirty-equals==0.10.0",
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.6.0",
"pyfakefs==5.10.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint==3.3.8",
"pylint-pydantic==0.4.0",
"pytest-asyncio==1.2.0",
"pylint==3.3.9",
"pylint-pydantic==0.4.1",
"pytest-asyncio==1.3.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.2",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"respx==0.22.0",
"ruff==0.14.0",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.1",
"version": "0.0.9",
"private": true,
"scripts": {
"dev": "next dev",
@@ -3,6 +3,7 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Icon } from '@/components/Icon';
import { useResponsiveStore } from '@/stores';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
@@ -13,6 +14,8 @@ export interface ToastProps {
icon?: string;
duration?: number;
onClose: (id: string) => void;
actionLabel?: string;
actionHref?: string;
}
const getToastConfig = (type: ToastType) => {
@@ -62,11 +65,14 @@ export const Toast = ({
icon,
duration = 4000,
onClose,
actionLabel,
actionHref,
}: ToastProps) => {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const config = getToastConfig(type);
const iconToUse = icon || config.icon;
const { isMobile } = useResponsiveStore();
useEffect(() => {
setIsVisible(true);
@@ -102,7 +108,12 @@ export const Toast = ({
overflow: hidden;
`}
>
<Box $direction="row" $align="center" $gap="12px">
<Box
$direction="row"
$align="center"
$gap="12px"
$justify="space-between"
>
<Icon
iconName={iconToUse}
$variation="600"
@@ -111,16 +122,41 @@ export const Toast = ({
color: ${config.color} !important;
`}
/>
<Text
$weight="500"
$size="14px"
$css={css`
color: ${config.color} !important;
padding: 4px;
`}
<Box
$direction="row"
$align="center"
$gap="12px"
$flex={1}
$justify="space-between"
>
{message}
</Text>
<Text
$weight="500"
$size="14px"
$css={css`
color: ${config.color} !important;
padding: 4px;
`}
>
{message}
</Text>
{actionLabel && actionHref && !isMobile && (
<a
href={actionHref}
target="_blank"
rel="noopener noreferrer"
style={{
color: config.color,
fontWeight: '500',
fontSize: '14px',
textDecoration: 'underline',
whiteSpace: 'nowrap',
}}
>
{actionLabel}
</a>
)}
</Box>
</Box>
</Box>
);
@@ -28,10 +28,16 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
transition: background 0.2s ease-in-out;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: 2px;
}
${({ $css }) => $css};
`;
@@ -102,6 +102,7 @@ export const DropdownMenu = ({
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
tabIndex={isDisabled ? -1 : 0}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -134,9 +135,15 @@ export const DropdownMenu = ({
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
&:hover,
&:focus {
background-color: var(--c--theme--colors--greyscale-050);
}
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: -2px;
}
`}
>
<Box
@@ -15,6 +15,7 @@ export const Icon = ({
return (
<Text
{...textProps}
aria-hidden="true"
className={clsx('--docs--icon-bg', textProps.className, {
'material-symbols': variant === 'filled',
'material-symbols-outlined': variant === 'outlined',
@@ -33,6 +34,7 @@ export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Icon
{...props}
aria-hidden="true"
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
user-select: none;
@@ -17,6 +17,8 @@ interface ToastItem {
type: ToastType;
icon?: string;
duration?: number;
actionLabel?: string;
actionHref?: string;
}
interface ToastContextType {
@@ -25,6 +27,7 @@ interface ToastContextType {
message: string,
icon?: string,
duration?: number,
options?: { actionLabel?: string; actionHref?: string },
) => void;
}
@@ -46,9 +49,23 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback(
(type: ToastType, message: string, icon?: string, duration = 4000) => {
(
type: ToastType,
message: string,
icon?: string,
duration = 4000,
options?: { actionLabel?: string; actionHref?: string },
) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast: ToastItem = { id, message, type, icon, duration };
const newToast: ToastItem = {
id,
message,
type,
icon,
duration,
actionLabel: options?.actionLabel,
actionHref: options?.actionHref,
};
setToasts((prev) => [newToast, ...prev]);
},
@@ -69,6 +86,7 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
{typeof window !== 'undefined' &&
createPortal(
<Box
aria-live="polite"
$css={`
position: fixed;
top: 8px;
@@ -94,6 +112,8 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
type={toast.type}
icon={toast.icon}
duration={toast.duration}
actionLabel={toast.actionLabel}
actionHref={toast.actionHref}
onClose={removeToast}
/>
))}
@@ -5,6 +5,7 @@
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
* @property {string} sub - The identity provider sub.
*/
export interface User {
id: string;
@@ -13,4 +14,5 @@ export interface User {
short_name: string;
language?: string;
allow_conversation_analytics: boolean;
sub?: string;
}
@@ -87,6 +87,12 @@ export const ActivationPage = () => {
e.preventDefault();
setError(null);
// Verify user is still authenticated before submitting
if (!authenticated) {
void router.push('/');
return;
}
if (!code.trim()) {
setError(t('Please enter an activation code'));
return;
@@ -135,6 +141,12 @@ export const ActivationPage = () => {
};
const handleNotificationRegister = () => {
// Verify user is still authenticated before registering
if (!authenticated) {
void router.push('/');
return;
}
void registerNotification(undefined, {
onSuccess: () => {
showToast('success', t('You will be notified!'));
@@ -39,7 +39,7 @@ jest.mock('next/router', () => ({
}),
}));
const dummyUser = { id: '123', email: 'test@example.com' };
const dummyUser = { id: '123', email: 'test@example.com', sub: 'test-sub' };
describe('useAuth hook - trackEvent effect', () => {
beforeEach(() => {
@@ -63,6 +63,7 @@ describe('useAuth hook - trackEvent effect', () => {
eventName: 'user',
id: dummyUser.id,
email: dummyUser.email,
sub: dummyUser.sub,
});
});
});
@@ -31,6 +31,7 @@ export const useAuth = () => {
eventName: 'user',
id: user?.id || '',
email: user?.email || '',
sub: user?.sub,
});
setHasTracked(true);
}
@@ -0,0 +1,88 @@
<svg width="118" height="86" viewBox="0 0 118 86" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_13649_715)">
<g filter="url(#filter1_d_13649_715)">
<g clip-path="url(#clip0_13649_715)">
<rect x="67.1685" y="17.71" width="37.9734" height="30.9413" rx="4.96294" transform="rotate(15.4037 67.1685 17.71)" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7995 26.0047C70.9944 25.2975 71.7257 24.8821 72.4329 25.0769L83.2801 28.0655C83.9874 28.2604 84.4027 28.9917 84.2079 29.6989C84.013 30.4062 83.2817 30.8215 82.5745 30.6267L71.7273 27.6381C71.02 27.4432 70.6047 26.7119 70.7995 26.0047Z" fill="#AE6257"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7995 26.0047C70.9944 25.2975 71.7257 24.8821 72.4329 25.0769L83.2801 28.0655C83.9874 28.2604 84.4027 28.9917 84.2079 29.6989C84.013 30.4062 83.2817 30.8215 82.5745 30.6267L71.7273 27.6381C71.02 27.4432 70.6047 26.7119 70.7995 26.0047Z" fill="#F6F8F9" fill-opacity="0.15"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.3049 31.428C69.4998 30.7208 70.231 30.3054 70.9383 30.5003L93.9886 36.851C94.6958 37.0459 95.1112 37.7772 94.9164 38.4844C94.7215 39.1916 93.9902 39.607 93.283 39.4122L70.2327 33.0614C69.5254 32.8666 69.11 32.1353 69.3049 31.428Z" fill="#AE6257"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.3049 31.428C69.4998 30.7208 70.231 30.3054 70.9383 30.5003L93.9886 36.851C94.6958 37.0459 95.1112 37.7772 94.9164 38.4844C94.7215 39.1916 93.9902 39.607 93.283 39.4122L70.2327 33.0614C69.5254 32.8666 69.11 32.1353 69.3049 31.428Z" fill="#F6F8F9" fill-opacity="0.8"/>
</g>
<rect x="67.4896" y="18.2754" width="37.0538" height="30.0217" rx="4.50314" transform="rotate(15.4037 67.4896 18.2754)" stroke="#AE6257" stroke-width="0.919604"/>
<rect x="67.4896" y="18.2754" width="37.0538" height="30.0217" rx="4.50314" transform="rotate(15.4037 67.4896 18.2754)" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="0.919604"/>
</g>
<g filter="url(#filter2_d_13649_715)">
<g clip-path="url(#clip1_13649_715)">
<rect x="13.6865" y="22.7539" width="30.5799" height="40.7732" rx="4.44697" transform="rotate(-15.6072 13.6865 22.7539)" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5742 30.4375C20.406 29.8351 20.7579 29.2102 21.3604 29.042L31.1778 26.2995C31.7803 26.1312 32.4051 26.4832 32.5734 27.0857C32.7417 27.6881 32.3897 28.3129 31.7873 28.4812L21.9698 31.2236C21.3673 31.3919 20.7425 31.04 20.5742 30.4375Z" fill="#3677CC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5742 30.4375C20.406 29.8351 20.7579 29.2102 21.3604 29.042L31.1778 26.2995C31.7803 26.1312 32.4051 26.4832 32.5734 27.0857C32.7417 27.6881 32.3897 28.3129 31.7873 28.4812L21.9698 31.2236C21.3673 31.3919 20.7425 31.04 20.5742 30.4375Z" fill="#F6F8F9" fill-opacity="0.15"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.6045 34.127C21.4362 33.5245 21.7882 32.8997 22.3906 32.7314L40.8655 27.5706C41.468 27.4023 42.0928 27.7543 42.261 28.3567C42.4293 28.9592 42.0774 29.584 41.4749 29.7523L23.0001 34.9131C22.3976 35.0814 21.7728 34.7294 21.6045 34.127ZM22.5187 37.3995C22.3504 36.797 22.7023 36.1722 23.3048 36.0039L41.7796 30.8431C42.3821 30.6748 43.0069 31.0268 43.1752 31.6292C43.3435 32.2317 42.9915 32.8565 42.3891 33.0248L23.9142 38.1856C23.3118 38.3539 22.6869 38.0019 22.5187 37.3995ZM23.4328 40.6719C23.2645 40.0695 23.6165 39.4447 24.2189 39.2764L42.6938 34.1156C43.2962 33.9473 43.921 34.2993 44.0893 34.9017C44.2576 35.5042 43.9057 36.129 43.3032 36.2973L24.8283 41.4581C24.2259 41.6263 23.6011 41.2744 23.4328 40.6719ZM24.3469 43.9444C24.1787 43.342 24.5306 42.7172 25.1331 42.5489L43.6079 37.3881C44.2104 37.2198 44.8352 37.5718 45.0035 38.1742C45.1718 38.7767 44.8198 39.4015 44.2174 39.5698L25.7425 44.7305C25.14 44.8988 24.5152 44.5469 24.3469 43.9444Z" fill="#3677CC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.6045 34.127C21.4362 33.5245 21.7882 32.8997 22.3906 32.7314L40.8655 27.5706C41.468 27.4023 42.0928 27.7543 42.261 28.3567C42.4293 28.9592 42.0774 29.584 41.4749 29.7523L23.0001 34.9131C22.3976 35.0814 21.7728 34.7294 21.6045 34.127ZM22.5187 37.3995C22.3504 36.797 22.7023 36.1722 23.3048 36.0039L41.7796 30.8431C42.3821 30.6748 43.0069 31.0268 43.1752 31.6292C43.3435 32.2317 42.9915 32.8565 42.3891 33.0248L23.9142 38.1856C23.3118 38.3539 22.6869 38.0019 22.5187 37.3995ZM23.4328 40.6719C23.2645 40.0695 23.6165 39.4447 24.2189 39.2764L42.6938 34.1156C43.2962 33.9473 43.921 34.2993 44.0893 34.9017C44.2576 35.5042 43.9057 36.129 43.3032 36.2973L24.8283 41.4581C24.2259 41.6263 23.6011 41.2744 23.4328 40.6719ZM24.3469 43.9444C24.1787 43.342 24.5306 42.7172 25.1331 42.5489L43.6079 37.3881C44.2104 37.2198 44.8352 37.5718 45.0035 38.1742C45.1718 38.7767 44.8198 39.4015 44.2174 39.5698L25.7425 44.7305C25.14 44.8988 24.5152 44.5469 24.3469 43.9444Z" fill="#F6F8F9" fill-opacity="0.8"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.2719 47.2555C25.1036 46.653 25.4555 46.0282 26.058 45.86L44.5328 40.6992C45.1353 40.5309 45.7601 40.8828 45.9284 41.4853C46.0967 42.0877 45.7447 42.7125 45.1423 42.8808L26.6674 48.0416C26.065 48.2099 25.4401 47.8579 25.2719 47.2555ZM26.186 50.528C26.0177 49.9255 26.3697 49.3007 26.9721 49.1324L45.447 43.9716C46.0494 43.8034 46.6742 44.1553 46.8425 44.7578C47.0108 45.3602 46.6589 45.985 46.0564 46.1533L27.5815 51.3141C26.9791 51.4824 26.3543 51.1304 26.186 50.528ZM27.1001 53.8005C26.9319 53.198 27.2838 52.5732 27.8863 52.4049L46.3611 47.2441C46.9636 47.0759 47.5884 47.4278 47.7567 48.0303C47.925 48.6327 47.573 49.2575 46.9706 49.4258L28.4957 54.5866C27.8932 54.7549 27.2684 54.4029 27.1001 53.8005ZM28.0143 57.073C27.846 56.4705 28.1979 55.8457 28.8004 55.6774L47.2753 50.5166C47.8777 50.3483 48.5025 50.7003 48.6708 51.3027C48.8391 51.9052 48.4871 52.53 47.8847 52.6983L29.4098 57.8591C28.8074 58.0274 28.1826 57.6754 28.0143 57.073Z" fill="#3677CC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.2719 47.2555C25.1036 46.653 25.4555 46.0282 26.058 45.86L44.5328 40.6992C45.1353 40.5309 45.7601 40.8828 45.9284 41.4853C46.0967 42.0877 45.7447 42.7125 45.1423 42.8808L26.6674 48.0416C26.065 48.2099 25.4401 47.8579 25.2719 47.2555ZM26.186 50.528C26.0177 49.9255 26.3697 49.3007 26.9721 49.1324L45.447 43.9716C46.0494 43.8034 46.6742 44.1553 46.8425 44.7578C47.0108 45.3602 46.6589 45.985 46.0564 46.1533L27.5815 51.3141C26.9791 51.4824 26.3543 51.1304 26.186 50.528ZM27.1001 53.8005C26.9319 53.198 27.2838 52.5732 27.8863 52.4049L46.3611 47.2441C46.9636 47.0759 47.5884 47.4278 47.7567 48.0303C47.925 48.6327 47.573 49.2575 46.9706 49.4258L28.4957 54.5866C27.8932 54.7549 27.2684 54.4029 27.1001 53.8005ZM28.0143 57.073C27.846 56.4705 28.1979 55.8457 28.8004 55.6774L47.2753 50.5166C47.8777 50.3483 48.5025 50.7003 48.6708 51.3027C48.8391 51.9052 48.4871 52.53 47.8847 52.6983L29.4098 57.8591C28.8074 58.0274 28.1826 57.6754 28.0143 57.073Z" fill="#F6F8F9" fill-opacity="0.8"/>
</g>
<rect x="14.2259" y="23.0577" width="29.7044" height="39.8977" rx="4.00923" transform="rotate(-15.6072 14.2259 23.0577)" stroke="#3677CC" stroke-width="0.875498"/>
<rect x="14.2259" y="23.0577" width="29.7044" height="39.8977" rx="4.00923" transform="rotate(-15.6072 14.2259 23.0577)" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="0.875498"/>
</g>
<g filter="url(#filter3_d_13649_715)">
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" fill="#6D778C"/>
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" fill="#F6F8F9" fill-opacity="0.975"/>
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" stroke="#6D778C" stroke-width="1.10324"/>
<rect x="38.4051" y="37.3387" width="39.5447" height="32.0173" rx="5.40236" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="1.10324"/>
<path d="M50.8763 49.2972C51.4755 49.6412 52.1356 49.8131 52.8568 49.8131C53.5779 49.8131 54.2325 49.6412 54.8206 49.2972C55.4086 48.9422 55.8801 48.4651 56.2351 47.866C56.5902 47.2669 56.7677 46.6067 56.7677 45.8856C56.7677 45.1755 56.5902 44.5209 56.2351 43.9218C55.8801 43.3227 55.4086 42.8456 54.8206 42.4906C54.2325 42.1355 53.5779 41.958 52.8568 41.958C52.1356 41.958 51.4755 42.1355 50.8763 42.4906C50.2883 42.8456 49.8168 43.3227 49.4618 43.9218C49.1067 44.5209 48.9292 45.1755 48.9292 45.8856C48.9292 46.6067 49.1067 47.2669 49.4618 47.866C49.8168 48.4651 50.2883 48.9422 50.8763 49.2972Z" fill="#9B6F02"/>
<path d="M50.8763 49.2972C51.4755 49.6412 52.1356 49.8131 52.8568 49.8131C53.5779 49.8131 54.2325 49.6412 54.8206 49.2972C55.4086 48.9422 55.8801 48.4651 56.2351 47.866C56.5902 47.2669 56.7677 46.6067 56.7677 45.8856C56.7677 45.1755 56.5902 44.5209 56.2351 43.9218C55.8801 43.3227 55.4086 42.8456 54.8206 42.4906C54.2325 42.1355 53.5779 41.958 52.8568 41.958C52.1356 41.958 51.4755 42.1355 50.8763 42.4906C50.2883 42.8456 49.8168 43.3227 49.4618 43.9218C49.1067 44.5209 48.9292 45.1755 48.9292 45.8856C48.9292 46.6067 49.1067 47.2669 49.4618 47.866C49.8168 48.4651 50.2883 48.9422 50.8763 49.2972Z" fill="#F6F8F9" fill-opacity="0.15"/>
<path d="M47.5929 55.7846C48.3745 54.6792 48.7652 54.1265 49.2447 53.929C49.6647 53.7561 50.1308 53.7561 50.5507 53.929C51.0302 54.1265 51.4209 54.6792 52.2025 55.7846L55.0263 59.7786C56.1326 61.3434 56.6858 62.1258 56.6721 62.7805C56.6602 63.3502 56.407 63.8842 55.9822 64.2353C55.4941 64.6388 54.5699 64.6388 52.7215 64.6388H47.0739C45.2255 64.6388 44.3013 64.6388 43.8132 64.2353C43.3884 63.8842 43.1352 63.3502 43.1233 62.7805C43.1096 62.1258 43.6628 61.3434 44.7691 59.7786L47.5929 55.7846Z" fill="#5A8228"/>
<path d="M47.5929 55.7846C48.3745 54.6792 48.7652 54.1265 49.2447 53.929C49.6647 53.7561 50.1308 53.7561 50.5507 53.929C51.0302 54.1265 51.4209 54.6792 52.2025 55.7846L55.0263 59.7786C56.1326 61.3434 56.6858 62.1258 56.6721 62.7805C56.6602 63.3502 56.407 63.8842 55.9822 64.2353C55.4941 64.6388 54.5699 64.6388 52.7215 64.6388H47.0739C45.2255 64.6388 44.3013 64.6388 43.8132 64.2353C43.3884 63.8842 43.1352 63.3502 43.1233 62.7805C43.1096 62.1258 43.6628 61.3434 44.7691 59.7786L47.5929 55.7846Z" fill="#F6F8F9" fill-opacity="0.55"/>
<path d="M60.0205 49.0171C60.8418 47.6462 61.2525 46.9607 61.7796 46.7263C62.2398 46.5216 62.7614 46.5216 63.2216 46.7263C63.7487 46.9607 64.1594 47.6462 64.9807 49.0171L71.586 60.0431C72.4852 61.5441 72.9348 62.2945 72.8846 62.9134C72.8409 63.4528 72.5744 63.9468 72.153 64.2689C71.6697 64.6385 70.8151 64.6385 69.106 64.6385H55.8952C54.1861 64.6385 53.3315 64.6385 52.8482 64.2689C52.4268 63.9468 52.1603 63.4528 52.1166 62.9134C52.0664 62.2945 52.516 61.5441 53.4152 60.0431L60.0205 49.0171Z" fill="#5A8228"/>
</g>
</g>
<defs>
<filter id="filter0_d_13649_715" x="0.0778322" y="0.618848" width="117.844" height="84.7633" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.35"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.243137 0 0 0 0 0.364706 0 0 0 0 0.905882 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
</filter>
<filter id="filter1_d_13649_715" x="48.6245" y="6.69141" width="65.8525" height="65.853" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2.62744"/>
<feGaussianBlur stdDeviation="2.62744"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
</filter>
<filter id="filter2_d_13649_715" x="3.77499" y="10.6539" width="60.2454" height="60.2454" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2.50142"/>
<feGaussianBlur stdDeviation="2.50142"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
</filter>
<filter id="filter3_d_13649_715" x="27.7856" y="25.3547" width="60.7837" height="60.7837" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="3.15211"/>
<feGaussianBlur stdDeviation="3.15211"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13649_715"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13649_715" result="shape"/>
</filter>
<clipPath id="clip0_13649_715">
<rect x="67.1685" y="17.71" width="37.9734" height="30.9413" rx="4.96294" transform="rotate(15.4037 67.1685 17.71)" fill="white"/>
</clipPath>
<clipPath id="clip1_13649_715">
<rect x="13.6865" y="22.7539" width="30.5799" height="40.7732" rx="4.44697" transform="rotate(-15.6072 13.6865 22.7539)" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

@@ -8,6 +8,7 @@ import { Modal, ModalSize } from '@openfun/cunningham-react';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
@@ -26,6 +27,7 @@ import {
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import { InputChat } from '@/features/chat/components/InputChat';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
@@ -153,7 +155,7 @@ export const Chat = ({
const [initialConversationMessages, setInitialConversationMessages] =
useState<Message[] | undefined>(undefined);
const [pendingFirstMessage, setPendingFirstMessage] = useState<{
event: React.FormEvent<HTMLFormElement>;
event: FormEvent<HTMLFormElement>;
attachments?: Attachment[];
forceWebSearch?: boolean;
} | null>(null);
@@ -244,7 +246,7 @@ export const Chat = ({
void stopGeneration();
};
const handleSubmitWrapper = (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmitWrapper = (event: FormEvent<HTMLFormElement>) => {
void handleSubmit(event);
};
@@ -361,7 +363,7 @@ export const Chat = ({
if (initialConversationId !== conversationId) {
handleInputChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>);
} as ChangeEvent<HTMLTextAreaElement>);
setHasInitialized(false); // Réinitialiser pour permettre le scroll au prochain chargement
}
}, [initialConversationId, conversationId, handleInputChange]);
@@ -375,7 +377,7 @@ export const Chat = ({
if (pendingInput) {
const syntheticEvent = {
target: { value: pendingInput },
} as React.ChangeEvent<HTMLInputElement>;
} as ChangeEvent<HTMLInputElement>;
handleInputChange(syntheticEvent);
}
if (pendingFiles) {
@@ -397,7 +399,7 @@ export const Chat = ({
const syntheticFormEvent = {
preventDefault: () => {},
target: form,
} as unknown as React.FormEvent<HTMLFormElement>;
} as unknown as FormEvent<HTMLFormElement>;
void handleSubmit(syntheticFormEvent);
setShouldAutoSubmit(false);
}
@@ -457,7 +459,7 @@ export const Chat = ({
}, [hasInitialized, messages.length]);
// Custom handleSubmit to include attachments and handle chat creation
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Upload files to server and get URLs
@@ -603,6 +605,7 @@ export const Chat = ({
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessageInConversation ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
@@ -643,6 +646,11 @@ export const Chat = ({
className="mainContent-chat"
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
@@ -659,12 +667,22 @@ export const Chat = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
@@ -696,7 +714,23 @@ export const Chat = ({
>
<Loader />
<Text $variation="600" $size="md">
{t('Search...')}
{(() => {
const toolInvocation = message.parts?.find(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !==
'document_parsing',
);
if (
toolInvocation?.type ===
'tool-invocation' &&
toolInvocation.toolInvocation.toolName ===
'summarize'
) {
return t('Summarizing...');
}
return t('Search...');
})()}
</Text>
</Box>
)}
@@ -789,7 +823,7 @@ export const Chat = ({
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen ? 'action-chat-button--open' : ''}`}
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => openSources(message.id)}
onKeyDown={(e) => {
if (
@@ -888,7 +922,6 @@ export const Chat = ({
{status === 'error' && (
<Box
$direction={isMobile ? 'column' : 'row'}
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
@@ -0,0 +1,80 @@
import { useRef } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useClipboard } from '@/hook';
interface CopyCodeButtonProps {
onCopy: () => void;
}
const CopyCodeButton = ({ onCopy }: CopyCodeButtonProps) => {
const { t } = useTranslation();
return (
<Box
as="button"
onClick={onCopy}
$css={`
position: absolute;
top: 8px;
right: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #fff;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
width: fit-content;
&:hover {
background:rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.20);
}
`}
>
<Icon
iconName="content_copy"
$size="14px"
$theme="greyscale"
$variation="200"
/>
<Text $size="xs" $theme="greyscale" $variation="200">
{t('Copy code')}
</Text>
</Box>
);
};
interface CodeBlockProps {
children: ReactNode;
[key: string]: unknown;
}
export const CodeBlock = ({ children, ...props }: CodeBlockProps) => {
const preRef = useRef<HTMLPreElement>(null);
const copyToClipboard = useClipboard();
const handleCopy = () => {
const code = preRef.current?.querySelector('code');
copyToClipboard(code?.textContent || '');
};
return (
<>
<CopyCodeButton onCopy={handleCopy} />
<Box ref={preRef} $position="relative" as="pre" {...props}>
{children}
</Box>
</>
);
};
@@ -1,13 +1,16 @@
import { Button } from '@openfun/cunningham-react';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useToast } from '@/components/ToastProvider';
import { FeatureFlagState, useConfig } from '@/core';
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import FilesIcon from '../assets/files.svg';
import { AttachmentList } from './AttachmentList';
import { ModelSelector } from './ModelSelector';
import { ScrollDown } from './ScrollDown';
@@ -49,6 +52,7 @@ export const InputChat = ({
isUploadingFiles = false,
}: InputChatProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
@@ -60,6 +64,29 @@ export const InputChat = ({
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isFileAccepted = useCallback(
(file: File): boolean => {
const acceptedConfig = conf?.chat_upload_accept;
if (!acceptedConfig) {
return true;
}
const acceptedTypes = acceptedConfig
.split(',')
.map((type) => type.trim());
return acceptedTypes.some((acceptedType) => {
if (acceptedType.startsWith('.')) {
return file.name.toLowerCase().endsWith(acceptedType.toLowerCase());
}
if (acceptedType.endsWith('/*')) {
const baseType = acceptedType.slice(0, -2);
return file.type.startsWith(baseType);
}
return file.type === acceptedType;
});
},
[conf?.chat_upload_accept],
);
const suggestions = [
t('Ask a question'),
t('Turn this list into bullet points'),
@@ -67,6 +94,20 @@ export const InputChat = ({
t('Find recent news about...'),
];
const showToastError = useCallback(() => {
showToast(
'error',
`${t('File type not supported')}`,
undefined,
undefined,
{
actionLabel: t('Know more'),
actionHref:
'https://docs.numerique.gouv.fr/docs/060b7b70-15aa-4d9a-86f5-2d31c3d693d5/',
},
);
}, [showToast, t]);
useEffect(() => {
if (!conf?.FEATURE_FLAGS) {
setWebSearchEnabled(false);
@@ -128,157 +169,283 @@ export const InputChat = ({
}
}, [status, input]);
useEffect(() => {
if (!fileUploadEnabled) {
return;
}
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// Only hide when leaving the window completely
if (!e.relatedTarget) {
setIsDragActive(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
// Check for rejected files during drag over (does not work on Safari)
if (e.dataTransfer?.items) {
const items = Array.from(e.dataTransfer.items);
items.some((item) => {
if (item.kind === 'file') {
// Check file type
const type = item.type;
const dummyFile = new File([], '', { type });
return !isFileAccepted(dummyFile);
}
return false;
});
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
if (!fileUploadEnabled) {
return;
}
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(droppedFiles).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length === 0) {
return;
}
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
acceptedFiles.forEach((f) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
return dt.files;
});
}
};
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [
fileUploadEnabled,
setFiles,
showToastError,
conf?.chat_upload_accept,
isFileAccepted,
]);
const isInputDisabled = status !== 'ready' || isUploadingFiles;
return (
<Box
$css={`
display: block;
position: relative;
opacity: ${status === 'error' ? '0.5' : '1'};
margin: auto;
width: 100%;
padding: ${isDesktop ? '0' : '0 10px'};
max-width: 750px;
`}
>
{/* Bouton de scroll vers le bas */}
{messagesLength > 1 &&
status !== 'streaming' &&
status !== 'submitted' &&
containerRef &&
onScrollToBottom && (
<Box
$css={`
<>
{isDragActive && (
<Box
$position="fixed"
$css={`
top: 0;
left: 0;
width: 100vw;
height: 100vh;
animation: fadeIn 0.3s;
z-index: 999;
background-color: rgba(255, 255, 255, 0.5);
pointer-events: all;
`}
/>
)}
<Box
$css={`
display: block;
position: relative;
opacity: ${status === 'error' ? '0.5' : '1'};
margin: auto;
width: 100%;
padding: ${isDesktop ? '0' : '0 10px'};
max-width: 750px;
`}
>
{/* Bouton de scroll vers le bas */}
{messagesLength > 1 &&
status !== 'streaming' &&
status !== 'submitted' &&
containerRef &&
onScrollToBottom && (
<Box
$css={`
position: relative;
height: 0;
width: 100%;
margin: auto;
max-width: 750px;
`}
>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
/>
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && (
<Box
$padding={{ all: 'base', bottom: 'md' }}
$align="center"
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
>
<ScrollDown
onClick={onScrollToBottom}
containerRef={containerRef}
/>
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
{t('What is on your mind?')}
</Text>
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && (
<Box
$padding={{ all: 'base', bottom: 'md' }}
$align="center"
$margin={{ horizontal: 'base', bottom: 'md', top: '-105px' }}
>
<Text as="h2" $size="xl" $weight="600" $margin={{ all: '0' }}>
{t('What is on your mind?')}
</Text>
</Box>
)}
<form
onSubmit={handleSubmit}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(fileUploadEnabled);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragActive(false);
if (!fileUploadEnabled) {
return;
}
if (e.dataTransfer.files?.length) {
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
Array.from(e.dataTransfer.files).forEach((f) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
return dt.files;
});
}
}}
style={{ width: '100%' }}
>
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
<Box
$flex={1}
$radius="12px"
$position="relative"
$background="white"
$css={`
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
<form
onSubmit={handleSubmit}
onDragOver={(e) => {
e.preventDefault();
setIsDragActive(fileUploadEnabled);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
// File handling is now done by global handler
}}
style={{ width: '100%' }}
>
<Box $padding={{ bottom: `${isDesktop ? 'base' : ''}` }}>
<Box
$flex={1}
$radius="12px"
$position="relative"
$background="white"
$css={`
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08);
border-radius: 12px;
border: ${
isDragActive
? '2px dashed var(--c--theme--colors--primary-400)'
: '1px solid var(--c--theme--colors--greyscale-200)'
};
border: 1px solid var(--c--theme--colors--greyscale-200);
position: relative;
background: white;
transition: all 0.2s ease;
`}
>
<textarea
ref={textareaRef}
value={input ?? ''}
name="inputchat-textarea"
onChange={(e) => {
handleInputChange(e);
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200);
textarea.style.height = `${newHeight}px`;
textarea.focus();
}}
disabled={isInputDisabled}
rows={1}
style={{
padding: '1rem 1.5rem',
background: 'transparent',
outline: 'none',
fontSize: '1rem',
border: 'none',
resize: 'none',
fontFamily: 'inherit',
minHeight: '64px',
maxHeight: '200px',
overflowY: 'auto',
transition: 'all 0.2s ease',
borderRadius: '12px',
color: 'var(--c--theme--colors--greyscale-800)',
lineHeight: '1.5',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
>
{isDragActive && (
<Box
$position="absolute"
$align="center"
$direction="row"
$justify="center"
$gap="1rem"
$css={`
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: #EDF0FF;
width: 100%;
height: 100%;
outline: 2px solid #90A7FF;
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
`}
>
<FilesIcon />
<Box>
<Text $weight="700" $color="#223E9E">
{t('Add file')}
</Text>
<Text $weight="400" $color="#223E9E">
{t('To add a file to the conversation, drop it here.')}
</Text>
</Box>
</Box>
)}
<textarea
ref={textareaRef}
aria-label={t('Enter your message or a question')}
value={input ?? ''}
name="inputchat-textarea"
onChange={(e) => {
handleInputChange(e);
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = '0';
e.currentTarget.form?.requestSubmit?.();
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200);
textarea.style.height = `${newHeight}px`;
textarea.focus();
}
}}
/>
}}
disabled={isInputDisabled}
rows={1}
style={{
padding: '1rem 1.5rem',
background: 'transparent',
outline: 'none',
fontSize: '1rem',
border: 'none',
resize: 'none',
fontFamily: 'inherit',
minHeight: '64px',
maxHeight: '200px',
overflowY: 'auto',
transition: 'all 0.2s ease',
borderRadius: '12px',
color: 'var(--c--theme--colors--greyscale-800)',
lineHeight: '1.5',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = '0';
e.currentTarget.form?.requestSubmit?.();
textarea.focus();
}
}}
/>
{!input && (
<Box
$css={`
{!input && (
<Box
$css={`
position: absolute;
top: 1rem;
left: 1.5rem;
@@ -291,130 +458,155 @@ export const InputChat = ({
line-height: 1.5;
overflow: hidden;
`}
>
<Box
$css={`
>
<Box
$css={`
display: flex;
flex-direction: column;
height: ${(suggestions.length + 1) * 100}%;
transform: translateY(-${currentSuggestionIndex * (100 / (suggestions.length + 1))}%);
transition: ${isResetting ? 'none' : 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'};
`}
>
{[...suggestions, suggestions[0]].map((suggestion, index) => (
<Box
key={index}
$css={`
>
{[...suggestions, suggestions[0]].map(
(suggestion, index) => (
<Box
key={index}
$css={`
height: calc(100% / ${suggestions.length + 1});
flex-shrink: 0;
white-space: nowrap;
display: flex;
justify-content: flex-start;
`}
>
{suggestion}
</Box>
))}
>
{suggestion}
</Box>
),
)}
</Box>
</Box>
</Box>
)}
)}
<input
accept={conf?.chat_upload_accept}
type="file"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(e) => {
const fileList = e.target.files;
if (!fileList) {
return;
}
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f: File) => dt.items.add(f));
<input
accept={conf?.chat_upload_accept}
type="file"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(e) => {
const fileList = e.target.files;
if (!fileList) {
return;
}
Array.from(fileList).forEach((f: File) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
return dt.files;
});
}}
/>
{/*Aperçu des fichiers*/}
{files && files.length > 0 && (
<Box
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
$padding={{ horizontal: 'base' }}
>
<AttachmentList
attachments={Array.from(files).map((file) => ({
name: file.name,
contentType: file.type,
url: URL.createObjectURL(file),
}))}
onRemove={(index) => {
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length === 0) {
e.target.value = '';
return;
}
setFiles((prev) => {
const dt = new DataTransfer();
Array.from(files).forEach((f, i) => {
if (i !== index) {
if (prev) {
Array.from(prev).forEach((f: File) => dt.items.add(f));
}
acceptedFiles.forEach((f: File) => {
if (
!Array.from(prev || []).some(
(pf) =>
pf.name === f.name &&
pf.size === f.size &&
pf.lastModified === f.lastModified,
)
) {
dt.items.add(f);
}
});
setFiles(dt.files.length > 0 ? dt.files : null);
}}
isReadOnly={false}
/>
</Box>
)}
<Box
$direction="row"
$gap="sm"
$padding={{ bottom: 'base' }}
$align="space-between"
>
<Box
$flex="1"
$direction="row"
$padding={{ horizontal: 'base' }}
$gap="xs"
>
<Button
size="small"
type="button"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={() => fileInputRef.current?.click()}
aria-label={t('Add attach file')}
className="c__button--neutral attach-file-button"
icon={
<Icon
iconName="attach_file"
$theme="greyscale"
$variation="550"
$size={`${isMobile ? '24px' : '16px'}`}
/>
}
>
{!isMobile && (
<Text $theme="greyscale" $variation="550" $weight="500">
{t('Attach file')}
</Text>
)}
</Button>
return dt.files;
});
{onToggleWebSearch && (
<Box
$margin={{ left: '4px' }}
$css={`
e.target.value = '';
}}
/>
{/*Aperçu des fichiers*/}
{files && files.length > 0 && (
<Box
$margin={{ horizontal: '0', bottom: 'xs', top: 'xs' }}
$padding={{ horizontal: 'base' }}
>
<AttachmentList
attachments={Array.from(files).map((file) => ({
name: file.name,
contentType: file.type,
url: URL.createObjectURL(file),
}))}
onRemove={(index) => {
const dt = new DataTransfer();
Array.from(files).forEach((f, i) => {
if (i !== index) {
dt.items.add(f);
}
});
setFiles(dt.files.length > 0 ? dt.files : null);
}}
isReadOnly={false}
/>
</Box>
)}
<Box
$direction="row"
$gap="sm"
$padding={{ bottom: 'base' }}
$align="space-between"
>
<Box
$flex="1"
$direction="row"
$padding={{ horizontal: 'base' }}
$gap="xs"
>
<Button
size="small"
type="button"
disabled={!fileUploadEnabled || isUploadingFiles}
onClick={() => fileInputRef.current?.click()}
aria-label={t('Add attach file')}
className="c__button--neutral attach-file-button"
icon={
<Icon
iconName="attach_file"
$theme="greyscale"
$variation="550"
$size={`${isMobile ? '24px' : '16px'}`}
/>
}
>
{!isMobile && (
<Text $theme="greyscale" $variation="550" $weight="500">
{t('Attach file')}
</Text>
)}
</Button>
{onToggleWebSearch && (
<Box
$margin={{ left: '4px' }}
$css={`
${
isMobile
? `
@@ -434,96 +626,101 @@ export const InputChat = ({
: ''
}
`}
>
<Button
size="small"
type="button"
disabled={!webSearchEnabled || isUploadingFiles}
onClick={() => {
onToggleWebSearch();
textareaRef.current?.focus();
}}
aria-label={t('Research on the web')}
className="c__button--neutral research-web-button"
icon={
<Icon
iconName="language"
$theme="greyscale"
$variation="550"
$css={`
>
<Button
size="small"
type="button"
disabled={!webSearchEnabled || isUploadingFiles}
onClick={() => {
onToggleWebSearch();
textareaRef.current?.focus();
}}
aria-label={t('Research on the web')}
className="c__button--neutral research-web-button"
icon={
<Icon
iconName="language"
$theme="greyscale"
$variation="550"
$css={`
color: ${forceWebSearch ? 'var(--c--theme--colors--primary-600) !important' : 'var(--c--theme--colors--greyscale-600)'}
`}
/>
}
>
{!isMobile && (
<Text
$theme={forceWebSearch ? 'primary' : 'greyscale'}
$variation="550"
>
{t('Research on the web')}
</Text>
)}
{isMobile && forceWebSearch && (
<Box
$direction="row"
$align="space-between"
$gap="xs"
$css={`
/>
}
>
{!isMobile && (
<Text
$theme={forceWebSearch ? 'primary' : 'greyscale'}
$variation="550"
>
{t('Research on the web')}
</Text>
)}
{isMobile && forceWebSearch && (
<Box
$direction="row"
$align="space-between"
$gap="xs"
$css={`
display: flex;
align-items: center;
line-height: 1;
`}
>
<Text
$theme="primary"
$weight="500"
$css={`
>
<Text
$theme="primary"
$weight="500"
$css={`
display: flex;
align-items: center;
`}
>
{t('Web')}
</Text>
<Icon
iconName="close"
$variation="text"
$theme="primary"
$size="md"
$css={`
>
{t('Web')}
</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>
)}
</Box>
<Box $direction="row" $padding={{ horizontal: 'base' }} $gap="xs">
<Box $padding={{ horizontal: 'xs' }}>
{onModelSelect && (
<ModelSelector
selectedModel={selectedModel || null}
onModelSelect={onModelSelect}
/>
/>
</Box>
)}
</Button>
</Box>
)}
</Box>
<Box
$direction="row"
$padding={{ horizontal: 'base' }}
$gap="xs"
>
<Box $padding={{ horizontal: 'xs' }}>
{onModelSelect && (
<ModelSelector
selectedModel={selectedModel || null}
onModelSelect={onModelSelect}
/>
)}
</Box>
<SendButton
status={status}
disabled={!input || !input.trim() || isUploadingFiles}
onClick={onStop}
/>
<SendButton
status={status}
disabled={!input || !input.trim() || isUploadingFiles}
onClick={onStop}
/>
</Box>
</Box>
</Box>
</Box>
</Box>
</form>
</Box>
</form>
</Box>
</>
);
};
@@ -151,7 +151,7 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
<Box $direction="row" $gap="4px" $align="center">
<Box
$direction="row"
$align="center"
$align="start"
$css="font-size: 14px;"
$width="100%"
>
@@ -168,6 +168,9 @@ export const SourceItem: React.FC<SourceItemProps> = ({ url, metadata }) => {
padding: 4px;
width: 100%;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: transparent;
transition: background-color 0.3s;
color: var(--c--theme--colors--greyscale-500);
@@ -54,7 +54,7 @@ export const Feedback = (_props: { buttonProps?: Partial<ButtonProps> }) => {
/>
<FeedbackButton
href="https://www.tchap.gouv.fr/#/room/!eeDqhgOFDpthMyyUjK:agent.dinum.tchap.gouv.fr"
href="https://tchap.gouv.fr/#/room/!eAHyPLdVHMxNhKAbaC:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr&via=agent.culture.tchap.gouv.fr&via=agent.education.tchap.gouv.fr"
icon={<TchapIcon />}
title={t('Write on Tchap')}
description={t('Direct exchange with our team')}
@@ -6,13 +6,15 @@ import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferenc
export const ButtonToggleLeftPanel = () => {
const { t } = useTranslation();
const { togglePanel } = useChatPreferencesStore();
const { isPanelOpen, togglePanel } = useChatPreferencesStore();
return (
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
aria-label={
isPanelOpen ? t('Close the left panel') : t('Open the left panel')
}
color="primary-text"
icon={<LeftPanelIcon />}
className="--docs--button-toggle-panel"
@@ -12,7 +12,7 @@ export const ButtonTogglePanel = () => {
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
aria-label={isPanelOpen ? t('Close the menu') : t('Open the menu')}
color="primary-text"
icon={<Icon $theme="primary" iconName={isPanelOpen ? 'close' : 'menu'} />}
className="mobile-no-focus"
@@ -41,6 +41,7 @@ export const LanguagePicker = () => {
<DropdownMenu
options={optionsPicker}
showArrow={isDesktop}
label={t('Language')}
buttonCss={css`
&:hover {
background-color: var(
@@ -61,7 +62,6 @@ export const LanguagePicker = () => {
<Text
$theme="primary"
$size="md"
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
className="--docs--language-picker-text"
@@ -2,7 +2,7 @@ import { Button as _Button, useModal } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { ModalRemoveConversation } from './ModalRemoveConversation';
@@ -30,47 +30,39 @@ export const ConversationItemActions = ({
return (
<>
<DropdownMenu options={options}>
<Box
role="button"
tabIndex={0}
aria-label={t('Conversation actions')}
aria-haspopup="menu"
aria-expanded="false"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// Le DropdownMenu gère l'ouverture
}
}}
<DropdownMenu
options={options}
label={t('Actions list for conversation {{title}}', {
title: conversation.title || t('Untitled conversation'),
})}
buttonCss={css`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: #e1e3e7 !important;
}
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: 2px;
}
`}
>
<Icon
data-testid={`conversation-item-actions-button-${conversation.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
$css={css`
display: block;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #e1e3e7 !important;
}
&:focus-visible {
outline: 2px solid #3e5de7;
outline-offset: 2px;
}
font-size: 1rem;
color: var(--c--theme--colors--primary-text-text);
pointer-events: none;
`}
>
<Icon
data-testid={`conversation-item-actions-button-${conversation.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
$css={css`
font-size: 1rem;
color: var(--c--theme--colors--primary-text-text);
pointer-events: none;
`}
/>
</Box>
/>
</DropdownMenu>
{deleteModal.isOpen && (

Some files were not shown because too many files have changed in this diff Show More