Compare commits

...

123 Commits

Author SHA1 Message Date
renovate[bot] 1401a1bc1b ⬆️(dependencies) update requests to v2.33.0 [SECURITY] 2026-03-26 14:33:32 +00:00
camilleAND cb2bfd53cc (websearch) add Brave llm/context snippets
Use llm/context endpoint with snippets, change tool name for web_search

Signed-off-by: camilleAND <camille.andre@modernisation.gouv.fr>
2026-03-25 12:01:29 +01:00
Laurent Paoletti f5a1b72913 🐛(back) add missing color option for project colors
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-24 15:12:56 +01:00
Laurent Paoletti 9e37f71818 ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-23 18:04:22 +01:00
Laurent Paoletti 6dd41e827e (front) projects management UI
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-17 10:49:56 +01:00
Laurent Paoletti f6edb14e1f ♻️(front) clean up zustand stores
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-16 16:41:08 +01:00
Laurent Paoletti bcf55c6c0c (back) add projects with custom LLM instructions
Introduce the project concept for conversations:
- Add nested project serializer for search results (`?title=` returns
project id, title, icon)
- Inject project-level custom LLM instructions into the AI agent systemprompt
- Document `title` and `project` query parameters in OpenAPI schema
- Fix ChatProjectFactory deprecation warning
- Add db compound indexes on ChatConversation for and ChatProject to speed
up sidebar queries

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-16 09:50:10 +01:00
Eléonore Voisin 2f74543d41 (search) add searchModal
fix ui, fix settings button
2026-03-12 17:53:19 +01:00
Laurent Paoletti 995ad407a6 (search) add search modal with improved UX
- Add search modal and modify left panel layout
- Fix result list flashing by keeping previous data during refetch
- Defer loader display to avoid search icon flickering on fast responses
- Add Cmd+K / Ctrl+K shortcut to toggle search modal
- Auto-focus search input on modal open
- Require minimum 3 characters before triggering search
- Only mount modal when authenticated and open
- Extract shared getRelativeTime utility
- Remove unused controlled selection state in QuickSearch

Co-Authored-By: Eléonore Voisin <elevoisin@gmail.com
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-12 16:31:03 +01:00
providenz 0746d23a1e 🌐(i18n) update translated strings
Update translated files with new translations

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-12 09:17:35 +01:00
Laurent Paoletti c699d7503d 🔖(patch) bump release to 0.0.14
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-11 16:20:40 +01:00
Laurent Paoletti bc7568ccf1 ⬆️(back) update django and pypdf
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-11 15:12:22 +01:00
eliott07 c17a8ae9b9 🐛(frontend) fix inverted toast message for settings toggles
fixes inverted toast introduced in smart search PR

Remove fixed inverted toast entry from changelog
2026-03-05 13:58:38 +01:00
eliott07 4545083210 (user) allow disabling automatic internet search
Added user preference to disable automatic tool calls to internet.
Preference is stored in user model, can be changed via frontend setting and
prevents the tool from showing up unless
'Search on the web' is explicitly enabled

Signed-off-by: eliott07 <eliott07@gmail.com>

🐛(settings) fix smart web search default and improve settings layout

Updated default value of smart search, adjusted settings modal layout & spacing
2026-02-26 16:27:56 +01:00
natoromano 1088d88aba 🚑️(back) fix mime type for pptx
Fix wrong mime type in config (for pptx)
2026-02-25 17:33:16 +01:00
Laurent Paoletti 99353357ff ♻️(front) migrate from ESLint 8 to ESLint 9 flat config
- Replace .eslintrc.js files with eslint.config.mjs (flat config)
  - Upgrade eslint from 8.57.0 to ^9.0.0
  - Replace eslint-plugin-import with eslint-plugin-import-x
  - Replace eslint-config-next with eslint-plugin-react +
  eslint-plugin-react-hooks
  - Convert shared config modules from .js (CJS) to .mjs (ESM)
  - Update lint scripts to use `eslint src/` instead of `next lint`

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-25 10:32:49 +01:00
Laurent Paoletti 8feed1e3ac ⬆️(back) update django-pydantic-field and pypdf
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-24 21:27:08 +01:00
Laurent Paoletti 61f86e18df ⬆️(back) update pillow
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-24 20:15:55 +01:00
Laurent Paoletti 2b8123462a 🐛(front) fix dark mode styling on chat messages
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-24 17:58:48 +01:00
Quentin BEY 3c3eaf3eeb 🚸(oidc) ignore case when fallback on email
Some identity providers might change the case, but in our
products we don't consider case variation to be consider as
different email addresses.

Next step would be to normalize the DB value of email to
be lower-case.
2026-02-24 16:07:21 +01:00
Quentin BEY e925bff1a2 👷(crowdin) use uv instead of pip
This garanties consistency across actions
2026-02-24 16:05:01 +01:00
Laurent Paoletti ba712af899 ♻️(back) refactor AIAgentService for readability and maintainability
Extract large _run_agent method into smaller focused methods
Add comprehensive module and method documentation
Minor fixes

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-19 23:31:28 +01:00
Manuel Raynaud e60c938d44 🐛(helm) reverse liveness and readiness for backend deployment
The liveness and readiness are reversed. The liveness was using the
heartbeat process that is cheking all django checks and the database
connection.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-19 19:46:45 +01:00
Eléonore Voisin 5d895d15f9 (table) temporarily adjust array
temporarily adjust css table
2026-02-18 09:56:27 +01:00
Eléonore Voisin 96a2963adf (waffle) hide the waffle if not fr theme
show waffe only on fr theme
2026-02-16 18:20:49 +01:00
Laurent Paoletti 224e6f83fd ⬆️(back) update pydantic-ai
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-16 11:11:27 +01:00
Laurent Paoletti af6facf4a3 ️(front) optimize streaming markdown rendering with block splitting
Split streaming content into independent memoized blocks to avoid
re-rendering all markdown/katex/syntax-highlighting on each update.
Add some tests for split functions and components.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-16 09:46:23 +01:00
natoromano 9d16460777 ⬆️ (fix): redo after rebase
(front) add e2e pasting an attachment from clipboard

