Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4d26517b | |||
| 6dd41e827e | |||
| f6edb14e1f | |||
| bcf55c6c0c | |||
| 2f74543d41 | |||
| 995ad407a6 | |||
| 0746d23a1e | |||
| c699d7503d | |||
| bc7568ccf1 | |||
| c17a8ae9b9 | |||
| 4545083210 | |||
| 1088d88aba | |||
| 99353357ff | |||
| 8feed1e3ac | |||
| 61f86e18df | |||
| 2b8123462a | |||
| 3c3eaf3eeb | |||
| e925bff1a2 | |||
| ba712af899 | |||
| e60c938d44 | |||
| 5d895d15f9 | |||
| 96a2963adf | |||
| 224e6f83fd | |||
| af6facf4a3 | |||
| 9d16460777 | |||
| 09dceb508d | |||
| 63e0e6c383 | |||
| a919d9a63b | |||
| 9506df341a | |||
| c87c734e98 | |||
| 41ade65a49 | |||
| fd372e1c56 | |||
| 3bbe7fb125 | |||
| 082937e107 | |||
| 54d2d3e807 | |||
| 23964cbf12 | |||
| ff9e8335f2 | |||
| 8c62f5d933 | |||
| 88bdcc2e60 | |||
| 1c573ad3a7 | |||
| 3106d5f25f | |||
| 23fa1d6b9e | |||
| 8ed72fb305 | |||
| c231e69871 | |||
| fb297b97e6 | |||
| 7858476b84 | |||
| c02254ec93 | |||
| 853305ae74 | |||
| ab2ad0348b | |||
| d752334b5e | |||
| 30f825c337 | |||
| f52a27f218 | |||
| 3e467cacf2 | |||
| 120b204729 | |||
| d9078e75e5 | |||
| 09b003856b | |||
| 0b5317a773 | |||
| abf61a9556 | |||
| 3e8c5c77d5 | |||
| ddfc86a88f | |||
| e7d76e4477 | |||
| fd3399dd66 | |||
| 13c6499c66 | |||
| a0b31e1e61 | |||
| daf90cf110 | |||
| 29f76fe040 |
@@ -1,6 +1,7 @@
|
||||
[codespell]
|
||||
skip =
|
||||
./git/,
|
||||
**/*.pdf,
|
||||
**/*.po,
|
||||
**/*.pot,
|
||||
**/*.json,
|
||||
@@ -8,9 +9,12 @@ skip =
|
||||
**/node_modules/**,
|
||||
**/e2e/report/**,
|
||||
*.tsbuildinfo,
|
||||
**/uv.lock,
|
||||
./docker/files/etc/mime.types,
|
||||
check-filenames = true
|
||||
ignore-words-list =
|
||||
afterAll,
|
||||
statics,
|
||||
exclude-file =
|
||||
./src/backend/chat/agent_rag/web_search/mocked.py,
|
||||
|
||||
|
||||
@@ -85,20 +85,24 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -181,25 +185,28 @@ jobs:
|
||||
mc version enable conversations/conversations-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
python-version: "3.13"
|
||||
- name: Install system dependencies for lxml
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxslt-dev
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
sudo cp $GITHUB_WORKSPACE/docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
security-trivy-critical:
|
||||
permissions:
|
||||
@@ -210,6 +217,8 @@ jobs:
|
||||
- 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
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
|
||||
security-trivy:
|
||||
permissions:
|
||||
@@ -219,3 +228,5 @@ jobs:
|
||||
- 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
|
||||
with:
|
||||
skip-files: src/mail/yarn.lock
|
||||
|
||||
@@ -22,15 +22,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13.3"
|
||||
cache: "pip"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
python-version-file: "src/backend/pyproject.toml"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -44,6 +44,9 @@ env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# Configuration
|
||||
**/conversations/configuration/llm/dev.json
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
+80
-2
@@ -8,15 +8,90 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(back) add projects with custom LLM instructions
|
||||
- ✨(front) projects management UI
|
||||
|
||||
## [0.0.14] - 2026-03-11
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(user) allow disabling automatic internet search
|
||||
- ✨(search) add searchModal and modify leftPanel
|
||||
- ✨(waffle) hide the waffle if not fr theme
|
||||
- ✨(front) allow pasting an attachment from clipboard
|
||||
- ✨(array) temporarily adjust array
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(front) optimize streaming markdown rendering performance
|
||||
- ⬆️(back) update pydantic-ai
|
||||
- ♻️(chat) refactor AIAgentService for readability and maintainability
|
||||
- 🚸(oidc) ignore case when fallback on email #281
|
||||
- ⬆️(back) update pillow, django-pydantic-field, pypdf
|
||||
- ♻️(front) migrate from ESLint 8 to ESLint 9 flat config
|
||||
- ⬆️(back) update django and pypdf
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
|
||||
- 🚑️(back) fix mime type for pptx
|
||||
- 🐛(front) fix math formulas and carousel translations
|
||||
- 🐛(helm) reverse liveness and readiness for backend deployment
|
||||
- 🐛(front) fix dark mode styling on chat messages
|
||||
- 🐛(front) fixed inverted toast for setting changes
|
||||
|
||||
## [0.0.13] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- 💄(front) ui fix : update ui-kit
|
||||
- ✨(front) add persistent darkmode
|
||||
- ✨(front) add ui kit #240
|
||||
- 🧱(files) allow to use S3 storage without external access #849
|
||||
- ✨(backend) add FindRagBackend #209
|
||||
- ⬆️(back) update dependencies
|
||||
- ✨(back) use adaptive parsing for pdf documents
|
||||
|
||||
### Changed
|
||||
|
||||
- 💄(darkmode) change color feedback button
|
||||
- 🏗️(back) migrate to uv
|
||||
- ♻️(front) optimize syntax highlighting bundle size
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) cast collection Ids to API expected types
|
||||
|
||||
## [0.0.12] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡️(front) performance improvements on chat input
|
||||
- 💄(front) i18n and standardize pdf parsing display
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) consider PDF documents as other kind of documents #234
|
||||
|
||||
## [0.0.11] - 2026-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(e2e) fix test-e2e-chromium
|
||||
- 🐛(back) fix system prompt compatibility with self-hosted models #200
|
||||
- ⚰️(back) remove dead code and unused files
|
||||
- 🐛(back) prevent tool call timeouts
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) remove thinking part from frontend #227
|
||||
|
||||
## [0.0.10] - 2025-12-15
|
||||
|
||||
@@ -171,8 +246,11 @@ and this project adheres to
|
||||
- ✨(onboarding) add activation code logic for launch #62
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.14...main
|
||||
[0.0.14]: https://github.com/suitenumerique/conversations/releases/v0.0.14
|
||||
[0.0.13]: https://github.com/suitenumerique/conversations/releases/v0.0.13
|
||||
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
|
||||
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
|
||||
[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
|
||||
|
||||
+33
-23
@@ -3,9 +3,6 @@
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
@@ -13,21 +10,31 @@ RUN apk update && \
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||
|
||||
# Install Rust and Cargo using Alpine's package manager
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
libffi-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
rust \
|
||||
cargo
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:24 AS mail-builder
|
||||
@@ -49,14 +56,16 @@ RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Copy conversations application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
@@ -79,10 +88,12 @@ RUN apk add \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
libxml2 \
|
||||
libxslt \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
COPY ./docker/files/etc/mime.types /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -92,17 +103,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy conversations application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
@@ -119,10 +130,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall conversations and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y conversations
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -53,9 +53,6 @@ MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
PATH_FRONT_CONVERSATIONS = $(PATH_FRONT)/apps/conversations
|
||||
FRONTEND_YARN = $(COMPOSE_RUN) -w /app/src/frontend node yarn
|
||||
FRONTEND_CONVERSATIONS_YARN = $(COMPOSE_RUN) -w /app/src/frontend/apps/conversations node yarn
|
||||
FRONTEND_CONVERSATIONS_YARN_3000 = $(COMPOSE_RUN) -p 3000:3000 -w /app/src/frontend/apps/conversations node yarn
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
@@ -234,7 +231,7 @@ superuser: ## Create an admin superuser with password "admin"
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@@ -340,19 +337,20 @@ help:
|
||||
|
||||
# Front
|
||||
frontend-development-install: ## install the frontend locally
|
||||
@$(FRONTEND_CONVERSATIONS_YARN) install
|
||||
cd $(PATH_FRONT_CONVERSATIONS) && yarn
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
@$(FRONTEND_YARN) lint
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(FRONTEND_CONVERSATIONS_YARN_3000) dev
|
||||
#@$(COMPOSE) stop frontend frontend-development
|
||||
cd $(PATH_FRONT_CONVERSATIONS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
@$(FRONTEND_YARN) i18n:extract
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
|
||||
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
|
||||
@@ -362,7 +360,7 @@ frontend-i18n-generate: \
|
||||
.PHONY: frontend-i18n-generate
|
||||
|
||||
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
|
||||
@$(FRONTEND_YARN) i18n:deploy
|
||||
cd $(PATH_FRONT) && yarn i18n:deploy
|
||||
.PHONY: frontend-i18n-compile
|
||||
|
||||
# -- K8S
|
||||
@@ -376,10 +374,10 @@ start-tilt: ## start the kubernetes cluster using kind
|
||||
|
||||
bump-packages-version: VERSION_TYPE ?= minor
|
||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||
@$(COMPOSE_RUN) -w /app/src/mail node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
@$(COMPOSE_RUN) -w /app/src/frontend node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
@$(COMPOSE_RUN) -w /app/src/frontend/apps/e2e node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
@$(COMPOSE_RUN) -w /app/src/frontend/apps/conversations node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
@$(COMPOSE_RUN) -w /app/src/frontend/packages/eslint-config-conversations node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
@$(COMPOSE_RUN) -w /app/src/frontend/packages/i18n node yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/conversations/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-config-conversations/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
.PHONY: bump-packages-version
|
||||
|
||||
+12
@@ -71,9 +71,13 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
@@ -89,6 +93,9 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -177,3 +184,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,9 @@ These are the environment variables you can set for the `conversations-backend`
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | conversations |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/conversations/configuration/theme/default.json | BASE_DIR/conversations/configuration/theme/default.json |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| FIND_API_KEY | API key of Find | |
|
||||
| FIND_API_URL | URL of Find | `https://app-find/api` |
|
||||
| FIND_API_TIMEOUT | Find API timeout | 30 |
|
||||
|
||||
|
||||
## conversations-frontend image
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# File Upload Modes
|
||||
|
||||
This document describes the different modes for handling file uploads in the Conversations application, and how to configure and use them.
|
||||
|
||||
## Overview
|
||||
|
||||
The application supports two independent configuration points:
|
||||
|
||||
1. **`FILE_UPLOAD_MODE`**: how the frontend uploads files (frontend → storage/backend)
|
||||
2. **`FILE_TO_LLM_MODE`**: how the backend provides files to the LLM (backend → LLM)
|
||||
|
||||
Each mode has different trade-offs in terms of security, performance, and LLM accessibility. The two settings can be combined based on your network constraints.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Frontend upload mode (`FILE_UPLOAD_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL upload (backward compatible)
|
||||
FILE_UPLOAD_MODE=presigned_url
|
||||
|
||||
# Frontend uploads directly to backend
|
||||
FILE_UPLOAD_MODE=backend_to_s3
|
||||
```
|
||||
|
||||
### Backend delivery mode (`FILE_TO_LLM_MODE`)
|
||||
|
||||
```bash
|
||||
# Default: presigned URL mode (backward compatible)
|
||||
FILE_TO_LLM_MODE=presigned_url
|
||||
|
||||
# Backend provides base64-encoded data URLs
|
||||
FILE_TO_LLM_MODE=backend_base64
|
||||
|
||||
# Backend provides temporary URLs through the backend
|
||||
FILE_TO_LLM_MODE=backend_temporary_url
|
||||
```
|
||||
|
||||
Additional settings for backend temporary URL mode:
|
||||
|
||||
```bash
|
||||
# Base URL to reach backend
|
||||
FILE_BACKEND_URL="http://localhost:8071"
|
||||
|
||||
# Expiration time for temporary URLs (in seconds, default: 180 = 3 minutes)
|
||||
FILE_BACKEND_TEMPORARY_URL_EXPIRATION=180
|
||||
```
|
||||
|
||||
## Mode Details
|
||||
|
||||
### 1. Presigned URL Mode (Default)
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=presigned_url`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=presigned_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend requests a presigned URL from the backend
|
||||
- Frontend uploads the file directly to S3 using the presigned URL
|
||||
- Frontend notifies the backend when upload is complete
|
||||
- Backend initiates malware detection
|
||||
- Backend returns presigned S3 URLs to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- Files don't pass through the backend server (lower bandwidth usage)
|
||||
- Faster uploads for large files (direct to S3)
|
||||
- S3 handles the upload, no backend load
|
||||
- Backward compatible with existing frontend implementations
|
||||
|
||||
**Disadvantages:**
|
||||
- S3 bucket must be accessible from the frontend
|
||||
- Presigned URLs can be leaked if not handled carefully
|
||||
- Frontend needs to handle S3 credentials/configuration
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
- Documents: Presigned S3 URLs with expiration (default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When frontend has direct access to S3
|
||||
- When you want to minimize backend load
|
||||
- When S3 is publicly accessible or accessible via VPN
|
||||
|
||||
|
||||
### 2. Backend Base64 Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_base64`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend reads the file, encodes it as base64, and creates a data URL
|
||||
- LLM receives the file as a base64-encoded data URL
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend)
|
||||
- Files always go through the backend for validation
|
||||
- No presigned URLs to manage
|
||||
- Better control over file access
|
||||
- Data URLs work with all LLMs that support file content
|
||||
|
||||
**Disadvantages:**
|
||||
- Backend memory usage increases (entire file loaded for base64 encoding)
|
||||
- Slower for very large files (encoding overhead)
|
||||
- Increased bandwidth on backend
|
||||
- Data URLs can be very large in responses
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Base64-encoded data URLs (format: `data:image/png;base64,...`)
|
||||
- Documents: Base64-encoded data URLs (format: `data:application/pdf;base64,...`)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend
|
||||
- When you want all file uploads to go through the backend
|
||||
- When the LLM supports base64-encoded data URLs
|
||||
- For smaller files (< 50MB)
|
||||
|
||||
|
||||
### 3. Backend Temporary URL Mode
|
||||
|
||||
**Frontend upload configuration:** `FILE_UPLOAD_MODE=backend_to_s3`
|
||||
|
||||
**Backend delivery configuration:** `FILE_TO_LLM_MODE=backend_temporary_url`
|
||||
|
||||
**How it works:**
|
||||
- Frontend uploads the file directly to the backend
|
||||
- Backend stores the file on S3
|
||||
- Backend generates a secure temporary access token stored in cache (TTL: 3 minutes by default)
|
||||
- Backend returns a temporary URL pointing to the backend's file-stream endpoint
|
||||
- LLM receives the temporary URL and accesses the file through the backend
|
||||
- Backend validates the token and streams the file content from S3 to the LLM
|
||||
|
||||
**Advantages:**
|
||||
- S3 can be private/internal (not accessible from frontend or LLM directly)
|
||||
- Files always go through the backend for validation and access control
|
||||
- LLM doesn't need direct access to S3
|
||||
- Tokens expire quickly (better security than long-lived presigned URLs)
|
||||
- No large data URL strings in memory or responses
|
||||
- Lower backend memory usage than base64 mode
|
||||
- Centralized file access control through the backend
|
||||
- Good balance between security and performance
|
||||
|
||||
**Disadvantages:**
|
||||
- LLM must be able to access the backend server
|
||||
- File streaming goes through the backend (adds some latency)
|
||||
- Time-limited access (token expires)
|
||||
|
||||
**LLM Access:**
|
||||
- Images: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
- Documents: Temporary backend URLs with format `/api/v1.0/file-stream/{temporary_key}/` (token expiration: configurable, default: 3 minutes)
|
||||
|
||||
**When to use:**
|
||||
- When S3 is not accessible from the frontend or LLM
|
||||
- When you want backend control over uploads and file access
|
||||
- When you want time-limited access to files with centralized control
|
||||
- When you want the LLM to access files through the backend gateway
|
||||
- For large files (backend streams directly from S3 without loading entirely into memory)
|
||||
@@ -244,9 +244,9 @@ For Mistral AI models using the Etalab platform:
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"hrid": "mistral-large",
|
||||
"model_name": "mistral-large-latest",
|
||||
"human_readable_name": "Mistral Large (Etalab)",
|
||||
"hrid": "mistral-medium",
|
||||
"model_name": "mistral-medium-2508",
|
||||
"human_readable_name": "Mistral Medium (Etalab)",
|
||||
"provider_name": "mistral-etalab",
|
||||
"profile": null,
|
||||
"settings": {
|
||||
|
||||
@@ -357,6 +357,7 @@ The RAG backend performs semantic search to find the most relevant content:
|
||||
rag_results = document_store.search(
|
||||
query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
**kwargs, # Additional search parameters like session with access_token
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.j
|
||||
# Features
|
||||
FEATURE_FLAG_WEB_SEARCH=ENABLED
|
||||
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES=3
|
||||
|
||||
@@ -9,11 +9,28 @@ from . import models
|
||||
class ChatConversationAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the ChatConversation model"""
|
||||
|
||||
autocomplete_fields = ("owner",)
|
||||
autocomplete_fields = ("owner", "project")
|
||||
list_select_related = ("project",)
|
||||
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.ChatProject)
|
||||
class ChatProjectAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the ChatProject model"""
|
||||
|
||||
search_fields = ("title",)
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"icon",
|
||||
"color",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Document parsers for RAG backends."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from io import BytesIO
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseParser:
|
||||
"""Base class for document parsers."""
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for storage.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
|
||||
class AlbertParser(BaseParser):
|
||||
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
|
||||
|
||||
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse PDF document using Albert API."""
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
|
||||
},
|
||||
files={
|
||||
"file": (name, content, content_type),
|
||||
"output_format": (None, "markdown"),
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse document based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
|
||||
METHOD_TEXT_EXTRACTION = "text_extraction"
|
||||
METHOD_OCR = "ocr"
|
||||
|
||||
|
||||
def analyze_pdf(pdf_data: bytes) -> dict:
|
||||
"""
|
||||
Analyze a PDF to determine if it needs OCR or can use direct text extraction.
|
||||
"""
|
||||
reader = PdfReader(BytesIO(pdf_data))
|
||||
total_pages = len(reader.pages)
|
||||
if total_pages == 0:
|
||||
logger.info("No page found in pdf")
|
||||
return {
|
||||
"total_pages": 0,
|
||||
"pages_with_text": 0,
|
||||
"avg_chars_per_page": 0,
|
||||
"text_coverage": 0,
|
||||
"recommended_method": METHOD_TEXT_EXTRACTION,
|
||||
}
|
||||
|
||||
total_chars = 0
|
||||
pages_with_text = 0
|
||||
for page in reader.pages:
|
||||
text = (page.extract_text() or "").strip()
|
||||
char_count = len(text)
|
||||
total_chars += char_count
|
||||
|
||||
if char_count > 50:
|
||||
pages_with_text += 1
|
||||
|
||||
avg_chars = total_chars / total_pages
|
||||
text_coverage = pages_with_text / total_pages
|
||||
|
||||
# Decision logic
|
||||
if (
|
||||
avg_chars > settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
and text_coverage > settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION
|
||||
):
|
||||
method = METHOD_TEXT_EXTRACTION
|
||||
|
||||
else:
|
||||
method = METHOD_OCR
|
||||
|
||||
return {
|
||||
"total_pages": total_pages,
|
||||
"pages_with_text": pages_with_text,
|
||||
"avg_chars_per_page": round(avg_chars),
|
||||
"text_coverage": round(text_coverage, 2),
|
||||
"recommended_method": method,
|
||||
}
|
||||
|
||||
|
||||
class AdaptiveParserMixin:
|
||||
"""
|
||||
Mixin that adds adaptive PDF parsing behavior.
|
||||
|
||||
Analyzes PDF content to choose between direct text extraction (fast) and OCR
|
||||
(for scanned/image PDFs). Subclasses must implement `parse_pdf_document_with_ocr`.
|
||||
"""
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Analyze PDF and route to text extraction or OCR based on content."""
|
||||
analysis = analyze_pdf(content)
|
||||
|
||||
logger.info(
|
||||
"Pdf analysis - pages: %s, pages with text: %s, text_coverage: %s, "
|
||||
"recommended method: %s",
|
||||
analysis["total_pages"],
|
||||
analysis["pages_with_text"],
|
||||
analysis["text_coverage"],
|
||||
analysis["recommended_method"],
|
||||
)
|
||||
|
||||
method = analysis["recommended_method"]
|
||||
if method == METHOD_TEXT_EXTRACTION:
|
||||
return self.extract_text_from_pdf(name=name, content_type=content_type, content=content)
|
||||
return self.parse_pdf_document_with_ocr(name=name, content=content)
|
||||
|
||||
def extract_text_from_pdf(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Extract text directly from PDF without OCR (for text-based PDFs)."""
|
||||
logger.info("Parsing pdf with text extraction")
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
|
||||
"""Process PDF through OCR. Must be implemented by subclass."""
|
||||
raise NotImplementedError("Subclass must implement parse_pdf_document_with_ocr")
|
||||
|
||||
|
||||
class AdaptivePdfParser(AdaptiveParserMixin, BaseParser):
|
||||
"""
|
||||
PDF parser with adaptive text extraction / OCR routing.
|
||||
|
||||
Uses Mistral OCR API for scanned/image PDFs, processed in batches with retry logic.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.endpoint = urljoin(
|
||||
settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.base_url, "/v1/ocr"
|
||||
)
|
||||
self.max_retries = settings.OCR_MAX_RETRIES
|
||||
self.retry_delay = settings.OCR_RETRY_DELAY
|
||||
api_key = settings.LLM_CONFIGURATIONS[settings.OCR_HRID].provider.api_key
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def extract_page_batch(self, reader: PdfReader, start_index: int, end_index: int) -> bytes:
|
||||
"""Extract a range of pages from PDF as a new PDF bytes object."""
|
||||
writer = PdfWriter()
|
||||
for i in range(start_index, end_index):
|
||||
writer.add_page(reader.pages[i])
|
||||
output = BytesIO()
|
||||
writer.write(output)
|
||||
return output.getvalue()
|
||||
|
||||
def ocr_page_batch(
|
||||
self,
|
||||
name: str,
|
||||
page_content: bytes,
|
||||
start_index: int,
|
||||
end_index: int,
|
||||
) -> list[str]:
|
||||
"""Send page batch to Mistral OCR API with static delay retry."""
|
||||
file_data = base64.standard_b64encode(page_content).decode("utf-8")
|
||||
payload = {
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_name": f"{name}_pages_{start_index + 1}_to_{end_index}",
|
||||
"document_url": f"data:application/pdf;base64,{file_data}",
|
||||
},
|
||||
"model": settings.OCR_MODEL,
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=settings.OCR_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
pages = response.json().get("pages", [])
|
||||
return [page.get("markdown", "") for page in pages]
|
||||
|
||||
except (requests.Timeout, requests.RequestException) as e:
|
||||
last_exception = e
|
||||
if attempt < self.max_retries - 1:
|
||||
logger.warning(
|
||||
"OCR attempt %d/%d failed for pages %d-%d: %s. Retrying in %.1fs...",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
start_index + 1,
|
||||
end_index,
|
||||
str(e),
|
||||
self.retry_delay,
|
||||
)
|
||||
time.sleep(self.retry_delay)
|
||||
|
||||
logger.error(
|
||||
"OCR failed for pages %d-%d after %d attempts: %s",
|
||||
start_index + 1,
|
||||
end_index,
|
||||
self.max_retries,
|
||||
str(last_exception),
|
||||
)
|
||||
raise last_exception
|
||||
|
||||
def parse_pdf_document_with_ocr(self, name: str, content: bytes) -> str:
|
||||
"""Process PDF through OCR in batches, returning concatenated markdown."""
|
||||
reader = PdfReader(BytesIO(content))
|
||||
total_pages = len(reader.pages)
|
||||
batch_size = settings.OCR_BATCH_PAGES
|
||||
|
||||
logger.info("Parsing pdf with OCR (%d pages, batch size %d)", total_pages, batch_size)
|
||||
|
||||
results = []
|
||||
for start_index in range(0, total_pages, batch_size):
|
||||
end_index = min(start_index + batch_size, total_pages)
|
||||
batch_content = self.extract_page_batch(reader, start_index, end_index)
|
||||
try:
|
||||
batch_results = self.ocr_page_batch(name, batch_content, start_index, end_index)
|
||||
results.extend(batch_results)
|
||||
logger.debug(
|
||||
"Completed OCR for pages %d-%d/%d", start_index + 1, end_index, total_pages
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except #noqa: BLE001
|
||||
logger.error("Failed to OCR pages %d-%d: %s", start_index + 1, end_index, str(e))
|
||||
# Preserve page count with empty placeholders to maintain correct ordering
|
||||
results.extend([""] * (end_index - start_index))
|
||||
|
||||
return "\n\n".join(results)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Route to PDF parser or DocumentConverter based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
@@ -7,13 +7,13 @@ from typing import List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,9 +26,6 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
|
||||
It provides methods to:
|
||||
- Create a collection for the search operation.
|
||||
- Parse documents and convert them to Markdown format:
|
||||
+ Handle PDF parsing using the Albert API.
|
||||
+ Use the DocumentConverter (markitdown) for other formats.
|
||||
- Store parsed documents in the Albert collection.
|
||||
- Perform a search operation using the Albert API.
|
||||
"""
|
||||
@@ -46,10 +43,15 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
}
|
||||
self._collections_endpoint = urljoin(self._base_url, "/v1/collections")
|
||||
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
|
||||
self._pdf_parser_endpoint = urljoin(self._base_url, "/v1/parse-beta")
|
||||
self._search_endpoint = urljoin(self._base_url, "/v1/search")
|
||||
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
parser_class = import_string(settings.RAG_DOCUMENT_PARSER)
|
||||
self.parser = parser_class()
|
||||
|
||||
@staticmethod
|
||||
def cast_collection_id(collection_id):
|
||||
"""Albert API expects int Ids."""
|
||||
return int(collection_id)
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -91,7 +93,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
self.collection_id = str(response.json()["id"])
|
||||
return self.collection_id
|
||||
|
||||
def delete_collection(self) -> None:
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Delete the current collection
|
||||
"""
|
||||
@@ -102,7 +104,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def adelete_collection(self) -> None:
|
||||
async def adelete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Asynchronously delete the current collection
|
||||
"""
|
||||
@@ -114,59 +116,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
"""
|
||||
Parse the PDF document content and return the text content.
|
||||
This method should handle the logic to convert the PDF into
|
||||
a format suitable for the Albert API.
|
||||
"""
|
||||
response = requests.post(
|
||||
self._pdf_parser_endpoint,
|
||||
headers=self._headers,
|
||||
files={
|
||||
"file": (
|
||||
name,
|
||||
content,
|
||||
content_type,
|
||||
), # Use the name as the filename in the request
|
||||
"output_format": (None, "markdown"), # Specify the output format as Markdown,
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for the Albert API.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
# Implement the parsing logic here
|
||||
if content_type == "application/pdf":
|
||||
# Handle PDF parsing
|
||||
markdown_content = self.parse_pdf_document(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
else:
|
||||
markdown_content = DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
return markdown_content
|
||||
|
||||
def store_document(self, name: str, content: str) -> None:
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -174,6 +124,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
response = requests.post(
|
||||
urljoin(self._base_url, self._documents_endpoint),
|
||||
@@ -188,7 +139,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
async def astore_document(self, name: str, content: str) -> None:
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
This method should handle the logic to send the document content to the Albert API.
|
||||
@@ -196,6 +147,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
@@ -213,13 +165,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
**kwargs: Additional arguments.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
@@ -256,13 +209,14 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
),
|
||||
)
|
||||
|
||||
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
async def asearch(self, query, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Perform an asynchronous search using the Albert API based on the provided query.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
results_count (int): The number of results to return.
|
||||
**kwargs: Additional arguments.
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""Implementation of the Albert API for RAG document search."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from chat.agent_rag.constants import RAGWebResults
|
||||
from chat.agent_rag.document_converter.parser import BaseParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseRagBackend:
|
||||
class BaseRagBackend(ABC):
|
||||
"""Base class for RAG backends."""
|
||||
|
||||
def __init__(
|
||||
@@ -38,6 +39,12 @@ class BaseRagBackend:
|
||||
self.collection_id = collection_id
|
||||
self.read_only_collection_id = read_only_collection_id or []
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser: BaseParser = BaseParser()
|
||||
|
||||
@staticmethod
|
||||
def cast_collection_id(collection_id):
|
||||
"""Dummy method to be overridden when needed."""
|
||||
return collection_id
|
||||
|
||||
def get_all_collection_ids(self) -> List[str]:
|
||||
"""
|
||||
@@ -53,13 +60,17 @@ class BaseRagBackend:
|
||||
|
||||
collection_ids = []
|
||||
if self.collection_id:
|
||||
collection_ids.append(int(self.collection_id))
|
||||
collection_ids.append(self.cast_collection_id(self.collection_id))
|
||||
if self.read_only_collection_id:
|
||||
collection_ids.extend(
|
||||
[int(collection_id) for collection_id in self.read_only_collection_id]
|
||||
[
|
||||
self.cast_collection_id(collection_id)
|
||||
for collection_id in self.read_only_collection_id
|
||||
]
|
||||
)
|
||||
return collection_ids
|
||||
|
||||
@abstractmethod
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
Create a temporary collection for the search operation.
|
||||
@@ -74,7 +85,7 @@ class BaseRagBackend:
|
||||
"""
|
||||
return await sync_to_async(self.create_collection)(name=name, description=description)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
def parse_document(self, name: str, content_type: str, content: bytes):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
@@ -83,14 +94,15 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
return self.parser.parse_document(name, content_type, content)
|
||||
|
||||
def store_document(self, name: str, content: str) -> None:
|
||||
@abstractmethod
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -98,10 +110,11 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def astore_document(self, name: str, content: str) -> None:
|
||||
async def astore_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
This method should handle the logic to send the document content to the API.
|
||||
@@ -109,50 +122,66 @@ class BaseRagBackend:
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
return await sync_to_async(self.store_document)(name=name, content=content)
|
||||
return await sync_to_async(self.store_document)(name=name, content=content, **kwargs)
|
||||
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
def parse_and_store_document(
|
||||
self, name: str, content_type: str, content: bytes, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
Parse the document and store it in the Albert collection.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
**kwargs: Additional arguments. ex: "user_sub" for access control.
|
||||
"""
|
||||
if not self.collection_id:
|
||||
raise RuntimeError("The RAG backend requires collection_id")
|
||||
|
||||
document_content = self.parse_document(name, content_type, content)
|
||||
self.store_document(name, document_content)
|
||||
self.store_document(name, document_content, **kwargs)
|
||||
return document_content
|
||||
|
||||
def delete_collection(self) -> None:
|
||||
@abstractmethod
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Delete the collection.
|
||||
This method should handle the logic to delete the collection from the backend.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def adelete_collection(self) -> None:
|
||||
async def adelete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Delete the collection.
|
||||
This method should handle the logic to delete the collection from the backend.
|
||||
"""
|
||||
return await sync_to_async(self.delete_collection)()
|
||||
return await sync_to_async(self.delete_collection)(**kwargs)
|
||||
|
||||
def search(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
@abstractmethod
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def asearch(self, query, results_count: int = 4) -> RAGWebResults:
|
||||
async def asearch(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
Search the collection for the given query asynchronously.
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. ex: 'session' for OIDC authentication.
|
||||
"""
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count)
|
||||
return await sync_to_async(self.search)(query=query, results_count=results_count, **kwargs)
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
@@ -168,7 +197,9 @@ class BaseRagBackend:
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
|
||||
async def temporary_collection_async(
|
||||
cls, name: str, description: Optional[str] = None, **kwargs
|
||||
):
|
||||
"""Context manager for RAG backend with temporary collections."""
|
||||
backend = cls()
|
||||
|
||||
@@ -176,4 +207,4 @@ class BaseRagBackend:
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
await backend.adelete_collection()
|
||||
await backend.adelete_collection(**kwargs)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Implementation of the Find API for RAG document search."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import AlbertParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
from utils.oidc import with_fresh_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SUPPORTED_LANGUAGE_CODES = ["en", "fr", "de", "nl"]
|
||||
|
||||
|
||||
class FindRagBackend(BaseRagBackend):
|
||||
"""
|
||||
This class is a placeholder for the Find API implementation.
|
||||
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
|
||||
|
||||
It provides methods to:
|
||||
- Store parsed documents in the Find index.
|
||||
- Perform a search operation using the Find API.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collection_id: Optional[str] = None,
|
||||
read_only_collection_id: Optional[List[str]] = None,
|
||||
):
|
||||
# Initialize any necessary parameters or configurations here
|
||||
super().__init__(collection_id, read_only_collection_id)
|
||||
self.api_key = settings.FIND_API_KEY
|
||||
self.search_endpoint = "api/v1.0/documents/search/"
|
||||
self.indexing_endpoint = "api/v1.0/documents/index/"
|
||||
self.deleting_endpoint = "api/v1.0/documents/delete/"
|
||||
self.parser = AlbertParser() # Find Rag relies on Albert parser
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
init collection_id
|
||||
"""
|
||||
self.collection_id = self.collection_id or str(uuid.uuid4())
|
||||
return self.collection_id
|
||||
|
||||
@with_fresh_access_token
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
"""
|
||||
Delete the current collection
|
||||
"""
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.deleting_endpoint),
|
||||
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
|
||||
json={"tags": [f"collection-{self.collection_id}"], "service": "conversations"},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
index document in Find
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content (str): The content of the document in Markdown format.
|
||||
user_sub (str): The user subject identifier for access control.
|
||||
"""
|
||||
logger.debug("index document '%s' in Find", name)
|
||||
|
||||
user_sub = kwargs.get("user_sub")
|
||||
if not user_sub:
|
||||
raise ValueError("user_sub is required to store document in FindRagBackend")
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.indexing_endpoint),
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"id": str(uuid4()),
|
||||
"title": str(name) or "",
|
||||
"depth": 0,
|
||||
"path": str(name) or "",
|
||||
"numchild": 0,
|
||||
"content": content or "",
|
||||
"created_at": timezone.now().isoformat(),
|
||||
"updated_at": timezone.now().isoformat(),
|
||||
"tags": [f"collection-{self.collection_id}"],
|
||||
"size": len(content.encode("utf-8")),
|
||||
"users": [user_sub],
|
||||
"groups": [],
|
||||
"reach": "authenticated",
|
||||
"is_active": True,
|
||||
},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@with_fresh_access_token
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Perform a search using the Find API.
|
||||
Uses the user's OIDC token from the request session.
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
results_count: Number of results to return.
|
||||
**kwargs: Additional arguments. Expected: 'session' containing OIDC tokens,
|
||||
|
||||
Returns:
|
||||
RAGWebResults: The search results.
|
||||
"""
|
||||
logger.debug("search documents in Find with query '%s'", query)
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.search_endpoint),
|
||||
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
|
||||
json={
|
||||
"q": query or "*",
|
||||
"tags": [
|
||||
f"collection-{collection_id}" for collection_id in self.get_all_collection_ids()
|
||||
],
|
||||
"k": results_count,
|
||||
},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return RAGWebResults(
|
||||
data=[
|
||||
RAGWebResult(
|
||||
url=get_language_value(result["_source"], "title"),
|
||||
content=get_language_value(result["_source"], "content"),
|
||||
score=result["_score"],
|
||||
)
|
||||
for result in response.json()
|
||||
],
|
||||
usage=RAGWebUsage(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_language_value(source, language_field):
|
||||
"""
|
||||
extract the value of the language field with the correct language_code extension.
|
||||
"title" and "content" have extensions like "title.en" or "title.fr".
|
||||
get_language_value will return the value regardless of the extension.
|
||||
"""
|
||||
for language_code in SUPPORTED_LANGUAGE_CODES:
|
||||
if f"{language_field}.{language_code}" in source:
|
||||
return source[f"{language_field}.{language_code}"]
|
||||
raise ValueError(f"No '{language_field}' field with any supported language code in object")
|
||||
@@ -10,7 +10,6 @@ import httpx
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models import get_user_agent
|
||||
from pydantic_ai.profiles import ModelProfile
|
||||
from pydantic_ai.toolsets import FunctionToolset
|
||||
|
||||
from chat.tools import get_pydantic_tools_by_name
|
||||
|
||||
@@ -174,20 +173,18 @@ class BaseAgent(Agent):
|
||||
# and pydantic_ai.models.infer_model()
|
||||
_model_instance = self.configuration.model_name
|
||||
|
||||
_system_prompt = self.configuration.system_prompt
|
||||
_base_toolset = (
|
||||
[
|
||||
FunctionToolset(
|
||||
tools=[
|
||||
get_pydantic_tools_by_name(tool_name)
|
||||
for tool_name in self.configuration.tools
|
||||
]
|
||||
)
|
||||
]
|
||||
if self.configuration.tools
|
||||
else None
|
||||
)
|
||||
_system_prompt = self.get_system_prompt()
|
||||
|
||||
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
_tools = self.get_tools()
|
||||
|
||||
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
|
||||
|
||||
def get_system_prompt(self) -> str | None:
|
||||
"""Override this method to customize the system prompt."""
|
||||
return self.configuration.system_prompt
|
||||
|
||||
def get_tools(self) -> list | None:
|
||||
"""Override this method to customize tools."""
|
||||
if not self.configuration.tools:
|
||||
return []
|
||||
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
|
||||
@@ -131,3 +131,25 @@ class ConversationAgent(BaseAgent):
|
||||
if tool.name.startswith("web_search_"):
|
||||
return tool.name
|
||||
return None
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class TitleGenerationAgent(BaseAgent):
|
||||
"""Agent that generates concise, descriptive titles for conversations."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
|
||||
output_type=str,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_tools(self):
|
||||
return []
|
||||
|
||||
def get_system_prompt(self):
|
||||
return (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
|
||||
@@ -6,14 +6,103 @@ for the LLM to access them, and then reverting them back to local URLs when
|
||||
storing the messages in the database.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from pydantic_ai import DocumentUrl, ImageUrl, ModelMessage, ModelRequest, UserPromptPart
|
||||
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
from core.file_upload.utils import generate_retrieve_policy
|
||||
|
||||
from chat.models import ChatConversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_temporary_url(key: str) -> str:
|
||||
"""
|
||||
Generate a temporary URL for accessing a file through the backend.
|
||||
|
||||
Instead of using S3 presigned URLs, this creates a temporary access key
|
||||
that's stored in cache (3 minutes TTL). The LLM accesses the file through
|
||||
a backend endpoint that validates the key and streams the file content.
|
||||
|
||||
This approach:
|
||||
- Works even when S3 is not accessible from the LLM
|
||||
- Provides better security (key is time-limited and single-use)
|
||||
- Allows the backend to control file access centrally
|
||||
|
||||
Args:
|
||||
key (str): The S3 object key where the file is stored.
|
||||
|
||||
Returns:
|
||||
str: A temporary URL with format: /api/v1.0/file-stream/{temporary_key}/
|
||||
"""
|
||||
# Generate a secure random key
|
||||
temporary_key = secrets.token_urlsafe(32)
|
||||
|
||||
# Store the S3 key in cache
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
cache.set(cache_key, key, timeout=settings.FILE_BACKEND_TEMPORARY_URL_EXPIRATION)
|
||||
|
||||
logger.info("Generated temporary file access key for S3 key: %s", key)
|
||||
|
||||
# Return the URL that the LLM will use to access the file
|
||||
return f"{settings.FILE_BACKEND_URL}/api/v1.0/file-stream/{temporary_key}/"
|
||||
|
||||
|
||||
def _get_file_url_for_llm(key: str, mode: str | None = None) -> str:
|
||||
"""
|
||||
Get the appropriate URL for the LLM to access a file based on the upload mode.
|
||||
|
||||
Args:
|
||||
key (str): The S3 object key where the file is stored.
|
||||
mode (str, optional): The upload mode. Defaults to FILE_TO_LLM_MODE setting.
|
||||
|
||||
Returns:
|
||||
str: The URL or data URL for the LLM to use.
|
||||
|
||||
Supported modes:
|
||||
- presigned_url: Returns a presigned S3 URL (default)
|
||||
- backend_temporary_url: Returns a presigned URL with shorter expiration
|
||||
- backend_base64: Returns a data URL with base64-encoded file content
|
||||
"""
|
||||
if mode is None:
|
||||
mode = settings.FILE_TO_LLM_MODE
|
||||
|
||||
if mode == FileToLLMMode.BACKEND_BASE64:
|
||||
# Read file from S3 and encode as base64 data URL
|
||||
try:
|
||||
with default_storage.open(key, "rb") as file:
|
||||
file_content = file.read()
|
||||
# Detect MIME type from file extension or default to octet-stream
|
||||
mime_type, _ = mimetypes.guess_type(key)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# Create data URL
|
||||
b64_content = base64.b64encode(file_content).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{b64_content}"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Fall back to presigned URL on error
|
||||
logger.exception(
|
||||
"Failed to read file for base64 encoding, falling back to presigned URL"
|
||||
)
|
||||
return generate_retrieve_policy(key)
|
||||
|
||||
elif mode == FileToLLMMode.BACKEND_TEMPORARY_URL:
|
||||
return generate_temporary_url(key)
|
||||
|
||||
# FileToLLMMode.PRESIGNED_URL or default
|
||||
return generate_retrieve_policy(key)
|
||||
|
||||
|
||||
def update_local_urls(
|
||||
conversation: ChatConversation,
|
||||
@@ -21,7 +110,9 @@ def update_local_urls(
|
||||
updated_url: Dict[str, str] | None = None,
|
||||
) -> Iterable[ImageUrl | DocumentUrl]:
|
||||
"""
|
||||
Replace local image or document URLs in the content list to use presigned S3 URLs.
|
||||
Replace local image or document URLs in the content list to use appropriate S3 URLs
|
||||
based on the configured FILE_TO_LLM_MODE.
|
||||
|
||||
⚠️Be careful, `media_contents` are replaced in place.
|
||||
|
||||
Args:
|
||||
@@ -31,7 +122,7 @@ def update_local_urls(
|
||||
mapping of original URLs to updated URLs.
|
||||
Returns:
|
||||
Iterable[ImageUrl | DocumentUrl]: Updated iterable of UserContent objects
|
||||
with presigned URLs.
|
||||
with appropriate S3 URLs based on the configured mode.
|
||||
"""
|
||||
# When images are stored locally, there is no host in the URL, so we can
|
||||
# just check if the URL starts, frontend adds a prefix `/media-key/` to the key.
|
||||
@@ -41,7 +132,9 @@ def update_local_urls(
|
||||
# Filter only ImageUrl contents
|
||||
media_contents = (c for c in contents if isinstance(c, (ImageUrl, DocumentUrl)))
|
||||
|
||||
# Replace URLs with presigned URLs
|
||||
# Replace URLs with appropriate S3 URLs based on mode
|
||||
upload_mode = settings.FILE_TO_LLM_MODE
|
||||
|
||||
for content in media_contents:
|
||||
idx = content.url.find(local_media_url_prefix)
|
||||
|
||||
@@ -57,7 +150,7 @@ def update_local_urls(
|
||||
# except if the user tampers with the conversation.
|
||||
continue
|
||||
|
||||
content.url = generate_retrieve_policy(key)
|
||||
content.url = _get_file_url_for_llm(key, upload_mode)
|
||||
if updated_url is not None:
|
||||
updated_url[content.url] = _initial_url
|
||||
|
||||
@@ -68,7 +161,7 @@ def update_history_local_urls(
|
||||
conversation: ChatConversation, messages: list[ModelMessage]
|
||||
) -> list[ModelMessage]:
|
||||
"""
|
||||
Replace local image/documents URLs in the message list to use presigned S3 URLs.
|
||||
Replace local image/documents URLs in the message list to use appropriate S3 URLs.
|
||||
|
||||
⚠️Be careful, `messages` are replaced in place.
|
||||
|
||||
@@ -79,7 +172,7 @@ def update_history_local_urls(
|
||||
Args:
|
||||
messages (list[ModelMessage]): List of ModelMessage objects.
|
||||
Returns:
|
||||
list[ModelMessage]: Updated list of ModelMessage objects with presigned URLs.
|
||||
list[ModelMessage]: Updated list of ModelMessage objects with appropriate S3 URLs.
|
||||
"""
|
||||
# Filter only ModelRequest messages
|
||||
requests = (msg for msg in messages if isinstance(msg, ModelRequest))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,29 @@ from core.factories import UserFactory
|
||||
from . import models
|
||||
|
||||
|
||||
class ChatProjectFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Project instances."""
|
||||
|
||||
title = factory.Sequence(lambda n: f"title {n}")
|
||||
owner = factory.SubFactory(UserFactory)
|
||||
icon = factory.fuzzy.FuzzyChoice(models.ChatProjectIcon)
|
||||
color = factory.fuzzy.FuzzyChoice(models.ChatProjectColor)
|
||||
|
||||
class Meta:
|
||||
model = models.ChatProject
|
||||
skip_postgeneration_save = True
|
||||
|
||||
@factory.post_generation
|
||||
def number_of_conversations(self, create, extracted, **kwargs):
|
||||
"""Create attached conversations for the project."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
|
||||
if not isinstance(extracted, int):
|
||||
raise TypeError("number_of_conversations must be an integer")
|
||||
ChatConversationFactory.create_batch(extracted, project=self, owner=self.owner)
|
||||
|
||||
|
||||
class ChatConversationFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating ChatConversation instances."""
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Helpers to prevent proxy timeouts during long-running stream operations.
|
||||
|
||||
This module provides utilities to wrap synchronous and asynchronous iterators
|
||||
with keepalive messages. When a stream pauses for longer than the specified
|
||||
interval, keepalive messages are injected to prevent proxy/gateway
|
||||
timeouts while waiting for the stream data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .vercel_ai_sdk.core.events_v4 import DataPart as V4DataPart
|
||||
from .vercel_ai_sdk.core.events_v5 import DataPart as V5DataPart
|
||||
from .vercel_ai_sdk.encoder import (
|
||||
CURRENT_EVENT_ENCODER_VERSION,
|
||||
EventEncoder,
|
||||
EventEncoderVersion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_keepalive_message() -> str:
|
||||
"""Generate a keepalive message based on encoder/SDK version."""
|
||||
if CURRENT_EVENT_ENCODER_VERSION == EventEncoderVersion.V4:
|
||||
event = V4DataPart(data=[{"status": "WAITING"}])
|
||||
else:
|
||||
event = V5DataPart(data={"status": "WAITING"})
|
||||
encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION)
|
||||
return encoder.encode(event)
|
||||
|
||||
|
||||
async def stream_with_keepalive_async(
|
||||
stream: AsyncIterator[str],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Wrap an async iterator to emit keepalive during long pauses.
|
||||
|
||||
Args:
|
||||
stream: The async iterator to wrap
|
||||
Yields:
|
||||
Items from the original stream, plus keepalive messages during pauses
|
||||
Raises:
|
||||
Any exception raised by the original stream
|
||||
"""
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
finished = asyncio.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
|
||||
async def producer():
|
||||
"""Background task that consumes the original stream into a queue."""
|
||||
|
||||
try:
|
||||
async for stream_item in stream:
|
||||
await q.put(stream_item)
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
# Pass exceptions through the queue so the consumer can re-raise them.
|
||||
# This ensures errors aren't silently swallowed.
|
||||
await q.put(exc)
|
||||
finally:
|
||||
finished.set()
|
||||
await q.put(None) # Sentinel to signal completion
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(q.get(), timeout=settings.KEEPALIVE_INTERVAL)
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
except asyncio.TimeoutError:
|
||||
# No data received within interval
|
||||
if finished.is_set():
|
||||
# Producer is done, queue is empty (else we would not have timed out)
|
||||
break
|
||||
|
||||
logger.debug("Send keepalive")
|
||||
yield keepalive_message
|
||||
finally:
|
||||
# Cleanup
|
||||
producer_task.cancel()
|
||||
try:
|
||||
await producer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def get_current_time() -> float:
|
||||
"""Get current monotonic time, avoiding freezegun interferences.
|
||||
|
||||
Returns time.monotonic() which:
|
||||
- Is NOT affected by freezegun's @freeze_time decorator (unlike time.time())
|
||||
- Prevents issues where frozen time in main thread differs from real time in
|
||||
spawned threads, causing incorrect keepalive interval computation
|
||||
- Is the best clock for measuring time intervals
|
||||
|
||||
Wrapped in a function to ease mocking in tests.
|
||||
|
||||
Returns:
|
||||
float: Monotonic time in seconds since an arbitrary reference point
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def stream_with_keepalive_sync(stream: Iterator[str]) -> Iterator[str]:
|
||||
"""Wraps a synchronous stream with keepalive messages."""
|
||||
|
||||
q: queue.Queue = queue.Queue()
|
||||
stream_done = threading.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
# Mutable container so threads can read/write shared timestamp
|
||||
last_yield_time = [get_current_time()]
|
||||
|
||||
def consume_stream():
|
||||
"""Read from source stream and forward chunks to queue."""
|
||||
try:
|
||||
for chunk in stream:
|
||||
if stream_done.is_set():
|
||||
return # early exit
|
||||
q.put(chunk, timeout=1) # Arbitrary timeout prevents blocking forever
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception("Error in stream consumption")
|
||||
q.put(e)
|
||||
finally:
|
||||
stream_done.set()
|
||||
|
||||
def send_keepalives():
|
||||
"""Inject keepalive messages when idle too long.
|
||||
|
||||
Uses get_current_time() (time.monotonic) instead of time.time()
|
||||
to avoid issues with freezegun in tests.
|
||||
"""
|
||||
while not stream_done.is_set():
|
||||
# Sleep before checking to give main loop time to process and update timestamp
|
||||
time.sleep(0.5) # let main loop process first, empiric value
|
||||
if get_current_time() - last_yield_time[0] >= settings.KEEPALIVE_INTERVAL:
|
||||
try:
|
||||
q.put(keepalive_message, timeout=0.1)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
for target in (consume_stream, send_keepalives):
|
||||
threading.Thread(target=target, daemon=True).start()
|
||||
|
||||
try:
|
||||
# Continue while stream is active or queue has still items
|
||||
while not stream_done.is_set() or not q.empty():
|
||||
try:
|
||||
item = q.get(timeout=1) # short timeout, avoid blocking and stay responsive
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# Re-raise from consume_stream
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
|
||||
yield item
|
||||
last_yield_time[0] = get_current_time()
|
||||
finally:
|
||||
# Signal threads to stop
|
||||
stream_done.set()
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-30 09:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("chat", "0004_chatconversationattachment_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="chatconversation",
|
||||
name="title_set_by_user_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic title generation.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-25 16:20
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("chat", "0005_chatconversation_title_set_by_user_at"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChatProject",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(help_text="Title of the chat project", max_length=100)),
|
||||
(
|
||||
"icon",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("folder", "Folder icon"),
|
||||
("file", "File icon"),
|
||||
("perso", "Perso icon"),
|
||||
("gear", "Gear icon"),
|
||||
("megaphone", "Megaphone icon"),
|
||||
("star", "Star icon"),
|
||||
("bookmark", "Bookmark icon"),
|
||||
("chart", "Chart icon"),
|
||||
("photo", "Photo icon"),
|
||||
("euro", "Euro icon"),
|
||||
("key", "Key icon"),
|
||||
("justice", "Justice icon"),
|
||||
("book", "Book icon"),
|
||||
("puzzle", "Puzzle icon"),
|
||||
("palette", "Palette icon"),
|
||||
("terminal", "Terminal icon"),
|
||||
("car", "Car icon"),
|
||||
("music", "Music icon"),
|
||||
("checkmark", "Checkmark icon"),
|
||||
("la_suite", "La Suite icon"),
|
||||
],
|
||||
help_text="Project icon",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"color",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("color_1", "Color 1"),
|
||||
("color_2", "Color 2"),
|
||||
("color_3", "Color 3"),
|
||||
("color_4", "Color 4"),
|
||||
("color_5", "Color 5"),
|
||||
("color_6", "Color 6"),
|
||||
("color_7", "Color 7"),
|
||||
("color_8", "Color 8"),
|
||||
("color_9", "Color 9"),
|
||||
],
|
||||
help_text="Project icon color",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"llm_instructions",
|
||||
models.TextField(
|
||||
blank=True, help_text="Custom user instructions to be sent to the llm"
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="projects",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="chatconversation",
|
||||
name="project",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="conversations",
|
||||
to="chat.chatproject",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatconversation",
|
||||
index=models.Index(
|
||||
fields=["owner", "-created_at"], name="chat_chatco_owner_i_48266a_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chatconversation",
|
||||
index=models.Index(fields=["owner", "project"], name="chat_chatco_owner_i_38d719_idx"),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,73 @@ from chat.ai_sdk_types import UIMessage
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ChatProjectIcon(models.TextChoices):
|
||||
"""Project icon text choices."""
|
||||
|
||||
FOLDER = "folder", "Folder icon"
|
||||
FILE = "file", "File icon"
|
||||
PERSO = "perso", "Perso icon"
|
||||
GEAR = "gear", "Gear icon"
|
||||
MEGAPHONE = "megaphone", "Megaphone icon"
|
||||
STAR = "star", "Star icon"
|
||||
BOOKMARK = "bookmark", "Bookmark icon"
|
||||
CHART = "chart", "Chart icon"
|
||||
PHOTO = "photo", "Photo icon"
|
||||
EURO = "euro", "Euro icon"
|
||||
KEY = "key", "Key icon"
|
||||
JUSTICE = "justice", "Justice icon"
|
||||
BOOK = "book", "Book icon"
|
||||
PUZZLE = "puzzle", "Puzzle icon"
|
||||
PALETTE = "palette", "Palette icon"
|
||||
TERMINAL = "terminal", "Terminal icon"
|
||||
CAR = "car", "Car icon"
|
||||
MUSIC = "music", "Music icon"
|
||||
CHECKMARK = "checkmark", "Checkmark icon"
|
||||
LA_SUITE = "la_suite", "La Suite icon"
|
||||
|
||||
|
||||
class ChatProjectColor(models.TextChoices):
|
||||
"""Project icon color choices. We keep it generic to ease frontend compatibility."""
|
||||
|
||||
COLOR_1 = "color_1", "Color 1"
|
||||
COLOR_2 = "color_2", "Color 2"
|
||||
COLOR_3 = "color_3", "Color 3"
|
||||
COLOR_4 = "color_4", "Color 4"
|
||||
COLOR_5 = "color_5", "Color 5"
|
||||
COLOR_6 = "color_6", "Color 6"
|
||||
COLOR_7 = "color_7", "Color 7"
|
||||
COLOR_8 = "color_8", "Color 8"
|
||||
COLOR_9 = "color_9", "Color 9"
|
||||
|
||||
|
||||
class ChatProject(BaseModel):
|
||||
"""Model representing a project that groups conversations together."""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
related_name="projects",
|
||||
on_delete=models.CASCADE,
|
||||
null=False,
|
||||
blank=False,
|
||||
)
|
||||
title = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Title of the chat project",
|
||||
)
|
||||
icon = models.CharField(max_length=20, choices=ChatProjectIcon, help_text="Project icon")
|
||||
color = models.CharField(
|
||||
max_length=20, choices=ChatProjectColor, help_text="Project icon color"
|
||||
)
|
||||
|
||||
llm_instructions = models.TextField(
|
||||
blank=True,
|
||||
help_text="Custom user instructions to be sent to the llm",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ChatConversation(BaseModel):
|
||||
"""
|
||||
Model representing a chat conversation.
|
||||
@@ -44,7 +111,12 @@ class ChatConversation(BaseModel):
|
||||
null=True,
|
||||
help_text="Title of the chat conversation",
|
||||
)
|
||||
|
||||
title_set_by_user_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic "
|
||||
"title generation.",
|
||||
)
|
||||
ui_messages = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
@@ -74,6 +146,23 @@ class ChatConversation(BaseModel):
|
||||
help_text="Collection ID for the conversation, used for RAG document search",
|
||||
)
|
||||
|
||||
project = models.ForeignKey(
|
||||
ChatProject,
|
||||
related_name="conversations",
|
||||
on_delete=models.SET_NULL, # explicitly avoid Cascade here
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
indexes = [
|
||||
models.Index(fields=["owner", "-created_at"]),
|
||||
models.Index(fields=["owner", "project"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or str(self.pk)
|
||||
|
||||
|
||||
class ChatConversationAttachment(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -4,12 +4,13 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField # pylint: disable=no-name-in-module
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.file_upload.enums import AttachmentStatus
|
||||
from core.file_upload.enums import AttachmentStatus, FileUploadMode
|
||||
from core.file_upload.utils import generate_upload_policy
|
||||
|
||||
from chat import models
|
||||
@@ -24,9 +25,26 @@ class ChatConversationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner", "project"]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "messages"]
|
||||
|
||||
def validate_project(self, project):
|
||||
"""Ensure the project belongs to the current user."""
|
||||
if project and project.owner != self.context["request"].user:
|
||||
raise serializers.ValidationError("The project must belong to the current user.")
|
||||
return project
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Project is immutable after creation — no moving or detaching
|
||||
if "project" in validated_data:
|
||||
raise serializers.ValidationError(
|
||||
{"project": "This field can only be set at creation time."}
|
||||
)
|
||||
# If title is being changed, mark it as user-set
|
||||
if "title" in validated_data and validated_data["title"] != instance.title:
|
||||
instance.title_set_by_user_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ChatConversationInputSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -173,7 +191,11 @@ class ChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating chat conversation attachments."""
|
||||
"""Serializer for creating chat conversation attachments.
|
||||
|
||||
For presigned_url mode: returns 'policy' field with presigned URL for direct S3 upload
|
||||
For backend modes: does not return 'policy' field (upload handled via backend endpoint)
|
||||
"""
|
||||
|
||||
policy = serializers.SerializerMethodField()
|
||||
uploaded_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
@@ -183,9 +205,15 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
model = models.ChatConversationAttachment
|
||||
fields = ["id", "key", "content_type", "file_name", "size", "policy", "uploaded_by"]
|
||||
|
||||
def get_policy(self, attachment) -> str:
|
||||
"""Return the policy to use if the item is a file."""
|
||||
return generate_upload_policy(attachment.key)
|
||||
def get_policy(self, attachment) -> str | None:
|
||||
"""Return the policy (presigned URL) only for presigned_url mode."""
|
||||
upload_mode = settings.FILE_UPLOAD_MODE
|
||||
|
||||
# Only return presigned URL in presigned_url mode
|
||||
if upload_mode == FileUploadMode.PRESIGNED_URL:
|
||||
return generate_upload_policy(attachment.key)
|
||||
|
||||
return None
|
||||
|
||||
def validate_size(self, size: Optional[int]) -> Optional[int]:
|
||||
"""Validate that the size is not greater than the maximum allowed size."""
|
||||
@@ -199,3 +227,68 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
return size
|
||||
|
||||
|
||||
class ChatProjectNestedSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight read-only serializer for nested project info in search results."""
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatProject
|
||||
fields = ["id", "title", "icon"]
|
||||
read_only_fields = ["id", "title", "icon"]
|
||||
|
||||
|
||||
class ChatConversationSearchSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for conversation search results with nested project info."""
|
||||
|
||||
project = ChatProjectNestedSerializer(read_only=True)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = ["id", "title", "created_at", "updated_at", "project"]
|
||||
read_only_fields = ["id", "title", "created_at", "updated_at", "project"]
|
||||
|
||||
|
||||
class ChatConversationNestedSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for chat conversations."""
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatConversation
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
]
|
||||
read_only_fields = ["id", "title"]
|
||||
|
||||
|
||||
class ChatProjectSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for projects."""
|
||||
|
||||
LLM_INSTRUCTIONS_MAX_LENGTH = 4000 # prevent too large prompts, easier to handle here
|
||||
|
||||
owner = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
# Unbounded: the sidebar needs all conversations per project.
|
||||
# Projects are paginated at the view level, keeping payloads reasonable.
|
||||
conversations = ChatConversationNestedSerializer(many=True, read_only=True)
|
||||
llm_instructions = serializers.CharField(
|
||||
max_length=LLM_INSTRUCTIONS_MAX_LENGTH, required=False, allow_blank=True
|
||||
)
|
||||
|
||||
class Meta: # pylint: disable=missing-class-docstring
|
||||
model = models.ChatProject
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"icon",
|
||||
"color",
|
||||
"llm_instructions",
|
||||
"owner",
|
||||
"conversations",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer (pypdf)
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 10
|
||||
/Kids [ 4 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Length 132
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 612 792 ]
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000054 00000 n
|
||||
0000000174 00000 n
|
||||
0000000223 00000 n
|
||||
0000000351 00000 n
|
||||
0000000534 00000 n
|
||||
0000000631 00000 n
|
||||
0000000759 00000 n
|
||||
0000000853 00000 n
|
||||
0000000947 00000 n
|
||||
0000001042 00000 n
|
||||
0000001137 00000 n
|
||||
0000001232 00000 n
|
||||
0000001327 00000 n
|
||||
0000001422 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 16
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
1517
|
||||
%%EOF
|
||||
@@ -0,0 +1,355 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer (pypdf)
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 10
|
||||
/Kids [ 4 0 R 7 0 R 10 0 R 13 0 R 16 0 R 19 0 R 22 0 R 25 0 R 28 0 R 31 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 5 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 6 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 8 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 9 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 11 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 12 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 14 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 15 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 17 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 18 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 20 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 21 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 23 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 24 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 26 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 27 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 29 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 30 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
30 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
31 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [ 0 0 612 792 ]
|
||||
/Contents 32 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 33 0 R
|
||||
>>
|
||||
>>
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
32 0 obj
|
||||
<<
|
||||
/Length 332
|
||||
>>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
33 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 34
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000054 00000 n
|
||||
0000000176 00000 n
|
||||
0000000225 00000 n
|
||||
0000000353 00000 n
|
||||
0000000736 00000 n
|
||||
0000000833 00000 n
|
||||
0000000961 00000 n
|
||||
0000001344 00000 n
|
||||
0000001441 00000 n
|
||||
0000001572 00000 n
|
||||
0000001956 00000 n
|
||||
0000002054 00000 n
|
||||
0000002185 00000 n
|
||||
0000002569 00000 n
|
||||
0000002667 00000 n
|
||||
0000002798 00000 n
|
||||
0000003182 00000 n
|
||||
0000003280 00000 n
|
||||
0000003411 00000 n
|
||||
0000003795 00000 n
|
||||
0000003893 00000 n
|
||||
0000004024 00000 n
|
||||
0000004408 00000 n
|
||||
0000004506 00000 n
|
||||
0000004637 00000 n
|
||||
0000005021 00000 n
|
||||
0000005119 00000 n
|
||||
0000005250 00000 n
|
||||
0000005634 00000 n
|
||||
0000005732 00000 n
|
||||
0000005863 00000 n
|
||||
0000006247 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 34
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
6345
|
||||
%%EOF
|
||||
Binary file not shown.
@@ -0,0 +1,310 @@
|
||||
"""Tests for AdaptivePdfParser and AdaptiveParserMixin."""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
|
||||
from chat.agent_rag.document_converter.parser import (
|
||||
METHOD_OCR,
|
||||
METHOD_TEXT_EXTRACTION,
|
||||
AdaptivePdfParser,
|
||||
analyze_pdf,
|
||||
)
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture(name="text_pdf_1_page")
|
||||
def provide_text_pdf_1_page():
|
||||
"""Load a 1 page PDF with extractable text."""
|
||||
return (FIXTURES_DIR / "text_pdf_1_page.pdf").read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(name="text_pdf_10_pages")
|
||||
def provide_text_pdf_10_pages():
|
||||
"""Load a 10-page PDF with extractable text (~300 chars per page)."""
|
||||
return (FIXTURES_DIR / "text_10_pages.pdf").read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(name="mixed_pdf_10_pages")
|
||||
def provide_mixed_pdf_10_pages():
|
||||
"""Load a 10-page PDF with 2 pages of text and 8 blank pages."""
|
||||
return (FIXTURES_DIR / "mixed_10_pages.pdf").read_bytes()
|
||||
|
||||
|
||||
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = 200
|
||||
OCR_RETRY_DELAY = 1
|
||||
OCR_MAX_RETRIES = 3
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Mock Django settings for OCR configuration."""
|
||||
settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = 0.7
|
||||
settings.OCR_HRID = "test-ocr-hrid"
|
||||
settings.OCR_MODEL = "test-ocr-model"
|
||||
settings.OCR_TIMEOUT = 60
|
||||
settings.OCR_MAX_RETRIES = OCR_MAX_RETRIES
|
||||
settings.OCR_RETRY_DELAY = OCR_RETRY_DELAY
|
||||
settings.OCR_BATCH_PAGES = 10
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"test-ocr-hrid": MagicMock(
|
||||
provider=MagicMock(
|
||||
base_url="https://ocr.example.com",
|
||||
api_key="test-api-key",
|
||||
)
|
||||
)
|
||||
}
|
||||
return settings
|
||||
|
||||
|
||||
def test_analyze_pdf_returns_correct_structure(text_pdf_10_pages):
|
||||
"""analyze_pdf should return dict with expected keys."""
|
||||
result = analyze_pdf(text_pdf_10_pages)
|
||||
|
||||
assert "total_pages" in result
|
||||
assert "pages_with_text" in result
|
||||
assert "avg_chars_per_page" in result
|
||||
assert "text_coverage" in result
|
||||
assert "recommended_method" in result
|
||||
|
||||
|
||||
def test_analyze_pdf_with_text_recommends_extraction(text_pdf_1_page):
|
||||
"""PDF with sufficient text should recommend text extraction."""
|
||||
result = analyze_pdf(text_pdf_1_page)
|
||||
|
||||
assert result["total_pages"] == 1
|
||||
assert result["pages_with_text"] == 1
|
||||
assert result["text_coverage"] == pytest.approx(1.0)
|
||||
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
|
||||
|
||||
|
||||
def test_analyze_multi_page_pdf_with_text_recommends_extraction(text_pdf_10_pages):
|
||||
"""PDF with sufficient text should recommend text extraction."""
|
||||
result = analyze_pdf(text_pdf_10_pages)
|
||||
|
||||
assert result["total_pages"] == 10
|
||||
assert result["pages_with_text"] == 10
|
||||
assert result["text_coverage"] == pytest.approx(1.0)
|
||||
assert result["avg_chars_per_page"] > MIN_AVG_CHARS_FOR_TEXT_EXTRACTION
|
||||
assert result["recommended_method"] == METHOD_TEXT_EXTRACTION
|
||||
|
||||
|
||||
def test_analyze_pdf_mixed_content_recommends_ocr(mixed_pdf_10_pages):
|
||||
"""PDF with low text coverage should recommend OCR."""
|
||||
result = analyze_pdf(mixed_pdf_10_pages)
|
||||
|
||||
assert result["total_pages"] == 10
|
||||
assert result["pages_with_text"] == 2
|
||||
assert result["text_coverage"] == pytest.approx(0.2)
|
||||
assert result["recommended_method"] == METHOD_OCR
|
||||
|
||||
|
||||
def test_extract_page_batch_single_page(text_pdf_10_pages):
|
||||
"""Should extract a single page correctly."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 0, 1)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 1
|
||||
|
||||
|
||||
def test_extract_page_batch_multiple_pages(text_pdf_10_pages):
|
||||
"""Should extract multiple pages correctly."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 2, 7)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 5
|
||||
|
||||
|
||||
def test_extract_page_batch_last_batch(text_pdf_10_pages):
|
||||
"""Should handle last batch with fewer pages."""
|
||||
parser = AdaptivePdfParser()
|
||||
reader = PdfReader(BytesIO(text_pdf_10_pages))
|
||||
|
||||
result = parser.extract_page_batch(reader, 7, 10)
|
||||
|
||||
result_reader = PdfReader(BytesIO(result))
|
||||
assert len(result_reader.pages) == 3
|
||||
|
||||
|
||||
def test_ocr_page_batch_success(text_pdf_1_page):
|
||||
"""Should return markdown content on successful OCR."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.return_value = {
|
||||
"pages": [
|
||||
{"markdown": "# Page 1 content"},
|
||||
]
|
||||
}
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Page 1 content"]
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
def test_ocr_page_batch_retry_on_timeout(text_pdf_1_page):
|
||||
"""Should retry on timeout with static delay."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep") as mock_sleep:
|
||||
mock_post.side_effect = [
|
||||
requests.Timeout("Connection timed out"),
|
||||
MagicMock(
|
||||
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
|
||||
raise_for_status=MagicMock(),
|
||||
),
|
||||
]
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Content"]
|
||||
assert mock_post.call_count == 2
|
||||
mock_sleep.assert_called_once_with(OCR_RETRY_DELAY)
|
||||
|
||||
|
||||
def test_ocr_page_batch_fails_after_max_retries(text_pdf_1_page):
|
||||
"""Should raise exception after max retries exceeded."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
mock_post.side_effect = requests.Timeout("Connection timed out")
|
||||
|
||||
with pytest.raises(requests.Timeout):
|
||||
parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert mock_post.call_count == OCR_MAX_RETRIES
|
||||
|
||||
|
||||
def test_ocr_page_batch_retry_on_request_exception(text_pdf_1_page):
|
||||
"""Should retry on general request exceptions."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
mock_post.side_effect = [
|
||||
requests.RequestException("Network error"),
|
||||
requests.RequestException("Network error"),
|
||||
MagicMock(
|
||||
json=MagicMock(return_value={"pages": [{"markdown": "# Content"}]}),
|
||||
raise_for_status=MagicMock(),
|
||||
),
|
||||
]
|
||||
|
||||
result = parser.ocr_page_batch("test.pdf", text_pdf_1_page, 0, 1)
|
||||
|
||||
assert result == ["# Content"]
|
||||
assert mock_post.call_count == 3
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_single_batch(text_pdf_10_pages):
|
||||
"""Should process PDF in single batch when pages <= batch size."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.return_value = {
|
||||
"pages": [{"markdown": f"Page {i}"} for i in range(1, 11)]
|
||||
}
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
assert "Page 1" in result
|
||||
assert "Page 10" in result
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_multiple_batches(text_pdf_10_pages, settings):
|
||||
"""Should process PDF in multiple batches when pages > batch size."""
|
||||
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
mock_post.return_value.json.side_effect = [
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]},
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(5, 9)]},
|
||||
{"pages": [{"markdown": f"Page {i}"} for i in range(9, 11)]},
|
||||
]
|
||||
mock_post.return_value.raise_for_status = MagicMock()
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
assert mock_post.call_count == 3
|
||||
assert "Page 1" in result
|
||||
assert "Page 10" in result
|
||||
|
||||
|
||||
def test_parse_pdf_with_ocr_partial_failure(text_pdf_10_pages, settings):
|
||||
"""Should insert empty placeholders for failed batches."""
|
||||
settings.OCR_BATCH_PAGES = 4 # Force multiple batches
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
success_response = MagicMock()
|
||||
success_response.json.return_value = {"pages": [{"markdown": f"Page {i}"} for i in range(1, 5)]}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.requests.post") as mock_post:
|
||||
with patch("chat.agent_rag.document_converter.parser.time.sleep"):
|
||||
# First batch succeeds, then all retries fail for remaining batches
|
||||
mock_post.side_effect = [
|
||||
success_response,
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
requests.Timeout("OCR failed"),
|
||||
]
|
||||
|
||||
result = parser.parse_pdf_document_with_ocr("test.pdf", text_pdf_10_pages)
|
||||
|
||||
parts = result.split("\n\n")
|
||||
# First batch succeeded (4 pages), remaining batches failed (6 pages as placeholders)
|
||||
assert len(parts) == 10
|
||||
assert parts[0] == "Page 1"
|
||||
assert parts[3] == "Page 4"
|
||||
assert parts[4] == "" # Failed batch placeholder
|
||||
|
||||
|
||||
def test_parse_document_pdf_routed_correctly(text_pdf_1_page):
|
||||
"""Should route PDF content type to PDF parser."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch.object(parser, "parse_pdf_document", return_value="pdf content") as mock_parse:
|
||||
result = parser.parse_document("test.pdf", "application/pdf", text_pdf_1_page)
|
||||
|
||||
assert result == "pdf content"
|
||||
mock_parse.assert_called_once_with(
|
||||
name="test.pdf",
|
||||
content_type="application/pdf",
|
||||
content=text_pdf_1_page,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_document_non_pdf_uses_document_converter():
|
||||
"""Should route non-PDF content to DocumentConverter."""
|
||||
parser = AdaptivePdfParser()
|
||||
|
||||
with patch("chat.agent_rag.document_converter.parser.DocumentConverter") as mock_converter:
|
||||
mock_converter.return_value.convert_raw.return_value = "docx content"
|
||||
|
||||
result = parser.parse_document("test.docx", "application/vnd.openxmlformats", b"content")
|
||||
|
||||
assert result == "docx content"
|
||||
mock_converter.return_value.convert_raw.assert_called_once()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Test cases for the TitleGenerationAgent class."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
|
||||
|
||||
@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-XYZ"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
|
||||
settings.AI_AGENT_TOOLS = []
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_uses_default_model_hrid(settings):
|
||||
"""Test that TitleGenerationAgent uses LLM_DEFAULT_MODEL_HRID from settings."""
|
||||
settings.AI_MODEL = "custom-llm-model"
|
||||
settings.AI_BASE_URL = "https://custom.api.com/v1/"
|
||||
settings.AI_API_KEY = "custom-key"
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert settings.LLM_CONFIGURATIONS["default-model"].model_name == "custom-llm-model"
|
||||
assert agent._model.model_name == "custom-llm-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_model_configuration():
|
||||
"""Test that the agent model is properly configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert agent._model.model_name == "model-XYZ"
|
||||
assert str(agent._model.client.base_url) == "https://api.llm.com/v1/"
|
||||
assert agent._model.client.api_key == "test-key"
|
||||
|
||||
|
||||
def test_title_generation_agent_has_no_tools():
|
||||
"""Test that TitleGenerationAgent has no tools configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert agent._function_toolset.tools == {}
|
||||
assert not agent.get_tools()
|
||||
|
||||
|
||||
def test_title_generation_agent_instructions():
|
||||
"""Test that the agent instructions contain the system prompt."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
# The agent should have the title generation system prompt as instructions
|
||||
instructions = agent._instructions
|
||||
assert len(instructions) == 1
|
||||
expected = (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
assert instructions[0] == expected
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Unit tests for project-level LLM instructions injection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@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 = []
|
||||
|
||||
|
||||
def _get_instruction_names(service):
|
||||
"""Return the names of dynamic (callable) instructions registered on the conversation agent."""
|
||||
# pylint: disable=protected-access
|
||||
return [fn.__name__ for fn in service.conversation_agent._instructions if callable(fn)]
|
||||
|
||||
|
||||
def test_project_instructions_injected_when_present():
|
||||
"""Test that project LLM instructions are injected as a dynamic agent instruction."""
|
||||
project = ChatProjectFactory(llm_instructions="Always answer in bullet points.")
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" in _get_instruction_names(service)
|
||||
# Verify the instruction returns the correct content
|
||||
instruction_fn = next(
|
||||
fn
|
||||
for fn in service.conversation_agent._instructions # pylint: disable=protected-access
|
||||
if callable(fn) and fn.__name__ == "project_instructions"
|
||||
)
|
||||
assert instruction_fn() == "Always answer in bullet points."
|
||||
|
||||
|
||||
def test_project_instructions_not_injected_when_empty():
|
||||
"""Test that empty project instructions are not injected."""
|
||||
project = ChatProjectFactory(llm_instructions="")
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" not in _get_instruction_names(service)
|
||||
|
||||
|
||||
def test_project_instructions_not_injected_when_no_project():
|
||||
"""Test that no project instruction is injected for standalone conversations."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
service = AIAgentService(conversation, user=conversation.owner)
|
||||
|
||||
assert "project_instructions" not in _get_instruction_names(service)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Unit tests for add_document_rag_search_tool_from_setting integration with AIAgentService."""
|
||||
|
||||
# pylint: disable=redefined-outer-name, protected-access
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.factories import ChatConversationFactory, UserFactory
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
|
||||
pytestmark = pytest.mark.django_db()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _llm_config_with_websearch(settings):
|
||||
"""Configure a single active model that includes the web search tool."""
|
||||
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=["web_search_brave_with_document_backend"],
|
||||
provider=LLMProvider(
|
||||
hrid="unused",
|
||||
base_url="https://example.com",
|
||||
api_key="key",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_smart_search_disabled_suppresses_tool_at_runtime(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is off, the tool must be suppressed at runtime.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=False)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is False
|
||||
assert service._is_web_search_enabled is True
|
||||
|
||||
# Replicate what _run_agent does before calling the model
|
||||
if not service._is_smart_search_enabled and service._is_web_search_enabled:
|
||||
service._context_deps.web_search_enabled = False
|
||||
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
|
||||
def test_smart_search_enabled_tool_is_called(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is on, the tool must be invoked.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=True)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is True
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
assert "web_search_brave_with_document_backend" in response.output
|
||||
|
||||
|
||||
def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websearch):
|
||||
"""
|
||||
When smart search is off, the tool must be enabled via force_web_search.
|
||||
"""
|
||||
user = UserFactory(allow_smart_web_search=False)
|
||||
conversation = ChatConversationFactory(owner=user)
|
||||
service = AIAgentService(conversation, user=user)
|
||||
|
||||
assert service._is_smart_search_enabled is False
|
||||
assert service._context_deps.web_search_enabled is False
|
||||
|
||||
service._setup_web_search(force_web_search=True)
|
||||
|
||||
web_search_tool_name = service.conversation_agent.get_web_search_tool_name()
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
assert any(
|
||||
callable(instr) and web_search_tool_name in instr()
|
||||
for instr in service.conversation_agent._instructions
|
||||
)
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
assert "web_search_brave_with_document_backend" in response.output
|
||||
@@ -2,19 +2,25 @@
|
||||
|
||||
import logging
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from importlib import reload
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.urls import clear_url_caches, set_urlconf
|
||||
from django.utils import formats, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
import core.urls
|
||||
|
||||
import chat.views
|
||||
import conversations.urls
|
||||
from chat.agents.summarize import SummarizationAgent
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(name="today_promt_date")
|
||||
@pytest.fixture(name="today_prompt_date")
|
||||
def today_prompt_date_fixture():
|
||||
"""Fixture to mock date the system prompt when useless to test it."""
|
||||
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
|
||||
@@ -90,3 +96,35 @@ PIXEL_PNG = (
|
||||
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
|
||||
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="oidc_refresh_token_enabled")
|
||||
def fixture_oidc_refresh_token_enabled(settings):
|
||||
"""
|
||||
Fixture to enable OIDC refresh token storage during the test.
|
||||
|
||||
This is not a nice fixture, as it forces reloading views and URL configurations.
|
||||
Maybe there is a better way to do this, because even the conditional_refresh_oidc_token
|
||||
function is not nice, but I don't want it to be lazy either.
|
||||
"""
|
||||
__initial_value = bool(settings.OIDC_STORE_REFRESH_TOKEN)
|
||||
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||
|
||||
# force view reload
|
||||
reload(chat.views)
|
||||
reload(core.urls)
|
||||
reload(conversations.urls)
|
||||
clear_url_caches()
|
||||
set_urlconf(None)
|
||||
|
||||
yield settings
|
||||
|
||||
settings.OIDC_STORE_REFRESH_TOKEN = __initial_value
|
||||
|
||||
# force view reload
|
||||
reload(chat.views)
|
||||
reload(core.urls)
|
||||
reload(conversations.urls)
|
||||
clear_url_caches()
|
||||
set_urlconf(None)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for chat factories."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Unit tests for the ChatConversationFactory."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_conversation_factory():
|
||||
"""Test that the factory creates a valid conversation with default values."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
assert conversation.owner is not None
|
||||
assert conversation.title is None
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_conversation_factory_with_project():
|
||||
"""Test that the factory accepts a project."""
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(project=project, owner=project.owner)
|
||||
|
||||
assert conversation.project == project
|
||||
assert conversation.owner == project.owner
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Unit tests for the ChatProjectFactory."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_project_factory():
|
||||
"""Test that the factory creates a valid project with default values."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
assert project.title.startswith("title ")
|
||||
assert project.icon
|
||||
assert project.color
|
||||
assert project.owner is not None
|
||||
assert project.conversations.count() == 0
|
||||
|
||||
|
||||
def test_project_factory_number_of_conversations():
|
||||
"""Test that number_of_conversations creates attached conversations."""
|
||||
project = ChatProjectFactory(number_of_conversations=3)
|
||||
|
||||
assert project.conversations.count() == 3
|
||||
assert all(c.owner == project.owner for c in project.conversations.all())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for the ChatProjectSerializer."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat import serializers
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="request_context")
|
||||
def request_context_fixture():
|
||||
"""Return a serializer context with an authenticated request."""
|
||||
user = UserFactory()
|
||||
request = APIRequestFactory().post("/")
|
||||
request.user = user
|
||||
return {"request": request}
|
||||
|
||||
|
||||
def test_serialize_project():
|
||||
"""Test serializing a project returns expected fields."""
|
||||
project = ChatProjectFactory(
|
||||
title="My project",
|
||||
llm_instructions="My custom instructions",
|
||||
color=ChatProjectColor.COLOR_2,
|
||||
icon=ChatProjectIcon.JUSTICE,
|
||||
)
|
||||
serializer = serializers.ChatProjectSerializer(project)
|
||||
data = serializer.data
|
||||
|
||||
assert data["title"] == "My project"
|
||||
assert data["id"] == str(project.pk)
|
||||
assert data["icon"] == "justice"
|
||||
assert data["color"] == "color_2"
|
||||
assert data["llm_instructions"] == "My custom instructions"
|
||||
assert data["conversations"] == []
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
|
||||
def test_serialize_project_with_conversations():
|
||||
"""Test serializing a project includes nested conversations."""
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation"
|
||||
)
|
||||
serializer = serializers.ChatProjectSerializer(project)
|
||||
data = serializer.data
|
||||
|
||||
assert len(data["conversations"]) == 1
|
||||
assert data["conversations"][0] == {
|
||||
"id": str(conversation.pk),
|
||||
"title": "My conversation",
|
||||
}
|
||||
|
||||
|
||||
def test_deserialize_valid_project(request_context):
|
||||
"""Test deserializing valid project data."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": "star",
|
||||
"color": "color_2",
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
assert serializer.validated_data["title"] == "My Project"
|
||||
assert serializer.validated_data["icon"] == ChatProjectIcon.STAR
|
||||
assert serializer.validated_data["color"] == ChatProjectColor.COLOR_2
|
||||
|
||||
|
||||
def test_deserialize_missing_title(request_context):
|
||||
"""Test that title is required."""
|
||||
data = {
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "title" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_invalid_icon(request_context):
|
||||
"""Test that an invalid icon value is rejected."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": "invalid_icon",
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "icon" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_invalid_color(request_context):
|
||||
"""Test that an invalid color value is rejected."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": "invalid_color",
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "color" in serializer.errors
|
||||
|
||||
|
||||
def test_deserialize_title_max_length(request_context):
|
||||
"""Test that a title exceeding 100 characters is rejected."""
|
||||
data = {
|
||||
"title": "X" * 101,
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "title" in serializer.errors
|
||||
|
||||
|
||||
def test_conversations_field_is_read_only(request_context):
|
||||
"""Test that conversations cannot be set via input data."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"conversations": [{"id": "fake", "title": "fake"}],
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
assert "conversations" not in serializer.validated_data
|
||||
|
||||
|
||||
def test_owner_is_set_from_request(request_context):
|
||||
"""Test that the owner is automatically set from the request user."""
|
||||
data = {
|
||||
"title": "My Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
|
||||
|
||||
assert serializer.is_valid()
|
||||
project = serializer.save()
|
||||
assert project.owner == request_context["request"].user
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for local_media_url_processors."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +13,10 @@ from pydantic_ai import (
|
||||
UserPromptPart,
|
||||
)
|
||||
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
|
||||
from chat.agents.local_media_url_processors import (
|
||||
_get_file_url_for_llm,
|
||||
update_history_local_urls,
|
||||
update_local_urls,
|
||||
)
|
||||
@@ -121,3 +125,549 @@ def test_update_history_local_urls_no_user_prompt_parts():
|
||||
result = update_history_local_urls(conversation, messages)
|
||||
assert result == messages
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_uses_get_file_url_for_llm(mock_get_file_url):
|
||||
"""Test that update_local_urls uses _get_file_url_for_llm for mode-aware URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "mode-aware-url"
|
||||
mock_get_file_url.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_get_file_url_presigned_mode(mock_policy):
|
||||
"""Test URL generation in presigned_url mode."""
|
||||
|
||||
mock_policy.return_value = "presigned_s3_url"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.PRESIGNED_URL)
|
||||
|
||||
assert url == "presigned_s3_url"
|
||||
mock_policy.assert_called_once_with("test/key.pdf")
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_get_file_url_backend_temporary_url_mode(mock_temp_url):
|
||||
"""Test URL generation in backend_temporary_url mode."""
|
||||
|
||||
mock_temp_url.return_value = "/api/v1.0/file-stream/token123/"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_TEMPORARY_URL)
|
||||
|
||||
assert url == "/api/v1.0/file-stream/token123/"
|
||||
mock_temp_url.assert_called_once_with("test/key.pdf")
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_get_file_url_backend_base64_mode(mock_storage):
|
||||
"""Test URL generation in backend_base64 mode."""
|
||||
|
||||
# Mock file content
|
||||
file_content = b"PDF binary content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
|
||||
|
||||
# URL should be a data URL
|
||||
assert url.startswith("data:")
|
||||
assert "base64" in url
|
||||
mock_storage.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"chat.agents.local_media_url_processors.default_storage.open", side_effect=Exception("S3 error")
|
||||
)
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_get_file_url_backend_base64_fallback(mock_policy, _mock_storage):
|
||||
"""Test fallback to presigned URL when base64 encoding fails."""
|
||||
|
||||
mock_policy.return_value = "fallback_presigned_url"
|
||||
|
||||
url = _get_file_url_for_llm("test/key.pdf", FileToLLMMode.BACKEND_BASE64)
|
||||
|
||||
# Should fall back to presigned URL
|
||||
assert url == "fallback_presigned_url"
|
||||
mock_policy.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_multiple_images_with_modes(mock_get_file_url):
|
||||
"""Test handling multiple images with mode-aware URL generation."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Mock different URLs for different calls
|
||||
urls = ["url1", "url2", "url3"]
|
||||
mock_get_file_url.side_effect = urls
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "url1"
|
||||
assert result[1].url == "url2"
|
||||
assert result[2].url == "url3"
|
||||
assert mock_get_file_url.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_mixed_external_and_local_urls(mock_get_file_url):
|
||||
"""Test handling of mixed external and local URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key}"), # Local URL - will be processed
|
||||
ImageUrl(url="https://external.com/image.jpg"), # External URL - kept as is
|
||||
ImageUrl(url="http://another.com/image.png"), # External URL - kept as is
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "mode-aware-url"
|
||||
assert result[1].url == "https://external.com/image.jpg"
|
||||
assert result[2].url == "http://another.com/image.png"
|
||||
# Only one local URL was processed
|
||||
assert mock_get_file_url.call_count == 1
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_history_local_urls_with_mode_detection(mock_get_file_url):
|
||||
"""Test that update_history_local_urls uses mode detection for URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
user_prompt_content = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
messages = [
|
||||
ModelRequest(parts=[UserPromptPart(content=user_prompt_content)]),
|
||||
ModelResponse(parts=[TextPart(content="I see your image.")]),
|
||||
]
|
||||
|
||||
with patch("chat.agents.local_media_url_processors.update_local_urls") as mock_update:
|
||||
mock_update.return_value = iter([ImageUrl(url="mode-aware-url")])
|
||||
result = update_history_local_urls(conversation, messages)
|
||||
|
||||
assert len(result) == 2
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
def test_update_local_urls_preserves_other_url_types():
|
||||
"""Test that update_local_urls preserves other URL types unchanged."""
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
contents = [
|
||||
ImageUrl(url="data:image/png;base64,iVBORw0KG..."), # Already data URL
|
||||
ImageUrl(url="https://example.com/image.jpg"), # HTTPS
|
||||
ImageUrl(url="http://example.com/image.jpg"), # HTTP
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "data:image/png;base64,iVBORw0KG..."
|
||||
assert result[1].url == "https://example.com/image.jpg"
|
||||
assert result[2].url == "http://example.com/image.jpg"
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_stores_updated_urls_mapping(mock_get_file_url):
|
||||
"""Test that update_local_urls stores the mapping of new to old URLs."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "new-mode-aware-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
old_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=old_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert "new-mode-aware-url" in updated_urls
|
||||
assert updated_urls["new-mode-aware-url"] == old_url
|
||||
|
||||
|
||||
def test_update_local_urls_security_prevents_other_conversation_access():
|
||||
"""Test that security check prevents accessing other conversation's files."""
|
||||
conversation = ChatConversationFactory()
|
||||
other_conversation_key = "other-uuid/attachments/file.jpg"
|
||||
|
||||
# Try to access file from different conversation
|
||||
contents = [ImageUrl(url=f"/media-key/{other_conversation_key}")]
|
||||
|
||||
with patch("chat.agents.local_media_url_processors._get_file_url_for_llm") as mock_get:
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should not be processed (security check failed)
|
||||
assert result[0].url == f"/media-key/{other_conversation_key}"
|
||||
# _get_file_url_for_llm should NOT be called
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_get_file_url_is_called_with_correct_arguments(mock_get_file_url):
|
||||
"""Test that _get_file_url_for_llm is called with correct arguments."""
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "processed-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
list(update_local_urls(conversation, contents))
|
||||
|
||||
# Verify the function was called with the S3 key (without /media-key/ prefix)
|
||||
mock_get_file_url.assert_called_once()
|
||||
call_args = mock_get_file_url.call_args
|
||||
assert call_args[0][0] == key # First argument should be the S3 key
|
||||
|
||||
|
||||
# ==================== Tests for FILE_TO_LLM_MODE settings ====================
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_with_presigned_url_mode(mock_policy, settings):
|
||||
"""Test update_local_urls with PRESIGNED_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.return_value = "https://s3.example.com/presigned-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "https://s3.example.com/presigned-url"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_with_backend_temporary_url_mode(mock_temp_url, settings):
|
||||
"""Test update_local_urls with BACKEND_TEMPORARY_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_temp_url.return_value = "/api/v1.0/file-stream/temp-token-123/"
|
||||
|
||||
key = f"{conversation.pk}/test.pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "/api/v1.0/file-stream/temp-token-123/"
|
||||
mock_temp_url.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_with_backend_base64_mode(mock_storage, settings):
|
||||
"""Test update_local_urls with BACKEND_BASE64 mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
file_content = b"Mock image binary content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should be a data URL
|
||||
assert result[0].url.startswith("data:")
|
||||
assert "base64" in result[0].url
|
||||
mock_storage.assert_called_once()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_backend_base64_fallback_on_error(mock_storage, mock_policy, settings):
|
||||
"""Test that update_local_urls falls back to presigned URL when base64 fails."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
mock_storage.side_effect = Exception("S3 connection error")
|
||||
mock_policy.return_value = "https://s3.example.com/fallback-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should fall back to presigned URL
|
||||
assert result[0].url == "https://s3.example.com/fallback-url"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_multiple_files_presigned_mode(mock_policy, settings):
|
||||
"""Test update_local_urls with multiple files in PRESIGNED_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.side_effect = [
|
||||
"https://s3.example.com/image1-presigned",
|
||||
"https://s3.example.com/image2-presigned",
|
||||
"https://s3.example.com/document-presigned",
|
||||
]
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "https://s3.example.com/image1-presigned"
|
||||
assert result[1].url == "https://s3.example.com/image2-presigned"
|
||||
assert result[2].url == "https://s3.example.com/document-presigned"
|
||||
assert mock_policy.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_multiple_files_temporary_url_mode(mock_temp_url, settings):
|
||||
"""Test update_local_urls with multiple files in BACKEND_TEMPORARY_URL mode."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_temp_url.side_effect = [
|
||||
"/api/v1.0/file-stream/token1/",
|
||||
"/api/v1.0/file-stream/token2/",
|
||||
"/api/v1.0/file-stream/token3/",
|
||||
]
|
||||
|
||||
key1 = f"{conversation.pk}/image1.jpg"
|
||||
key2 = f"{conversation.pk}/image2.png"
|
||||
key3 = f"{conversation.pk}/document.pdf"
|
||||
|
||||
contents = [
|
||||
ImageUrl(url=f"/media-key/{key1}"),
|
||||
ImageUrl(url=f"/media-key/{key2}"),
|
||||
DocumentUrl(url=f"/media-key/{key3}"),
|
||||
]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0].url == "/api/v1.0/file-stream/token1/"
|
||||
assert result[1].url == "/api/v1.0/file-stream/token2/"
|
||||
assert result[2].url == "/api/v1.0/file-stream/token3/"
|
||||
assert mock_temp_url.call_count == 3
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_preserves_mapping(mock_policy, settings):
|
||||
"""Test that PRESIGNED_URL mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
presigned_url = "https://s3.example.com/presigned-123"
|
||||
mock_policy.return_value = presigned_url
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert presigned_url in updated_urls
|
||||
assert updated_urls[presigned_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_temporary_url_mode_preserves_mapping(mock_temp_url, settings):
|
||||
"""Test that BACKEND_TEMPORARY_URL mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
temp_url = "/api/v1.0/file-stream/temp-abc-def/"
|
||||
mock_temp_url.return_value = temp_url
|
||||
|
||||
key = f"{conversation.pk}/test.pdf"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [DocumentUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
assert temp_url in updated_urls
|
||||
assert updated_urls[temp_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_preserves_mapping(mock_storage, settings):
|
||||
"""Test that BACKEND_BASE64 mode correctly stores updated URLs mapping."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
file_content = b"test image content"
|
||||
mock_file = BytesIO(file_content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
original_url = f"/media-key/{key}"
|
||||
contents = [ImageUrl(url=original_url)]
|
||||
updated_urls = {}
|
||||
|
||||
result = list(update_local_urls(conversation, contents, updated_urls))
|
||||
|
||||
# Verify mapping was stored
|
||||
assert len(updated_urls) == 1
|
||||
# The data URL should be the key in the mapping
|
||||
data_url = result[0].url
|
||||
assert data_url in updated_urls
|
||||
assert updated_urls[data_url] == original_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_security_check(mock_policy, settings):
|
||||
"""Test that PRESIGNED_URL mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# generate_retrieve_policy should not be called
|
||||
mock_policy.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_temporary_url")
|
||||
def test_update_local_urls_temporary_mode_security_check(mock_temp_url, settings):
|
||||
"""Test that BACKEND_TEMPORARY_URL mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# generate_temporary_url should not be called
|
||||
mock_temp_url.assert_not_called()
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_security_check(mock_storage, settings):
|
||||
"""Test that BACKEND_BASE64 mode respects security checks."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
other_key = "other-conversation-id/test.jpg"
|
||||
contents = [ImageUrl(url=f"/media-key/{other_key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
# URL should remain unchanged
|
||||
assert result[0].url == f"/media-key/{other_key}"
|
||||
# Storage should not be opened
|
||||
mock_storage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_to_llm_mode",
|
||||
[
|
||||
FileToLLMMode.PRESIGNED_URL,
|
||||
FileToLLMMode.BACKEND_TEMPORARY_URL,
|
||||
FileToLLMMode.BACKEND_BASE64,
|
||||
],
|
||||
)
|
||||
@patch("chat.agents.local_media_url_processors._get_file_url_for_llm")
|
||||
def test_update_local_urls_all_modes_with_external_urls(
|
||||
mock_get_file_url, file_to_llm_mode, settings
|
||||
):
|
||||
"""Test that all modes preserve external URLs unchanged."""
|
||||
settings.FILE_TO_LLM_MODE = file_to_llm_mode
|
||||
conversation = ChatConversationFactory()
|
||||
mock_get_file_url.return_value = "processed-url"
|
||||
|
||||
key = f"{conversation.pk}/test.jpg"
|
||||
external_urls = [
|
||||
"https://example.com/image.jpg",
|
||||
"http://another.com/image.png",
|
||||
"data:image/png;base64,iVBORw0KG...",
|
||||
]
|
||||
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")] + [ImageUrl(url=url) for url in external_urls]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 4
|
||||
assert result[0].url == "processed-url"
|
||||
for i, external_url in enumerate(external_urls, start=1):
|
||||
assert result[i].url == external_url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.default_storage.open")
|
||||
def test_update_local_urls_base64_mode_with_different_file_types(mock_storage, settings):
|
||||
"""Test BACKEND_BASE64 mode with different file MIME types."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.BACKEND_BASE64
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Test with different file types
|
||||
test_cases = [
|
||||
("test.jpg", b"JPEG binary"),
|
||||
("test.png", b"PNG binary"),
|
||||
("test.pdf", b"PDF binary"),
|
||||
("test.txt", b"Text content"),
|
||||
]
|
||||
|
||||
for filename, content in test_cases:
|
||||
mock_file = BytesIO(content)
|
||||
mock_storage.return_value.__enter__.return_value = mock_file
|
||||
|
||||
key = f"{conversation.pk}/{filename}"
|
||||
contents = [ImageUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
# Should be a data URL
|
||||
assert result[0].url.startswith("data:")
|
||||
assert "base64" in result[0].url
|
||||
|
||||
|
||||
@patch("chat.agents.local_media_url_processors.generate_retrieve_policy")
|
||||
def test_update_local_urls_presigned_mode_with_special_characters_in_key(mock_policy, settings):
|
||||
"""Test PRESIGNED_URL mode handles keys with special characters."""
|
||||
settings.FILE_TO_LLM_MODE = FileToLLMMode.PRESIGNED_URL
|
||||
conversation = ChatConversationFactory()
|
||||
mock_policy.return_value = "https://s3.example.com/presigned"
|
||||
|
||||
# Key with special characters (should be handled properly by S3)
|
||||
key = f"{conversation.pk}/attachments/file (1).pdf"
|
||||
contents = [DocumentUrl(url=f"/media-key/{key}")]
|
||||
|
||||
result = list(update_local_urls(conversation, contents))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].url == "https://s3.example.com/presigned"
|
||||
mock_policy.assert_called_once_with(key)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""Tests for the Brave web search tool."""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
from typing import Sequence
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.sessions.backends.cache import SessionStore
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic_ai import ModelRetry, RunContext, RunUsage
|
||||
from pydantic_ai._run_context import RunContextAgentDepsT
|
||||
|
||||
from chat.tools.exceptions import ModelCannotRetry
|
||||
from chat.tools.web_search_brave import (
|
||||
@@ -38,9 +43,6 @@ def brave_settings(settings):
|
||||
settings.BRAVE_SEARCH_EXTRA_SNIPPETS = True
|
||||
settings.BRAVE_SUMMARIZATION_ENABLED = False
|
||||
settings.BRAVE_CACHE_TTL = 3600
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
|
||||
)
|
||||
settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = 5
|
||||
|
||||
|
||||
@@ -48,6 +50,13 @@ def brave_settings(settings):
|
||||
def fixture_mocked_context():
|
||||
"""Fixture for a mocked RunContext."""
|
||||
mock_ctx = Mock(spec=RunContext)
|
||||
mock_ctx.deps = Mock(spec=RunContextAgentDepsT)
|
||||
user = Mock(spec=AbstractBaseUser)
|
||||
user.sub = Mock(spec=Sequence)
|
||||
mock_ctx.deps.user = user
|
||||
session = SessionStore()
|
||||
session["oidc_access_token"] = "mocked-access-token"
|
||||
mock_ctx.deps.session = session
|
||||
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
|
||||
mock_ctx.max_retries = 2
|
||||
mock_ctx.retries = {}
|
||||
@@ -1011,7 +1020,12 @@ async def test_web_search_brave_with_document_backend_rag_search_params(mocked_c
|
||||
await web_search_brave_with_document_backend(mocked_context, "test query")
|
||||
|
||||
# Verify RAG search was called with correct parameters
|
||||
mock_document_store.asearch.assert_called_once_with("test query", results_count=5)
|
||||
mock_document_store.asearch.assert_called_once_with(
|
||||
query="test query",
|
||||
results_count=5,
|
||||
session=mocked_context.deps.session,
|
||||
user_sub=mocked_context.deps.user.sub,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.file_upload.enums import AttachmentStatus
|
||||
from core.file_upload.enums import AttachmentStatus, FileUploadMode
|
||||
|
||||
from chat import factories, models
|
||||
from chat.tests.conftest import PIXEL_PNG
|
||||
@@ -266,3 +266,40 @@ def test_upload_ended_fix_extension(api_client, name, content, _extension, conte
|
||||
assert attachment.content_type == content_type # updated
|
||||
assert attachment.file_name == name # updated
|
||||
assert attachment.size == len(content) # updated
|
||||
|
||||
|
||||
def test_attachment_create_presigned_url_mode_returns_policy(api_client, settings):
|
||||
"""Test that presigned_url mode returns policy field."""
|
||||
settings.FILE_UPLOAD_MODE = FileUploadMode.PRESIGNED_URL
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
|
||||
response = api_client.post(
|
||||
url,
|
||||
{"file_name": "test.pdf", "size": 1000, "content_type": "application/pdf"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["policy"] is not None
|
||||
assert "s3" in data["policy"].lower() or "minio" in data["policy"].lower()
|
||||
|
||||
|
||||
def test_attachment_create_backend_to_s3_mode_no_policy(api_client, settings):
|
||||
"""Test that backend_to_s3 mode does not return policy."""
|
||||
settings.FILE_UPLOAD_MODE = FileUploadMode.BACKEND_TO_S3
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/"
|
||||
response = api_client.post(
|
||||
url, {"file_name": "test.pdf", "content_type": "application/pdf"}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["policy"] is None
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for file upload mode `backend_to_s3`."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pytest
|
||||
|
||||
from chat import factories, models
|
||||
from chat.tests.conftest import PIXEL_PNG
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_backend_upload_anonymous_forbidden(api_client):
|
||||
"""Anonymous users should not be able to use backend upload."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
|
||||
response = api_client.post(url, {}, format="multipart")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_backend_upload_not_owner_forbidden(api_client):
|
||||
"""Users who don't own the conversation cannot upload via backend."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
user = factories.UserFactory()
|
||||
api_client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@mock.patch("chat.views.malware_detection.analyse_file")
|
||||
def test_backend_upload_success(mock_malware, api_client):
|
||||
"""Test successful backend file upload."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "id" in data
|
||||
assert "key" in data
|
||||
assert data["file_name"] == "test.png"
|
||||
assert data["size"] == len(PIXEL_PNG)
|
||||
assert data["content_type"] == "image/png"
|
||||
|
||||
# Verify attachment was created
|
||||
attachment = models.ChatConversationAttachment.objects.get(pk=data["id"])
|
||||
assert attachment.conversation == conversation
|
||||
assert attachment.uploaded_by == conversation.owner
|
||||
assert attachment.file_name == "test.png"
|
||||
assert attachment.size == len(PIXEL_PNG)
|
||||
|
||||
# Verify malware detection was called
|
||||
mock_malware.assert_called_once()
|
||||
|
||||
|
||||
def test_backend_upload_missing_file(api_client):
|
||||
"""Test that backend upload fails without file field."""
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
|
||||
response = api_client.post(
|
||||
url,
|
||||
{"file_name": "test.png"}, # No file field
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "file" in response.json()
|
||||
|
||||
|
||||
@mock.patch("chat.views.malware_detection.analyse_file")
|
||||
def test_backend_upload_creates_s3_file(_mock_malware, api_client):
|
||||
"""Test that backend upload creates file in S3."""
|
||||
|
||||
conversation = factories.ChatConversationFactory()
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk!s}/attachments/backend-upload/"
|
||||
file_obj = BytesIO(PIXEL_PNG)
|
||||
file_obj.name = "test.png"
|
||||
|
||||
response = api_client.post(url, {"file": file_obj, "file_name": "test.png"}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
key = data["key"]
|
||||
|
||||
# Verify file exists in S3
|
||||
assert default_storage.exists(key)
|
||||
|
||||
# Verify file content
|
||||
with default_storage.open(key, "rb") as f:
|
||||
content = f.read()
|
||||
assert content == PIXEL_PNG
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Common test fixtures for chat conversation endpoint tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
@@ -10,15 +11,9 @@ import respx
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
openai_stream = (
|
||||
def _create_openai_stream_data():
|
||||
"""Helper to create OpenAI stream data."""
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
@@ -59,12 +54,111 @@ def fixture_mock_openai_stream():
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_openai_route(with_delays: bool = False, delay_seconds: float = 1.0):
|
||||
"""Create a mock OpenAI stream route with optional delays."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
lines = openai_stream.splitlines(keepends=True)
|
||||
for i, line in enumerate(lines):
|
||||
yield line.encode()
|
||||
if with_delays and i == 1:
|
||||
# Delay after second line to trigger keepalive during streaming
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
return respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response (no delays).
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=False)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_slow")
|
||||
def fixture_mock_openai_stream_slow():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response with delays to trigger keepalives.
|
||||
|
||||
No @freeze_time decorator because asyncio.sleep() needs real time to work properly.
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=True, delay_seconds=1.0)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_with_title_generation")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream_with_title_generation():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
|
||||
This fixture handles two different types of API calls made during a single request:
|
||||
|
||||
1. **Conversation (streaming)**: The main chat uses `stream=True` to get real-time
|
||||
token-by-token responses. The API returns chunked data like:
|
||||
`data: {"choices": [{"delta": {"content": "Hello"}}]}`
|
||||
|
||||
2. **Title generation (non-streaming)**: After the conversation, the backend calls
|
||||
the API again with `stream=False` to generate a title. This returns a standard
|
||||
JSON response with the complete message.
|
||||
|
||||
The `handle_request` function inspects each incoming request's body to determine
|
||||
which type of response to return:
|
||||
- `{"stream": true, ...}` → SSE streaming response
|
||||
- `{"stream": false, ...}` → JSON response with generated title
|
||||
Each call gets a new generator instance (avoiding generator exhaustion)
|
||||
"""
|
||||
|
||||
def create_stream_response():
|
||||
"""Create a fresh streaming response for each call."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
yield line.encode()
|
||||
|
||||
return httpx.Response(200, stream=mock_stream())
|
||||
|
||||
def create_non_stream_response():
|
||||
"""Create a non-streaming response for title generation."""
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-title",
|
||||
"object": "chat.completion",
|
||||
"created": int(timezone.make_naive(timezone.now()).timestamp()),
|
||||
"model": "test-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "GENERATED TITLE",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 50, "completion_tokens": 5, "total_tokens": 55},
|
||||
},
|
||||
)
|
||||
|
||||
def handle_request(request):
|
||||
"""Route to streaming or non-streaming response based on request."""
|
||||
body = json.loads(request.content)
|
||||
if body.get("stream", False):
|
||||
return create_stream_response()
|
||||
return create_non_stream_response()
|
||||
|
||||
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
side_effect=handle_request
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -188,6 +190,7 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -196,15 +199,171 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.keepalive.get_current_time")
|
||||
def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
mock_time, api_client, mock_openai_stream
|
||||
):
|
||||
"""Test streaming response contains keepalive messages"""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
mock_time.side_effect = [float(i * 60) for i in range(10)]
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream.called
|
||||
|
||||
chat_conversation.refresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -309,6 +468,7 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -317,15 +477,29 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -495,6 +669,7 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -515,15 +690,29 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "I see a cat in the picture.", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "I see a cat in the picture.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -661,6 +850,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -669,23 +859,31 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -705,6 +903,7 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
|
||||
@@ -716,17 +915,29 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The current weather in Paris is nice",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -863,6 +1074,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Weather in Paris?"],
|
||||
@@ -871,23 +1083,31 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -907,6 +1127,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in french."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": "Unknown tool name: 'get_current_weather'. No tools available.",
|
||||
@@ -917,17 +1138,29 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "I cannot give you an answer to that.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "I cannot give you an answer to that.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1173,6 +1406,7 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Why the sky is blue?"],
|
||||
@@ -1181,10 +1415,12 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1192,12 +1428,18 @@ def test_post_conversation_data_protocol_no_stream(
|
||||
"Rayleigh scattering.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-92c413bb5a45426299335d0621324654",
|
||||
"timestamp": "2025-09-22T14:13:49Z",
|
||||
"provider_url": "https://www.external-ai-service.com",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
@@ -1313,6 +1555,7 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
@@ -1321,15 +1564,29 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1344,3 +1601,261 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z", tick=True)
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_conversation_async_triggers_keepalive(
|
||||
api_client, mock_openai_stream_slow, monkeypatch, caplog, settings
|
||||
):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
|
||||
|
||||
settings.KEEPALIVE_INTERVAL = 1 # s
|
||||
|
||||
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
await api_client.aforce_login(chat_conversation.owner)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(level=logging.DEBUG, logger="chat.views")
|
||||
|
||||
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
assert "Using ASYNC streaming for chat conversation" in caplog.text
|
||||
|
||||
# Wait for the streaming content to be fully received => async iterator -> list
|
||||
# This fails it the streaming is not an async generator
|
||||
response_content = b"".join([content async for content in response.streaming_content]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream_slow.called
|
||||
|
||||
await chat_conversation.arefresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[0].createdAt, # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[1].createdAt, # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
|
||||
# using ANY because time is not frozen in this api mock
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": ANY,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "Hello there",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": ANY,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_post_conversation_oidc_refresh_enabled_unrefreshed( # pylint: disable=unused-argument
|
||||
api_client, oidc_refresh_token_enabled
|
||||
):
|
||||
"""Test posting messages to a conversation without fresh access token should be forbidden."""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(
|
||||
chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_oidc_refresh_enabled( # pylint: disable=unused-argument
|
||||
api_client, mock_openai_stream, oidc_refresh_token_enabled
|
||||
):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(
|
||||
chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||
)
|
||||
session = api_client.session
|
||||
|
||||
session["oidc_id_token_expiration"] = time.time() + 3600 # valid for 1 hour
|
||||
session["oidc_token_expiration"] = session["oidc_id_token_expiration"] # ...
|
||||
session.save()
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
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 == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
assert len(chat_conversation.pydantic_messages) == 2
|
||||
|
||||
+109
-29
@@ -8,6 +8,7 @@ import logging
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sessions.backends.cache import SessionStore
|
||||
from django.utils import formats, timezone
|
||||
|
||||
import httpx
|
||||
@@ -41,28 +42,49 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
@pytest.fixture(
|
||||
autouse=True,
|
||||
params=[
|
||||
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
|
||||
],
|
||||
)
|
||||
def ai_settings(request, settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
# Enable Albert API for document search
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = (
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend"
|
||||
)
|
||||
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
|
||||
settings.ALBERT_API_KEY = "albert-api-key"
|
||||
# enable on rag document search tool
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
|
||||
settings.RAG_WEB_SEARCH_PROMPT_UPDATE = (
|
||||
"Based on the following document contents:\n\n{search_results}\n\n"
|
||||
"Please answer the user's question: {user_prompt}"
|
||||
)
|
||||
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
# Albert API settings
|
||||
settings.ALBERT_API_URL = "https://albert.api.etalab.gouv.fr"
|
||||
settings.ALBERT_API_KEY = "albert-api-key"
|
||||
|
||||
# Find API settings
|
||||
settings.FIND_API_URL = "https://find.api.example.com"
|
||||
settings.FIND_API_KEY = "find-api-key"
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_refresh_access_token():
|
||||
"""Mock refresh_access_token to bypass token refresh in tests."""
|
||||
with mock.patch("utils.oidc.refresh_access_token") as mocked_refresh_access_token:
|
||||
session = SessionStore()
|
||||
session["oidc_access_token"] = "mocked-access-token"
|
||||
mocked_refresh_access_token.return_value = session
|
||||
yield mocked_refresh_access_token
|
||||
|
||||
|
||||
@pytest.fixture(name="sample_pdf_content")
|
||||
def fixture_sample_pdf_content():
|
||||
"""Create a dummy PDF content as BytesIO."""
|
||||
@@ -81,10 +103,18 @@ def fixture_sample_pdf_content():
|
||||
return BytesIO(pdf_data)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_albert_api")
|
||||
def fixture_mock_albert_api():
|
||||
@pytest.fixture(name="mock_document_api")
|
||||
def fixture_mock_document_api():
|
||||
"""Fixture to mock the Albert API endpoints."""
|
||||
# Mock collection creation
|
||||
|
||||
document_name = "sample.pdf"
|
||||
document_content = "This is the content of the PDF."
|
||||
prompt_tokens = 10
|
||||
completion_tokens = 20
|
||||
search_method = "semantic"
|
||||
search_score = 0.9
|
||||
|
||||
responses.post(
|
||||
"https://albert.api.etalab.gouv.fr/v1/collections",
|
||||
json={"id": "123", "name": "test-collection"},
|
||||
@@ -101,7 +131,7 @@ def fixture_mock_albert_api():
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -119,20 +149,42 @@ def fixture_mock_albert_api():
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"method": "semantic",
|
||||
"method": search_method,
|
||||
"chunk": {
|
||||
"id": 123,
|
||||
"content": "This is the content of the PDF.",
|
||||
"metadata": {"document_name": "sample.pdf"},
|
||||
"content": document_content,
|
||||
"metadata": {"document_name": document_name},
|
||||
},
|
||||
"score": 0.9,
|
||||
"score": search_score,
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
||||
"usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Mock document indexing (Find API)
|
||||
responses.post(
|
||||
"https://find.api.example.com/api/v1.0/documents/index/",
|
||||
json={"id": "456", "status": "indexed"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Mock document search (Find API)
|
||||
responses.post(
|
||||
"https://find.api.example.com/api/v1.0/documents/search/",
|
||||
json=[
|
||||
{
|
||||
"_source": {
|
||||
"title.fr": document_name,
|
||||
"content.fr": document_content,
|
||||
},
|
||||
"_score": search_score,
|
||||
}
|
||||
],
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_summarization_agent")
|
||||
def fixture_mock_summarization_agent():
|
||||
@@ -219,9 +271,9 @@ def fixture_mock_openai_stream():
|
||||
def test_post_conversation_with_document_upload(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -357,7 +409,7 @@ def test_post_conversation_with_document_upload(
|
||||
|
||||
assert chat_conversation.pydantic_messages[0] == {
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -372,6 +424,7 @@ def test_post_conversation_with_document_upload(
|
||||
"Do not request re-upload of documents; consider them already "
|
||||
"available via the internal store.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What does the document say?"],
|
||||
@@ -380,10 +433,12 @@ def test_post_conversation_with_document_upload(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[1] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -392,11 +447,14 @@ def test_post_conversation_with_document_upload(
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "document_search_rag",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -413,7 +471,7 @@ def test_post_conversation_with_document_upload(
|
||||
assert chat_conversation.pydantic_messages[2] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -429,6 +487,7 @@ def test_post_conversation_with_document_upload(
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -446,21 +505,26 @@ def test_post_conversation_with_document_upload(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[3] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "From the document, I can see that it says 'Hello PDF'.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -548,9 +612,9 @@ def test_post_conversation_with_document_upload_feature_disabled(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
|
||||
api_client,
|
||||
mock_albert_api, # pylint: disable=unused-argument
|
||||
mock_document_api, # pylint: disable=unused-argument
|
||||
sample_pdf_content,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
mock_summarization_agent, # pylint: disable=unused-argument
|
||||
):
|
||||
@@ -687,7 +751,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
assert chat_conversation.pydantic_messages[0] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -703,6 +767,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Make a summary of this document."],
|
||||
@@ -711,10 +776,12 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[1] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -723,11 +790,14 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"part_kind": "tool-call",
|
||||
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
|
||||
"tool_name": "summarize",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -744,7 +814,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
assert chat_conversation.pydantic_messages[2] == {
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -760,6 +830,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
"available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": "The document discusses various topics.",
|
||||
@@ -771,17 +842,26 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timezone_now,
|
||||
}
|
||||
assert chat_conversation.pydantic_messages[3] == {
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{"content": "The document discusses various topics.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The document discusses various topics.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timezone_now,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
+90
-42
@@ -37,11 +37,20 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
@pytest.fixture(
|
||||
autouse=True,
|
||||
params=[
|
||||
"chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend",
|
||||
"chat.agent_rag.document_rag_backends.albert_rag_backend.AlbertRagBackend",
|
||||
],
|
||||
)
|
||||
def ai_settings(request, settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
settings.RAG_DOCUMENT_SEARCH_BACKEND = request.param
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.FIND_API_URL = "https://app-find"
|
||||
settings.FIND_API_KEY = "find-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
return settings
|
||||
@@ -64,12 +73,13 @@ 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,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
Test POST to /api/v1/chats/{pk}/conversation/ with a document URL.
|
||||
"""
|
||||
|
||||
responses.post(
|
||||
"https://albert.api.etalab.gouv.fr/v1/collections",
|
||||
json={"id": 123, "object": "collection"},
|
||||
@@ -85,6 +95,10 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
json={"id": "document_id", "object": "document"},
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
"https://app-find/api/v1.0/documents/index/",
|
||||
status=200,
|
||||
)
|
||||
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
@@ -120,30 +134,30 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
)
|
||||
|
||||
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
|
||||
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
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
UserPromptPart(
|
||||
content=[
|
||||
"What is in this document?",
|
||||
DocumentUrl(
|
||||
url=presigned_url, # presigned URL for this conversation
|
||||
media_type="application/pdf",
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
|
||||
],
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_prompt_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,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is a document about a single pixel."
|
||||
@@ -186,9 +200,7 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
experimental_attachments=[
|
||||
Attachment(name="sample.pdf", contentType="application/pdf", url=document_url)
|
||||
],
|
||||
experimental_attachments=None, # We should fix this, but for now document appears in source
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
@@ -219,42 +231,57 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.",
|
||||
f"{today_prompt_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"
|
||||
"\n"
|
||||
"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.\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",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
"What is in this document?",
|
||||
{
|
||||
"force_download": False,
|
||||
"identifier": "sample.pdf",
|
||||
"kind": "document-url",
|
||||
"media_type": "application/pdf",
|
||||
"url": document_url,
|
||||
"vendor_metadata": None,
|
||||
},
|
||||
],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document about a single pixel.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timestamp,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -526,6 +553,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
assert presigned_url.find("X-Amz-Signature=") != -1
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
@@ -539,7 +567,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
@@ -551,7 +579,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
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(),
|
||||
timestamp=timestamp_now,
|
||||
run_id=messages[1].run_id,
|
||||
),
|
||||
ModelRequest(
|
||||
@@ -560,9 +588,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
content=[
|
||||
"Give more details about this document.",
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
)
|
||||
],
|
||||
timestamp=timestamp_now,
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\n"
|
||||
"Answer in english.",
|
||||
@@ -719,6 +748,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\n"
|
||||
"Answer in english.",
|
||||
"metadata": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
@@ -728,21 +758,26 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document of square, very small and nice.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -772,7 +807,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
|
||||
def test_post_conversation_with_local_not_pdf_document_url(
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
file_name,
|
||||
content_type,
|
||||
@@ -795,6 +830,10 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
json={"id": "document_id", "object": "document"},
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
"https://app-find/api/v1.0/documents/index/",
|
||||
status=200,
|
||||
)
|
||||
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
api_client.force_authenticate(user=chat_conversation.owner)
|
||||
@@ -830,6 +869,8 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
)
|
||||
|
||||
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -838,12 +879,13 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
"What is in this document?",
|
||||
# No presigned URL for non-PDF documents (not supporter by LLM)
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
timestamp=timestamp_now,
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -934,7 +976,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
f"{today_prompt_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, "
|
||||
@@ -951,6 +993,7 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
"consider them already available via the internal store."
|
||||
),
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -961,21 +1004,26 @@ def test_post_conversation_with_local_not_pdf_document_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is a document about you.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": timestamp,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
+530
-90
@@ -2,6 +2,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -11,6 +12,7 @@ from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
from chat.ai_sdk_types import (
|
||||
Attachment,
|
||||
TextUIPart,
|
||||
@@ -27,6 +29,10 @@ from chat.tests.utils import replace_uuids_with_placeholder
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
PYAI_CURRENT = "current"
|
||||
PYAI_V1_17 = "v1.17"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
@@ -35,20 +41,13 @@ def ai_settings(settings):
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = None # disable auto title generation
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture(name="history_conversation")
|
||||
def history_conversation_fixture():
|
||||
"""Create a conversation with existing message history."""
|
||||
# Create a timestamp for the first message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
|
||||
# Create a conversation with pre-existing messages
|
||||
conversation = ChatConversationFactory()
|
||||
|
||||
# Add previous user and assistant messages
|
||||
conversation.messages = [
|
||||
def build__history_conversation_ui_messages(history_timestamp):
|
||||
"""Build ui messages list for fixtures."""
|
||||
return [
|
||||
UIMessage(
|
||||
id="prev-user-msg-1",
|
||||
createdAt=history_timestamp,
|
||||
@@ -117,94 +116,205 @@ def history_conversation_fixture():
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="history_conversation")
|
||||
def history_conversation_fixture(request):
|
||||
"""Create a conversation with existing message history according to pydantic ai version."""
|
||||
|
||||
# Create a timestamp for the first message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
|
||||
# Create a conversation with pre-existing messages
|
||||
conversation = ChatConversationFactory()
|
||||
pyai_version = getattr(request, "param", PYAI_CURRENT)
|
||||
# Add previous user and assistant messages
|
||||
if pyai_version == PYAI_V1_17:
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
]
|
||||
else:
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "some model",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"provider_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"finish_reason": "stop",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "test-model",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"provider_details": {
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
"provider_name": "test-model",
|
||||
"provider_response_id": "xyz",
|
||||
},
|
||||
]
|
||||
|
||||
# Set up the OpenAI message format as well
|
||||
conversation.pydantic_messages = [
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": "You are a helpful test assistant :)",
|
||||
"dynamic_ref": None,
|
||||
"part_kind": "system-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
{
|
||||
"content": ["How does machine learning work?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:30:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Machine learning is a branch of artificial intelligence that "
|
||||
"focuses on building systems that learn from data."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:31:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 10,
|
||||
"requests": 1,
|
||||
"response_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
{
|
||||
"instructions": None,
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["What are neural networks?"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-06-15T10:32:00.000000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"content": (
|
||||
"Neural networks are computing systems inspired by the "
|
||||
"biological neural networks in animal brains."
|
||||
),
|
||||
"part_kind": "text",
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-06-15T10:33:00.000000Z",
|
||||
"usage": {
|
||||
"details": None,
|
||||
"request_tokens": 5,
|
||||
"requests": 1,
|
||||
"response_tokens": 15,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
"vendor_details": None,
|
||||
"vendor_id": None,
|
||||
},
|
||||
]
|
||||
conversation.messages = build__history_conversation_ui_messages(history_timestamp)
|
||||
|
||||
conversation.save()
|
||||
return conversation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_conversation", [PYAI_CURRENT, PYAI_V1_17], indirect=True)
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_data_protocol_with_history(
|
||||
api_client, mock_openai_stream, history_conversation
|
||||
):
|
||||
"""Test posting messages to a conversation with history using the 'data' protocol."""
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
@@ -1025,6 +1135,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1066,6 +1177,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1099,6 +1211,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1145,6 +1258,7 @@ def history_conversation_with_tool_fixture():
|
||||
},
|
||||
{
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
@@ -1381,6 +1495,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in dutch.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["How about Paris weather?"],
|
||||
@@ -1389,24 +1504,32 @@ def test_post_conversation_with_existing_tool_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
}
|
||||
|
||||
assert history_conversation_with_tool.pydantic_messages[9] == {
|
||||
"finish_reason": "tool_call",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{
|
||||
"args": '{"location":"Paris", "unit":"celsius"}',
|
||||
"id": None,
|
||||
"part_kind": "tool-call",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47",
|
||||
"tool_name": "get_current_weather",
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "tool_calls"},
|
||||
"provider_details": {
|
||||
"finish_reason": "tool_calls",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-tool-call",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1426,6 +1549,7 @@ def test_post_conversation_with_existing_tool_history(
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in dutch.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": {"location": "Paris", "temperature": 22, "unit": "celsius"},
|
||||
@@ -1437,18 +1561,30 @@ def test_post_conversation_with_existing_tool_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
}
|
||||
|
||||
assert history_conversation_with_tool.pydantic_messages[11] == {
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "test-model",
|
||||
"parts": [
|
||||
{"content": "The current weather in Paris is nice", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "The current weather in Paris is nice",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_details": {
|
||||
"finish_reason": "stop",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-final",
|
||||
"provider_url": "https://www.external-ai-service.com/",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -1573,3 +1709,307 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.clients.pydantic_ai.TitleGenerationAgent", wraps=TitleGenerationAgent)
|
||||
def test_post_conversation_triggers_automatic_title_generation_after_first_message(
|
||||
mock_title_agent, api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that posting the first user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
|
||||
The conversation is a new one. Posting the first message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
conversation = ChatConversationFactory()
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
conversation.title = "initial title"
|
||||
conversation.save()
|
||||
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
# Verify TitleGenerationAgent was called
|
||||
mock_title_agent.assert_called_once()
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_triggers_automatic_title_generation_at_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_regenerate_title_when_user_set(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that title is NOT regenerated if the user has manually set a title.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Simulate user having set a custom title
|
||||
history_conversation.title = "My Custom Title"
|
||||
history_conversation.title_set_by_user_at = timezone.now()
|
||||
history_conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream since title wasn't generated
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT changed
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "My Custom Title"
|
||||
assert history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_before_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that title is NOT generated before reaching the message threshold.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Create a conversation with only 1 user message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
conversation = ChatConversationFactory(title="initial title")
|
||||
|
||||
conversation.messages = [
|
||||
UIMessage(
|
||||
id="prev-user-msg-1",
|
||||
createdAt=history_timestamp,
|
||||
content="Hello!",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello!")],
|
||||
),
|
||||
UIMessage(
|
||||
id="prev-assistant-msg-1",
|
||||
createdAt=history_timestamp.replace(minute=31),
|
||||
content="Hi there! How can I help you?",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hi there! How can I help you?")],
|
||||
),
|
||||
]
|
||||
conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "second-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "What's machine learning?", "type": "text"}],
|
||||
"content": "What's machine learning?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream (only 2 user messages)
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was not updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_after_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message does not trigger automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should not trigger title generation.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is not in the stream
|
||||
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT updated (past threshold)
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
# title not updated
|
||||
assert history_conversation.title == "initial title"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
+34
-11
@@ -92,6 +92,7 @@ def test_post_conversation_with_local_image_url(
|
||||
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=[
|
||||
@@ -110,6 +111,7 @@ def test_post_conversation_with_local_image_url(
|
||||
instructions="You are a helpful test assistant :)\n\nToday is "
|
||||
f"{formatted_date}.\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -181,6 +183,7 @@ def test_post_conversation_with_local_image_url(
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
@@ -199,17 +202,26 @@ def test_post_conversation_with_local_image_url(
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{"content": "This is an image of a single pixel.", "id": None, "part_kind": "text"}
|
||||
{
|
||||
"content": "This is an image of a single pixel.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
@@ -229,7 +241,7 @@ def test_post_conversation_with_local_image_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_local_image_wrong_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -275,7 +287,8 @@ 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}"
|
||||
timestamp=timezone.now(),
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
@@ -314,7 +327,7 @@ def test_post_conversation_with_local_image_wrong_url(
|
||||
@freeze_time()
|
||||
def test_post_conversation_with_remote_image_url(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -361,8 +374,9 @@ def test_post_conversation_with_remote_image_url(
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
]
|
||||
yield "This is an image of a single pixel."
|
||||
@@ -432,7 +446,7 @@ def test_post_conversation_with_remote_image_url(
|
||||
@freeze_time("2025-10-18T20:48:20.286204Z")
|
||||
def test_post_conversation_with_local_image_url_in_history(
|
||||
api_client,
|
||||
today_promt_date,
|
||||
today_prompt_date,
|
||||
mock_ai_agent_service,
|
||||
):
|
||||
"""
|
||||
@@ -475,7 +489,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
],
|
||||
pydantic_messages=[
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -547,6 +561,8 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
|
||||
timestamp_now = timezone.now()
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
@@ -559,11 +575,11 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
identifier="sample.png",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
],
|
||||
instructions="You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\nAnswer in english.",
|
||||
f"{today_prompt_date}\n\nAnswer in english.",
|
||||
),
|
||||
ModelResponse(
|
||||
parts=[TextPart(content="This is an image of a single pixel.")],
|
||||
@@ -577,12 +593,13 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
content=[
|
||||
"Give more details about this image.",
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
timestamp=timestamp_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.",
|
||||
timestamp=timestamp_now,
|
||||
),
|
||||
]
|
||||
yield "This is an image of square, very small and nice."
|
||||
@@ -681,7 +698,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
@@ -728,6 +745,7 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
|
||||
"\n\nAnswer in english.",
|
||||
"kind": "request",
|
||||
"metadata": None,
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Give more details about this image."],
|
||||
@@ -736,21 +754,26 @@ def test_post_conversation_with_local_image_url_in_history(
|
||||
}
|
||||
],
|
||||
"run_id": _run_id,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
},
|
||||
{
|
||||
"finish_reason": None,
|
||||
"kind": "response",
|
||||
"metadata": None,
|
||||
"model_name": "function::agent_model",
|
||||
"parts": [
|
||||
{
|
||||
"content": "This is an image of square, very small and nice.",
|
||||
"id": None,
|
||||
"part_kind": "text",
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
}
|
||||
],
|
||||
"provider_details": None,
|
||||
"provider_name": None,
|
||||
"provider_response_id": None,
|
||||
"provider_url": None,
|
||||
"timestamp": "2025-10-18T20:48:20.286204Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for conversation with project LLM instructions."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.tests.utils import replace_uuids_with_placeholder
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI service URLs for testing."""
|
||||
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
|
||||
settings.AI_API_KEY = "test-api-key"
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_includes_project_llm_instructions(api_client, mock_openai_stream):
|
||||
"""Test that project LLM instructions are sent to the LLM as part of the system prompt."""
|
||||
project = ChatProjectFactory(llm_instructions="Always reply in bullet points.")
|
||||
conversation = ChatConversationFactory(
|
||||
owner=project.owner, project=project, owner__language="en-us"
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
last_request_payload = json.loads(respx.calls.last.request.content)
|
||||
system_message = last_request_payload["messages"][0]
|
||||
assert system_message["role"] == "system"
|
||||
assert "Always reply in bullet points." in system_message["content"]
|
||||
assert mock_openai_stream.called
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_without_project_has_no_project_instructions(
|
||||
api_client, mock_openai_stream
|
||||
):
|
||||
"""Test that conversations without a project do not include project instructions."""
|
||||
conversation = ChatConversationFactory(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
last_request_payload = json.loads(respx.calls.last.request.content)
|
||||
system_message = last_request_payload["messages"][0]
|
||||
assert system_message["role"] == "system"
|
||||
assert system_message["content"] == (
|
||||
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
|
||||
)
|
||||
assert mock_openai_stream.called
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -28,6 +30,7 @@ def test_create_conversation(api_client):
|
||||
conversation = ChatConversation.objects.get(id=response.data["id"])
|
||||
assert conversation.owner == user
|
||||
assert conversation.title == "New Conversation"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_create_conversation_other_owner(api_client):
|
||||
@@ -51,6 +54,60 @@ def test_create_conversation_other_owner(api_client):
|
||||
assert conversation.title == "New Conversation"
|
||||
|
||||
|
||||
def test_create_conversation_with_project(api_client):
|
||||
"""Test creating a conversation attached to a project."""
|
||||
project = ChatProjectFactory()
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {
|
||||
"title": "New Conversation",
|
||||
"project": str(project.pk),
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert str(response.data["project"]) == str(project.pk)
|
||||
|
||||
conversation = ChatConversation.objects.get(id=response.data["id"])
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_create_conversation_with_other_user_project_fails(api_client):
|
||||
"""Test that creating a conversation with another user's project is rejected."""
|
||||
user = UserFactory()
|
||||
other_project = ChatProjectFactory() # owned by another user
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {
|
||||
"title": "New Conversation",
|
||||
"project": str(other_project.pk),
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
assert response.data == {
|
||||
"project": [
|
||||
ErrorDetail(
|
||||
string="The project must belong to the current user.",
|
||||
code="invalid",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_create_conversation_without_project(api_client):
|
||||
"""Test creating a conversation without a project."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/chats/"
|
||||
data = {"title": "New Conversation"}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["project"] is None
|
||||
|
||||
|
||||
def test_create_conversation_anonymous(api_client):
|
||||
"""Test creating a conversation as an anonymous user returns a 401 error."""
|
||||
url = "/api/v1.0/chats/"
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -79,3 +79,145 @@ def test_ordering_conversations(api_client):
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"][0]["id"] == str(conv1.id)
|
||||
assert response.data["results"][1]["id"] == str(conv2.id)
|
||||
|
||||
|
||||
def test_list_conversations_no_project_filter_returns_all(api_client):
|
||||
"""Test that without project filter, all conversations are returned."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
conv_no_project = ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 2
|
||||
result_ids = {r["id"] for r in response.data["results"]}
|
||||
assert result_ids == {str(conv_in_project.pk), str(conv_no_project.pk)}
|
||||
|
||||
|
||||
def test_filter_conversations_by_project(api_client):
|
||||
"""Test filtering conversations by a specific project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/chats/?project={project.pk}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_invalid_uuid(api_client):
|
||||
"""Test that an invalid UUID for project filter returns empty results."""
|
||||
user = UserFactory()
|
||||
ChatConversationFactory(owner=user, title="Some chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=notauuid")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_none(api_client):
|
||||
"""Test filtering conversations not linked to any project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
conv_no_project = ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=none")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_no_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_project_any(api_client):
|
||||
"""Test filtering conversations linked to any project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
|
||||
ChatConversationFactory(owner=user, title="No project")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?project=any")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
|
||||
|
||||
|
||||
def test_filter_conversations_by_title_and_project(api_client):
|
||||
"""Test filtering conversations by both title and project."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
conv_match = ChatConversationFactory(owner=user, project=project, title="Design review")
|
||||
ChatConversationFactory(owner=user, project=project, title="Budget plan")
|
||||
ChatConversationFactory(owner=user, title="Design ideas")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/chats/?title=Design&project={project.pk}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["id"] == str(conv_match.pk)
|
||||
|
||||
|
||||
def test_search_by_title_returns_nested_project_info(api_client):
|
||||
"""Test that searching by title returns nested project info (id, title, icon)."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user, title="My Project", icon="folder")
|
||||
conv = ChatConversationFactory(owner=user, project=project, title="Hello world")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?title=Hello")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
assert result["id"] == str(conv.pk)
|
||||
assert result["project"] == {
|
||||
"id": str(project.pk),
|
||||
"title": "My Project",
|
||||
"icon": "folder",
|
||||
}
|
||||
assert "messages" not in result
|
||||
|
||||
|
||||
def test_search_by_title_returns_null_project_when_none(api_client):
|
||||
"""Test that searching by title for a conversation without a project returns null."""
|
||||
user = UserFactory()
|
||||
conv = ChatConversationFactory(owner=user, title="Standalone chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/?title=Standalone")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
assert result["id"] == str(conv.pk)
|
||||
assert result["project"] is None
|
||||
|
||||
|
||||
def test_list_without_title_filter_does_not_nest_project(api_client):
|
||||
"""Test that listing without title filter returns project as a UUID, not nested."""
|
||||
user = UserFactory()
|
||||
project = ChatProjectFactory(owner=user)
|
||||
ChatConversationFactory(owner=user, project=project, title="Some chat")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/chats/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
result = response.data["results"][0]
|
||||
# project should be a flat UUID, not a nested dict
|
||||
assert not isinstance(result["project"], dict)
|
||||
assert str(result["project"]) == str(project.pk)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit tests for partially updating chat conversations in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_partial_update_conversation_title(api_client):
|
||||
"""Test partially updating a chat conversation title as the owner."""
|
||||
chat_conversation = ChatConversationFactory(title="Original Title")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated Title"}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Updated Title"
|
||||
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Updated Title"
|
||||
assert conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_partial_update_conversation_anonymous(api_client):
|
||||
"""Test partially updating a conversation as an anonymous user returns a 401 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated Title"}
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_partial_update_conversation_project_fails(api_client):
|
||||
"""Test that partially updating a conversation's project is rejected."""
|
||||
conversation = ChatConversationFactory()
|
||||
project = ChatProjectFactory(owner=conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"project": str(project.pk)}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_partial_update_conversation_remove_project_fails(api_client):
|
||||
"""Test that trying to remove a conversation from a project fails."""
|
||||
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"project": None}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_partial_update_other_user_conversation_fails(api_client):
|
||||
"""Test that partially updating another user's conversation returns a 404 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
data = {"title": "Updated By Other User"}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
from chat.models import ChatConversation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -26,6 +27,34 @@ def test_update_conversation(api_client):
|
||||
# Verify in database
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Updated Title"
|
||||
assert conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_limit_title_length(api_client):
|
||||
"""Test that updating a conversation with a title exceeding 100 characters fails validation."""
|
||||
chat_conversation = ChatConversationFactory(title="Initial title")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
# Create a 101-character title to exceed the 100-character maximum limit
|
||||
new_title = "X" * 101
|
||||
data = {"title": new_title}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
assert response.data == {
|
||||
"title": [
|
||||
ErrorDetail(
|
||||
string="Ensure this field has no more than 100 characters.", code="max_length"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
# Verify in database (title should remain unchanged)
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_anonymous(api_client):
|
||||
@@ -39,6 +68,42 @@ def test_update_conversation_anonymous(api_client):
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_update_conversation_project_fails(api_client):
|
||||
"""Test that updating a conversation's project is rejected."""
|
||||
conversation = ChatConversationFactory()
|
||||
project = ChatProjectFactory(owner=conversation.owner)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
data = {"title": "Updated Title", "project": str(project.pk)}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project is None
|
||||
|
||||
|
||||
def test_update_conversation_remove_project_fails(api_client):
|
||||
"""Test that trying to remove a conversation from a project fails."""
|
||||
|
||||
project = ChatProjectFactory()
|
||||
conversation = ChatConversationFactory(owner=project.owner, project=project)
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/"
|
||||
|
||||
data = {"title": "Updated Title", "project": None}
|
||||
api_client.force_login(conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "project" in response.data
|
||||
|
||||
conversation.refresh_from_db()
|
||||
assert conversation.project == project
|
||||
|
||||
|
||||
def test_update_other_user_conversation_fails(api_client):
|
||||
"""Test that updating another user's conversation returns a 404 error."""
|
||||
chat_conversation = ChatConversationFactory()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for creating projects."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.models import ChatProject, ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_project(api_client):
|
||||
"""Test creating a new project as an authenticated user."""
|
||||
user = UserFactory(sub="testuser", email="test@example.com")
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "New Project"
|
||||
|
||||
# Verify in database
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.owner == user
|
||||
assert project.title == "New Project"
|
||||
assert project.icon == ChatProjectIcon.FOLDER
|
||||
assert project.color == ChatProjectColor.COLOR_1
|
||||
|
||||
|
||||
def test_create_project_other_owner(api_client):
|
||||
"""Test that a user cannot assign another user as the owner of a project."""
|
||||
other_user = UserFactory()
|
||||
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"owner": str(other_user.pk), # Attempt to set another user as owner
|
||||
}
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify in database
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.owner == user
|
||||
assert project.title == "New Project"
|
||||
|
||||
|
||||
def test_create_project_with_llm_instructions(api_client):
|
||||
"""Test creating a project with custom llm instructions."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
"llm_instructions": "Always answer in French.",
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["llm_instructions"] == "Always answer in French."
|
||||
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.llm_instructions == "Always answer in French."
|
||||
|
||||
|
||||
def test_create_project_anonymous(api_client):
|
||||
"""Test creating a project as an anonymous user returns a 401 error."""
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "New Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Unit tests for project deletion in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatConversation, ChatProject
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_delete_project(api_client):
|
||||
"""Test deleting a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# Verify deletion in database
|
||||
assert not ChatProject.objects.filter(id=project.pk).exists()
|
||||
|
||||
|
||||
def test_delete_project_deletes_related_conversations(api_client):
|
||||
"""Test that deleting a project also deletes its conversations."""
|
||||
project = ChatProjectFactory(number_of_conversations=2)
|
||||
conversation_pks = list(project.conversations.values_list("pk", flat=True))
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not ChatProject.objects.filter(id=project.pk).exists()
|
||||
assert not ChatConversation.objects.filter(pk__in=conversation_pks).exists()
|
||||
|
||||
|
||||
def test_delete_project_does_not_affect_other_conversations(api_client):
|
||||
"""Test that deleting a project does not delete conversations from other projects."""
|
||||
project = ChatProjectFactory(number_of_conversations=1)
|
||||
other_project = ChatProjectFactory(owner=project.owner, number_of_conversations=1)
|
||||
other_conversation = other_project.conversations.get()
|
||||
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert ChatConversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_delete_other_user_project_fails(api_client):
|
||||
"""Test that deleting another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
other_user = UserFactory()
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify project still exists
|
||||
assert ChatProject.objects.filter(id=project.pk).exists()
|
||||
|
||||
|
||||
def test_delete_project_user_anonymous(api_client):
|
||||
"""Test deleting a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# Verify project still exists
|
||||
assert ChatProject.objects.filter(id=project.pk).exists()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Unit tests for listing projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatConversationFactory, ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_list_projects(api_client, django_assert_num_queries):
|
||||
"""Test retrieving the list of projects for an authenticated user."""
|
||||
project = ChatProjectFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(project.owner)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(project.pk)
|
||||
assert results[0]["title"] == project.title
|
||||
assert results[0]["conversations"] == []
|
||||
|
||||
|
||||
def test_filter_projects_by_title(api_client):
|
||||
"""Test filtering projects by title substring."""
|
||||
user = UserFactory(sub="testuser", email="test@example.com")
|
||||
|
||||
ChatProjectFactory(owner=user, title="Test Project")
|
||||
|
||||
ChatProjectFactory(owner=user, title="Other Project")
|
||||
|
||||
url = "/api/v1.0/projects/?title=Test"
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["title"] == "Test Project"
|
||||
|
||||
|
||||
def test_list_projects_with_conversations(api_client, django_assert_num_queries):
|
||||
"""Test retrieving projects with associated conversations ordered by -created_at."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
with freeze_time("2026-01-01"):
|
||||
conversation_1 = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation 1"
|
||||
)
|
||||
with freeze_time("2026-01-02"):
|
||||
conversation_2 = ChatConversationFactory(
|
||||
project=project, owner=project.owner, title="My conversation 2"
|
||||
)
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(project.owner)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(project.pk)
|
||||
assert results[0]["title"] == project.title
|
||||
assert results[0]["conversations"] == [
|
||||
{
|
||||
"id": str(conversation_2.id),
|
||||
"title": conversation_2.title,
|
||||
},
|
||||
{
|
||||
"id": str(conversation_1.id),
|
||||
"title": conversation_1.title,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_projects_no_n_plus_one(api_client, django_assert_num_queries):
|
||||
"""Test that query count stays constant regardless of project/conversation count."""
|
||||
user = UserFactory()
|
||||
for _ in range(3):
|
||||
ChatProjectFactory(owner=user, number_of_conversations=2)
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4): # user, project count, project list, conversations
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 3
|
||||
|
||||
|
||||
def test_list_projects_ordered_by_title(api_client):
|
||||
"""Test that projects are returned in alphabetical order by title."""
|
||||
user = UserFactory()
|
||||
ChatProjectFactory(owner=user, title="Zeta")
|
||||
ChatProjectFactory(owner=user, title="Alpha")
|
||||
ChatProjectFactory(owner=user, title="Mu")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/projects/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == ["Alpha", "Mu", "Zeta"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ordering,expected_titles",
|
||||
[
|
||||
("created_at", ["First", "Second", "Third"]),
|
||||
("-created_at", ["Third", "Second", "First"]),
|
||||
("title", ["First", "Second", "Third"]),
|
||||
("-title", ["Third", "Second", "First"]),
|
||||
],
|
||||
)
|
||||
def test_list_projects_ordering(api_client, ordering, expected_titles):
|
||||
"""Test ordering projects by the allowed ordering fields."""
|
||||
user = UserFactory()
|
||||
with freeze_time("2026-01-01"):
|
||||
ChatProjectFactory(owner=user, title="First")
|
||||
with freeze_time("2026-01-02"):
|
||||
ChatProjectFactory(owner=user, title="Second")
|
||||
with freeze_time("2026-01-03"):
|
||||
ChatProjectFactory(owner=user, title="Third")
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(f"/api/v1.0/projects/?ordering={ordering}")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == expected_titles
|
||||
|
||||
|
||||
def test_list_projects_ordering_by_updated_at(api_client):
|
||||
"""Test ordering projects by updated_at."""
|
||||
user = UserFactory()
|
||||
with freeze_time("2026-01-01"):
|
||||
project_a = ChatProjectFactory(owner=user, title="A")
|
||||
ChatProjectFactory(owner=user, title="B")
|
||||
with freeze_time("2026-01-02"):
|
||||
project_a.title = "A updated"
|
||||
project_a.save()
|
||||
|
||||
api_client.force_login(user)
|
||||
response = api_client.get("/api/v1.0/projects/?ordering=updated_at")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [r["title"] for r in response.data["results"]]
|
||||
assert titles == ["B", "A updated"]
|
||||
|
||||
|
||||
def test_list_projects_empty(api_client):
|
||||
"""Test retrieving the list of projects for an authenticated user."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
api_client.force_login(user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
def test_list_projects_anonymous(api_client):
|
||||
"""Test listing projects as an anonymous user returns a 401 error."""
|
||||
|
||||
url = "/api/v1.0/projects/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for partial update of projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("title", "Updated Title"),
|
||||
("icon", ChatProjectIcon.STAR),
|
||||
("color", ChatProjectColor.COLOR_3),
|
||||
("llm_instructions", "Always answer in French."),
|
||||
],
|
||||
)
|
||||
def test_partial_update_project(api_client, field, value):
|
||||
"""Test updating a project field as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {field: value}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data[field] == value
|
||||
|
||||
# Verify in database
|
||||
project.refresh_from_db()
|
||||
assert getattr(project, field) == value
|
||||
|
||||
|
||||
def test_partial_update_other_user_project_fails(api_client):
|
||||
"""Test that updating another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
|
||||
data = {
|
||||
"title": "Updated By Other User",
|
||||
}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_partial_update_project_anonymous(api_client):
|
||||
"""Test updating a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
}
|
||||
response = api_client.patch(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for retrieving projects."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_retrieve_project(api_client):
|
||||
"""Test retrieving a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["id"] == str(project.pk)
|
||||
assert response.data["title"] == project.title
|
||||
|
||||
|
||||
def test_retrieve_other_user_project_fails(api_client):
|
||||
"""Test that retrieving another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_retrieve_project_anonymous(api_client):
|
||||
"""Test retrieving a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Unit tests for updating projects in the chat API view."""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
from chat.factories import ChatProjectFactory
|
||||
from chat.models import ChatProjectColor, ChatProjectIcon
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_project(api_client):
|
||||
"""Test updating a project as the owner."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Updated Title"
|
||||
|
||||
# Verify in database
|
||||
project.refresh_from_db()
|
||||
assert project.title == "Updated Title"
|
||||
|
||||
|
||||
def test_update_project_llm_instructions(api_client):
|
||||
"""Test updating a project's LLM instructions via PUT."""
|
||||
project = ChatProjectFactory(llm_instructions="Old instructions")
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": project.title,
|
||||
"icon": project.icon,
|
||||
"color": project.color,
|
||||
"llm_instructions": "New instructions",
|
||||
}
|
||||
api_client.force_login(project.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["llm_instructions"] == "New instructions"
|
||||
|
||||
project.refresh_from_db()
|
||||
assert project.llm_instructions == "New instructions"
|
||||
|
||||
|
||||
def test_update_other_user_project_fails(api_client):
|
||||
"""Test that updating another user's project returns a 404 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
other_user = UserFactory()
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
|
||||
data = {
|
||||
"title": "Updated By Other User",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
api_client.force_login(other_user)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_update_project_anonymous(api_client):
|
||||
"""Test updating a project as an anonymous user returns a 401 error."""
|
||||
project = ChatProjectFactory()
|
||||
|
||||
url = f"/api/v1.0/projects/{project.pk}/"
|
||||
data = {
|
||||
"title": "Updated Title",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": ChatProjectColor.COLOR_1,
|
||||
}
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the file stream endpoint."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
def test_file_stream_invalid_key(api_client):
|
||||
"""Test that invalid temporary keys return 404."""
|
||||
cache.clear()
|
||||
url = "/api/v1.0/file-stream/invalid-key/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
error = response.json()["detail"].lower()
|
||||
assert "expired" in error or "invalid" in error
|
||||
|
||||
|
||||
def test_file_stream_expired_key(api_client):
|
||||
"""Test that expired keys return 404."""
|
||||
cache.clear()
|
||||
# Create a key that's already expired
|
||||
cache.set("file_access:expired-key", "path/to/file.pdf", timeout=0)
|
||||
|
||||
url = "/api/v1.0/file-stream/expired-key/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@mock.patch("chat.views.magic.Magic")
|
||||
@mock.patch("chat.views.default_storage.open")
|
||||
def test_file_stream_valid_key_streams_file(mock_storage_open, mock_magic, api_client):
|
||||
"""Test that valid temporary keys stream file content."""
|
||||
cache.clear()
|
||||
|
||||
# Create a valid temporary key
|
||||
temporary_key = "test-valid-key"
|
||||
s3_key = "test/path/file.pdf"
|
||||
cache.set(f"file_access:{temporary_key}", s3_key, timeout=300)
|
||||
|
||||
# Mock storage.open to return file content
|
||||
file_mock = BytesIO(b"PDF content here")
|
||||
mock_storage_open.return_value = file_mock
|
||||
|
||||
# Mock magic detector
|
||||
mock_magic_instance = mock.MagicMock()
|
||||
mock_magic_instance.from_buffer.return_value = "application/pdf"
|
||||
mock_magic.return_value = mock_magic_instance
|
||||
|
||||
url = f"/api/v1.0/file-stream/{temporary_key}/"
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -26,7 +26,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
|
||||
|
||||
document_store = document_store_backend(ctx.deps.conversation.collection_id)
|
||||
|
||||
rag_results = document_store.search(query)
|
||||
rag_results = document_store.search(query, session=ctx.deps.session)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -127,7 +127,7 @@ async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[st
|
||||
return []
|
||||
|
||||
|
||||
async def _fetch_and_store_async(url: str, document_store) -> None:
|
||||
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
|
||||
"""Fetch, extract and store text content from the URL in the document store."""
|
||||
|
||||
try:
|
||||
@@ -136,7 +136,7 @@ async def _fetch_and_store_async(url: str, document_store) -> None:
|
||||
logger.debug("Fetched document: %s", document)
|
||||
|
||||
if document:
|
||||
await document_store.astore_document(url, document)
|
||||
await document_store.astore_document(url, document, **kwargs)
|
||||
except DocumentFetchError as e:
|
||||
logger.warning("Failed to fetch and store %s: %s", url, e)
|
||||
# Continue with other documents
|
||||
@@ -307,19 +307,26 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
|
||||
temp_collection_name = f"tmp-{uuid.uuid4()}"
|
||||
try:
|
||||
async with document_store_backend.temporary_collection_async(
|
||||
temp_collection_name
|
||||
temp_collection_name, session=ctx.deps.session
|
||||
) as document_store:
|
||||
# Fetch and store all documents concurrently
|
||||
tasks = [
|
||||
_fetch_and_store_async(result["url"], document_store)
|
||||
_fetch_and_store_async(
|
||||
result["url"],
|
||||
document_store,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
session=ctx.deps.session,
|
||||
)
|
||||
for result in raw_search_results
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Perform RAG search
|
||||
rag_results = await document_store.asearch(
|
||||
query,
|
||||
query=query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
session=ctx.deps.session,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
)
|
||||
logger.info("RAG search returned: %s", rag_results)
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
This module contains the EventEncoder class.
|
||||
"""
|
||||
|
||||
from .encoder import EventEncoder
|
||||
from .encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder, EventEncoderVersion
|
||||
|
||||
__all__ = ["EventEncoder"]
|
||||
__all__ = ["EventEncoder", "CURRENT_EVENT_ENCODER_VERSION", "EventEncoderVersion"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Event Encoder for Vercel AI SDK"""
|
||||
|
||||
from typing import Literal, Union
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
from ..core.events_v4 import BaseEvent as V4BaseEvent
|
||||
from ..core.events_v4 import TextPart
|
||||
@@ -8,16 +9,26 @@ from ..core.events_v5 import BaseEvent as V5BaseEvent
|
||||
from ..core.events_v5 import TextDeltaEvent
|
||||
|
||||
|
||||
class EventEncoderVersion(str, Enum):
|
||||
"""Enumeration of supported event encoder versions."""
|
||||
|
||||
V4 = "v4"
|
||||
V5 = "v5"
|
||||
|
||||
|
||||
CURRENT_EVENT_ENCODER_VERSION = EventEncoderVersion.V4 # used encoder version
|
||||
|
||||
|
||||
class EventEncoder:
|
||||
"""
|
||||
Encodes events for the Vercel AI SDK based on the specified version.
|
||||
"""
|
||||
|
||||
def __init__(self, version: Literal["v4", "v5"] = None):
|
||||
def __init__(self, version: EventEncoderVersion):
|
||||
"""
|
||||
Initializes the EventEncoder with the specified version.
|
||||
"""
|
||||
if version not in ["v4", "v5"]:
|
||||
if version not in [EventEncoderVersion.V4, EventEncoderVersion.V5]:
|
||||
raise ValueError("Unsupported version. Supported versions are 'v4' and 'v5'.")
|
||||
|
||||
self.version = version
|
||||
@@ -28,7 +39,7 @@ class EventEncoder:
|
||||
"""
|
||||
return "text/event-stream"
|
||||
|
||||
def encode(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -38,15 +49,15 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == "v4" and isinstance(event, V4BaseEvent):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, V4BaseEvent):
|
||||
return self._encode_v4_streaming(event)
|
||||
|
||||
if self.version == "v5" and isinstance(event, V5BaseEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, V5BaseEvent):
|
||||
return self._encode_sse(event)
|
||||
|
||||
return None
|
||||
|
||||
def encode_text(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -56,10 +67,10 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == "v4" and isinstance(event, TextPart):
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
|
||||
return event.text
|
||||
|
||||
if self.version == "v5" and isinstance(event, TextDeltaEvent):
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, TextDeltaEvent):
|
||||
return event.delta
|
||||
|
||||
return None
|
||||
@@ -70,7 +81,7 @@ class EventEncoder:
|
||||
"""
|
||||
return f"{event.type}:{event.model_dump_json(by_alias=True, exclude={'type'})}\n"
|
||||
|
||||
def _encode_sse(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str:
|
||||
def _encode_sse(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str:
|
||||
"""
|
||||
Encodes an event into an SSE string.
|
||||
"""
|
||||
|
||||
+324
-18
@@ -2,37 +2,58 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import Prefetch
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
import langfuse
|
||||
import magic
|
||||
import posthog
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.api.viewsets import Pagination, SerializerPerActionMixin
|
||||
from core.file_upload import enums
|
||||
from core.file_upload.enums import AttachmentStatus
|
||||
from core.file_upload.mixins import AttachmentMixin
|
||||
from core.file_upload.serializers import FileUploadSerializer
|
||||
from core.filters import remove_accents
|
||||
|
||||
from activation_codes.permissions import IsActivatedUser
|
||||
from chat import models, serializers
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.keepalive import stream_with_keepalive_async, stream_with_keepalive_sync
|
||||
from chat.serializers import ChatConversationRequestSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatConversationFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversation."""
|
||||
def conditional_refresh_oidc_token(func):
|
||||
"""
|
||||
Conditionally apply refresh_oidc_access_token decorator.
|
||||
|
||||
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
|
||||
we can actually refresh something. Broader settings checks are done in settings.py.
|
||||
"""
|
||||
if settings.OIDC_STORE_REFRESH_TOKEN:
|
||||
return method_decorator(refresh_oidc_access_token)(func)
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class TitleSearchFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversation by title (accent-insensitive)."""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""Filter conversation by title."""
|
||||
@@ -40,6 +61,58 @@ class ChatConversationFilter(filters.BaseFilterBackend):
|
||||
queryset = queryset.filter(title__unaccent__icontains=remove_accents(title))
|
||||
return queryset
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
"""Return the schema for the ``title`` query parameter (drf-spectacular)."""
|
||||
return [
|
||||
{
|
||||
"name": "title",
|
||||
"required": False,
|
||||
"in": "query",
|
||||
"description": "Search conversations by title (accent-insensitive). "
|
||||
"When provided, the response uses a search-specific serializer "
|
||||
"with nested project info.",
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ProjectFilter(filters.BaseFilterBackend):
|
||||
"""Filter conversations by project.
|
||||
|
||||
Accepts a `project` query parameter:
|
||||
- a UUID: conversations belonging to that specific project
|
||||
- "none": conversations not linked to any project
|
||||
- "any": conversations linked to any project
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""Filter conversations by project."""
|
||||
project_id = request.GET.get("project")
|
||||
if project_id is None:
|
||||
return queryset
|
||||
if project_id == "none":
|
||||
return queryset.filter(project__isnull=True)
|
||||
if project_id == "any":
|
||||
return queryset.filter(project__isnull=False)
|
||||
try:
|
||||
UUID(project_id)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
return queryset.filter(project_id=project_id)
|
||||
|
||||
def get_schema_operation_parameters(self, view):
|
||||
"""Return the schema for the ``project`` query parameter (drf-spectacular)."""
|
||||
return [
|
||||
{
|
||||
"name": "project",
|
||||
"required": False,
|
||||
"in": "query",
|
||||
"description": "Filter by project. Pass a UUID for a specific project, "
|
||||
'"none" for standalone conversations, or "any" for all project conversations.',
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ChatAttachmentMixin(AttachmentMixin): # pylint: disable=abstract-method
|
||||
"""Mixin to handle attachment authorization for chat conversations."""
|
||||
@@ -102,18 +175,42 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
]
|
||||
serializer_class = serializers.ChatConversationSerializer
|
||||
post_conversation_serializer_class = serializers.ChatConversationInputSerializer
|
||||
filter_backends = [filters.OrderingFilter, ChatConversationFilter]
|
||||
filter_backends = [filters.OrderingFilter, TitleSearchFilter, ProjectFilter]
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at"]
|
||||
queryset = models.ChatConversation.objects # defined to be used in AttachmentMixin
|
||||
|
||||
@extend_schema(
|
||||
responses=serializers.ChatConversationSearchSerializer(many=True),
|
||||
description=(
|
||||
"When the `title` query parameter is provided, returns search results "
|
||||
"with nested project info (id, title, icon) and no messages. "
|
||||
"Without `title`, returns the default conversation list."
|
||||
),
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return search serializer when filtering by title on list action."""
|
||||
|
||||
# Search results only include nested project info
|
||||
if self.action == "list" and self.request.query_params.get("title"):
|
||||
return serializers.ChatConversationSearchSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the chat conversations."""
|
||||
return (
|
||||
self.queryset.filter(owner=self.request.user)
|
||||
if self.request.user.is_authenticated
|
||||
else self.queryset.none()
|
||||
)
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.queryset.none()
|
||||
|
||||
qs = self.queryset.filter(owner=self.request.user)
|
||||
|
||||
# Search results use nested project info; post_conversation needs
|
||||
# project.llm_instructions — prefetch to avoid extra queries
|
||||
if self.request.query_params.get("title") or self.action == "post_conversation":
|
||||
qs = qs.select_related("project")
|
||||
return qs
|
||||
|
||||
def get_permissions(self):
|
||||
"""Return the permissions for the viewset."""
|
||||
@@ -122,6 +219,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
self.permission_classes = []
|
||||
return super().get_permissions()
|
||||
|
||||
@conditional_refresh_oidc_token
|
||||
@decorators.action(
|
||||
methods=["post"],
|
||||
detail=True,
|
||||
@@ -173,6 +271,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
ai_service = AIAgentService(
|
||||
conversation=conversation,
|
||||
user=self.request.user,
|
||||
session=request.session,
|
||||
model_hrid=model_hrid,
|
||||
language=(
|
||||
self.request.user.language
|
||||
@@ -188,29 +287,28 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
if is_async_mode:
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
base_stream = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
base_stream = ai_service.stream_text_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
streaming_content = stream_with_keepalive_async(base_stream)
|
||||
else:
|
||||
logger.debug("Using SYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
else: # Default to 'text' protocol
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
base_stream = ai_service.stream_text(messages, force_web_search=force_web_search)
|
||||
|
||||
streaming_content = stream_with_keepalive_sync(base_stream)
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content,
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
|
||||
"X-Accel-Buffering": "no", # Prevent nginx buffering
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -371,7 +469,6 @@ class ChatConversationAttachmentViewSet(
|
||||
owner=self.request.user,
|
||||
).exists():
|
||||
raise Http404
|
||||
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
extension = file_name.rpartition(".")[-1] if "." in file_name else None
|
||||
|
||||
@@ -435,3 +532,212 @@ class ChatConversationAttachmentViewSet(
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="backend-upload",
|
||||
url_name="backend-upload",
|
||||
)
|
||||
def backend_upload_attachment(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handle backend file upload for backend_to_s3 mode.
|
||||
|
||||
This endpoint is used when FILE_UPLOAD_MODE is set to backend_to_s3.
|
||||
The frontend sends the file directly to this endpoint,
|
||||
and the backend stores it on S3 and initiates malware detection.
|
||||
|
||||
The attachment lifecycle:
|
||||
1. Frontend sends file via this endpoint
|
||||
2. Backend stores file on S3
|
||||
3. Backend detects MIME type and file size
|
||||
4. Backend initiates malware detection
|
||||
5. After detection, attachment status becomes READY or SUSPICIOUS
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# Verify the user owns the conversation
|
||||
conversation_id = self.kwargs["conversation_pk"]
|
||||
if not models.ChatConversation.objects.filter(
|
||||
pk=conversation_id,
|
||||
owner=request.user,
|
||||
).exists():
|
||||
raise Http404
|
||||
|
||||
serializer = FileUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
file_obj = serializer.validated_data["file"]
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
|
||||
# Generate unique file ID and storage key
|
||||
file_id = uuid4()
|
||||
extension = file_name.rpartition(".")[-1] if "." in file_name else None
|
||||
ext_suffix = f".{extension}" if extension else ""
|
||||
key = f"{conversation_id}/{AttachmentMixin.ATTACHMENTS_FOLDER}/{file_id}{ext_suffix}"
|
||||
|
||||
# Store file on S3
|
||||
try:
|
||||
stored_path = default_storage.save(key, file_obj)
|
||||
logger.info("File uploaded to S3: %s", stored_path)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to upload file to S3 for conversation %s", conversation_id)
|
||||
return Response(
|
||||
{"detail": "Failed to upload file to storage"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Detect MIME type
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
with default_storage.open(key, "rb") as file:
|
||||
mimetype = mime_detector.from_buffer(file.read(2048))
|
||||
file_size = file.size
|
||||
|
||||
# Create attachment record with ANALYZING status
|
||||
attachment = models.ChatConversationAttachment.objects.create(
|
||||
conversation_id=conversation_id,
|
||||
uploaded_by=request.user,
|
||||
upload_state=AttachmentStatus.ANALYZING,
|
||||
key=key,
|
||||
file_name=file_name,
|
||||
content_type=mimetype,
|
||||
size=file_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created attachment %s for conversation %s, starting malware detection",
|
||||
attachment.pk,
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# Start malware detection (will update status to READY or SUSPICIOUS via callbacks)
|
||||
malware_detection.analyse_file(
|
||||
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,
|
||||
)
|
||||
|
||||
# Track upload event
|
||||
if settings.POSTHOG_KEY:
|
||||
posthog.capture(
|
||||
"item_uploaded_backend",
|
||||
distinct_id=str(request.user.pk),
|
||||
properties={
|
||||
"id": attachment.pk,
|
||||
"file_name": attachment.file_name,
|
||||
"size": attachment.size,
|
||||
"mimetype": attachment.content_type,
|
||||
"mode": settings.FILE_UPLOAD_MODE,
|
||||
},
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(attachment)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FileStreamView(APIView):
|
||||
"""
|
||||
Stream file content for temporary access URLs.
|
||||
|
||||
This view is used by LLMs to access file content when they cannot directly
|
||||
access S3. A temporary key is stored in cache and validated before serving
|
||||
the file.
|
||||
|
||||
Security:
|
||||
- Temporary key expires after FILE_BACKEND_TEMPORARY_URL_EXPIRATION seconds
|
||||
(default: 180 seconds / 3 minutes)
|
||||
- No authentication required (key is single-use temporary token)
|
||||
- Key is generated using secure random tokens
|
||||
"""
|
||||
|
||||
permission_classes = [] # No authentication needed for temporary keys
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "file-stream"
|
||||
|
||||
def get(self, request, temporary_key):
|
||||
"""
|
||||
Stream file content using a temporary access key.
|
||||
|
||||
Args:
|
||||
temporary_key: The temporary key generated by generate_temporary_url()
|
||||
|
||||
Returns:
|
||||
StreamingHttpResponse with file content
|
||||
"""
|
||||
# Retrieve the S3 key from cache using the temporary key
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
s3_key = cache.get(cache_key)
|
||||
|
||||
if not s3_key:
|
||||
logger.warning("Temporary file access key not found or expired: %s", temporary_key)
|
||||
raise Http404("File access key expired or invalid")
|
||||
|
||||
# Delete the key from cache to prevent reuse
|
||||
cache.delete(cache_key)
|
||||
|
||||
logger.info("Serving file via temporary key: %s", s3_key)
|
||||
|
||||
try:
|
||||
# Open the file from S3
|
||||
file_obj = default_storage.open(s3_key, "rb")
|
||||
|
||||
# Detect MIME type for proper content-type header
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
file_content = file_obj.read(2048)
|
||||
file_obj.seek(0)
|
||||
content_type = mime_detector.from_buffer(file_content)
|
||||
|
||||
# Extract filename from S3 key (last part after /)
|
||||
filename = s3_key.split("/")[-1]
|
||||
|
||||
# Stream the file content
|
||||
response = StreamingHttpResponse(
|
||||
file_obj,
|
||||
content_type=content_type,
|
||||
)
|
||||
response["Content-Disposition"] = f'inline; filename="{filename}"'
|
||||
return response
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to serve file via temporary key: %s", temporary_key)
|
||||
raise Http404("Failed to retrieve file") from exc
|
||||
|
||||
|
||||
class ChatProjectViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
"""ViewSet for managing projects."""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
IsActivatedUser, # see activation_codes application
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
ordering = ["title"]
|
||||
ordering_fields = ["title", "created_at", "updated_at"]
|
||||
queryset = models.ChatProject.objects
|
||||
serializer_class = serializers.ChatProjectSerializer
|
||||
filter_backends = [filters.OrderingFilter, TitleSearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the projects."""
|
||||
|
||||
# Prefetch conversations ordered by most recent first
|
||||
conversations_prefetch = Prefetch(
|
||||
"conversations",
|
||||
queryset=models.ChatConversation.objects.order_by("-created_at"),
|
||||
)
|
||||
|
||||
return (
|
||||
self.queryset.filter(owner=self.request.user).prefetch_related(conversations_prefetch)
|
||||
if self.request.user.is_authenticated
|
||||
else self.queryset.none()
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete a project and its related conversations.
|
||||
|
||||
ChatConversation.project uses on_delete=SET_NULL (to avoid accidental
|
||||
cascade), so we explicitly delete conversations here.
|
||||
"""
|
||||
instance.conversations.all().delete()
|
||||
instance.delete()
|
||||
|
||||
@@ -22,7 +22,7 @@ def no_http_requests(monkeypatch):
|
||||
Credits: https://blog.jerrycodes.com/no-http-requests/
|
||||
"""
|
||||
|
||||
allowed_hosts = {"localhost", "minio", "minio:9000"}
|
||||
allowed_hosts = {"localhost", "127.0.0.1", "minio", "minio:9000"}
|
||||
original_urlopen = HTTPConnectionPool.urlopen
|
||||
|
||||
def urlopen_mock(self, method, url, *args, **kwargs):
|
||||
|
||||
@@ -230,6 +230,27 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="ATTACHMENT_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_UPLOAD_MODE = values.Value(
|
||||
"presigned_url",
|
||||
environ_name="FILE_UPLOAD_MODE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_TO_LLM_MODE = values.Value(
|
||||
"presigned_url",
|
||||
environ_name="FILE_TO_LLM_MODE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_BACKEND_URL = values.Value(
|
||||
"",
|
||||
environ_name="FILE_BACKEND_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FILE_BACKEND_TEMPORARY_URL_EXPIRATION = values.IntegerValue(
|
||||
180,
|
||||
environ_name="FILE_BACKEND_TEMPORARY_URL_EXPIRATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
MALWARE_DETECTION = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.malware_detection.backends.dummy.DummyBackend",
|
||||
@@ -395,6 +416,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"file-stream": values.Value(
|
||||
default="60/minute",
|
||||
environ_name="API_FILE_STREAM_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -646,6 +672,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="DEFAULT_ALLOW_CONVERSATION_ANALYTICS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DEFAULT_ALLOW_SMART_WEB_SEARCH = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="DEFAULT_ALLOW_SMART_WEB_SEARCH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# These settings are default values used in the default LLM_CONFIGURATIONS
|
||||
# They allow a deployment with only one model without a specific configuration file
|
||||
@@ -676,7 +707,7 @@ class Base(BraveSettings, Configuration):
|
||||
# docx files
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
# pptx files
|
||||
"application/vnd.openxmlformats-officedocument.presentationml",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
# xlsx and xls files
|
||||
"application/vnd.ms-excel",
|
||||
"application/excel",
|
||||
@@ -717,6 +748,11 @@ class Base(BraveSettings, Configuration):
|
||||
environ_name="RAG_DOCUMENT_SEARCH_BACKEND",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RAG_DOCUMENT_PARSER = values.Value(
|
||||
"chat.agent_rag.document_converter.parser.AlbertParser",
|
||||
environ_name="RAG_DOCUMENT_PARSER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = values.DictValue(
|
||||
default={},
|
||||
environ_name="SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS",
|
||||
@@ -782,6 +818,51 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OCR settings for AdaptivePdfParser
|
||||
OCR_HRID = values.Value(
|
||||
default="etalab-plateform-mistral-medium-2508",
|
||||
environ_name="OCR_HRID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Specific Mistral OCR model - Designates which Mistral vision model to use for OCR
|
||||
OCR_MODEL = values.Value(
|
||||
default="mistral-ocr-2512",
|
||||
environ_name="OCR_MODEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OCR_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=240,
|
||||
environ_name="OCR_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OCR_MAX_RETRIES = values.PositiveIntegerValue(
|
||||
default=3,
|
||||
environ_name="OCR_MAX_RETRIES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OCR_RETRY_DELAY = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="OCR_RETRY_DELAY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OCR_BATCH_PAGES = values.PositiveIntegerValue(
|
||||
default=10,
|
||||
environ_name="OCR_BATCH_PAGES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = values.PositiveIntegerValue(
|
||||
default=200,
|
||||
environ_name="MIN_AVG_CHARS_FOR_TEXT_EXTRACTION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION = values.FloatValue(
|
||||
default=0.7,
|
||||
environ_name="MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Summarization
|
||||
SUMMARIZATION_SYSTEM_PROMPT = values.Value(
|
||||
(
|
||||
@@ -841,6 +922,23 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Find
|
||||
FIND_API_KEY = values.Value(
|
||||
None,
|
||||
environ_name="FIND_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FIND_API_URL = values.Value(
|
||||
"https://app-find/api",
|
||||
environ_name="FIND_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FIND_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
default=30, # seconds
|
||||
environ_name="FIND_API_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
@@ -911,7 +1009,9 @@ USER QUESTION:
|
||||
LANGFUSE_MEDIA_UPLOAD_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = values.PositiveIntegerValue(
|
||||
default=None, environ_name="AUTO_TITLE_AFTER_USER_MESSAGES", environ_prefix=None
|
||||
)
|
||||
# WARNING: Testing purpose only. Do not use in production.
|
||||
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
|
||||
default=False,
|
||||
@@ -919,6 +1019,12 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Default keepalive interval: 55s (safely below typical 60s proxy timeouts)
|
||||
# Prevents connection drops during long stream pauses while providing 5s safety margin.
|
||||
KEEPALIVE_INTERVAL = values.PositiveIntegerValue(
|
||||
default=55, environ_name="KEEPALIVE_INTERVAL", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -1038,6 +1144,42 @@ USER QUESTION:
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
# File access configuration validation
|
||||
if cls.FILE_TO_LLM_MODE == "backend_temporary_url" and not cls.FILE_BACKEND_URL:
|
||||
raise ValueError(
|
||||
"FILE_TO_LLM_MODE is set to 'backend_temporary_url' but FILE_BACKEND_URL is empty. "
|
||||
"Please set FILE_BACKEND_URL to a valid URL for backend temporary file access."
|
||||
)
|
||||
|
||||
# Find configuration
|
||||
if (
|
||||
cls.RAG_DOCUMENT_SEARCH_BACKEND
|
||||
== "chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend"
|
||||
and not all(
|
||||
(
|
||||
cls.FIND_API_KEY,
|
||||
cls.FIND_API_URL,
|
||||
cls.OIDC_STORE_ACCESS_TOKEN,
|
||||
cls.OIDC_STORE_REFRESH_TOKEN,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{cls.RAG_DOCUMENT_SEARCH_BACKEND} requires FIND_API_KEY, FIND_API_URL, "
|
||||
"OIDC_STORE_ACCESS_TOKEN and OIDC_STORE_REFRESH_TOKEN to be set."
|
||||
)
|
||||
|
||||
# OCR configuration validation
|
||||
# Note: we call load_llm_configuration directly because LLM_CONFIGURATIONS is a
|
||||
# @property returning a lazy object, which cannot be accessed via cls in a classmethod.
|
||||
if cls.RAG_DOCUMENT_PARSER == "chat.agent_rag.document_converter.parser.AdaptivePdfParser":
|
||||
llm_configs = load_llm_configuration(cls._llm_configuration_file_path)
|
||||
if cls.OCR_HRID not in llm_configs:
|
||||
raise ValueError(
|
||||
f"OCR_HRID '{cls.OCR_HRID}' not found in LLM_CONFIGURATIONS. "
|
||||
"Please add a matching provider entry or set OCR_HRID to an existing key."
|
||||
)
|
||||
|
||||
# Langfuse initialization
|
||||
if cls.LANGFUSE_ENABLED:
|
||||
if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED:
|
||||
@@ -1131,6 +1273,8 @@ class Test(Base):
|
||||
|
||||
POSTHOG_KEY = None
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = None
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -13,6 +13,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
|
||||
@@ -211,6 +211,7 @@ class ConfigView(drf.views.APIView):
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
"FEATURE_FLAGS",
|
||||
"FILE_UPLOAD_MODE",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
|
||||
@@ -55,7 +55,11 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
more information about the usage of the application.
|
||||
"""
|
||||
return super().create_user(
|
||||
claims | {"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS}
|
||||
claims
|
||||
| {
|
||||
"allow_conversation_analytics": settings.DEFAULT_ALLOW_CONVERSATION_ANALYTICS,
|
||||
"allow_smart_web_search": settings.DEFAULT_ALLOW_SMART_WEB_SEARCH,
|
||||
}
|
||||
)
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
|
||||
@@ -22,6 +22,7 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
skip_postgeneration_save = True
|
||||
|
||||
allow_conversation_analytics = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
allow_smart_web_search = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
|
||||
@@ -20,3 +20,28 @@ class AttachmentStatus(StrEnum):
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
|
||||
class FileUploadMode(StrEnum):
|
||||
"""Defines the possible modes for file upload (from frontend) handling."""
|
||||
|
||||
PRESIGNED_URL = "presigned_url"
|
||||
BACKEND_TO_S3 = "backend_to_s3"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
|
||||
class FileToLLMMode(StrEnum):
|
||||
"""Defines the possible modes to send file to the LLM."""
|
||||
|
||||
PRESIGNED_URL = "presigned_url"
|
||||
BACKEND_BASE64 = "backend_base64"
|
||||
BACKEND_TEMPORARY_URL = "backend_temporary_url"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Return a list of tuples for each enum member."""
|
||||
return [(member.value, member.name) for member in cls]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0003_alter_user_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_smart_web_search",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether the user allows to use smart web search features.",
|
||||
verbose_name="allow smart web search",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -77,10 +77,13 @@ class UserManager(auth_models.UserManager):
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
return self.get(email__iexact=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif self.filter(email=email).exists() and not settings.OIDC_ALLOW_DUPLICATE_EMAILS:
|
||||
elif (
|
||||
self.filter(email__iexact=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
@@ -163,6 +166,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
help_text=_("Whether the user allows to use their conversations for analytics."),
|
||||
)
|
||||
|
||||
allow_smart_web_search = models.BooleanField(
|
||||
_("allow smart web search"),
|
||||
default=True,
|
||||
help_text=_("Whether the user allows to use smart web search features."),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
|
||||
@@ -64,6 +64,28 @@ def test_authentication_getter_existing_user_via_email(django_assert_num_queries
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_case_insensitive(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email with different case,
|
||||
the user should be returned (case-insensitive email matching).
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
@@ -149,6 +171,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate_case_insensitive(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should detect duplicate emails even with different case.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
_db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(django_assert_num_queries, monkeypatch):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for generate_temporary_url utility function."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.agents.local_media_url_processors import generate_temporary_url
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_generate_temporary_url_returns_string():
|
||||
"""Test that generate_temporary_url returns a valid backend streaming URL."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test/file.pdf")
|
||||
|
||||
assert isinstance(url, str)
|
||||
assert url.startswith(settings.FILE_BACKEND_URL + "/api/v1.0/file-stream/")
|
||||
assert url.endswith("/")
|
||||
|
||||
|
||||
def test_generate_temporary_url_creates_cache_entry():
|
||||
"""Test that a cache entry is created with correct mapping."""
|
||||
cache.clear()
|
||||
s3_key = "conversation-id/attachments/file-uuid.pdf"
|
||||
url = generate_temporary_url(s3_key)
|
||||
|
||||
# Extract temporary key from URL
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Verify cache entry
|
||||
cache_key = f"file_access:{temporary_key}"
|
||||
cached_value = cache.get(cache_key)
|
||||
assert cached_value == s3_key
|
||||
|
||||
|
||||
def test_generate_temporary_url_unique_tokens():
|
||||
"""Test that different S3 keys produce different temporary tokens."""
|
||||
cache.clear()
|
||||
url1 = generate_temporary_url("file1.pdf")
|
||||
url2 = generate_temporary_url("file2.pdf")
|
||||
|
||||
assert url1 != url2
|
||||
|
||||
key1 = url1.split("/file-stream/")[1].rstrip("/")
|
||||
key2 = url2.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
assert cache.get(f"file_access:{key1}") == "file1.pdf"
|
||||
assert cache.get(f"file_access:{key2}") == "file2.pdf"
|
||||
|
||||
|
||||
def test_generate_temporary_url_token_is_url_safe():
|
||||
"""Test that generated tokens contain only URL-safe characters."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test.pdf")
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Token should only contain alphanumeric, dash, and underscore
|
||||
assert all(c.isalnum() or c in "-_" for c in temporary_key)
|
||||
|
||||
|
||||
def test_generate_temporary_url_token_sufficient_entropy():
|
||||
"""Test that generated tokens have sufficient entropy."""
|
||||
cache.clear()
|
||||
url = generate_temporary_url("test.pdf")
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
|
||||
# Token should be reasonably long
|
||||
assert len(temporary_key) >= 32
|
||||
|
||||
|
||||
def test_generate_temporary_url_no_sensitive_data_in_url():
|
||||
"""Test that temporary URLs don't contain S3 key information."""
|
||||
cache.clear()
|
||||
|
||||
s3_key = "secret/conversation-123/attachments/file.pdf"
|
||||
url = generate_temporary_url(s3_key)
|
||||
|
||||
# URL should not contain the actual S3 key
|
||||
assert "secret" not in url
|
||||
assert "conversation-123" not in url
|
||||
assert "file.pdf" not in url
|
||||
# Only the endpoint and random token
|
||||
assert "/api/v1.0/file-stream/" in url
|
||||
|
||||
|
||||
def test_generate_temporary_url_various_key_formats():
|
||||
"""Test generate_temporary_url with various S3 key formats."""
|
||||
cache.clear()
|
||||
|
||||
test_keys = [
|
||||
"simple/key.pdf",
|
||||
"conversation-123/attachments/file-uuid.pdf",
|
||||
"nested/folder/structure/file.jpg",
|
||||
"file_with_special-chars_123.png",
|
||||
]
|
||||
|
||||
urls = []
|
||||
for key in test_keys:
|
||||
url = generate_temporary_url(key)
|
||||
urls.append(url)
|
||||
|
||||
temporary_key = url.split("/file-stream/")[1].rstrip("/")
|
||||
assert cache.get(f"file_access:{temporary_key}") == key
|
||||
|
||||
# All URLs should be different
|
||||
assert len(set(urls)) == len(urls)
|
||||
@@ -47,6 +47,7 @@ def test_api_config(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
|
||||
"FILE_UPLOAD_MODE": "presigned_url",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
@@ -189,6 +190,7 @@ async def test_api_config_async(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"},
|
||||
"FILE_UPLOAD_MODE": "presigned_url",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
|
||||
@@ -225,6 +225,7 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"allow_conversation_analytics": user.allow_conversation_analytics,
|
||||
"allow_smart_web_search": user.allow_smart_web_search,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"language": user.language,
|
||||
@@ -338,7 +339,7 @@ def test_api_users_update_anonymous():
|
||||
def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to update their own user but only "language",
|
||||
"allow_conversation_analytics" and "timezone" fields.
|
||||
"allow_conversation_analytics", "allow_smart_web_search" and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -350,6 +351,7 @@ def test_api_users_update_authenticated_self():
|
||||
serializers.UserSerializer(
|
||||
instance=factories.UserFactory(
|
||||
allow_conversation_analytics=not user.allow_conversation_analytics,
|
||||
allow_smart_web_search=not user.allow_smart_web_search,
|
||||
)
|
||||
).data
|
||||
)
|
||||
@@ -364,7 +366,12 @@ def test_api_users_update_authenticated_self():
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["allow_conversation_analytics", "language", "timezone"]:
|
||||
if key in [
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"language",
|
||||
"timezone",
|
||||
]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
@@ -419,7 +426,7 @@ def test_api_users_patch_anonymous():
|
||||
def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to patch their own user but only "language",
|
||||
"allow_conversation_analytics" and "timezone" fields.
|
||||
"allow_conversation_analytics", "allow_smart_web_search" and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -431,6 +438,7 @@ def test_api_users_patch_authenticated_self():
|
||||
serializers.UserSerializer(
|
||||
instance=factories.UserFactory(
|
||||
allow_conversation_analytics=not user.allow_conversation_analytics,
|
||||
allow_smart_web_search=not user.allow_smart_web_search,
|
||||
)
|
||||
).data
|
||||
)
|
||||
@@ -446,7 +454,12 @@ def test_api_users_patch_authenticated_self():
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["allow_conversation_analytics", "language", "timezone"]:
|
||||
if key in [
|
||||
"allow_conversation_analytics",
|
||||
"allow_smart_web_search",
|
||||
"language",
|
||||
"timezone",
|
||||
]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
"""URL configuration for the core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.file_upload.enums import FileToLLMMode
|
||||
|
||||
from activation_codes import viewsets as activation_viewsets
|
||||
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
|
||||
from chat.views import (
|
||||
ChatConversationAttachmentViewSet,
|
||||
ChatProjectViewSet,
|
||||
ChatViewSet,
|
||||
FileStreamView,
|
||||
LLMConfigurationView,
|
||||
)
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("chats", ChatViewSet, basename="chats")
|
||||
router.register("projects", ChatProjectViewSet, basename="projects")
|
||||
router.register("activation", activation_viewsets.ActivationViewSet, basename="activation")
|
||||
|
||||
conversation_router = DefaultRouter()
|
||||
@@ -37,6 +45,21 @@ urlpatterns = [
|
||||
include(conversation_router.urls),
|
||||
),
|
||||
]
|
||||
+ (
|
||||
# Only allow file stream URL when configured for backend temporary URL mode
|
||||
[
|
||||
re_path(
|
||||
r"^file-stream/(?P<temporary_key>[a-zA-Z0-9_-]+)/$",
|
||||
FileStreamView.as_view(),
|
||||
name="file-stream",
|
||||
),
|
||||
]
|
||||
if (
|
||||
settings.FILE_TO_LLM_MODE == FileToLLMMode.BACKEND_TEMPORARY_URL
|
||||
or settings.ENVIRONMENT == "test"
|
||||
)
|
||||
else []
|
||||
)
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr ""
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Configuration"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Détails d'utilisation"
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Horodatages"
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Utilisation"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Description"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Aucun utilisateur n'a encore utilisé ce code"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "E-mail"
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Date"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Utilisateurs qui ont utilisé ce code"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Recalculer les utilisations actuelles à partir des activations liées"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Tous les codes d'activation sélectionnés ont déjà un nombre correct d'utilisations."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Utilisateur"
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "A utilisé le code d'activation"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Ajouter les utilisateurs sélectionnés à la liste d'attente Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "%(count)d utilisateur(s) ajoutés à la liste d'attente Brevo."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Aucune adresse e-mail valide trouvée dans les inscriptions sélectionnées."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Supprimer les utilisateurs sélectionnés de la liste d'attente Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Suppression de %(count)d utilisateur(s) de la liste d'attente Brevo."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "code d'activation"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Le code d'activation que les utilisateurs entreront"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "Le code doit être alphanumérique et ne contenir ni espaces ni caractères spéciaux"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "utilisations maximales"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Nombre maximum d'utilisation de ce code. 0 signifie illimité."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "utilisations actuelles"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Nombre de fois où ce code a été utilisé"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Si ce code peut encore être utilisé"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "expiration"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Date et heure d'expiration de ce code"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Description interne ou notes à propos de ce code"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "codes d'activation"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Ce code d'activation n'est plus valide"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Vous avez déjà activé votre compte"
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "L'utilisateur qui a utilisé le code d'activation"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Le code d'activation qui a été utilisé"
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "activation d'utilisateur"
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "activations d'utilisateurs"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "L'utilisateur qui a fait la demande d'enregistrement"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "demande d'inscription d'utilisateur"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "demandes d'inscription d'utilisateur"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "Le code d'activation à valider"
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Votre compte a été activé avec succès"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "application de chat"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement en tant que UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure de création de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Saisissez un 'sub' valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail de l'administrateur"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "autoriser les analyses de conversation"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Si l'utilisateur autorise l'utilisation de ses conversations pour des analyses."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "autoriser la recherche intelligente sur le web"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Si l'utilisateur autorise l'utilisation de fonctions de recherche intelligente sur le Web."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Configuratie"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Gebruiksdetails"
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Tijdstempels"
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Gebruik"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Beschrijving"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Er zijn nog geen gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Naam"
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "E-mail"
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Datum"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Gebruikers die deze code hebben gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Herbereken het huidige gebruik van gerelateerde activeringen"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Alle geselecteerde activeringscodes hebben al het juiste gebruiksaantal."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Het gebruik van %(count)d activeringscode(s) is opnieuw berekend."
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Gebruiker"
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Heeft activeringscode gebruikt"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Voeg geselecteerde gebruikers toe aan de Brevo-wachtlijst"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "%(count)d gebruiker(s) toegevoegd aan de Brevo-wachtlijst."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Er is geen geldig e-mailadres gevonden in de geselecteerde registraties."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Geselecteerde gebruikers van de Brevo-wachtlijst verwijderen"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "%(count)d gebruiker(s) verwijderd van de Brevo-wachtlijst."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "activeringscode"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "De activeringscode die gebruikers invoeren"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "De code moet alfanumeriek zijn en mag geen spaties of speciale tekens bevatten"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "maximaal gebruik"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Maximaal aantal keren dat deze code kan worden gebruikt. 0 betekent onbeperkt."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "huidig gebruik"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Aantal keren dat deze code is gebruikt"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Of deze code nog gebruikt kan worden"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "vervalt op"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Datum en tijd waarop deze code verloopt"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "beschrijving"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Interne beschrijving of notities over deze code"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "activeringscodes"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Deze activeringscode is niet meer geldig"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Je hebt je account al geactiveerd"
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "De gebruiker die de activeringscode heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "De activeringscode die is gebruikt"
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "gebruikers activering"
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "gebruikersactivaties"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "De gebruiker die het registratieverzoek heeft gedaan"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Opslaan of de gebruiker een activeringscode heeft ontvangen en deze heeft gebruikt"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "gebruikersregistratieverzoek"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "gebruikersregistratieverzoeken"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "De activeringscode om te valideren"
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Uw account is succesvol geactiveerd"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "chatapplicatie"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke gegevens"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "gemaakt op"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record is aangemaakt"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "We konden geen gebruiker met dit e-mailadres vinden, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Voer een geldig subsubtype in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/:-tekens bevatten."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Verplicht. Maximaal 255 tekens. Alleen letters, cijfers en @/./+/-/_/: tekens."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "identiteits e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "beheerders e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Of deze gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van accounts te verwijderen."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "conversatieanalyse toestaan"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Of de gebruiker toestaat dat zijn/haar gesprekken voor analyses worden gebruikt."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "smart web zoeken toestaan"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Of de gebruiker toestemming geeft om smart web zoekfuncties te gebruiken."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Настройки"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Сведения об использовании"
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Временные метки"
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Использование"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Пока нет пользователей, использовавших этот код"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Имя"
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "Эл. почта"
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Пользователи, использующие этот код"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Обновить данные использования связанных активаций"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Все выбранные коды активации уже имеют правильное количество использований."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Количество использованных кодов активации (%(count)d) успешно пересчитано."
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Пользователь"
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Использует код активации"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Добавить выбранных пользователей в список ожидания Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "В список ожидания Brevo добавлено пользователей: %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "В выбранных регистрациях не найден действительный адрес электронной почты."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Удалить выбранных пользователей из списка ожидания Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Из списка ожидания Brevo удалено пользователей: %(count)d."
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "код активации"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Код активации, который будут вводить пользователи"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "Код должен быть буквенно-цифровым и не содержать пробелов или специальных символов"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "максимум использований"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Сколько раз можно использовать этот код. 0 означает неограниченно."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "использовано"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Сколько раз этот код был использован"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "активный"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Можно ли ещё использовать этот код"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "действителен до"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Дата и время окончания действия этого кода"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Внутреннее описание или примечания об этом коде"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "коды активации"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Этот код активации больше не действителен"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Вы уже активировали свою учётную запись"
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "пользователь"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "Пользователь, использовавший код активации"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Использованный код активации"
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "активация пользователя"
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "активации пользователя"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "Пользователь, который сделал запрос на регистрацию"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Сохранить, если пользователь получил код активации и использовал его"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "запрос на регистрацию пользователя"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "запросы на регистрацию пользователя"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "Код активации для проверки"
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Ваша учётная запись успешно активирована"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "приложение чата"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Личные данные"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первичный ключ для записи как UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "создано"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата и время создания записи"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "обновлено"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата и время последнего обновления записи"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введите правильный префикс. Он может содержать только буквы, цифры и символы @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "префикс"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "полное имя"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "короткое имя"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "личный адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "e-mail администратора"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "язык"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "устройство"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Пользователь является устройством или человеком."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "статус сотрудника"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Может ли пользователь войти на этот административный сайт."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "разрешить аналитику для беседы"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Разрешает ли пользователь использовать свои беседы для аналитики."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "разрешить умный поиск в Интернете"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Разрешает ли пользователь использовать умный поиск в Интернете."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
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-12-15 13:49\n"
|
||||
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
|
||||
"PO-Revision-Date: 2026-03-11 15:23\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,328 +17,320 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-conversations.pot\n"
|
||||
"X-Crowdin-File-ID: 26\n"
|
||||
|
||||
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
|
||||
#: activation_codes/admin.py:55
|
||||
msgid "Configuration"
|
||||
msgstr "Налаштування"
|
||||
|
||||
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
|
||||
#: activation_codes/admin.py:66
|
||||
msgid "Usage details"
|
||||
msgstr "Відомості про використання"
|
||||
|
||||
#: activation_codes/admin.py:70 activation_codes/admin.py:226
|
||||
#: build/lib/activation_codes/admin.py:70
|
||||
#: build/lib/activation_codes/admin.py:226
|
||||
msgid "Timestamps"
|
||||
msgstr "Відмітки часу"
|
||||
|
||||
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
|
||||
#: activation_codes/admin.py:109
|
||||
msgid "Usage"
|
||||
msgstr "Використання"
|
||||
|
||||
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
|
||||
#: activation_codes/admin.py:117
|
||||
msgid "Description"
|
||||
msgstr "Опис"
|
||||
|
||||
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
|
||||
#: activation_codes/admin.py:124
|
||||
msgid "No users have used this code yet"
|
||||
msgstr "Користувачі ще не використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
|
||||
#: activation_codes/admin.py:135
|
||||
msgid "Name"
|
||||
msgstr "Ім’я"
|
||||
|
||||
#: activation_codes/admin.py:136 activation_codes/admin.py:246
|
||||
#: build/lib/activation_codes/admin.py:136
|
||||
#: build/lib/activation_codes/admin.py:246
|
||||
msgid "Email"
|
||||
msgstr "Ел. пошта"
|
||||
|
||||
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
|
||||
#: activation_codes/admin.py:137
|
||||
msgid "Date"
|
||||
msgstr "Дата"
|
||||
|
||||
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
|
||||
#: activation_codes/admin.py:161
|
||||
msgid "Users who used this code"
|
||||
msgstr "Користувачі, що використовували цей код"
|
||||
|
||||
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
|
||||
#: activation_codes/admin.py:163
|
||||
msgid "Recompute current uses from related activations"
|
||||
msgstr "Перерахувати поточні використання пов'язаних ресурсів"
|
||||
|
||||
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
|
||||
#: activation_codes/admin.py:177
|
||||
msgid "All selected activation codes already have correct usage counts."
|
||||
msgstr "Усі обрані коди активації вже мають коректні лічильники використання."
|
||||
|
||||
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
|
||||
#: activation_codes/admin.py:182
|
||||
#, python-format
|
||||
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
|
||||
msgstr "Успішно переобчислено використання коду активації %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:240 activation_codes/admin.py:284
|
||||
#: build/lib/activation_codes/admin.py:240
|
||||
#: build/lib/activation_codes/admin.py:284
|
||||
msgid "User"
|
||||
msgstr "Користувач"
|
||||
|
||||
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
|
||||
#: activation_codes/admin.py:291
|
||||
msgid "Has used activation code"
|
||||
msgstr "Використано код активації"
|
||||
|
||||
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
|
||||
#: activation_codes/admin.py:293
|
||||
msgid "Add selected users to Brevo waiting list"
|
||||
msgstr "Додати обраних користувачів до списку очікування Brevo"
|
||||
|
||||
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
|
||||
#: activation_codes/admin.py:314
|
||||
#, python-format
|
||||
msgid "Added %(count)d user(s) to Brevo waiting list."
|
||||
msgstr "До списку очікування Brevo додано користувачів: %(count)d."
|
||||
|
||||
#: activation_codes/admin.py:319 activation_codes/admin.py:347
|
||||
#: build/lib/activation_codes/admin.py:319
|
||||
#: build/lib/activation_codes/admin.py:347
|
||||
msgid "No valid email address found in selected registrations."
|
||||
msgstr "Серед обраних реєстрацій не знайдено дійсної адреси електронної пошти."
|
||||
|
||||
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
|
||||
#: activation_codes/admin.py:323
|
||||
msgid "Remove selected users from Brevo waiting list"
|
||||
msgstr "Видалити обраних користувачів зі списку очікування Brevo"
|
||||
|
||||
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
|
||||
#: activation_codes/admin.py:342
|
||||
#, python-format
|
||||
msgid "Removed %(count)d user(s) from Brevo waiting list."
|
||||
msgstr "Зі списку очікування Brevo видалено користувачів: %(count)d"
|
||||
|
||||
#: activation_codes/models.py:38 activation_codes/models.py:85
|
||||
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
|
||||
#: build/lib/activation_codes/models.py:85
|
||||
#: build/lib/activation_codes/models.py:178
|
||||
#: activation_codes/models.py:178
|
||||
msgid "activation code"
|
||||
msgstr "код активації"
|
||||
|
||||
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
|
||||
#: activation_codes/models.py:39
|
||||
msgid "The activation code that users will enter"
|
||||
msgstr "Код активації, що буде введений користувачами"
|
||||
|
||||
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
|
||||
#: activation_codes/models.py:46
|
||||
msgid "Code must be alphanumeric and contain no spaces or special characters"
|
||||
msgstr "Код має бути буквено-цифровим, без пробілів або спеціальних символів"
|
||||
|
||||
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
|
||||
#: activation_codes/models.py:52
|
||||
msgid "maximum uses"
|
||||
msgstr "максимум використань"
|
||||
|
||||
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
|
||||
#: activation_codes/models.py:53
|
||||
msgid "Maximum number of times this code can be used. 0 means unlimited."
|
||||
msgstr "Максимальна кількість разів використання для цього коду. 0 - необмежена."
|
||||
|
||||
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
|
||||
#: activation_codes/models.py:58
|
||||
msgid "current uses"
|
||||
msgstr "використано"
|
||||
|
||||
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
|
||||
#: activation_codes/models.py:59
|
||||
msgid "Number of times this code has been used"
|
||||
msgstr "Кількість разів використання цього коду"
|
||||
|
||||
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
#: activation_codes/models.py:65 core/models.py:154
|
||||
msgid "active"
|
||||
msgstr "активний"
|
||||
|
||||
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
|
||||
#: activation_codes/models.py:66
|
||||
msgid "Whether this code can still be used"
|
||||
msgstr "Чи цей код все ще може бути використаний"
|
||||
|
||||
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
|
||||
#: activation_codes/models.py:71
|
||||
msgid "expires at"
|
||||
msgstr "дійсний до"
|
||||
|
||||
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
|
||||
#: activation_codes/models.py:72
|
||||
msgid "Date and time when this code expires"
|
||||
msgstr "Дата та час, коли закінчується дія цього коду"
|
||||
|
||||
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
|
||||
#: activation_codes/models.py:78
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
|
||||
#: activation_codes/models.py:79
|
||||
msgid "Internal description or notes about this code"
|
||||
msgstr "Внутрішній опис або нотатки про цей код"
|
||||
|
||||
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
|
||||
#: activation_codes/models.py:86
|
||||
msgid "activation codes"
|
||||
msgstr "коди активації"
|
||||
|
||||
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
|
||||
#: activation_codes/models.py:128
|
||||
msgid "This activation code is no longer valid"
|
||||
msgstr "Цей код активації вже не дійсний"
|
||||
|
||||
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
|
||||
#: activation_codes/models.py:136
|
||||
msgid "You have already activated your account"
|
||||
msgstr "Ви вже активували свій обліковий запис"
|
||||
|
||||
#: activation_codes/models.py:170 activation_codes/models.py:202
|
||||
#: build/lib/activation_codes/models.py:170
|
||||
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
|
||||
#: core/models.py:173
|
||||
#: core/models.py:182
|
||||
msgid "user"
|
||||
msgstr "користувач"
|
||||
|
||||
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
|
||||
#: activation_codes/models.py:171
|
||||
msgid "The user who used the activation code"
|
||||
msgstr "Користувач, який користувався кодом активації"
|
||||
|
||||
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
|
||||
#: activation_codes/models.py:179
|
||||
msgid "The activation code that was used"
|
||||
msgstr "Використаний код активації"
|
||||
|
||||
#: activation_codes/models.py:186 activation_codes/models.py:210
|
||||
#: build/lib/activation_codes/models.py:186
|
||||
#: build/lib/activation_codes/models.py:210
|
||||
msgid "user activation"
|
||||
msgstr "активація користувача"
|
||||
|
||||
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
|
||||
#: activation_codes/models.py:187
|
||||
msgid "user activations"
|
||||
msgstr "активації користувача"
|
||||
|
||||
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
|
||||
#: activation_codes/models.py:203
|
||||
msgid "The user who made the registration request"
|
||||
msgstr "Користувач, що зробив запит на реєстрацію"
|
||||
|
||||
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
|
||||
#: activation_codes/models.py:211
|
||||
msgid "Store if the user received an activation code and used it"
|
||||
msgstr "Зберегти, якщо користувач отримав код активації та використав його"
|
||||
|
||||
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
|
||||
#: activation_codes/models.py:220
|
||||
msgid "user registration request"
|
||||
msgstr "запит на реєстрацію користувача"
|
||||
|
||||
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
|
||||
#: activation_codes/models.py:221
|
||||
msgid "user registration requests"
|
||||
msgstr "запити на реєстрацію користувачів"
|
||||
|
||||
#: activation_codes/serializers.py:14
|
||||
#: build/lib/activation_codes/serializers.py:14
|
||||
msgid "The activation code to validate"
|
||||
msgstr "Код активації для перевірки"
|
||||
|
||||
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
|
||||
#: activation_codes/viewsets.py:107
|
||||
msgid "Your account has been successfully activated"
|
||||
msgstr "Ваш обліковий запис успішно активовано"
|
||||
|
||||
#: build/lib/chat/apps.py:12 chat/apps.py:12
|
||||
#: chat/apps.py:12
|
||||
msgid "chat application"
|
||||
msgstr "чат-застосунок"
|
||||
|
||||
#: build/lib/core/admin.py:26 core/admin.py:26
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:40 core/admin.py:40
|
||||
#: core/admin.py:40
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:52 core/admin.py:52
|
||||
#: core/admin.py:52
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/models.py:39 core/models.py:39
|
||||
#: core/models.py:39
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:40 core/models.py:40
|
||||
#: core/models.py:40
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первинний ключ для запису як UUID"
|
||||
|
||||
#: build/lib/core/models.py:46 core/models.py:46
|
||||
#: core/models.py:46
|
||||
msgid "created on"
|
||||
msgstr "створено"
|
||||
|
||||
#: build/lib/core/models.py:47 core/models.py:47
|
||||
#: core/models.py:47
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата і час, коли запис було створено"
|
||||
|
||||
#: build/lib/core/models.py:52 core/models.py:52
|
||||
#: core/models.py:52
|
||||
msgid "updated on"
|
||||
msgstr "оновлено"
|
||||
|
||||
#: build/lib/core/models.py:53 core/models.py:53
|
||||
#: core/models.py:53
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата і час, коли запис був востаннє оновлений"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Ми не змогли знайти користувача з цими даними, але адреса вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
#: build/lib/core/models.py:99 core/models.py:99
|
||||
#: core/models.py:102
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Введіть правильний префікс. Це значення може містити лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:105 core/models.py:105
|
||||
#: core/models.py:108
|
||||
msgid "sub"
|
||||
msgstr "префікс"
|
||||
|
||||
#: build/lib/core/models.py:107 core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Обов'язково. 255 символів або менше. Лише літери, цифри та символи @/./+/-/_/."
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: core/models.py:119
|
||||
msgid "full name"
|
||||
msgstr "повне ім'я"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: core/models.py:120
|
||||
msgid "short name"
|
||||
msgstr "коротке ім'я"
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
#: core/models.py:122
|
||||
msgid "identity email address"
|
||||
msgstr "адреса електронної пошти особи"
|
||||
|
||||
#: build/lib/core/models.py:123 core/models.py:123
|
||||
#: core/models.py:126
|
||||
msgid "admin email address"
|
||||
msgstr "електронна адреса адміністратора"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
#: core/models.py:132
|
||||
msgid "language"
|
||||
msgstr "мова"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: core/models.py:133
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Мова, якою користувач хоче бачити інтерфейс."
|
||||
|
||||
#: build/lib/core/models.py:138 core/models.py:138
|
||||
#: core/models.py:141
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовий пояс, в якому користувач хоче бачити час."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid "device"
|
||||
msgstr "пристрій"
|
||||
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
#: core/models.py:146
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Чи є користувач пристроєм чи реальним користувачем."
|
||||
|
||||
#: build/lib/core/models.py:146 core/models.py:146
|
||||
#: core/models.py:149
|
||||
msgid "staff status"
|
||||
msgstr "статус співробітника"
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
#: core/models.py:151
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Чи може користувач увійти на цей сайт адміністратора."
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:157
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:164
|
||||
msgid "allow conversation analytics"
|
||||
msgstr "дозволити аналітику бесіди"
|
||||
|
||||
#: build/lib/core/models.py:163 core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "Whether the user allows to use their conversations for analytics."
|
||||
msgstr "Чи дозволяє користувач використовувати свої розмови для аналітики."
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: core/models.py:170
|
||||
msgid "allow smart web search"
|
||||
msgstr "дозволити розумний пошук в Інтернеті"
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "Whether the user allows to use smart web search features."
|
||||
msgstr "Чи дозволяє користувач використовувати розумні пошукові функції, пов'язані з Інтернетом."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
|
||||
+53
-33
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "conversations"
|
||||
version = "0.0.10"
|
||||
version = "0.0.14"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -23,49 +23,51 @@ description = "An application to chat with your own AI."
|
||||
keywords = ["Django", "AI", "Chatbot", "OpenAI", "Pydantic AI", "Conversations"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
requires-python = "~=3.14.3"
|
||||
dependencies = [
|
||||
"deprecated",
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.73",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.68",
|
||||
"Brotli==1.2.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.18",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-parler==2.3",
|
||||
"django-pydantic-field==0.4.0",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.9",
|
||||
"django==6.0.3",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.10.0",
|
||||
"gunicorn==25.1.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"jsonschema==4.26.0",
|
||||
"langfuse==4.0.0",
|
||||
"lxml==5.4.0",
|
||||
"markdown==3.10",
|
||||
"markdown==3.10.2",
|
||||
"markitdown==0.0.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==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",
|
||||
"posthog==7.9.12",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.68.0",
|
||||
"psycopg[binary]==3.3.3",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"semchunk==3.2.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"sentry-sdk==2.54.0",
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"uvicorn==0.41.0",
|
||||
"whitenoise==6.12.0",
|
||||
"pypdf==6.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -76,27 +78,27 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"dirty-equals==0.10.0",
|
||||
"dirty-equals==0.11",
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"pylint-django==2.6.1",
|
||||
"ipython==9.11.0",
|
||||
"pyfakefs==6.1.5",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint==3.3.9",
|
||||
"pylint-pydantic==0.4.1",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.1",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"responses==0.26.0",
|
||||
"respx==0.22.0",
|
||||
"ruff==0.14.5",
|
||||
"types-requests==2.32.4.20250913",
|
||||
"ruff==0.15.6",
|
||||
"types-requests==2.32.4.20260107",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -106,6 +108,24 @@ zip-safe = true
|
||||
[tool.distutils.bdist_wheel]
|
||||
universal = true
|
||||
|
||||
[tool.uv]
|
||||
required-environments = [
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
"sys_platform == 'darwin'",
|
||||
]
|
||||
override-dependencies = [
|
||||
"cryptography>=46.0.5", # CVE-2026-26007
|
||||
"pillow>=12.1.1", #CVE-2026-25990
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-root = ""
|
||||
source-exclude = [
|
||||
"**/tests/**",
|
||||
"**/test_*.py",
|
||||
"**/tests.py",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".git",
|
||||
@@ -137,8 +157,8 @@ select = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","conversations","first-party","local-folder"]
|
||||
sections = { conversations=["core"], django=["django"] }
|
||||
section-order = ["future", "standard-library", "django", "third-party", "conversations", "first-party", "local-folder"]
|
||||
sections = { conversations = ["core"], django = ["django"] }
|
||||
extra-standard-library = ["tomllib"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Setup file for the conversations module. All configuration stands in the setup.cfg file."""
|
||||
# coding: utf-8
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Utility functions for OIDC token management."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
def refresh_access_token(session):
|
||||
"""Refresh the OIDC access token using the refresh token."""
|
||||
refresh_token = get_oidc_refresh_token(session)
|
||||
if not refresh_token:
|
||||
raise AuthenticationFailed({"error": "Refresh token is missing from session"})
|
||||
|
||||
response = requests.post(
|
||||
settings.OIDC_OP_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": settings.OIDC_RP_CLIENT_ID,
|
||||
"client_secret": settings.OIDC_RP_CLIENT_SECRET,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_info = response.json()
|
||||
|
||||
store_tokens(
|
||||
session,
|
||||
access_token=token_info.get("access_token"),
|
||||
id_token=None,
|
||||
refresh_token=token_info.get("refresh_token"),
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def with_fresh_access_token(func):
|
||||
"""
|
||||
Decorator to handle OIDC token refresh and extraction.
|
||||
Expects 'session' in kwargs and update it with the fresh token.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
session = kwargs.pop("session", None)
|
||||
if session is None:
|
||||
raise AuthenticationFailed({"error": "Session is required but not provided"})
|
||||
refreshed_session = refresh_access_token(session)
|
||||
return func(*args, session=refreshed_session, **kwargs)
|
||||
|
||||
return wrapper
|
||||
Generated
+3182
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['conversations/next'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import { nextConfig } from 'eslint-config-conversations/next.mjs';
|
||||
|
||||
export default nextConfig({
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
nextRootDir: import.meta.dirname,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -8,38 +8,36 @@
|
||||
"build:ci": "cp .env.development .env.local && yarn build",
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
"lint": "tsc --noEmit && next lint",
|
||||
"lint": "tsc --noEmit && eslint src/",
|
||||
"lint:fix": "tsc --noEmit && eslint src/ --fix",
|
||||
"prettier": "prettier --write .",
|
||||
"stylelint": "stylelint \"**/*.css\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@ai-sdk/react": "1.2.12",
|
||||
"@ai-sdk/ui-utils": "1.2.11",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/cunningham-react": "^4.2.0",
|
||||
"@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",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@sentry/nextjs": "9.26.0",
|
||||
"@shikijs/rehype": "^3.21.0",
|
||||
"@tanstack/react-query": "5.80.5",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"emoji-mart": "5.6.0",
|
||||
"i18next": "25.2.1",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"lottie-react": "^2.4.1",
|
||||
"luxon": "3.6.1",
|
||||
"micromark-extension-llm-math": "3.1.1-20250610",
|
||||
"next": "15.3.8",
|
||||
"next": "15.3.9",
|
||||
"posthog-js": "1.249.3",
|
||||
"react": "19.2.1",
|
||||
"react-aria-components": "1.9.0",
|
||||
@@ -47,12 +45,10 @@
|
||||
"react-i18next": "15.5.2",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-select": "5.10.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"shiki": "^3.13.0",
|
||||
"shiki": "^3.21.0",
|
||||
"styled-components": "6.1.18",
|
||||
"use-debounce": "10.0.4",
|
||||
"zod": "^3.25.67",
|
||||
@@ -79,6 +75,7 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"sass": "^1.97.3",
|
||||
"stylelint": "16.20.0",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
|
||||
@@ -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 |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user