Compare commits

...

54 Commits

Author SHA1 Message Date
Eléonore Voisin eb000e5a5a Merge branch 'main' into feature/add-ui-kit 2026-01-16 12:10:38 +01:00
Eléonore Voisin 693beaba86 [WIP] ui-kit 2026-01-16 11:49:28 +01:00
Quentin BEY 13c6499c66 🔥(chat) remove thinking part from frontend
We want to enable the OSS model but seems like it returns
thinking values twice and we don't manage it well...

So we disable the frontend while we still don't know
how to display the thinking stuff.
We could have also cleaned the backend while unused.
2026-01-16 11:43:05 +01:00
Berry den Hartog a0b31e1e61 🐛(front) fix link color in LeftPanelConversationItem component
fix link color component for default theme
2026-01-16 11:43:05 +01:00
Laurent Paoletti daf90cf110 ⚰️(back) remove dead code and unused files
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-16 11:43:05 +01:00
Laurent Paoletti 29f76fe040 🐛(back) fix system prompt compatibility with self-hosted models
Pydantic AI allows setting multiple static and dynamic system prompts
to define conversation context and rules. Previously, these were sent
to the model API as separate messages, which caused compatibility
issues with some self-hosted models (e.g., Gemma3/vLLM).

This commit switches from using `system_prompt` to `instruction` as
recommended in the Pydantic AI documentation, thus merging several
instructions into a single message.

Reference: https://ai.pydantic.dev/agents/#system-prompts
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-16 11:43:05 +01:00
Laurent Paoletti f3680b6905 ⚰️(back) remove dead code and unused files
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-06 10:42:08 +01:00
Laurent Paoletti 5676ce68c0 🐛(back) fix system prompt compatibility with self-hosted models
Pydantic AI allows setting multiple static and dynamic system prompts
to define conversation context and rules. Previously, these were sent
to the model API as separate messages, which caused compatibility
issues with some self-hosted models (e.g., Gemma3/vLLM).

This commit switches from using `system_prompt` to `instruction` as
recommended in the Pydantic AI documentation, thus merging several
instructions into a single message.

Reference: https://ai.pydantic.dev/agents/#system-prompts
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-05 18:43:38 +01:00
Eléonore Voisin 50a395c546 Revert "🐛(front) optimize chat"
This reverts commit 69bf2cab5d.
2025-12-30 13:46:04 +01:00
Eléonore Voisin 69bf2cab5d 🐛(front) optimize chat
Simplified chat rendering
2025-12-19 17:12:53 +01:00
Eléonore Voisin dc61fdce00 🐛(e2e) fix test-e2e-chronium
don't need to wait for the See more button to appear
2025-12-19 08:27:02 +01:00
Eléonore Voisin aa42a9b4d3 📦️(front) update react
update react to 19.2.1
2025-12-16 16:48:06 +01:00
qbey 5475bcd04e 🌐(i18n) update translated strings
Update translated files with new translations
2025-12-16 13:15:08 +01:00
Quentin BEY b1533c016a 🔖(minor) bump release to 0.0.10
Added

- (front) add retry button

Fixed

- 🐛(front) fix long user messages
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
- 🐛(front) fix parsing documents display
- 🐛(front) fix opacity input in error
- 🐛(front) resolve React hydration errors
- 🚑️(user) allow longer short names #182
2025-12-15 14:19:30 +01:00
renovate[bot] 9329ee8c90 ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-05 17:18:44 +01:00
Quentin BEY 39bf8f0c2d 🔒️(trivy) run trivy scan on sources (not in docker)
Inside the docker images, trivy does not detect frontend issues,
we run trivy here to have the lock files.
This does not work well for python packages.
2025-12-05 16:52:57 +01:00
Eléonore Voisin a1ed561204 🐛(front) fix long user messages
delete MarkDown for user message
2025-12-04 20:56:54 +01:00
Eléonore Voisin 6dfb9b7328 🐛(front) fix Maximum update depth exceeded - error in Chat component
Fix infinite render loop
2025-12-04 14:55:04 +01:00
Eléonore Voisin 38ae97aa31 🐛(front) fix parsing documents display
fix width display parsing + fix width doc title'
2025-12-02 17:22:39 +01:00
Eléonore Voisin 19cf3b2663 🐛(front) fix opacity input in error
fix opacity input in error
2025-12-02 14:40:27 +01:00
Eléonore Voisin 83904d8878 🐛(front) resolve React hydration errors
resolve React hydration errors and infinite update loop

🐛(front) resolve React hydration errors

