Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1401a1bc1b | |||
| cb2bfd53cc | |||
| f5a1b72913 | |||
| 9e37f71818 | |||
| 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 |
@@ -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
|
||||
|
||||
|
||||
+84
-2
@@ -8,15 +8,94 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(back) add projects with custom LLM instructions
|
||||
- ✨(front) projects management UI
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) add missing color option for project colors
|
||||
|
||||
## [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
|
||||
|
||||
@@ -175,8 +254,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
|
||||
|
||||
@@ -231,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
|
||||
|
||||
+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]
|
||||
|
||||
@@ -117,17 +117,33 @@ class ConversationAgent(BaseAgent):
|
||||
"""Dynamic instruction function to set the expected language to use."""
|
||||
return f"Answer in {get_language_name(language).lower()}." if language else ""
|
||||
|
||||
def get_web_search_tool_name(self) -> str | None:
|
||||
def is_web_search_configured(self) -> bool:
|
||||
"""
|
||||
Get the name of the web search tool if available.
|
||||
Return True when a web search backend is configured on this model.
|
||||
|
||||
If several are available, return the first one found.
|
||||
|
||||
Warning, this says the tool is available, not that
|
||||
it (the tool/feature) is enabled for the current conversation.
|
||||
This does not mean web search is enabled for the current conversation
|
||||
(feature flags and runtime deps still apply).
|
||||
"""
|
||||
for toolset in self.toolsets:
|
||||
for tool in toolset.tools.values():
|
||||
if tool.name.startswith("web_search_"):
|
||||
return tool.name
|
||||
return None
|
||||
return bool(getattr(self.configuration, "web_search", 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()
|
||||
@@ -125,6 +125,7 @@ class LLModel(BaseModel):
|
||||
supports_streaming: bool | None = None
|
||||
system_prompt: SettingEnvValue
|
||||
tools: list[str]
|
||||
web_search: SettingEnvValue | None = None
|
||||
|
||||
@field_validator("tools", mode="before")
|
||||
@classmethod
|
||||
@@ -134,6 +135,14 @@ class LLModel(BaseModel):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@field_validator("web_search", mode="before")
|
||||
@classmethod
|
||||
def validate_web_search(cls, value: str | None) -> str | None:
|
||||
"""Convert web_search path if it's a setting or environment variable."""
|
||||
if isinstance(value, str):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_provider_or_provider_name(self) -> Self:
|
||||
"""
|
||||
|
||||
@@ -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,136 @@
|
||||
# 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"),
|
||||
("color_10", "Color 10"),
|
||||
],
|
||||
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,74 @@ 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"
|
||||
COLOR_10 = "color_10", "Color 10"
|
||||
|
||||
|
||||
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 +112,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 +147,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()
|
||||
@@ -3,14 +3,12 @@
|
||||
# pylint:disable=protected-access
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.clients.pydantic_ai import ContextDeps
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -87,47 +85,30 @@ def test_add_dynamic_system_prompt():
|
||||
assert agent._instructions[2]() == "Answer in french."
|
||||
|
||||
|
||||
def test_agent_get_web_search_tool_name(settings):
|
||||
"""Test the web_search_available method."""
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_albert_rag"]
|
||||
def test_agent_is_web_search_configured():
|
||||
"""Test whether web search backend is configured on the model."""
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() == "web_search_albert_rag"
|
||||
assert agent.is_web_search_configured() is False
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather"]
|
||||
|
||||
def test_agent_is_web_search_configured_when_defined_in_model_config(settings):
|
||||
"""Web search is configured when LLModel.web_search is set."""
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"default-model": LLModel(
|
||||
hrid="default-model",
|
||||
model_name="model-123",
|
||||
human_readable_name="Default Model",
|
||||
is_active=True,
|
||||
icon=None,
|
||||
system_prompt="You are a helpful assistant",
|
||||
tools=[],
|
||||
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
provider=LLMProvider(
|
||||
hrid="default-provider",
|
||||
base_url="https://api.llm.com/v1/",
|
||||
api_key="test-key",
|
||||
),
|
||||
),
|
||||
}
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() is None
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_tavily", "web_search_albert_rag"]
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() == "web_search_tavily"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_web_search_tool_avalability(settings):
|
||||
"""Test the web search tool availability according to context."""
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://api.tavily.com/search",
|
||||
json={"results": []},
|
||||
status=200,
|
||||
)
|
||||
context_deps = ContextDeps(conversation=None, user=None, web_search_enabled=True)
|
||||
|
||||
# No tools (context allows web search, but no tool configured)
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
# Tool configured, context allows web search
|
||||
settings.AI_AGENT_TOOLS = ["web_search_tavily"]
|
||||
agent = ConversationAgent(model_hrid="default-model") # re-init to pick up new settings
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == '{"web_search_tavily":[]}'
|
||||
|
||||
# Tool configured, context disables web search
|
||||
context_deps.web_search_enabled = False
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
assert agent.is_web_search_configured() is True
|
||||
|
||||
@@ -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,100 @@
|
||||
"""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="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
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
|
||||
|
||||
service._setup_web_search_tool()
|
||||
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
|
||||
|
||||
service._setup_web_search_tool()
|
||||
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" 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
|
||||
|
||||
# Match _run_agent: register the tool first, then enable deps + forced prompt.
|
||||
service._setup_web_search_tool()
|
||||
service._setup_web_search(force_web_search=True)
|
||||
|
||||
assert service.conversation_agent.is_web_search_configured() is True
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
assert any(
|
||||
callable(instr) and "web_search" 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" 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 (
|
||||
@@ -16,13 +21,14 @@ from chat.tools.web_search_brave import (
|
||||
_extract_and_summarize_snippets_async,
|
||||
_fetch_and_extract_async,
|
||||
_fetch_and_store_async,
|
||||
_query_brave_api_async,
|
||||
_query_brave_llm_context_api_async,
|
||||
format_tool_return,
|
||||
web_search_brave,
|
||||
web_search_brave_with_document_backend,
|
||||
)
|
||||
|
||||
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_LLM_CONTEXT_URL = "https://api.search.brave.com/res/v1/llm/context"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -38,9 +44,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 +51,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 = {}
|
||||
@@ -58,7 +68,7 @@ def fixture_mocked_context():
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results with extra_snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -113,7 +123,7 @@ async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_without_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results without extra_snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -159,7 +169,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
"""Test when the Brave search returns results without extra_snippets with summarization."""
|
||||
settings.BRAVE_SUMMARIZATION_ENABLED = True
|
||||
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -207,7 +217,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
"""Test when the Brave search returns no results."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -224,7 +234,7 @@ async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_http_error(mocked_context):
|
||||
"""Test handling of HTTP errors from Brave API."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
@@ -247,7 +257,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
settings.BRAVE_SEARCH_COUNTRY = None
|
||||
settings.BRAVE_SEARCH_LANG = None
|
||||
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -281,7 +291,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_concurrent_processing(mocked_context):
|
||||
"""Test concurrent processing with asyncio.gather."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -392,7 +402,7 @@ async def test_extract_and_summarize_snippets_summarization_failure(settings):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend with successful RAG search."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -443,7 +453,7 @@ async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_fetch_error(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document fetching fails."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -486,7 +496,7 @@ async def test_web_search_brave_with_document_backend_no_matching_rag_results(mo
|
||||
|
||||
This is actually a problematic scenario, but we want to ensure graceful handling.
|
||||
"""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -556,37 +566,37 @@ async def test_fetch_and_store_empty_document():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_web_key():
|
||||
"""Test _query_brave_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"error": "no web results"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await _query_brave_api_async("query")
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_results_key():
|
||||
"""Test _query_brave_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {}},
|
||||
)
|
||||
)
|
||||
result = await _query_brave_api_async("query")
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_rate_limit():
|
||||
"""Test _query_brave_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=429,
|
||||
json={"error": "Rate limit exceeded"},
|
||||
@@ -594,7 +604,7 @@ async def test_query_brave_api_rate_limit():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "rate limited" in str(exc.value).lower()
|
||||
|
||||
@@ -602,8 +612,8 @@ async def test_query_brave_api_rate_limit():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_client_error():
|
||||
"""Test _query_brave_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
"""Test _query_brave_llm_context_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=400,
|
||||
json={"error": "Bad request"},
|
||||
@@ -611,7 +621,7 @@ async def test_query_brave_api_client_error():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "client error" in str(exc.value).lower()
|
||||
|
||||
@@ -619,11 +629,11 @@ async def test_query_brave_api_client_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_timeout():
|
||||
"""Test _query_brave_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
"""Test _query_brave_llm_context_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "timed out" in str(exc.value).lower()
|
||||
|
||||
@@ -631,11 +641,11 @@ async def test_query_brave_api_timeout():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_generic_http_error():
|
||||
"""Test _query_brave_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
"""Test _query_brave_llm_context_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "connection error" in str(exc.value).lower()
|
||||
|
||||
@@ -643,11 +653,11 @@ async def test_query_brave_api_generic_http_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_unexpected_error():
|
||||
"""Test _query_brave_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
"""Test _query_brave_llm_context_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_api_async("query")
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
|
||||
assert "unexpected error" in str(exc.value).lower()
|
||||
|
||||
@@ -759,7 +769,7 @@ async def test_fetch_and_store_extraction_error():
|
||||
@respx.mock
|
||||
async def test_web_search_brave_mixed_results(mocked_context):
|
||||
"""Test web_search_brave with mixed results (some with snippets, some without)."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -803,7 +813,7 @@ async def test_web_search_brave_mixed_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
"""Test web_search_brave when extraction fails for all results without snippets."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -836,7 +846,7 @@ async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
"""Test web_search_brave handling of ModelCannotRetry from API."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=403,
|
||||
json={"error": "Forbidden"},
|
||||
@@ -854,7 +864,7 @@ async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -885,7 +895,7 @@ async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when RAG search returns empty results."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -920,7 +930,7 @@ async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_c
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_store_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document store raises exception."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -949,7 +959,7 @@ async def test_web_search_brave_with_document_backend_store_exception(mocked_con
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
|
||||
result = await web_search_brave_with_document_backend(mocked_context, "error query")
|
||||
assert result == (
|
||||
@@ -962,7 +972,7 @@ async def test_web_search_brave_with_document_backend_unexpected_exception(mocke
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of ModelCannotRetry."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=401,
|
||||
json={"error": "Unauthorized"},
|
||||
@@ -980,7 +990,7 @@ async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_rag_search_params(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend passes correct parameters to RAG search."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -1011,7 +1021,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,109 @@
|
||||
"""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."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ChatProjectColor)
|
||||
def test_create_project_with_each_color(api_client, color):
|
||||
"""Test creating a project with each available color."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "Color Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": color,
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.color == color
|
||||
|
||||
|
||||
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
|
||||
@@ -1,46 +1,13 @@
|
||||
"""Tools for the chat agent."""
|
||||
|
||||
from pydantic_ai import Tool, ToolDefinition
|
||||
|
||||
from pydantic_ai import Tool # noqa: I001
|
||||
from .fake_current_weather import get_current_weather
|
||||
from .web_seach_albert_rag import web_search_albert_rag
|
||||
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
|
||||
from .web_search_tavily import web_search_tavily
|
||||
|
||||
|
||||
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
|
||||
"""Prepare function to include a tool only if web search is enabled in the context."""
|
||||
return tool_def if ctx.deps.web_search_enabled else None
|
||||
|
||||
|
||||
def get_pydantic_tools_by_name(name: str) -> Tool:
|
||||
"""Get a tool by its name."""
|
||||
tool_dict = {
|
||||
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
|
||||
"web_search_brave": Tool(
|
||||
web_search_brave,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_brave_with_document_backend": Tool(
|
||||
web_search_brave_with_document_backend,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_tavily": Tool(
|
||||
web_search_tavily,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_albert_rag": Tool(
|
||||
web_search_albert_rag,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
}
|
||||
|
||||
return tool_dict[name] # will raise on purpose if name is not found
|
||||
|
||||
@@ -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,29 +136,39 @@ 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
|
||||
|
||||
|
||||
async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
"""Query the Brave Search API and return the raw results."""
|
||||
url = "https://api.search.brave.com/res/v1/web/search"
|
||||
def _normalize_llm_context_results(json_response: dict) -> List[dict]:
|
||||
"""Normalize Brave LLM context payload into our common result shape."""
|
||||
generic_results = json_response.get("grounding", {}).get("generic", []) or []
|
||||
normalized_results: List[dict] = []
|
||||
for item in generic_results:
|
||||
item_url = item.get("url")
|
||||
if not item_url:
|
||||
continue
|
||||
|
||||
normalized_results.append(
|
||||
{
|
||||
"url": item_url,
|
||||
# Fallback to URL if no title is provided
|
||||
"title": item.get("title") or item_url,
|
||||
# `snippets` is already a list
|
||||
"snippets": item.get("snippets") or [],
|
||||
}
|
||||
)
|
||||
return normalized_results
|
||||
|
||||
|
||||
async def _query_brave_api_with_endpoint_async(url: str, data: dict) -> List[dict]:
|
||||
"""Query a Brave endpoint and return raw results normalized to our schema."""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Subscription-Token": settings.BRAVE_API_KEY,
|
||||
}
|
||||
data = {
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
}
|
||||
params = {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
try:
|
||||
@@ -167,6 +177,12 @@ async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
response.raise_for_status()
|
||||
json_response = response.json()
|
||||
|
||||
# LLM context API: results are under `grounding.generic`
|
||||
# See: https://api-dashboard.search.brave.com/documentation/services/llm-context
|
||||
if "grounding" in json_response:
|
||||
return _normalize_llm_context_results(json_response)
|
||||
|
||||
# Fallback for classic web search JSON shape
|
||||
# https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
|
||||
return json_response.get("web", {}).get("results", [])
|
||||
|
||||
@@ -209,44 +225,99 @@ async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
) from e
|
||||
|
||||
|
||||
async def _query_brave_llm_context_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave LLM context endpoint and return normalized results."""
|
||||
logger.debug("Using LLM context endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/llm/context",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
"maximum_number_of_urls": settings.BRAVE_MAX_RESULTS,
|
||||
"maximum_number_of_tokens": settings.BRAVE_MAX_TOKENS,
|
||||
"maximum_number_of_snippets": settings.BRAVE_MAX_SNIPPETS,
|
||||
"maximum_number_of_snippets_per_url": settings.BRAVE_MAX_SNIPPETS_PER_URL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _query_brave_web_search_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave classic web search endpoint and return normalized results."""
|
||||
logger.debug("Using classic web search endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
|
||||
"""Format the raw search results into a ToolReturn object."""
|
||||
"""Build the tool payload from Brave results.
|
||||
|
||||
Keep only sources that have non-empty snippets and prefer `snippets` over
|
||||
`extra_snippets` when both are present.
|
||||
"""
|
||||
formatted_results = {}
|
||||
sources = set()
|
||||
|
||||
for idx, result in enumerate(raw_search_results):
|
||||
logger.debug("Formatting result: %s", result)
|
||||
snippets = result.get("snippets") or result.get("extra_snippets") or []
|
||||
if not snippets:
|
||||
continue
|
||||
|
||||
formatted_results[str(idx)] = {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": snippets,
|
||||
}
|
||||
sources.add(result["url"])
|
||||
|
||||
return ToolReturn(
|
||||
# Format return value "mistral-like": https://docs.mistral.ai/capabilities/citations/
|
||||
return_value={
|
||||
str(idx): {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": result.get("extra_snippets", []),
|
||||
}
|
||||
for idx, result in enumerate(raw_search_results)
|
||||
if result.get("extra_snippets", [])
|
||||
},
|
||||
metadata={
|
||||
"sources": {
|
||||
result["url"] for result in raw_search_results if result.get("extra_snippets", [])
|
||||
}
|
||||
},
|
||||
return_value=formatted_results,
|
||||
metadata={"sources": sources},
|
||||
)
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information
|
||||
Search the web for up-to-date information.
|
||||
This function use the classic websearch endpoint of the Brave API.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then summarized using the LLM summarization agent.
|
||||
The results are then formatted and returned.
|
||||
|
||||
Args:
|
||||
_ctx (RunContext): The run context, used by the wrapper.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.debug("Starting classic web search without RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
|
||||
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
|
||||
|
||||
# Parallelize fetch/extract for results that don't include extra_snippets
|
||||
# Parallelize fetch/extract only for results that don't already include any snippets
|
||||
# (neither Brave `snippets` nor `extra_snippets`).
|
||||
to_process = [
|
||||
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
|
||||
(idx, r)
|
||||
for idx, r in enumerate(raw_search_results)
|
||||
if not r.get("extra_snippets") and not r.get("snippets")
|
||||
]
|
||||
|
||||
if to_process:
|
||||
@@ -283,18 +354,45 @@ async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_llm_context(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web using Brave LLM context endpoint (no RAG post-processing).
|
||||
This function use the LLM context endpoint of the Brave API.
|
||||
The results are then formatted and returned.
|
||||
"""
|
||||
logger.debug("Starting web search with LLM context endpoint for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_llm_context_api_async(query)
|
||||
formatted_result = format_tool_return(raw_search_results)
|
||||
if not formatted_result.return_value:
|
||||
raise ModelRetry("No valid search results were extracted from Brave LLM context.")
|
||||
return formatted_result
|
||||
except (ModelCannotRetry, ModelRetry):
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error in web_search_brave_llm_context: %s", exc)
|
||||
raise ModelCannotRetry(
|
||||
f"An unexpected error occurred during web search: {type(exc).__name__}. "
|
||||
"You must explain this to the user and not try to answer based on your knowledge."
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information using RAG backend
|
||||
Search the web for up-to-date information using RAG backend.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then stored in a temporary document store for RAG search.
|
||||
The RAG search is then performed and the results are returned.
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.info("Starting web search with RAG backend for query: %s", query)
|
||||
logger.debug("Starting web search with RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
|
||||
# Clear trafilatura caches in thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -307,21 +405,28 @@ 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)
|
||||
logger.debug("RAG search returned: %s", rag_results)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -74,3 +74,20 @@ class BraveSettings:
|
||||
environ_name="BRAVE_SEARCH_EXTRA_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# LLM context endpoint limits
|
||||
BRAVE_MAX_TOKENS = values.IntegerValue(
|
||||
default=8192,
|
||||
environ_name="BRAVE_MAX_TOKENS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS = values.IntegerValue(
|
||||
default=50,
|
||||
environ_name="BRAVE_MAX_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS_PER_URL = values.IntegerValue(
|
||||
default=10,
|
||||
environ_name="BRAVE_MAX_SNIPPETS_PER_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
@@ -347,6 +368,7 @@ class Base(BraveSettings, Configuration):
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
]
|
||||
|
||||
# Cache
|
||||
@@ -395,6 +417,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 +673,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 +708,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 +749,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 +819,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 +923,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 +1010,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 +1020,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 +1145,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 +1274,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 "користувачі"
|
||||
|
||||
|
||||
+31
-10
@@ -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,7 +23,7 @@ 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.13.0"
|
||||
dependencies = [
|
||||
"deprecated",
|
||||
"beautifulsoup4==4.14.2",
|
||||
@@ -33,19 +33,20 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.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==5.2.12",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.10.0",
|
||||
"lxml==5.4.0",
|
||||
@@ -55,17 +56,18 @@ dependencies = [
|
||||
"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",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.62.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.10.1",
|
||||
"PyJWT==2.12.0",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"requests==2.33.0",
|
||||
"semchunk==3.2.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"pypdf==6.9.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -106,6 +108,25 @@ 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
|
||||
"joserfc>=1.6.3", # CVE-2026-27932
|
||||
"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 +158,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
+3150
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,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user