add test e2e paste attachment
2026-02-13 11:53:17 +01:00
Laurent Paoletti 09dceb508d 🐛(front) fix math formulas and carousel translations
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-10 10:23:11 +01:00
Laurent Paoletti 63e0e6c383 💚(docker) vendor mime.types file i/o fetching from Apache
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-10 09:45:25 +01:00
providenz a919d9a63b 🌐(i18n) update translated strings
Update translated files with new translations

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-09 15:14:24 +01:00
Laurent Paoletti 9506df341a 🔖(patch) bump release to 0.0.13
- 💄(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

- 💄(darkmode) change color feedback butto
- 🏗️(back) migrate to uv
- ♻️(front) optimize syntax highlighting bundle size

-  🐛(back) Cast collection Ids to API expected types

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-09 15:10:10 +01:00
Laurent Paoletti c87c734e98 🐛(back) cast collection Ids to API expected types
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-05 14:56:38 +01:00
Eléonore Voisin 41ade65a49 💄(darkmode) change color feedback button
change the color of the feedback button & add a dark mode illustration
2026-02-05 10:41:58 +01:00
Quentin BEY fd372e1c56 🧱(helm) mount the LLM json config file in jobs
The backend might do some consistency checks for settings,
some of them are the proper LLM configuration...
2026-02-04 21:41:39 +01:00
Laurent Paoletti 3bbe7fb125 (back) Add adaptive pdf parser (text extraction /ocr)
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-04 16:01:06 +01:00
Eléonore Voisin 082937e107 💄(front) ui fix : update ui-kit - fix Marianne font
upgrade package ui-kit
add darkmode logo gouv
2026-02-04 11:57:59 +01:00
Laurent Paoletti 54d2d3e807 ⬆️(back) update django
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-03 23:19:13 +01:00
Quentin BEY 23964cbf12 ♻️(front) optimize syntax highlighting bundle size
Replace rehype-pretty-code with direct Shiki integration

Migrate syntax highlighting from rehype-pretty-code to @shikijs/rehype for
better bundle optimization. Create a centralized Shiki highlighter that
pre-loads only required languages.

Also remove unused dependencies: @ag-media/react-pdf-table,
@react-pdf/renderer, react-select.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-02-03 21:30:42 +01:00
Eléonore Voisin ff9e8335f2 (front) add persistent darkmode
add persistent darkmode and change settings modal
2026-02-03 17:37:27 +01:00
Quentin BEY 8c62f5d933 🐛(find) allow the chat completion view w/o access token
Find requires access and refresh tokens, but the current
beahavior using Albert API does not. We need to be able to
still use the completion endpoint without any stored
token.
2026-02-03 10:20:56 +01:00
charles 88bdcc2e60 (backend) handle deleting temporary collections
I handle deleting document in temporary collection
for web_search_brave_with_document_backend
2026-02-02 22:27:26 +01:00
charles 1c573ad3a7 ♻️(backend) refactor document parsers
I refactor document parsing by introducing
AlbertParser and BaseParser
2026-02-02 22:27:26 +01:00
Quentin BEY 3106d5f25f (backend) enhance Find API integration with user sub and tag
I enhance Find API integration with user
access control and configuration options
2026-02-02 22:27:26 +01:00
charles 23fa1d6b9e (backend) implement FindRagBackend
We want to be able to use Find api in rag tools.
I add a new rag backend class to do so.
2026-02-02 22:27:26 +01:00
Eléonore Voisin 8ed72fb305 (front) fix hover button darkmode
fix buttons colors missing -> source item, feedback buttons
2026-02-02 14:18:58 +01:00
Eléonore Voisin c231e69871 (front) add ui kit with dark mode
Add UI kit and dark mode
Fix missing color variables
2026-02-02 11:00:31 +01:00
Quentin BEY fb297b97e6 🔇(ci) don't run trivy on mail generation yarn.lock
We don't use email generation for now, and we don't expose
anything from this yarn file.
2026-01-30 14:58:51 +01:00
Quentin BEY 7858476b84 ⬆️(next) bump version to 5.3.9
Fixes GHSA-h25m-26qc-wcjf
2026-01-30 14:47:47 +01:00
Quentin BEY c02254ec93 ⬆️(protobuf) bump version to 6.33.5
Bump with `uv lock --upgrade-package protobuf`
This fixes CVE-2026-0994
2026-01-30 14:11:27 +01:00
Quentin BEY 853305ae74 🧱(files) allow to use S3 storage without external access
Some architectures do not expose their S3, in such cases
it is only available through the backend.

This commit proposes two implementations to manage this:
- frontend can now upload files to the backend (no direct access
  to S3)
- two new modes to send file to the LLM: a temporary URL on the
  backend, or directly the file in b64.
2026-01-29 22:30:15 +01:00
Laurent Paoletti ab2ad0348b 🏗️(back) migrate to uv
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-29 15:27:49 +01:00
qbey d752334b5e 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-27 16:41:55 +01:00
Quentin BEY 30f825c337 🔖(patch) bump release to 0.0.12
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
2026-01-27 16:37:54 +01:00
natoromano f52a27f218 (front) i18n and standardize pdf parsing display
fix: align i18n key

🌐fix: keep filename

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-27 12:13:36 +01:00
Laurent Paoletti 3e467cacf2 🐛(back) fix keepalives not sent during document parsing
Wrap document_store.parse_and_store_document() calls with
  asyncio.to_thread() to prevent blocking the event loop.

  Previously, synchronous document parsing (e.g., PDF) blocked the
  event loop, preventing keepalive messages from being sent and
  causing nginx timeouts in production

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-26 16:20:25 +01:00
Laurent Paoletti 120b204729 ️(front) chat input performance improvements
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-22 12:19:27 +01:00
charles d9078e75e5 🚨(backend) fix tests
I am removing hard coded datetime.
2026-01-22 11:39:47 +01:00
Quentin BEY 09b003856b 🔒️(node-packages) update fixed CVE packages
Trivy complains about some packages with fixed CVE,
we update them.
2026-01-19 16:09:07 +01:00
Quentin BEY 0b5317a773 🔒️(jaraco) enforce version to fix CVE
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see GHSA-58pv-8j8x-9vj2

The CVE is not actionable, anyway, we want to please
trivy.
2026-01-19 14:38:59 +01:00
Quentin BEY abf61a9556 🔥(chat) consider PDF documents as other kind of documents
We remove the specific management for PDF because it introduces:
 - limitation regarding the LLM we can use
 - bad behavior when uploading huge PDFs
 - more code complexity
while not providing really actionnable improvements.

This commit removes this, to keep a better control over this.
2026-01-19 14:04:32 +01:00
Laurent Paoletti 3e8c5c77d5 (chat) generate and edit conversation title
- Auto-generate title via LLM after reaching user message threshold
- Add title_set_by_user_at field to track user-customized titles
- Skip auto-generation when user has set a custom title
- Stream conversation_metadata event to frontend on title update
- Invalidate React Query cache to refresh conversation list

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-18 23:04:43 +01:00
Laurent Paoletti ddfc86a88f 🐛(back) stream tool responses to prevent too call timeouts
Implement sync/sync utilities that inject
keepalive messages at regular intervals during stream pauses,
preventing proxy timeouts on long-running operations like
document(s) summarization.

Keepalive messages maintain active connections while tools execute,
eliminating forced conversation restarts.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-17 13:50:35 +01:00
qbey e7d76e4477 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-16 12:13:55 +01:00
Quentin BEY fd3399dd66 🔖(patch) bump release to 0.0.11
Changed

- 📦️(front) update react

Fixed

- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
- ⚰️(back) remove dead code and unused files

Removed

- 🔥(chat) remove thinking part from frontend #227
2026-01-16 12:03:05 +01:00
Quentin BEY 13c6499c66 🔥(chat) remove thinking part from frontend
We want to enable the OSS model but seems like it returns
thinking values twice and we don't manage it well...

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

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

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

- (front) add retry button

Fixed

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

🐛(front) resolve React hydration errors

resolve React hydration errors and infinite update loop
2025-12-02 10:35:48 +01:00
Eléonore Voisin d3922b7448 (front) add retry button
on chat error add retry button
2025-12-01 19:21:34 +01:00
Quentin BEY cdac7cad3b 🚑️(user) allow longer short names
I don't know why it was so short, but a legit user is blocked.
2025-12-01 09:43:36 +01:00
qbey 93ee3cd10d 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-17 17:39:03 +01:00
Quentin BEY 91e1c73ccf 🔖(patch) bump release to 0.0.9
Added
- (front) add code copy button
- (RAG) add generic collection RAG tools #159

Fixed

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

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

Removed

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

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

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

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

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

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

=> burn the now useless middleware
=> use the user PK as distinct ID
2025-10-28 09:27:51 +01:00
Arnaud Robin 7d8b6fc07c 🚑️(stats) fix tracking id in upload event
Previously, upload event tracking used the user's email instead of
their user ID, preventing reliable association between uploads
and users.
2025-10-27 23:27:34 +01:00
Quentin BEY 2b96ba0597 🔧(django-lasuite) add new settings for back-channel logout
The latest version of the lib, allows to configure OIDC back-
channel logout which requires some specific settings.
While unused in our environment, this allows use by others.
2025-10-27 21:48:59 +01:00
Quentin BEY 34cf348f4c (pydantic-ai) update tests after last update
There is a new consistendy enforcement in the library, which
enforces each message to have a unique ID, therefor the UUID
mock fails (which was expected TBH).
2025-10-27 21:45:12 +01:00
Quentin BEY 0cce897c69 (django-lasuite) update tests after latest release
The library now does a `save` instead of an `update` which
triggers a unicity check.
2025-10-27 21:45:12 +01:00
renovate[bot] 7c8d8e9de7 ⬆️(dependencies) update python dependencies 2025-10-27 21:45:12 +01:00
375 changed files with 45438 additions and 14913 deletions
+4
View File
@@ -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,
+50 -20
View File
@@ -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,22 +185,48 @@ 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:
contents: read
security-events: write
runs-on: ubuntu-latest
steps:
- name: Run Trivy analysis for critical vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy-critical@main
with:
skip-files: src/mail/yarn.lock
security-trivy:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Run Trivy analysis for vulnerabilities
# We use main branch while we might still iterate on the action
uses: numerique-gouv/action-trivy-cache/security-trivy@main
with:
skip-files: src/mail/yarn.lock
+8 -9
View File
@@ -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
+2
View File
@@ -47,6 +47,7 @@ jobs:
docker-image-name: 'docker.io/lasuite/conversations-backend:${{ github.sha }}'
-
name: Build and push
if: always()
uses: docker/build-push-action@v6
with:
context: .
@@ -86,6 +87,7 @@ jobs:
docker-image-name: 'docker.io/lasuite/conversations-frontend:${{ github.sha }}'
-
name: Build and push
if: always()
uses: docker/build-push-action@v6
with:
context: .
+3
View File
@@ -44,6 +44,9 @@ env.d/development/*
!env.d/development/*.dist
env.d/terraform
# Configuration
**/conversations/configuration/llm/dev.json
# npm
node_modules
+159 -8
View File
@@ -8,17 +8,162 @@ 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
- 🔥(chat) remove thinking part from frontend #227
## [0.0.10] - 2025-12-15
### Added
- ✨(front) add retry button
### Fixed
- 🐛(front) fix long user messages
- 🐛(front) fix "Maximum update depth exceeded" error in Chat component
- 🐛(front) fix parsing documents display
- 🐛(front) fix opacity input in error
- 🐛(front) resolve React hydration errors
- 🚑️(user) allow longer short names #182
## [0.0.9] - 2025-11-17
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
### Fixed
- 🔊(langfuse) enable tracing with redacted content #162
## [0.0.8] - 2025-11-10
### Fixed
- 🦺(front) Fix send prohibited file types
- 🐛(front) fix target blank links in chat #103
- 🚑️(posthog) pass str instead of UUID for user PK #134
- ⚡️(web-search) keep running when tool call fails #137
- ✨(summarize): new summarize tool integration #78
### Removed
- 🔥(posthog) remove posthog middleware for async mode fix #146
## [0.0.7] - 2025-10-28
### Fixed
- 🚑️(posthog) fix the posthog middleware for async mode #133
## [0.0.6] - 2025-10-28
### Fixed
- 🚑️(stats) fix tracking id in upload event #130
## [0.0.5] - 2025-10-27
### Fixed
- 🚑️(drag-drop) fix the rejection display on Safari #127
## [0.0.4] - 2025-10-27
### Added
- ♿️(a11y) improve accessibility #135
- 🌐(i18n) add dutch language #117
### Changed
@@ -30,14 +175,12 @@ and this project adheres to
- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
## [0.0.3] - 2025-10-21
### Fixed
- 🚑️(web-search) fix missing argument in RAG backend #116
## [0.0.2] - 2025-10-21
### Added
@@ -47,6 +190,7 @@ and this project adheres to
- 📈(posthog) add `sub` field to tracking #95
### Changed
- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
@@ -54,7 +198,6 @@ and this project adheres to
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
## [0.0.1] - 2025-10-19
### Changed
@@ -77,7 +220,7 @@ and this project adheres to
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- ✨(ui) fix retour global ui
- ✨(ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- ✨(ui) delete flex message
@@ -111,9 +254,17 @@ 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.5...main
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.5
[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
[0.0.7]: https://github.com/suitenumerique/conversations/releases/v0.0.7
[0.0.6]: https://github.com/suitenumerique/conversations/releases/v0.0.6
[0.0.5]: https://github.com/suitenumerique/conversations/releases/v0.0.5
[0.0.4]: https://github.com/suitenumerique/conversations/releases/v0.0.4
[0.0.3]: https://github.com/suitenumerique/conversations/releases/v0.0.3
[0.0.2]: https://github.com/suitenumerique/conversations/releases/v0.0.2
+34 -23
View File
@@ -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
@@ -167,6 +177,7 @@ CMD [\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"conversations.asgi:application"\
]
+3 -3
View File
@@ -126,7 +126,7 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build frontend openmockllm-mistral $(cache)
.PHONY: build-e2e
down: ## stop and remove containers, networks, images, and volumes
@@ -158,7 +158,7 @@ create-compose-with-models: ## override the docker-compose file with models
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d frontend openmockllm-mistral
.PHONY: run-e2e
status: ## an alias for "docker compose ps"
@@ -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
+37
View File
@@ -115,6 +115,31 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend
```
**Setup a basic LLM call**
To be able to use Conversations, you need to configure at least one Large Language Model (LLM) provider.
You can do so by setting the appropriate environment variables in the `env.d/development/common` file:
```ini
AI_BASE_URL=http://host.docker.internal:12434/v1/
AI_MODEL=gemma3:4b
AI_API_KEY=XXX
```
for a local ollama, or by running a local LLM with docker-compose:
```shellscript
$ make create-compose-with-models
```
which will create a `compose.override.yml` file to start a local models `ai/smollm2`
which can be changed later by editing the `compose.override.yml` file.
You will need to call `make run` after changing the `env.d/development/common`
or `compose.override.yml` file.
You can find more information about configuring LLM providers in the [LLM Configuration](docs/llm-configuration.md) documentation.
**Adding content**
You can create a basic demo site by running this command:
@@ -141,6 +166,18 @@ You first need to create a superuser account:
$ make superuser
```
## Documentation 📚
Additional documentation is available in the `docs/` directory:
- [LLM Configuration](docs/llm-configuration.md) - Configure Large Language Models and providers
- [Attachments](docs/attachments.md) - How to use attachments in conversations
- [Tools for Agents](docs/tools.md) - Available tools and how to add new ones
- [Environment Variables](docs/env.md) - All available environment variables
- [Installation Guide](docs/installation.md) - Deploy on a Kubernetes cluster
- [Theming](docs/theming.md) - Customize the application appearance
- [Architecture](docs/architecture.md) - Technical architecture overview
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/conversations/blob/main/LICENSE)).
+19
View File
@@ -11,3 +11,22 @@ services:
image: conversations:frontend-production
ports:
- "3000:3000"
openmockllm-mistral:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/OpenMockLLM/Dockerfile
image: conversations:openmockllm-mistral
command:
- openmockllm
- --host
- "0.0.0.0"
- --port
- "8000"
- --backend
- mistral
- --model-name
- mistral-mock
ports:
- "8900:8000"
+12
View File
@@ -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
+2 -2
View File
@@ -7,8 +7,8 @@ flowchart TD
User -- HTTP --> Front("Frontend (NextJS SPA)")
Front -- REST API --> Back("Backend (Django)")
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
Back -- REST API --> Yserver
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back --> Cache("Cache (Redis)")
Back ----> S3("Minio (S3)")
Back -- REST API --> LLM("LLM Providers")
```
+400
View File
@@ -0,0 +1,400 @@
# Conversation Attachments
This document describes how conversation attachments work in the Conversations application, including the upload process, security measures, and how documents are processed for use with Large Language Models (LLMs).
## Table of Contents
- [Overview](#overview)
- [Supported Attachment Types](#supported-attachment-types)
- [Architecture & Flow](#architecture--flow)
- [High-Level Overview](#high-level-overview)
- [Detailed Technical Flow](#detailed-technical-flow)
- [Security & Validation](#security--validation)
- [MIME Type Validation](#mime-type-validation)
- [Malware Detection](#malware-detection)
- [Document Processing for LLMs](#document-processing-for-llms)
- [Image Attachments](#image-attachments)
- [PDF Documents](#pdf-documents)
- [Other Document Types](#other-document-types)
- [Configuration](#configuration)
---
## Overview
Conversations allows users to attach files to their conversations with the AI assistant. These attachments can be:
- **Images** (displayed directly to vision-capable LLMs)
- **PDF documents** (sent as document URLs to the LLM)
- **Other documents** (converted to text and indexed for semantic search)
The attachment system uses **S3-compatible object storage** (such as MinIO in development) to store files securely.
The backend generates **presigned URLs** that allow the frontend to upload files directly to the storage,
without routing the file data through the backend server.
Note about documents: The system uses a tool called **MarkItDown** to convert various document formats
(Word, Excel, PowerPoint, text files, etc.) into Markdown text for processing by LLMs. When at least
one non-PDF/image document is attached, the system enables:
- a **Retrieval-Augmented Generation (RAG)** search tool to allow the LLM to query relevant sections of the documents.
- a **summarization tool** to provide document summaries on user request.
⚠️ naive implementation at the moment, needs improvement before being used in production.
## Supported Attachment Types
The following attachment types are supported:
- **Images**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
- **PDF documents**: `application/pdf`
- **Other documents**:
- Microsoft Word: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
- Microsoft Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- Microsoft PowerPoint: `application/vnd.openxmlformats-officedocument.presentationml.presentation`
- Text files: `text/plain`, `text/markdown`, `text/csv`
**Warning**: The current implementation for PDF expects the LLM to be able to manage them. We need to
improve the handling of PDFs in case the LLM cannot process them natively.
**Todo**:
- Add support for more file types and improve document processing workflows.
- Allow PDF management via RAG search when the LLM cannot handle them natively.
- Allow file type restrictions based on model settings, instead of globally.
- Improve the summarization tool to provide better summaries and handle larger documents.
- Start file upload right away when the user selects a file, instead of waiting for the user to send the message.
---
## Architecture & Flow
### High-Level Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │ │ Backend │ │ S3 Storage │ │ Malware Det.│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ 1. Create attachment│ │ │
├────────────────────>│ │ │
│ │ │ │
│ 2. Return presigned │ │ │
│ URL for upload │ │ │
│<────────────────────┤ │ │
│ │ │ │
│ 3. Upload file │ │ │
│ directly to S3 │ │ │
├──────────────────────────────────────────>│ │
│ │ │ │
│ 4. Notify upload │ │ │
│ completed │ │ │
├────────────────────>│ │ │
│ │ │ │
│ │ 5. Detect MIME type │ │
│ ├────────────────────>│ │
│ │ │ │
│ │ 6. Scan for malware │ │
│ ├──────────────────────────────────────────>│
│ │ │ │
│ │ 7. Update status │ │
│ 8. Return status │<──────────────────────────────────────────┤
│<────────────────────┤ │ │
│ │ │ │
```
### Detailed Technical Flow
#### Step 1: Attachment Creation Request
When a user selects a file to upload, the frontend sends a POST request to create an attachment record:
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/`
**Request payload**:
```json
{
"file_name": "document.pdf",
"size": 1048576,
"content_type": "application/pdf"
}
```
**Backend processing** (`ChatConversationAttachmentViewSet.perform_create`):
1. Verifies the user owns the conversation
2. Generates a unique UUID for the file
3. Creates a storage key: `{conversation_id}/attachments/{uuid}.{extension}`
4. Creates a database record with status `PENDING`
**Response**:
```json
{
"id": "uuid-of-attachment",
"key": "conversation-id/attachments/file-id.pdf",
"file_name": "document.pdf",
"size": 1048576,
"upload_state": "pending",
"policy": "https://s3.example.com/bucket/...?presigned-params"
}
```
The `policy` field contains a **presigned URL** valid for a limited time (configured by `AWS_S3_UPLOAD_POLICY_EXPIRATION`).
#### Step 2: Direct Upload to S3
The frontend uses the presigned URL to upload the file directly to S3 storage using a PUT request.
**Technical details**:
- The presigned URL includes authentication parameters
- The upload is done with `Content-Type` header matching the file's MIME type
- No backend involvement in the data transfer
#### Step 3: Upload Completion Notification
After successful upload, the frontend notifies the backend:
**Endpoint**: `POST /api/conversations/{conversation_id}/attachments/{attachment_id}/upload-ended/`
**Backend processing** (`ChatConversationAttachmentViewSet.upload_ended`):
1. **MIME Type Detection** (`chat/views.py`):
```python
mime_detector = magic.Magic(mime=True)
with default_storage.open(attachment.key, "rb") as file:
mimetype = mime_detector.from_buffer(file.read(2048))
size = file.size
```
Uses `python-magic` to detect the actual MIME type from file content (first 2048 bytes).
2. **Update attachment status**:
- Status: `PENDING` → `ANALYZING`
- Store detected MIME type and actual file size
3. **Trigger Malware Detection**:
```python
malware_detection.analyse_file(
attachment.key,
safe_callback="chat.malware_detection.conversation_safe_attachment_callback",
unknown_callback="chat.malware_detection.unknown_attachment_callback",
unsafe_callback="chat.malware_detection.conversation_unsafe_attachment_callback",
conversation_id=conversation_id,
)
```
#### Step 4: Malware Detection Callbacks
The malware detection service (configurable via `MALWARE_DETECTION_BACKEND`) scans the file and calls one of three callbacks:
**Safe file** (`conversation_safe_attachment_callback`):
- Status: `ANALYZING` → `READY`
- File is ready for use
**Unsafe file** (`conversation_unsafe_attachment_callback`):
- Status: `ANALYZING` → `SUSPICIOUS`
- File is quarantined and not accessible
- Security log entry created
**Unknown status** (`unknown_attachment_callback`):
- Handles special cases (e.g., file too large to analyze)
- Status: `ANALYZING` → `FILE_TOO_LARGE_TO_ANALYZE`
---
## Security & Validation
For now, the system is not intended to host user-uploaded files for public download.
All files are stored in private S3 buckets with presigned URLs for controlled access and only
the owner of the conversation/the uploader can access them, so the risk is quite low around bad use of
the attachment system.
Also, the document content is sent to the LLM and does not prevent any prompt injection attacks, which is not
an issue specific to the attachment system but to the overall design of LLM-based applications and should be
addressed globally. Also for the moment, the system does not have any action tools that could be used to execute
malicious code based on document content.
### Malware Detection
The malware detection system is **pluggable** and configurable, allowing different backends to be used.
By default, a `DummyBackend` is provided that marks all files as safe.
⚠️ The current implementation does not disallow any file types or status from being used in conversations.
This is a potential security risk and should be addressed in future versions.
---
## Document Processing for LLMs
When a user sends a message with attachments, the system processes them differently based on their type:
### Image Attachments
**MIME types**: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, etc.
**Processing flow**:
1. **URL Conversion**: Local media URLs are converted to presigned S3 URLs before sending to the LLM:
```python
# From: chat/agents/local_media_url_processors.py
content.url = generate_retrieve_policy(key)
```
2. **Sent to LLM**: Images are sent as `ImageUrl` objects in the prompt:
```python
ImageUrl(
url="https://s3.example.com/bucket/key?presigned-params",
identifier="file-id.png",
)
```
3. **Vision models** can analyze the image content directly.
4. **Response processing**: After the LLM responds, presigned URLs are converted back to local URLs for storage:
```python
# Mapping: presigned_url -> /media-key/{conversation_id}/attachments/{file_id}.png
```
### PDF Documents
**MIME type**: `application/pdf`
**Processing flow**:
1. **Direct URL passing**: PDFs are sent as `DocumentUrl` objects :
```python
DocumentUrl(
url="https://s3.example.com/bucket/key?presigned-params",
identifier="file-id.pdf",
)
```
2. **LLM processing**: Compatible LLMs can:
- Extract and read text from PDFs
- Understand document structure
- Answer questions about the content
3. **No conversion needed**: PDFs are passed directly without preprocessing.
### Other Document Types
**MIME types**: Word documents, Excel spreadsheets, PowerPoint, text files, Markdown, etc.
**Processing flow**:
1. **Document parsing**: When a document is uploaded, it's parsed using the `AlbertRagBackend` class.
2. **Conversion to Markdown**: Documents are converted using **MarkItDown** library or using the "Albert API" for PDFs.
3. **RAG (Retrieval-Augmented Generation)**:
- Converted text is indexed in a vector database
- The LLM uses a `document_rag_search` tool to query relevant sections
- Only relevant chunks are sent to the LLM to fit context windows
4. **Summarization tool** if needed.
### Processing Strategy Decision Tree
**Decision logic**:
- **No documents**: Standard conversation
- **Images**: Send as direct (presigned) URLs to the LLM
- **Only PDFs**: Send as direct (presigned) URLs to the LLM
- **Other documents present**: Enable RAG search tool + convert to Markdown
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------------------------------------------|----------------|------------------------------------------------------------|
| `ATTACHMENT_MAX_SIZE` | Configurable | Maximum file size in bytes |
| `ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED` | `True` | Enable/disable MIME type validation |
| `AWS_S3_UPLOAD_POLICY_EXPIRATION` | 3600 | Presigned URL expiration (seconds) |
| `AWS_S3_RETRIEVE_POLICY_EXPIRATION` | 3600 | Presigned retrieval URL expiration (seconds) |
| `AWS_S3_DOMAIN_REPLACE` | None | Alternative S3 domain for presigned URLs (for development) |
| `MALWARE_DETECTION_BACKEND` | `DummyBackend` | Malware scanning backend class |
| `MALWARE_DETECTION_PARAMETERS` | `{}` | Backend-specific configuration |
| `RAG_FILES_ACCEPTED_FORMATS` | See below | List of MIME types accepted for file uploads |
#### RAG_FILES_ACCEPTED_FORMATS
This environment variable controls which file types users are allowed to upload as attachments to conversations.
**Configuration**:
- **Type**: List of strings (comma-separated MIME types when using environment variable)
- **Default value**: Includes a comprehensive list of document and image formats:
- Microsoft Office documents (`.docx`, `.pptx`, `.xlsx`, `.xls`)
- Text files (`.txt`, `.csv`)
- PDF documents (`.pdf`)
- HTML files
- Markdown files (`.md`)
- Outlook messages (`.msg`)
- Images (`.jpeg`, `.png`, `.gif`, `.webp`)
**Example configuration**:
```ini
# In environment variable (comma-separated)
RAG_FILES_ACCEPTED_FORMATS="application/pdf,text/plain,image/png,image/jpeg"
```
```python
# In Django settings (as a Python list)
RAG_FILES_ACCEPTED_FORMATS = [
"application/pdf",
"text/plain",
"image/png",
"image/jpeg",
]
```
**How it's used**:
1. **Backend**: The list is exposed via the `/api/v1.0/config/` endpoint as `chat_upload_accept` (MIME types joined with commas)
2. **Frontend**: The configuration is used to validate files before upload in the chat interface:
- Checks exact MIME type matches
- Supports wildcard patterns (e.g., `image/*` for all image types)
- Supports file extension patterns (e.g., `.pdf`)
3. **User experience**: Files that don't match the accepted formats are rejected with a user-friendly error message
**Notes**:
- This setting controls frontend validation only. Backend validation should also be implemented for security.
- Future improvements may include per-model file type restrictions.
### Storage Configuration
**MinIO (Development)**:
```yaml
# docker-compose.yml
minio:
image: minio/minio
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
```
---
## Troubleshooting
### LLM Cannot Access Image/PDF
**Possible causes**:
- Presigned URL has expired
- S3 storage is not accessible from the LLM provider
- CORS configuration issues
**Solution**: Check `AWS_S3_RETRIEVE_POLICY_EXPIRATION` and S3 access policies.
### Document Not Appearing in RAG Search
**Possible causes**:
- Document conversion failed
- Vector database indexing failed
**Check logs**: Look for errors in `DocumentConverter` and RAG backend logs.
---
## Related Documentation
- [Installation Guide](installation.md) - S3 storage setup
- [LLM Configuration](llm-configuration.md) - Model capabilities for attachments
- [Architecture](architecture.md) - System overview
- [Tools](tools.md) - Document search and RAG tools
+12 -9
View File
@@ -10,7 +10,6 @@ These are the environment variables you can set for the `conversations-backend`
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | conversations |
| DB_USER | user to authenticate with | dinum |
@@ -24,12 +23,11 @@ These are the environment variables you can set for the `conversations-backend`
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | conversations-media-storage |
| ATTACHMENT_MAX_SIZE | maximum size of document in bytes | 10485760 |
| ATTACHMENT_MAX_SIZE | maximum size of document in bytes | 10485760 |
| LANGUAGE_CODE | default language | en-us |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_HOST | host name of email | |
@@ -76,12 +74,14 @@ These are the environment variables you can set for the `conversations-backend`
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_AGENT_INSTRUCTION | Base instruction for the AI agent | You are a helpful assistant |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| LLM_CONFIGURATION_FILE_PATH | Path to the LLM configuration JSON file. See [LLM Configuration](llm-configuration.md) for details | <BASE_DIR>/conversations/configuration/llm/default.json |
| LLM_DEFAULT_MODEL_HRID | HRID of the model used for conversations | default-model |
| LLM_SUMMARIZATION_MODEL_HRID | HRID of the model used for summarization | default-summarization-model |
| AI_API_KEY | AI API key to be used for the default provider (used in default LLM configuration, not for production use) | |
| AI_BASE_URL | OpenAI compatible AI base URL (used in default LLM configuration, not for production use) | |
| AI_MODEL | AI Model name to use (used in default LLM configuration, not for production use) | |
| AI_AGENT_INSTRUCTIONS | Base instruction for the AI agent (used in default LLM configuration, not for production use) | You are a helpful assistant. Wrap formulas... |
| AI_AGENT_TOOLS | List of enabled tools for the agent (used in default LLM configuration, not for production use) | [] |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
@@ -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
-1
View File
@@ -9,7 +9,6 @@ backend:
DJANGO_CSRF_TRUSTED_ORIGINS: https://conversations.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: conversations.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: conversations.settings
DJANGO_SUPERUSER_PASSWORD: admin
+159
View File
@@ -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)
+1 -1
View File
@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Conversation
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example)
- a Memcached server (if you don't have one, we provide an example)
- a Redis server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example)
### Test cluster
+412
View File
@@ -0,0 +1,412 @@
# LLM Configuration
This document describes how to configure Large Language Models (LLMs) in Conversations via the configuration file.
## Overview
Conversations uses a JSON configuration file to define LLM models and providers. This approach allows you to:
- Configure multiple LLM models from different providers
- Switch between models without code changes
- Customize model-specific settings like temperature, max tokens, and system prompts
- Enable or disable models dynamically
The overall structure consists of two main sections: `providers` and `models`.
Settings for models, provides customization through `settings` and `profile`, which corresponds to the
Pydantic AI model settings and profile. While we currently not use those settings extensively,
they are available for future use and advanced configurations, please reach us if you face any problem using them.
## Configuration File Location
The default LLM configuration file is located at:
```
src/backend/conversations/configuration/llm/default.json
```
You can override this location by setting the `LLM_CONFIGURATION_FILE_PATH` environment variable, but be careful as
this path must be accessible by the backend application _inside the docker image_:
``` ini
LLM_CONFIGURATION_FILE_PATH=/path/to/your/llm/config.json
```
## Default Behavior
### Default Configuration
The default configuration file is useful for local development and running the test, while it can be used
in production, we suggest to create a specific one for production and replace the `settings.` values with
`environ.` one.
The default configuration file (`default.json`) includes:
1. **Two default models**:
- `default-model`: The primary conversational model used for chat interactions
- `default-summarization-model`: A specialized model for summarizing conversations
2. **One default provider**:
- `default-provider`: An OpenAI-compatible provider that uses environment variables for configuration
### Environment Variable Integration
The configuration uses dynamic value resolution with two special prefixes:
- `settings.VARIABLE_NAME`: Resolves to a Django setting value
- `environ.VARIABLE_NAME`: Resolves to an environment variable value
For example, in the default configuration:
```json
{
"model_name": "settings.AI_MODEL",
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": "settings.AI_AGENT_TOOLS"
}
```
This allows to configure models in tests using the setting override mechanism from Django/Pytest (but might be replaced
later with a simple override of the full configuration like it's done in some tests already).
### Required Environment Variables
For the default configuration to work, you need to set these environment variables:
| Variable | Description | Example |
|-------------------------------|----------------------------------------|-----------------------------|
| `AI_API_KEY` | API key for the default provider | `sk-...` |
| `AI_BASE_URL` | Base URL for the OpenAI-compatible API | `https://api.openai.com/v1` |
| `AI_MODEL` | Model name to use | `gpt-4o-mini` |
### Optional Environment Variables
If you want to customize the agent behavior and tools, you can set these optional environment variables
(defaults are provided in the default configuration):
| Variable | Description | Default |
|-------------------------------|----------------------------------------|-------------------|
| `AI_AGENT_INSTRUCTIONS` | System prompt for the agent | see `settings.py` |
| `AI_AGENT_TOOLS` | List of enabled tools | `[]` |
| `SUMMARIZATION_SYSTEM_PROMPT` | Base prompt of the summarization agent | see `settings.py` |
### Model Selection
You can configure which models are used for specific tasks via environment variables:
| Variable | Description | Default |
|--------------------------------|------------------------------------------|-------------------------------|
| `LLM_DEFAULT_MODEL_HRID` | HRID of the model used for conversations | `default-model` |
| `LLM_SUMMARIZATION_MODEL_HRID` | HRID of the model used for summarization | `default-summarization-model` |
## Configuration Structure
The configuration file has two main sections:
### 1. Providers
Providers define the API endpoints and authentication for LLM services.
```json
{
"providers": [
{
"hrid": "unique-provider-id",
"base_url": "https://api.example.com/v1",
"api_key": "environ.API_KEY_VAR",
"kind": "openai"
}
]
}
```
**Provider Fields:**
| Field | Type | Required | Description |
|------------|--------|----------|---------------------------------------------------------|
| `hrid` | string | Yes | Unique identifier for the provider |
| `base_url` | string | Yes | API base URL (can use `settings.` or `environ.` prefix) |
| `api_key` | string | Yes | API authentication key (use `environ.` here) |
| `kind` | string | Yes | Provider type: `openai` or `mistral` |
### 2. Models
Models define the LLMs available in your application.
```json
{
"models": [
{
"hrid": "unique-model-id",
"model_name": "gpt-4o-mini",
"human_readable_name": "GPT-4o Mini",
"provider_name": "unique-provider-id",
"profile": null,
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a helpful assistant",
"tools": []
}
]
}
```
**Model Fields:**
| Field | Type | Required | Description |
|-----------------------|--------------|----------|-----------------------------------------------------------------------------------------------------|
| `hrid` | string | Yes | Unique identifier for the model |
| `model_name` | string | Yes | Name of the model as recognized by the provider (can use `settings.` or `environ.` prefix) |
| `human_readable_name` | string | Yes | Display name shown to users |
| `provider_name` | string | No* | Reference to a provider's `hrid` |
| `provider` | object | No* | Inline provider definition (alternative to `provider_name`) |
| `profile` | object | No | Model-specific capabilities and settings |
| `settings` | object | No | Model inference settings (temperature, max_tokens, etc.) |
| `is_active` | boolean | Yes | Whether the model is available for use |
| `icon` | string/array | No | Base64-encoded icon or array of icon parts |
| `system_prompt` | string | Yes | Default system prompt for the model (can use `settings.` or `environ.` prefix) |
| `tools` | array | Yes | List of enabled tools for this model (can use `settings.` or `environ.` prefix for the whole array) |
| `supports_streaming` | boolean | No | Whether the model supports streaming responses |
\* Either `provider_name` or `provider` must be set, unless `model_name` is in the format `<provider>:<model>`.
## Adding New Models
### Example 1: Adding a New OpenAI Model
To add a new OpenAI model using the existing default provider:
```json
{
"models": [
// ...existing models...
{
"hrid": "gpt-4-turbo",
"model_name": "gpt-4-turbo-preview",
"human_readable_name": "GPT-4 Turbo",
"provider_name": "default-provider",
"profile": null,
"settings": {
"temperature": 0.7,
"max_tokens": 4096
},
"is_active": true,
"icon": null,
"system_prompt": "You are an expert AI assistant.",
"tools": ["web_search_brave_with_document_backend"],
"supports_streaming": true
}
],
"providers": [
// ...existing providers...
]
}
```
### Example 2: Adding a Model using Pydantic AI format
To add a model with a specific provider using the default Pydantic AI format, you don't need to define the provider separately if you use the `model_name` format `<provider>:<model>`.
1. **Add the model without provider**:
```json
{
"models": [
{
"hrid": "claude-3-opus",
"model_name": "anthropic:claude-3-opus-20240229",
"human_readable_name": "Claude 3 Opus",
"provider_name": null,
"profile": null,
"settings": {
"temperature": 0.7,
"max_tokens": 4096
},
"is_active": true,
"icon": null,
"system_prompt": "You are Claude, a helpful AI assistant.",
"tools": []
}
],
"providers": []
}
```
2**Set the environment variable**:
Pydantic AI expects the API key in an environment variable named `ANTHROPIC_API_KEY` is this example, so set it accordingly:
```ini
ANTHROPIC_API_KEY=your-api-key-here
```
### Example 3: Adding a Mistral Model
For Mistral AI models using the Etalab platform:
```json
{
"models": [
{
"hrid": "mistral-medium",
"model_name": "mistral-medium-2508",
"human_readable_name": "Mistral Medium (Etalab)",
"provider_name": "mistral-etalab",
"profile": null,
"settings": {
"temperature": 0.5,
"max_tokens": 8192
},
"is_active": true,
"icon": null,
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": ["web_search_brave_with_document_backend"]
}
],
"providers": [
{
"hrid": "mistral-etalab",
"base_url": "https://api.mistral.etalab.gouv.fr/",
"api_key": "environ.MISTRAL_ETALAB_API_KEY",
"kind": "mistral"
}
]
}
```
### Example 4: Using Inline Provider Definition
Instead of referencing a provider by name, you can define it inline if you use a unique configuration:
```json
{
"models": [
{
"hrid": "custom-model",
"model_name": "custom-model-v1",
"human_readable_name": "Custom Model",
"provider": {
"hrid": "custom-provider-inline",
"base_url": "https://custom-api.example.com/v1",
"api_key": "environ.CUSTOM_API_KEY",
"kind": "openai"
},
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "You are a custom assistant.",
"tools": []
}
]
}
```
## Advanced Configuration
### Model Settings
The `settings` object supports various inference parameters:
```json
{
"settings": {
"max_tokens": 4096,
"temperature": 0.7,
"top_p": 0.9,
"timeout": 60.0,
"parallel_tool_calls": true,
"seed": 42,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"logit_bias": {},
"stop_sequences": [],
"extra_headers": {},
"extra_body": {}
}
}
```
### Model Profile
The `profile` object defines model capabilities:
```json
{
"profile": {
"supports_tools": true,
"supports_json_schema_output": true,
"supports_json_object_output": true,
"default_structured_output_mode": "json_schema",
"thinking_tags": ["<thinking>", "</thinking>"],
"ignore_streamed_leading_whitespace": true
}
}
```
### Available Tools
Tools can be specified in the `tools` array. Common tools include:
- `web_search_brave_with_document_backend`: Web search using Brave API with document processing
You can also reference the tools list from Django settings:
```json
{
"tools": "settings.AI_AGENT_TOOLS"
}
```
### Custom Icons
Icons can be provided as base64-encoded PNG images. For long strings, you can split them into an array:
```json
{
"icon": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+m",
"AAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAziABUAGIWbpsTwq7HhAAAA"
]
}
```
## Validation
The configuration is validated when loaded. Common validation errors include:
- **Provider not found**: A model references a `provider_name` that doesn't exist in the `providers` array
- **Missing provider**: Neither `provider_name` nor `provider` is specified, and `model_name` is not in `<provider>:<model>` format
- **Environment variable not set**: A value using `environ.` prefix references an undefined environment variable
- **Django setting not set**: A value using `settings.` prefix references an undefined Django setting
- **Invalid provider kind**: The `kind` field must be either `openai` or `mistral`
## Testing Your Configuration
After modifying the configuration file, you can test it by:
1. **Checking for syntax errors**:
```bash
python -m json.tool src/backend/conversations/configuration/llm/default.json
```
2. **Starting the application** and checking the logs for validation errors
3. **Using the Django shell** to load the configuration:
```bash
./bin/manage shell
```
```python
from django.conf import settings
models = settings.LLM_CONFIGURATIONS
models.keys() # Should show all model HRIDs
```
## Best Practices
1. **Use environment variables** for sensitive data like API keys (with `environ.` prefix)
2. **Use Django settings** for configurable values that may change between environments (with `settings.` prefix)
3. **Keep provider definitions separate** from models to avoid duplication when using multiple models from the same provider
4. **Set `is_active: false`** for models you want to keep in the configuration but temporarily disable
5. **Use descriptive `hrid` values** that clearly identify the model and provider
6. **Document custom configurations** in your deployment documentation
7. **Test configuration changes** in a development environment before deploying to production
## See Also
- [Environment Variables Documentation](env.md) - For configuring environment variables
- [Installation Guide](installation.md) - For deployment instructions
+20 -20
View File
@@ -14,15 +14,15 @@ Memory is the first bottleneck; CPU matters only when Celery or the Next.js buil
## 2. Development Environment Memory Requirements
| Service | Typical use | Rationale / source |
|-----------------------|-------------------------------|-----------------------------------------------------------------------------------------|
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API (+ Celery) | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
| Service | Typical use | Rationale / source |
|------------------|-------------------------------|-----------------------------------------------------------------------------------------|
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
@@ -58,7 +58,7 @@ Production deployments differ significantly from development environments. The t
| Service | Memory | Notes |
|----------------------------------|------------|----------------------------------------|
| PostgreSQL | **2 GB** | Core database |
| Django API (+ Celery) | **1.5 GB** | Backend services |
| Django API | **1.5 GB** | Backend services |
| Nginx | **100 MB** | Static files + reverse proxy |
| Redis | **256 MB** | Session storage |
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
@@ -81,16 +81,16 @@ Production deployments differ significantly from development environments. The t
## 5. Ports (dev defaults)
| Port | Service |
|-----------|-----------------------|
| 3000 | Next.js |
| 8071 | Django |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | Maildev |
| Port | Service |
|-----------|----------------------------|
| 3000 | Next.js |
| 8071 | Django |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | Maildev (currently unused) |
## 6. Sizing Guidelines
+4 -4
View File
@@ -4,7 +4,7 @@
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
```ini
FRONTEND_CSS_URL=http://anything/custom-style.css
```
@@ -38,7 +38,7 @@ The footer is configurable from the theme customization file.
### Settings 🔧
```shellscript
```ini
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
@@ -55,10 +55,10 @@ The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
```ini
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
The json must follow some rules: https://github.com/suitenumerique/conversations/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
+238
View File
@@ -0,0 +1,238 @@
# Tools for the Conversation Agent
The conversation agent can be extended with various tools that provide additional capabilities such as web search,
weather information, and more. We currently only have web search tools, but more tools can be added as needed.
This document explains how to configure and use these tools.
## Overview
Tools are functions that the LLM can call during a conversation to access external data or perform specific actions.
The agent decides when to use these tools based on the user's query and the conversation context.
## Configuring Tools for a Model
Tools are configured at the model level in the LLM configuration file.
Each model can have its own set of available tools.
### Configuration File Location
Read the [LLM Configuration](llm-configuration.md) document to find out where the configuration file is located
and how to use it.
### Example Configuration
```json
{
"models": [
{
"hrid": "default-model",
"model_name": "gpt-4",
"human_readable_name": "GPT-4 with Tools",
"provider_name": "default-provider",
"is_active": true,
"system_prompt": "You are a helpful assistant.",
"tools": [
"web_search_brave",
"get_current_weather"
]
}
],
"providers": [
{
"hrid": "default-provider",
"base_url": "https://api.openai.com/v1",
"api_key": "settings.AI_API_KEY",
"kind": "openai"
}
]
}
```
The `tools` field accepts either:
- A list of tool names: `["tool_name_1", "tool_name_2"]`
- A reference to a settings variable: `"settings.AI_AGENT_TOOLS"`
## Available Tools
To make a tool available to be in a model's configuration, it must be registered in the tool registry located at
`src/backend/chat/tools/__init__.py`.
This is not dynamic - any changes to the tool registry require a code deployment...
We want to add dynamic loading in the future.
| Tool Name | Description | Documentation |
|------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------------|
| `get_current_weather` | Fake weather tool for testing purposes | [Details](tools/get_current_weather.md) |
| `web_search_tavily` | Web search using Tavily API | [Details](tools/web_search_tavily.md) |
| `web_search_brave` | Web search using Brave Search API with optional summarization | [Details](tools/web_search_brave.md) |
| `web_search_brave_with_document_backend` | Web search using Brave with RAG-based document processing | [Details](tools/web_search_brave.md#web_search_brave_with_document_backend) |
| `web_search_albert_rag` | ⚠️ **Deprecated** - Web search using Albert API with RAG | [Details](tools/web_search_brave.md#deprecated-web_search_albert_rag) |
## Adding a New Tool
To add a new tool to the system, follow these steps:
### 1. Create the Tool Function
Create a new Python file in `src/backend/chat/tools/` with your tool function. The function should:
- Have clear type annotations
- Include a comprehensive docstring (the LLM uses this to understand when to use the tool)
- Accept `RunContext` as the first parameter if it needs access to conversation context
- Return appropriate data types
Example:
```python
"""My custom tool for the chat agent."""
from pydantic_ai import RunContext
def my_custom_tool(ctx: RunContext, param1: str, param2: int) -> dict:
"""
Brief description of what the tool does.
The LLM uses this description to decide when to call this tool.
Args:
ctx (RunContext): The run context containing the conversation.
param1 (str): Description of parameter 1.
param2 (int): Description of parameter 2.
Returns:
dict: Description of the return value.
"""
# Your implementation here
return {"result": "example"}
```
### 2. Register the Tool
Add your tool to the registry in `src/backend/chat/tools/__init__.py`:
```python
from .my_custom_tool import my_custom_tool
def get_pydantic_tools_by_name(name: str) -> Tool:
"""Get a tool by its name."""
tool_dict = {
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
"web_search_brave": Tool(
web_search_brave, takes_ctx=False, prepare=only_if_web_search_enabled
),
# Add your tool here
"my_custom_tool": Tool(
my_custom_tool,
takes_ctx=True, # Set to True if your tool needs RunContext
# prepare=only_if_web_search_enabled # Optional: add conditions
),
}
return tool_dict[name]
```
### 3. Update Imports
Don't forget to import your tool function at the top of `__init__.py`:
```python
from .my_custom_tool import my_custom_tool
```
### 4. Add to Model Configuration
Add your tool name to the `tools` list in your LLM configuration file or
to the `AI_AGENT_TOOLS` environment variable for local/test purpose.
## Tool Preparation: Conditional Tool Availability
Some tools should only be available under certain conditions. The `prepare` parameter in the `Tool` constructor
allows you to specify a function that determines whether a tool should be included.
### The `only_if_web_search_enabled` Prepare Function
This is a built-in prepare function that checks if web search feature is enabled in the conversation context:
```python
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
"""Prepare function to include a tool only if web search is enabled in the context."""
return tool_def if ctx.deps.web_search_enabled else None
```
### Usage
All web search tools use this prepare function:
```python
"web_search_brave": Tool(
web_search_brave,
takes_ctx=False,
prepare=only_if_web_search_enabled
),
```
This ensures that web search tools are only available when the user or conversation settings have enabled web search functionality.
### Creating Custom Prepare Functions
You can create your own prepare functions for custom conditions:
```python
async def only_if_feature_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
"""Include tool only if a specific feature is enabled."""
return tool_def if ctx.deps.feature_enabled else None
```
## Web Search Enable/Disable
Web search tools can be toggled on or off based on conversation settings. When web search is disabled:
- Web search tools are not included in the agent's available tools
- The LLM cannot make web search calls even if it tries
- This is enforced by the `only_if_web_search_enabled` prepare function
The `web_search_enabled` flag is typically set:
- Per conversation in the conversation settings
- Per user preference
- Through admin configuration
## Best Practices
1. **Keep tools focused** - Each tool should do one thing well
2. **Clear documentation** - The LLM relies on docstrings to understand when to use tools
3. **Error handling** - Tools should handle errors gracefully and return meaningful messages
4. **Performance** - Be mindful of API rate limits and timeout values
5. **Security** - Never log sensitive data (API keys, user data, etc.)
6. **Caching** - Use Django's cache framework for expensive operations when appropriate
## Troubleshooting
### Tool Not Being Called
If the LLM isn't calling your tool:
- Check that the tool is registered in `get_pydantic_tools_by_name`
- Verify the tool is in the model's `tools` configuration
- Review the tool's docstring - make it clearer when the tool should be used
- Check if any `prepare` function is preventing the tool from being included
### Tool Errors
If a tool is throwing errors:
- Check the logs for detailed error messages
- Verify all required environment variables are set
- Ensure the tool's dependencies are installed
- Test the tool function independently
We recommend wrapping external API calls in try/except blocks to handle potential issues gracefully and use
the Pydantic AI `ModelRetry` exception to let the LLM manage the errors.
### Tool Response Issues
If the LLM isn't using the tool response correctly:
- Ensure the return type is clear and well-structured
- Consider returning a `ToolReturn` object with metadata
- Check if the response format matches what the LLM expects
## See Also
- [Web Search Configuration](llm-configuration.md)
- [Architecture](architecture.md)
- [Environment Variables](env.md)
+113
View File
@@ -0,0 +1,113 @@
# get_current_weather Tool
## Overview
The `get_current_weather` tool is a **fake weather tool** designed for testing and demonstration purposes. It does not connect to any real weather API and always returns hardcoded weather data.
## Purpose
This tool is useful for:
- **Testing** the tool calling functionality of LLMs
- **Demonstrating** how tools work without requiring API keys
- **Development** and debugging of the agent system
- **Example implementation** for creating new tools
⚠️ **Warning**: This tool should **not** be used in production environments. It always returns fake data regardless of the location or conditions.
## Configuration
### Add to Model
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"get_current_weather"
]
}
]
}
```
Or via environment variable when using local environment settings:
```ini
AI_AGENT_TOOLS=get_current_weather
```
### No Additional Settings Required
This tool does not require any API keys, environment variables, or additional configuration.
## Function Signature
```python
def get_current_weather(location: str, unit: str) -> dict:
"""
Get the current weather in a given location.
Args:
location (str): The city and state, e.g. San Francisco, CA.
unit (str): The unit of temperature, either 'celsius' or 'fahrenheit'.
Returns:
dict: A dictionary containing the location, temperature, and unit.
"""
```
## Parameters
| Parameter | Type | Required | Description |
|------------|------|----------|-----------------------------------------------------------------|
| `location` | str | Yes | The city and state (e.g., "San Francisco, CA", "Paris, France") |
| `unit` | str | Yes | Temperature unit: either "celsius" or "fahrenheit" |
## Return Value
Returns a dictionary with the following structure:
```python
{
"location": str, # The location that was queried
"temperature": int, # Always 22°C or 72°F
"unit": str # The unit that was requested
}
```
## How the LLM Uses It
When a user asks about weather, the LLM will:
1. **Recognize** the weather-related query
2. **Extract** the location from the user's message
3. **Determine** the appropriate unit (often from context or user preference)
4. **Call** the `get_current_weather` tool
5. **Receive** the fake weather data
6. **Format** a response to the user
### Example Conversation
**User**: "What's the weather like in London?"
**LLM** (internal): *Calls `get_current_weather("London, UK", "celsius")`*
**Tool Response**:
```json
{
"location": "London, UK",
"temperature": 22,
"unit": "celsius"
}
```
**LLM** (to user): "The current weather in London, UK is 22°C."
## See Also
- [Tools Overview](../tools.md)
- [Adding a New Tool](../tools.md#adding-a-new-tool)
- [Testing Tools](../tools.md#testing-your-tools)
+671
View File
@@ -0,0 +1,671 @@
# Brave Web Search Tools
## Overview
The Brave web search tools enable the conversation agent to search the web using the [Brave Search API](https://brave.com/search/api/).
Brave Search is a privacy-focused search engine that provides comprehensive web search results.
This documentation covers three related tools:
1. **`web_search_brave`** - Standard web search with optional summarization
2. **`web_search_brave_with_document_backend`** - Web search with RAG-based document processing
3. **`web_search_albert_rag`** - ⚠️ **Deprecated** - Use `web_search_brave_with_document_backend` instead
## Table of Contents
- [Common Configuration](#common-configuration)
- [web_search_brave](#web_search_brave)
- [web_search_brave_with_document_backend](#web_search_brave_with_document_backend)
- [Deprecated: web_search_albert_rag](#deprecated-web_search_albert_rag)
- [Comparison](#comparison)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
---
## Common Configuration
### Prerequisites
1. **Brave Search API Key**: Sign up at [Brave Search API](https://brave.com/search/api/) to get an API key
2. **Environment Variables**: Configure the required settings
### Common Environment Variables
All Brave tools share these common settings:
| Variable | Required | Default | Description |
|---------------------|----------|---------|----------------------------------------------------|
| `BRAVE_API_KEY` | **Yes** | None | Your Brave Search API key |
| `BRAVE_API_TIMEOUT` | No | 5 | API request timeout in seconds |
| `BRAVE_MAX_RESULTS` | No | 8 | Maximum number of search results |
| `BRAVE_CACHE_TTL` | No | 1800 | Cache time-to-live in seconds (30 minutes) |
### Search Parameters
Check on the Brave API documentation for more details on these parameters:
| Variable | Required | Default | Description |
|-------------------------------|----------|------------|---------------------------------------------------|
| `BRAVE_SEARCH_COUNTRY` | No | None | Country code for search (e.g., "US", "FR") |
| `BRAVE_SEARCH_LANG` | No | None | Language code (e.g., "en", "fr") |
| `BRAVE_SEARCH_SAFE_SEARCH` | No | "moderate" | Safe search level: "off", "moderate", or "strict" |
| `BRAVE_SEARCH_SPELLCHECK` | No | True | Enable spell checking |
| `BRAVE_SEARCH_EXTRA_SNIPPETS` | No | True | Fetch extra snippets from pages |
Note: even if `BRAVE_SEARCH_EXTRA_SNIPPETS` is enabled, the API may not include them if you don't have a plan for this.
This is why, in `web_search_brave`, we also fetch the page content ourselves when needed.
### Configuration Example
```bash
# .env file
BRAVE_API_KEY=BSA-your-api-key-here
BRAVE_MAX_RESULTS=8
BRAVE_MAX_WORKERS=4
BRAVE_SEARCH_COUNTRY=US
BRAVE_SEARCH_LANG=en
BRAVE_SEARCH_SAFE_SEARCH=moderate
```
### Django Settings
All Brave settings are defined in `src/backend/conversations/brave_settings.py`:
```python
class BraveSettings:
"""Brave settings for web_search_brave tool."""
BRAVE_API_KEY = values.Value(
default=None,
environ_name="BRAVE_API_KEY",
environ_prefix=None,
)
# ... more settings
```
---
## web_search_brave
### Overview
Standard Brave web search tool with optional LLM-based summarization of page content.
### Purpose
- Search the web for up-to-date information
- Extract content from web pages
- Optionally summarize content using an LLM
- Provide structured results with snippets
### Additional Configuration
| Variable | Required | Default | Description |
|-------------------------------|----------|---------|-------------------------------------------------|
| `BRAVE_SUMMARIZATION_ENABLED` | No | False | Enable LLM-based summarization of fetched pages |
### Function Signature
```python
def web_search_brave(query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
ToolReturn: Formatted search results with metadata
"""
```
### Return Value
Returns a `ToolReturn` object with:
```python
ToolReturn(
return_value={
"0": {
"url": "https://example.com/page1",
"title": "Example Page Title",
"snippets": ["Extracted or summarized content..."]
},
"1": {
"url": "https://example.com/page2",
"title": "Another Page",
"snippets": ["More content..."]
}
},
metadata={
"sources": {
"https://example.com/page1",
"https://example.com/page2"
}
}
)
```
### How It Works
1. **Query API**: Sends search query to Brave Search API
2. **Receive Results**: Gets list of matching web pages
3. **Fetch Content**: For results without extra_snippets:
- Fetches the HTML content using `trafilatura`
- Extracts the main text content
- Caches the extracted content
4. **Summarize (Optional)**: If `BRAVE_SUMMARIZATION_ENABLED=True`:
- Sends extracted content to summarization agent
- Receives concise summary focused on the query
5. **Format Results**: Returns structured data with URLs, titles, and snippets
### Workflow Diagram
```
User Query
Brave Search API
Search Results (URLs, titles, descriptions)
[For each result without snippets]
Fetch HTML (trafilatura) → Extract Text → Cache
[If BRAVE_SUMMARIZATION_ENABLED]
Summarization Agent (LLM)
Summary Text
Format & Return
```
### Caching
Extracted content is cached to avoid repeated fetches:
```python
cache_key = f"web_search_brave:extract:{url}"
cache.set(cache_key, document, settings.BRAVE_CACHE_TTL)
```
**Cache Duration**: Controlled by `BRAVE_CACHE_TTL` (default: 30 minutes)
### Summarization
When enabled, the tool uses the `SummarizationAgent` to condense page content:
```python
prompt = f"""
Based on the following request, summarize the following text in a concise manner,
focusing on the key points regarding the user request.
The result should be up to 30 lines long.
<user request>
{query}
</user request>
<text to summarize>
{text}
</text to summarize>
"""
```
**Note**: Summarization is costly (additional LLM calls).
Use only when necessary, we prefer the document vector search from `web_search_brave_with_document_backend`.
### Add to Model
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_brave"
]
}
]
}
```
### Example Usage
**User**: "What are the new features in Django 5.0?"
**Tool Call**: `web_search_brave("Django 5.0 new features")`
**Tool Response**:
```python
{
"0": {
"url": "https://docs.djangoproject.com/en/5.0/releases/5.0/",
"title": "Django 5.0 release notes",
"snippets": ["Django 5.0 introduces several new features including..."]
},
# ... more results
}
```
### Registration
```python
"web_search_brave": Tool(
web_search_brave,
takes_ctx=False,
prepare=only_if_web_search_enabled
)
```
---
## web_search_brave_with_document_backend
### Overview
Advanced Brave web search tool that uses RAG (Retrieval-Augmented Generation)
with a document backend for intelligent content processing and retrieval.
### Purpose
- Search the web and process results through a RAG system
- Store fetched documents in a temporary vector database
- Perform semantic search across fetched content
- Return the most relevant chunks based on the query
### Additional Configuration
| Variable | Required | Default | Description |
|-------------------------------------|----------|------------------|----------------------------------------------|
| `BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER` | No | 10 | Number of chunks to retrieve from RAG search |
| `RAG_DOCUMENT_SEARCH_BACKEND` | No | AlbertRagBackend | Document backend for RAG processing |
### Function Signature
```python
def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web for up-to-date information
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search for.
Returns:
ToolReturn: Formatted search results with RAG-enhanced snippets
"""
```
### How It Works
1. **Query API**: Sends search query to Brave Search API
2. **Receive Results**: Gets list of matching web pages
3. **Create Temporary Collection**: Creates a temporary vector database collection
4. **Fetch & Store**: For each result:
- Fetches the HTML content
- Extracts the main text
- Stores in the temporary document backend
5. **RAG Search**: Performs semantic search across stored documents
6. **Map Results**: Maps RAG chunks back to original search results
7. **Format & Return**: Returns structured data with enhanced snippets
8. **Cleanup**: Temporary collection is automatically deleted
### Workflow Diagram
```
User Query
Brave Search API
Search Results (URLs)
Create Temporary Vector Collection
[For each URL]
Fetch HTML → Extract Text → Store in Vector DB
RAG Semantic Search
Retrieve Most Relevant Chunks
Map Chunks to Original URLs
Format & Return
Delete Temporary Collection
```
### Temporary Collection
The tool creates a temporary collection with a unique ID:
```python
with document_store_backend.temporary_collection(f"tmp-{uuid.uuid4()}") as document_store:
# Fetch and store documents
# Perform search
# Collection is automatically deleted on exit
```
### RAG Search
The RAG backend performs semantic search to find the most relevant content:
```python
rag_results = document_store.search(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
**kwargs, # Additional search parameters like session with access_token
)
```
Returns chunks ranked by relevance to the query, not just keyword matching.
### Token Usage Tracking
The tool tracks LLM tokens used during RAG processing:
```python
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
```
### Document Backend
The default backend is `AlbertRagBackend`, but you can configure a different one:
```bash
RAG_DOCUMENT_SEARCH_BACKEND=chat.agent_rag.document_rag_backends.custom_backend.CustomBackend
```
### Add to Model
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_brave_with_document_backend"
]
}
]
}
```
### Example Usage
**User**: "Explain the concept of async views in Django"
**Tool Call**: `web_search_brave_with_document_backend(ctx, "Django async views explained")`
**Tool Response**:
```python
{
"0": {
"url": "https://docs.djangoproject.com/en/stable/topics/async/",
"title": "Asynchronous support",
"snippets": [
"Django has support for writing asynchronous views...",
"Async views are declared using Python's async def syntax..."
]
},
# ... more results with relevant chunks
}
```
### Registration
```python
"web_search_brave_with_document_backend": Tool(
web_search_brave_with_document_backend,
takes_ctx=True,
prepare=only_if_web_search_enabled,
)
```
### Advantages Over Standard web_search_brave
| Feature | web_search_brave | web_search_brave_with_document_backend |
|-------------------|--------------------------------|----------------------------------------|
| Content Retrieval | Full page or summary | Semantic chunks |
| Relevance | Keyword-based | Semantic similarity |
| Token Efficiency | May include irrelevant content | Only relevant chunks |
| Processing | Simpler, faster | More intelligent, slower |
| Cost | Lower | Higher (RAG processing) |
| Best For | General search | Deep research, technical queries |
---
## Deprecated: web_search_albert_rag
### ⚠️ Deprecation Notice
The `web_search_albert_rag` tool is **deprecated** and should not be used in new implementations.
**Replacement**: Use `web_search_brave_with_document_backend` instead, which provides:
- Better performance
- More control over the RAG backend
- Temporary collections (no cleanup issues)
- Token usage tracking
- Parallel processing support
### Why Deprecated?
- Limited to Albert API only
- No control over document backend
- Less flexible than the new approach
- Maintenance burden
### Timeline
- **Current**: Still functional but not recommended
- **Future**: Will be removed in a future version
---
## Comparison
### When to Use Which Tool?
#### Use `web_search_brave`
**Best for**:
- General web search queries
- Quick information retrieval
- When speed is important
- Lower cost requirements
- Simple fact-finding
**Not ideal for**:
- Deep research requiring precise context
- Technical documentation queries
- When semantic relevance is crucial
#### Use `web_search_brave_with_document_backend`
**Best for**:
- Complex technical queries
- Research requiring precise context
- When semantic relevance is important
- Questions needing deep understanding
- Documentation and how-to queries
**Not ideal for**:
- Simple factual queries
- When speed is critical
- Budget-constrained scenarios
- High-volume usage
---
## Best Practices
### Query Formulation
Help the LLM formulate effective queries:
```python
# Good queries
"Python asyncio tutorial 2024"
"Django REST framework authentication"
"React hooks best practices"
# Poor queries
"tell me about programming" # Too vague
"how do I do the thing with the stuff" # Unclear
```
### Performance Optimization
#### 1. Optimize Cache
```bash
# Longer cache for stable content
BRAVE_CACHE_TTL=3600 # 1 hour
# Shorter cache for dynamic content
BRAVE_CACHE_TTL=300 # 5 minutes
```
#### 2. Control Result Count
```bash
# Fewer results = faster responses
BRAVE_MAX_RESULTS=5
# More results = more comprehensive
BRAVE_MAX_RESULTS=10
```
### Summarization Best Practices
Only enable summarization when needed:
```bash
# Enable for long-form content
BRAVE_SUMMARIZATION_ENABLED=True
# Disable for speed
BRAVE_SUMMARIZATION_ENABLED=False
```
**Cost consideration**: Summarization makes additional LLM calls for each result,
significantly increasing costs (and execution time).
### RAG Configuration
For `web_search_brave_with_document_backend`:
```bash
# More chunks = more context, higher cost
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=10
# Fewer chunks = faster, less context
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER=5
```
### Search Parameters
```bash
# Localize results
BRAVE_SEARCH_COUNTRY=FR
BRAVE_SEARCH_LANG=fr
# Safe search for public deployments
BRAVE_SEARCH_SAFE_SEARCH=strict
# Enable spell check for better results
BRAVE_SEARCH_SPELLCHECK=True
```
---
## Troubleshooting
### Common Issues
#### 1. No Results Returned
**Symptoms**: Empty results or no snippets
**Causes**:
- Query too specific
- Content extraction failed
- Trafilatura couldn't parse the pages
**Solutions**:
```bash
# Enable extra snippets
BRAVE_SEARCH_EXTRA_SNIPPETS=True
# Increase result count
BRAVE_MAX_RESULTS=10
# Check logs for extraction errors
```
#### 2. API Errors
**Symptoms**: HTTP errors, authentication failures
**Causes**:
- Invalid API key
- Rate limit exceeded
- API service issues
**Solutions**:
```bash
# Verify API key is set
echo $BRAVE_API_KEY
# Check Brave API dashboard for limits
# Implement rate limiting in your application
```
#### 3. The tool is not being called
**Symptoms**: LLM doesn't use the tool even when appropriate
**Causes**:
- Web search not enabled for the conversation
- Tool not in model configuration
**Solutions**:
- Check conversation settings have `web_search_enabled=True`
- Verify tool is in the model's `tools` list
---
## Security Considerations
This tool is quite "raw", so be cautious about:
- the results returned by the web search
- the context size which might be large when not using summarization or RAG if long results are returned
- the query content which might include sensitive information
- ...
### Content Validation
Be aware that fetched content may contain:
- Malicious scripts (mitigated by text extraction)
- Inappropriate content
- Misinformation
- Biased information
The LLM should evaluate sources critically.
---
## See Also
- [Tools Overview](../tools.md)
- [Tavily Web Search Tool](web_search_tavily.md)
- [LLM Configuration](../llm-configuration.md)
- [Environment Variables](../env.md)
- [Brave Search API Documentation](https://brave.com/search/api/)
+370
View File
@@ -0,0 +1,370 @@
# web_search_tavily Tool
## Overview
The `web_search_tavily` tool enables the conversation agent to search the web for up-to-date
information using the [Tavily Search API](https://tavily.com/).
## Purpose
This tool allows the LLM to:
- Access current, real-time information beyond its training data
- Answer questions about recent events, news, or developments
- Provide factual information with sources
- Retrieve specific information from the web
## Configuration
### Prerequisites
1. **Tavily API Key**: Sign up at [Tavily](https://tavily.com/) to get an API key
2. **Environment Variables**: Configure the required settings
### Environment Variables
| Variable | Required | Default | Description |
|----------------------|----------|---------|--------------------------------------------|
| `TAVILY_API_KEY` | **Yes** | None | Your Tavily API key |
| `TAVILY_MAX_RESULTS` | No | 5 | Maximum number of search results to return |
| `TAVILY_API_TIMEOUT` | No | 10 | API request timeout in seconds |
### Configuration Example
```bash
# .env file
TAVILY_API_KEY=tvly-your-api-key-here
TAVILY_MAX_RESULTS=5
TAVILY_API_TIMEOUT=10
```
### Add to Model
To enable this tool for a model, add it to the `tools` list in your LLM configuration:
```json
{
"models": [
{
"hrid": "my-model",
"tools": [
"web_search_tavily"
]
}
]
}
```
Or via environment variable when using local environment settings:
```ini
AI_AGENT_TOOLS=web_search_tavily
```
## Function Signature
```python
def web_search_tavily(query: str) -> list[dict]:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
list[dict]: A list of search results, each represented as a dictionary.
"""
```
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------------------|
| `query` | str | Yes | The search query string |
## Return Value
Returns a list of dictionaries, each containing:
```python
{
"link": str, # URL of the result
"title": str, # Title of the page
"snippet": str # Content snippet from the page
}
```
### Example Return Value
```python
[
{
"link": "https://example.com/article1",
"title": "Introduction to Python",
"snippet": "Python is a high-level programming language known for its simplicity..."
},
{
"link": "https://example.com/article2",
"title": "Python Best Practices",
"snippet": "Follow these best practices to write clean and efficient Python code..."
}
]
```
## How the LLM Uses It
When a user asks for current information or specific facts:
1. **LLM recognizes** the need for external information
2. **Formulates** an appropriate search query
3. **Calls** `web_search_tavily(query="search terms")`
4. **Receives** a list of search results
5. **Synthesizes** the information into a response
6. **Provides** the answer with source references
### Example Conversation
**User**: "What are the latest developments in quantum computing?"
**LLM** (internal): *Calls `web_search_tavily("latest developments quantum computing 2024")`*
**Tool Response**:
```python
[
{
"link": "https://techcrunch.com/quantum-news",
"title": "Major Breakthrough in Quantum Computing",
"snippet": "Researchers announced a significant breakthrough..."
},
# ... more results
]
```
**LLM** (to user): "Based on recent sources, there have been several developments in quantum computing.
Researchers recently announced a breakthrough in error correction. Additionally, new quantum processors
with improved qubit stability have been unveiled..."
## Implementation Details
### Source Code
Located at: `src/backend/chat/tools/web_search_tavily.py`
```python
"""Web search tool using Tavily for the chat agent."""
from django.conf import settings
import requests
def web_search_tavily(query: str) -> list[dict]:
"""
Search the web for up-to-date information
Args:
query (str): The query to search for.
Returns:
list[dict]: A list of search results, each represented as a dictionary.
"""
url = "https://api.tavily.com/search"
data = {
"query": query,
"api_key": settings.TAVILY_API_KEY,
"max_results": settings.TAVILY_MAX_RESULTS,
}
response = requests.post(url, json=data, timeout=settings.TAVILY_API_TIMEOUT)
response.raise_for_status()
json_response = response.json()
raw_search_results = json_response.get("results", [])
return [
{
"link": result["url"],
"title": result.get("title", ""),
"snippet": result.get("content"),
}
for result in raw_search_results
]
```
### Registration
The tool is registered in `src/backend/chat/tools/__init__.py`:
```python
"web_search_tavily": Tool(
web_search_tavily,
takes_ctx=False,
prepare=only_if_web_search_enabled
)
```
Note that:
- `takes_ctx=False` - This tool doesn't need the conversation context
- `prepare=only_if_web_search_enabled` - Only available when web search is enabled
## Django Settings
The tool uses these Django settings from `settings.py`:
```python
# Tavily API
TAVILY_API_KEY = values.Value(
None, # Tavily API key is not set by default
environ_name="TAVILY_API_KEY",
environ_prefix=None,
)
TAVILY_MAX_RESULTS = values.PositiveIntegerValue(
default=5,
environ_name="TAVILY_MAX_RESULTS",
environ_prefix=None,
)
TAVILY_API_TIMEOUT = values.PositiveIntegerValue(
default=10, # seconds
environ_name="TAVILY_API_TIMEOUT",
environ_prefix=None,
)
```
## Error Handling
The tool may raise exceptions in the following cases:
### Missing API Key
```python
# If TAVILY_API_KEY is not set
AttributeError: 'Settings' object has no attribute 'TAVILY_API_KEY'
```
**Solution**: Set the `TAVILY_API_KEY` environment variable
### API Errors
```python
# If the API request fails
requests.exceptions.HTTPError: 401 Unauthorized
```
**Possible causes**:
- Invalid API key
- Exceeded rate limits
- API service unavailable
### Timeout Errors
```python
# If the request takes too long
requests.exceptions.Timeout
```
**Solution**: Increase `TAVILY_API_TIMEOUT` or check network connectivity
## Best Practices
### Query Formulation
The LLM should formulate queries that are:
- **Specific and focused** - Better results with targeted queries
- **Up-to-date** - Include year or "latest" when relevant
- **Clear** - Avoid ambiguous terms
- **Concise** - Remove unnecessary words
Good query examples:
- ✅ "quantum computing breakthroughs 2024"
- ✅ "latest Python 3.12 features"
- ✅ "climate change COP29 outcomes"
Poor query examples:
- ❌ "tell me about stuff happening" (too vague)
- ❌ "what is the weather like today in Paris on November 5th 2024 at 3pm" (too specific/long)
### Rate Limiting
Be aware of Tavily API rate limits:
- Free tier: Limited requests per month
- Paid tiers: Higher limits
Monitor your usage and implement caching if needed.
### Result Count
The `TAVILY_MAX_RESULTS` setting controls how many results are returned:
- **Lower values (3-5)**: Faster responses, less context for LLM
- **Higher values (8-10)**: More comprehensive, but slower and more expensive
Recommended: **5 results** for most use cases
## Troubleshooting
### Tool Not Being Called
**Symptoms**: LLM doesn't use web search even when appropriate
**Possible causes**:
1. Web search not enabled for the conversation
2. Tool not in model configuration
3. API key not set
**Solutions**:
1. Check conversation settings have `web_search_enabled=True`
2. Verify tool is in the model's `tools` list
3. Confirm `TAVILY_API_KEY` is set
### No Results Returned
**Symptoms**: Tool returns empty list
**Possible causes**:
1. Query too specific
2. No matching results
3. API filtering results
**Solutions**:
1. Try broader query terms
2. Check Tavily dashboard for query logs
3. Review API response in logs
### Slow Responses
**Symptoms**: Tool takes a long time to respond
**Possible causes**:
1. Network latency
2. Tavily API slow
3. Timeout too high
**Solutions**:
1. Check network connectivity
2. Monitor Tavily status page
3. Adjust `TAVILY_API_TIMEOUT` if needed
## Security Considerations
This tool is quite "raw", and was currently only used for test purpose, so be cautious about:
- the results returned by the web search
- the context size which might be large if many results are returned
- the query content which might include sensitive information
- ...
## Performance Optimization
### Query Optimization
You may want to help the LLM formulate better queries by including something like this in the system prompt:
```
When using web search:
- Use specific, focused queries
- Include relevant time periods if needed
- Avoid unnecessary words
- Combine related terms
```
## See Also
- [Tools Overview](../tools.md)
- [Brave Web Search Tool](web_search_brave.md)
- [Web Search Configuration](../llm-configuration.md)
- [Environment Variables](../env.md)
+5 -1
View File
@@ -1,8 +1,12 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
# LLM
LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.json
# Features
FEATURE_FLAG_WEB_SEARCH=ENABLED
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
AUTO_TITLE_AFTER_USER_MESSAGES=3
-6
View File
@@ -1,6 +0,0 @@
{
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@ai-sdk/ui-utils": "^1.2.11"
}
}
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.13.3-alpine
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools lorem-text
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apk add --no-cache git
# Install the package
RUN pip install git+https://github.com/etalab-ia/openmockllm.git
# Expose the default port
EXPOSE 8000
# Set default command
CMD ["openmockllm", "--host", "0.0.0.0", "--port", "8000"]
+19
View File
@@ -0,0 +1,19 @@
[OpenMockLLM](https://github.com/etalab-ia/OpenMockLLM) is a FastAPI-based mock LLM API server that simulates
several Large Language Model API providers.
This is a simple docker image to run the server for testing and development purposes (E2E tests mainly).
It's a bit overkill to have a dedicated image for that, but it allows simple E2E stack with docker-compose since
our code is also run in Docker containers.
## Build and Run manually
```bash
docker build -t openmockllm .
docker run -p 8000:8000 openmockllm
```
## Next steps
- Add more chat completion behaviors (specific text streaming, function calling, etc.)
- Pin a specific OpenMockLLM version in the Dockerfile
+18 -1
View File
@@ -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
)
@@ -3,16 +3,17 @@
import json
import logging
from io import BytesIO
from typing import Optional
from typing import List, Optional
from urllib.parse import urljoin
from django.conf import settings
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__)
@@ -25,26 +26,32 @@ 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.
"""
def __init__(self, collection_id: Optional[str] = None):
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
# Initialize any necessary parameters or configurations here
super().__init__(collection_id)
super().__init__(collection_id, read_only_collection_id)
self._base_url = settings.ALBERT_API_URL
self._headers = {
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
}
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:
"""
@@ -65,7 +72,28 @@ 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:
async def acreate_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
self._collections_endpoint,
headers=self._headers,
json={
"name": name,
"description": description or self._default_collection_description,
"visibility": "private",
},
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
self.collection_id = str(response.json()["id"])
return self.collection_id
def delete_collection(self, **kwargs) -> None:
"""
Delete the current collection
"""
@@ -76,59 +104,19 @@ 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:
async def adelete_collection(self, **kwargs) -> None:
"""
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.
Asynchronously delete the current collection
"""
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
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.delete(
urljoin(f"{self._collections_endpoint}/", self.collection_id),
headers=self._headers,
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
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.
@@ -136,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),
@@ -150,22 +139,51 @@ 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:
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.
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(
urljoin(self._base_url, self._documents_endpoint),
headers=self._headers,
files={
"file": (f"{name}.md", BytesIO(content.encode("utf-8")), "text/markdown"),
},
data={
"collection": int(self.collection_id),
"metadata": json.dumps({"document_name": name}), # undocumented API
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug(response.json())
response.raise_for_status()
def search(self, query: 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.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
response = requests.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [int(self.collection_id)],
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
@@ -190,3 +208,51 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
completion_tokens=searches.usage.completion_tokens,
),
)
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.
"""
collection_ids = self.get_all_collection_ids() # might raise RuntimeError
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug("Search response: %s %s", response.text, response.status_code)
response.raise_for_status()
searches = Searches(**response.json())
return RAGWebResults(
data=[
RAGWebResult(
url=result.chunk.metadata["document_name"],
content=result.chunk.content,
score=result.score,
)
for result in searches.data
],
usage=RAGWebUsage(
prompt_tokens=searches.usage.prompt_tokens,
completion_tokens=searches.usage.completion_tokens,
),
)
@@ -1,23 +1,76 @@
"""Implementation of the Albert API for RAG document search."""
import logging
from contextlib import contextmanager
from io import BytesIO
from typing import Optional
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager, contextmanager
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__(self, collection_id: Optional[str] = None):
"""Backend settings."""
self.collection_id = collection_id
self._default_collection_description = "Temporary collection for RAG document search"
def __init__(
self,
collection_id: Optional[str] = None,
read_only_collection_id: Optional[List[str]] = None,
):
"""
Backend settings.
Collection ID is required for RAG operations, where you want to manage the collection
lifecycle (create/delete).
Read-only collection IDs can be used to access existing collections
without managing their lifecycle.
Collection ID and read-only collection IDs are separated in the implementation to prevent
unwanted actions.
Args:
collection_id (Optional[str]): The collection ID for managing the collection lifecycle.
read_only_collection_id (Optional[List[str]]): List of read-only collection IDs.
"""
self.collection_id = collection_id
self.read_only_collection_id = read_only_collection_id or []
self._default_collection_description = "Temporary collection for RAG document search"
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]:
"""
Get all collection IDs, including the main collection ID and read-only collection IDs.
Returns:
List[str]: List of all collection IDs.
Raises:
RuntimeError: If neither collection_id nor read_only_collection_id is provided.
"""
if not self.collection_id and not self.read_only_collection_id:
raise RuntimeError("The RAG backend requires collection_id or read_only_collection_id")
collection_ids = []
if self.collection_id:
collection_ids.append(self.cast_collection_id(self.collection_id))
if self.read_only_collection_id:
collection_ids.extend(
[
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.
@@ -25,7 +78,14 @@ class BaseRagBackend:
"""
raise NotImplementedError("Must be implemented in subclass.")
def parse_document(self, name: str, content_type: str, content: BytesIO):
async def acreate_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
return await sync_to_async(self.create_collection)(name=name, description=description)
def parse_document(self, name: str, content_type: str, content: bytes):
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
@@ -34,53 +94,95 @@ 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 Albert collection.
This method should handle the logic to send the document content to the Albert API.
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
Args:
name (str): The name of the document.
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.")
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO) -> str:
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.
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, **kwargs)
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.")
def search(self, query, results_count: int = 4) -> RAGWebResults:
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)(**kwargs)
@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: str, results_count: int = 4, **kwargs) -> RAGWebResults:
"""
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, **kwargs)
@classmethod
@contextmanager
def temporary_collection(cls, name: str, description: Optional[str] = None):
@@ -92,3 +194,17 @@ class BaseRagBackend:
yield backend
finally:
backend.delete_collection()
@classmethod
@asynccontextmanager
async def temporary_collection_async(
cls, name: str, description: Optional[str] = None, **kwargs
):
"""Context manager for RAG backend with temporary collections."""
backend = cls()
await backend.acreate_collection(name=name, description=description)
try:
yield backend
finally:
await backend.adelete_collection(**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")
+13 -18
View File
@@ -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,22 +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, system_prompt=_system_prompt, tools=_tools, **kwargs
)
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]
+31 -16
View File
@@ -16,7 +16,6 @@ from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
@@ -102,10 +101,10 @@ class ConversationAgent(BaseAgent):
if settings.WARNING_MOCK_CONVERSATION_AGENT:
self._model = FunctionModel(stream_function=mocked_agent_model)
@self.system_prompt
@self.instructions
def add_the_date() -> str:
"""
Dynamic system prompt function to add the current date.
Dynamic instruction function to add the current date.
Warning: this will always use the date in the server timezone,
not the user's timezone...
@@ -113,22 +112,38 @@ class ConversationAgent(BaseAgent):
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
return f"Today is {_formatted_date}."
@self.system_prompt
@self.instructions
def enforce_response_language() -> str:
"""Dynamic system prompt function to set the expected language to use."""
"""Dynamic instruction function to set the expected language to use."""
return f"Answer in {get_language_name(language).lower()}." if language else ""
def get_web_search_tool_name(self) -> str | None:
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))
-62
View File
@@ -4,11 +4,6 @@ import dataclasses
import logging
from django.conf import settings
from django.core.files.storage import default_storage
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext
from pydantic_ai.messages import ToolReturn
from .base import BaseAgent
@@ -26,60 +21,3 @@ class SummarizationAgent(BaseAgent):
output_type=str,
**kwargs,
)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
"""
Generate a complete, ready-to-use summary of the documents in context
(do not request the documents to the user).
Return this summary directly to the user WITHOUT any modification,
or additional summarization.
The summary is already optimized and MUST be presented as-is in the final response
or translated preserving the information.
"""
summarization_agent = SummarizationAgent()
prompt = (
"Do not mention the user request in your answer.\n"
"User request:\n"
"{user_prompt}\n\n"
"Document contents:\n"
"{documents_prompt}\n"
)
text_attachment = await sync_to_async(list)(
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
)
documents = [await read_document_content(doc) for doc in text_attachment]
documents_prompt = "\n\n".join(
[
(f"<document>\n<name>\n{name}\n</name>\n<content>\n{content}\n</content>\n</document>")
for name, content in documents
]
)
formatted_prompt = prompt.format(
user_prompt=ctx.prompt,
documents_prompt=documents_prompt,
)
logger.debug("Summarize prompt: %s", formatted_prompt)
response = await summarization_agent.run(formatted_prompt, usage=ctx.usage)
logger.debug("Summarize response: %s", response)
return ToolReturn(
return_value=response.output,
metadata={"sources": {doc[0] for doc in documents}},
)
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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."""
+171
View File
@@ -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()
+9
View File
@@ -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"),
),
]
+91 -1
View File
@@ -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):
"""
+99 -6
View File
@@ -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
@@ -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)
@@ -27,9 +25,14 @@ def test_build_pydantic_agent_success_no_tools():
"""Test successful agent creation without tools."""
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ()
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[2].__name__ == "enforce_response_language"
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -37,6 +40,7 @@ def test_build_pydantic_agent_success_no_tools():
assert agent._function_toolset.tools == {}
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_build_pydantic_agent_with_tools(settings):
"""Test successful agent creation with tools."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -44,8 +48,14 @@ def test_build_pydantic_agent_with_tools(settings):
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -56,64 +66,49 @@ def test_build_pydantic_agent_with_tools(settings):
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_add_dynamic_system_prompt():
"""
Ensure add_the_date and enforce_response_language system prompt are registered
Ensure add_the_date and enforce_response_language instructions are registered
and returns proper values.
"""
agent = ConversationAgent(model_hrid="default-model")
assert len(agent._system_prompt_functions) == 2
assert len(agent._system_prompt_functions) == 0
assert agent._system_prompt_functions[0].function.__name__ == "add_the_date"
assert agent._system_prompt_functions[0].function() == "Today is Friday 25/07/2025."
assert agent._system_prompt_functions[1].function.__name__ == "enforce_response_language"
assert agent._system_prompt_functions[1].function() == ""
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
agent = ConversationAgent(model_hrid="default-model", language="fr-fr")
assert agent._system_prompt_functions[1].function() == "Answer in french."
assert agent._instructions[2]() == "Answer in french."
def test_agent_get_web_search_tool_name(settings):
"""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,66 @@
"""Unit tests for add_document_rag_search_tool_from_setting integration with AIAgentService."""
import pytest
from core.factories import UserFactory
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
from chat.llm_configuration import LLModel, LLMProvider
pytestmark = pytest.mark.django_db()
def test_ai_agent_service_adds_rag_tools_from_settings(settings):
"""Test that AIAgentService adds RAG tools from SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.LLM_CONFIGURATIONS = {
"default-model": LLModel(
hrid="default-model",
model_name="amazing-llm",
human_readable_name="Amazing LLM",
is_active=True,
icon=None,
system_prompt="You are an amazing assistant.",
tools=[],
provider=LLMProvider(hrid="unused", base_url="https://example.com", api_key="key"),
),
}
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services."
),
},
}
user = UserFactory()
conversation = ChatConversationFactory(owner=user)
# Create the service
service = AIAgentService(conversation, user=user)
# Check that tools were added to the conversation_agent
agent_tools = service.conversation_agent._function_toolset.tools # pylint: disable=protected-access
assert "legal_documents" in agent_tools
assert "french_public_services" in agent_tools
# Verify tool names and descriptions
assert agent_tools["legal_documents"].name == "legal_documents"
assert (
agent_tools["legal_documents"].description
== "Use this tool to search legal documents and laws."
)
assert agent_tools["french_public_services"].name == "french_public_services"
assert (
agent_tools["french_public_services"].description
== "Use this tool when the user asks for information about French public services."
)
@@ -0,0 +1,270 @@
"""Unit tests for Langfuse tracing in AIAgentService."""
import pytest
import responses
from asgiref.sync import sync_to_async
from langfuse import Langfuse
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.factories import UserFactory
from chat.ai_sdk_types import TextUIPart, UIMessage
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory
pytestmark = pytest.mark.django_db()
@pytest.fixture(name="langfuse_client", scope="function")
def langfuse_client_fixture():
"""Fixture to init langfuse for tests."""
langfuse_client = Langfuse(
public_key="pk-test-key",
secret_key="sk-test-key",
host="https://langfuse.example.com",
environment="test",
debug=True,
)
yield langfuse_client
langfuse_client._resources.prompt_cache._task_manager.shutdown() # pylint: disable=protected-access
langfuse_client.shutdown()
@pytest.fixture(autouse=True)
def base_settings(settings):
"""Set up base settings for the tests."""
settings.AI_BASE_URL = "https://api.llm.com/v1/"
settings.AI_API_KEY = "test-key"
settings.AI_MODEL = "model-123"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
settings.AI_AGENT_TOOLS = []
@pytest.fixture(name="ui_messages")
def ui_messages_fixture():
"""Fixture for test UI messages."""
return [
UIMessage(
id="msg-1",
role="user",
content="Hello, how are you?",
parts=[TextUIPart(type="text", text="Hello, how are you?")],
)
]
@pytest.fixture(name="agent_model")
def agent_model_fixture():
"""Fixture for agent model function."""
async def _agent_model(_messages: list[ModelMessage], _info: AgentInfo):
"""Simple agent model that returns a fixed response."""
yield "Hello! I'm doing well, thank you for asking."
return FunctionModel(stream_function=_agent_model)
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_allowed(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created when enabled and user allows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics enabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_langfuse_span_created_when_enabled_and_analytics_disabled(
agent_model, ui_messages, settings, langfuse_client
):
"""Test Langfuse span is created even when user disallows analytics."""
settings.LANGFUSE_ENABLED = True
# Mock Langfuse HTTP endpoints
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/otel/v1/traces",
json={"success": True},
status=200,
)
# Create user with analytics disabled
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify Langfuse HTTP calls were made
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://langfuse.example.com/api/public/otel/v1/traces"
)
# quite complex to parse the full body, so just check that expected output is in there
assert b"Hello! I'm doing well, thank you for asking." not in responses.calls[0].request.body
assert b"REDACTED" in responses.calls[0].request.body
@pytest.mark.asyncio
@responses.activate
async def test_no_langfuse_span_when_disabled(agent_model, ui_messages, settings, langfuse_client):
"""Test Langfuse span is not created when Langfuse is disabled."""
settings.LANGFUSE_ENABLED = False
# Mock Langfuse HTTP endpoints (should not be called)
responses.add(
responses.POST,
"https://langfuse.example.com/api/public/ingestion",
json={"success": True},
status=200,
)
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
results = []
with service.conversation_agent.override(model=agent_model):
async for result in service.stream_text_async(ui_messages):
results.append(result)
# Verify that results were generated
assert results == ["Hello! I'm doing well, thank you for asking."]
langfuse_client.flush()
# Verify NO Langfuse HTTP calls were made
assert len(responses.calls) == 0
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_enabled(settings):
"""Test service correctly sets flags when Langfuse and analytics are enabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is True
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_settings_with_analytics_disabled(settings):
"""Test service correctly sets flags when Langfuse enabled but analytics disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = await sync_to_async(UserFactory)(allow_conversation_analytics=False)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is True
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
@pytest.mark.asyncio
async def test_instrumentation_disabled_when_langfuse_disabled(settings):
"""Test service correctly sets flags when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = await sync_to_async(UserFactory)(allow_conversation_analytics=True)
conversation = await sync_to_async(ChatConversationFactory)(owner=user)
service = AIAgentService(conversation, user=user)
# Verify that flags are set correctly
assert service._langfuse_available is False
assert service._store_analytics is False
# ConversationAgent should be created successfully
assert service.conversation_agent is not None
def test_store_analytics_flag_when_langfuse_enabled_and_user_allows(settings):
"""Test _store_analytics is True when Langfuse enabled and user allows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is True
def test_store_analytics_flag_when_langfuse_enabled_and_user_disallows(settings):
"""Test _store_analytics is False when Langfuse enabled but user disallows analytics."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = True
user = UserFactory(allow_conversation_analytics=False)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is True
assert service._store_analytics is False
def test_store_analytics_flag_when_langfuse_disabled(settings):
"""Test _store_analytics is False when Langfuse is disabled."""
# pylint: disable=protected-access
settings.LANGFUSE_ENABLED = False
user = UserFactory(allow_conversation_analytics=True)
conversation = ChatConversationFactory(owner=user)
service = AIAgentService(conversation, user=user)
assert service._langfuse_available is False
assert service._store_analytics is False
@@ -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
@@ -3,11 +3,11 @@
import datetime
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ImageUrl
from pydantic_ai.messages import (
@@ -37,27 +37,22 @@ from chat.ai_sdk_types import (
from chat.clients.pydantic_ui_message_converter import model_message_to_ui_message
@pytest.fixture(autouse=True)
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
with patch("uuid.uuid4", return_value=uuid.UUID("f0cc3bb5-f207-401b-8281-4cba6202991d")):
yield
def test_model_message_to_ui_message_text_user_full():
"""Test converting a ModelRequest with UserPromptPart containing text to UIMessage."""
timestamp = datetime.datetime.now()
model_message = ModelRequest(
parts=[UserPromptPart(content="Hello!", timestamp=timestamp)], kind="request"
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="user",
content="Hello!",
parts=[TextUIPart(type="text", text="Hello!")],
createdAt=timestamp,
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -65,14 +60,15 @@ def test_model_message_to_ui_message_text_user_full():
def test_model_message_to_ui_message_text_assistant_full():
"""Test converting a ModelResponse with TextPart to UIMessage."""
model_message = ModelResponse(parts=[TextPart(content="Hi there!")])
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="Hi there!",
parts=[TextUIPart(type="text", text="Hi there!")],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -83,8 +79,10 @@ def test_model_message_to_ui_message_tool_call_full():
model_message = ModelResponse(
parts=[ToolCallPart(tool_call_id="id1", tool_name="tool", args=args)]
)
result = model_message_to_ui_message(model_message)
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=result.id, # Use the generated ID
role="assistant",
content="",
parts=[
@@ -100,7 +98,7 @@ def test_model_message_to_ui_message_tool_call_full():
],
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result == expected
@@ -109,7 +107,7 @@ def test_model_message_to_ui_message_reasoning_full():
"""Test converting a ModelResponse with ThinkingPart to UIMessage."""
model_message = ModelResponse(parts=[ThinkingPart(content="reason", signature="sig")])
expected = UIMessage(
id="f0cc3bb5-f207-401b-8281-4cba6202991d", # Mocked UUID
id=str(uuid.uuid4()), # not used in comparison
role="assistant",
content="",
parts=[
@@ -122,7 +120,7 @@ def test_model_message_to_ui_message_reasoning_full():
createdAt=timezone.now(),
)
result = model_message_to_ui_message(model_message)
assert result.id == expected.id
assert result.id == IsUUID(4)
assert result.role == expected.role
assert result.content == expected.content
assert result.createdAt == expected.createdAt
+74 -1
View File
@@ -2,18 +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)
@@ -50,8 +57,74 @@ def mock_ai_agent_service_fixture():
yield _mock_service
@pytest.fixture(name="mock_summarization_agent")
def mock_summarization_agent_fixture():
"""Fixture to mock SummarizationAgent with a custom model."""
@contextmanager
def _mock_agent(model):
"""Context manager to mock SummarizationAgent with a custom model."""
with ExitStack() as stack:
class SummarizationAgentMock(SummarizationAgent):
"""Mocked SummarizationAgent to override the model."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# We cannot use stack.enter_context(agent.override(model=model))
# Because the agent is used outside of this context manager.
# So we directly override the protected member.
logger.info("Overriding SummarizationAgent model with %s", model)
self._model = model # pylint: disable=protected-access
# Mock the SummarizationAgent in all relevant modules, because first import wins
stack.enter_context(
patch("chat.agents.summarize.SummarizationAgent", new=SummarizationAgentMock)
)
stack.enter_context(
patch(
"chat.tools.document_summarize.SummarizationAgent", new=SummarizationAgentMock
)
)
yield
yield _mock_agent
PIXEL_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
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)
@@ -0,0 +1,403 @@
"""
Unit tests for document generic search RAG tool functionality.
"""
import json
import logging
import httpx
import pytest
import responses
import respx
from asgiref.sync import sync_to_async
from pydantic_ai import Agent, RunContext, RunUsage
from core.factories import UserFactory
from chat.tools.document_generic_search_rag import (
add_document_rag_search_tool_from_setting,
get_specific_rag_search_tool_config,
)
pytestmark = pytest.mark.django_db()
def test_get_specific_rag_search_tool_config_with_disabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "disabled",
"tool_description": "Use this tool to search French legal documents and laws.",
"rag_backend_name": "chat.tests.tools.test_document_generic_search_rag.MockRagBackend",
},
}
# The fixture tools are disabled by default
assert get_specific_rag_search_tool_config(user) == {}
def test_get_specific_rag_search_tool_config_with_enabled_features(settings):
"""Test get_specific_rag_search_tool_config returns tools for enabled features."""
user = UserFactory()
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "enabled",
"tool_description": "Use this tool when the user "
"asks for information about "
"French public services, the "
"French labor market, "
"employment laws, social "
"benefits, or assistance with "
"administrative procedures.",
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
@responses.activate
def test_get_specific_rag_search_tool_config_with_dynamic_features(settings, posthog):
"""Test get_specific_rag_search_tool_config with dynamic features."""
user = UserFactory()
responses.post(
f"{posthog.host}/flags/?v=2",
json={"flags": {"legal-documents": {"enabled": True}}},
status=200,
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "dynamic",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
},
}
assert get_specific_rag_search_tool_config(user) == {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "dynamic",
"tool_description": "Use this tool to search French legal documents and laws.",
}
}
def test_add_document_rag_search_tool_from_setting_adds_tools(settings):
"""Test that add_document_rag_search_tool_from_setting adds tools to the agent."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
add_document_rag_search_tool_from_setting(agent, user)
# Check that tools were added
assert len(agent._function_toolset.tools) == 1 # pylint: disable=protected-access
assert agent._function_toolset.tools["legal_documents"].name == "legal_documents" # pylint: disable=protected-access
assert (
agent._function_toolset.tools["legal_documents"].description # pylint: disable=protected-access
== "Use this tool to search legal documents and laws."
)
assert agent._function_toolset.tools["legal_documents"].function_schema.json_schema == { # pylint: disable=protected-access
"additionalProperties": False,
"properties": {
"query": {"description": "The query to search information about.", "type": "string"}
},
"required": ["query"],
"type": "object",
}
def test_add_document_rag_search_tool_with_invalid_backend(settings, caplog):
"""Test that invalid backend import is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"rag_backend_name": "non.existent.Backend",
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "Could not import RAG backend non.existent.Backend" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_collection_ids(settings, caplog):
"""Test that missing collection_ids is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No collection IDs provided for tool legal_documents" in caplog.records[0].message
def test_add_document_rag_search_tool_with_missing_tool_description(settings, caplog):
"""Test that missing tool_description is handled gracefully."""
caplog.set_level(logging.WARNING, logger="chat.tools.document_generic_search_rag")
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
},
}
user = UserFactory()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Tool should not be added due to import error
assert len(agent._function_toolset.tools) == 0 # pylint: disable=protected-access
# Check that warning was logged
assert len(caplog.records) == 1
assert "No tool description provided for tool legal_documents" in caplog.records[0].message
@respx.mock
def test_document_search_rag_tool_execution(settings):
"""Test that the generated RAG tool executes correctly."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
"legal_documents_2": {
"collection_ids": [200],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = UserFactory()
agent = Agent(model="test")
add_document_rag_search_tool_from_setting(agent, user)
result = agent.run_sync("What information can you find about French services?")
# Verify the result
assert json.loads(result.output) == {
"legal_documents": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
"legal_documents_2": {"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}},
}
assert len(search_mock.calls) == 2
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
assert json.loads(search_mock.calls[1].request.content) == {
"collections": [200],
"k": 4,
"prompt": "a",
"score_threshold": 0.6,
}
def test_get_specific_rag_search_tool_config_with_empty_settings(settings):
"""Test get_specific_rag_search_tool_config with empty SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS."""
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {}
user = UserFactory()
config = get_specific_rag_search_tool_config(user)
assert config == {}
@pytest.mark.asyncio
@respx.mock
async def test_add_document_rag_search_tool_function_call(settings):
"""Test the function behavior."""
search_mock = respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=200,
json={
"data": [
{
"method": "semantic",
"chunk": {
"id": 1,
"content": "Relevant content snippet.",
"metadata": {"document_name": "doc1.txt"},
},
"score": 0.9,
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
assert result.return_value == {
"0": {"snippets": "Relevant content snippet.", "url": "doc1.txt"}
}
assert result.metadata == {"sources": {"doc1.txt"}}
assert len(search_mock.calls) == 1
assert json.loads(search_mock.calls[0].request.content) == {
"collections": [100, 101, 102],
"k": 4,
"prompt": "Find information about French laws.",
"score_threshold": 0.6,
}
@pytest.mark.asyncio
@respx.mock
async def test_document_search_rag_http_status_error(settings, caplog):
"""Test that HTTPStatusError is properly handled and logged."""
caplog.set_level(logging.ERROR, logger="chat.tools.document_generic_search_rag")
# Mock the API to return a 500 error
respx.post("https://albert.api.etalab.gouv.fr/v1/search").mock(
return_value=httpx.Response(
status_code=500,
json={"error": "Internal server error"},
)
)
settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"legal_documents": {
"collection_ids": [100, 101, 102],
"feature_flag_value": "enabled",
"tool_description": "Use this tool to search legal documents and laws.",
},
}
user = await sync_to_async(UserFactory)()
agent = Agent("test")
add_document_rag_search_tool_from_setting(agent, user)
# Call the tool function and expect a ModelRetry to be raised and caught
tool_result = await agent._function_toolset.tools["legal_documents"].function( # pylint: disable=protected-access
RunContext(model="test", usage=RunUsage(), deps={}),
query="Find information about French laws.",
)
# Verify the exception message
assert tool_result == (
"Document search service is currently unavailable: Server error '500 Internal "
"Server Error' for url 'https://albert.api.etalab.gouv.fr/v1/search'\n"
"For more information check: "
"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 You must "
"explain this to the user and not try to answer based on your knowledge."
)
# Verify that error was logged
assert "RAG document search failed for tool legal_documents" in caplog.records[0].message
assert "Document search service is currently unavailable" in caplog.records[1].message
@@ -0,0 +1,472 @@
"""Tests for document_summarize functionality."""
import io
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from pydantic_ai import ModelResponse, RunContext, TextPart
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.usage import RunUsage
from chat.agents.summarize import SummarizationAgent
from chat.llm_configuration import LLModel, LLMProvider
from chat.tools.document_summarize import document_summarize, summarize_chunk
@pytest.fixture(autouse=True)
def fixture_summarization_agent_config(settings):
"""Fixture to set used settings for agent configuration."""
settings.LLM_CONFIGURATIONS = {
settings.LLM_SUMMARIZATION_MODEL_HRID: LLModel(
hrid="mistral-model",
model_name="mistral-7b-instruct-v0.1",
human_readable_name="Mistral 7B Instruct",
profile=None,
provider=LLMProvider(
hrid="mistral",
kind="mistral",
base_url="https://api.mistral.ai/v1",
api_key="testkey",
),
is_active=True,
system_prompt="direct",
tools=[],
),
}
@pytest.fixture(name="mocked_context")
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = mock.Mock(spec=RunContext)
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
return mock_ctx
def mocked_summary(_messages, _info=None):
"""Mocked summary response."""
return ModelResponse(parts=[TextPart(content="This is a summary of the test chunk.")])
@pytest.mark.asyncio
async def test_summarize_chunk_returns_summary(mocked_context):
"""Test that summarize_chunk returns a summary."""
summarization_agent = SummarizationAgent()
with summarization_agent.override(model=FunctionModel(mocked_summary)):
chunk = "This is a test chunk of text that needs to be summarized."
result = await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
assert result == "This is a summary of the test chunk."
@pytest.mark.asyncio
async def test_summarize_chunk_raises_model_retry_on_error(mocked_context):
"""Test that summarize_chunk raises ModelRetry when agent fails."""
summarization_agent = SummarizationAgent()
def mocked_summary_error(_messages, _info=None):
"""Mocked summary that raises an error."""
raise ValueError("Simulated LLM error")
with summarization_agent.override(model=FunctionModel(mocked_summary_error)):
chunk = "This is a test chunk."
with pytest.raises(ModelRetry) as exc_info:
await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
assert "An error occurred while summarizing a part of the document chunk" in str(
exc_info.value
)
@pytest.mark.asyncio
async def test_summarize_chunk_handles_empty_response(mocked_context):
"""Test that summarize_chunk handles empty responses from the agent."""
summarization_agent = SummarizationAgent()
def mocked_empty_summary(_messages, _info=None):
"""Mocked summary that returns empty content."""
return ModelResponse(parts=[TextPart(content="")])
with summarization_agent.override(model=FunctionModel(mocked_empty_summary)):
chunk = "This is a test chunk."
# Empty responses cause ModelRetry since pydantic-ai considers them invalid
with pytest.raises(ModelRetry):
await summarize_chunk(1, chunk, 1, summarization_agent, mocked_context)
@pytest.mark.asyncio
async def test_document_summarize_single_document(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with a single document."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
# Create mock conversation with a text attachment
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test_doc.txt"
mock_attachment.file_name = "test_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
# Mock file storage
file_content = "This is a test document. " * 20 # Create a document with some content
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
call_count = {"chunk": 0, "merge": 0}
def mocked_summary_full(messages, _info=None):
"""Mocked summary for full flow."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
call_count["merge"] += 1
return ModelResponse(
parts=[TextPart(content="# Final Summary\n\nThis is the final merged summary.")]
)
call_count["chunk"] += 1
return ModelResponse(
parts=[TextPart(content=f"Summary of chunk {call_count['chunk']}")]
)
with mock_summarization_agent(FunctionModel(mocked_summary_full)):
result = await document_summarize(mocked_context, instructions=None)
assert result.return_value == "# Final Summary\n\nThis is the final merged summary."
assert result.metadata["sources"] == {"test_doc.txt"}
assert call_count["merge"] == 1
@pytest.mark.asyncio
async def test_document_summarize_multiple_documents(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with multiple documents."""
settings.SUMMARIZATION_CHUNK_SIZE = 50
settings.SUMMARIZATION_OVERLAP_SIZE = 5
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
# Create mock conversation with multiple text attachments
mock_conversation = mock.Mock()
mock_attachment1 = mock.Mock()
mock_attachment1.key = "doc1.txt"
mock_attachment1.file_name = "doc1.txt"
mock_attachment1.content_type = "text/plain"
mock_attachment2 = mock.Mock()
mock_attachment2.key = "doc2.txt"
mock_attachment2.file_name = "doc2.txt"
mock_attachment2.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment1, mock_attachment2]
file_content1 = "Content of document one. " * 10
file_content2 = "Content of document two. " * 10
def mock_open_side_effect(key):
"""Mock file opening based on key."""
if key == "doc1.txt":
return io.BytesIO(file_content1.encode("utf-8"))
return io.BytesIO(file_content2.encode("utf-8"))
with mock.patch.object(default_storage, "open", side_effect=mock_open_side_effect):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_multi(messages, _info=None):
"""Mocked summary for multiple documents."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(parts=[TextPart(content="Combined summary of all documents")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_multi)):
result = await document_summarize(mocked_context, instructions=None)
assert result.return_value == "Combined summary of all documents"
assert result.metadata["sources"] == {"doc1.txt", "doc2.txt"}
@pytest.mark.asyncio
async def test_document_summarize_with_custom_instructions(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with custom instructions."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
captured_merge_prompt = []
def mocked_summary_with_instructions(messages, _info=None):
"""Mocked summary that captures merge prompt."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
captured_merge_prompt.append(messages_text)
return ModelResponse(parts=[TextPart(content="Summary in 2 paragraphs")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_with_instructions)):
result = await document_summarize(
mocked_context, instructions="summary in 2 paragraphs"
)
assert result.return_value == "Summary in 2 paragraphs"
assert len(captured_merge_prompt) == 1
assert "summary in 2 paragraphs" in captured_merge_prompt[0]
@pytest.mark.asyncio
async def test_document_summarize_no_text_attachments(mocked_context, mock_summarization_agent):
"""Test document_summarize returns error message when no text documents found."""
mock_conversation = mock.Mock()
mock_conversation.attachments.filter.return_value = []
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
# The decorator @last_model_retry_soft_fail catches ModelCannotRetry and returns a message
# We still need to provide a mock agent even if it won't be called
with mock_summarization_agent(FunctionModel(mocked_summary)):
result = await document_summarize(mocked_context, instructions=None)
assert "No text documents found in the conversation" in result
@pytest.mark.asyncio
async def test_document_summarize_error_reading_document(mocked_context, mock_summarization_agent):
"""Test document_summarize handles errors when reading documents."""
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
# The decorator @last_model_retry_soft_fail catches ModelCannotRetry and returns a message
# We still need to provide a mock agent even if it won't be called
with mock_summarization_agent(FunctionModel(mocked_summary)):
result = await document_summarize(mocked_context, instructions=None)
assert "An unexpected error occurred during document summarization" in result
@pytest.mark.asyncio
async def test_document_summarize_error_during_chunk_summarization(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize handles ModelRetry during chunk summarization."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_error(messages, _info=None):
"""Mocked summary that raises an error during chunks."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" not in messages_text:
raise ValueError("Chunk processing error")
return ModelResponse(parts=[TextPart(content="Final summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_error)):
with pytest.raises(ModelRetry):
await document_summarize(mocked_context, instructions=None)
@pytest.mark.asyncio
async def test_document_summarize_error_during_merge(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize handles errors during final merge."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_summary_merge_error(messages, _info=None):
"""Mocked summary that raises an error during merge."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
raise ValueError("Merge error")
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_summary_merge_error)):
with pytest.raises(ModelRetry) as exc_info:
await document_summarize(mocked_context, instructions=None)
# Should raise ModelRetry regardless of which phase failed
assert "An error occurred" in str(exc_info.value)
@pytest.mark.asyncio
async def test_document_summarize_empty_result(settings, mocked_context, mock_summarization_agent):
"""Test document_summarize raises ModelRetry when summarization produces empty result."""
settings.SUMMARIZATION_CHUNK_SIZE = 100
settings.SUMMARIZATION_OVERLAP_SIZE = 10
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_empty_summary(messages, _info=None):
"""Mocked summary that returns empty for merge."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(parts=[TextPart(content=" ")])
return ModelResponse(parts=[TextPart(content="Chunk summary")])
with mock_summarization_agent(FunctionModel(mocked_empty_summary)):
with pytest.raises(ModelRetry) as exc_info:
await document_summarize(mocked_context, instructions=None)
# Should raise ModelRetry with the specific message
assert "The summarization produced an empty result" in str(exc_info.value)
@pytest.mark.asyncio
async def test_document_summarize_large_document_multiple_chunks(
settings, mocked_context, mock_summarization_agent
):
"""Test document_summarize with a large document requiring multiple chunks."""
settings.SUMMARIZATION_CHUNK_SIZE = 20 # Small chunk size to force multiple chunks
settings.SUMMARIZATION_OVERLAP_SIZE = 5
settings.SUMMARIZATION_CONCURRENT_REQUESTS = 2
mock_conversation = mock.Mock()
mock_attachment = mock.Mock()
mock_attachment.key = "large_doc.txt"
mock_attachment.file_name = "large_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation.attachments.filter.return_value = [mock_attachment]
# Create a large document
file_content = "This is a word. " * 100 # Should create multiple chunks
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
# Set up mocked_context with conversation
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
chunk_count = {"count": 0}
def mocked_summary_multi_chunks(messages, _info=None):
"""Mocked summary that counts chunks."""
messages_text = messages[0].parts[-1].content
if "Produce a coherent synthesis" in messages_text:
return ModelResponse(
parts=[TextPart(content=f"Final summary of {chunk_count['count']} chunks")]
)
chunk_count["count"] += 1
return ModelResponse(
parts=[TextPart(content=f"Summary of chunk {chunk_count['count']}")]
)
with mock_summarization_agent(FunctionModel(mocked_summary_multi_chunks)):
result = await document_summarize(mocked_context, instructions=None)
assert "Final summary of" in result.return_value
assert chunk_count["count"] > 1 # Ensure multiple chunks were processed
+154
View File
@@ -0,0 +1,154 @@
"""Tests for chat tool utilities."""
import inspect
from typing import get_type_hints
import pytest
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
def test_last_model_retry_soft_fail_preserves_function_metadata():
"""Test that the decorator preserves function metadata for schema generation."""
@last_model_retry_soft_fail
async def example_tool(ctx: RunContext, query: str, limit: int = 10) -> str: # pylint: disable=unused-argument
"""
Example tool function.
Args:
ctx: The run context.
query: The search query.
limit: Maximum number of results.
Returns:
The search results.
"""
return f"Results for {query} (limit: {limit})"
# Check that function name is preserved
assert example_tool.__name__ == "example_tool"
# Check that docstring is preserved
assert example_tool.__doc__ is not None
assert "Example tool function" in example_tool.__doc__
# Check that signature is preserved
sig = inspect.signature(example_tool)
assert "ctx" in sig.parameters
assert "query" in sig.parameters
assert "limit" in sig.parameters
assert sig.parameters["limit"].default == 10
# Check that type hints are preserved
type_hints = get_type_hints(example_tool)
assert "query" in type_hints
assert type_hints["query"] == str
assert "limit" in type_hints
assert type_hints["limit"] == int
assert type_hints["return"] == str
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_normal_execution():
"""Test that the decorator doesn't interfere with normal execution."""
@last_model_retry_soft_fail
async def example_tool(_ctx: RunContext, value: str) -> str:
"""Example tool."""
return f"Result: {value}"
# Create a mock context
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {}
tool_name = "example_tool"
ctx = MockContext()
result = await example_tool(ctx, "test")
assert result == "Result: test"
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_handles_retry_exception():
"""Test that the decorator handles ModelRetry exceptions correctly."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelRetry("Please retry with different parameters")
return "Success"
# Create a mock context
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries haven't been exhausted - should re-raise
with pytest.raises(ModelRetry):
await failing_tool(ctx, should_fail=True)
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_returns_message_when_max_retries_reached():
"""Test that the decorator returns the error message when max retries is reached."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelRetry("Please retry with different parameters.")
return "Success"
# Create a mock context with max retries already reached
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {"failing_tool": 3}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries have been exhausted - should return message
result = await failing_tool(ctx, should_fail=True)
assert result == (
"Please retry with different parameters. "
"You must explain this to the user and not try to answer based on your knowledge."
)
@pytest.mark.asyncio
async def test_last_model_retry_soft_fail_returns_message_when_model_cannot_retry():
"""Test that the decorator returns the error message when ModelCannotRetry is raised."""
@last_model_retry_soft_fail
async def failing_tool(_ctx: RunContext, should_fail: bool) -> str:
"""Tool that can raise ModelRetry."""
if should_fail:
raise ModelCannotRetry("This is broken duh.")
return "Success"
# Create a mock context with max retries already reached
class MockContext:
"""Fake context for testing."""
max_retries = 3
retries = {"failing_tool": 3}
tool_name = "failing_tool"
ctx = MockContext()
# Test when retries have been exhausted - should return message
result = await failing_tool(ctx, should_fail=True)
assert result == "This is broken duh."
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
"""tools for testing chat functionality"""
import re
def replace_uuids_with_placeholder(text):
"""Replace all UUIDs in the given text with a placeholder."""
text = re.sub('"toolCallId":"([a-z0-9-]){36}"', '"toolCallId":"XXX"', text)
text = re.sub('"toolCallId":"pyd_ai_([a-z0-9]){32}"', '"toolCallId":"pyd_ai_YYY"', text)
text = re.sub('"([a-z0-9-]){36}"', '"<mocked_uuid>"', text)
return text
@@ -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,8 +1,7 @@
"""Common test fixtures for chat conversation endpoint tests."""
import asyncio
import json
import uuid
from unittest.mock import patch
from django.utils import timezone
@@ -12,23 +11,9 @@ import respx
from freezegun import freeze_time
@pytest.fixture(name="mock_uuid4")
def mock_uuid4_fixture():
"""Fixture to mock UUID generation for testing."""
value = uuid.uuid4()
with patch("uuid.uuid4", return_value=value):
yield value
@pytest.fixture(name="mock_openai_stream")
@freeze_time("2025-07-25T10:36:35.297675Z")
def fixture_mock_openai_stream():
"""
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(
{
@@ -69,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
File diff suppressed because it is too large Load Diff
@@ -8,12 +8,14 @@ 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
import pytest
import responses
import respx
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel
@@ -32,6 +34,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -39,28 +42,49 @@ from chat.factories import ChatConversationFactory
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."""
@@ -79,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"},
@@ -99,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,
)
@@ -117,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():
@@ -149,7 +203,7 @@ def fixture_mock_summarization_agent():
super().__init__(**kwargs)
self._model = FunctionModel(function=summarization_model) # pylint: disable=protected-access
with mock.patch("chat.agents.summarize.SummarizationAgent", new=SummarizationAgentMock):
with mock.patch("chat.tools.document_summarize.SummarizationAgent", new=SummarizationAgentMock):
yield
@@ -214,12 +268,12 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_albert_api, # pylint: disable=unused-argument
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -273,9 +327,11 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
@@ -283,19 +339,22 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
'b:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag",'
'"args":{"query":"What does the document say?"}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf","title":null,"providerMetadata":{}}\n'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf","title":null,'
'"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":[{"url":"sample.pdf","content":"This '
'is the content of the PDF.","score":0.9}]}\n'
"0:\"From the document, I can see that it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":100,"completionTokens":20}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What does the document say?",
reasoning=None,
@@ -305,8 +364,10 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
toolInvocations=None,
parts=[TextUIPart(type="text", text="What does the document say?")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="From the document, I can see that it says 'Hello PDF'.",
reasoning=None,
@@ -318,7 +379,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="document_search_rag",
args={"query": "What does the document say?"},
state="call",
@@ -330,7 +391,7 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf",
title=None,
providerMetadata={},
@@ -343,75 +404,57 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": None,
"instructions": "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.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "When you receive a result from the summarization tool, "
"you MUST return it directly to the user without any "
"modification, paraphrasing, or additional "
"summarization.The tool already produces optimized "
"summaries that should be presented verbatim.You may "
"translate the summary if required, but you MUST "
"preserve all the information from the original "
"summary.You may add a follow-up question after the "
"summary if needed.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["What does the document say?"],
"part_kind": "user-prompt",
"timestamp": timezone_now,
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"args": '{"query": "What does the document say?"}',
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
"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,
@@ -423,10 +466,28 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"output_audio_tokens": 0,
"output_tokens": 8,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": None,
"instructions": (
"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."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": [
@@ -439,25 +500,31 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"metadata": {"sources": ["sample.pdf"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "document_search_rag",
}
],
"run_id": _run_id,
"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,
@@ -469,19 +536,20 @@ def test_post_conversation_with_document_upload( # noqa: PLR0913 # pylint: disa
"output_audio_tokens": 0,
"output_tokens": 12,
},
"run_id": _run_id,
}
@responses.activate
@respx.mock
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload_feature_disabled(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
caplog,
mock_openai_stream, # pylint: disable=unused-argument
sample_pdf_content,
feature_flags,
mock_uuid4,
):
"""
Test POST to /api/v1/chats/{pk}/conversation/ with a PDF document while feature is disabled.
@@ -526,13 +594,15 @@ def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR09
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"From the document, I can see that "\n'
"0:\"it says 'Hello PDF'.\"\n"
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
# This behavior must be improved in the future to inform the user properly
assert "Document upload feature is disabled, ignoring input documents." in caplog.text
@@ -542,10 +612,9 @@ def test_post_conversation_with_document_upload_feature_disabled( # noqa: PLR09
@freeze_time()
def test_post_conversation_with_document_upload_summarize( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
api_client,
mock_albert_api, # pylint: disable=unused-argument
mock_document_api, # pylint: disable=unused-argument
sample_pdf_content,
today_promt_date,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
mock_summarization_agent, # pylint: disable=unused-argument
):
@@ -556,6 +625,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
api_client.force_authenticate(user=chat_conversation.owner)
pdf_base64 = base64.b64encode(sample_pdf_content.read()).decode("utf-8")
message = UIMessage(
id="1",
role="user",
@@ -600,29 +670,33 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
assert response.streaming
# Wait for the streaming content to be fully received
str_mock_uuid4 = str(mock_uuid4)
toolcall_id = f"pyd_ai_{str_mock_uuid4.replace('-', '')}"
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'b:{"toolCallId":"pyd_ai_YYY","toolName":"summarize"}\n'
'9:{"toolCallId":"pyd_ai_YYY","toolName":"summarize","args":{}}\n'
'h:{"sourceType":"url","id":"XXX","url":"sample.pdf.md",'
'h:{"sourceType":"url","id":"<mocked_uuid>","url":"sample.pdf.md",'
'"title":null,"providerMetadata":{}}\n'
'a:{"toolCallId":"pyd_ai_YYY","result":"The '
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"XXX"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":201,"completionTokens":13}}\n'
).replace("XXX", str_mock_uuid4).replace("pyd_ai_YYY", toolcall_id)
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":287,"completionTokens":19}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="Make a summary of this document.",
reasoning=None,
@@ -632,8 +706,10 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
toolInvocations=None,
parts=[TextUIPart(type="text", text="Make a summary of this document.")],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str_mock_uuid4,
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="The document discusses various topics.",
reasoning=None,
@@ -645,7 +721,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
toolCallId=toolcall_id,
toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId,
toolName="summarize",
args={},
state="call",
@@ -657,7 +733,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
type="source",
source=LanguageModelV1Source(
sourceType="url",
id=str_mock_uuid4,
id=chat_conversation.messages[1].parts[2].source.id,
url="sample.pdf.md", # might be fixed in the future
title=None,
providerMetadata={},
@@ -670,75 +746,58 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_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."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "When you receive a result from the summarization tool, "
"you MUST return it directly to the user without any "
"modification, paraphrasing, or additional "
"summarization.The tool already produces optimized "
"summaries that should be presented verbatim.You may "
"translate the summary if required, but you MUST "
"preserve all the information from the original "
"summary.You may add a follow-up question after the "
"summary if needed.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["Make a summary of this document."],
"part_kind": "user-prompt",
"timestamp": timezone_now,
},
],
"run_id": _run_id,
"timestamp": timezone_now,
}
assert chat_conversation.pydantic_messages[1] == {
"finish_reason": None,
"kind": "response",
"metadata": None,
"model_name": "function::agent_model",
"parts": [
{
"args": "{}",
"id": None,
"part_kind": "tool-call",
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
"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,
@@ -750,31 +809,59 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 1,
},
"run_id": _run_id,
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_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."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "The document discusses various topics.",
"metadata": {"sources": ["sample.pdf.md"]},
"part_kind": "tool-return",
"timestamp": timezone_now,
"tool_call_id": toolcall_id,
"tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"],
"tool_name": "summarize",
}
],
"run_id": _run_id,
"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,
@@ -786,4 +873,5 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
"output_audio_tokens": 0,
"output_tokens": 6,
},
"run_id": _run_id,
}
@@ -1,6 +1,8 @@
"""Unit tests for chat conversation actions with document URL."""
# pylint: disable=too-many-lines
import uuid
# pylint: disable=too-many-lines
from io import BytesIO
from django.core.files.storage import default_storage
@@ -8,13 +10,13 @@ from django.utils import formats, timezone
import pytest
import responses
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
DocumentUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -27,6 +29,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationAttachmentFactory, ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -34,11 +37,20 @@ from chat.factories import ChatConversationAttachmentFactory, ChatConversationFa
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
@@ -57,16 +69,17 @@ def fixture_sample_document_content():
@responses.activate
@freeze_time()
def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_promt_date,
mock_uuid4,
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"},
@@ -82,6 +95,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
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)
@@ -117,32 +134,30 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
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=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this document?",
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=(
"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."
@@ -162,26 +177,30 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
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,
@@ -189,8 +208,10 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -206,60 +227,61 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_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": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
{
"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,
@@ -271,6 +293,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -278,7 +301,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
@freeze_time()
def test_post_conversation_with_local_document_wrong_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -287,7 +309,7 @@ def test_post_conversation_with_local_document_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
document_url = f"/media-key/{mock_uuid4}/sample.pdf"
document_url = f"/media-key/{uuid.uuid4()}/sample.pdf"
message = UIMessage(
id="1",
@@ -326,10 +348,14 @@ def test_post_conversation_with_local_document_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"Document '
'URL does not belong to the conversation."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -343,7 +369,6 @@ def test_post_conversation_with_local_document_wrong_url(
@freeze_time()
def test_post_conversation_with_remote_document_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -391,10 +416,14 @@ def test_post_conversation_with_remote_document_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
'"args":{"documents":[{"identifier":"sample.pdf"}]}}\n'
f'a:{{"toolCallId":"{mock_uuid4}",'
'a:{"toolCallId":"XXX",'
'"result":{"state":"error","error":"External document '
'URL are not accepted yet."}}\n'
'd:{"finishReason":"error","usage":{"promptTokens":0,"completionTokens":0}}\n'
@@ -408,8 +437,6 @@ def test_post_conversation_with_remote_document_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -417,12 +444,14 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"""
chat_conversation_pk = "0be55da5-8eb7-4dad-aa0f-fea454bd5809"
document_url = f"/media-key/{chat_conversation_pk}/sample.pdf"
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
chat_conversation = ChatConversationFactory(
pk=chat_conversation_pk,
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -437,7 +466,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -452,27 +481,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
pydantic_messages=[
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"Today is {formatted_date}.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -535,27 +548,16 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -565,15 +567,20 @@ 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"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[0].run_id,
),
ModelResponse(
parts=[TextPart(content="This is a document about a single pixel.")],
usage=RequestUsage(input_tokens=50, output_tokens=9),
model_name="function::agent_model",
timestamp=timezone.now(),
timestamp=timestamp_now,
run_id=messages[1].run_id,
),
ModelRequest(
parts=[
@@ -581,9 +588,14 @@ 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.",
run_id=messages[2].run_id,
),
]
yield "This is a document of square, very small and nice."
@@ -603,17 +615,23 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is a document of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -627,8 +645,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about a single pixel.",
reasoning=None,
@@ -640,8 +660,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="This is a document about a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id,
createdAt=timezone.now(),
content="Give more details about this document.",
reasoning=None,
@@ -653,8 +675,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
TextUIPart(type="text", text="Give more details about this document."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id,
createdAt=timezone.now(),
content="This is a document of square, very small and nice.",
reasoning=None,
@@ -667,29 +691,14 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -706,6 +715,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
# no run_id here
},
{
"finish_reason": None,
@@ -732,9 +742,13 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 9,
},
# no run_id here
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"metadata": None,
"kind": "request",
"parts": [
{
@@ -743,21 +757,27 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"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,
@@ -769,6 +789,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -783,10 +804,10 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
("data.csv", "text/csv"),
],
)
def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_not_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
file_name,
content_type,
@@ -809,6 +830,10 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
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)
@@ -844,44 +869,39 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
SystemPromptPart(
content=(
"If the user wants specific information from a document, "
"invoke web_search_albert_rag with an appropriate query string."
"Do not ask the user for the document; rely on the tool to locate "
"and return relevant passages."
),
timestamp=timezone.now(),
),
SystemPromptPart(
content=(
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
),
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
# 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_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,
)
]
yield "This is a document about you."
@@ -901,20 +921,26 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
f'9:{{"toolCallId":"{mock_uuid4}","toolName":"document_parsing",'
'9:{"toolCallId":"XXX","toolName":"document_parsing",'
f'"args":{{"documents":[{{"identifier":"{file_name}"}}]}}}}\n'
f'a:{{"toolCallId":"{mock_uuid4}","result":{{"state":"done"}}}}\n'
'a:{"toolCallId":"XXX","result":{"state":"done"}}\n'
'0:"This is a document about you."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":7}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id,
createdAt=timezone.now(),
content="What is in this document?",
reasoning=None,
@@ -926,8 +952,10 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
TextUIPart(type="text", text="What is in this document?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id,
createdAt=timezone.now(),
content="This is a document about you.",
reasoning=None,
@@ -943,53 +971,30 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
timestamp = timezone.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"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."
),
"kind": "request",
"metadata": None,
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "If the user wants specific information from a "
"document, invoke web_search_albert_rag with an "
"appropriate query string.Do not ask the user for the "
"document; rely on the tool to locate and return "
"relevant passages.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "When you receive a result from the summarization "
"tool, you MUST return it directly to the user without "
"any modification, paraphrasing, or additional "
"summarization.The tool already produces optimized "
"summaries that should be presented verbatim.You may "
"translate the summary if required, but you MUST "
"preserve all the information from the original "
"summary.You may add a follow-up question after the "
"summary if needed.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -998,21 +1003,27 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
"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 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,
@@ -1024,5 +1035,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # noqa: PLR0913 # p
"output_audio_tokens": 0,
"output_tokens": 7,
},
"run_id": _run_id,
},
]
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,17 @@
"""Unit tests for chat conversation actions with image URL."""
from django.utils import timezone
import uuid
from django.utils import formats, timezone
import pytest
from dirty_equals import IsUUID
from freezegun import freeze_time
from pydantic_ai import ModelRequest, RequestUsage
from pydantic_ai.messages import (
ImageUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -22,6 +24,7 @@ from chat.ai_sdk_types import (
UIMessage,
)
from chat.factories import ChatConversationFactory
from chat.tests.utils import replace_uuids_with_placeholder
# enable database transactions for tests:
# transaction=True ensures that the data are available in the database
@@ -53,7 +56,6 @@ def fixture_sample_image_content():
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_image_url(
api_client,
mock_uuid4,
mock_ai_agent_service,
):
"""
@@ -84,22 +86,16 @@ def test_post_conversation_with_local_image_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
presigned_url = messages[0].parts[0].content[1].url
# assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(
content="Today is Saturday 18/10/2025.", timestamp=timezone.now()
),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -111,7 +107,11 @@ def test_post_conversation_with_local_image_url(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
timestamp=timezone.now(),
)
]
yield "This is an image of a single pixel."
@@ -131,17 +131,23 @@ def test_post_conversation_with_local_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -155,8 +161,10 @@ def test_post_conversation_with_local_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -169,29 +177,14 @@ def test_post_conversation_with_local_image_url(
],
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Today is Saturday 18/10/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -208,17 +201,27 @@ def test_post_conversation_with_local_image_url(
"timestamp": "2025-10-18T20:48:20.286204Z",
},
],
"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,
@@ -230,6 +233,7 @@ def test_post_conversation_with_local_image_url(
"output_audio_tokens": 0,
"output_tokens": 9,
},
"run_id": _run_id,
},
]
@@ -237,8 +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,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -247,7 +250,7 @@ def test_post_conversation_with_local_image_wrong_url(
chat_conversation = ChatConversationFactory(owner__language="en-us")
api_client.force_authenticate(user=chat_conversation.owner)
image_url = f"/media-key/{mock_uuid4}/sample.png"
image_url = f"/media-key/{uuid.uuid4()}/sample.png"
message = UIMessage(
id="1",
@@ -272,11 +275,6 @@ def test_post_conversation_with_local_image_wrong_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -288,7 +286,11 @@ def test_post_conversation_with_local_image_wrong_url(
],
timestamp=timezone.now(),
),
]
],
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,
)
]
yield "cannot read image." # IRL a 400 error would be raised by the LLM
@@ -308,9 +310,13 @@ def test_post_conversation_with_local_image_wrong_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"cannot read image."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":4}}\n'
)
@@ -321,8 +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,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -356,11 +361,6 @@ def test_post_conversation_with_remote_image_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -372,7 +372,11 @@ def test_post_conversation_with_remote_image_url(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
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."
@@ -392,17 +396,23 @@ def test_post_conversation_with_remote_image_url(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of a single pixel."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":9}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -416,8 +426,10 @@ def test_post_conversation_with_remote_image_url(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -434,8 +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,
mock_uuid4,
today_prompt_date,
mock_ai_agent_service,
):
"""
@@ -448,7 +459,7 @@ def test_post_conversation_with_local_image_url_in_history(
owner__language="en-us",
messages=[
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -463,7 +474,7 @@ def test_post_conversation_with_local_image_url_in_history(
],
),
UIMessage(
id=str(mock_uuid4),
id=str(uuid.uuid4()),
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -478,27 +489,10 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -561,27 +555,17 @@ def test_post_conversation_with_local_image_url_in_history(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
timestamp_now = timezone.now()
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this image?",
@@ -591,9 +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_prompt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -607,9 +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."
@@ -629,17 +619,23 @@ def test_post_conversation_with_local_image_url_in_history(
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"This is an image of square, very small and nice."\n'
f'f:{{"messageId":"{mock_uuid4}"}}\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":50,"completionTokens":11}}\n'
)
# Check that the conversation was updated
chat_conversation.refresh_from_db()
assert len(chat_conversation.messages) == 2 + 2
assert chat_conversation.messages[0].id == IsUUID(4)
assert chat_conversation.messages[0] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[0].id, # don't test the value directly
createdAt=timezone.now(),
content="What is in this image?",
reasoning=None,
@@ -653,8 +649,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="What is in this image?"),
],
)
assert chat_conversation.messages[1].id == IsUUID(4)
assert chat_conversation.messages[1] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[1].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of a single pixel.",
reasoning=None,
@@ -666,8 +664,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="This is an image of a single pixel."),
],
)
assert chat_conversation.messages[2].id == IsUUID(4)
assert chat_conversation.messages[2] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[2].id, # don't test the value directly
createdAt=timezone.now(),
content="Give more details about this image.",
reasoning=None,
@@ -679,8 +679,10 @@ def test_post_conversation_with_local_image_url_in_history(
TextUIPart(type="text", text="Give more details about this image."),
],
)
assert chat_conversation.messages[3].id == IsUUID(4)
assert chat_conversation.messages[3] == UIMessage(
id=str(mock_uuid4),
id=chat_conversation.messages[3].id, # don't test the value directly
createdAt=timezone.now(),
content="This is an image of square, very small and nice.",
reasoning=None,
@@ -693,29 +695,13 @@ def test_post_conversation_with_local_image_url_in_history(
],
)
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_prompt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -756,8 +742,10 @@ def test_post_conversation_with_local_image_url_in_history(
},
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"metadata": None,
"parts": [
{
"content": ["Give more details about this image."],
@@ -765,21 +753,27 @@ def test_post_conversation_with_local_image_url_in_history(
"timestamp": "2025-10-18T20:48:20.286204Z",
}
],
"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,
@@ -791,5 +785,6 @@ def test_post_conversation_with_local_image_url_in_history(
"output_audio_tokens": 0,
"output_tokens": 11,
},
"run_id": _run_id,
},
]
@@ -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
@@ -1,4 +1,4 @@
"""Test the post_stop_steaming view."""
"""Test the post_stop_streaming view."""
from unittest.mock import patch
@@ -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/"
+143 -1
View File
@@ -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)
@@ -25,6 +25,8 @@ def test_api_media_auth_unkown_document(api_client):
Trying to download a media related to a conversation that does not exist
should not have the side effect to create it (no regression test).
"""
ChatConversation.objects.all().delete()
original_url = f"http://localhost/media/{uuid4()!s}/attachments/{uuid4()!s}.jpg"
response = api_client.get("/api/v1.0/chats/media-auth/", HTTP_X_ORIGINAL_URL=original_url)
@@ -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 -24
View File
@@ -1,36 +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=False, prepare=only_if_web_search_enabled
),
"web_search_brave_with_document_backend": Tool(
web_search_brave_with_document_backend,
takes_ctx=True,
prepare=only_if_web_search_enabled,
),
"web_search_tavily": Tool(
web_search_tavily, takes_ctx=False, prepare=only_if_web_search_enabled
),
"web_search_albert_rag": Tool(
web_search_albert_rag, takes_ctx=True, prepare=only_if_web_search_enabled
),
}
return tool_dict[name] # will raise on purpose if name is not found
@@ -0,0 +1,137 @@
"""
Helpers to add RAG document search tools to an agent based on settings.
The purpose is to provide a generic way to add multiple RAG document search tools
to an agent based on configuration in settings. Each tool can target specific
document collections and have its own description.
Our use case implies that different users might have access to different document collections,
so the tools added to the agent are also user-specific.
"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.module_loading import import_string
from httpx import HTTPStatusError
from pydantic_ai import Agent, ModelRetry, RunContext, RunUsage
from pydantic_ai.messages import ToolReturn
from core.feature_flags.helpers import is_feature_enabled
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
User = get_user_model()
def get_specific_rag_search_tool_config(user: User) -> dict:
"""
Get the specific RAG search tool configuration from settings.
Settings example:
SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS = {
"french_public_services": {
"collection_ids": [784, 785],
"feature_flag_value": "disabled",
"tool_description": (
"Use this tool when the user asks for information about French public services, "
"the French labor market, employment laws, social benefits, or "
"assistance with administrative procedures."
),
},
}
"""
return {
tool_name: tool_config
for tool_name, tool_config in settings.SPECIFIC_RAG_DOCUMENT_SEARCH_TOOLS.items()
if is_feature_enabled(user, tool_name)
}
def _create_document_search_rag(agent, name, description, backend, ids):
"""Factory function to create a document search RAG tool."""
@agent.tool(
name=name,
retries=1,
require_parameter_descriptions=True,
description=description,
)
@last_model_retry_soft_fail
async def document_search_rag(ctx: RunContext, query: str) -> ToolReturn:
"""
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The query to search information about.
"""
document_store = backend(read_only_collection_id=ids)
try:
rag_results = await document_store.asearch(query)
except HTTPStatusError as exc:
logger.error(
"RAG document search failed for tool %s with error: %s", name, exc, exc_info=True
)
raise ModelRetry(f"Document search service is currently unavailable: {exc}") from exc
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
return ToolReturn(
return_value={
str(idx): {
"url": result.url,
"snippets": result.content,
}
for idx, result in enumerate(rag_results.data)
},
metadata={"sources": {result.url for result in rag_results.data}},
)
return document_search_rag
def add_document_rag_search_tool_from_setting(agent: Agent, user: User) -> None:
"""
This function takes a configuration setting and generates specific search RAG tools and add
it to the agent.
Args:
agent (Agent): The agent to which the tool will be added.
user (User): The user for whom the tool is being added.
"""
for tool_name, tool_config in get_specific_rag_search_tool_config(user).items():
document_store_backend_name = tool_config.get(
"rag_backend_name", settings.RAG_DOCUMENT_SEARCH_BACKEND
)
try:
document_store_backend = import_string(document_store_backend_name)
except ImportError as exc:
logger.warning(
"Could not import RAG backend %s: %s",
document_store_backend_name,
exc,
exc_info=True,
)
continue # Skip if the backend is not available
collection_ids = tool_config.get("collection_ids", [])
if not collection_ids:
logger.warning("No collection IDs provided for tool %s, skipping.", tool_name)
continue # Skip if no collection IDs are provided
tool_description = tool_config.get("tool_description")
if not tool_description:
logger.warning("No tool description provided for tool %s, skipping.", tool_name)
continue # Skip if no tool description is provided
_create_document_search_rag(
agent, tool_name, tool_description, document_store_backend, collection_ids
)
@@ -20,13 +20,13 @@ def add_document_rag_search_tool(agent: Agent) -> None:
Args:
ctx (RunContext): The run context containing the conversation.
query (str): The term to search the internet for.
query (str): The query to search the documents for.
"""
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
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,
@@ -39,12 +39,10 @@ def add_document_rag_search_tool(agent: Agent) -> None:
metadata={"sources": {result.url for result in rag_results.data}},
)
@agent.system_prompt
@agent.instructions
def document_rag_instructions() -> str:
"""Dynamic system prompt function to add RAG instructions if any."""
return (
"If the user wants specific information from a document, invoke "
"web_search_albert_rag with an appropriate query string."
"Do not ask the user for the document; rely on the tool to locate "
"and return relevant passages."
"Use document_search_rag ONLY to retrieve specific passages from attached documents. "
"Do NOT use it to summarize; for summaries, call the summarize tool instead."
)
@@ -0,0 +1,189 @@
"""Summarization tool used for uploaded documents."""
import asyncio
import logging
from django.conf import settings
from django.core.files.storage import default_storage
import semchunk
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from chat.agents.summarize import SummarizationAgent
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
@sync_to_async
def read_document_content(doc):
"""Read document content asynchronously."""
with default_storage.open(doc.key) as f:
return doc.file_name, f.read().decode("utf-8")
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
"""Summarize a single chunk of text."""
sum_prompt = (
"You are an agent specializing in text summarization. "
"Generate a clear and concise summary of the following passage "
f"(part {idx}/{total_chunks}):\n'''\n{chunk}\n'''\n\n"
)
logger.debug(
"[summarize] CHUNK %s/%s prompt=> %s", idx, total_chunks, sum_prompt[0:100] + "..."
)
try:
resp = await summarization_agent.run(sum_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during chunk summarization: %s", exc, exc_info=True)
raise ModelRetry(
"An error occurred while summarizing a part of the document chunk."
) from exc
logger.debug("[summarize] CHUNK %s/%s response<= %s", idx, total_chunks, resp.output or "")
return resp.output or ""
@last_model_retry_soft_fail
async def document_summarize( # pylint: disable=too-many-locals
ctx: RunContext, *, instructions: str | None = None
) -> ToolReturn:
"""
Generate a complete, ready-to-use summary of the documents in context
(do not request the documents to the user).
Return this summary directly to the user WITHOUT any modification,
or additional summarization.
The summary is already optimized and MUST be presented as-is in the final response
or translated preserving the information.
Instructions are optional but should reflect the user's request.
Examples:
"Summarize this doc in 2 paragraphs" -> instructions = "summary in 2 paragraphs"
"Summarize this doc in English" -> instructions = "In English"
"Summarize this doc" -> instructions = "" (default)
Args:
instructions (str | None): The instructions the user gave to use for the summarization
"""
try:
instructions_hint = (
instructions.strip() if instructions else "The summary should contain 2 or 3 parts."
)
summarization_agent = SummarizationAgent()
# Collect documents content
text_attachment = await sync_to_async(list)(
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
)
if not text_attachment:
raise ModelCannotRetry(
"No text documents found in the conversation. "
"You must explain this to the user and ask them to provide documents."
)
documents = [await read_document_content(doc) for doc in text_attachment]
# Chunk documents and summarize each chunk
chunk_size = settings.SUMMARIZATION_CHUNK_SIZE
chunker = semchunk.chunkerify(
tokenizer_or_token_counter=lambda text: len(text.split()),
chunk_size=chunk_size,
)
documents_chunks = chunker(
[doc[1] for doc in documents],
overlap=settings.SUMMARIZATION_OVERLAP_SIZE,
)
logger.info(
"[summarize] chunking: %s parts (size~%s), instructions='%s'",
sum(len(chunks) for chunks in documents_chunks),
chunk_size,
instructions_hint,
)
# Parallelize the chunk summarization with a semaphore to limit concurrent tasks
# because it can be very resource intensive on the LLM backend
semaphore = asyncio.Semaphore(settings.SUMMARIZATION_CONCURRENT_REQUESTS)
async def summarize_chunk_with_semaphore(idx, chunk, total_chunks):
"""Summarize a chunk with semaphore-controlled concurrency."""
async with semaphore:
return await summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx)
doc_chunk_summaries = []
try:
for doc_chunks in documents_chunks:
summarization_tasks = [
summarize_chunk_with_semaphore(idx, chunk, len(doc_chunks))
for idx, chunk in enumerate(doc_chunks, start=1)
]
chunk_summaries = await asyncio.gather(*summarization_tasks)
doc_chunk_summaries.append(chunk_summaries)
except ModelRetry as exc:
logger.warning("Retryable error during chunk summarization: %s", exc, exc_info=True)
raise
except Exception as exc:
logger.warning("Error during chunk summarization: %s", exc, exc_info=True)
raise ModelRetry("An error occurred while processing document chunks.") from exc
context = "\n\n".join(
doc_name + "\n\n" + "\n\n".join(summaries)
for doc_name, summaries in zip(
(doc[0] for doc in documents),
doc_chunk_summaries,
strict=True,
)
)
# Merge chunk summaries into a single concise summary
merged_prompt = (
"Produce a coherent synthesis from the summaries below.\n\n"
f"'''\n{context}\n'''\n\n"
"Constraints:\n"
"- Summarize without repetition.\n"
"- Harmonize style and terminology.\n"
"- The final summary must be well-structured and formatted in markdown.\n"
f"- Follow the instructions: {instructions_hint}\n"
"Respond directly with the final summary."
)
logger.debug("[summarize] MERGE prompt=> %s", merged_prompt)
try:
merged_resp = await summarization_agent.run(merged_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during merge summarization: %s", exc, exc_info=True)
raise ModelRetry("An error occurred while generating the final summary.") from exc
final_summary = (merged_resp.output or "").strip()
if not final_summary:
raise ModelRetry("The summarization produced an empty result.")
logger.debug("[summarize] MERGE response<= %s", final_summary)
return ToolReturn(
return_value=final_summary,
metadata={"sources": {doc[0] for doc in documents}},
)
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as exc:
# Unexpected error - stop and inform user
logger.exception("Unexpected error in document_summarize: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during document summarization: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
+12
View File
@@ -0,0 +1,12 @@
"""Exceptions for tool function retries."""
from pydantic_ai import ModelRetry
class ModelCannotRetry(ModelRetry):
"""
Exception to raise when a tool function cannot be retried.
We use this exception to signal that the model should not attempt to retry
the tool call, typically because the error is not transient or recoverable.
"""
+50
View File
@@ -0,0 +1,50 @@
"""Tool calling utilities for the chat agent."""
import functools
import logging
from typing import Any, Callable
from pydantic_ai import ModelRetry, RunContext
from chat.tools.exceptions import ModelCannotRetry
logger = logging.getLogger(__name__)
def last_model_retry_soft_fail(
tool_func: Callable[..., Any],
) -> Callable[..., Any]:
"""
Wrap a tool function to handle ModelRetry exceptions.
If the tool function raises ModelRetry and the maximum number of retries
has been reached, a ModelCannotRetry exception is raised instead.
Args:
tool_func: The original tool function to wrap.
Returns:
A wrapped tool function with retry handling.
"""
@functools.wraps(tool_func)
async def wrapper(ctx: RunContext, *args, **kwargs) -> Any:
try:
return await tool_func(ctx, *args, **kwargs)
except ModelCannotRetry as exc:
return str(exc.message)
except ModelRetry as exc:
logger.error("Tool '%s' raised ModelRetry: %s", ctx, exc.message)
if (ctx.retries.get(ctx.tool_name, 0) + 1) >= ctx.max_retries:
logger.error("Max retries reached for tool '%s'.", ctx.tool_name)
# A bit of a hack to signal that we cannot retry here, while preventing
# the LLM to generate an outdated answer.
# We may define a more specific exception later base on ModelRetry which
# adds a specific message for this case.
return (
f"{exc.message} You must explain this to the user and "
"not try to answer based on your knowledge."
)
raise # Re-raise to allow retrying
return wrapper
+364 -129
View File
@@ -1,24 +1,42 @@
"""Web search tool using Brave for the chat agent."""
import asyncio
import logging
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List
from django.conf import settings
from django.core.cache import cache
from django.utils.module_loading import import_string
from django.utils.text import slugify
import requests
import httpx
from asgiref.sync import sync_to_async
from pydantic_ai import RunContext, RunUsage
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from trafilatura import extract, fetch_url
from trafilatura import extract
from trafilatura.meta import reset_caches
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
def llm_summarize(query: str, text: str) -> str:
class WebSearchError(Exception):
"""Base exception for web search errors."""
class BraveAPIError(WebSearchError):
"""Error when calling Brave API."""
class DocumentFetchError(WebSearchError):
"""Error when fetching or extracting documents."""
async def llm_summarize_async(query: str, text: str) -> str:
"""
Summarize the text using the LLM summarization agent.
@@ -33,7 +51,7 @@ def llm_summarize(query: str, text: str) -> str:
prompt = f"""
Based on the following request, summarize the following text in a concise manner,
focusing on the key points regarding the user request.
he result should be up to 30 lines long.
The result should be up to 30 lines long.
<user request>
{query}
@@ -44,188 +62,405 @@ he result should be up to 30 lines long.
</text to summarize>
"""
result = summarization_agent.run_sync(prompt)
result = await summarization_agent.run(prompt)
return result.output
def _fetch_and_extract(url: str) -> str:
"""Fetch and extract text content from the URL."""
cache_key = f"web_search_brave:extract:{url}"
async def _fetch_url_async(url: str, timeout: int = 30) -> str:
"""Fetch URL content asynchronously."""
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
return response.text
if (document := cache.get(cache_key)) is not None:
async def _fetch_and_extract_async(url: str) -> str:
"""Fetch and extract text content from the URL asynchronously."""
cache_key = f"web_search_brave:extract:{slugify(url)}"
# Check cache first
if (document := await cache.aget(cache_key)) is not None:
return document
html = fetch_url(url)
document = extract(html, include_comments=False, no_fallback=True) or ""
cache.set(cache_key, document, settings.BRAVE_CACHE_TTL)
try:
# Fetch HTML
html = await _fetch_url_async(url, timeout=settings.BRAVE_API_TIMEOUT)
return document
# Extract text in thread pool (trafilatura is CPU-bound)
document = await sync_to_async(extract)(html, include_comments=False, no_fallback=True)
# Cache the result
await cache.aset(cache_key, document, settings.BRAVE_CACHE_TTL)
return document
except httpx.HTTPError as e:
logger.warning("HTTP error fetching %s: %s", url, e, exc_info=True)
raise DocumentFetchError(f"Failed to fetch {url}: {e}") from e
except Exception as e:
logger.warning("Error extracting content from %s: %s", url, e, exc_info=True)
raise DocumentFetchError(f"Failed to extract content from {url}: {e}") from e
def _extract_and_summarize_snippets(query: str, url: str) -> List[str]:
async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[str]:
"""Fetch, extract and summarize text content from the URL.
Returns a list of snippets (0 or 1 element, preserving existing behavior).
"""
# Cache by URL to avoid repeated fetch/extract across calls
document = _fetch_and_extract(url)
if not document:
try:
document = await _fetch_and_extract_async(url)
if not document:
return []
if not settings.BRAVE_SUMMARIZATION_ENABLED:
return [document]
try:
snippet = await llm_summarize_async(query, document)
return [snippet] if snippet else []
except Exception as e: # pylint: disable=broad-except
logger.exception("Summarization failed for %s: %s", url, e)
# Fallback to raw document if summarization fails
return [document]
except DocumentFetchError:
# Document fetch failed, return empty
return []
if not settings.BRAVE_SUMMARIZATION_ENABLED:
return [document]
async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
try:
snippet = llm_summarize(query, document)
except Exception as e: # pylint: disable=broad-except
logger.exception("Summarization failed for %s: %s", url, e)
snippet = None
document = await _fetch_and_extract_async(url)
return [snippet] if snippet else []
logger.debug("Fetched document: %s", document)
if document:
await document_store.astore_document(url, document, **kwargs)
except DocumentFetchError as e:
logger.warning("Failed to fetch and store %s: %s", url, e)
# Continue with other documents
def _fetch_and_store(url: str, document_store) -> None:
"""Fetch, extract and store text content from the URL in the document store."""
document = _fetch_and_extract(url)
if document:
document_store.store_document(url, document)
def _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
def _query_brave_api(query: str) -> List[dict]:
"""Query the Brave Search API and return the raw results."""
url = "https://api.search.brave.com/res/v1/web/search"
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}
response = requests.get(url, headers=headers, params=params, timeout=settings.BRAVE_API_TIMEOUT)
response.raise_for_status()
json_response = response.json()
try:
async with httpx.AsyncClient(timeout=settings.BRAVE_API_TIMEOUT) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
# See https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
# & https://api-dashboard.search.brave.com/app/documentation/web-search/responses#SearchResult
return json_response.get("web", {}).get("results", [])
# 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", [])
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
# Rate limit - retryable
logger.warning("Brave API rate limited: %s", e)
raise ModelRetry(
"The search API is rate limited. Please wait a moment and try again."
) from e
if e.response.status_code >= 500:
# Server error - retryable
logger.warning("Brave API error: %s", e)
raise ModelRetry(
"The search service is temporarily unavailable due to a server error. Retrying..."
) from e
# Client error (4xx) - not retryable, stop and inform user
logger.error("Brave API client error: %s", e)
raise ModelCannotRetry(
f"Web search failed with a client error (status {e.response.status_code}). "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
except httpx.TimeoutException as e:
# Timeout - retryable
logger.warning("Brave API timeout: %s", e)
raise ModelRetry("The search request timed out. Retrying with a fresh attempt...") from e
except httpx.HTTPError as e:
# Other HTTP errors - retryable
logger.warning("Brave API connection error: %s", e)
raise ModelRetry(
f"Connection error while searching the web: {type(e).__name__}. Retrying..."
) from e
except Exception as e:
# Unexpected errors - not retryable, stop completely
logger.exception("Unexpected error querying Brave API: %s", e)
raise ModelCannotRetry(
f"An unexpected error occurred with the search service: {type(e).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
"""Format the raw search results into a ToolReturn object."""
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", [])
}
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,
},
)
def web_search_brave(query: str) -> ToolReturn:
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:
"""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.
"""
Search the web for up-to-date information
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(
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.
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.
"""
raw_search_results = _query_brave_api(query)
logger.debug("Starting classic web search without RAG backend for query: %s", query)
try:
raw_search_results = await _query_brave_web_search_api_async(query)
reset_caches() # Clear trafilatura caches to avoid memory bloat/leaks
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
# Parallelize fetch/extract for results that don't include extra_snippets
to_process = [
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
]
# Parallelize fetch/extract 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") and not r.get("snippets")
]
if to_process:
max_workers = min(settings.BRAVE_MAX_WORKERS, len(to_process))
if max_workers == 1:
# Avoid overhead of ThreadPoolExecutor if only one task
for idx, r in to_process:
raw_search_results[idx]["extra_snippets"] = _extract_and_summarize_snippets(
query, r["url"]
)
if to_process:
# Process all URLs concurrently
tasks = [
_extract_and_summarize_snippets_async(query, r["url"]) for idx, r in to_process
]
results = await asyncio.gather(*tasks, return_exceptions=False)
else:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(_extract_and_summarize_snippets, query, r["url"]): idx
for idx, r in to_process
}
for future in as_completed(future_map):
idx = future_map[future]
raw_search_results[idx]["extra_snippets"] = future.result()
# Update raw_search_results with extracted snippets
for (idx, _), snippets in zip(to_process, results, strict=True):
raw_search_results[idx]["extra_snippets"] = snippets
return format_tool_return(raw_search_results)
formatted_result = format_tool_return(raw_search_results)
# Check if we got any valid results
if not formatted_result.return_value:
raise ModelRetry(
"No valid search results were extracted from the web pages. "
"Retrying the search to find better sources..."
)
return formatted_result
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as exc:
# Unexpected error in our code - stop and inform user
logger.exception("Unexpected error in web_search_brave: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during web search: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
@last_model_retry_soft_fail
async def web_search_brave_llm_context(_ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web for up-to-date information
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.
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.
"""
raw_search_results = _query_brave_api(query)
logger.debug("Starting web search with RAG backend for query: %s", query)
try:
raw_search_results = await _query_brave_web_search_api_async(query)
reset_caches() # Clear trafilatura caches to avoid memory bloat/leaks
# Clear trafilatura caches in thread pool to avoid blocking
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, reset_caches)
# Store documents in a temporary document store for RAG search
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
with document_store_backend.temporary_collection(f"tmp-{uuid.uuid4()}") as document_store:
max_workers = min(settings.BRAVE_MAX_WORKERS, len(raw_search_results))
if max_workers == 1:
for result in raw_search_results:
# Fetch and extract document content
_fetch_and_store(result["url"], document_store)
else:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(_fetch_and_store, result["url"], document_store)
# Store documents in a temporary document store for RAG search
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
# Create temporary collection
temp_collection_name = f"tmp-{uuid.uuid4()}"
try:
async with document_store_backend.temporary_collection_async(
temp_collection_name, session=ctx.deps.session
) as document_store:
# Fetch and store all documents concurrently
tasks = [
_fetch_and_store_async(
result["url"],
document_store,
user_sub=ctx.deps.user.sub,
session=ctx.deps.session,
)
for result in raw_search_results
]
for future in as_completed(futures):
try:
future.result()
except Exception as e: # pylint: disable=broad-except
logger.exception("Error fetching/storing document: %s", e)
await asyncio.gather(*tasks, return_exceptions=True)
rag_results = document_store.search(
query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
)
# Perform RAG search
rag_results = await document_store.asearch(
query=query,
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
session=ctx.deps.session,
user_sub=ctx.deps.user.sub,
)
logger.debug("RAG search returned: %s", rag_results)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
ctx.usage += RunUsage(
input_tokens=rag_results.usage.prompt_tokens,
output_tokens=rag_results.usage.completion_tokens,
)
# Map RAG results back to raw search results to include extra_snippets
# Suboptimal O(N^2) but N is small...
for rag_result in rag_results.data:
for result in raw_search_results:
if result["url"] == rag_result.url:
result.setdefault("extra_snippets", []).append(rag_result.content)
break
# Map RAG results back to raw search results to include extra_snippets
for rag_result in rag_results.data:
for result in raw_search_results:
if result["url"] == rag_result.url:
result.setdefault("extra_snippets", []).append(rag_result.content)
break
return format_tool_return(raw_search_results)
except Exception as exc:
logger.exception("Error with document store: %s", exc)
raise ModelRetry(
f"Document storage temporarily failed: {type(exc).__name__}. "
"Retrying the operation..."
) from exc
formatted_result = format_tool_return(raw_search_results)
# Check if we got any valid results
if not formatted_result.return_value:
raise ModelRetry("No valid search results were extracted.")
return formatted_result
except (ModelCannotRetry, ModelRetry):
# Re-raise these as-is
raise
except Exception as e:
# Unexpected error - stop and inform user
logger.exception("Unexpected error in web_search_brave_with_document_backend: %s", e)
raise ModelCannotRetry(
f"An unexpected error occurred during web search with RAG: {type(e).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from e
@@ -2,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.
"""

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