resolve React hydration errors and infinite update loop
2025-12-02 10:35:48 +01:00
Eléonore Voisin d3922b7448 (front) add retry button
on chat error add retry button
2025-12-01 19:21:34 +01:00
Quentin BEY cdac7cad3b 🚑️(user) allow longer short names
I don't know why it was so short, but a legit user is blocked.
2025-12-01 09:43:36 +01:00
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
152 changed files with 22214 additions and 9784 deletions
+19
View File
@@ -200,3 +200,22 @@ jobs:
- name: Run tests
run: ~/.local/bin/pytest -n 2
security-trivy-critical:
permissions:
contents: read
security-events: write
runs-on: ubuntu-latest
steps:
- name: Run Trivy analysis for critical vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy-critical@main
security-trivy:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Run Trivy analysis for vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy@main
+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: .
+64 -8
View File
@@ -8,31 +8,86 @@ and this project adheres to
## [Unreleased]
### Changed
- 📦️(front) update react
- ✨(front) add ui-kit
- ✨(front) add dark-mode
### Fixed
- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
- ⚰️(back) remove dead code and unused files
### Removed
- 🔥(chat) remove thinking part from frontend #227
## [0.0.10] - 2025-12-15
### Added
- ✨(front) add retry button
### Fixed
- 🐛(front) fix long user messages
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
- 🐛(front) fix parsing documents display
- 🐛(front) fix opacity input in error
- 🐛(front) resolve React hydration errors
- 🚑️(user) allow longer short names #182
## [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
@@ -44,14 +99,12 @@ and this project adheres to
- 🐛(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
@@ -61,6 +114,7 @@ and this project adheres to
- 📈(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
@@ -68,7 +122,6 @@ and this project adheres to
- 🐛(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
@@ -91,7 +144,7 @@ and this project adheres to
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- ✨(ui) fix retour global ui
- ✨(ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- ✨(ui) delete flex message
@@ -126,7 +179,10 @@ and this project adheres to
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.7...main
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
[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
+1
View File
@@ -167,6 +167,7 @@ CMD [\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"conversations.asgi: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
-6
View File
@@ -1,6 +0,0 @@
{
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@ai-sdk/ui-utils": "^1.2.11"
}
}
+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
@@ -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,6 +188,31 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
logger.debug(response.json())
response.raise_for_status()
async def astore_document(self, name: str, content: str) -> None:
"""
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.
@@ -161,11 +224,13 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
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
@@ -190,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.")
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()
+1 -3
View File
@@ -190,6 +190,4 @@ class BaseAgent(Agent):
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
super().__init__(
model=_model_instance, system_prompt=_system_prompt, tools=_tools, **kwargs
)
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
+4 -5
View File
@@ -16,7 +16,6 @@ from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
@@ -102,10 +101,10 @@ class ConversationAgent(BaseAgent):
if settings.WARNING_MOCK_CONVERSATION_AGENT:
self._model = FunctionModel(stream_function=mocked_agent_model)
@self.system_prompt
@self.instructions
def add_the_date() -> str:
"""
Dynamic system prompt function to add the current date.
Dynamic instruction function to add the current date.
Warning: this will always use the date in the server timezone,
not the user's timezone...
@@ -113,9 +112,9 @@ class ConversationAgent(BaseAgent):
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
return f"Today is {_formatted_date}."
@self.system_prompt
@self.instructions
def enforce_response_language() -> str:
"""Dynamic system prompt function to set the expected language to use."""
"""Dynamic instruction function to set the expected language to use."""
return f"Answer in {get_language_name(language).lower()}." if language else ""
def get_web_search_tool_name(self) -> str | None:
-62
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,60 +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:
"""
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.
"""
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}},
)
+46 -24
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,10 +72,15 @@ 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
# Keep at the top of the file to avoid mocking issues
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -115,7 +120,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 +142,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 +185,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 +197,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):
@@ -227,6 +239,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
Parse and store input documents in the conversation's document store.
"""
# Early external document URL rejection
if any(
not document.url.startswith("/media-key/")
for document in documents
@@ -240,8 +253,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
):
raise ValueError("Document URL does not belong to the conversation.")
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
document_store = document_store_backend(self.conversation.collection_id)
if not document_store.collection_id:
# Create a new collection for the conversation
@@ -352,7 +363,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 +387,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}
@@ -409,6 +422,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
],
},
)
try:
await self.parse_input_documents(input_documents)
except Exception as exc: # pylint: disable=broad-except
@@ -446,7 +460,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
if force_web_search:
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def force_web_search_prompt() -> str:
"""Dynamic system prompt function to force web search."""
return (
@@ -480,7 +494,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
elif has_not_pdf_docs:
add_document_rag_search_tool(self.conversation_agent)
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def summarization_system_prompt() -> str:
return (
"When you receive a result from the summarization tool, you MUST return it "
@@ -493,13 +507,20 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"You may add a follow-up question after the summary if needed."
)
@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)
# Inform the model (system-level) that documents are attached and available
@self.conversation_agent.instructions
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
@@ -685,7 +706,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(
@@ -709,9 +730,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(
finish_reason=events_v4.FinishReason.STOP,
@@ -27,9 +27,14 @@ def test_build_pydantic_agent_success_no_tools():
"""Test successful agent creation without tools."""
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ()
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[2].__name__ == "enforce_response_language"
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -37,6 +42,7 @@ def test_build_pydantic_agent_success_no_tools():
assert agent._function_toolset.tools == {}
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_build_pydantic_agent_with_tools(settings):
"""Test successful agent creation with tools."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -44,8 +50,14 @@ def test_build_pydantic_agent_with_tools(settings):
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -56,21 +68,23 @@ def test_build_pydantic_agent_with_tools(settings):
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_add_dynamic_system_prompt():
"""
Ensure add_the_date and enforce_response_language system prompt are registered
Ensure add_the_date and enforce_response_language instructions are registered
and returns proper values.
"""
agent = ConversationAgent(model_hrid="default-model")
assert len(agent._system_prompt_functions) == 2
assert len(agent._system_prompt_functions) == 0
assert agent._system_prompt_functions[0].function.__name__ == "add_the_date"
assert agent._system_prompt_functions[0].function() == "Today is Friday 25/07/2025."
assert agent._system_prompt_functions[1].function.__name__ == "enforce_response_language"
assert agent._system_prompt_functions[1].function() == ""
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
agent = ConversationAgent(model_hrid="default-model", language="fr-fr")
assert agent._system_prompt_functions[1].function() == "Answer in french."
assert agent._instructions[2]() == "Answer in french."
def test_agent_get_web_search_tool_name(settings):
@@ -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
+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
@@ -130,6 +130,16 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
{
@@ -169,35 +179,23 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"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",
@@ -218,6 +216,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -252,6 +251,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
assert response_content == "Hello there"
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
@@ -292,35 +300,23 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"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",
@@ -341,6 +337,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -403,11 +400,12 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
# Check the exact structure expected by the AI service
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025."
"\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{
"content": [
{"text": "Hello, what do you see on this picture?", "type": "text"},
@@ -489,29 +487,15 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
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,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"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, what do you see on this picture?",
@@ -530,6 +514,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -550,6 +535,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -607,11 +593,12 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -666,35 +653,22 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"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": ["Weather in Paris?"],
"part_kind": "user-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -723,9 +697,13 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
@@ -737,6 +715,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -759,6 +738,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -815,11 +795,12 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in french.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -874,35 +855,22 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"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 french.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Weather in Paris?"],
"part_kind": "user-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "tool_call",
@@ -931,9 +899,13 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"parts": [
{
@@ -944,6 +916,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"tool_name": "get_current_weather",
}
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -966,6 +939,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -1192,35 +1166,21 @@ def test_post_conversation_data_protocol_no_stream(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are an amazing 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": ["Why the sky is blue?"],
"part_kind": "user-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": "stop",
@@ -1248,6 +1208,7 @@ def test_post_conversation_data_protocol_no_stream(
"output_audio_tokens": 0,
"output_tokens": 135,
},
"run_id": _run_id,
},
]
@@ -1344,35 +1305,22 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
parts=[TextUIPart(type="text", text="Hello there")],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"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",
@@ -1393,5 +1341,6 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
},
]
@@ -151,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
@@ -216,7 +216,8 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # 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,
@@ -351,58 +352,34 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
_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": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["What does the document say?"],
"part_kind": "user-prompt",
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -431,9 +408,26 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"output_audio_tokens": 0,
"output_tokens": 8,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
{
@@ -451,6 +445,7 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"tool_name": "document_search_rag",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -477,13 +472,15 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
"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( # 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
@@ -536,14 +533,12 @@ def test_post_conversation_with_document_upload_feature_disabled( # pylint: dis
# 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:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
# This behavior must be improved in the future to inform the user properly
assert "Document upload feature is disabled, ignoring input documents." in caplog.text
@@ -566,6 +561,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
api_client.force_authenticate(user=chat_conversation.owner)
pdf_base64 = base64.b64encode(sample_pdf_content.read()).decode("utf-8")
message = UIMessage(
id="1",
role="user",
@@ -627,7 +623,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":201,"completionTokens":13}}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":287,"completionTokens":19}}\n'
)
# Check that the conversation was updated
@@ -686,58 +682,35 @@ 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": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["Make a summary of this document."],
"part_kind": "user-prompt",
"timestamp": timezone_now,
},
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
@@ -766,9 +739,26 @@ 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": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
{
@@ -780,6 +770,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"tool_name": "summarize",
}
],
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[3] == {
"finish_reason": None,
@@ -802,4 +793,5 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 6,
},
"run_id": _run_id,
}
@@ -17,7 +17,6 @@ from pydantic_ai.messages import (
DocumentUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -61,7 +60,8 @@ def fixture_sample_document_content():
@responses.activate
@freeze_time()
def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_promt_date,
@@ -120,7 +120,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -129,11 +129,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this document?",
@@ -145,7 +140,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
],
timestamp=timezone.now(),
),
]
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
yield "This is a document about a single pixel."
@@ -217,29 +215,14 @@ 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,
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -256,6 +239,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -282,6 +266,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,
},
]
@@ -425,7 +410,6 @@ def test_post_conversation_with_remote_document_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
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_ai_agent_service,
):
"""
@@ -433,6 +417,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"""
chat_conversation_pk = "0be55da5-8eb7-4dad-aa0f-fea454bd5809"
document_url = f"/media-key/{chat_conversation_pk}/sample.pdf"
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
chat_conversation = ChatConversationFactory(
pk=chat_conversation_pk,
owner__language="en-us",
@@ -468,27 +454,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
pydantic_messages=[
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"Today is {formatted_date}.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -551,7 +521,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -560,18 +530,6 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -583,13 +541,18 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
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=[
@@ -599,7 +562,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
timestamp=timezone.now(),
)
]
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[2].run_id,
),
]
yield "This is a document of square, very small and nice."
@@ -695,29 +662,14 @@ 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,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -734,6 +686,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,
@@ -760,9 +713,12 @@ 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,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
@@ -771,6 +727,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,
@@ -797,6 +754,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -811,7 +769,8 @@ 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( # 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_ai_agent_service,
@@ -874,33 +833,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
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."
),
timestamp=timezone.now(),
),
SystemPromptPart(
content=(
"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."
),
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -908,7 +840,26 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
],
timestamp=timezone.now(),
),
]
],
instructions=(
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; "
"consider them already available via the internal store."
),
run_id=messages[0].run_id,
)
]
yield "This is a document about you."
@@ -978,53 +929,29 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
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": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen 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.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; "
"consider them already available via the internal store."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "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.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -1033,6 +960,7 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
"timestamp": timestamp,
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -1059,5 +987,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
"output_audio_tokens": 0,
"output_tokens": 7,
},
"run_id": _run_id,
},
]
@@ -919,7 +919,7 @@ def history_conversation_with_tool_fixture():
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages including a tool invocation
conversation = ChatConversationFactory()
conversation = ChatConversationFactory(owner__language="nl-nl")
# Add previous user and assistant messages with tool invocation
conversation.messages = [
@@ -1373,9 +1373,13 @@ 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,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -1384,6 +1388,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] == {
@@ -1413,10 +1418,13 @@ 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] == {
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -1428,6 +1436,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] == {
@@ -1451,6 +1460,7 @@ def test_post_conversation_with_existing_tool_history(
"output_audio_tokens": 0,
"output_tokens": 0,
},
"run_id": _run_id,
}
@@ -2,7 +2,7 @@
import uuid
from django.utils import timezone
from django.utils import formats, timezone
import pytest
from dirty_equals import IsUUID
@@ -12,7 +12,6 @@ from pydantic_ai.messages import (
ImageUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -87,22 +86,15 @@ def test_post_conversation_with_local_image_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
presigned_url = messages[0].parts[0].content[1].url
# assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(
content="Today is Saturday 18/10/2025.", timestamp=timezone.now()
),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -114,7 +106,10 @@ def test_post_conversation_with_local_image_url(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -180,29 +175,13 @@ def test_post_conversation_with_local_image_url(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Today is Saturday 18/10/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -219,6 +198,7 @@ def test_post_conversation_with_local_image_url(
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
"run_id": _run_id,
},
{
"finish_reason": None,
@@ -241,6 +221,7 @@ def test_post_conversation_with_local_image_url(
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -282,11 +263,6 @@ def test_post_conversation_with_local_image_wrong_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -298,7 +274,10 @@ def test_post_conversation_with_local_image_wrong_url(
],
timestamp=timezone.now(),
),
]
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
yield "cannot read image." # IRL a 400 error would be raised by the LLM
@@ -369,11 +348,6 @@ def test_post_conversation_with_remote_image_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -385,7 +359,10 @@ def test_post_conversation_with_remote_image_url(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
yield "This is an image of a single pixel."
@@ -498,27 +475,10 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -581,7 +541,7 @@ def test_post_conversation_with_local_image_url_in_history(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -590,18 +550,6 @@ def test_post_conversation_with_local_image_url_in_history(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this image?",
@@ -613,7 +561,9 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -629,7 +579,10 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
)
]
],
run_id=messages[2].run_id,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
),
]
yield "This is an image of square, very small and nice."
@@ -725,29 +678,13 @@ 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,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -788,7 +725,8 @@ def test_post_conversation_with_local_image_url_in_history(
},
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
@@ -797,6 +735,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,
@@ -823,5 +762,6 @@ def test_post_conversation_with_local_image_url_in_history(
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -1,4 +1,4 @@
"""Test the post_stop_steaming view."""
"""Test the post_stop_streaming view."""
from unittest.mock import patch
@@ -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)
@@ -39,12 +39,10 @@ def add_document_rag_search_tool(agent: Agent) -> None:
metadata={"sources": {result.url for result in rag_results.data}},
)
@agent.system_prompt
@agent.instructions
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
+12
View File
@@ -0,0 +1,12 @@
"""Exceptions for tool function retries."""
from pydantic_ai import ModelRetry
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 -101
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,92 +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,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
)
# 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
+2 -2
View File
@@ -221,7 +221,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
url_path="stop-streaming",
url_name="stop-streaming",
)
def post_stop_steaming(self, request, pk): # pylint: disable=unused-argument
def post_stop_streaming(self, request, pk): # pylint: disable=unused-argument
"""Handle POST requests to stop streaming the chat conversation.
This action will put a poison pill in the redis cache to stop any ongoing streaming.
@@ -425,7 +425,7 @@ class ChatConversationAttachmentViewSet(
if settings.POSTHOG_KEY:
posthog.capture(
"item_uploaded",
distinct_id=request.user.pk, # same as set by the frontend
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
@@ -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"
}
]
}
+21 -4
View File
@@ -312,7 +312,7 @@ class Base(BraveSettings, Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.posthog.AsyncPosthogContextMiddleware",
"posthog.integrations.django.PosthogContextMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
@@ -631,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",
@@ -720,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(
@@ -789,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(
-14
View File
@@ -1,12 +1,9 @@
"""Conversations core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework import exceptions as drf_exceptions
from rest_framework import views as drf_views
from rest_framework.decorators import api_view
from rest_framework.response import Response
def exception_handler(exc, context):
@@ -28,14 +25,3 @@ def exception_handler(exc, context):
exc = drf_exceptions.ValidationError(detail=detail)
return drf_views.exception_handler(exc, context)
# pylint: disable=unused-argument
@api_view(["GET"])
def get_frontend_configuration(request):
"""Returns the frontend configuration dict as configured in settings."""
frontend_configuration = {
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
-20
View File
@@ -20,23 +20,3 @@ class UserSerializer(serializers.ModelSerializer):
"sub",
]
read_only_fields = ["id", "email", "full_name", "short_name", "sub"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -1,52 +0,0 @@
"""Custom authentication classes for the Conversations core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create document server to server'"
+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 -1
View File
@@ -38,7 +38,7 @@ def is_feature_enabled(
if posthog is not None:
return posthog.feature_enabled(
frontend_feature_name(feature_name),
user.pk, # same as set by the frontend
str(user.pk), # same as set by the frontend
)
# No feature flag manager
-25
View File
@@ -1,25 +0,0 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers
class JSONField(serializers.Field):
"""
A custom field for handling JSON data.
"""
def to_representation(self, value):
"""
Convert the JSON string to a Python dictionary for serialization.
"""
return value
def to_internal_value(self, data):
"""
Convert the Python dictionary to a JSON string for deserialization.
"""
if data is None:
return None
return json.dumps(data)
-22
View File
@@ -2,31 +2,9 @@
import unicodedata
import django_filters
def remove_accents(value):
"""Remove accents from a string (vélo -> velo)."""
return "".join(
c for c in unicodedata.normalize("NFD", value) if unicodedata.category(c) != "Mn"
)
class AccentInsensitiveCharFilter(django_filters.CharFilter):
"""
A custom CharFilter that filters on the accent-insensitive value searched.
"""
def filter(self, qs, value):
"""
Apply the filter to the queryset using the unaccented version of the field.
Args:
qs: The queryset to filter.
value: The value to search for in the unaccented field.
Returns:
A filtered queryset.
"""
if value:
value = remove_accents(value)
return super().filter(qs, value)
@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-12-01 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_user_allow_conversation_analytics"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(blank=True, max_length=50, null=True, verbose_name="short name"),
),
]
+1 -1
View File
@@ -114,7 +114,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=50, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
-113
View File
@@ -1,113 +0,0 @@
"""
Asynchronous PostHog context middleware for Django.
Since https://github.com/PostHog/posthog-python/commit/6af129f41413f4f7d55731763ff42e4c0fb66844
The official PostHog Django middleware supports both sync and async requests,
but the call to request.user fails in async contexts.
This is an horrible patch while waiting for an official fix.
Follow https://github.com/PostHog/posthog-python/issues/355
"""
from posthog import contexts
from posthog.integrations.django import PosthogContextMiddleware
class AsyncPosthogContextMiddleware(PosthogContextMiddleware):
"""
Asynchronous Django middleware to extract PostHog context from HTTP requests.
While the original PosthogContextMiddleware is supposed to manage both sync and async requests,
the call to request.user fails in async contexts.
"""
async def extract_tags_async(self, request):
"""Extract tags from the HTTP request asynchronously."""
tags = {}
(user_id, user_email) = await self.extract_request_user_async(request)
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
contexts.set_context_session(session_id)
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
contexts.identify_context(distinct_id)
# Extract user email
if user_email:
tags["email"] = user_email
# Extract current URL
absolute_url = request.build_absolute_uri()
if absolute_url:
tags["$current_url"] = absolute_url
# Extract request method
if request.method:
tags["$request_method"] = request.method
# Extract request path
if request.path:
tags["$request_path"] = request.path
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
if user_agent:
tags["$user_agent"] = user_agent
# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
if extra:
tags.update(extra)
# Apply tag mapping if configured
if self.tag_map:
tags = self.tag_map(tags)
return tags
async def extract_request_user_async(self, request):
"""Extract user ID and email from the HTTP request asynchronously."""
user_id = None
email = None
user = await request.auser()
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
pass
try:
email = str(user.email)
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
pass
return user_id, email
async def __acall__(self, request):
"""
Asynchronous entry point for async request handling.
This method is called when the middleware chain is async.
"""
if self.request_filter and not self.request_filter(request):
return await self.get_response(request)
with contexts.new_context(self.capture_exceptions, client=self.client):
tags = await self.extract_tags_async(request)
for k, v in tags.items():
contexts.tag(k, v)
return await self.get_response(request)
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -1,58 +0,0 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
@@ -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,18 +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.pk,
)
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")
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -86,7 +86,7 @@ msgstr "A utilisé le code d'activation"
#: 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 de Brevo"
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
@@ -272,7 +272,7 @@ msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-
#: 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 sous-groupe valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
msgstr "Saisissez un 'sub' valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:105 core/models.py:105
msgid "sub"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2025-10-27 08:29\n"
"PO-Revision-Date: 2025-12-15 13:49\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
+21 -20
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.7"
version = "0.0.10"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,41 +27,42 @@ requires-python = ">=3.12"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
"boto3==1.40.59",
"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.16",
"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.9",
"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.8.1",
"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.10",
"pydantic==2.12.3",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.6.0",
"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.42.1",
"semchunk==3.2.5",
"sentry-sdk==2.44.0",
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
@@ -81,20 +82,20 @@ dev = [
"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.9",
"pylint-pydantic==0.4.0",
"pytest-asyncio==1.2.0",
"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.2",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.7",
"version": "0.0.10",
"private": true,
"scripts": {
"dev": "next dev",
@@ -21,9 +21,10 @@
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.7.0",
"@openfun/cunningham-react": "3.1.0",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.26.0",
"@tanstack/react-query": "5.80.5",
@@ -36,16 +37,16 @@
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"lottie-react": "^2.4.1",
"luxon": "3.6.1",
"micromark-extension-llm-math": "3.1.1-20250610",
"next": "15.3.3",
"next": "15.3.8",
"posthog-js": "1.249.3",
"react": "*",
"react": "19.2.1",
"react-aria-components": "1.9.0",
"react-dom": "18.3.1",
"react-dom": "19.2.1",
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-lottie": "^1.2.10",
"react-markdown": "10.1.0",
"react-select": "5.10.1",
"rehype-katex": "7.0.1",
@@ -1,8 +1,8 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.9612 12.1078C43.9612 10.7446 43.9612 10.063 43.8197 9.44065C43.5582 8.29032 42.9454 7.24979 42.0663 6.46318C41.5907 6.03759 40.9946 5.707 39.8025 5.04582L36.7561 3.35621C35.6298 2.73158 35.0667 2.41926 34.4857 2.24352C33.4121 1.91883 32.2665 1.91883 31.1929 2.24352C30.6119 2.41926 30.0488 2.73158 28.9226 3.35621L27.6662 4.05301C26.1413 4.89875 25.3789 5.32161 25.0296 5.81385C24.3763 6.73448 24.3769 7.96763 25.0311 8.88764C25.3808 9.37954 26.1437 9.80168 27.6694 10.646L28.6864 11.2088C29.8804 11.8695 30.4774 12.1998 30.9538 12.6255C31.8343 13.4121 32.4482 14.4534 32.7101 15.6047C32.8519 16.2276 32.8519 16.9099 32.8519 18.2745V19.4391C32.8519 21.0927 32.8519 21.9195 33.1003 22.4525C33.5646 23.4487 34.595 24.0556 35.6913 23.9787C36.278 23.9376 37.0012 23.5368 38.4474 22.7351L39.8008 21.9849C40.9934 21.3238 41.5897 20.9933 42.0655 20.5677C42.945 19.7811 43.558 18.7404 43.8196 17.5898C43.9612 16.9673 43.9612 16.2855 43.9612 14.922V12.1078Z" fill="#3E5DE7"/>
<path d="M10.0512 32.3143C8.87068 32.9959 8.2804 33.3367 7.81215 33.7704C6.94669 34.572 6.35194 35.623 6.11028 36.7776C5.97953 37.4023 5.99126 38.0838 6.01473 39.4468L6.07469 42.9299C6.09685 44.2175 6.10794 44.8614 6.24627 45.4524C6.50185 46.5445 7.07465 47.5366 7.89263 48.304C8.33535 48.7194 8.88738 49.0509 9.99144 49.7139L11.2231 50.4535C12.7179 51.3513 13.4654 51.8001 14.0663 51.8565C15.1902 51.962 16.2579 51.3449 16.7275 50.3183C16.9787 49.7695 16.9628 48.8978 16.9311 47.1543L16.91 45.9921C16.8852 44.6277 16.8728 43.9456 17.0032 43.3202C17.2442 42.1643 17.839 41.1121 18.7051 40.3095C19.1737 39.8753 19.7646 39.5342 20.9464 38.8519L21.9549 38.2696C23.387 37.4428 24.103 37.0294 24.4404 36.5477C25.071 35.6475 25.0814 34.4518 24.4667 33.5407C24.1377 33.0532 23.429 32.6273 22.0116 31.7757L20.6853 30.9787C19.5165 30.2765 18.9321 29.9253 18.3256 29.726C17.2046 29.3577 15.9968 29.3471 14.8696 29.6959C14.2597 29.8845 13.6693 30.2254 12.4884 30.9072L10.0512 32.3143Z" fill="#3E5DE7"/>
<path d="M21.7175 51.8922C21.7175 53.2554 21.7175 53.937 21.8589 54.5594C22.1204 55.7097 22.7332 56.7502 23.6123 57.5368C24.088 57.9624 24.684 58.293 25.8762 58.9542L28.9226 60.6438C30.0488 61.2684 30.6119 61.5807 31.1929 61.7565C32.2665 62.0812 33.4121 62.0812 34.4857 61.7565C35.0667 61.5807 35.6298 61.2684 36.7561 60.6438L38.0124 59.947C39.5373 59.1012 40.2997 58.6784 40.649 58.1862C41.3023 57.2655 41.3017 56.0324 40.6476 55.1124C40.2978 54.6205 39.5349 54.1983 38.0093 53.354L36.9922 52.7912C35.7982 52.1305 35.2012 51.8002 34.7248 51.3745C33.8443 50.5879 33.2305 49.5466 32.9685 48.3953C32.8268 47.7724 32.8268 47.0901 32.8268 45.7255V44.5609C32.8268 42.9073 32.8268 42.0805 32.5783 41.5475C32.114 40.5513 31.0837 39.9444 29.9873 40.0213C29.4006 40.0624 28.6775 40.4632 27.2312 41.2649L25.8778 42.0151C24.6852 42.6761 24.089 43.0067 23.6131 43.4323C22.7336 44.2189 22.1206 45.2596 21.859 46.4102C21.7175 47.0327 21.7175 47.7145 21.7175 49.078V51.8922Z" fill="#3E5DE7"/>
<path d="M44.5055 51.5779C45.6861 52.2595 46.2764 52.6003 46.8861 52.789C48.0131 53.1377 49.2206 53.1273 50.3414 52.7592C50.9477 52.5601 51.5321 52.2092 52.7007 51.5074L55.6872 49.7139C56.7912 49.0509 57.3433 48.7194 57.786 48.304C58.604 47.5366 59.1768 46.5445 59.4324 45.4524C59.5707 44.8614 59.5818 44.2175 59.6039 42.9299L59.6287 41.4935C59.6587 39.75 59.6737 38.8783 59.422 38.3297C58.9514 37.3036 57.8832 36.6875 56.7593 36.794C56.1584 36.851 55.4114 37.3006 53.9174 38.1997L52.9215 38.7991C51.7523 39.5028 51.1677 39.8546 50.5609 40.0544C49.4394 40.4236 48.2307 40.4346 47.1027 40.0858C46.4924 39.8971 45.9015 39.5559 44.7197 38.8736L43.7111 38.2913C42.2791 37.4645 41.563 37.0511 40.9772 36.9998C39.8823 36.9038 38.8415 37.4926 38.3599 38.4806C38.1022 39.0092 38.0878 39.8359 38.0589 41.4892L38.0319 43.0364C38.0081 44.3997 37.9962 45.0814 38.1269 45.7062C38.3684 46.8612 38.9631 47.9125 39.8287 48.7143C40.2971 49.1481 40.8875 49.489 42.0684 50.1708L44.5055 51.5779Z" fill="#3E5DE7"/>
<path d="M55.6274 31.6857C56.8079 31.0041 57.3982 30.6633 57.8665 30.2296C58.7319 29.428 59.3267 28.377 59.5683 27.2224C59.6991 26.5977 59.6874 25.9162 59.6639 24.5532L59.6039 21.0701C59.5818 19.7825 59.5707 19.1386 59.4324 18.5476C59.1768 17.4555 58.604 16.4634 57.786 15.696C57.3433 15.2806 56.7912 14.9491 55.6872 14.2861L54.4556 13.5465C52.9607 12.6488 52.2133 12.1999 51.6123 12.1435C50.4884 12.038 49.4207 12.6551 48.9511 13.6817C48.7 14.2305 48.7158 15.1022 48.7475 16.8457L48.7686 18.0079C48.7934 19.3723 48.8058 20.0544 48.6754 20.6798C48.4344 21.8357 47.8396 22.8879 46.9735 23.6905C46.5049 24.1247 45.914 24.4658 44.7322 25.1481L43.7237 25.7304C42.2916 26.5572 41.5756 26.9706 41.2382 27.4523C40.6076 28.3525 40.5972 29.5482 41.2119 30.4593C41.5409 30.9468 42.2496 31.3727 43.667 32.2243L44.9934 33.0213C46.1622 33.7235 46.7466 34.0747 47.353 34.274C48.474 34.6423 49.6818 34.6529 50.809 34.3041C51.4189 34.1155 52.0094 33.7746 53.1902 33.0928L55.6274 31.6857Z" fill="#3E5DE7"/>
<path d="M21.173 12.4222C19.9925 11.7406 19.4022 11.3998 18.7924 11.2112C17.6655 10.8625 16.458 10.8728 15.3372 11.2409C14.7308 11.44 14.1465 11.7909 12.9778 12.4927L9.99135 14.2862C8.8873 14.9492 8.33527 15.2807 7.89255 15.6961C7.07457 16.4635 6.50177 17.4556 6.24619 18.5477C6.10786 19.1387 6.09677 19.7826 6.07461 21.0702L6.04988 22.5066C6.01987 24.2501 6.00486 25.1218 6.2565 25.6704C6.72713 26.6965 7.79538 27.3126 8.91921 27.2061C9.5201 27.1491 10.2671 26.6995 11.7611 25.8004L12.757 25.201C13.9262 24.4973 14.5108 24.1455 15.1176 23.9457C16.2392 23.5765 17.4478 23.5655 18.5759 23.9143C19.1862 24.103 19.7771 24.4442 20.9589 25.1265L21.9674 25.7088C23.3995 26.5356 24.1155 26.949 24.7014 27.0003C25.7962 27.0963 26.837 26.5075 27.3186 25.5195C27.5764 24.9909 27.5908 24.1642 27.6196 22.5109L27.6466 20.9638C27.6704 19.6004 27.6823 18.9187 27.5517 18.2939C27.3102 17.1389 26.7154 16.0876 25.8498 15.2858C25.3815 14.852 24.791 14.5111 23.6102 13.8293L21.173 12.4222Z" fill="#D3343F"/>
<path d="M43.9612 12.1078C43.9612 10.7446 43.9612 10.063 43.8197 9.44065C43.5582 8.29032 42.9454 7.24979 42.0663 6.46318C41.5907 6.03759 40.9946 5.707 39.8025 5.04582L36.7561 3.35621C35.6298 2.73158 35.0667 2.41926 34.4857 2.24352C33.4121 1.91883 32.2665 1.91883 31.1929 2.24352C30.6119 2.41926 30.0488 2.73158 28.9226 3.35621L27.6662 4.05301C26.1413 4.89875 25.3789 5.32161 25.0296 5.81385C24.3763 6.73448 24.3769 7.96763 25.0311 8.88764C25.3808 9.37954 26.1437 9.80168 27.6694 10.646L28.6864 11.2088C29.8804 11.8695 30.4774 12.1998 30.9538 12.6255C31.8343 13.4121 32.4482 14.4534 32.7101 15.6047C32.8519 16.2276 32.8519 16.9099 32.8519 18.2745V19.4391C32.8519 21.0927 32.8519 21.9195 33.1003 22.4525C33.5646 23.4487 34.595 24.0556 35.6913 23.9787C36.278 23.9376 37.0012 23.5368 38.4474 22.7351L39.8008 21.9849C40.9934 21.3238 41.5897 20.9933 42.0655 20.5677C42.945 19.7811 43.558 18.7404 43.8196 17.5898C43.9612 16.9673 43.9612 16.2855 43.9612 14.922V12.1078Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M10.0512 32.3143C8.87068 32.9959 8.2804 33.3367 7.81215 33.7704C6.94669 34.572 6.35194 35.623 6.11028 36.7776C5.97953 37.4023 5.99126 38.0838 6.01473 39.4468L6.07469 42.9299C6.09685 44.2175 6.10794 44.8614 6.24627 45.4524C6.50185 46.5445 7.07465 47.5366 7.89263 48.304C8.33535 48.7194 8.88738 49.0509 9.99144 49.7139L11.2231 50.4535C12.7179 51.3513 13.4654 51.8001 14.0663 51.8565C15.1902 51.962 16.2579 51.3449 16.7275 50.3183C16.9787 49.7695 16.9628 48.8978 16.9311 47.1543L16.91 45.9921C16.8852 44.6277 16.8728 43.9456 17.0032 43.3202C17.2442 42.1643 17.839 41.1121 18.7051 40.3095C19.1737 39.8753 19.7646 39.5342 20.9464 38.8519L21.9549 38.2696C23.387 37.4428 24.103 37.0294 24.4404 36.5477C25.071 35.6475 25.0814 34.4518 24.4667 33.5407C24.1377 33.0532 23.429 32.6273 22.0116 31.7757L20.6853 30.9787C19.5165 30.2765 18.9321 29.9253 18.3256 29.726C17.2046 29.3577 15.9968 29.3471 14.8696 29.6959C14.2597 29.8845 13.6693 30.2254 12.4884 30.9072L10.0512 32.3143Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M21.7175 51.8922C21.7175 53.2554 21.7175 53.937 21.8589 54.5594C22.1204 55.7097 22.7332 56.7502 23.6123 57.5368C24.088 57.9624 24.684 58.293 25.8762 58.9542L28.9226 60.6438C30.0488 61.2684 30.6119 61.5807 31.1929 61.7565C32.2665 62.0812 33.4121 62.0812 34.4857 61.7565C35.0667 61.5807 35.6298 61.2684 36.7561 60.6438L38.0124 59.947C39.5373 59.1012 40.2997 58.6784 40.649 58.1862C41.3023 57.2655 41.3017 56.0324 40.6476 55.1124C40.2978 54.6205 39.5349 54.1983 38.0093 53.354L36.9922 52.7912C35.7982 52.1305 35.2012 51.8002 34.7248 51.3745C33.8443 50.5879 33.2305 49.5466 32.9685 48.3953C32.8268 47.7724 32.8268 47.0901 32.8268 45.7255V44.5609C32.8268 42.9073 32.8268 42.0805 32.5783 41.5475C32.114 40.5513 31.0837 39.9444 29.9873 40.0213C29.4006 40.0624 28.6775 40.4632 27.2312 41.2649L25.8778 42.0151C24.6852 42.6761 24.089 43.0067 23.6131 43.4323C22.7336 44.2189 22.1206 45.2596 21.859 46.4102C21.7175 47.0327 21.7175 47.7145 21.7175 49.078V51.8922Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M44.5055 51.5779C45.6861 52.2595 46.2764 52.6003 46.8861 52.789C48.0131 53.1377 49.2206 53.1273 50.3414 52.7592C50.9477 52.5601 51.5321 52.2092 52.7007 51.5074L55.6872 49.7139C56.7912 49.0509 57.3433 48.7194 57.786 48.304C58.604 47.5366 59.1768 46.5445 59.4324 45.4524C59.5707 44.8614 59.5818 44.2175 59.6039 42.9299L59.6287 41.4935C59.6587 39.75 59.6737 38.8783 59.422 38.3297C58.9514 37.3036 57.8832 36.6875 56.7593 36.794C56.1584 36.851 55.4114 37.3006 53.9174 38.1997L52.9215 38.7991C51.7523 39.5028 51.1677 39.8546 50.5609 40.0544C49.4394 40.4236 48.2307 40.4346 47.1027 40.0858C46.4924 39.8971 45.9015 39.5559 44.7197 38.8736L43.7111 38.2913C42.2791 37.4645 41.563 37.0511 40.9772 36.9998C39.8823 36.9038 38.8415 37.4926 38.3599 38.4806C38.1022 39.0092 38.0878 39.8359 38.0589 41.4892L38.0319 43.0364C38.0081 44.3997 37.9962 45.0814 38.1269 45.7062C38.3684 46.8612 38.9631 47.9125 39.8287 48.7143C40.2971 49.1481 40.8875 49.489 42.0684 50.1708L44.5055 51.5779Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M55.6274 31.6857C56.8079 31.0041 57.3982 30.6633 57.8665 30.2296C58.7319 29.428 59.3267 28.377 59.5683 27.2224C59.6991 26.5977 59.6874 25.9162 59.6639 24.5532L59.6039 21.0701C59.5818 19.7825 59.5707 19.1386 59.4324 18.5476C59.1768 17.4555 58.604 16.4634 57.786 15.696C57.3433 15.2806 56.7912 14.9491 55.6872 14.2861L54.4556 13.5465C52.9607 12.6488 52.2133 12.1999 51.6123 12.1435C50.4884 12.038 49.4207 12.6551 48.9511 13.6817C48.7 14.2305 48.7158 15.1022 48.7475 16.8457L48.7686 18.0079C48.7934 19.3723 48.8058 20.0544 48.6754 20.6798C48.4344 21.8357 47.8396 22.8879 46.9735 23.6905C46.5049 24.1247 45.914 24.4658 44.7322 25.1481L43.7237 25.7304C42.2916 26.5572 41.5756 26.9706 41.2382 27.4523C40.6076 28.3525 40.5972 29.5482 41.2119 30.4593C41.5409 30.9468 42.2496 31.3727 43.667 32.2243L44.9934 33.0213C46.1622 33.7235 46.7466 34.0747 47.353 34.274C48.474 34.6423 49.6818 34.6529 50.809 34.3041C51.4189 34.1155 52.0094 33.7746 53.1902 33.0928L55.6274 31.6857Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M21.173 12.4222C19.9925 11.7406 19.4022 11.3998 18.7924 11.2112C17.6655 10.8625 16.458 10.8728 15.3372 11.2409C14.7308 11.44 14.1465 11.7909 12.9778 12.4927L9.99135 14.2862C8.8873 14.9492 8.33527 15.2807 7.89255 15.6961C7.07457 16.4635 6.50177 17.4556 6.24619 18.5477C6.10786 19.1387 6.09677 19.7826 6.07461 21.0702L6.04988 22.5066C6.01987 24.2501 6.00486 25.1218 6.2565 25.6704C6.72713 26.6965 7.79538 27.3126 8.91921 27.2061C9.5201 27.1491 10.2671 26.6995 11.7611 25.8004L12.757 25.201C13.9262 24.4973 14.5108 24.1455 15.1176 23.9457C16.2392 23.5765 17.4478 23.5655 18.5759 23.9143C19.1862 24.103 19.7771 24.4442 20.9589 25.1265L21.9674 25.7088C23.3995 26.5356 24.1155 26.949 24.7014 27.0003C25.7962 27.0963 26.837 26.5075 27.3186 25.5195C27.5764 24.9909 27.5908 24.1642 27.6196 22.5109L27.6466 20.9638C27.6704 19.6004 27.6823 18.9187 27.5517 18.2939C27.3102 17.1389 26.7154 16.0876 25.8498 15.2858C25.3815 14.852 24.791 14.5111 23.6102 13.8293L21.173 12.4222Z" fill="var(--c--globals--colors--logo-2)"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -1,3 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04601 20.8381C3.04306 20.8381 2.28465 20.5812 1.77079 20.0673C1.25693 19.5597 1 18.8105 1 17.82V6.51815C1 5.52139 1.25693 4.76917 1.77079 4.2615C2.28465 3.75383 3.04306 3.5 4.04601 3.5H19.9447C20.9538 3.5 21.7154 3.75383 22.2292 4.2615C22.7431 4.76917 23 5.52139 23 6.51815V17.82C23 18.8105 22.7431 19.5597 22.2292 20.0673C21.7154 20.5812 20.9538 20.8381 19.9447 20.8381H4.04601ZM4.15745 19.0087H19.8425C20.2697 19.0087 20.5979 18.8972 20.8269 18.6743C21.056 18.4515 21.1705 18.114 21.1705 17.6621V6.66674C21.1705 6.22098 21.056 5.88666 20.8269 5.66378C20.5979 5.4409 20.2697 5.32946 19.8425 5.32946H4.15745C3.72407 5.32946 3.39285 5.4409 3.16378 5.66378C2.9409 5.88666 2.82946 6.22098 2.82946 6.66674V17.6621C2.82946 18.114 2.9409 18.4515 3.16378 18.6743C3.39285 18.8972 3.72407 19.0087 4.15745 19.0087ZM8.38286 19.3058V5.01372H10.1195V19.3058H8.38286ZM6.56268 8.72837H4.65893C4.49796 8.72837 4.35556 8.66646 4.23174 8.54263C4.11411 8.41881 4.0553 8.28261 4.0553 8.13402C4.0553 7.97305 4.11411 7.83376 4.23174 7.71613C4.35556 7.59849 4.49796 7.53968 4.65893 7.53968H6.56268C6.72984 7.53968 6.87224 7.59849 6.98987 7.71613C7.11369 7.83376 7.1756 7.97305 7.1756 8.13402C7.1756 8.28261 7.11369 8.41881 6.98987 8.54263C6.87224 8.66646 6.72984 8.72837 6.56268 8.72837ZM6.56268 11.2172H4.65893C4.49796 11.2172 4.35556 11.1584 4.23174 11.0407C4.11411 10.9169 4.0553 10.7745 4.0553 10.6136C4.0553 10.4588 4.11411 10.3226 4.23174 10.2049C4.35556 10.0873 4.49796 10.0285 4.65893 10.0285H6.56268C6.72984 10.0285 6.87224 10.0873 6.98987 10.2049C7.11369 10.3226 7.1756 10.4588 7.1756 10.6136C7.1756 10.7745 7.11369 10.9169 6.98987 11.0407C6.87224 11.1584 6.72984 11.2172 6.56268 11.2172ZM6.56268 13.6967H4.65893C4.49796 13.6967 4.35556 13.6379 4.23174 13.5203C4.11411 13.4026 4.0553 13.2664 4.0553 13.1117C4.0553 12.9507 4.11411 12.8114 4.23174 12.6938C4.35556 12.5761 4.49796 12.5173 4.65893 12.5173H6.56268C6.72984 12.5173 6.87224 12.5761 6.98987 12.6938C7.11369 12.8114 7.1756 12.9507 7.1756 13.1117C7.1756 13.2664 7.11369 13.4026 6.98987 13.5203C6.87224 13.6379 6.72984 13.6967 6.56268 13.6967Z" fill="#3251E0"/>
<path d="M4.04601 20.8381C3.04306 20.8381 2.28465 20.5812 1.77079 20.0673C1.25693 19.5597 1 18.8105 1 17.82V6.51815C1 5.52139 1.25693 4.76917 1.77079 4.2615C2.28465 3.75383 3.04306 3.5 4.04601 3.5H19.9447C20.9538 3.5 21.7154 3.75383 22.2292 4.2615C22.7431 4.76917 23 5.52139 23 6.51815V17.82C23 18.8105 22.7431 19.5597 22.2292 20.0673C21.7154 20.5812 20.9538 20.8381 19.9447 20.8381H4.04601ZM4.15745 19.0087H19.8425C20.2697 19.0087 20.5979 18.8972 20.8269 18.6743C21.056 18.4515 21.1705 18.114 21.1705 17.6621V6.66674C21.1705 6.22098 21.056 5.88666 20.8269 5.66378C20.5979 5.4409 20.2697 5.32946 19.8425 5.32946H4.15745C3.72407 5.32946 3.39285 5.4409 3.16378 5.66378C2.9409 5.88666 2.82946 6.22098 2.82946 6.66674V17.6621C2.82946 18.114 2.9409 18.4515 3.16378 18.6743C3.39285 18.8972 3.72407 19.0087 4.15745 19.0087ZM8.38286 19.3058V5.01372H10.1195V19.3058H8.38286ZM6.56268 8.72837H4.65893C4.49796 8.72837 4.35556 8.66646 4.23174 8.54263C4.11411 8.41881 4.0553 8.28261 4.0553 8.13402C4.0553 7.97305 4.11411 7.83376 4.23174 7.71613C4.35556 7.59849 4.49796 7.53968 4.65893 7.53968H6.56268C6.72984 7.53968 6.87224 7.59849 6.98987 7.71613C7.11369 7.83376 7.1756 7.97305 7.1756 8.13402C7.1756 8.28261 7.11369 8.41881 6.98987 8.54263C6.87224 8.66646 6.72984 8.72837 6.56268 8.72837ZM6.56268 11.2172H4.65893C4.49796 11.2172 4.35556 11.1584 4.23174 11.0407C4.11411 10.9169 4.0553 10.7745 4.0553 10.6136C4.0553 10.4588 4.11411 10.3226 4.23174 10.2049C4.35556 10.0873 4.49796 10.0285 4.65893 10.0285H6.56268C6.72984 10.0285 6.87224 10.0873 6.98987 10.2049C7.11369 10.3226 7.1756 10.4588 7.1756 10.6136C7.1756 10.7745 7.11369 10.9169 6.98987 11.0407C6.87224 11.1584 6.72984 11.2172 6.56268 11.2172ZM6.56268 13.6967H4.65893C4.49796 13.6967 4.35556 13.6379 4.23174 13.5203C4.11411 13.4026 4.0553 13.2664 4.0553 13.1117C4.0553 12.9507 4.11411 12.8114 4.23174 12.6938C4.35556 12.5761 4.49796 12.5173 4.65893 12.5173H6.56268C6.72984 12.5173 6.87224 12.5761 6.98987 12.6938C7.11369 12.8114 7.1756 12.9507 7.1756 13.1117C7.1756 13.2664 7.11369 13.4026 6.98987 13.5203C6.87224 13.6379 6.72984 13.6967 6.56268 13.6967Z" fill="var(--c--contextuals--content--semantic--brand--tertiary)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,8 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.9612 12.1078C43.9612 10.7446 43.9612 10.063 43.8197 9.44065C43.5582 8.29032 42.9454 7.24979 42.0663 6.46318C41.5907 6.03759 40.9946 5.707 39.8025 5.04582L36.7561 3.35621C35.6298 2.73158 35.0667 2.41926 34.4857 2.24352C33.4121 1.91883 32.2665 1.91883 31.1929 2.24352C30.6119 2.41926 30.0488 2.73158 28.9226 3.35621L27.6662 4.05301C26.1413 4.89875 25.3789 5.32161 25.0296 5.81385C24.3763 6.73448 24.3769 7.96764 25.0311 8.88764C25.3808 9.37954 26.1437 9.80168 27.6694 10.646L28.6864 11.2088C29.8804 11.8695 30.4774 12.1998 30.9538 12.6255C31.8343 13.4121 32.4482 14.4534 32.7101 15.6047C32.8519 16.2276 32.8519 16.9099 32.8519 18.2745V19.4391C32.8519 21.0927 32.8519 21.9195 33.1003 22.4525C33.5646 23.4487 34.595 24.0556 35.6913 23.9787C36.278 23.9376 37.0012 23.5368 38.4474 22.7351L39.8008 21.9849C40.9934 21.3238 41.5897 20.9933 42.0655 20.5677C42.945 19.7811 43.558 18.7404 43.8196 17.5898C43.9612 16.9673 43.9612 16.2855 43.9612 14.922V12.1078Z" fill="#2845C1"/>
<path d="M10.0512 32.3143C8.87068 32.9959 8.2804 33.3367 7.81215 33.7704C6.94669 34.572 6.35194 35.623 6.11028 36.7776C5.97953 37.4023 5.99126 38.0838 6.01473 39.4468L6.07469 42.9299C6.09685 44.2175 6.10794 44.8614 6.24627 45.4524C6.50185 46.5445 7.07465 47.5366 7.89263 48.304C8.33535 48.7194 8.88738 49.0509 9.99144 49.7139L11.2231 50.4535C12.7179 51.3512 13.4654 51.8001 14.0663 51.8565C15.1902 51.962 16.2579 51.3449 16.7275 50.3183C16.9787 49.7695 16.9628 48.8978 16.9311 47.1543L16.91 45.9921C16.8852 44.6277 16.8728 43.9456 17.0032 43.3202C17.2442 42.1643 17.839 41.1121 18.7051 40.3095C19.1737 39.8753 19.7646 39.5342 20.9464 38.8519L21.9549 38.2696C23.387 37.4428 24.103 37.0294 24.4404 36.5477C25.071 35.6475 25.0814 34.4518 24.4667 33.5407C24.1377 33.0532 23.429 32.6273 22.0116 31.7757L20.6853 30.9787C19.5165 30.2765 18.9321 29.9253 18.3256 29.726C17.2046 29.3577 15.9968 29.3471 14.8696 29.6959C14.2597 29.8845 13.6693 30.2254 12.4884 30.9072L10.0512 32.3143Z" fill="#2845C1"/>
<path d="M21.7175 51.8922C21.7175 53.2554 21.7175 53.937 21.8589 54.5594C22.1204 55.7097 22.7332 56.7502 23.6123 57.5368C24.088 57.9624 24.684 58.293 25.8762 58.9542L28.9226 60.6438C30.0488 61.2684 30.6119 61.5807 31.1929 61.7565C32.2665 62.0812 33.4121 62.0812 34.4857 61.7565C35.0667 61.5807 35.6298 61.2684 36.7561 60.6438L38.0124 59.947C39.5373 59.1012 40.2997 58.6784 40.649 58.1862C41.3023 57.2655 41.3017 56.0324 40.6476 55.1124C40.2978 54.6205 39.5349 54.1983 38.0093 53.354L36.9922 52.7912C35.7982 52.1305 35.2012 51.8002 34.7248 51.3745C33.8443 50.5879 33.2305 49.5466 32.9685 48.3953C32.8268 47.7724 32.8268 47.0901 32.8268 45.7255V44.5609C32.8268 42.9073 32.8268 42.0805 32.5783 41.5475C32.114 40.5513 31.0837 39.9444 29.9873 40.0213C29.4006 40.0624 28.6775 40.4632 27.2312 41.2649L25.8778 42.0151C24.6852 42.6761 24.089 43.0067 23.6131 43.4323C22.7336 44.2189 22.1206 45.2596 21.859 46.4102C21.7175 47.0327 21.7175 47.7145 21.7175 49.078V51.8922Z" fill="#2845C1"/>
<path d="M44.5055 51.5779C45.6861 52.2595 46.2764 52.6003 46.8861 52.789C48.0131 53.1376 49.2206 53.1273 50.3414 52.7592C50.9478 52.5601 51.5321 52.2092 52.7007 51.5074L55.6872 49.7139C56.7912 49.0509 57.3433 48.7194 57.786 48.304C58.604 47.5366 59.1768 46.5445 59.4324 45.4524C59.5707 44.8614 59.5818 44.2175 59.6039 42.9299L59.6287 41.4935C59.6587 39.75 59.6737 38.8783 59.422 38.3297C58.9514 37.3036 57.8832 36.6875 56.7593 36.794C56.1584 36.851 55.4114 37.3006 53.9174 38.1997L52.9215 38.7991C51.7523 39.5028 51.1677 39.8546 50.5609 40.0544C49.4394 40.4236 48.2307 40.4346 47.1027 40.0858C46.4924 39.8971 45.9015 39.5559 44.7197 38.8736L43.7111 38.2913C42.2791 37.4645 41.563 37.0511 40.9772 36.9998C39.8823 36.9038 38.8415 37.4926 38.3599 38.4806C38.1022 39.0092 38.0878 39.8359 38.0589 41.4892L38.0319 43.0364C38.0081 44.3997 37.9962 45.0814 38.1269 45.7062C38.3684 46.8612 38.9631 47.9125 39.8287 48.7143C40.2971 49.1481 40.8875 49.489 42.0684 50.1708L44.5055 51.5779Z" fill="#2845C1"/>
<path d="M55.6274 31.6857C56.8079 31.0041 57.3982 30.6633 57.8665 30.2296C58.7319 29.428 59.3267 28.3771 59.5683 27.2224C59.6991 26.5977 59.6874 25.9162 59.6639 24.5532L59.6039 21.0701C59.5818 19.7825 59.5707 19.1386 59.4324 18.5476C59.1768 17.4555 58.604 16.4634 57.786 15.696C57.3433 15.2806 56.7912 14.9491 55.6872 14.2861L54.4556 13.5465C52.9607 12.6488 52.2133 12.1999 51.6123 12.1435C50.4884 12.038 49.4207 12.6551 48.9511 13.6817C48.7 14.2305 48.7158 15.1022 48.7475 16.8457L48.7686 18.0079C48.7934 19.3723 48.8058 20.0544 48.6754 20.6798C48.4344 21.8357 47.8396 22.8879 46.9735 23.6905C46.5049 24.1247 45.914 24.4658 44.7322 25.1481L43.7237 25.7304C42.2916 26.5572 41.5756 26.9706 41.2382 27.4523C40.6076 28.3525 40.5972 29.5482 41.2119 30.4593C41.5409 30.9468 42.2496 31.3727 43.667 32.2243L44.9934 33.0213C46.1622 33.7235 46.7466 34.0747 47.353 34.274C48.474 34.6423 49.6818 34.6529 50.809 34.3041C51.4189 34.1155 52.0094 33.7746 53.1902 33.0928L55.6274 31.6857Z" fill="#2845C1"/>
<path d="M21.173 12.4222C19.9925 11.7406 19.4022 11.3998 18.7924 11.2112C17.6655 10.8625 16.458 10.8728 15.3372 11.2409C14.7308 11.44 14.1465 11.7909 12.9778 12.4927L9.99135 14.2862C8.8873 14.9492 8.33527 15.2807 7.89255 15.6961C7.07457 16.4635 6.50177 17.4556 6.24619 18.5477C6.10786 19.1387 6.09677 19.7826 6.07461 21.0702L6.04988 22.5066C6.01987 24.2501 6.00486 25.1218 6.2565 25.6704C6.72713 26.6965 7.79538 27.3126 8.91921 27.2061C9.5201 27.1491 10.2671 26.6995 11.7611 25.8004L12.757 25.201C13.9262 24.4973 14.5108 24.1455 15.1176 23.9457C16.2392 23.5765 17.4478 23.5655 18.5759 23.9143C19.1862 24.103 19.7771 24.4442 20.9589 25.1265L21.9674 25.7088C23.3995 26.5356 24.1155 26.949 24.7014 27.0003C25.7962 27.0963 26.837 26.5075 27.3186 25.5195C27.5764 24.9909 27.5908 24.1642 27.6196 22.5109L27.6466 20.9638C27.6704 19.6004 27.6823 18.9187 27.5517 18.2939C27.3102 17.1389 26.7154 16.0876 25.8498 15.2858C25.3815 14.852 24.791 14.5111 23.6102 13.8293L21.173 12.4222Z" fill="#C83F49"/>
<path d="M43.9612 12.1078C43.9612 10.7446 43.9612 10.063 43.8197 9.44065C43.5582 8.29032 42.9454 7.24979 42.0663 6.46318C41.5907 6.03759 40.9946 5.707 39.8025 5.04582L36.7561 3.35621C35.6298 2.73158 35.0667 2.41926 34.4857 2.24352C33.4121 1.91883 32.2665 1.91883 31.1929 2.24352C30.6119 2.41926 30.0488 2.73158 28.9226 3.35621L27.6662 4.05301C26.1413 4.89875 25.3789 5.32161 25.0296 5.81385C24.3763 6.73448 24.3769 7.96764 25.0311 8.88764C25.3808 9.37954 26.1437 9.80168 27.6694 10.646L28.6864 11.2088C29.8804 11.8695 30.4774 12.1998 30.9538 12.6255C31.8343 13.4121 32.4482 14.4534 32.7101 15.6047C32.8519 16.2276 32.8519 16.9099 32.8519 18.2745V19.4391C32.8519 21.0927 32.8519 21.9195 33.1003 22.4525C33.5646 23.4487 34.595 24.0556 35.6913 23.9787C36.278 23.9376 37.0012 23.5368 38.4474 22.7351L39.8008 21.9849C40.9934 21.3238 41.5897 20.9933 42.0655 20.5677C42.945 19.7811 43.558 18.7404 43.8196 17.5898C43.9612 16.9673 43.9612 16.2855 43.9612 14.922V12.1078Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M10.0512 32.3143C8.87068 32.9959 8.2804 33.3367 7.81215 33.7704C6.94669 34.572 6.35194 35.623 6.11028 36.7776C5.97953 37.4023 5.99126 38.0838 6.01473 39.4468L6.07469 42.9299C6.09685 44.2175 6.10794 44.8614 6.24627 45.4524C6.50185 46.5445 7.07465 47.5366 7.89263 48.304C8.33535 48.7194 8.88738 49.0509 9.99144 49.7139L11.2231 50.4535C12.7179 51.3512 13.4654 51.8001 14.0663 51.8565C15.1902 51.962 16.2579 51.3449 16.7275 50.3183C16.9787 49.7695 16.9628 48.8978 16.9311 47.1543L16.91 45.9921C16.8852 44.6277 16.8728 43.9456 17.0032 43.3202C17.2442 42.1643 17.839 41.1121 18.7051 40.3095C19.1737 39.8753 19.7646 39.5342 20.9464 38.8519L21.9549 38.2696C23.387 37.4428 24.103 37.0294 24.4404 36.5477C25.071 35.6475 25.0814 34.4518 24.4667 33.5407C24.1377 33.0532 23.429 32.6273 22.0116 31.7757L20.6853 30.9787C19.5165 30.2765 18.9321 29.9253 18.3256 29.726C17.2046 29.3577 15.9968 29.3471 14.8696 29.6959C14.2597 29.8845 13.6693 30.2254 12.4884 30.9072L10.0512 32.3143Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M21.7175 51.8922C21.7175 53.2554 21.7175 53.937 21.8589 54.5594C22.1204 55.7097 22.7332 56.7502 23.6123 57.5368C24.088 57.9624 24.684 58.293 25.8762 58.9542L28.9226 60.6438C30.0488 61.2684 30.6119 61.5807 31.1929 61.7565C32.2665 62.0812 33.4121 62.0812 34.4857 61.7565C35.0667 61.5807 35.6298 61.2684 36.7561 60.6438L38.0124 59.947C39.5373 59.1012 40.2997 58.6784 40.649 58.1862C41.3023 57.2655 41.3017 56.0324 40.6476 55.1124C40.2978 54.6205 39.5349 54.1983 38.0093 53.354L36.9922 52.7912C35.7982 52.1305 35.2012 51.8002 34.7248 51.3745C33.8443 50.5879 33.2305 49.5466 32.9685 48.3953C32.8268 47.7724 32.8268 47.0901 32.8268 45.7255V44.5609C32.8268 42.9073 32.8268 42.0805 32.5783 41.5475C32.114 40.5513 31.0837 39.9444 29.9873 40.0213C29.4006 40.0624 28.6775 40.4632 27.2312 41.2649L25.8778 42.0151C24.6852 42.6761 24.089 43.0067 23.6131 43.4323C22.7336 44.2189 22.1206 45.2596 21.859 46.4102C21.7175 47.0327 21.7175 47.7145 21.7175 49.078V51.8922Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M44.5055 51.5779C45.6861 52.2595 46.2764 52.6003 46.8861 52.789C48.0131 53.1376 49.2206 53.1273 50.3414 52.7592C50.9478 52.5601 51.5321 52.2092 52.7007 51.5074L55.6872 49.7139C56.7912 49.0509 57.3433 48.7194 57.786 48.304C58.604 47.5366 59.1768 46.5445 59.4324 45.4524C59.5707 44.8614 59.5818 44.2175 59.6039 42.9299L59.6287 41.4935C59.6587 39.75 59.6737 38.8783 59.422 38.3297C58.9514 37.3036 57.8832 36.6875 56.7593 36.794C56.1584 36.851 55.4114 37.3006 53.9174 38.1997L52.9215 38.7991C51.7523 39.5028 51.1677 39.8546 50.5609 40.0544C49.4394 40.4236 48.2307 40.4346 47.1027 40.0858C46.4924 39.8971 45.9015 39.5559 44.7197 38.8736L43.7111 38.2913C42.2791 37.4645 41.563 37.0511 40.9772 36.9998C39.8823 36.9038 38.8415 37.4926 38.3599 38.4806C38.1022 39.0092 38.0878 39.8359 38.0589 41.4892L38.0319 43.0364C38.0081 44.3997 37.9962 45.0814 38.1269 45.7062C38.3684 46.8612 38.9631 47.9125 39.8287 48.7143C40.2971 49.1481 40.8875 49.489 42.0684 50.1708L44.5055 51.5779Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M55.6274 31.6857C56.8079 31.0041 57.3982 30.6633 57.8665 30.2296C58.7319 29.428 59.3267 28.3771 59.5683 27.2224C59.6991 26.5977 59.6874 25.9162 59.6639 24.5532L59.6039 21.0701C59.5818 19.7825 59.5707 19.1386 59.4324 18.5476C59.1768 17.4555 58.604 16.4634 57.786 15.696C57.3433 15.2806 56.7912 14.9491 55.6872 14.2861L54.4556 13.5465C52.9607 12.6488 52.2133 12.1999 51.6123 12.1435C50.4884 12.038 49.4207 12.6551 48.9511 13.6817C48.7 14.2305 48.7158 15.1022 48.7475 16.8457L48.7686 18.0079C48.7934 19.3723 48.8058 20.0544 48.6754 20.6798C48.4344 21.8357 47.8396 22.8879 46.9735 23.6905C46.5049 24.1247 45.914 24.4658 44.7322 25.1481L43.7237 25.7304C42.2916 26.5572 41.5756 26.9706 41.2382 27.4523C40.6076 28.3525 40.5972 29.5482 41.2119 30.4593C41.5409 30.9468 42.2496 31.3727 43.667 32.2243L44.9934 33.0213C46.1622 33.7235 46.7466 34.0747 47.353 34.274C48.474 34.6423 49.6818 34.6529 50.809 34.3041C51.4189 34.1155 52.0094 33.7746 53.1902 33.0928L55.6274 31.6857Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M21.173 12.4222C19.9925 11.7406 19.4022 11.3998 18.7924 11.2112C17.6655 10.8625 16.458 10.8728 15.3372 11.2409C14.7308 11.44 14.1465 11.7909 12.9778 12.4927L9.99135 14.2862C8.8873 14.9492 8.33527 15.2807 7.89255 15.6961C7.07457 16.4635 6.50177 17.4556 6.24619 18.5477C6.10786 19.1387 6.09677 19.7826 6.07461 21.0702L6.04988 22.5066C6.01987 24.2501 6.00486 25.1218 6.2565 25.6704C6.72713 26.6965 7.79538 27.3126 8.91921 27.2061C9.5201 27.1491 10.2671 26.6995 11.7611 25.8004L12.757 25.201C13.9262 24.4973 14.5108 24.1455 15.1176 23.9457C16.2392 23.5765 17.4478 23.5655 18.5759 23.9143C19.1862 24.103 19.7771 24.4442 20.9589 25.1265L21.9674 25.7088C23.3995 26.5356 24.1155 26.949 24.7014 27.0003C25.7962 27.0963 26.837 26.5075 27.3186 25.5195C27.5764 24.9909 27.5908 24.1642 27.6196 22.5109L27.6466 20.9638C27.6704 19.6004 27.6823 18.9187 27.5517 18.2939C27.3102 17.1389 26.7154 16.0876 25.8498 15.2858C25.3815 14.852 24.791 14.5111 23.6102 13.8293L21.173 12.4222Z" fill="var(--c--globals--colors--logo-2)"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -1,21 +1,21 @@
<svg width="183" height="41" viewBox="0 0 183 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.9806 10.5539C25.9806 9.87231 25.9806 9.53151 25.9098 9.22032C25.7791 8.64516 25.4727 8.12489 25.0331 7.73159C24.7953 7.51879 24.4973 7.3535 23.9012 7.02291L22.378 6.1781C21.8149 5.86579 21.5334 5.70963 21.2428 5.62176C20.7061 5.45941 20.1333 5.45941 19.5965 5.62176C19.3059 5.70963 19.0244 5.86579 18.4613 6.1781L17.8331 6.52651C17.0707 6.94937 16.6894 7.16081 16.5148 7.40692C16.1881 7.86724 16.1884 8.48382 16.5155 8.94382C16.6904 9.18977 17.0718 9.40084 17.8347 9.82298L18.3432 10.1044C18.9402 10.4347 19.2387 10.5999 19.4769 10.8127C19.9172 11.2061 20.2241 11.7267 20.3551 12.3023C20.4259 12.6138 20.4259 12.9549 20.4259 13.6372V14.2195C20.4259 15.0463 20.4259 15.4597 20.5502 15.7263C20.7823 16.2243 21.2975 16.5278 21.8457 16.4894C22.139 16.4688 22.5006 16.2684 23.2237 15.8675L23.9004 15.4925C24.4967 15.1619 24.7948 14.9967 25.0327 14.7839C25.4725 14.3905 25.779 13.8702 25.9098 13.2949C25.9806 12.9837 25.9806 12.6428 25.9806 11.961V10.5539Z" fill="#2845C1"/>
<path d="M9.02562 20.6571C8.43534 20.9979 8.1402 21.1683 7.90608 21.3852C7.47335 21.786 7.17597 22.3115 7.05514 22.8888C6.98977 23.2012 6.99563 23.5419 7.00736 24.2234L7.03734 25.9649C7.04843 26.6088 7.05397 26.9307 7.12313 27.2262C7.25092 27.7723 7.53733 28.2683 7.94632 28.652C8.16767 28.8597 8.44369 29.0254 8.99572 29.357L9.61153 29.7268C10.359 30.1756 10.7327 30.4001 11.0331 30.4282C11.5951 30.481 12.1289 30.1724 12.3638 29.6592C12.4893 29.3847 12.4814 28.9489 12.4656 28.0772L12.455 27.4961C12.4426 26.8139 12.4364 26.4728 12.5016 26.1601C12.6221 25.5821 12.9195 25.056 13.3526 24.6548C13.5869 24.4377 13.8823 24.2671 14.4732 23.9259L14.9775 23.6348C15.6935 23.2214 16.0515 23.0147 16.2202 22.7739C16.5355 22.3238 16.5407 21.7259 16.2333 21.2703C16.0689 21.0266 15.7145 20.8137 15.0058 20.3878L14.3426 19.9894C13.7582 19.6382 13.466 19.4627 13.1628 19.363C12.6023 19.1788 11.9984 19.1736 11.4348 19.3479C11.1298 19.4423 10.8346 19.6127 10.2442 19.9536L9.02562 20.6571Z" fill="#2845C1"/>
<path d="M14.8587 30.4461C14.8587 31.1277 14.8587 31.4685 14.9295 31.7797C15.0602 32.3548 15.3666 32.8751 15.8062 33.2684C16.044 33.4812 16.342 33.6465 16.9381 33.9771L18.4613 34.8219C19.0244 35.1342 19.306 35.2904 19.5965 35.3782C20.1333 35.5406 20.7061 35.5406 21.2428 35.3782C21.5334 35.2904 21.8149 35.1342 22.378 34.8219L23.0062 34.4735C23.7686 34.0506 24.1499 33.8392 24.3245 33.5931C24.6512 33.1328 24.6509 32.5162 24.3238 32.0562C24.1489 31.8102 23.7675 31.5992 23.0046 31.177L22.4961 30.8956C21.8991 30.5653 21.6006 30.4001 21.3624 30.1873C20.9222 29.7939 20.6152 29.2733 20.4842 28.6977C20.4134 28.3862 20.4134 28.0451 20.4134 27.3627V26.7805C20.4134 25.9537 20.4134 25.5403 20.2892 25.2737C20.057 24.7757 19.5418 24.4722 18.9936 24.5106C18.7003 24.5312 18.3387 24.7316 17.6156 25.1325L16.9389 25.5075C16.3426 25.8381 16.0445 26.0033 15.8066 26.2161C15.3668 26.6095 15.0603 27.1298 14.9295 27.7051C14.8587 28.0163 14.8587 28.3572 14.8587 29.039V30.4461Z" fill="#2845C1"/>
<path d="M26.2528 30.2889C26.843 30.6297 27.1382 30.8001 27.4431 30.8945C28.0065 31.0688 28.6103 31.0636 29.1707 30.8796C29.4739 30.78 29.766 30.6046 30.3504 30.2537L31.8436 29.3569C32.3956 29.0254 32.6716 28.8597 32.893 28.652C33.302 28.2683 33.5884 27.7723 33.7162 27.2262C33.7853 26.9307 33.7909 26.6088 33.802 25.9649L33.8143 25.2467C33.8293 24.375 33.8368 23.9391 33.711 23.6648C33.4757 23.1518 32.9416 22.8437 32.3797 22.897C32.0792 22.9255 31.7057 23.1503 30.9587 23.5999L30.4608 23.8995C29.8762 24.2514 29.5839 24.4273 29.2805 24.5272C28.7197 24.7118 28.1154 24.7173 27.5513 24.5429C27.2462 24.4485 26.9507 24.278 26.3598 23.9368L25.8556 23.6457C25.1395 23.2323 24.7815 23.0256 24.4886 22.9999C23.9411 22.9519 23.4208 23.2463 23.18 23.7403C23.0511 24.0046 23.0439 24.4179 23.0294 25.2446L23.0159 26.0182C23.004 26.6998 22.9981 27.0407 23.0634 27.3531C23.1842 27.9306 23.4816 28.4562 23.9144 28.8572C24.1485 29.0741 24.4438 29.2445 25.0342 29.5854L26.2528 30.2889Z" fill="#2845C1"/>
<path d="M31.8137 20.3429C32.404 20.0021 32.6991 19.8317 32.9332 19.6148C33.366 19.214 33.6633 18.6885 33.7842 18.1112C33.8495 17.7988 33.8437 17.4581 33.8319 16.7766L33.802 15.0351C33.7909 14.3912 33.7853 14.0693 33.7162 13.7738C33.5884 13.2277 33.302 12.7317 32.893 12.348C32.6716 12.1403 32.3956 11.9746 31.8436 11.6431L31.2278 11.2732C30.4804 10.8244 30.1066 10.5999 29.8062 10.5718C29.2442 10.519 28.7104 10.8276 28.4755 11.3408C28.35 11.6153 28.3579 12.0511 28.3737 12.9228L28.3843 13.5039C28.3967 14.1861 28.4029 14.5272 28.3377 14.8399C28.2172 15.4179 27.9198 15.944 27.4867 16.3452C27.2524 16.5623 26.957 16.7329 26.3661 17.0741L25.8618 17.3652C25.1458 17.7786 24.7878 17.9853 24.6191 18.2261C24.3038 18.6762 24.2986 19.2741 24.606 19.7297C24.7704 19.9734 25.1248 20.1863 25.8335 20.6122L26.4967 21.0106C27.0811 21.3618 27.3733 21.5373 27.6765 21.637C28.237 21.8212 28.8409 21.8264 29.4045 21.6521C29.7095 21.5577 30.0047 21.3873 30.5951 21.0464L31.8137 20.3429Z" fill="#2845C1"/>
<path d="M14.5863 10.7111C13.996 10.3703 13.7008 10.1999 13.396 10.1056C12.8325 9.93123 12.2287 9.93642 11.6683 10.1204C11.3652 10.22 11.073 10.3955 10.4887 10.7464L8.99543 11.6431C8.4434 11.9746 8.16739 12.1404 7.94603 12.348C7.53704 12.7317 7.25064 13.2278 7.12285 13.7738C7.05368 14.0694 7.04814 14.3913 7.03706 15.0351L7.0247 15.7533C7.00969 16.6251 7.00219 17.0609 7.128 17.3352C7.36332 17.8483 7.89744 18.1563 8.45936 18.103C8.75981 18.0746 9.13331 17.8498 9.88031 17.4002L10.3783 17.1005C10.9629 16.7487 11.2552 16.5728 11.5586 16.4729C12.1193 16.2883 12.7237 16.2828 13.2877 16.4572C13.5929 16.5515 13.8883 16.7221 14.4792 17.0632L14.9835 17.3544C15.6995 17.7678 16.0575 17.9745 16.3504 18.0002C16.8979 18.0482 17.4183 17.7537 17.6591 17.2598C17.7879 16.9955 17.7951 16.5821 17.8096 15.7554L17.8231 14.9819C17.835 14.3002 17.8409 13.9594 17.7756 13.6469C17.6548 13.0694 17.3575 12.5438 16.9247 12.1429C16.6905 11.926 16.3953 11.7555 15.8048 11.4146L14.5863 10.7111Z" fill="#C83F49"/>
<path d="M61.8577 16.0576C62.6931 16.0576 63.468 16.24 64.1819 16.6045C64.8463 16.9367 65.3785 17.3509 65.7781 17.8471C65.8437 17.9285 65.8294 18.0466 65.7498 18.1145L64.3459 19.3122C64.2574 19.3877 64.1236 19.3717 64.0503 19.2814C63.7909 18.9622 63.4929 18.6926 63.1565 18.4727C62.7616 18.2145 62.3285 18.085 61.8577 18.085C61.5237 18.085 61.2353 18.1462 60.9924 18.2676C60.7646 18.3891 60.5895 18.5486 60.468 18.7461C60.3617 18.9436 60.3088 19.1642 60.3088 19.4072C60.3089 19.7716 60.4375 20.0754 60.6955 20.3184C60.969 20.5462 61.319 20.744 61.7444 20.9111C62.1696 21.0782 62.6174 21.2606 63.0881 21.458C63.5742 21.6403 64.03 21.8682 64.4553 22.1416C64.8806 22.3998 65.2225 22.7489 65.4807 23.1895C65.7541 23.6147 65.8908 24.1537 65.8909 24.8066C65.8909 25.5812 65.7012 26.2423 65.3215 26.7891C64.957 27.3206 64.471 27.7309 63.8635 28.0195C63.2559 28.3082 62.5868 28.4531 61.8577 28.4531C60.8706 28.4531 60.0124 28.2707 59.2834 27.9062C58.6116 27.5563 58.0171 27.0962 57.5 26.5269C57.4264 26.4459 57.4354 26.3208 57.5176 26.2485L58.921 25.0135C59.0067 24.9381 59.1377 24.9498 59.2123 25.0363C59.5425 25.4194 59.9081 25.7384 60.3088 25.9922C60.7645 26.2807 61.2733 26.4248 61.8352 26.4248C62.3669 26.4248 62.7698 26.2881 63.0432 26.0146C63.3316 25.7261 63.4758 25.3768 63.4758 24.9668C63.4758 24.5871 63.339 24.2835 63.0657 24.0557C62.8074 23.8278 62.4656 23.63 62.0403 23.4629C61.6149 23.2958 61.1592 23.1208 60.6731 22.9385C60.2024 22.7411 59.7545 22.5057 59.3293 22.2324C58.9041 21.959 58.5539 21.6098 58.2805 21.1846C58.0223 20.744 57.8938 20.1816 57.8938 19.498C57.8938 18.8601 58.0606 18.283 58.3948 17.7666C58.729 17.2349 59.192 16.8165 59.7844 16.5127C60.3919 16.209 61.0831 16.0577 61.8577 16.0576Z" fill="#2845C1"/>
<path d="M71.3215 16.0576C72.1569 16.0577 72.9319 16.24 73.6457 16.6045C74.3101 16.9367 74.8424 17.3509 75.2419 17.8471C75.3075 17.9285 75.2933 18.0466 75.2137 18.1144L73.8098 19.3122C73.7213 19.3877 73.5875 19.3717 73.5141 19.2813C73.2548 18.9622 72.9568 18.6927 72.6204 18.4727C72.2255 18.2145 71.7923 18.085 71.3215 18.085C70.9873 18.085 70.6984 18.1461 70.4553 18.2676C70.2275 18.3891 70.0524 18.5486 69.9309 18.7461C69.8246 18.9435 69.7717 19.1642 69.7717 19.4072C69.7718 19.7717 69.9013 20.0754 70.1594 20.3184C70.4328 20.5462 70.782 20.7441 71.2073 20.9111C71.6326 21.0782 72.0811 21.2605 72.552 21.458C73.0379 21.6403 73.494 21.8682 73.9192 22.1416C74.3443 22.3998 74.6864 22.7491 74.9446 23.1895C75.2178 23.6146 75.3547 24.1538 75.3547 24.8066C75.3547 25.5813 75.1641 26.2422 74.7844 26.7891C74.4199 27.3207 73.934 27.7309 73.3264 28.0195C72.7189 28.3081 72.0506 28.4531 71.3215 28.4531C70.3341 28.4531 69.4755 28.2708 68.7463 27.9062C68.0745 27.5563 67.48 27.0962 66.9629 26.5269C66.8893 26.4459 66.8983 26.3208 66.9805 26.2485L68.3839 25.0135C68.4696 24.9381 68.6006 24.9498 68.6752 25.0363C69.0054 25.4194 69.371 25.7384 69.7717 25.9922C70.2273 26.2807 70.7362 26.4247 71.2981 26.4248C71.8298 26.4248 72.2327 26.2881 72.5061 26.0146C72.7946 25.7261 72.9387 25.3768 72.9387 24.9668C72.9387 24.587 72.802 24.2835 72.5286 24.0557C72.2703 23.8278 71.9285 23.63 71.5032 23.4629C71.0779 23.2958 70.622 23.1207 70.136 22.9385C69.6653 22.7411 69.2174 22.5057 68.7922 22.2324C68.3671 21.9591 68.0178 21.6097 67.7444 21.1846C67.4861 20.744 67.3567 20.1816 67.3567 19.498C67.3567 18.8602 67.5236 18.283 67.8577 17.7666C68.1918 17.235 68.655 16.8165 69.2473 16.5127C69.8549 16.2089 70.5468 16.0576 71.3215 16.0576Z" fill="#2845C1"/>
<path d="M86.3313 16.0576C87.1668 16.0576 87.9416 16.2399 88.6555 16.6045C89.32 16.9368 89.8521 17.3508 90.2517 17.8471C90.3173 17.9285 90.303 18.0466 90.2235 18.1144L88.8195 19.3122C88.731 19.3877 88.5973 19.3717 88.5239 19.2814C88.2646 18.9622 87.9666 18.6926 87.6301 18.4727C87.2352 18.2144 86.8022 18.085 86.3313 18.085C85.9971 18.085 85.7081 18.1461 85.4651 18.2676C85.2374 18.3891 85.0631 18.5488 84.9416 18.7461C84.8353 18.9436 84.7815 19.1642 84.7815 19.4072C84.7816 19.7716 84.911 20.0754 85.1692 20.3184C85.4426 20.5462 85.7918 20.7441 86.217 20.9111C86.6424 21.0782 87.0909 21.2605 87.5618 21.458C88.0478 21.6403 88.5036 21.8682 88.9289 22.1416C89.3542 22.3998 89.6961 22.749 89.9543 23.1895C90.2276 23.6147 90.3644 24.1537 90.3645 24.8066C90.3645 25.5811 90.1747 26.2423 89.7952 26.7891C89.4306 27.3207 88.9438 27.7309 88.3362 28.0195C87.7286 28.3081 87.0604 28.4531 86.3313 28.4531C85.3439 28.4531 84.4852 28.2708 83.7561 27.9062C83.0843 27.5563 82.4905 27.0961 81.9736 26.5269C81.9 26.4459 81.909 26.3208 81.9911 26.2485L83.3937 25.0136C83.4793 24.9381 83.6104 24.9498 83.685 25.0363C84.0152 25.4194 84.3808 25.7384 84.7815 25.9922C85.2372 26.2808 85.7468 26.4248 86.3088 26.4248C86.8403 26.4248 87.2425 26.288 87.5159 26.0146C87.8044 25.7261 87.9494 25.3769 87.9495 24.9668C87.9495 24.5872 87.8125 24.2835 87.5393 24.0557C87.2811 23.8279 86.9391 23.63 86.5139 23.4629C86.0886 23.2958 85.6328 23.1207 85.1467 22.9385C84.6758 22.741 84.2273 22.5059 83.802 22.2324C83.3768 21.9591 83.0275 21.6097 82.7541 21.1846C82.4959 20.744 82.3664 20.1816 82.3664 19.498C82.3664 18.86 82.5342 18.2831 82.8684 17.7666C83.2026 17.2349 83.6656 16.8165 84.258 16.5127C84.8656 16.209 85.5567 16.0576 86.3313 16.0576Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.191 16.2861C107.239 16.2861 108.128 16.4757 108.857 16.8555C109.586 17.2352 110.133 17.7594 110.498 18.4277C110.877 19.0961 111.067 19.8481 111.067 20.6836V28.0256C111.067 28.136 110.977 28.2256 110.867 28.2256H108.76C108.65 28.2256 108.56 28.136 108.56 28.0256V27.2662C108.56 27.1718 108.438 27.1271 108.372 27.1948C108.27 27.2997 108.159 27.4002 108.037 27.4961C107.626 27.8303 107.148 28.0892 106.601 28.2715C106.054 28.4689 105.5 28.5673 104.938 28.5674C103.814 28.5674 102.894 28.2639 102.18 27.6562C101.466 27.0486 101.11 26.2355 101.11 25.2178C101.11 24.352 101.436 23.6154 102.089 23.0078C102.743 22.4002 103.684 21.9973 104.915 21.7998L108.394 21.2089C108.49 21.1926 108.56 21.1093 108.56 21.0118V20.6836C108.56 20.0002 108.333 19.4686 107.877 19.0889C107.422 18.694 106.829 18.4962 106.1 18.4961C105.508 18.4961 104.968 18.6256 104.482 18.8838C104.071 19.1094 103.717 19.4102 103.422 19.7865C103.351 19.876 103.222 19.8965 103.132 19.8273L101.606 18.6598C101.52 18.5942 101.501 18.4719 101.568 18.3865C102.052 17.7617 102.666 17.2665 103.411 16.9014C104.231 16.4912 105.158 16.2861 106.191 16.2861ZM105.553 23.4639C104.87 23.5702 104.376 23.7671 104.072 24.0557C103.768 24.329 103.616 24.6784 103.616 25.1035C103.616 25.5288 103.775 25.8789 104.094 26.1523C104.428 26.4258 104.877 26.5625 105.439 26.5625C106.138 26.5625 106.753 26.403 107.285 26.084C107.721 25.8222 108.09 25.4882 108.393 25.083C108.508 24.9295 108.56 24.7399 108.56 24.5483V23.2008C108.56 23.0772 108.449 22.9832 108.327 23.0035L105.553 23.4639Z" fill="#2845C1"/>
<path d="M95.9868 16.5412C95.9868 16.6517 96.0764 16.7412 96.1868 16.7412H99.5017C99.6121 16.7412 99.7017 16.8308 99.7017 16.9412V18.7971C99.7017 18.9075 99.6121 18.9971 99.5017 18.9971H96.1868C96.0764 18.9971 95.9868 19.0866 95.9868 19.1971V24.1699C95.9868 24.671 96.0708 25.0659 96.2378 25.3545C96.4048 25.6429 96.6403 25.8555 96.9439 25.9922C97.2477 26.1137 97.605 26.1748 98.0151 26.1748C98.4101 26.1748 98.7446 26.152 99.0181 26.1064C99.1739 26.0718 99.3174 26.0347 99.4485 25.9951C99.5829 25.9545 99.7241 26.0522 99.7241 26.1926V27.9814C99.7241 28.0604 99.6778 28.1326 99.6042 28.1613C99.3605 28.2561 99.0888 28.3233 98.7896 28.3623C98.4554 28.423 98.0605 28.4531 97.605 28.4531C96.6936 28.4531 95.926 28.2936 95.3032 27.9746C94.6959 27.6404 94.2325 27.1617 93.9136 26.5391C93.6099 25.9012 93.4585 25.1115 93.4585 24.1699V19.1971C93.4585 19.0866 93.369 18.9971 93.2585 18.9971H91.6087C91.4982 18.9971 91.4087 18.9075 91.4087 18.7971V16.9412C91.4087 16.8308 91.4982 16.7412 91.6087 16.7412H93.2585C93.369 16.7412 93.4585 16.6517 93.4585 16.5412V14.0711C93.4585 13.9606 93.548 13.8711 93.6585 13.8711H95.7868C95.8973 13.8711 95.9868 13.9606 95.9868 14.0711V16.5412Z" fill="#2845C1"/>
<path d="M130.563 16.5412C130.563 16.6517 130.653 16.7412 130.763 16.7412H134.077C134.187 16.7412 134.277 16.8308 134.277 16.9412V18.7971C134.277 18.9075 134.187 18.9971 134.077 18.9971H130.763C130.653 18.9971 130.563 19.0866 130.563 19.1971V24.1699C130.563 24.671 130.646 25.0659 130.813 25.3545C130.98 25.6431 131.216 25.8555 131.52 25.9922C131.824 26.1137 132.18 26.1748 132.59 26.1748C132.985 26.1748 133.32 26.152 133.593 26.1064C133.749 26.0718 133.893 26.0347 134.024 25.9951C134.158 25.9545 134.299 26.0522 134.299 26.1925V27.9813C134.299 28.0604 134.253 28.1326 134.179 28.1613C133.936 28.2561 133.665 28.3233 133.366 28.3623C133.032 28.4231 132.636 28.4531 132.18 28.4531C131.269 28.4531 130.502 28.2936 129.879 27.9746C129.272 27.6404 128.809 27.1619 128.49 26.5391C128.186 25.9011 128.034 25.1116 128.034 24.1699V19.1971C128.034 19.0866 127.944 18.9971 127.834 18.9971H126.092C125.982 18.9971 125.892 18.9075 125.892 18.7971V16.9412C125.892 16.8308 125.982 16.7412 126.092 16.7412H127.834C127.944 16.7412 128.034 16.6517 128.034 16.5412V14.0711C128.034 13.9606 128.123 13.8711 128.234 13.8711H130.363C130.473 13.8711 130.563 13.9606 130.563 14.0711V16.5412Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.8209 27.9534C56.8716 28.0844 56.7749 28.2256 56.6344 28.2256H54.0801C53.9969 28.2256 53.9224 28.1741 53.893 28.0964L52.3355 23.9798C52.3061 23.902 52.2316 23.8506 52.1485 23.8506H45.6707C45.5876 23.8506 45.5132 23.9019 45.4837 23.9796L43.9196 28.0966C43.8901 28.1743 43.8157 28.2256 43.7326 28.2256H41.2002C41.0598 28.2256 40.9631 28.0846 41.0136 27.9536L47.0107 12.4034C47.0404 12.3263 47.1146 12.2754 47.1973 12.2754H50.6144C50.697 12.2754 50.7711 12.3262 50.8009 12.4032L56.8209 27.9534ZM46.5361 21.2104C46.4864 21.3413 46.5831 21.4814 46.7231 21.4814H51.1006C51.2405 21.4814 51.3372 21.3415 51.2876 21.2106L49.1032 15.4395C49.038 15.2673 48.7946 15.2672 48.7292 15.4392L46.5361 21.2104Z" fill="#2845C1"/>
<path d="M80.1372 28.0256C80.1372 28.136 80.0477 28.2256 79.9372 28.2256H77.8314C77.7209 28.2256 77.6314 28.136 77.6314 28.0256V16.9412C77.6314 16.8308 77.7209 16.7412 77.8314 16.7412H79.9372C80.0477 16.7412 80.1372 16.8308 80.1372 16.9412V28.0256Z" fill="#2845C1"/>
<path d="M120.009 16.2861C120.86 16.2861 121.619 16.4758 122.288 16.8555C122.971 17.2352 123.511 17.7977 123.906 18.542C124.301 19.2863 124.499 20.2208 124.499 21.3447V28.0256C124.499 28.136 124.409 28.2256 124.299 28.2256H122.169C122.059 28.2256 121.969 28.136 121.969 28.0256V21.4355C121.969 20.5546 121.741 19.8709 121.286 19.3848C120.83 18.8835 120.206 18.6328 119.417 18.6328C118.915 18.6329 118.475 18.7313 118.095 18.9287C117.715 19.111 117.389 19.3471 117.116 19.6357C116.919 19.8551 116.745 20.0831 116.592 20.3199C116.498 20.4662 116.455 20.6384 116.455 20.8124V28.0256C116.455 28.136 116.365 28.2256 116.255 28.2256H114.149C114.038 28.2256 113.949 28.136 113.949 28.0256V16.9412C113.949 16.8308 114.038 16.7412 114.149 16.7412H116.255C116.365 16.7412 116.455 16.8308 116.455 16.9412V17.5392C116.455 17.6294 116.566 17.6747 116.631 17.6128C117.008 17.2571 117.443 16.9588 117.936 16.7188C118.528 16.4302 119.219 16.2861 120.009 16.2861Z" fill="#2845C1"/>
<path d="M78.9067 11C79.2105 11 79.484 11.0757 79.7271 11.2275C79.9853 11.3794 80.1831 11.5845 80.3198 11.8428C80.4717 12.0858 80.5474 12.3593 80.5474 12.6631C80.5474 12.9668 80.4716 13.2477 80.3198 13.5059C80.1831 13.7641 79.9853 13.9692 79.7271 14.1211C79.484 14.2578 79.2106 14.3262 78.9067 14.3262C78.603 14.3262 78.3222 14.2578 78.064 14.1211C77.8057 13.9692 77.6006 13.7641 77.4487 13.5059C77.2969 13.2477 77.2212 12.9668 77.2212 12.6631C77.2212 12.2074 77.3807 11.82 77.6997 11.501C78.0338 11.167 78.436 11 78.9067 11Z" fill="#2845C1"/>
<rect x="143" y="12.5" width="40" height="16" rx="8" fill="#2845C1"/>
<path d="M25.9806 10.5539C25.9806 9.87231 25.9806 9.53151 25.9098 9.22032C25.7791 8.64516 25.4727 8.12489 25.0331 7.73159C24.7953 7.51879 24.4973 7.3535 23.9012 7.02291L22.378 6.1781C21.8149 5.86579 21.5334 5.70963 21.2428 5.62176C20.7061 5.45941 20.1333 5.45941 19.5965 5.62176C19.3059 5.70963 19.0244 5.86579 18.4613 6.1781L17.8331 6.52651C17.0707 6.94937 16.6894 7.16081 16.5148 7.40692C16.1881 7.86724 16.1884 8.48382 16.5155 8.94382C16.6904 9.18977 17.0718 9.40084 17.8347 9.82298L18.3432 10.1044C18.9402 10.4347 19.2387 10.5999 19.4769 10.8127C19.9172 11.2061 20.2241 11.7267 20.3551 12.3023C20.4259 12.6138 20.4259 12.9549 20.4259 13.6372V14.2195C20.4259 15.0463 20.4259 15.4597 20.5502 15.7263C20.7823 16.2243 21.2975 16.5278 21.8457 16.4894C22.139 16.4688 22.5006 16.2684 23.2237 15.8675L23.9004 15.4925C24.4967 15.1619 24.7948 14.9967 25.0327 14.7839C25.4725 14.3905 25.779 13.8702 25.9098 13.2949C25.9806 12.9837 25.9806 12.6428 25.9806 11.961V10.5539Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M9.02562 20.6571C8.43534 20.9979 8.1402 21.1683 7.90608 21.3852C7.47335 21.786 7.17597 22.3115 7.05514 22.8888C6.98977 23.2012 6.99563 23.5419 7.00736 24.2234L7.03734 25.9649C7.04843 26.6088 7.05397 26.9307 7.12313 27.2262C7.25092 27.7723 7.53733 28.2683 7.94632 28.652C8.16767 28.8597 8.44369 29.0254 8.99572 29.357L9.61153 29.7268C10.359 30.1756 10.7327 30.4001 11.0331 30.4282C11.5951 30.481 12.1289 30.1724 12.3638 29.6592C12.4893 29.3847 12.4814 28.9489 12.4656 28.0772L12.455 27.4961C12.4426 26.8139 12.4364 26.4728 12.5016 26.1601C12.6221 25.5821 12.9195 25.056 13.3526 24.6548C13.5869 24.4377 13.8823 24.2671 14.4732 23.9259L14.9775 23.6348C15.6935 23.2214 16.0515 23.0147 16.2202 22.7739C16.5355 22.3238 16.5407 21.7259 16.2333 21.2703C16.0689 21.0266 15.7145 20.8137 15.0058 20.3878L14.3426 19.9894C13.7582 19.6382 13.466 19.4627 13.1628 19.363C12.6023 19.1788 11.9984 19.1736 11.4348 19.3479C11.1298 19.4423 10.8346 19.6127 10.2442 19.9536L9.02562 20.6571Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M14.8587 30.4461C14.8587 31.1277 14.8587 31.4685 14.9295 31.7797C15.0602 32.3548 15.3666 32.8751 15.8062 33.2684C16.044 33.4812 16.342 33.6465 16.9381 33.9771L18.4613 34.8219C19.0244 35.1342 19.306 35.2904 19.5965 35.3782C20.1333 35.5406 20.7061 35.5406 21.2428 35.3782C21.5334 35.2904 21.8149 35.1342 22.378 34.8219L23.0062 34.4735C23.7686 34.0506 24.1499 33.8392 24.3245 33.5931C24.6512 33.1328 24.6509 32.5162 24.3238 32.0562C24.1489 31.8102 23.7675 31.5992 23.0046 31.177L22.4961 30.8956C21.8991 30.5653 21.6006 30.4001 21.3624 30.1873C20.9222 29.7939 20.6152 29.2733 20.4842 28.6977C20.4134 28.3862 20.4134 28.0451 20.4134 27.3627V26.7805C20.4134 25.9537 20.4134 25.5403 20.2892 25.2737C20.057 24.7757 19.5418 24.4722 18.9936 24.5106C18.7003 24.5312 18.3387 24.7316 17.6156 25.1325L16.9389 25.5075C16.3426 25.8381 16.0445 26.0033 15.8066 26.2161C15.3668 26.6095 15.0603 27.1298 14.9295 27.7051C14.8587 28.0163 14.8587 28.3572 14.8587 29.039V30.4461Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M26.2528 30.2889C26.843 30.6297 27.1382 30.8001 27.4431 30.8945C28.0065 31.0688 28.6103 31.0636 29.1707 30.8796C29.4739 30.78 29.766 30.6046 30.3504 30.2537L31.8436 29.3569C32.3956 29.0254 32.6716 28.8597 32.893 28.652C33.302 28.2683 33.5884 27.7723 33.7162 27.2262C33.7853 26.9307 33.7909 26.6088 33.802 25.9649L33.8143 25.2467C33.8293 24.375 33.8368 23.9391 33.711 23.6648C33.4757 23.1518 32.9416 22.8437 32.3797 22.897C32.0792 22.9255 31.7057 23.1503 30.9587 23.5999L30.4608 23.8995C29.8762 24.2514 29.5839 24.4273 29.2805 24.5272C28.7197 24.7118 28.1154 24.7173 27.5513 24.5429C27.2462 24.4485 26.9507 24.278 26.3598 23.9368L25.8556 23.6457C25.1395 23.2323 24.7815 23.0256 24.4886 22.9999C23.9411 22.9519 23.4208 23.2463 23.18 23.7403C23.0511 24.0046 23.0439 24.4179 23.0294 25.2446L23.0159 26.0182C23.004 26.6998 22.9981 27.0407 23.0634 27.3531C23.1842 27.9306 23.4816 28.4562 23.9144 28.8572C24.1485 29.0741 24.4438 29.2445 25.0342 29.5854L26.2528 30.2889Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M31.8137 20.3429C32.404 20.0021 32.6991 19.8317 32.9332 19.6148C33.366 19.214 33.6633 18.6885 33.7842 18.1112C33.8495 17.7988 33.8437 17.4581 33.8319 16.7766L33.802 15.0351C33.7909 14.3912 33.7853 14.0693 33.7162 13.7738C33.5884 13.2277 33.302 12.7317 32.893 12.348C32.6716 12.1403 32.3956 11.9746 31.8436 11.6431L31.2278 11.2732C30.4804 10.8244 30.1066 10.5999 29.8062 10.5718C29.2442 10.519 28.7104 10.8276 28.4755 11.3408C28.35 11.6153 28.3579 12.0511 28.3737 12.9228L28.3843 13.5039C28.3967 14.1861 28.4029 14.5272 28.3377 14.8399C28.2172 15.4179 27.9198 15.944 27.4867 16.3452C27.2524 16.5623 26.957 16.7329 26.3661 17.0741L25.8618 17.3652C25.1458 17.7786 24.7878 17.9853 24.6191 18.2261C24.3038 18.6762 24.2986 19.2741 24.606 19.7297C24.7704 19.9734 25.1248 20.1863 25.8335 20.6122L26.4967 21.0106C27.0811 21.3618 27.3733 21.5373 27.6765 21.637C28.237 21.8212 28.8409 21.8264 29.4045 21.6521C29.7095 21.5577 30.0047 21.3873 30.5951 21.0464L31.8137 20.3429Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M14.5863 10.7111C13.996 10.3703 13.7008 10.1999 13.396 10.1056C12.8325 9.93123 12.2287 9.93642 11.6683 10.1204C11.3652 10.22 11.073 10.3955 10.4887 10.7464L8.99543 11.6431C8.4434 11.9746 8.16739 12.1404 7.94603 12.348C7.53704 12.7317 7.25064 13.2278 7.12285 13.7738C7.05368 14.0694 7.04814 14.3913 7.03706 15.0351L7.0247 15.7533C7.00969 16.6251 7.00219 17.0609 7.128 17.3352C7.36332 17.8483 7.89744 18.1563 8.45936 18.103C8.75981 18.0746 9.13331 17.8498 9.88031 17.4002L10.3783 17.1005C10.9629 16.7487 11.2552 16.5728 11.5586 16.4729C12.1193 16.2883 12.7237 16.2828 13.2877 16.4572C13.5929 16.5515 13.8883 16.7221 14.4792 17.0632L14.9835 17.3544C15.6995 17.7678 16.0575 17.9745 16.3504 18.0002C16.8979 18.0482 17.4183 17.7537 17.6591 17.2598C17.7879 16.9955 17.7951 16.5821 17.8096 15.7554L17.8231 14.9819C17.835 14.3002 17.8409 13.9594 17.7756 13.6469C17.6548 13.0694 17.3575 12.5438 16.9247 12.1429C16.6905 11.926 16.3953 11.7555 15.8048 11.4146L14.5863 10.7111Z" fill="var(--c--contextuals--background--semantic--neutral--tertiary)"/>
<path d="M61.8577 16.0576C62.6931 16.0576 63.468 16.24 64.1819 16.6045C64.8463 16.9367 65.3785 17.3509 65.7781 17.8471C65.8437 17.9285 65.8294 18.0466 65.7498 18.1145L64.3459 19.3122C64.2574 19.3877 64.1236 19.3717 64.0503 19.2814C63.7909 18.9622 63.4929 18.6926 63.1565 18.4727C62.7616 18.2145 62.3285 18.085 61.8577 18.085C61.5237 18.085 61.2353 18.1462 60.9924 18.2676C60.7646 18.3891 60.5895 18.5486 60.468 18.7461C60.3617 18.9436 60.3088 19.1642 60.3088 19.4072C60.3089 19.7716 60.4375 20.0754 60.6955 20.3184C60.969 20.5462 61.319 20.744 61.7444 20.9111C62.1696 21.0782 62.6174 21.2606 63.0881 21.458C63.5742 21.6403 64.03 21.8682 64.4553 22.1416C64.8806 22.3998 65.2225 22.7489 65.4807 23.1895C65.7541 23.6147 65.8908 24.1537 65.8909 24.8066C65.8909 25.5812 65.7012 26.2423 65.3215 26.7891C64.957 27.3206 64.471 27.7309 63.8635 28.0195C63.2559 28.3082 62.5868 28.4531 61.8577 28.4531C60.8706 28.4531 60.0124 28.2707 59.2834 27.9062C58.6116 27.5563 58.0171 27.0962 57.5 26.5269C57.4264 26.4459 57.4354 26.3208 57.5176 26.2485L58.921 25.0135C59.0067 24.9381 59.1377 24.9498 59.2123 25.0363C59.5425 25.4194 59.9081 25.7384 60.3088 25.9922C60.7645 26.2807 61.2733 26.4248 61.8352 26.4248C62.3669 26.4248 62.7698 26.2881 63.0432 26.0146C63.3316 25.7261 63.4758 25.3768 63.4758 24.9668C63.4758 24.5871 63.339 24.2835 63.0657 24.0557C62.8074 23.8278 62.4656 23.63 62.0403 23.4629C61.6149 23.2958 61.1592 23.1208 60.6731 22.9385C60.2024 22.7411 59.7545 22.5057 59.3293 22.2324C58.9041 21.959 58.5539 21.6098 58.2805 21.1846C58.0223 20.744 57.8938 20.1816 57.8938 19.498C57.8938 18.8601 58.0606 18.283 58.3948 17.7666C58.729 17.2349 59.192 16.8165 59.7844 16.5127C60.3919 16.209 61.0831 16.0577 61.8577 16.0576Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M71.3215 16.0576C72.1569 16.0577 72.9319 16.24 73.6457 16.6045C74.3101 16.9367 74.8424 17.3509 75.2419 17.8471C75.3075 17.9285 75.2933 18.0466 75.2137 18.1144L73.8098 19.3122C73.7213 19.3877 73.5875 19.3717 73.5141 19.2813C73.2548 18.9622 72.9568 18.6927 72.6204 18.4727C72.2255 18.2145 71.7923 18.085 71.3215 18.085C70.9873 18.085 70.6984 18.1461 70.4553 18.2676C70.2275 18.3891 70.0524 18.5486 69.9309 18.7461C69.8246 18.9435 69.7717 19.1642 69.7717 19.4072C69.7718 19.7717 69.9013 20.0754 70.1594 20.3184C70.4328 20.5462 70.782 20.7441 71.2073 20.9111C71.6326 21.0782 72.0811 21.2605 72.552 21.458C73.0379 21.6403 73.494 21.8682 73.9192 22.1416C74.3443 22.3998 74.6864 22.7491 74.9446 23.1895C75.2178 23.6146 75.3547 24.1538 75.3547 24.8066C75.3547 25.5813 75.1641 26.2422 74.7844 26.7891C74.4199 27.3207 73.934 27.7309 73.3264 28.0195C72.7189 28.3081 72.0506 28.4531 71.3215 28.4531C70.3341 28.4531 69.4755 28.2708 68.7463 27.9062C68.0745 27.5563 67.48 27.0962 66.9629 26.5269C66.8893 26.4459 66.8983 26.3208 66.9805 26.2485L68.3839 25.0135C68.4696 24.9381 68.6006 24.9498 68.6752 25.0363C69.0054 25.4194 69.371 25.7384 69.7717 25.9922C70.2273 26.2807 70.7362 26.4247 71.2981 26.4248C71.8298 26.4248 72.2327 26.2881 72.5061 26.0146C72.7946 25.7261 72.9387 25.3768 72.9387 24.9668C72.9387 24.587 72.802 24.2835 72.5286 24.0557C72.2703 23.8278 71.9285 23.63 71.5032 23.4629C71.0779 23.2958 70.622 23.1207 70.136 22.9385C69.6653 22.7411 69.2174 22.5057 68.7922 22.2324C68.3671 21.9591 68.0178 21.6097 67.7444 21.1846C67.4861 20.744 67.3567 20.1816 67.3567 19.498C67.3567 18.8602 67.5236 18.283 67.8577 17.7666C68.1918 17.235 68.655 16.8165 69.2473 16.5127C69.8549 16.2089 70.5468 16.0576 71.3215 16.0576Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M86.3313 16.0576C87.1668 16.0576 87.9416 16.2399 88.6555 16.6045C89.32 16.9368 89.8521 17.3508 90.2517 17.8471C90.3173 17.9285 90.303 18.0466 90.2235 18.1144L88.8195 19.3122C88.731 19.3877 88.5973 19.3717 88.5239 19.2814C88.2646 18.9622 87.9666 18.6926 87.6301 18.4727C87.2352 18.2144 86.8022 18.085 86.3313 18.085C85.9971 18.085 85.7081 18.1461 85.4651 18.2676C85.2374 18.3891 85.0631 18.5488 84.9416 18.7461C84.8353 18.9436 84.7815 19.1642 84.7815 19.4072C84.7816 19.7716 84.911 20.0754 85.1692 20.3184C85.4426 20.5462 85.7918 20.7441 86.217 20.9111C86.6424 21.0782 87.0909 21.2605 87.5618 21.458C88.0478 21.6403 88.5036 21.8682 88.9289 22.1416C89.3542 22.3998 89.6961 22.749 89.9543 23.1895C90.2276 23.6147 90.3644 24.1537 90.3645 24.8066C90.3645 25.5811 90.1747 26.2423 89.7952 26.7891C89.4306 27.3207 88.9438 27.7309 88.3362 28.0195C87.7286 28.3081 87.0604 28.4531 86.3313 28.4531C85.3439 28.4531 84.4852 28.2708 83.7561 27.9062C83.0843 27.5563 82.4905 27.0961 81.9736 26.5269C81.9 26.4459 81.909 26.3208 81.9911 26.2485L83.3937 25.0136C83.4793 24.9381 83.6104 24.9498 83.685 25.0363C84.0152 25.4194 84.3808 25.7384 84.7815 25.9922C85.2372 26.2808 85.7468 26.4248 86.3088 26.4248C86.8403 26.4248 87.2425 26.288 87.5159 26.0146C87.8044 25.7261 87.9494 25.3769 87.9495 24.9668C87.9495 24.5872 87.8125 24.2835 87.5393 24.0557C87.2811 23.8279 86.9391 23.63 86.5139 23.4629C86.0886 23.2958 85.6328 23.1207 85.1467 22.9385C84.6758 22.741 84.2273 22.5059 83.802 22.2324C83.3768 21.9591 83.0275 21.6097 82.7541 21.1846C82.4959 20.744 82.3664 20.1816 82.3664 19.498C82.3664 18.86 82.5342 18.2831 82.8684 17.7666C83.2026 17.2349 83.6656 16.8165 84.258 16.5127C84.8656 16.209 85.5567 16.0576 86.3313 16.0576Z" fill="var(--c--globals--colors--logo-1)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.191 16.2861C107.239 16.2861 108.128 16.4757 108.857 16.8555C109.586 17.2352 110.133 17.7594 110.498 18.4277C110.877 19.0961 111.067 19.8481 111.067 20.6836V28.0256C111.067 28.136 110.977 28.2256 110.867 28.2256H108.76C108.65 28.2256 108.56 28.136 108.56 28.0256V27.2662C108.56 27.1718 108.438 27.1271 108.372 27.1948C108.27 27.2997 108.159 27.4002 108.037 27.4961C107.626 27.8303 107.148 28.0892 106.601 28.2715C106.054 28.4689 105.5 28.5673 104.938 28.5674C103.814 28.5674 102.894 28.2639 102.18 27.6562C101.466 27.0486 101.11 26.2355 101.11 25.2178C101.11 24.352 101.436 23.6154 102.089 23.0078C102.743 22.4002 103.684 21.9973 104.915 21.7998L108.394 21.2089C108.49 21.1926 108.56 21.1093 108.56 21.0118V20.6836C108.56 20.0002 108.333 19.4686 107.877 19.0889C107.422 18.694 106.829 18.4962 106.1 18.4961C105.508 18.4961 104.968 18.6256 104.482 18.8838C104.071 19.1094 103.717 19.4102 103.422 19.7865C103.351 19.876 103.222 19.8965 103.132 19.8273L101.606 18.6598C101.52 18.5942 101.501 18.4719 101.568 18.3865C102.052 17.7617 102.666 17.2665 103.411 16.9014C104.231 16.4912 105.158 16.2861 106.191 16.2861ZM105.553 23.4639C104.87 23.5702 104.376 23.7671 104.072 24.0557C103.768 24.329 103.616 24.6784 103.616 25.1035C103.616 25.5288 103.775 25.8789 104.094 26.1523C104.428 26.4258 104.877 26.5625 105.439 26.5625C106.138 26.5625 106.753 26.403 107.285 26.084C107.721 25.8222 108.09 25.4882 108.393 25.083C108.508 24.9295 108.56 24.7399 108.56 24.5483V23.2008C108.56 23.0772 108.449 22.9832 108.327 23.0035L105.553 23.4639Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M95.9868 16.5412C95.9868 16.6517 96.0764 16.7412 96.1868 16.7412H99.5017C99.6121 16.7412 99.7017 16.8308 99.7017 16.9412V18.7971C99.7017 18.9075 99.6121 18.9971 99.5017 18.9971H96.1868C96.0764 18.9971 95.9868 19.0866 95.9868 19.1971V24.1699C95.9868 24.671 96.0708 25.0659 96.2378 25.3545C96.4048 25.6429 96.6403 25.8555 96.9439 25.9922C97.2477 26.1137 97.605 26.1748 98.0151 26.1748C98.4101 26.1748 98.7446 26.152 99.0181 26.1064C99.1739 26.0718 99.3174 26.0347 99.4485 25.9951C99.5829 25.9545 99.7241 26.0522 99.7241 26.1926V27.9814C99.7241 28.0604 99.6778 28.1326 99.6042 28.1613C99.3605 28.2561 99.0888 28.3233 98.7896 28.3623C98.4554 28.423 98.0605 28.4531 97.605 28.4531C96.6936 28.4531 95.926 28.2936 95.3032 27.9746C94.6959 27.6404 94.2325 27.1617 93.9136 26.5391C93.6099 25.9012 93.4585 25.1115 93.4585 24.1699V19.1971C93.4585 19.0866 93.369 18.9971 93.2585 18.9971H91.6087C91.4982 18.9971 91.4087 18.9075 91.4087 18.7971V16.9412C91.4087 16.8308 91.4982 16.7412 91.6087 16.7412H93.2585C93.369 16.7412 93.4585 16.6517 93.4585 16.5412V14.0711C93.4585 13.9606 93.548 13.8711 93.6585 13.8711H95.7868C95.8973 13.8711 95.9868 13.9606 95.9868 14.0711V16.5412Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M130.563 16.5412C130.563 16.6517 130.653 16.7412 130.763 16.7412H134.077C134.187 16.7412 134.277 16.8308 134.277 16.9412V18.7971C134.277 18.9075 134.187 18.9971 134.077 18.9971H130.763C130.653 18.9971 130.563 19.0866 130.563 19.1971V24.1699C130.563 24.671 130.646 25.0659 130.813 25.3545C130.98 25.6431 131.216 25.8555 131.52 25.9922C131.824 26.1137 132.18 26.1748 132.59 26.1748C132.985 26.1748 133.32 26.152 133.593 26.1064C133.749 26.0718 133.893 26.0347 134.024 25.9951C134.158 25.9545 134.299 26.0522 134.299 26.1925V27.9813C134.299 28.0604 134.253 28.1326 134.179 28.1613C133.936 28.2561 133.665 28.3233 133.366 28.3623C133.032 28.4231 132.636 28.4531 132.18 28.4531C131.269 28.4531 130.502 28.2936 129.879 27.9746C129.272 27.6404 128.809 27.1619 128.49 26.5391C128.186 25.9011 128.034 25.1116 128.034 24.1699V19.1971C128.034 19.0866 127.944 18.9971 127.834 18.9971H126.092C125.982 18.9971 125.892 18.9075 125.892 18.7971V16.9412C125.892 16.8308 125.982 16.7412 126.092 16.7412H127.834C127.944 16.7412 128.034 16.6517 128.034 16.5412V14.0711C128.034 13.9606 128.123 13.8711 128.234 13.8711H130.363C130.473 13.8711 130.563 13.9606 130.563 14.0711V16.5412Z" fill="var(--c--globals--colors--logo-1)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.8209 27.9534C56.8716 28.0844 56.7749 28.2256 56.6344 28.2256H54.0801C53.9969 28.2256 53.9224 28.1741 53.893 28.0964L52.3355 23.9798C52.3061 23.902 52.2316 23.8506 52.1485 23.8506H45.6707C45.5876 23.8506 45.5132 23.9019 45.4837 23.9796L43.9196 28.0966C43.8901 28.1743 43.8157 28.2256 43.7326 28.2256H41.2002C41.0598 28.2256 40.9631 28.0846 41.0136 27.9536L47.0107 12.4034C47.0404 12.3263 47.1146 12.2754 47.1973 12.2754H50.6144C50.697 12.2754 50.7711 12.3262 50.8009 12.4032L56.8209 27.9534ZM46.5361 21.2104C46.4864 21.3413 46.5831 21.4814 46.7231 21.4814H51.1006C51.2405 21.4814 51.3372 21.3415 51.2876 21.2106L49.1032 15.4395C49.038 15.2673 48.7946 15.2672 48.7292 15.4392L46.5361 21.2104Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M80.1372 28.0256C80.1372 28.136 80.0477 28.2256 79.9372 28.2256H77.8314C77.7209 28.2256 77.6314 28.136 77.6314 28.0256V16.9412C77.6314 16.8308 77.7209 16.7412 77.8314 16.7412H79.9372C80.0477 16.7412 80.1372 16.8308 80.1372 16.9412V28.0256Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M120.009 16.2861C120.86 16.2861 121.619 16.4758 122.288 16.8555C122.971 17.2352 123.511 17.7977 123.906 18.542C124.301 19.2863 124.499 20.2208 124.499 21.3447V28.0256C124.499 28.136 124.409 28.2256 124.299 28.2256H122.169C122.059 28.2256 121.969 28.136 121.969 28.0256V21.4355C121.969 20.5546 121.741 19.8709 121.286 19.3848C120.83 18.8835 120.206 18.6328 119.417 18.6328C118.915 18.6329 118.475 18.7313 118.095 18.9287C117.715 19.111 117.389 19.3471 117.116 19.6357C116.919 19.8551 116.745 20.0831 116.592 20.3199C116.498 20.4662 116.455 20.6384 116.455 20.8124V28.0256C116.455 28.136 116.365 28.2256 116.255 28.2256H114.149C114.038 28.2256 113.949 28.136 113.949 28.0256V16.9412C113.949 16.8308 114.038 16.7412 114.149 16.7412H116.255C116.365 16.7412 116.455 16.8308 116.455 16.9412V17.5392C116.455 17.6294 116.566 17.6747 116.631 17.6128C117.008 17.2571 117.443 16.9588 117.936 16.7188C118.528 16.4302 119.219 16.2861 120.009 16.2861Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M78.9067 11C79.2105 11 79.484 11.0757 79.7271 11.2275C79.9853 11.3794 80.1831 11.5845 80.3198 11.8428C80.4717 12.0858 80.5474 12.3593 80.5474 12.6631C80.5474 12.9668 80.4716 13.2477 80.3198 13.5059C80.1831 13.7641 79.9853 13.9692 79.7271 14.1211C79.484 14.2578 79.2106 14.3262 78.9067 14.3262C78.603 14.3262 78.3222 14.2578 78.064 14.1211C77.8057 13.9692 77.6006 13.7641 77.4487 13.5059C77.2969 13.2477 77.2212 12.9668 77.2212 12.6631C77.2212 12.2074 77.3807 11.82 77.6997 11.501C78.0338 11.167 78.436 11 78.9067 11Z" fill="var(--c--globals--colors--logo-1)"/>
<rect x="143" y="12.5" width="40" height="16" rx="8" fill="var(--c--globals--colors--logo-1)"/>
<rect x="143" y="12.5" width="40" height="16" rx="8" fill="#F7F8FA" fill-opacity="0.9"/>
<path d="M150.133 24.5V16.8H152.399C154.005 16.8 154.984 17.592 154.984 18.89C154.984 19.517 154.698 20.056 154.159 20.408C154.973 20.771 155.424 21.431 155.424 22.234C155.424 23.642 154.335 24.5 152.564 24.5H150.133ZM152.432 18.098H151.695V19.836H152.432C153.026 19.836 153.378 19.517 153.378 18.956C153.378 18.417 153.026 18.098 152.432 18.098ZM152.652 21.134H151.695V23.202H152.652C153.378 23.202 153.818 22.817 153.818 22.168C153.818 21.508 153.378 21.134 152.652 21.134ZM156.917 24.5V16.8H161.405V18.098H158.479V19.924H160.965V21.222H158.479V23.202H161.405V24.5H156.917ZM162.479 18.219V16.8H168.793V18.219H166.417V24.5H164.855V18.219H162.479ZM168.346 24.5L171.261 16.8H173.307L176.222 24.5H174.561L173.824 22.498H170.744L170.007 24.5H168.346ZM171.228 21.167H173.34L172.284 18.274L171.228 21.167Z" fill="#2845C1"/>
<path d="M150.133 24.5V16.8H152.399C154.005 16.8 154.984 17.592 154.984 18.89C154.984 19.517 154.698 20.056 154.159 20.408C154.973 20.771 155.424 21.431 155.424 22.234C155.424 23.642 154.335 24.5 152.564 24.5H150.133ZM152.432 18.098H151.695V19.836H152.432C153.026 19.836 153.378 19.517 153.378 18.956C153.378 18.417 153.026 18.098 152.432 18.098ZM152.652 21.134H151.695V23.202H152.652C153.378 23.202 153.818 22.817 153.818 22.168C153.818 21.508 153.378 21.134 152.652 21.134ZM156.917 24.5V16.8H161.405V18.098H158.479V19.924H160.965V21.222H158.479V23.202H161.405V24.5H156.917ZM162.479 18.219V16.8H168.793V18.219H166.417V24.5H164.855V18.219H162.479ZM168.346 24.5L171.261 16.8H173.307L176.222 24.5H174.561L173.824 22.498H170.744L170.007 24.5H168.346ZM171.228 21.167H173.34L172.284 18.274L171.228 21.167Z" fill="var(--c--globals--colors--logo-1)"/>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,30 +1,30 @@
<svg width="139" height="40" viewBox="0 0 139 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.9806 10.0539C25.9806 9.37231 25.9806 9.03151 25.9098 8.72032C25.7791 8.14516 25.4727 7.62489 25.0331 7.23159C24.7953 7.01879 24.4973 6.8535 23.9012 6.52291L22.378 5.6781C21.8149 5.36579 21.5334 5.20963 21.2428 5.12176C20.7061 4.95941 20.1333 4.95941 19.5965 5.12176C19.3059 5.20963 19.0244 5.36579 18.4613 5.6781L17.8331 6.02651C17.0707 6.44937 16.6894 6.66081 16.5148 6.90692C16.1881 7.36724 16.1884 7.98382 16.5155 8.44382C16.6904 8.68977 17.0718 8.90084 17.8347 9.32298L18.3432 9.60438C18.9402 9.93474 19.2387 10.0999 19.4769 10.3127C19.9172 10.7061 20.2241 11.2267 20.3551 11.8023C20.4259 12.1138 20.4259 12.4549 20.4259 13.1372V13.7195C20.4259 14.5463 20.4259 14.9597 20.5502 15.2263C20.7823 15.7243 21.2975 16.0278 21.8457 15.9894C22.139 15.9688 22.5006 15.7684 23.2237 15.3675L23.9004 14.9925C24.4967 14.6619 24.7948 14.4967 25.0327 14.2839C25.4725 13.8905 25.779 13.3702 25.9098 12.7949C25.9806 12.4837 25.9806 12.1428 25.9806 11.461V10.0539Z" fill="#2845C1"/>
<path d="M9.02562 20.1571C8.43534 20.4979 8.1402 20.6683 7.90608 20.8852C7.47335 21.286 7.17597 21.8115 7.05514 22.3888C6.98977 22.7012 6.99563 23.0419 7.00736 23.7234L7.03734 25.4649C7.04843 26.1088 7.05397 26.4307 7.12313 26.7262C7.25092 27.2723 7.53733 27.7683 7.94632 28.152C8.16767 28.3597 8.44369 28.5254 8.99572 28.857L9.61153 29.2268C10.359 29.6756 10.7327 29.9001 11.0331 29.9282C11.5951 29.981 12.1289 29.6724 12.3638 29.1592C12.4893 28.8847 12.4814 28.4489 12.4656 27.5772L12.455 26.9961C12.4426 26.3139 12.4364 25.9728 12.5016 25.6601C12.6221 25.0821 12.9195 24.556 13.3526 24.1548C13.5869 23.9377 13.8823 23.7671 14.4732 23.4259L14.9775 23.1348C15.6935 22.7214 16.0515 22.5147 16.2202 22.2739C16.5355 21.8238 16.5407 21.2259 16.2333 20.7703C16.0689 20.5266 15.7145 20.3137 15.0058 19.8878L14.3426 19.4894C13.7582 19.1382 13.466 18.9627 13.1628 18.863C12.6023 18.6788 11.9984 18.6736 11.4348 18.8479C11.1298 18.9423 10.8346 19.1127 10.2442 19.4536L9.02562 20.1571Z" fill="#2845C1"/>
<path d="M14.8587 29.9461C14.8587 30.6277 14.8587 30.9685 14.9295 31.2797C15.0602 31.8548 15.3666 32.3751 15.8062 32.7684C16.044 32.9812 16.342 33.1465 16.9381 33.4771L18.4613 34.3219C19.0244 34.6342 19.306 34.7904 19.5965 34.8782C20.1333 35.0406 20.7061 35.0406 21.2428 34.8782C21.5334 34.7904 21.8149 34.6342 22.378 34.3219L23.0062 33.9735C23.7686 33.5506 24.1499 33.3392 24.3245 33.0931C24.6512 32.6328 24.6509 32.0162 24.3238 31.5562C24.1489 31.3102 23.7675 31.0992 23.0046 30.677L22.4961 30.3956C21.8991 30.0653 21.6006 29.9001 21.3624 29.6873C20.9222 29.2939 20.6152 28.7733 20.4842 28.1977C20.4134 27.8862 20.4134 27.5451 20.4134 26.8627V26.2805C20.4134 25.4537 20.4134 25.0403 20.2892 24.7737C20.057 24.2757 19.5418 23.9722 18.9936 24.0106C18.7003 24.0312 18.3387 24.2316 17.6156 24.6325L16.9389 25.0075C16.3426 25.3381 16.0445 25.5033 15.8066 25.7161C15.3668 26.1095 15.0603 26.6298 14.9295 27.2051C14.8587 27.5163 14.8587 27.8572 14.8587 28.539V29.9461Z" fill="#2845C1"/>
<path d="M26.2528 29.7889C26.843 30.1297 27.1382 30.3001 27.4431 30.3945C28.0065 30.5688 28.6103 30.5636 29.1707 30.3796C29.4739 30.28 29.766 30.1046 30.3504 29.7537L31.8436 28.8569C32.3956 28.5254 32.6716 28.3597 32.893 28.152C33.302 27.7683 33.5884 27.2723 33.7162 26.7262C33.7853 26.4307 33.7909 26.1088 33.802 25.4649L33.8143 24.7467C33.8293 23.875 33.8368 23.4391 33.711 23.1648C33.4757 22.6518 32.9416 22.3437 32.3797 22.397C32.0792 22.4255 31.7057 22.6503 30.9587 23.0999L30.4608 23.3995C29.8762 23.7514 29.5839 23.9273 29.2805 24.0272C28.7197 24.2118 28.1154 24.2173 27.5513 24.0429C27.2462 23.9485 26.9507 23.778 26.3598 23.4368L25.8556 23.1457C25.1395 22.7323 24.7815 22.5256 24.4886 22.4999C23.9411 22.4519 23.4208 22.7463 23.18 23.2403C23.0511 23.5046 23.0439 23.9179 23.0294 24.7446L23.0159 25.5182C23.004 26.1998 22.9981 26.5407 23.0634 26.8531C23.1842 27.4306 23.4816 27.9562 23.9144 28.3572C24.1485 28.5741 24.4438 28.7445 25.0342 29.0854L26.2528 29.7889Z" fill="#2845C1"/>
<path d="M31.8137 19.8429C32.404 19.5021 32.6991 19.3317 32.9332 19.1148C33.366 18.714 33.6633 18.1885 33.7842 17.6112C33.8495 17.2988 33.8437 16.9581 33.8319 16.2766L33.802 14.5351C33.7909 13.8912 33.7853 13.5693 33.7162 13.2738C33.5884 12.7277 33.302 12.2317 32.893 11.848C32.6716 11.6403 32.3956 11.4746 31.8436 11.1431L31.2278 10.7732C30.4804 10.3244 30.1066 10.0999 29.8062 10.0718C29.2442 10.019 28.7104 10.3276 28.4755 10.8408C28.35 11.1153 28.3579 11.5511 28.3737 12.4228L28.3843 13.0039C28.3967 13.6861 28.4029 14.0272 28.3377 14.3399C28.2172 14.9179 27.9198 15.444 27.4867 15.8452C27.2524 16.0623 26.957 16.2329 26.3661 16.5741L25.8618 16.8652C25.1458 17.2786 24.7878 17.4853 24.6191 17.7261C24.3038 18.1762 24.2986 18.7741 24.606 19.2297C24.7704 19.4734 25.1248 19.6863 25.8335 20.1122L26.4967 20.5106C27.0811 20.8618 27.3733 21.0373 27.6765 21.137C28.237 21.3212 28.8409 21.3264 29.4045 21.1521C29.7095 21.0577 30.0047 20.8873 30.5951 20.5464L31.8137 19.8429Z" fill="#2845C1"/>
<path d="M14.5865 10.2111C13.9962 9.87031 13.7011 9.69991 13.3962 9.60558C12.8327 9.43123 12.229 9.43642 11.6686 9.62044C11.3654 9.72001 11.0732 9.89546 10.4889 10.2464L8.99568 11.1431C8.44365 11.4746 8.16763 11.6404 7.94627 11.848C7.53729 12.2317 7.25088 12.7278 7.12309 13.2738C7.05393 13.5694 7.04839 13.8913 7.0373 14.5351L7.02494 15.2533C7.00993 16.1251 7.00243 16.5609 7.12825 16.8352C7.36357 17.3483 7.89769 17.6563 8.45961 17.603C8.76005 17.5746 9.13355 17.3498 9.88056 16.9002L10.3785 16.6005C10.9631 16.2487 11.2554 16.0728 11.5588 15.9729C12.1196 15.7883 12.7239 15.7828 13.2879 15.9572C13.5931 16.0515 13.8885 16.2221 14.4794 16.5632L14.9837 16.8544C15.6997 17.2678 16.0577 17.4745 16.3507 17.5002C16.8981 17.5482 17.4185 17.2537 17.6593 16.7598C17.7882 16.4955 17.7954 16.0821 17.8098 15.2554L17.8233 14.4819C17.8352 13.8002 17.8412 13.4594 17.7758 13.1469C17.6551 12.5694 17.3577 12.0438 16.9249 11.6429C16.6907 11.426 16.3955 11.2555 15.8051 10.9146L14.5865 10.2111Z" fill="#C83F49"/>
<rect x="8" y="27" width="24" height="9" rx="4.5" fill="#EEF1F4"/>
<path d="M25.9806 10.0539C25.9806 9.37231 25.9806 9.03151 25.9098 8.72032C25.7791 8.14516 25.4727 7.62489 25.0331 7.23159C24.7953 7.01879 24.4973 6.8535 23.9012 6.52291L22.378 5.6781C21.8149 5.36579 21.5334 5.20963 21.2428 5.12176C20.7061 4.95941 20.1333 4.95941 19.5965 5.12176C19.3059 5.20963 19.0244 5.36579 18.4613 5.6781L17.8331 6.02651C17.0707 6.44937 16.6894 6.66081 16.5148 6.90692C16.1881 7.36724 16.1884 7.98382 16.5155 8.44382C16.6904 8.68977 17.0718 8.90084 17.8347 9.32298L18.3432 9.60438C18.9402 9.93474 19.2387 10.0999 19.4769 10.3127C19.9172 10.7061 20.2241 11.2267 20.3551 11.8023C20.4259 12.1138 20.4259 12.4549 20.4259 13.1372V13.7195C20.4259 14.5463 20.4259 14.9597 20.5502 15.2263C20.7823 15.7243 21.2975 16.0278 21.8457 15.9894C22.139 15.9688 22.5006 15.7684 23.2237 15.3675L23.9004 14.9925C24.4967 14.6619 24.7948 14.4967 25.0327 14.2839C25.4725 13.8905 25.779 13.3702 25.9098 12.7949C25.9806 12.4837 25.9806 12.1428 25.9806 11.461V10.0539Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M9.02562 20.1571C8.43534 20.4979 8.1402 20.6683 7.90608 20.8852C7.47335 21.286 7.17597 21.8115 7.05514 22.3888C6.98977 22.7012 6.99563 23.0419 7.00736 23.7234L7.03734 25.4649C7.04843 26.1088 7.05397 26.4307 7.12313 26.7262C7.25092 27.2723 7.53733 27.7683 7.94632 28.152C8.16767 28.3597 8.44369 28.5254 8.99572 28.857L9.61153 29.2268C10.359 29.6756 10.7327 29.9001 11.0331 29.9282C11.5951 29.981 12.1289 29.6724 12.3638 29.1592C12.4893 28.8847 12.4814 28.4489 12.4656 27.5772L12.455 26.9961C12.4426 26.3139 12.4364 25.9728 12.5016 25.6601C12.6221 25.0821 12.9195 24.556 13.3526 24.1548C13.5869 23.9377 13.8823 23.7671 14.4732 23.4259L14.9775 23.1348C15.6935 22.7214 16.0515 22.5147 16.2202 22.2739C16.5355 21.8238 16.5407 21.2259 16.2333 20.7703C16.0689 20.5266 15.7145 20.3137 15.0058 19.8878L14.3426 19.4894C13.7582 19.1382 13.466 18.9627 13.1628 18.863C12.6023 18.6788 11.9984 18.6736 11.4348 18.8479C11.1298 18.9423 10.8346 19.1127 10.2442 19.4536L9.02562 20.1571Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M14.8587 29.9461C14.8587 30.6277 14.8587 30.9685 14.9295 31.2797C15.0602 31.8548 15.3666 32.3751 15.8062 32.7684C16.044 32.9812 16.342 33.1465 16.9381 33.4771L18.4613 34.3219C19.0244 34.6342 19.306 34.7904 19.5965 34.8782C20.1333 35.0406 20.7061 35.0406 21.2428 34.8782C21.5334 34.7904 21.8149 34.6342 22.378 34.3219L23.0062 33.9735C23.7686 33.5506 24.1499 33.3392 24.3245 33.0931C24.6512 32.6328 24.6509 32.0162 24.3238 31.5562C24.1489 31.3102 23.7675 31.0992 23.0046 30.677L22.4961 30.3956C21.8991 30.0653 21.6006 29.9001 21.3624 29.6873C20.9222 29.2939 20.6152 28.7733 20.4842 28.1977C20.4134 27.8862 20.4134 27.5451 20.4134 26.8627V26.2805C20.4134 25.4537 20.4134 25.0403 20.2892 24.7737C20.057 24.2757 19.5418 23.9722 18.9936 24.0106C18.7003 24.0312 18.3387 24.2316 17.6156 24.6325L16.9389 25.0075C16.3426 25.3381 16.0445 25.5033 15.8066 25.7161C15.3668 26.1095 15.0603 26.6298 14.9295 27.2051C14.8587 27.5163 14.8587 27.8572 14.8587 28.539V29.9461Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M26.2528 29.7889C26.843 30.1297 27.1382 30.3001 27.4431 30.3945C28.0065 30.5688 28.6103 30.5636 29.1707 30.3796C29.4739 30.28 29.766 30.1046 30.3504 29.7537L31.8436 28.8569C32.3956 28.5254 32.6716 28.3597 32.893 28.152C33.302 27.7683 33.5884 27.2723 33.7162 26.7262C33.7853 26.4307 33.7909 26.1088 33.802 25.4649L33.8143 24.7467C33.8293 23.875 33.8368 23.4391 33.711 23.1648C33.4757 22.6518 32.9416 22.3437 32.3797 22.397C32.0792 22.4255 31.7057 22.6503 30.9587 23.0999L30.4608 23.3995C29.8762 23.7514 29.5839 23.9273 29.2805 24.0272C28.7197 24.2118 28.1154 24.2173 27.5513 24.0429C27.2462 23.9485 26.9507 23.778 26.3598 23.4368L25.8556 23.1457C25.1395 22.7323 24.7815 22.5256 24.4886 22.4999C23.9411 22.4519 23.4208 22.7463 23.18 23.2403C23.0511 23.5046 23.0439 23.9179 23.0294 24.7446L23.0159 25.5182C23.004 26.1998 22.9981 26.5407 23.0634 26.8531C23.1842 27.4306 23.4816 27.9562 23.9144 28.3572C24.1485 28.5741 24.4438 28.7445 25.0342 29.0854L26.2528 29.7889Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M31.8137 19.8429C32.404 19.5021 32.6991 19.3317 32.9332 19.1148C33.366 18.714 33.6633 18.1885 33.7842 17.6112C33.8495 17.2988 33.8437 16.9581 33.8319 16.2766L33.802 14.5351C33.7909 13.8912 33.7853 13.5693 33.7162 13.2738C33.5884 12.7277 33.302 12.2317 32.893 11.848C32.6716 11.6403 32.3956 11.4746 31.8436 11.1431L31.2278 10.7732C30.4804 10.3244 30.1066 10.0999 29.8062 10.0718C29.2442 10.019 28.7104 10.3276 28.4755 10.8408C28.35 11.1153 28.3579 11.5511 28.3737 12.4228L28.3843 13.0039C28.3967 13.6861 28.4029 14.0272 28.3377 14.3399C28.2172 14.9179 27.9198 15.444 27.4867 15.8452C27.2524 16.0623 26.957 16.2329 26.3661 16.5741L25.8618 16.8652C25.1458 17.2786 24.7878 17.4853 24.6191 17.7261C24.3038 18.1762 24.2986 18.7741 24.606 19.2297C24.7704 19.4734 25.1248 19.6863 25.8335 20.1122L26.4967 20.5106C27.0811 20.8618 27.3733 21.0373 27.6765 21.137C28.237 21.3212 28.8409 21.3264 29.4045 21.1521C29.7095 21.0577 30.0047 20.8873 30.5951 20.5464L31.8137 19.8429Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M14.5865 10.2111C13.9962 9.87031 13.7011 9.69991 13.3962 9.60558C12.8327 9.43123 12.229 9.43642 11.6686 9.62044C11.3654 9.72001 11.0732 9.89546 10.4889 10.2464L8.99568 11.1431C8.44365 11.4746 8.16763 11.6404 7.94627 11.848C7.53729 12.2317 7.25088 12.7278 7.12309 13.2738C7.05393 13.5694 7.04839 13.8913 7.0373 14.5351L7.02494 15.2533C7.00993 16.1251 7.00243 16.5609 7.12825 16.8352C7.36357 17.3483 7.89769 17.6563 8.45961 17.603C8.76005 17.5746 9.13355 17.3498 9.88056 16.9002L10.3785 16.6005C10.9631 16.2487 11.2554 16.0728 11.5588 15.9729C12.1196 15.7883 12.7239 15.7828 13.2879 15.9572C13.5931 16.0515 13.8885 16.2221 14.4794 16.5632L14.9837 16.8544C15.6997 17.2678 16.0577 17.4745 16.3507 17.5002C16.8981 17.5482 17.4185 17.2537 17.6593 16.7598C17.7882 16.4955 17.7954 16.0821 17.8098 15.2554L17.8233 14.4819C17.8352 13.8002 17.8412 13.4594 17.7758 13.1469C17.6551 12.5694 17.3577 12.0438 16.9249 11.6429C16.6907 11.426 16.3955 11.2555 15.8051 10.9146L14.5865 10.2111Z" fill="var(--c--globals--colors--logo-2)"/>
<rect x="8" y="27" width="24" height="9" rx="4.5" fill="var(--c--contextuals--background--semantic--neutral--tertiary)"/>
<rect x="8" y="27" width="24" height="9" rx="4.5" fill="url(#paint0_linear_6479_3681)" fill-opacity="0.1"/>
<path d="M12.3425 33.7362C12.2926 33.7362 12.2676 33.7362 12.2486 33.7265C12.2318 33.718 12.2182 33.7044 12.2097 33.6876C12.2 33.6686 12.2 33.6436 12.2 33.5937V29.2425C12.2 29.1926 12.2 29.1677 12.2097 29.1486C12.2182 29.1318 12.2318 29.1182 12.2486 29.1097C12.2676 29.1 12.2926 29.1 12.3425 29.1H13.5643C14.5313 29.1 15.1208 29.5768 15.1208 30.3584C15.1208 30.648 15.0195 30.9064 14.8257 31.1068C14.7572 31.1777 14.7229 31.2131 14.7171 31.2364C14.7109 31.2615 14.7123 31.274 14.7237 31.2972C14.7343 31.3187 14.7739 31.3451 14.8532 31.3978C15.1981 31.6275 15.3857 31.9699 15.3857 32.3719C15.3857 33.2196 14.73 33.7362 13.6637 33.7362H12.3425ZM13.5842 29.8815H13.283C13.2331 29.8815 13.2081 29.8815 13.1891 29.8912C13.1723 29.8998 13.1587 29.9134 13.1502 29.9301C13.1405 29.9492 13.1405 29.9741 13.1405 30.024V30.7855C13.1405 30.8354 13.1405 30.8603 13.1502 30.8794C13.1587 30.8961 13.1723 30.9097 13.1891 30.9183C13.2081 30.928 13.2331 30.928 13.283 30.928H13.5842C13.9419 30.928 14.1538 30.7359 14.1538 30.3981C14.1538 30.0736 13.9419 29.8815 13.5842 29.8815ZM13.7167 31.7095H13.283C13.2331 31.7095 13.2081 31.7095 13.1891 31.7192C13.1723 31.7278 13.1587 31.7414 13.1502 31.7582C13.1405 31.7772 13.1405 31.8022 13.1405 31.852V32.8122C13.1405 32.8621 13.1405 32.887 13.1502 32.9061C13.1587 32.9228 13.1723 32.9365 13.1891 32.945C13.2081 32.9547 13.2331 32.9547 13.283 32.9547H13.7167C14.1538 32.9547 14.4187 32.7229 14.4187 32.3321C14.4187 31.9347 14.1538 31.7095 13.7167 31.7095Z" fill="#2845C1"/>
<path d="M16.4271 33.7362C16.3772 33.7362 16.3523 33.7362 16.3333 33.7265C16.3165 33.718 16.3029 33.7044 16.2943 33.6876C16.2846 33.6686 16.2846 33.6436 16.2846 33.5937V29.2425C16.2846 29.1926 16.2846 29.1677 16.2943 29.1486C16.3029 29.1318 16.3165 29.1182 16.3333 29.1097C16.3523 29.1 16.3772 29.1 16.4271 29.1H18.8444C18.8943 29.1 18.9192 29.1 18.9383 29.1097C18.955 29.1182 18.9687 29.1318 18.9772 29.1486C18.9869 29.1677 18.9869 29.1926 18.9869 29.2425V29.739C18.9869 29.7889 18.9869 29.8138 18.9772 29.8329C18.9687 29.8496 18.955 29.8633 18.9383 29.8718C18.9192 29.8815 18.8943 29.8815 18.8444 29.8815H17.3676C17.3177 29.8815 17.2928 29.8815 17.2738 29.8912C17.257 29.8998 17.2434 29.9134 17.2348 29.9301C17.2251 29.9492 17.2251 29.9741 17.2251 30.024V30.8385C17.2251 30.8884 17.2251 30.9133 17.2348 30.9323C17.2434 30.9491 17.257 30.9627 17.2738 30.9713C17.2928 30.981 17.3177 30.981 17.3676 30.981H18.5795C18.6294 30.981 18.6543 30.981 18.6733 30.9907C18.6901 30.9992 18.7037 31.0128 18.7123 31.0296C18.722 31.0487 18.722 31.0736 18.722 31.1235V31.62C18.722 31.6699 18.722 31.6948 18.7123 31.7139C18.7037 31.7306 18.6901 31.7443 18.6733 31.7528C18.6543 31.7625 18.6294 31.7625 18.5795 31.7625H17.3676C17.3177 31.7625 17.2928 31.7625 17.2738 31.7722C17.257 31.7808 17.2434 31.7944 17.2348 31.8111C17.2251 31.8302 17.2251 31.8551 17.2251 31.905V32.8122C17.2251 32.8621 17.2251 32.887 17.2348 32.9061C17.2434 32.9228 17.257 32.9365 17.2738 32.945C17.2928 32.9547 17.3177 32.9547 17.3676 32.9547H18.8444C18.8943 32.9547 18.9192 32.9547 18.9383 32.9644C18.955 32.9729 18.9687 32.9866 18.9772 33.0033C18.9869 33.0224 18.9869 33.0473 18.9869 33.0972V33.5937C18.9869 33.6436 18.9869 33.6686 18.9772 33.6876C18.9687 33.7044 18.955 33.718 18.9383 33.7265C18.9192 33.7362 18.8943 33.7362 18.8444 33.7362H16.4271Z" fill="#2845C1"/>
<path d="M19.7762 29.9544C19.7263 29.9544 19.7014 29.9544 19.6823 29.9447C19.6656 29.9361 19.652 29.9225 19.6434 29.9057C19.6337 29.8867 19.6337 29.8618 19.6337 29.8119V29.2425C19.6337 29.1926 19.6337 29.1677 19.6434 29.1486C19.652 29.1318 19.6656 29.1182 19.6823 29.1097C19.7014 29.1 19.7263 29.1 19.7762 29.1H23.2929C23.3428 29.1 23.3678 29.1 23.3868 29.1097C23.4036 29.1182 23.4172 29.1318 23.4257 29.1486C23.4354 29.1677 23.4354 29.1926 23.4354 29.2425V29.8119C23.4354 29.8618 23.4354 29.8867 23.4257 29.9057C23.4172 29.9225 23.4036 29.9361 23.3868 29.9447C23.3678 29.9544 23.3428 29.9544 23.2929 29.9544H22.1473C22.0974 29.9544 22.0725 29.9544 22.0535 29.9641C22.0367 29.9726 22.0231 29.9862 22.0145 30.003C22.0048 30.0221 22.0048 30.047 22.0048 30.0969V33.5937C22.0048 33.6436 22.0048 33.6686 21.9951 33.6876C21.9866 33.7044 21.973 33.718 21.9562 33.7265C21.9371 33.7362 21.9122 33.7362 21.8623 33.7362H21.2068C21.1569 33.7362 21.132 33.7362 21.113 33.7265C21.0962 33.718 21.0826 33.7044 21.074 33.6876C21.0643 33.6686 21.0643 33.6436 21.0643 33.5937V30.0969C21.0643 30.047 21.0643 30.0221 21.0546 30.003C21.0461 29.9862 21.0325 29.9726 21.0157 29.9641C20.9966 29.9544 20.9717 29.9544 20.9218 29.9544H19.7762Z" fill="#2845C1"/>
<path d="M23.3724 33.7362C23.3031 33.7362 23.2685 33.7362 23.2463 33.7217C23.2268 33.709 23.2131 33.6891 23.2081 33.6664C23.2024 33.6405 23.2146 33.6081 23.2392 33.5433L24.8864 29.192C24.899 29.1588 24.9053 29.1422 24.9161 29.13C24.9257 29.1192 24.9377 29.1109 24.9512 29.1058C24.9665 29.1 24.9842 29.1 25.0197 29.1H26.0548C26.0903 29.1 26.108 29.1 26.1233 29.1058C26.1368 29.1109 26.1488 29.1192 26.1584 29.13C26.1692 29.1422 26.1755 29.1588 26.188 29.192L27.8353 33.5433C27.8598 33.6081 27.8721 33.6405 27.8664 33.6664C27.8613 33.6891 27.8476 33.709 27.8282 33.7217C27.806 33.7362 27.7713 33.7362 27.702 33.7362H27.0076C26.9718 33.7362 26.9539 33.7362 26.9385 33.7304C26.925 33.7252 26.9129 33.7167 26.9033 33.7058C26.8925 33.6934 26.8863 33.6766 26.8739 33.643L26.4988 32.6241C26.4865 32.5905 26.4803 32.5737 26.4694 32.5613C26.4599 32.5503 26.4478 32.5419 26.4342 32.5367C26.4188 32.5308 26.4009 32.5308 26.3651 32.5308H24.7094C24.6736 32.5308 24.6556 32.5308 24.6403 32.5367C24.6267 32.5419 24.6146 32.5503 24.605 32.5613C24.5942 32.5737 24.588 32.5905 24.5757 32.6241L24.2006 33.643C24.1882 33.6766 24.182 33.6934 24.1712 33.7058C24.1616 33.7167 24.1495 33.7252 24.1359 33.7304C24.1206 33.7362 24.1027 33.7362 24.0668 33.7362H23.3724ZM24.9713 31.538C24.9478 31.6024 24.936 31.6346 24.9419 31.6603C24.9471 31.6828 24.9608 31.7024 24.9802 31.715C25.0023 31.7294 25.0366 31.7294 25.1051 31.7294H25.9694C26.0379 31.7294 26.0721 31.7294 26.0943 31.715C26.1136 31.7024 26.1274 31.6828 26.1325 31.6603C26.1385 31.6346 26.1267 31.6024 26.1032 31.538L25.6711 30.3542C25.6298 30.2411 25.6092 30.1846 25.579 30.1686C25.5529 30.1547 25.5216 30.1547 25.4955 30.1686C25.4653 30.1846 25.4447 30.2411 25.4034 30.3542L24.9713 31.538Z" fill="#2845C1"/>
<path d="M61.8577 15.8076C62.6931 15.8076 63.468 15.99 64.1819 16.3545C64.8463 16.6867 65.3785 17.1009 65.7781 17.5971C65.8437 17.6785 65.8294 17.7966 65.7498 17.8645L64.3459 19.0622C64.2574 19.1377 64.1236 19.1217 64.0503 19.0314C63.7909 18.7122 63.4929 18.4426 63.1565 18.2227C62.7616 17.9645 62.3285 17.835 61.8577 17.835C61.5237 17.835 61.2353 17.8962 60.9924 18.0176C60.7646 18.1391 60.5895 18.2986 60.468 18.4961C60.3617 18.6936 60.3088 18.9142 60.3088 19.1572C60.3089 19.5216 60.4375 19.8254 60.6955 20.0684C60.969 20.2962 61.319 20.494 61.7444 20.6611C62.1696 20.8282 62.6174 21.0106 63.0881 21.208C63.5742 21.3903 64.03 21.6182 64.4553 21.8916C64.8806 22.1498 65.2225 22.4989 65.4807 22.9395C65.7541 23.3647 65.8908 23.9037 65.8909 24.5566C65.8909 25.3312 65.7012 25.9923 65.3215 26.5391C64.957 27.0706 64.471 27.4809 63.8635 27.7695C63.2559 28.0582 62.5868 28.2031 61.8577 28.2031C60.8706 28.2031 60.0124 28.0207 59.2834 27.6562C58.6116 27.3063 58.0171 26.8462 57.5 26.2769C57.4264 26.1959 57.4354 26.0708 57.5176 25.9985L58.921 24.7635C59.0067 24.6881 59.1377 24.6998 59.2123 24.7863C59.5425 25.1694 59.9081 25.4884 60.3088 25.7422C60.7645 26.0307 61.2733 26.1748 61.8352 26.1748C62.3669 26.1748 62.7698 26.0381 63.0432 25.7646C63.3316 25.4761 63.4758 25.1268 63.4758 24.7168C63.4758 24.3371 63.339 24.0335 63.0657 23.8057C62.8074 23.5778 62.4656 23.38 62.0403 23.2129C61.6149 23.0458 61.1592 22.8708 60.6731 22.6885C60.2024 22.4911 59.7545 22.2557 59.3293 21.9824C58.9041 21.709 58.5539 21.3598 58.2805 20.9346C58.0223 20.494 57.8938 19.9316 57.8938 19.248C57.8938 18.6101 58.0606 18.033 58.3948 17.5166C58.729 16.9849 59.192 16.5665 59.7844 16.2627C60.3919 15.959 61.0831 15.8077 61.8577 15.8076Z" fill="#2845C1"/>
<path d="M71.3215 15.8076C72.1569 15.8077 72.9319 15.99 73.6457 16.3545C74.3101 16.6867 74.8424 17.1009 75.2419 17.5971C75.3075 17.6785 75.2933 17.7966 75.2137 17.8644L73.8098 19.0622C73.7213 19.1377 73.5875 19.1217 73.5141 19.0313C73.2548 18.7122 72.9568 18.4427 72.6204 18.2227C72.2255 17.9645 71.7923 17.835 71.3215 17.835C70.9873 17.835 70.6984 17.8961 70.4553 18.0176C70.2275 18.1391 70.0524 18.2986 69.9309 18.4961C69.8246 18.6935 69.7717 18.9142 69.7717 19.1572C69.7718 19.5217 69.9013 19.8254 70.1594 20.0684C70.4328 20.2962 70.782 20.4941 71.2073 20.6611C71.6326 20.8282 72.0811 21.0105 72.552 21.208C73.0379 21.3903 73.494 21.6182 73.9192 21.8916C74.3443 22.1498 74.6864 22.4991 74.9446 22.9395C75.2178 23.3646 75.3547 23.9038 75.3547 24.5566C75.3547 25.3313 75.1641 25.9922 74.7844 26.5391C74.4199 27.0707 73.934 27.4809 73.3264 27.7695C72.7189 28.0581 72.0506 28.2031 71.3215 28.2031C70.3341 28.2031 69.4755 28.0208 68.7463 27.6562C68.0745 27.3063 67.48 26.8462 66.9629 26.2769C66.8893 26.1959 66.8983 26.0708 66.9805 25.9985L68.3839 24.7635C68.4696 24.6881 68.6006 24.6998 68.6752 24.7863C69.0054 25.1694 69.371 25.4884 69.7717 25.7422C70.2273 26.0307 70.7362 26.1747 71.2981 26.1748C71.8298 26.1748 72.2327 26.0381 72.5061 25.7646C72.7946 25.4761 72.9387 25.1268 72.9387 24.7168C72.9387 24.337 72.802 24.0335 72.5286 23.8057C72.2703 23.5778 71.9285 23.38 71.5032 23.2129C71.0779 23.0458 70.622 22.8707 70.136 22.6885C69.6653 22.4911 69.2174 22.2557 68.7922 21.9824C68.3671 21.7091 68.0178 21.3597 67.7444 20.9346C67.4861 20.494 67.3567 19.9316 67.3567 19.248C67.3567 18.6102 67.5236 18.033 67.8577 17.5166C68.1918 16.985 68.655 16.5665 69.2473 16.2627C69.8549 15.9589 70.5468 15.8076 71.3215 15.8076Z" fill="#2845C1"/>
<path d="M86.3313 15.8076C87.1668 15.8076 87.9416 15.9899 88.6555 16.3545C89.32 16.6868 89.8521 17.1008 90.2517 17.5971C90.3173 17.6785 90.303 17.7966 90.2235 17.8644L88.8195 19.0622C88.731 19.1377 88.5973 19.1217 88.5239 19.0314C88.2646 18.7122 87.9666 18.4426 87.6301 18.2227C87.2352 17.9644 86.8022 17.835 86.3313 17.835C85.9971 17.835 85.7081 17.8961 85.4651 18.0176C85.2374 18.1391 85.0631 18.2988 84.9416 18.4961C84.8353 18.6936 84.7815 18.9142 84.7815 19.1572C84.7816 19.5216 84.911 19.8254 85.1692 20.0684C85.4426 20.2962 85.7918 20.4941 86.217 20.6611C86.6424 20.8282 87.0909 21.0105 87.5618 21.208C88.0478 21.3903 88.5036 21.6182 88.9289 21.8916C89.3542 22.1498 89.6961 22.499 89.9543 22.9395C90.2276 23.3647 90.3644 23.9037 90.3645 24.5566C90.3645 25.3311 90.1747 25.9923 89.7952 26.5391C89.4306 27.0707 88.9438 27.4809 88.3362 27.7695C87.7286 28.0581 87.0604 28.2031 86.3313 28.2031C85.3439 28.2031 84.4852 28.0208 83.7561 27.6562C83.0843 27.3063 82.4905 26.8461 81.9736 26.2769C81.9 26.1959 81.909 26.0708 81.9911 25.9985L83.3937 24.7636C83.4793 24.6881 83.6104 24.6998 83.685 24.7863C84.0152 25.1694 84.3808 25.4884 84.7815 25.7422C85.2372 26.0308 85.7468 26.1748 86.3088 26.1748C86.8403 26.1748 87.2425 26.038 87.5159 25.7646C87.8044 25.4761 87.9494 25.1269 87.9495 24.7168C87.9495 24.3372 87.8125 24.0335 87.5393 23.8057C87.2811 23.5779 86.9391 23.38 86.5139 23.2129C86.0886 23.0458 85.6328 22.8707 85.1467 22.6885C84.6758 22.491 84.2273 22.2559 83.802 21.9824C83.3768 21.7091 83.0275 21.3597 82.7541 20.9346C82.4959 20.494 82.3664 19.9316 82.3664 19.248C82.3664 18.61 82.5342 18.0331 82.8684 17.5166C83.2026 16.9849 83.6656 16.5665 84.258 16.2627C84.8656 15.959 85.5567 15.8076 86.3313 15.8076Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.191 16.0361C107.239 16.0361 108.128 16.2257 108.857 16.6055C109.586 16.9852 110.133 17.5094 110.498 18.1777C110.877 18.8461 111.067 19.5981 111.067 20.4336V27.7756C111.067 27.886 110.977 27.9756 110.867 27.9756H108.76C108.65 27.9756 108.56 27.886 108.56 27.7756V27.0162C108.56 26.9218 108.438 26.8771 108.372 26.9448C108.27 27.0497 108.159 27.1502 108.037 27.2461C107.626 27.5803 107.148 27.8392 106.601 28.0215C106.054 28.2189 105.5 28.3173 104.938 28.3174C103.814 28.3174 102.894 28.0139 102.18 27.4062C101.466 26.7986 101.11 25.9855 101.11 24.9678C101.11 24.102 101.436 23.3654 102.089 22.7578C102.743 22.1502 103.684 21.7473 104.915 21.5498L108.394 20.9589C108.49 20.9426 108.56 20.8593 108.56 20.7618V20.4336C108.56 19.7502 108.333 19.2186 107.877 18.8389C107.422 18.444 106.829 18.2462 106.1 18.2461C105.508 18.2461 104.968 18.3756 104.482 18.6338C104.071 18.8594 103.717 19.1602 103.422 19.5365C103.351 19.626 103.222 19.6465 103.132 19.5773L101.606 18.4098C101.52 18.3442 101.501 18.2219 101.568 18.1365C102.052 17.5117 102.666 17.0165 103.411 16.6514C104.231 16.2412 105.158 16.0361 106.191 16.0361ZM105.553 23.2139C104.87 23.3202 104.376 23.5171 104.072 23.8057C103.768 24.079 103.616 24.4284 103.616 24.8535C103.616 25.2788 103.775 25.6289 104.094 25.9023C104.428 26.1758 104.877 26.3125 105.439 26.3125C106.138 26.3125 106.753 26.153 107.285 25.834C107.721 25.5722 108.09 25.2382 108.393 24.833C108.508 24.6795 108.56 24.4899 108.56 24.2983V22.9508C108.56 22.8272 108.449 22.7332 108.327 22.7535L105.553 23.2139Z" fill="#2845C1"/>
<path d="M95.9868 16.2912C95.9868 16.4017 96.0764 16.4912 96.1868 16.4912H99.5017C99.6121 16.4912 99.7017 16.5808 99.7017 16.6912V18.5471C99.7017 18.6575 99.6121 18.7471 99.5017 18.7471H96.1868C96.0764 18.7471 95.9868 18.8366 95.9868 18.9471V23.9199C95.9868 24.421 96.0708 24.8159 96.2378 25.1045C96.4048 25.3929 96.6403 25.6055 96.9439 25.7422C97.2477 25.8637 97.605 25.9248 98.0151 25.9248C98.4101 25.9248 98.7446 25.902 99.0181 25.8564C99.1739 25.8218 99.3174 25.7847 99.4485 25.7451C99.5829 25.7045 99.7241 25.8022 99.7241 25.9426V27.7314C99.7241 27.8104 99.6778 27.8826 99.6042 27.9113C99.3605 28.0061 99.0888 28.0733 98.7896 28.1123C98.4554 28.173 98.0605 28.2031 97.605 28.2031C96.6936 28.2031 95.926 28.0436 95.3032 27.7246C94.6959 27.3904 94.2325 26.9117 93.9136 26.2891C93.6099 25.6512 93.4585 24.8615 93.4585 23.9199V18.9471C93.4585 18.8366 93.369 18.7471 93.2585 18.7471H91.6087C91.4982 18.7471 91.4087 18.6575 91.4087 18.5471V16.6912C91.4087 16.5808 91.4982 16.4912 91.6087 16.4912H93.2585C93.369 16.4912 93.4585 16.4017 93.4585 16.2912V13.8211C93.4585 13.7106 93.548 13.6211 93.6585 13.6211H95.7868C95.8973 13.6211 95.9868 13.7106 95.9868 13.8211V16.2912Z" fill="#2845C1"/>
<path d="M130.563 16.2912C130.563 16.4017 130.653 16.4912 130.763 16.4912H134.077C134.187 16.4912 134.277 16.5808 134.277 16.6912V18.5471C134.277 18.6575 134.187 18.7471 134.077 18.7471H130.763C130.653 18.7471 130.563 18.8366 130.563 18.9471V23.9199C130.563 24.421 130.646 24.8159 130.813 25.1045C130.98 25.3931 131.216 25.6055 131.52 25.7422C131.824 25.8637 132.18 25.9248 132.59 25.9248C132.985 25.9248 133.32 25.902 133.593 25.8564C133.749 25.8218 133.893 25.7847 134.024 25.7451C134.158 25.7045 134.299 25.8022 134.299 25.9425V27.7313C134.299 27.8104 134.253 27.8826 134.179 27.9113C133.936 28.0061 133.665 28.0733 133.366 28.1123C133.032 28.1731 132.636 28.2031 132.18 28.2031C131.269 28.2031 130.502 28.0436 129.879 27.7246C129.272 27.3904 128.809 26.9119 128.49 26.2891C128.186 25.6511 128.034 24.8616 128.034 23.9199V18.9471C128.034 18.8366 127.944 18.7471 127.834 18.7471H126.092C125.982 18.7471 125.892 18.6575 125.892 18.5471V16.6912C125.892 16.5808 125.982 16.4912 126.092 16.4912H127.834C127.944 16.4912 128.034 16.4017 128.034 16.2912V13.8211C128.034 13.7106 128.123 13.6211 128.234 13.6211H130.363C130.473 13.6211 130.563 13.7106 130.563 13.8211V16.2912Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.8209 27.7034C56.8716 27.8344 56.7749 27.9756 56.6344 27.9756H54.0801C53.9969 27.9756 53.9224 27.9241 53.893 27.8464L52.3355 23.7298C52.3061 23.652 52.2316 23.6006 52.1485 23.6006H45.6707C45.5876 23.6006 45.5132 23.6519 45.4837 23.7296L43.9196 27.8466C43.8901 27.9243 43.8157 27.9756 43.7326 27.9756H41.2002C41.0598 27.9756 40.9631 27.8346 41.0136 27.7036L47.0107 12.1534C47.0404 12.0763 47.1146 12.0254 47.1973 12.0254H50.6144C50.697 12.0254 50.7711 12.0762 50.8009 12.1532L56.8209 27.7034ZM46.5361 20.9604C46.4864 21.0913 46.5831 21.2314 46.7231 21.2314H51.1006C51.2405 21.2314 51.3372 21.0915 51.2876 20.9606L49.1032 15.1895C49.038 15.0173 48.7946 15.0172 48.7292 15.1892L46.5361 20.9604Z" fill="#2845C1"/>
<path d="M80.1372 27.7756C80.1372 27.886 80.0477 27.9756 79.9372 27.9756H77.8314C77.7209 27.9756 77.6314 27.886 77.6314 27.7756V16.6912C77.6314 16.5808 77.7209 16.4912 77.8314 16.4912H79.9372C80.0477 16.4912 80.1372 16.5808 80.1372 16.6912V27.7756Z" fill="#2845C1"/>
<path d="M120.009 16.0361C120.86 16.0361 121.619 16.2258 122.288 16.6055C122.971 16.9852 123.511 17.5477 123.906 18.292C124.301 19.0363 124.499 19.9708 124.499 21.0947V27.7756C124.499 27.886 124.409 27.9756 124.299 27.9756H122.169C122.059 27.9756 121.969 27.886 121.969 27.7756V21.1855C121.969 20.3046 121.741 19.6209 121.286 19.1348C120.83 18.6335 120.206 18.3828 119.417 18.3828C118.915 18.3829 118.475 18.4813 118.095 18.6787C117.715 18.861 117.389 19.0971 117.116 19.3857C116.919 19.6051 116.745 19.8331 116.592 20.0699C116.498 20.2162 116.455 20.3884 116.455 20.5624V27.7756C116.455 27.886 116.365 27.9756 116.255 27.9756H114.149C114.038 27.9756 113.949 27.886 113.949 27.7756V16.6912C113.949 16.5808 114.038 16.4912 114.149 16.4912H116.255C116.365 16.4912 116.455 16.5808 116.455 16.6912V17.2892C116.455 17.3794 116.566 17.4247 116.631 17.3628C117.008 17.0071 117.443 16.7088 117.936 16.4688C118.528 16.1802 119.219 16.0361 120.009 16.0361Z" fill="#2845C1"/>
<path d="M78.9067 10.75C79.2105 10.75 79.484 10.8257 79.7271 10.9775C79.9853 11.1294 80.1831 11.3345 80.3198 11.5928C80.4717 11.8358 80.5474 12.1093 80.5474 12.4131C80.5474 12.7168 80.4716 12.9977 80.3198 13.2559C80.1831 13.5141 79.9853 13.7192 79.7271 13.8711C79.484 14.0078 79.2106 14.0762 78.9067 14.0762C78.603 14.0762 78.3222 14.0078 78.064 13.8711C77.8057 13.7192 77.6006 13.5141 77.4487 13.2559C77.2969 12.9977 77.2212 12.7168 77.2212 12.4131C77.2212 11.9574 77.3807 11.57 77.6997 11.251C78.0338 10.917 78.436 10.75 78.9067 10.75Z" fill="#2845C1"/>
<path d="M12.3425 33.7362C12.2926 33.7362 12.2676 33.7362 12.2486 33.7265C12.2318 33.718 12.2182 33.7044 12.2097 33.6876C12.2 33.6686 12.2 33.6436 12.2 33.5937V29.2425C12.2 29.1926 12.2 29.1677 12.2097 29.1486C12.2182 29.1318 12.2318 29.1182 12.2486 29.1097C12.2676 29.1 12.2926 29.1 12.3425 29.1H13.5643C14.5313 29.1 15.1208 29.5768 15.1208 30.3584C15.1208 30.648 15.0195 30.9064 14.8257 31.1068C14.7572 31.1777 14.7229 31.2131 14.7171 31.2364C14.7109 31.2615 14.7123 31.274 14.7237 31.2972C14.7343 31.3187 14.7739 31.3451 14.8532 31.3978C15.1981 31.6275 15.3857 31.9699 15.3857 32.3719C15.3857 33.2196 14.73 33.7362 13.6637 33.7362H12.3425ZM13.5842 29.8815H13.283C13.2331 29.8815 13.2081 29.8815 13.1891 29.8912C13.1723 29.8998 13.1587 29.9134 13.1502 29.9301C13.1405 29.9492 13.1405 29.9741 13.1405 30.024V30.7855C13.1405 30.8354 13.1405 30.8603 13.1502 30.8794C13.1587 30.8961 13.1723 30.9097 13.1891 30.9183C13.2081 30.928 13.2331 30.928 13.283 30.928H13.5842C13.9419 30.928 14.1538 30.7359 14.1538 30.3981C14.1538 30.0736 13.9419 29.8815 13.5842 29.8815ZM13.7167 31.7095H13.283C13.2331 31.7095 13.2081 31.7095 13.1891 31.7192C13.1723 31.7278 13.1587 31.7414 13.1502 31.7582C13.1405 31.7772 13.1405 31.8022 13.1405 31.852V32.8122C13.1405 32.8621 13.1405 32.887 13.1502 32.9061C13.1587 32.9228 13.1723 32.9365 13.1891 32.945C13.2081 32.9547 13.2331 32.9547 13.283 32.9547H13.7167C14.1538 32.9547 14.4187 32.7229 14.4187 32.3321C14.4187 31.9347 14.1538 31.7095 13.7167 31.7095Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M16.4271 33.7362C16.3772 33.7362 16.3523 33.7362 16.3333 33.7265C16.3165 33.718 16.3029 33.7044 16.2943 33.6876C16.2846 33.6686 16.2846 33.6436 16.2846 33.5937V29.2425C16.2846 29.1926 16.2846 29.1677 16.2943 29.1486C16.3029 29.1318 16.3165 29.1182 16.3333 29.1097C16.3523 29.1 16.3772 29.1 16.4271 29.1H18.8444C18.8943 29.1 18.9192 29.1 18.9383 29.1097C18.955 29.1182 18.9687 29.1318 18.9772 29.1486C18.9869 29.1677 18.9869 29.1926 18.9869 29.2425V29.739C18.9869 29.7889 18.9869 29.8138 18.9772 29.8329C18.9687 29.8496 18.955 29.8633 18.9383 29.8718C18.9192 29.8815 18.8943 29.8815 18.8444 29.8815H17.3676C17.3177 29.8815 17.2928 29.8815 17.2738 29.8912C17.257 29.8998 17.2434 29.9134 17.2348 29.9301C17.2251 29.9492 17.2251 29.9741 17.2251 30.024V30.8385C17.2251 30.8884 17.2251 30.9133 17.2348 30.9323C17.2434 30.9491 17.257 30.9627 17.2738 30.9713C17.2928 30.981 17.3177 30.981 17.3676 30.981H18.5795C18.6294 30.981 18.6543 30.981 18.6733 30.9907C18.6901 30.9992 18.7037 31.0128 18.7123 31.0296C18.722 31.0487 18.722 31.0736 18.722 31.1235V31.62C18.722 31.6699 18.722 31.6948 18.7123 31.7139C18.7037 31.7306 18.6901 31.7443 18.6733 31.7528C18.6543 31.7625 18.6294 31.7625 18.5795 31.7625H17.3676C17.3177 31.7625 17.2928 31.7625 17.2738 31.7722C17.257 31.7808 17.2434 31.7944 17.2348 31.8111C17.2251 31.8302 17.2251 31.8551 17.2251 31.905V32.8122C17.2251 32.8621 17.2251 32.887 17.2348 32.9061C17.2434 32.9228 17.257 32.9365 17.2738 32.945C17.2928 32.9547 17.3177 32.9547 17.3676 32.9547H18.8444C18.8943 32.9547 18.9192 32.9547 18.9383 32.9644C18.955 32.9729 18.9687 32.9866 18.9772 33.0033C18.9869 33.0224 18.9869 33.0473 18.9869 33.0972V33.5937C18.9869 33.6436 18.9869 33.6686 18.9772 33.6876C18.9687 33.7044 18.955 33.718 18.9383 33.7265C18.9192 33.7362 18.8943 33.7362 18.8444 33.7362H16.4271Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M19.7762 29.9544C19.7263 29.9544 19.7014 29.9544 19.6823 29.9447C19.6656 29.9361 19.652 29.9225 19.6434 29.9057C19.6337 29.8867 19.6337 29.8618 19.6337 29.8119V29.2425C19.6337 29.1926 19.6337 29.1677 19.6434 29.1486C19.652 29.1318 19.6656 29.1182 19.6823 29.1097C19.7014 29.1 19.7263 29.1 19.7762 29.1H23.2929C23.3428 29.1 23.3678 29.1 23.3868 29.1097C23.4036 29.1182 23.4172 29.1318 23.4257 29.1486C23.4354 29.1677 23.4354 29.1926 23.4354 29.2425V29.8119C23.4354 29.8618 23.4354 29.8867 23.4257 29.9057C23.4172 29.9225 23.4036 29.9361 23.3868 29.9447C23.3678 29.9544 23.3428 29.9544 23.2929 29.9544H22.1473C22.0974 29.9544 22.0725 29.9544 22.0535 29.9641C22.0367 29.9726 22.0231 29.9862 22.0145 30.003C22.0048 30.0221 22.0048 30.047 22.0048 30.0969V33.5937C22.0048 33.6436 22.0048 33.6686 21.9951 33.6876C21.9866 33.7044 21.973 33.718 21.9562 33.7265C21.9371 33.7362 21.9122 33.7362 21.8623 33.7362H21.2068C21.1569 33.7362 21.132 33.7362 21.113 33.7265C21.0962 33.718 21.0826 33.7044 21.074 33.6876C21.0643 33.6686 21.0643 33.6436 21.0643 33.5937V30.0969C21.0643 30.047 21.0643 30.0221 21.0546 30.003C21.0461 29.9862 21.0325 29.9726 21.0157 29.9641C20.9966 29.9544 20.9717 29.9544 20.9218 29.9544H19.7762Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M23.3724 33.7362C23.3031 33.7362 23.2685 33.7362 23.2463 33.7217C23.2268 33.709 23.2131 33.6891 23.2081 33.6664C23.2024 33.6405 23.2146 33.6081 23.2392 33.5433L24.8864 29.192C24.899 29.1588 24.9053 29.1422 24.9161 29.13C24.9257 29.1192 24.9377 29.1109 24.9512 29.1058C24.9665 29.1 24.9842 29.1 25.0197 29.1H26.0548C26.0903 29.1 26.108 29.1 26.1233 29.1058C26.1368 29.1109 26.1488 29.1192 26.1584 29.13C26.1692 29.1422 26.1755 29.1588 26.188 29.192L27.8353 33.5433C27.8598 33.6081 27.8721 33.6405 27.8664 33.6664C27.8613 33.6891 27.8476 33.709 27.8282 33.7217C27.806 33.7362 27.7713 33.7362 27.702 33.7362H27.0076C26.9718 33.7362 26.9539 33.7362 26.9385 33.7304C26.925 33.7252 26.9129 33.7167 26.9033 33.7058C26.8925 33.6934 26.8863 33.6766 26.8739 33.643L26.4988 32.6241C26.4865 32.5905 26.4803 32.5737 26.4694 32.5613C26.4599 32.5503 26.4478 32.5419 26.4342 32.5367C26.4188 32.5308 26.4009 32.5308 26.3651 32.5308H24.7094C24.6736 32.5308 24.6556 32.5308 24.6403 32.5367C24.6267 32.5419 24.6146 32.5503 24.605 32.5613C24.5942 32.5737 24.588 32.5905 24.5757 32.6241L24.2006 33.643C24.1882 33.6766 24.182 33.6934 24.1712 33.7058C24.1616 33.7167 24.1495 33.7252 24.1359 33.7304C24.1206 33.7362 24.1027 33.7362 24.0668 33.7362H23.3724ZM24.9713 31.538C24.9478 31.6024 24.936 31.6346 24.9419 31.6603C24.9471 31.6828 24.9608 31.7024 24.9802 31.715C25.0023 31.7294 25.0366 31.7294 25.1051 31.7294H25.9694C26.0379 31.7294 26.0721 31.7294 26.0943 31.715C26.1136 31.7024 26.1274 31.6828 26.1325 31.6603C26.1385 31.6346 26.1267 31.6024 26.1032 31.538L25.6711 30.3542C25.6298 30.2411 25.6092 30.1846 25.579 30.1686C25.5529 30.1547 25.5216 30.1547 25.4955 30.1686C25.4653 30.1846 25.4447 30.2411 25.4034 30.3542L24.9713 31.538Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M61.8577 15.8076C62.6931 15.8076 63.468 15.99 64.1819 16.3545C64.8463 16.6867 65.3785 17.1009 65.7781 17.5971C65.8437 17.6785 65.8294 17.7966 65.7498 17.8645L64.3459 19.0622C64.2574 19.1377 64.1236 19.1217 64.0503 19.0314C63.7909 18.7122 63.4929 18.4426 63.1565 18.2227C62.7616 17.9645 62.3285 17.835 61.8577 17.835C61.5237 17.835 61.2353 17.8962 60.9924 18.0176C60.7646 18.1391 60.5895 18.2986 60.468 18.4961C60.3617 18.6936 60.3088 18.9142 60.3088 19.1572C60.3089 19.5216 60.4375 19.8254 60.6955 20.0684C60.969 20.2962 61.319 20.494 61.7444 20.6611C62.1696 20.8282 62.6174 21.0106 63.0881 21.208C63.5742 21.3903 64.03 21.6182 64.4553 21.8916C64.8806 22.1498 65.2225 22.4989 65.4807 22.9395C65.7541 23.3647 65.8908 23.9037 65.8909 24.5566C65.8909 25.3312 65.7012 25.9923 65.3215 26.5391C64.957 27.0706 64.471 27.4809 63.8635 27.7695C63.2559 28.0582 62.5868 28.2031 61.8577 28.2031C60.8706 28.2031 60.0124 28.0207 59.2834 27.6562C58.6116 27.3063 58.0171 26.8462 57.5 26.2769C57.4264 26.1959 57.4354 26.0708 57.5176 25.9985L58.921 24.7635C59.0067 24.6881 59.1377 24.6998 59.2123 24.7863C59.5425 25.1694 59.9081 25.4884 60.3088 25.7422C60.7645 26.0307 61.2733 26.1748 61.8352 26.1748C62.3669 26.1748 62.7698 26.0381 63.0432 25.7646C63.3316 25.4761 63.4758 25.1268 63.4758 24.7168C63.4758 24.3371 63.339 24.0335 63.0657 23.8057C62.8074 23.5778 62.4656 23.38 62.0403 23.2129C61.6149 23.0458 61.1592 22.8708 60.6731 22.6885C60.2024 22.4911 59.7545 22.2557 59.3293 21.9824C58.9041 21.709 58.5539 21.3598 58.2805 20.9346C58.0223 20.494 57.8938 19.9316 57.8938 19.248C57.8938 18.6101 58.0606 18.033 58.3948 17.5166C58.729 16.9849 59.192 16.5665 59.7844 16.2627C60.3919 15.959 61.0831 15.8077 61.8577 15.8076Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M71.3215 15.8076C72.1569 15.8077 72.9319 15.99 73.6457 16.3545C74.3101 16.6867 74.8424 17.1009 75.2419 17.5971C75.3075 17.6785 75.2933 17.7966 75.2137 17.8644L73.8098 19.0622C73.7213 19.1377 73.5875 19.1217 73.5141 19.0313C73.2548 18.7122 72.9568 18.4427 72.6204 18.2227C72.2255 17.9645 71.7923 17.835 71.3215 17.835C70.9873 17.835 70.6984 17.8961 70.4553 18.0176C70.2275 18.1391 70.0524 18.2986 69.9309 18.4961C69.8246 18.6935 69.7717 18.9142 69.7717 19.1572C69.7718 19.5217 69.9013 19.8254 70.1594 20.0684C70.4328 20.2962 70.782 20.4941 71.2073 20.6611C71.6326 20.8282 72.0811 21.0105 72.552 21.208C73.0379 21.3903 73.494 21.6182 73.9192 21.8916C74.3443 22.1498 74.6864 22.4991 74.9446 22.9395C75.2178 23.3646 75.3547 23.9038 75.3547 24.5566C75.3547 25.3313 75.1641 25.9922 74.7844 26.5391C74.4199 27.0707 73.934 27.4809 73.3264 27.7695C72.7189 28.0581 72.0506 28.2031 71.3215 28.2031C70.3341 28.2031 69.4755 28.0208 68.7463 27.6562C68.0745 27.3063 67.48 26.8462 66.9629 26.2769C66.8893 26.1959 66.8983 26.0708 66.9805 25.9985L68.3839 24.7635C68.4696 24.6881 68.6006 24.6998 68.6752 24.7863C69.0054 25.1694 69.371 25.4884 69.7717 25.7422C70.2273 26.0307 70.7362 26.1747 71.2981 26.1748C71.8298 26.1748 72.2327 26.0381 72.5061 25.7646C72.7946 25.4761 72.9387 25.1268 72.9387 24.7168C72.9387 24.337 72.802 24.0335 72.5286 23.8057C72.2703 23.5778 71.9285 23.38 71.5032 23.2129C71.0779 23.0458 70.622 22.8707 70.136 22.6885C69.6653 22.4911 69.2174 22.2557 68.7922 21.9824C68.3671 21.7091 68.0178 21.3597 67.7444 20.9346C67.4861 20.494 67.3567 19.9316 67.3567 19.248C67.3567 18.6102 67.5236 18.033 67.8577 17.5166C68.1918 16.985 68.655 16.5665 69.2473 16.2627C69.8549 15.9589 70.5468 15.8076 71.3215 15.8076Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M86.3313 15.8076C87.1668 15.8076 87.9416 15.9899 88.6555 16.3545C89.32 16.6868 89.8521 17.1008 90.2517 17.5971C90.3173 17.6785 90.303 17.7966 90.2235 17.8644L88.8195 19.0622C88.731 19.1377 88.5973 19.1217 88.5239 19.0314C88.2646 18.7122 87.9666 18.4426 87.6301 18.2227C87.2352 17.9644 86.8022 17.835 86.3313 17.835C85.9971 17.835 85.7081 17.8961 85.4651 18.0176C85.2374 18.1391 85.0631 18.2988 84.9416 18.4961C84.8353 18.6936 84.7815 18.9142 84.7815 19.1572C84.7816 19.5216 84.911 19.8254 85.1692 20.0684C85.4426 20.2962 85.7918 20.4941 86.217 20.6611C86.6424 20.8282 87.0909 21.0105 87.5618 21.208C88.0478 21.3903 88.5036 21.6182 88.9289 21.8916C89.3542 22.1498 89.6961 22.499 89.9543 22.9395C90.2276 23.3647 90.3644 23.9037 90.3645 24.5566C90.3645 25.3311 90.1747 25.9923 89.7952 26.5391C89.4306 27.0707 88.9438 27.4809 88.3362 27.7695C87.7286 28.0581 87.0604 28.2031 86.3313 28.2031C85.3439 28.2031 84.4852 28.0208 83.7561 27.6562C83.0843 27.3063 82.4905 26.8461 81.9736 26.2769C81.9 26.1959 81.909 26.0708 81.9911 25.9985L83.3937 24.7636C83.4793 24.6881 83.6104 24.6998 83.685 24.7863C84.0152 25.1694 84.3808 25.4884 84.7815 25.7422C85.2372 26.0308 85.7468 26.1748 86.3088 26.1748C86.8403 26.1748 87.2425 26.038 87.5159 25.7646C87.8044 25.4761 87.9494 25.1269 87.9495 24.7168C87.9495 24.3372 87.8125 24.0335 87.5393 23.8057C87.2811 23.5779 86.9391 23.38 86.5139 23.2129C86.0886 23.0458 85.6328 22.8707 85.1467 22.6885C84.6758 22.491 84.2273 22.2559 83.802 21.9824C83.3768 21.7091 83.0275 21.3597 82.7541 20.9346C82.4959 20.494 82.3664 19.9316 82.3664 19.248C82.3664 18.61 82.5342 18.0331 82.8684 17.5166C83.2026 16.9849 83.6656 16.5665 84.258 16.2627C84.8656 15.959 85.5567 15.8076 86.3313 15.8076Z" fill="var(--c--globals--colors--logo-1)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.191 16.0361C107.239 16.0361 108.128 16.2257 108.857 16.6055C109.586 16.9852 110.133 17.5094 110.498 18.1777C110.877 18.8461 111.067 19.5981 111.067 20.4336V27.7756C111.067 27.886 110.977 27.9756 110.867 27.9756H108.76C108.65 27.9756 108.56 27.886 108.56 27.7756V27.0162C108.56 26.9218 108.438 26.8771 108.372 26.9448C108.27 27.0497 108.159 27.1502 108.037 27.2461C107.626 27.5803 107.148 27.8392 106.601 28.0215C106.054 28.2189 105.5 28.3173 104.938 28.3174C103.814 28.3174 102.894 28.0139 102.18 27.4062C101.466 26.7986 101.11 25.9855 101.11 24.9678C101.11 24.102 101.436 23.3654 102.089 22.7578C102.743 22.1502 103.684 21.7473 104.915 21.5498L108.394 20.9589C108.49 20.9426 108.56 20.8593 108.56 20.7618V20.4336C108.56 19.7502 108.333 19.2186 107.877 18.8389C107.422 18.444 106.829 18.2462 106.1 18.2461C105.508 18.2461 104.968 18.3756 104.482 18.6338C104.071 18.8594 103.717 19.1602 103.422 19.5365C103.351 19.626 103.222 19.6465 103.132 19.5773L101.606 18.4098C101.52 18.3442 101.501 18.2219 101.568 18.1365C102.052 17.5117 102.666 17.0165 103.411 16.6514C104.231 16.2412 105.158 16.0361 106.191 16.0361ZM105.553 23.2139C104.87 23.3202 104.376 23.5171 104.072 23.8057C103.768 24.079 103.616 24.4284 103.616 24.8535C103.616 25.2788 103.775 25.6289 104.094 25.9023C104.428 26.1758 104.877 26.3125 105.439 26.3125C106.138 26.3125 106.753 26.153 107.285 25.834C107.721 25.5722 108.09 25.2382 108.393 24.833C108.508 24.6795 108.56 24.4899 108.56 24.2983V22.9508C108.56 22.8272 108.449 22.7332 108.327 22.7535L105.553 23.2139Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M95.9868 16.2912C95.9868 16.4017 96.0764 16.4912 96.1868 16.4912H99.5017C99.6121 16.4912 99.7017 16.5808 99.7017 16.6912V18.5471C99.7017 18.6575 99.6121 18.7471 99.5017 18.7471H96.1868C96.0764 18.7471 95.9868 18.8366 95.9868 18.9471V23.9199C95.9868 24.421 96.0708 24.8159 96.2378 25.1045C96.4048 25.3929 96.6403 25.6055 96.9439 25.7422C97.2477 25.8637 97.605 25.9248 98.0151 25.9248C98.4101 25.9248 98.7446 25.902 99.0181 25.8564C99.1739 25.8218 99.3174 25.7847 99.4485 25.7451C99.5829 25.7045 99.7241 25.8022 99.7241 25.9426V27.7314C99.7241 27.8104 99.6778 27.8826 99.6042 27.9113C99.3605 28.0061 99.0888 28.0733 98.7896 28.1123C98.4554 28.173 98.0605 28.2031 97.605 28.2031C96.6936 28.2031 95.926 28.0436 95.3032 27.7246C94.6959 27.3904 94.2325 26.9117 93.9136 26.2891C93.6099 25.6512 93.4585 24.8615 93.4585 23.9199V18.9471C93.4585 18.8366 93.369 18.7471 93.2585 18.7471H91.6087C91.4982 18.7471 91.4087 18.6575 91.4087 18.5471V16.6912C91.4087 16.5808 91.4982 16.4912 91.6087 16.4912H93.2585C93.369 16.4912 93.4585 16.4017 93.4585 16.2912V13.8211C93.4585 13.7106 93.548 13.6211 93.6585 13.6211H95.7868C95.8973 13.6211 95.9868 13.7106 95.9868 13.8211V16.2912Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M130.563 16.2912C130.563 16.4017 130.653 16.4912 130.763 16.4912H134.077C134.187 16.4912 134.277 16.5808 134.277 16.6912V18.5471C134.277 18.6575 134.187 18.7471 134.077 18.7471H130.763C130.653 18.7471 130.563 18.8366 130.563 18.9471V23.9199C130.563 24.421 130.646 24.8159 130.813 25.1045C130.98 25.3931 131.216 25.6055 131.52 25.7422C131.824 25.8637 132.18 25.9248 132.59 25.9248C132.985 25.9248 133.32 25.902 133.593 25.8564C133.749 25.8218 133.893 25.7847 134.024 25.7451C134.158 25.7045 134.299 25.8022 134.299 25.9425V27.7313C134.299 27.8104 134.253 27.8826 134.179 27.9113C133.936 28.0061 133.665 28.0733 133.366 28.1123C133.032 28.1731 132.636 28.2031 132.18 28.2031C131.269 28.2031 130.502 28.0436 129.879 27.7246C129.272 27.3904 128.809 26.9119 128.49 26.2891C128.186 25.6511 128.034 24.8616 128.034 23.9199V18.9471C128.034 18.8366 127.944 18.7471 127.834 18.7471H126.092C125.982 18.7471 125.892 18.6575 125.892 18.5471V16.6912C125.892 16.5808 125.982 16.4912 126.092 16.4912H127.834C127.944 16.4912 128.034 16.4017 128.034 16.2912V13.8211C128.034 13.7106 128.123 13.6211 128.234 13.6211H130.363C130.473 13.6211 130.563 13.7106 130.563 13.8211V16.2912Z" fill="var(--c--globals--colors--logo-1)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.8209 27.7034C56.8716 27.8344 56.7749 27.9756 56.6344 27.9756H54.0801C53.9969 27.9756 53.9224 27.9241 53.893 27.8464L52.3355 23.7298C52.3061 23.652 52.2316 23.6006 52.1485 23.6006H45.6707C45.5876 23.6006 45.5132 23.6519 45.4837 23.7296L43.9196 27.8466C43.8901 27.9243 43.8157 27.9756 43.7326 27.9756H41.2002C41.0598 27.9756 40.9631 27.8346 41.0136 27.7036L47.0107 12.1534C47.0404 12.0763 47.1146 12.0254 47.1973 12.0254H50.6144C50.697 12.0254 50.7711 12.0762 50.8009 12.1532L56.8209 27.7034ZM46.5361 20.9604C46.4864 21.0913 46.5831 21.2314 46.7231 21.2314H51.1006C51.2405 21.2314 51.3372 21.0915 51.2876 20.9606L49.1032 15.1895C49.038 15.0173 48.7946 15.0172 48.7292 15.1892L46.5361 20.9604Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M80.1372 27.7756C80.1372 27.886 80.0477 27.9756 79.9372 27.9756H77.8314C77.7209 27.9756 77.6314 27.886 77.6314 27.7756V16.6912C77.6314 16.5808 77.7209 16.4912 77.8314 16.4912H79.9372C80.0477 16.4912 80.1372 16.5808 80.1372 16.6912V27.7756Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M120.009 16.0361C120.86 16.0361 121.619 16.2258 122.288 16.6055C122.971 16.9852 123.511 17.5477 123.906 18.292C124.301 19.0363 124.499 19.9708 124.499 21.0947V27.7756C124.499 27.886 124.409 27.9756 124.299 27.9756H122.169C122.059 27.9756 121.969 27.886 121.969 27.7756V21.1855C121.969 20.3046 121.741 19.6209 121.286 19.1348C120.83 18.6335 120.206 18.3828 119.417 18.3828C118.915 18.3829 118.475 18.4813 118.095 18.6787C117.715 18.861 117.389 19.0971 117.116 19.3857C116.919 19.6051 116.745 19.8331 116.592 20.0699C116.498 20.2162 116.455 20.3884 116.455 20.5624V27.7756C116.455 27.886 116.365 27.9756 116.255 27.9756H114.149C114.038 27.9756 113.949 27.886 113.949 27.7756V16.6912C113.949 16.5808 114.038 16.4912 114.149 16.4912H116.255C116.365 16.4912 116.455 16.5808 116.455 16.6912V17.2892C116.455 17.3794 116.566 17.4247 116.631 17.3628C117.008 17.0071 117.443 16.7088 117.936 16.4688C118.528 16.1802 119.219 16.0361 120.009 16.0361Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M78.9067 10.75C79.2105 10.75 79.484 10.8257 79.7271 10.9775C79.9853 11.1294 80.1831 11.3345 80.3198 11.5928C80.4717 11.8358 80.5474 12.1093 80.5474 12.4131C80.5474 12.7168 80.4716 12.9977 80.3198 13.2559C80.1831 13.5141 79.9853 13.7192 79.7271 13.8711C79.484 14.0078 79.2106 14.0762 78.9067 14.0762C78.603 14.0762 78.3222 14.0078 78.064 13.8711C77.8057 13.7192 77.6006 13.5141 77.4487 13.2559C77.2969 12.9977 77.2212 12.7168 77.2212 12.4131C77.2212 11.9574 77.3807 11.57 77.6997 11.251C78.0338 10.917 78.436 10.75 78.9067 10.75Z" fill="var(--c--globals--colors--logo-1)"/>
<defs>
<linearGradient id="paint0_linear_6479_3681" x1="20" y1="27" x2="20" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#2845C1"/>
<stop offset="1" stop-color="#EEF1F4"/>
<stop stop-color="var(--c--globals--colors--logo-1)"/>
<stop offset="1" stop-color="var(--c--contextuals--background--semantic--neutral--tertiary)"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1,20 +1,20 @@
<svg width="65" height="64" viewBox="0 0 65 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44.7737 12.1078C44.7737 10.7446 44.7737 10.063 44.6322 9.44065C44.3707 8.29032 43.7579 7.24979 42.8788 6.46318C42.4032 6.03759 41.8071 5.707 40.615 5.04582L37.5686 3.35621C36.4423 2.73158 35.8792 2.41926 35.2982 2.24352C34.2246 1.91883 33.079 1.91883 32.0054 2.24352C31.4244 2.41926 30.8613 2.73158 29.7351 3.35621L28.4787 4.05301C26.9538 4.89875 26.1914 5.32161 25.8421 5.81385C25.1888 6.73448 25.1894 7.96764 25.8436 8.88764C26.1933 9.37954 26.9562 9.80168 28.4819 10.646L29.4989 11.2088C30.6929 11.8695 31.2899 12.1998 31.7663 12.6255C32.6468 13.4121 33.2607 14.4534 33.5226 15.6047C33.6644 16.2276 33.6644 16.9099 33.6644 18.2745V19.4391C33.6644 21.0927 33.6644 21.9195 33.9128 22.4525C34.3771 23.4487 35.4075 24.0556 36.5038 23.9787C37.0905 23.9376 37.8137 23.5368 39.2599 22.7351L40.6133 21.9849C41.8059 21.3238 42.4022 20.9933 42.878 20.5677C43.7575 19.7811 44.3705 18.7404 44.6321 17.5898C44.7737 16.9673 44.7737 16.2855 44.7737 14.922V12.1078Z" fill="#2845C1"/>
<path d="M10.8637 32.3143C9.68318 32.9959 9.0929 33.3367 8.62465 33.7704C7.75919 34.572 7.16444 35.623 6.92278 36.7776C6.79203 37.4023 6.80376 38.0838 6.82723 39.4468L6.88719 42.9299C6.90935 44.2175 6.92044 44.8614 7.05877 45.4524C7.31435 46.5445 7.88715 47.5366 8.70513 48.304C9.14785 48.7194 9.69988 49.0509 10.8039 49.7139L12.0356 50.4535C13.5304 51.3512 14.2779 51.8001 14.8788 51.8565C16.0027 51.962 17.0704 51.3449 17.54 50.3183C17.7912 49.7695 17.7753 48.8978 17.7436 47.1543L17.7225 45.9921C17.6977 44.6277 17.6853 43.9456 17.8157 43.3202C18.0567 42.1643 18.6515 41.1121 19.5176 40.3095C19.9862 39.8753 20.5771 39.5342 21.7589 38.8519L22.7674 38.2696C24.1995 37.4428 24.9155 37.0294 25.2529 36.5477C25.8835 35.6475 25.8939 34.4518 25.2792 33.5407C24.9502 33.0532 24.2415 32.6273 22.8241 31.7757L21.4978 30.9787C20.329 30.2765 19.7446 29.9253 19.1381 29.726C18.0171 29.3577 16.8093 29.3471 15.6821 29.6959C15.0722 29.8845 14.4818 30.2254 13.3009 30.9072L10.8637 32.3143Z" fill="#2845C1"/>
<path d="M22.53 51.8922C22.53 53.2554 22.53 53.937 22.6714 54.5594C22.9329 55.7097 23.5457 56.7502 24.4248 57.5368C24.9005 57.9624 25.4965 58.293 26.6887 58.9542L29.7351 60.6438C30.8613 61.2684 31.4244 61.5807 32.0054 61.7565C33.079 62.0812 34.2246 62.0812 35.2982 61.7565C35.8792 61.5807 36.4423 61.2684 37.5686 60.6438L38.8249 59.947C40.3498 59.1012 41.1122 58.6784 41.4615 58.1862C42.1148 57.2655 42.1142 56.0324 41.4601 55.1124C41.1103 54.6205 40.3474 54.1983 38.8218 53.354L37.8047 52.7912C36.6107 52.1305 36.0137 51.8002 35.5373 51.3745C34.6568 50.5879 34.043 49.5466 33.781 48.3953C33.6393 47.7724 33.6393 47.0901 33.6393 45.7255V44.5609C33.6393 42.9073 33.6393 42.0805 33.3908 41.5475C32.9265 40.5513 31.8962 39.9444 30.7998 40.0213C30.2131 40.0624 29.49 40.4632 28.0437 41.2649L26.6903 42.0151C25.4977 42.6761 24.9015 43.0067 24.4256 43.4323C23.5461 44.2189 22.9331 45.2596 22.6715 46.4102C22.53 47.0327 22.53 47.7145 22.53 49.078V51.8922Z" fill="#2845C1"/>
<path d="M45.318 51.5779C46.4986 52.2595 47.0889 52.6003 47.6986 52.789C48.8256 53.1376 50.0331 53.1273 51.1539 52.7592C51.7603 52.5601 52.3446 52.2092 53.5132 51.5074L56.4997 49.7139C57.6037 49.0509 58.1558 48.7194 58.5985 48.304C59.4165 47.5366 59.9893 46.5445 60.2449 45.4524C60.3832 44.8614 60.3943 44.2175 60.4164 42.9299L60.4412 41.4935C60.4712 39.75 60.4862 38.8783 60.2345 38.3297C59.7639 37.3036 58.6957 36.6875 57.5718 36.794C56.9709 36.851 56.2239 37.3006 54.7299 38.1997L53.734 38.7991C52.5648 39.5028 51.9802 39.8546 51.3734 40.0544C50.2519 40.4236 49.0432 40.4346 47.9152 40.0858C47.3049 39.8971 46.714 39.5559 45.5322 38.8736L44.5236 38.2913C43.0916 37.4645 42.3755 37.0511 41.7897 36.9998C40.6948 36.9038 39.654 37.4926 39.1724 38.4806C38.9147 39.0092 38.9003 39.8359 38.8714 41.4892L38.8444 43.0364C38.8206 44.3997 38.8087 45.0814 38.9394 45.7062C39.1809 46.8612 39.7756 47.9125 40.6412 48.7143C41.1096 49.1481 41.7 49.489 42.8809 50.1708L45.318 51.5779Z" fill="#2845C1"/>
<path d="M56.4399 31.6857C57.6204 31.0041 58.2107 30.6633 58.679 30.2296C59.5444 29.428 60.1392 28.3771 60.3808 27.2224C60.5116 26.5977 60.4999 25.9162 60.4764 24.5532L60.4164 21.0701C60.3943 19.7825 60.3832 19.1386 60.2449 18.5476C59.9893 17.4555 59.4165 16.4634 58.5985 15.696C58.1558 15.2806 57.6037 14.9491 56.4997 14.2861L55.2681 13.5465C53.7732 12.6488 53.0258 12.1999 52.4248 12.1435C51.3009 12.038 50.2332 12.6551 49.7636 13.6817C49.5125 14.2305 49.5283 15.1022 49.56 16.8457L49.5811 18.0079C49.6059 19.3723 49.6183 20.0544 49.4879 20.6798C49.2469 21.8357 48.6521 22.8879 47.786 23.6905C47.3174 24.1247 46.7265 24.4658 45.5447 25.1481L44.5362 25.7304C43.1041 26.5572 42.3881 26.9706 42.0507 27.4523C41.4201 28.3525 41.4097 29.5482 42.0244 30.4593C42.3534 30.9468 43.0621 31.3727 44.4795 32.2243L45.8059 33.0213C46.9747 33.7235 47.5591 34.0747 48.1655 34.274C49.2865 34.6423 50.4943 34.6529 51.6215 34.3041C52.2314 34.1155 52.8219 33.7746 54.0027 33.0928L56.4399 31.6857Z" fill="#2845C1"/>
<path d="M21.9855 12.4222C20.805 11.7406 20.2147 11.3998 19.6049 11.2112C18.478 10.8625 17.2705 10.8728 16.1497 11.2409C15.5433 11.44 14.959 11.7909 13.7903 12.4927L10.8039 14.2862C9.6998 14.9492 9.14777 15.2807 8.70505 15.6961C7.88707 16.4635 7.31427 17.4556 7.05869 18.5477C6.92036 19.1387 6.90927 19.7826 6.88711 21.0702L6.86238 22.5066C6.83237 24.2501 6.81736 25.1218 7.069 25.6704C7.53963 26.6965 8.60788 27.3126 9.73171 27.2061C10.3326 27.1491 11.0796 26.6995 12.5736 25.8004L13.5695 25.201C14.7387 24.4973 15.3233 24.1455 15.9301 23.9457C17.0517 23.5765 18.2603 23.5655 19.3884 23.9143C19.9987 24.103 20.5896 24.4442 21.7714 25.1265L22.7799 25.7088C24.212 26.5356 24.928 26.949 25.5139 27.0003C26.6087 27.0963 27.6495 26.5075 28.1311 25.5195C28.3889 24.9909 28.4033 24.1642 28.4321 22.5109L28.4591 20.9638C28.4829 19.6004 28.4948 18.9187 28.3642 18.2939C28.1227 17.1389 27.5279 16.0876 26.6623 15.2858C26.194 14.852 25.6035 14.5111 24.4227 13.8293L21.9855 12.4222Z" fill="#C83F49"/>
<rect x="8.8125" y="46" width="48" height="18" rx="9" fill="#EEF1F4"/>
<path d="M44.7737 12.1078C44.7737 10.7446 44.7737 10.063 44.6322 9.44065C44.3707 8.29032 43.7579 7.24979 42.8788 6.46318C42.4032 6.03759 41.8071 5.707 40.615 5.04582L37.5686 3.35621C36.4423 2.73158 35.8792 2.41926 35.2982 2.24352C34.2246 1.91883 33.079 1.91883 32.0054 2.24352C31.4244 2.41926 30.8613 2.73158 29.7351 3.35621L28.4787 4.05301C26.9538 4.89875 26.1914 5.32161 25.8421 5.81385C25.1888 6.73448 25.1894 7.96764 25.8436 8.88764C26.1933 9.37954 26.9562 9.80168 28.4819 10.646L29.4989 11.2088C30.6929 11.8695 31.2899 12.1998 31.7663 12.6255C32.6468 13.4121 33.2607 14.4534 33.5226 15.6047C33.6644 16.2276 33.6644 16.9099 33.6644 18.2745V19.4391C33.6644 21.0927 33.6644 21.9195 33.9128 22.4525C34.3771 23.4487 35.4075 24.0556 36.5038 23.9787C37.0905 23.9376 37.8137 23.5368 39.2599 22.7351L40.6133 21.9849C41.8059 21.3238 42.4022 20.9933 42.878 20.5677C43.7575 19.7811 44.3705 18.7404 44.6321 17.5898C44.7737 16.9673 44.7737 16.2855 44.7737 14.922V12.1078Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M10.8637 32.3143C9.68318 32.9959 9.0929 33.3367 8.62465 33.7704C7.75919 34.572 7.16444 35.623 6.92278 36.7776C6.79203 37.4023 6.80376 38.0838 6.82723 39.4468L6.88719 42.9299C6.90935 44.2175 6.92044 44.8614 7.05877 45.4524C7.31435 46.5445 7.88715 47.5366 8.70513 48.304C9.14785 48.7194 9.69988 49.0509 10.8039 49.7139L12.0356 50.4535C13.5304 51.3512 14.2779 51.8001 14.8788 51.8565C16.0027 51.962 17.0704 51.3449 17.54 50.3183C17.7912 49.7695 17.7753 48.8978 17.7436 47.1543L17.7225 45.9921C17.6977 44.6277 17.6853 43.9456 17.8157 43.3202C18.0567 42.1643 18.6515 41.1121 19.5176 40.3095C19.9862 39.8753 20.5771 39.5342 21.7589 38.8519L22.7674 38.2696C24.1995 37.4428 24.9155 37.0294 25.2529 36.5477C25.8835 35.6475 25.8939 34.4518 25.2792 33.5407C24.9502 33.0532 24.2415 32.6273 22.8241 31.7757L21.4978 30.9787C20.329 30.2765 19.7446 29.9253 19.1381 29.726C18.0171 29.3577 16.8093 29.3471 15.6821 29.6959C15.0722 29.8845 14.4818 30.2254 13.3009 30.9072L10.8637 32.3143Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M22.53 51.8922C22.53 53.2554 22.53 53.937 22.6714 54.5594C22.9329 55.7097 23.5457 56.7502 24.4248 57.5368C24.9005 57.9624 25.4965 58.293 26.6887 58.9542L29.7351 60.6438C30.8613 61.2684 31.4244 61.5807 32.0054 61.7565C33.079 62.0812 34.2246 62.0812 35.2982 61.7565C35.8792 61.5807 36.4423 61.2684 37.5686 60.6438L38.8249 59.947C40.3498 59.1012 41.1122 58.6784 41.4615 58.1862C42.1148 57.2655 42.1142 56.0324 41.4601 55.1124C41.1103 54.6205 40.3474 54.1983 38.8218 53.354L37.8047 52.7912C36.6107 52.1305 36.0137 51.8002 35.5373 51.3745C34.6568 50.5879 34.043 49.5466 33.781 48.3953C33.6393 47.7724 33.6393 47.0901 33.6393 45.7255V44.5609C33.6393 42.9073 33.6393 42.0805 33.3908 41.5475C32.9265 40.5513 31.8962 39.9444 30.7998 40.0213C30.2131 40.0624 29.49 40.4632 28.0437 41.2649L26.6903 42.0151C25.4977 42.6761 24.9015 43.0067 24.4256 43.4323C23.5461 44.2189 22.9331 45.2596 22.6715 46.4102C22.53 47.0327 22.53 47.7145 22.53 49.078V51.8922Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M45.318 51.5779C46.4986 52.2595 47.0889 52.6003 47.6986 52.789C48.8256 53.1376 50.0331 53.1273 51.1539 52.7592C51.7603 52.5601 52.3446 52.2092 53.5132 51.5074L56.4997 49.7139C57.6037 49.0509 58.1558 48.7194 58.5985 48.304C59.4165 47.5366 59.9893 46.5445 60.2449 45.4524C60.3832 44.8614 60.3943 44.2175 60.4164 42.9299L60.4412 41.4935C60.4712 39.75 60.4862 38.8783 60.2345 38.3297C59.7639 37.3036 58.6957 36.6875 57.5718 36.794C56.9709 36.851 56.2239 37.3006 54.7299 38.1997L53.734 38.7991C52.5648 39.5028 51.9802 39.8546 51.3734 40.0544C50.2519 40.4236 49.0432 40.4346 47.9152 40.0858C47.3049 39.8971 46.714 39.5559 45.5322 38.8736L44.5236 38.2913C43.0916 37.4645 42.3755 37.0511 41.7897 36.9998C40.6948 36.9038 39.654 37.4926 39.1724 38.4806C38.9147 39.0092 38.9003 39.8359 38.8714 41.4892L38.8444 43.0364C38.8206 44.3997 38.8087 45.0814 38.9394 45.7062C39.1809 46.8612 39.7756 47.9125 40.6412 48.7143C41.1096 49.1481 41.7 49.489 42.8809 50.1708L45.318 51.5779Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M56.4399 31.6857C57.6204 31.0041 58.2107 30.6633 58.679 30.2296C59.5444 29.428 60.1392 28.3771 60.3808 27.2224C60.5116 26.5977 60.4999 25.9162 60.4764 24.5532L60.4164 21.0701C60.3943 19.7825 60.3832 19.1386 60.2449 18.5476C59.9893 17.4555 59.4165 16.4634 58.5985 15.696C58.1558 15.2806 57.6037 14.9491 56.4997 14.2861L55.2681 13.5465C53.7732 12.6488 53.0258 12.1999 52.4248 12.1435C51.3009 12.038 50.2332 12.6551 49.7636 13.6817C49.5125 14.2305 49.5283 15.1022 49.56 16.8457L49.5811 18.0079C49.6059 19.3723 49.6183 20.0544 49.4879 20.6798C49.2469 21.8357 48.6521 22.8879 47.786 23.6905C47.3174 24.1247 46.7265 24.4658 45.5447 25.1481L44.5362 25.7304C43.1041 26.5572 42.3881 26.9706 42.0507 27.4523C41.4201 28.3525 41.4097 29.5482 42.0244 30.4593C42.3534 30.9468 43.0621 31.3727 44.4795 32.2243L45.8059 33.0213C46.9747 33.7235 47.5591 34.0747 48.1655 34.274C49.2865 34.6423 50.4943 34.6529 51.6215 34.3041C52.2314 34.1155 52.8219 33.7746 54.0027 33.0928L56.4399 31.6857Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M21.9855 12.4222C20.805 11.7406 20.2147 11.3998 19.6049 11.2112C18.478 10.8625 17.2705 10.8728 16.1497 11.2409C15.5433 11.44 14.959 11.7909 13.7903 12.4927L10.8039 14.2862C9.6998 14.9492 9.14777 15.2807 8.70505 15.6961C7.88707 16.4635 7.31427 17.4556 7.05869 18.5477C6.92036 19.1387 6.90927 19.7826 6.88711 21.0702L6.86238 22.5066C6.83237 24.2501 6.81736 25.1218 7.069 25.6704C7.53963 26.6965 8.60788 27.3126 9.73171 27.2061C10.3326 27.1491 11.0796 26.6995 12.5736 25.8004L13.5695 25.201C14.7387 24.4973 15.3233 24.1455 15.9301 23.9457C17.0517 23.5765 18.2603 23.5655 19.3884 23.9143C19.9987 24.103 20.5896 24.4442 21.7714 25.1265L22.7799 25.7088C24.212 26.5356 24.928 26.949 25.5139 27.0003C26.6087 27.0963 27.6495 26.5075 28.1311 25.5195C28.3889 24.9909 28.4033 24.1642 28.4321 22.5109L28.4591 20.9638C28.4829 19.6004 28.4948 18.9187 28.3642 18.2939C28.1227 17.1389 27.5279 16.0876 26.6623 15.2858C26.194 14.852 25.6035 14.5111 24.4227 13.8293L21.9855 12.4222Z" fill="var(--c--globals--colors--logo-2)"/>
<rect x="8.8125" y="46" width="48" height="18" rx="9" fill="var(--c--contextuals--background--semantic--neutral--tertiary)"/>
<rect x="8.8125" y="46" width="48" height="18" rx="9" fill="url(#paint0_linear_13360_26870)" fill-opacity="0.1"/>
<path d="M17.4974 59.4725C17.3976 59.4725 17.3478 59.4725 17.3097 59.4531C17.2761 59.436 17.2489 59.4087 17.2318 59.3752C17.2124 59.3371 17.2124 59.2872 17.2124 59.1875V50.485C17.2124 50.3852 17.2124 50.3353 17.2318 50.2972C17.2489 50.2637 17.2761 50.2364 17.3097 50.2194C17.3478 50.2 17.3976 50.2 17.4974 50.2H19.9412C21.8752 50.2 23.0541 51.1537 23.0541 52.7168C23.0541 53.296 22.8514 53.8129 22.464 54.2136C22.3269 54.3553 22.2583 54.4262 22.2468 54.4727C22.2343 54.5229 22.237 54.5479 22.2599 54.5943C22.2811 54.6373 22.3603 54.6901 22.5189 54.7957C23.2086 55.255 23.584 55.9399 23.584 56.7437C23.584 58.4393 22.2726 59.4725 20.1399 59.4725H17.4974ZM19.9809 51.763H19.3784C19.2786 51.763 19.2288 51.763 19.1907 51.7825C19.1571 51.7995 19.1299 51.8268 19.1128 51.8603C19.0934 51.8984 19.0934 51.9483 19.0934 52.048V53.571C19.0934 53.6707 19.0934 53.7206 19.1128 53.7587C19.1299 53.7922 19.1571 53.8195 19.1907 53.8366C19.2288 53.856 19.2786 53.856 19.3784 53.856H19.9809C20.6962 53.856 21.1201 53.4718 21.1201 52.7963C21.1201 52.1472 20.6962 51.763 19.9809 51.763ZM20.2458 55.4191H19.3784C19.2786 55.4191 19.2288 55.4191 19.1907 55.4385C19.1571 55.4556 19.1299 55.4828 19.1128 55.5163C19.0934 55.5544 19.0934 55.6043 19.0934 55.7041V57.6244C19.0934 57.7242 19.0934 57.774 19.1128 57.8121C19.1299 57.8457 19.1571 57.8729 19.1907 57.89C19.2288 57.9094 19.2786 57.9094 19.3784 57.9094H20.2458C21.1201 57.9094 21.65 57.4458 21.65 56.6642C21.65 55.8694 21.1201 55.4191 20.2458 55.4191Z" fill="#2845C1"/>
<path d="M25.6668 59.4725C25.567 59.4725 25.5171 59.4725 25.479 59.4531C25.4455 59.436 25.4182 59.4087 25.4012 59.3752C25.3818 59.3371 25.3818 59.2872 25.3818 59.1875V50.485C25.3818 50.3852 25.3818 50.3353 25.4012 50.2972C25.4182 50.2637 25.4455 50.2364 25.479 50.2194C25.5171 50.2 25.567 50.2 25.6668 50.2H30.5013C30.6011 50.2 30.651 50.2 30.6891 50.2194C30.7226 50.2364 30.7498 50.2637 30.7669 50.2972C30.7863 50.3353 30.7863 50.3852 30.7863 50.485V51.478C30.7863 51.5778 30.7863 51.6277 30.7669 51.6658C30.7498 51.6993 30.7226 51.7265 30.6891 51.7436C30.651 51.763 30.6011 51.763 30.5013 51.763H27.5478C27.448 51.763 27.3981 51.763 27.36 51.7825C27.3265 51.7995 27.2992 51.8268 27.2822 51.8603C27.2628 51.8984 27.2628 51.9483 27.2628 52.048V53.677C27.2628 53.7767 27.2628 53.8266 27.2822 53.8647C27.2992 53.8982 27.3265 53.9255 27.36 53.9425C27.3981 53.962 27.448 53.962 27.5478 53.962H29.9715C30.0712 53.962 30.1211 53.962 30.1592 53.9814C30.1927 53.9984 30.22 54.0257 30.237 54.0592C30.2565 54.0973 30.2565 54.1472 30.2565 54.247V55.24C30.2565 55.3398 30.2565 55.3897 30.237 55.4278C30.22 55.4613 30.1927 55.4885 30.1592 55.5056C30.1211 55.525 30.0712 55.525 29.9715 55.525H27.5478C27.448 55.525 27.3981 55.525 27.36 55.5445C27.3265 55.5615 27.2992 55.5888 27.2822 55.6223C27.2628 55.6604 27.2628 55.7103 27.2628 55.81V57.6244C27.2628 57.7242 27.2628 57.774 27.2822 57.8121C27.2992 57.8457 27.3265 57.8729 27.36 57.89C27.3981 57.9094 27.448 57.9094 27.5478 57.9094H30.5013C30.6011 57.9094 30.651 57.9094 30.6891 57.9288C30.7226 57.9459 30.7498 57.9731 30.7669 58.0067C30.7863 58.0448 30.7863 58.0946 30.7863 58.1944V59.1875C30.7863 59.2872 30.7863 59.3371 30.7669 59.3752C30.7498 59.4087 30.7226 59.436 30.6891 59.4531C30.651 59.4725 30.6011 59.4725 30.5013 59.4725H25.6668Z" fill="#2845C1"/>
<path d="M32.3649 51.9087C32.2652 51.9087 32.2153 51.9087 32.1772 51.8893C32.1437 51.8723 32.1164 51.845 32.0993 51.8115C32.0799 51.7734 32.0799 51.7235 32.0799 51.6237V50.485C32.0799 50.3852 32.0799 50.3353 32.0993 50.2972C32.1164 50.2637 32.1437 50.2364 32.1772 50.2194C32.2153 50.2 32.2652 50.2 32.3649 50.2H39.3984C39.4982 50.2 39.548 50.2 39.5861 50.2194C39.6197 50.2364 39.6469 50.2637 39.664 50.2972C39.6834 50.3353 39.6834 50.3852 39.6834 50.485V51.6237C39.6834 51.7235 39.6834 51.7734 39.664 51.8115C39.6469 51.845 39.6197 51.8723 39.5861 51.8893C39.548 51.9087 39.4982 51.9087 39.3984 51.9087H37.1072C37.0074 51.9087 36.9575 51.9087 36.9194 51.9282C36.8859 51.9452 36.8587 51.9725 36.8416 52.006C36.8222 52.0441 36.8222 52.094 36.8222 52.1937V59.1875C36.8222 59.2872 36.8222 59.3371 36.8027 59.3752C36.7857 59.4087 36.7584 59.436 36.7249 59.4531C36.6868 59.4725 36.6369 59.4725 36.5372 59.4725H35.2262C35.1264 59.4725 35.0765 59.4725 35.0384 59.4531C35.0049 59.436 34.9776 59.4087 34.9606 59.3752C34.9412 59.3371 34.9412 59.2872 34.9412 59.1875V52.1937C34.9412 52.094 34.9412 52.0441 34.9217 52.006C34.9047 51.9725 34.8774 51.9452 34.8439 51.9282C34.8058 51.9087 34.7559 51.9087 34.6562 51.9087H32.3649Z" fill="#2845C1"/>
<path d="M39.5574 59.4725C39.4188 59.4725 39.3495 59.4725 39.305 59.4434C39.2662 59.4179 39.2388 59.3783 39.2287 59.3329C39.2172 59.281 39.2418 59.2162 39.2908 59.0866L42.5854 50.384C42.6105 50.3177 42.6231 50.2845 42.6447 50.26C42.6638 50.2384 42.6879 50.2218 42.7149 50.2115C42.7454 50.2 42.7809 50.2 42.8519 50.2H44.9221C44.993 50.2 45.0285 50.2 45.0591 50.2115C45.086 50.2218 45.1101 50.2384 45.1293 50.26C45.1509 50.2845 45.1635 50.3177 45.1886 50.384L48.4831 59.0866C48.5322 59.2162 48.5567 59.281 48.5452 59.3329C48.5352 59.3783 48.5078 59.4179 48.4689 59.4434C48.4245 59.4725 48.3552 59.4725 48.2166 59.4725H46.8278C46.7561 59.4725 46.7203 59.4725 46.6896 59.4607C46.6624 59.4503 46.6382 59.4335 46.6191 59.4116C46.5975 59.3868 46.5851 59.3532 46.5603 59.2859L45.8102 57.2482C45.7854 57.1809 45.773 57.1473 45.7514 57.1225C45.7323 57.1007 45.7081 57.0838 45.6809 57.0734C45.6502 57.0616 45.6143 57.0616 45.5427 57.0616H42.2313C42.1596 57.0616 42.1238 57.0616 42.0931 57.0734C42.0659 57.0838 42.0417 57.1007 42.0226 57.1225C42.0009 57.1473 41.9885 57.1809 41.9638 57.2482L41.2136 59.2859C41.1889 59.3532 41.1765 59.3868 41.1549 59.4116C41.1357 59.4335 41.1115 59.4503 41.0844 59.4607C41.0536 59.4725 41.0178 59.4725 40.9462 59.4725H39.5574ZM42.755 55.0761C42.708 55.2048 42.6845 55.2692 42.6964 55.3206C42.7067 55.3656 42.7342 55.4049 42.7729 55.43C42.8172 55.4588 42.8857 55.4588 43.0227 55.4588H44.7512C44.8883 55.4588 44.9568 55.4588 45.0011 55.43C45.0398 55.4049 45.0672 55.3656 45.0776 55.3206C45.0894 55.2692 45.0659 55.2048 45.0189 55.0761L44.1547 52.7084C44.0722 52.4823 44.0309 52.3692 43.9705 52.3372C43.9183 52.3094 43.8557 52.3094 43.8034 52.3372C43.7431 52.3692 43.7018 52.4823 43.6193 52.7084L42.755 55.0761Z" fill="#2845C1"/>
<path d="M17.4974 59.4725C17.3976 59.4725 17.3478 59.4725 17.3097 59.4531C17.2761 59.436 17.2489 59.4087 17.2318 59.3752C17.2124 59.3371 17.2124 59.2872 17.2124 59.1875V50.485C17.2124 50.3852 17.2124 50.3353 17.2318 50.2972C17.2489 50.2637 17.2761 50.2364 17.3097 50.2194C17.3478 50.2 17.3976 50.2 17.4974 50.2H19.9412C21.8752 50.2 23.0541 51.1537 23.0541 52.7168C23.0541 53.296 22.8514 53.8129 22.464 54.2136C22.3269 54.3553 22.2583 54.4262 22.2468 54.4727C22.2343 54.5229 22.237 54.5479 22.2599 54.5943C22.2811 54.6373 22.3603 54.6901 22.5189 54.7957C23.2086 55.255 23.584 55.9399 23.584 56.7437C23.584 58.4393 22.2726 59.4725 20.1399 59.4725H17.4974ZM19.9809 51.763H19.3784C19.2786 51.763 19.2288 51.763 19.1907 51.7825C19.1571 51.7995 19.1299 51.8268 19.1128 51.8603C19.0934 51.8984 19.0934 51.9483 19.0934 52.048V53.571C19.0934 53.6707 19.0934 53.7206 19.1128 53.7587C19.1299 53.7922 19.1571 53.8195 19.1907 53.8366C19.2288 53.856 19.2786 53.856 19.3784 53.856H19.9809C20.6962 53.856 21.1201 53.4718 21.1201 52.7963C21.1201 52.1472 20.6962 51.763 19.9809 51.763ZM20.2458 55.4191H19.3784C19.2786 55.4191 19.2288 55.4191 19.1907 55.4385C19.1571 55.4556 19.1299 55.4828 19.1128 55.5163C19.0934 55.5544 19.0934 55.6043 19.0934 55.7041V57.6244C19.0934 57.7242 19.0934 57.774 19.1128 57.8121C19.1299 57.8457 19.1571 57.8729 19.1907 57.89C19.2288 57.9094 19.2786 57.9094 19.3784 57.9094H20.2458C21.1201 57.9094 21.65 57.4458 21.65 56.6642C21.65 55.8694 21.1201 55.4191 20.2458 55.4191Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M25.6668 59.4725C25.567 59.4725 25.5171 59.4725 25.479 59.4531C25.4455 59.436 25.4182 59.4087 25.4012 59.3752C25.3818 59.3371 25.3818 59.2872 25.3818 59.1875V50.485C25.3818 50.3852 25.3818 50.3353 25.4012 50.2972C25.4182 50.2637 25.4455 50.2364 25.479 50.2194C25.5171 50.2 25.567 50.2 25.6668 50.2H30.5013C30.6011 50.2 30.651 50.2 30.6891 50.2194C30.7226 50.2364 30.7498 50.2637 30.7669 50.2972C30.7863 50.3353 30.7863 50.3852 30.7863 50.485V51.478C30.7863 51.5778 30.7863 51.6277 30.7669 51.6658C30.7498 51.6993 30.7226 51.7265 30.6891 51.7436C30.651 51.763 30.6011 51.763 30.5013 51.763H27.5478C27.448 51.763 27.3981 51.763 27.36 51.7825C27.3265 51.7995 27.2992 51.8268 27.2822 51.8603C27.2628 51.8984 27.2628 51.9483 27.2628 52.048V53.677C27.2628 53.7767 27.2628 53.8266 27.2822 53.8647C27.2992 53.8982 27.3265 53.9255 27.36 53.9425C27.3981 53.962 27.448 53.962 27.5478 53.962H29.9715C30.0712 53.962 30.1211 53.962 30.1592 53.9814C30.1927 53.9984 30.22 54.0257 30.237 54.0592C30.2565 54.0973 30.2565 54.1472 30.2565 54.247V55.24C30.2565 55.3398 30.2565 55.3897 30.237 55.4278C30.22 55.4613 30.1927 55.4885 30.1592 55.5056C30.1211 55.525 30.0712 55.525 29.9715 55.525H27.5478C27.448 55.525 27.3981 55.525 27.36 55.5445C27.3265 55.5615 27.2992 55.5888 27.2822 55.6223C27.2628 55.6604 27.2628 55.7103 27.2628 55.81V57.6244C27.2628 57.7242 27.2628 57.774 27.2822 57.8121C27.2992 57.8457 27.3265 57.8729 27.36 57.89C27.3981 57.9094 27.448 57.9094 27.5478 57.9094H30.5013C30.6011 57.9094 30.651 57.9094 30.6891 57.9288C30.7226 57.9459 30.7498 57.9731 30.7669 58.0067C30.7863 58.0448 30.7863 58.0946 30.7863 58.1944V59.1875C30.7863 59.2872 30.7863 59.3371 30.7669 59.3752C30.7498 59.4087 30.7226 59.436 30.6891 59.4531C30.651 59.4725 30.6011 59.4725 30.5013 59.4725H25.6668Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M32.3649 51.9087C32.2652 51.9087 32.2153 51.9087 32.1772 51.8893C32.1437 51.8723 32.1164 51.845 32.0993 51.8115C32.0799 51.7734 32.0799 51.7235 32.0799 51.6237V50.485C32.0799 50.3852 32.0799 50.3353 32.0993 50.2972C32.1164 50.2637 32.1437 50.2364 32.1772 50.2194C32.2153 50.2 32.2652 50.2 32.3649 50.2H39.3984C39.4982 50.2 39.548 50.2 39.5861 50.2194C39.6197 50.2364 39.6469 50.2637 39.664 50.2972C39.6834 50.3353 39.6834 50.3852 39.6834 50.485V51.6237C39.6834 51.7235 39.6834 51.7734 39.664 51.8115C39.6469 51.845 39.6197 51.8723 39.5861 51.8893C39.548 51.9087 39.4982 51.9087 39.3984 51.9087H37.1072C37.0074 51.9087 36.9575 51.9087 36.9194 51.9282C36.8859 51.9452 36.8587 51.9725 36.8416 52.006C36.8222 52.0441 36.8222 52.094 36.8222 52.1937V59.1875C36.8222 59.2872 36.8222 59.3371 36.8027 59.3752C36.7857 59.4087 36.7584 59.436 36.7249 59.4531C36.6868 59.4725 36.6369 59.4725 36.5372 59.4725H35.2262C35.1264 59.4725 35.0765 59.4725 35.0384 59.4531C35.0049 59.436 34.9776 59.4087 34.9606 59.3752C34.9412 59.3371 34.9412 59.2872 34.9412 59.1875V52.1937C34.9412 52.094 34.9412 52.0441 34.9217 52.006C34.9047 51.9725 34.8774 51.9452 34.8439 51.9282C34.8058 51.9087 34.7559 51.9087 34.6562 51.9087H32.3649Z" fill="var(--c--globals--colors--logo-1)"/>
<path d="M39.5574 59.4725C39.4188 59.4725 39.3495 59.4725 39.305 59.4434C39.2662 59.4179 39.2388 59.3783 39.2287 59.3329C39.2172 59.281 39.2418 59.2162 39.2908 59.0866L42.5854 50.384C42.6105 50.3177 42.6231 50.2845 42.6447 50.26C42.6638 50.2384 42.6879 50.2218 42.7149 50.2115C42.7454 50.2 42.7809 50.2 42.8519 50.2H44.9221C44.993 50.2 45.0285 50.2 45.0591 50.2115C45.086 50.2218 45.1101 50.2384 45.1293 50.26C45.1509 50.2845 45.1635 50.3177 45.1886 50.384L48.4831 59.0866C48.5322 59.2162 48.5567 59.281 48.5452 59.3329C48.5352 59.3783 48.5078 59.4179 48.4689 59.4434C48.4245 59.4725 48.3552 59.4725 48.2166 59.4725H46.8278C46.7561 59.4725 46.7203 59.4725 46.6896 59.4607C46.6624 59.4503 46.6382 59.4335 46.6191 59.4116C46.5975 59.3868 46.5851 59.3532 46.5603 59.2859L45.8102 57.2482C45.7854 57.1809 45.773 57.1473 45.7514 57.1225C45.7323 57.1007 45.7081 57.0838 45.6809 57.0734C45.6502 57.0616 45.6143 57.0616 45.5427 57.0616H42.2313C42.1596 57.0616 42.1238 57.0616 42.0931 57.0734C42.0659 57.0838 42.0417 57.1007 42.0226 57.1225C42.0009 57.1473 41.9885 57.1809 41.9638 57.2482L41.2136 59.2859C41.1889 59.3532 41.1765 59.3868 41.1549 59.4116C41.1357 59.4335 41.1115 59.4503 41.0844 59.4607C41.0536 59.4725 41.0178 59.4725 40.9462 59.4725H39.5574ZM42.755 55.0761C42.708 55.2048 42.6845 55.2692 42.6964 55.3206C42.7067 55.3656 42.7342 55.4049 42.7729 55.43C42.8172 55.4588 42.8857 55.4588 43.0227 55.4588H44.7512C44.8883 55.4588 44.9568 55.4588 45.0011 55.43C45.0398 55.4049 45.0672 55.3656 45.0776 55.3206C45.0894 55.2692 45.0659 55.2048 45.0189 55.0761L44.1547 52.7084C44.0722 52.4823 44.0309 52.3692 43.9705 52.3372C43.9183 52.3094 43.8557 52.3094 43.8034 52.3372C43.7431 52.3692 43.7018 52.4823 43.6193 52.7084L42.755 55.0761Z" fill="var(--c--globals--colors--logo-1)"/>
<defs>
<linearGradient id="paint0_linear_13360_26870" x1="32.8125" y1="46" x2="32.8125" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#2845C1"/>
<stop offset="1" stop-color="#EEF1F4"/>
<stop stop-color="var(--c--globals--colors--logo-1)"/>
<stop offset="1" stop-color="var(--c--contextuals--background--semantic--neutral--tertiary)"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -14,8 +14,10 @@ export interface BoxProps {
as?: HTMLElementType;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$border?: CSSProperties['border'];
$color?: CSSProperties['color'];
$css?: string | RuleSet<object>;
$cursor?: CSSProperties['cursor'];
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
@@ -37,28 +39,44 @@ export interface BoxProps {
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
$wrap?: CSSProperties['flexWrap'];
// Theming props
$layer?: 'background' | 'content' | 'border';
$theme?:
| 'brand'
| 'error'
| 'gray'
| 'info'
| 'success'
| 'warning'
| 'neutral'
| 'contextual'
| 'disabled'
| (string & {});
$scope?: 'surface' | 'semantic' | 'palette' | (string & {});
$variation?: 'primary' | 'secondary' | 'tertiary' | (string & {});
$withThemeBG?: boolean;
$withThemeBorder?: boolean;
$withThemeInherited?: boolean;
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
`display: ${$display || (as?.match('span|input') ? 'inline-flex' : 'flex')};`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
? `transition: all 0.5s var(--c--globals--transitions--ease-out);`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
? `transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
@@ -72,6 +90,86 @@ export const Box = styled('div')<BoxProps>`
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({
$layer = 'border',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$border,
$withThemeBorder,
$withThemeInherited,
}) => {
if ($border) {
return `border: ${$border};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBorder) {
return '';
}
if ($withThemeInherited) {
return `border: inherit;`;
}
return `border: 1px solid var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'background',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$background,
$withThemeBG,
$withThemeInherited,
}) => {
if ($background) {
return `background: ${$background};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBG) {
return '';
}
if ($withThemeInherited) {
return `background: inherit;`;
}
return `background: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'content',
$theme = 'neutral',
$variation = 'primary',
$scope = 'semantic',
$color,
$withThemeBG,
$withThemeInherited,
}) => {
if ($color) {
return `color: ${$color};`;
}
if (!$layer || !$scope) {
return '';
}
// There is a special case when primary with background
if (
$withThemeBG &&
$layer === 'content' &&
$scope === 'semantic' &&
$variation === 'primary' &&
$theme
) {
$variation = `on-${$theme}`;
}
if ($withThemeInherited) {
return `color: inherit;`;
}
return `color: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
@@ -91,7 +189,7 @@ export const Box = styled('div')<BoxProps>`
return (
effect &&
`
transition: all 0.3s ease-in-out;
transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);
${effect}
`
);
@@ -39,12 +39,12 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
? 'var(--c--theme--colors--gray-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
className={`--conversations--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
@@ -17,10 +17,6 @@ export const Card = ({
className={`--docs--card ${props.className || ''}`}
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens['greyscale-200']};
${$css}
`}
{...props}
>
{children}
@@ -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>
);
@@ -13,11 +13,13 @@ import { useCunninghamTheme } from '@/cunningham';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
background-color: var(--c--contextuals--background--surface--primary);
border-radius: var(--c--globals--spacings--st);
box-shadow: 0 0 6px 0 rgba(0, 0, 145, 0.1);
border: 1px solid var(--c--contextuals--border--surface--primary);
transition: opacity var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
color: var(--c--contextuals--content--semantic--brand--tertiary);
`;
interface StyledButtonProps {
@@ -28,10 +30,23 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
font-weight: var(--c--components--button--font-weight);
font-size: var(--c--components--button--medium-font-size);
padding: var(--c--globals--spacings--0);
border-radius: var(--c--globals--spacings--st);
color: var(--c--contextuals--content--semantic--brand--tertiary);
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400);
background-color: var(
--c--contextuals--background--semantic--brand--tertiary-hover
);
border-radius: var(--c--globals--spacings--st);
}
${({ $css }) => $css};
`;
@@ -41,6 +56,7 @@ export interface DropButtonProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
testId?: string;
}
export const DropButton = ({
@@ -50,6 +66,7 @@ export const DropButton = ({
onOpenChange,
children,
label,
testId,
}: PropsWithChildren<DropButtonProps>) => {
const { themeTokens } = useCunninghamTheme();
const font = themeTokens['font']?.['families']['base'];
@@ -72,6 +89,7 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
data-testid={testId}
$css={css`
font-family: ${font};
${buttonCss};
@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -35,8 +35,16 @@ export const DropdownMenu = ({
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
const blockButtonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Mettre à jour la largeur uniquement côté client
if (blockButtonRef.current) {
setButtonWidth(blockButtonRef.current.clientWidth);
}
}, [isOpen]);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
@@ -56,14 +64,20 @@ export const DropdownMenu = ({
<Box
ref={blockButtonRef}
$direction="row"
$theme="brand"
$scope="semantic"
$variation="neutral"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Icon
$variation="text"
$theme="primary"
$css={css`
color: var(
--c--contextuals--content--semantic--brand--tertiary
);
`}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
@@ -76,12 +90,13 @@ export const DropdownMenu = ({
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
$minWidth={buttonWidth ? `${buttonWidth}px` : undefined}
role="menu"
>
{topMessage && (
<Text
$variation="700"
$theme="brand"
$variation="tertiary"
$wrap="wrap"
$size="xs"
$weight="bold"
@@ -102,6 +117,7 @@ export const DropdownMenu = ({
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
tabIndex={isDisabled ? -1 : 0}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -111,8 +127,6 @@ export const DropdownMenu = ({
key={option.label}
$align="center"
$justify="space-between"
$background={colorsTokens['greyscale-000']}
$color={colorsTokens['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacingsTokens['base']}
@@ -125,17 +139,29 @@ export const DropdownMenu = ({
`}
${index === options.length - 1 &&
css`
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: var(--c--globals--spacings--st);
border-bottom-right-radius: var(--c--globals--spacings--st);
`}
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
font-size: var(--c--globals--font--sizes--sm);
color: var(
--c--contextuals--content--semantic--brand--tertiary
);
font-weight: var(--c--globals--font--weights--medium);
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
&:focus-visible {
outline: 2px solid var(--c--globals--colors--brand-400);
outline-offset: -2px;
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
`}
>
@@ -147,9 +173,10 @@ export const DropdownMenu = ({
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
iconName={option.icon}
aria-hidden="true"
/>
)}
<Text $variation={isDisabled ? '400' : '1000'}>
@@ -157,7 +184,7 @@ export const DropdownMenu = ({
</Text>
</Box>
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
<Icon iconName="check" $size="20px" aria-hidden="true" />
)}
</BoxButton>
);
@@ -10,11 +10,14 @@ type IconProps = TextType & {
export const Icon = ({
iconName,
variant = 'outlined',
$theme = 'neutral',
...textProps
}: IconProps) => {
return (
<Text
{...textProps}
aria-hidden="true"
$theme={$theme}
className={clsx('--docs--icon-bg', textProps.className, {
'material-symbols': variant === 'filled',
'material-symbols-outlined': variant === 'outlined',
@@ -33,6 +36,7 @@ export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Icon
{...props}
aria-hidden="true"
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
user-select: none;
@@ -39,7 +39,7 @@ export const InfiniteScroll = ({
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
color="neutral"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
@@ -1,20 +1,17 @@
import Lottie from 'react-lottie';
import dynamic from 'next/dynamic';
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
import searchingAnimation from '@/assets/lotties/searching';
export const Loader = () => {
const LoaderOptions = {
loop: true,
autoplay: true,
animationData: searchingAnimation,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
} as const;
export function Loader() {
return (
<div>
<Lottie options={LoaderOptions} height={24} width={24} />
<div role="status">
<Lottie
animationData={searchingAnimation}
loop
autoplay
style={{ width: 24, height: 24 }}
/>
</div>
);
};
}
@@ -5,7 +5,7 @@ import { tokens } from '@/cunningham';
import { Box, BoxProps } from './Box';
const { sizes } = tokens.themes.default.theme.font;
const { sizes } = tokens.themes.default.globals.font;
type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
@@ -13,65 +13,28 @@ export interface TextProps extends BoxProps {
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$textTransform?: CSSProperties['textTransform'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'greyscale';
$variation?:
| 'text'
| '000'
| '050'
| '100'
| '200'
| '300'
| '400'
| '500'
| '550'
| '600'
| '650'
| '700'
| '750'
| '800'
| '850'
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $textTransform }) =>
$textTransform && `text-transform: ${$textTransform};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&
`font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`}
${({ $theme, $variation }) =>
`color: var(--c--theme--colors--${$theme}-${$variation});`}
${({ $color }) => $color && `color: ${$color};`}
${({ $ellipsis }) =>
$ellipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={className}
{...props}
/>
);
(props, ref) => {
return <TextStyled ref={ref} as="span" {...props} />;
},
);
@@ -3,6 +3,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { createPortal } from 'react-dom';
@@ -17,6 +18,8 @@ interface ToastItem {
type: ToastType;
icon?: string;
duration?: number;
actionLabel?: string;
actionHref?: string;
}
interface ToastContextType {
@@ -25,6 +28,7 @@ interface ToastContextType {
message: string,
icon?: string,
duration?: number,
options?: { actionLabel?: string; actionHref?: string },
) => void;
}
@@ -44,11 +48,30 @@ interface ToastProviderProps {
export const ToastProvider = ({ children }: ToastProviderProps) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
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]);
},
@@ -66,9 +89,12 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
return (
<ToastContext.Provider value={value}>
{children}
{typeof window !== 'undefined' &&
{isMounted &&
typeof document !== 'undefined' &&
document.body &&
createPortal(
<Box
aria-live="polite"
$css={`
position: fixed;
top: 8px;
@@ -94,6 +120,8 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
type={toast.type}
icon={toast.icon}
duration={toast.duration}
actionLabel={toast.actionLabel}
actionHref={toast.actionHref}
onClose={removeToast}
/>
))}
@@ -0,0 +1,71 @@
import { Box } from './Box';
import { Icon } from './Icon';
interface ToggleSwitchProps {
checked: boolean;
onChange: () => void;
disabled?: boolean;
'aria-label'?: string;
}
export const ToggleSwitch = ({
checked,
onChange,
disabled = false,
'aria-label': ariaLabel,
}: ToggleSwitchProps) => {
return (
<Box $css="padding-top: 2px;">
<Box
$css={`
position: relative;
width: 44px;
height: 24px;
background-color: ${checked ? 'var(--c--contextuals--content--semantic--brand--tertiary)' : 'var(--c--contextuals--content--semantic--neutral--tertiary)'};
border-radius: 12px;
cursor: ${disabled ? 'not-allowed' : 'pointer'};
transition: all 0.2s ease;
opacity: ${disabled ? 0.6 : 1};
`}
onClick={disabled ? undefined : onChange}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled) {
onChange();
}
}
}}
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
<Box
$css={`
position: absolute;
top: 2px;
left: ${checked ? '22px' : '2px'};
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: left 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
`}
>
<Icon
iconName={checked ? 'check' : ''}
$size="12px"
$theme="brand"
$variation="tertiary"
/>
</Box>
</Box>
</Box>
);
};
@@ -12,5 +12,6 @@ export * from './SideModal';
export * from './separators';
export * from './Text';
export * from './TextErrors';
export * from './ToggleSwitch';
export * from './CustomToast';
export * from './ToastProvider';
@@ -64,9 +64,8 @@ export const QuickSearch = ({
bottom: -20px;
background: linear-gradient(
to bottom,
#F7F8FA 0%,
rgba(247, 248, 250, 0.7) 40%,
rgba(255, 255, 255, 0) 100%
var(--c--contextuals--background--surface--tertiary) 0%,
transparent 100%
);
}
${
@@ -74,17 +73,16 @@ export const QuickSearch = ({
? `
&:before {
content: "";
background-color: #F7F8FA;
background-color: var(--c--contextuals--background--surface--tertiary);
position: absolute;
width: 100%;
height: 20px;
top: -8px;
}
background-color: #F7F8FA;
`
: 'background-color: #FFF;'
: ''
}
}`}
`}
>
<QuickSearchInput
withSeparator={hasChildrens(children)}
@@ -77,23 +77,24 @@ export const QuickSearchInput = ({
justify-content: space-between;
border-radius: 4px;
margin: 12px 12px 0 12px;
border: 1px solid #CFD5DE;
background-color: #eff1f5;
border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary);
background-color: var(--c--contextuals--background--semantic--neutral--tertiary);
padding: 6px 8px;
cursor: pointer !important;
transition: border-color 0.2s ease;
&:hover {
border: 2px solid #CFD5DE;
border: 2px solid var(--c--contextuals--border--semantic--neutral--tertiary);
padding: 5px 7px;
}
&:focus-within {
border: 2px solid #3E5DE7;
border: 2px solid var(--c--contextuals--border--semantic--brand--primary);
padding: 5px 7px;
}
& input {
font-family: 'Marianne' !important;
border: none;
font-size: 0.9rem;
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 400;
background-color: transparent;
&:focus {
@@ -104,7 +105,7 @@ export const QuickSearchInput = ({
`}
>
<Box $direction="row" $gap="6px">
<Icon iconName="search" $variation="600" />
<Icon iconName="search" $theme="neutral" $variation="tertiary" />
<Command.Input
aria-label={t('Quick search input')}
value={localValue}
@@ -132,10 +133,10 @@ export const QuickSearchInput = ({
padding: 7px;
z-index: 1000;
&:hover {
background-color: #e4e6ea;
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
}
&:focus-visible {
outline: 2px solid #3E5DE7;
outline: 2px solid var(--c--contextuals--border--semantic--brand--primary);
outline-offset: 2px;
}`}
>
@@ -15,10 +15,9 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] {
border: none;
&::placeholder {
input::placeholder {
font-size: 14px;
color: #626A80;
color: var(--c--contextuals--content--semantic--neutral--tertiary) !important;
}
}
@@ -41,14 +40,14 @@ export const QuickSearchStyle = createGlobalStyle`
&:hover,
&[data-selected='true'] {
background: var(--c--theme--colors--greyscale-100);
background: var(--c--theme--colors--gray-100);
.show-right-on-focus {
opacity: 1;
}
}
&[data-disabled='true'] {
color: var(--c--theme--colors--greyscale-500);
color: var(--c--theme--colors--gray-500);
cursor: not-allowed;
}
@@ -79,7 +78,7 @@ export const QuickSearchStyle = createGlobalStyle`
height: 20px;
border-radius: 4px;
color: white;
background: var(--c--theme--colors--greyscale-500);
background: var(--c--contextuals--content--semantic--neutral--tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -102,7 +101,7 @@ export const QuickSearchStyle = createGlobalStyle`
padding: 0 var(--c--theme--spacings--xs);
user-select: none;
font-size: 12px;
color: var(--c--theme--colors--greyscale-700);
color: var(--c--theme--colors--gray-700);
font-weight: 700;;
margin-bottom: var(--c--theme--spacings--xs);
}

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