Compare commits

..

283 Commits

Author SHA1 Message Date
renovate[bot] 9d4d26517b ⬆️(dependencies) update python dependencies 2026-03-23 01:07:12 +00: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
Quentin BEY 14b920466b 🔖(patch) bump release to 0.0.5
Fixed

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

- 🌐(i18n) add dutch language #117

Changed

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

Fixed

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

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

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

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

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

Changed

- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
- ️(web-search) allow to override returned chunks #107
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
2025-10-21 22:45:32 +02:00
Quentin BEY 151914f55c ⚗️(summarization) add system prompt to handle tool
With these modifications, we expect the LLM to not sumarize the
summarization tool result.
2025-10-21 22:12:50 +02:00
elvoisin ab45eea736 🔧(front) update links feedback + popup settings (#111)
change tchap channel + change links in settings popup
2025-10-21 17:28:19 +02:00
elvoisin ef7c15507d (front) add drag'n drop file (#110)
add drag n drop in input chat on the entire screen
2025-10-21 16:04:07 +02:00
Quentin BEY ca0c3a0169 🐛(activation-codes) create contact in brevo before add to list
Brevo requires the contact to exist before being added to a list.
2025-10-21 14:18:25 +02:00
Quentin BEY 6b2327b0f1 ️(web-search) allow to override returned chunks
When using the document vector storage, we want to be able to
return more chunks.
2025-10-21 13:55:04 +02:00
Eléonore Voisin 9887d79bd5 💬(wording) error page wording
change error page wording 401 - 403
2025-10-21 13:21:11 +02:00
Arnaud Robin d1852d210c 📈(posthog) add sub field to tracking
This will allow to merge analytics from posthog and langfuse.
2025-10-21 13:12:59 +02:00
qbey 12a9488f30 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-20 23:45:35 +02:00
Quentin BEY 15a84132b0 (activation-codes) register users also on Brevo
This provides a way to store the users on Brevo directly
for later product opening mail campaign.
2025-10-20 23:38:57 +02:00
elvoisin 8a52ae8608 🐛(front) code activation fix session end (#101)
add check auth in activation page
2025-10-20 18:45:56 +02:00
Quentin BEY fcd0e24a16 🔖(major) bump release to 0.0.1
This is the first release of the projet, yay

- 🎨(front) activation page footer
- 👷(front) change size small modal
- 🎨(front) retour ui global
- 👷(front) fix button scrollDown
- 💥(front) disable input when error occurred
- 👷(front) fix scroll
- 🐛(front) fix left panel status + fix scroll
- 🐛(llm) add is_active field and persist chat preference
- (frontend) add LLM selection in chat input #53
- 🎨(front) fix width chat container
- 🎨(front) fix width chat container #55
- 🐛(front) fix button search web on new conversation
- 🎨(front) improvement search input scroll
- (404) fix front 404 page
- (chat) add frontend feature flags #29
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- (ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- (ui) delete flex message
- (front) add enabled/disabled conversation analysis
- 🎨(front) amelioration chat ux
- 🎨(front) global layout modification
- (front) global layout UI
- ♻️(chat) rewrite backend using Pydantic AI SDK #4
- 🗃️(chat) enforce messages stored JSON format #6
- 🐛(chat) UI messages must have a unique identifier #6
- (llm) allow configuration from JSON file #22
- 💥(agent) replace routing w/ tool calls #40
- 🧱(storage) upload the user documents into S3 #86

- 🎉(conversations) bootstrap backend & frontend #1
- (web-search) add RAG capability to do web search #7
- (chat) add document RAG on document uploaded by user #8
- (backend) allow use to stop conversation streaming #14
- 🐛(agent) add the current date in the system prompt #18
- (backend) add feature flags from posthog #13
- (user) allow to use conversation data for analytics #23
- (chat) enforce response in user language #24
- 📈(langfuse) add light instrumentation #26
- 🚑️(agent) allow Mistral w/ vLLM & tools #36
- (web-search) add Brave search tool #47
- (models) add mistral support & customization #51
- 🐛(web-search) add summarization to Brave results #58
- (langfuse) allow user to score messages from LLM #6
- (onboarding) add activation code logic for launch #62
- 💄(chat) add code highlighting for LLM responses #67
2025-10-20 00:00:21 +02:00
Quentin BEY 620575140e 🌐(project) add missing translation files
In Crowdin we added ru, uk and nl, which breaks the download
action.
2025-10-19 23:59:51 +02:00
Quentin BEY 1d55f51e15 🧱(storage) bump helm chart to v0.0.4
The new chart includes the "media" related service and ingress.
Even if they are not used yet...
2025-10-19 23:06:28 +02:00
Quentin BEY d91cb498e0 🩹(storage) add missing "media_auth" implementation
This provides a way for users to get access to the document
attached to a conversation. But for now the frontend does not
provide any link to this.
2025-10-19 22:58:55 +02:00
Quentin BEY 7cb21e694e 📈(langfuse) add user FQDN to trace metadata
This will allow use to improve according to user origin.
2025-10-19 17:42:22 +02:00
Quentin BEY 661f678363 💩(chat-input) manage S3 attachment upload
This is quite bad because it would be nice to start upload
before sending the first message.
Also, it would be nice to start parsing earlier (more work...)
And it would be nice to display progression to the user.
2025-10-19 17:24:31 +02:00
Quentin BEY f3781e0dde 🧱(storage) upload the user documents into S3
The user uploaded content (images/documents) are now directly
uploaded to S3, and a pre-signed URL is given to the LLM.

The code is a bit complicated because there are behaviors
from docs, other from drive, and in the end some parts are
not used.

This will need some cleanup.

Improvements:
 - Allow to upload PDF without vectorized storage (to let
   LLM manage them on the fly)
 - Allow to configure whether a model can support images/PDF
   or not
2025-10-19 16:34:45 +02:00
Quentin BEY c510adf832 🧱(storage) enable S3 media storage again
Simply restore the commits regarding the S3 management.
2025-10-18 21:42:29 +02:00
elvoisin 6641457cdd 🎨(front) activation page footer (#84)
fix footer in activation page + fix user message
2025-10-17 17:30:37 +02:00
github-actions[bot] b15aa901f8 🌐(i18n) update translated strings (#85)
Update translated files with new translations

Co-authored-by: elvoisin <95469923+elvoisin@users.noreply.github.com>
2025-10-17 16:47:42 +02:00
elvoisin 161cdfe17c 👷(front) change size small modal (#83)
change size modal + fix scroll first message
2025-10-17 09:45:59 +02:00
Quentin BEY c9b724f8da (activation-codes) allow code customization
The activation codes can be fully customized on creation and
not edited afterward.
2025-10-16 21:21:35 +02:00
Quentin BEY ef9bdf9b95 🐛(chat) fix the file format restriction for upload
Restore the `accept` attribute.
2025-10-16 20:45:02 +02:00
elvoisin daa623d2df 🎨(front) retour ui global (#80)
* 👷(front) fix scroll

fix scroll + fix background model selector

* 🎨(front) retour ui global

retour ui robin : scroll, searchbar, margin
2025-10-16 18:18:30 +02:00
Quentin BEY 14739ba2e6 🐛(llm) use Tex markers for formula rendering
The previous implementation was expecting dollar signs for
formulas, but mistral seems trained on \(...\) and \[...\]
format.

Let's hope it does not break anything.
2025-10-16 10:53:37 +02:00
elvoisin d242328c6d 👷(front) fix button scrollDown (#77)
* 👷(front) fix scroll

fix scroll + fix background model selector

* 👷(front) fix button scrollDown

fix sensitive button scrollDown
2025-10-15 10:48:46 +02:00
elvoisin aeba1ba869 💥(front) disable input when error occured (#76)
* 👷(front) fix scroll

fix scroll + fix background model selector

* 💥(front) disable input when error occured

add text error + disable input chat
2025-10-15 09:38:03 +02:00
elvoisin 81c1dcbf81 👷(front) fix scroll (#72)
fix scroll + fix background model selector
2025-10-15 09:21:38 +02:00
Quentin BEY 8dba8b08e3 🚑️(mistral) attempt to fix Unmarshaller error
Mistral model sometime returns
`{'type': 'reference', 'reference_ids': ['cuisineaz']}` which
does not match the Mistral SDK format for `ReferenceChunk`:
`reference_ids: List[int]``...

The Mistral documentation provides a way to return citation
(even if we don't want to use this systemp), so we expect
the model to behave properly with this change.
2025-10-14 22:25:18 +02:00
qbey fb92ac9f2b 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-14 21:32:23 +02:00
Quentin BEY 859b2f312d ⚰️(RAG) remove old RAG_WEB_SEARCH_BACKEND setting
This settings has been removed when we switched to tool calls.
2025-10-14 21:18:17 +02:00
Quentin BEY 4b70641ac6 (pydantic) fix tests after dependencies update
`pydantic` and `pydantic-ai` has been upgraded, fix tests
accordingly
2025-10-14 16:49:46 +02:00
Quentin BEY c460f80cf8 📌(pylint) htmldate does not support lxml >=6 yet
https://github.com/adbar/htmldate/issues/185
2025-10-14 16:33:11 +02:00
renovate[bot] c4af7dcc0b ⬆️(dependencies) update python dependencies 2025-10-14 16:31:13 +02:00
Quentin BEY d12b959812 📌(pylint) pylint-django does not support pylint >=4 yet
https://github.com/pylint-dev/pylint-django/issues/467
2025-10-14 15:40:20 +02:00
Quentin BEY f014de69af 🚑️(mistral) fix the monkeypatch which ignores reference
The previous patch was a bit optimistic and happend to break
returned format.
The new patch will require update and monitor...
2025-10-14 15:27:15 +02:00
Quentin BEY 4b9a251219 💄(chat) add code highlighting for LLM responses
This adds a plugin to nicely display the code in the conversation.
The plugin is rehype-pretty-code, with the `github-dark-dimmed`
theme.

We may select another theme, and later also add light and dark
themes:
 - https://shiki.style/themes#themes

For later we may add instruction for the LLM to manage
transformers: https://shiki.style/packages/transformers

We also considered using https://sugar-high.vercel.app/ which
is quite light and powerful, we might switch later if pretty-code
is too heavy...

In both cases, the themes can be fully overriden if we want to
match the application colors ;)
2025-10-14 10:57:04 +02:00
elvoisin 146c9ff19f 🐛(front) fix left panel status + fix scroll (#65)
fix scroll + fix left panel status + fix show sources
2025-10-14 10:05:28 +02:00
Eléonore Voisin 7c6e3da2d9 (onboarding) add activation code front
add activation code form
2025-10-13 17:09:43 +02:00
Quentin BEY 605f730b16 💄(frontend) add account activation page
This displays an activation page when the setting
`ACTIVATION_REQUIRED` is enabled.
2025-10-13 17:09:43 +02:00
Quentin BEY 04cf9cbc34 (onboarding) allow users to register to beta opening
This simply store the user and their email to notify them
later about a wider opening of the beta version.
2025-10-13 17:09:43 +02:00
Quentin BEY 5d30f275cf (onboarding) add activation code logic for launch
This is a very specific feature, which might be only used for the
product launch.
It allows to restrict user who have access to the product by
asking them to enter an activation code.
2025-10-13 17:09:42 +02:00
BEY Quentin ba62bd82d5 (langfuse) allow user to score messages from LLM (#61)
* (langfuse) allow user to score messages from LLM

We store the trace ID in the message ID... this is not very clean
but it is the easiest way to consistently save the trace ID.

This adds the endpoint for the "thumb up"/"thumb down" calls.

* 💄(frontend) add the thumb up/down to score LLM messages

This enables the score button when a trace exists (ie the
user allows analytics and Langfuse is setup).

* (langfuse) allow user to score messages from LLM

allow user to score messages from LLM

---------

Co-authored-by: Eléonore Voisin <elevoisin@gmail.com>
Co-authored-by: elvoisin <95469923+elvoisin@users.noreply.github.com>
2025-10-10 11:27:22 +02:00
elvoisin 5ed92c286e 🐛(llm) add is_active field and persist chat preference (#63)
add is_active field for LLM models & add user preferences store
2025-10-10 10:40:49 +02:00
BEY Quentin feba5509d2 (frontend) add LLM selection in chat input (#53)
* (frontend) add LLM selection in chat input

This allows user to select which model to use, we currently plan
to use it only in staging.

* (frontend) add LLM selection in chat input

add LLM selection in chat input

---------

Co-authored-by: Eléonore Voisin <elevoisin@gmail.com>
Co-authored-by: elvoisin <95469923+elvoisin@users.noreply.github.com>
2025-10-08 17:16:22 +02:00
Quentin BEY 747ca8499d 🐛(web-search) support web search using document storage
This allows to not call an LLM for summary but instead use
a vectorized search to return only useful snippets.
2025-10-06 17:47:09 +02:00
Quentin BEY f2deeedbc4 🐛(document) fix collection context manager
Fix the URL build and the context manager (needs a test)
2025-10-06 17:34:59 +02:00
Quentin BEY 1cdccd3d24 🐛(web-search) add summarization to Brave results
Loaded pages can be too long for the context, so a naive
implementation is to summarize them.

It would be better to store results in a Vector space, but
also requires a call to a vectorization model.
2025-10-06 17:18:04 +02:00
Quentin BEY 79b5be6004 ♻️(rag) rewrite the document RAG search code
This will ease the use of other document storage for semantic
search.
2025-10-06 15:54:45 +02:00
elvoisin a6a5dec0ac 🎨(front) fix width chat container (#57)
refix width container
2025-10-04 14:36:36 +02:00
elvoisin 592b67ca4c 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-03 15:45:36 +02:00
Quentin BEY 45c346e1e7 🚑️(mistral) fix wrongly commented line...
This monkeypatch is dirty, we may manage the assertion error at
the streaming level... Anyway, this lack a test.
2025-10-03 15:44:19 +02:00
github-actions[bot] fe7f7b2730 🌐(i18n) update translated strings (#54)
* 🌐(i18n) update translated strings

Update translated files with new translations

* 👷(i18n) add noChangeLog label to opened PR

The translation update commit does not need a changelog entry.

---------

Co-authored-by: qbey <5311687+qbey@users.noreply.github.com>
Co-authored-by: Quentin BEY <quentin.bey@mail.numerique.gouv.fr>
2025-10-03 15:04:17 +02:00
elvoisin aabb2a0991 🎨(front) fix width chat container (#55)
fix quicksearch + fix width container
2025-10-03 14:45:18 +02:00
Quentin BEY 48fb064594 ⬆️(django) bump version to 5.2.7
Trivy scan is complaining this fixes CVE-2025-59681.
2025-10-02 22:59:39 +02:00
Quentin BEY bc65d646e5 🎨(pydantic-ai) use the proper profile setting
The strict attribute for "tools" can now be removed with a specific
parameter for the model profile. Let's use it.
2025-10-02 22:59:39 +02:00
Quentin BEY 99c4b6ba29 (models) add mistral support & customization
This allows to connect to a Mistral model, on a Mistral
platform, self-hosted.
2025-10-02 16:52:38 +02:00
elvoisin c9c10ce19a 🐛(front) fix button search web on new conversation (#50)
* 🎨(front) improvement search input scroll

improvement search input scroll and margin

* 🐛(front) fix button search web on new conversation

fix button search web on new conversation'
2025-10-02 16:43:08 +02:00
elvoisin 1b9e814be7 🎨(front) improvement search input scroll (#49)
improvement search input scroll and margin
2025-10-02 11:50:10 +02:00
Quentin BEY 9644b8ccd3 🧑‍💻(admin) allow to update user consent
This might be removed later at the end of the alpha, because
we are not supposed to update this without the user knowing it.
2025-10-02 10:04:09 +02:00
Quentin BEY ac65ffe7f7 🐛(admin) do not load all users in conversation edition
This prevents the load of all users when editing a conversation.
2025-10-02 10:02:52 +02:00
Quentin BEY bb6ced4728 (web-search) add Brave search tool
This allows to use Brave as a web search engine in a tool.
2025-10-01 16:46:42 +02:00
Quentin BEY 02ac8af714 ⚗️(users) allow to enable analytics by default
This will be helpful to have analytics during the alpha
deployment.
2025-09-30 16:46:03 +02:00
Quentin BEY 8c706fd2d8 ⚗️(agent) improve force web search
Forcing a specific tool call is currently a bit complicated,
so we choose a more naive way.
2025-09-30 16:36:09 +02:00
Quentin BEY c9efa978e1 💥(agent) replace routing w/ tool calls
The first implementation was made because our models had not
tool calling capabilities, and the routing and context management
were quite bad.

Now we can use tools (yay) the whole code is switched to tool
calling.

This first implementation of summarize agent is quite bad, this
will be improved soon.
2025-09-30 15:28:56 +02:00
elvoisin d361d0177c (404) fix front 404 page (#44)
remove margin top on the header
2025-09-30 10:35:32 +02:00
BEY Quentin 934098a1c2 💩(chat) add frontend feature flags (#29)
* 💩(chat) add frontend feature flags

This introduce the use of feature flags:
 - flags can be globally enabled or disabled from the backend
 - when dynamic, it queries Posthog to get the value

* (chat) add frontend feature flags

disable buttons for feature flags

---------

Co-authored-by: Eléonore Voisin <elevoisin@gmail.com>
2025-09-29 10:42:10 +02:00
Quentin BEY 5c4b1ff388 📌(frontend) stay with Vercel SDK <5
While the frontend and the backend are not ready we
need to stay in the v4.x version
2025-09-26 14:18:45 +02:00
renovate[bot] 689f2ce672 ⬆️(dependencies) update python dependencies 2025-09-24 23:01:06 +02:00
elvoisin 2515e14942 🎨(front) move emplacement for attachment (#39)
move emplacement for attachement in chat
2025-09-24 16:23:49 +02:00
elvoisin 273332c370 🎨(front) move emplacement for attachment (#38)
move emplacement for attachement in chat
2025-09-23 16:25:15 +02:00
elvoisin 623a951ca2 🎨(ui) retour ui sources files (#37)
* (ui) fix retour global ui

correction retour ui

* 🎨(ui) retour ui sources files

fix retour ui source files

* 🐛(fix) fix front build

prettier - fix build front
2025-09-23 13:29:52 +02:00
Quentin BEY 9e4f0d3310 ♻️(agents) rewrite the awy to setup agents
Not sure this rewrite was mandatory, but it paves the way to a
rewrite of the "client" module.
2025-09-23 00:08:36 +02:00
Quentin BEY a560ea9e78 🔒️(frontend) update alpine packages in production image
Force an update of installed package in the image used for
the frontend in production.
2025-09-22 22:47:25 +02:00
Quentin BEY e04a050486 (agent) add fake streaming when the model does not support it
To have a better UX, we simulate a streamed return. The default
value is quite fast to not block the worker too long...
2025-09-22 22:47:25 +02:00
Quentin BEY 7e460b55a3 🚑️(agent) allow Mistral w/ vLLM & tools
To be able to call our Mistral model, few fixes are required:
- "strict" is nor allowed for tools, vLLM will refuse the schema
  validation
- response streaming w/o tools works, but not w/ tools...
  As we expect to switch from old-school "intent detection" to
  automatic tool use, we need to be able to disable streaming.

What we should do next:
- tidy code: the iteration loginc might be in the Agent class
- fake result streaming for end-user. I don't like that, but UX
  might prefer.
2025-09-22 22:47:25 +02:00
Quentin BEY dce69eb31c (llm) allow profile configuration for model
This allows to use a specific profile for some models.
This is useful in the case of a mistral model behind vLLM.

There is room for improvement to use the real default
and not be forced to pass OpenAI default "supports"...

```
"profile": {
  "json_schema_transformer":
    "chat.agents.json_schema_converters.MistralVllmJsonSchemaTransformer",
  "supports_json_schema_output": true,
  "supports_json_object_output": true
}
```
2025-09-22 22:47:21 +02:00
Quentin BEY 297af78936 ⚰️(vercel-sdk) remove code wrongly pushed
This was not supposed to be here.
2025-09-22 22:46:57 +02:00
Quentin BEY 7507f1a8db ⬆️(pydantic-ai) bump to the latest version
This might be helpful to include the latest fixes.
2025-09-22 22:46:50 +02:00
Quentin BEY 05dceec3e7 🐛(vercel-sdk) allow tool calls to have no arguments
Some tools might not need arguments because they rely only
on the run context, we can allow that.
2025-09-22 22:39:19 +02:00
Quentin BEY d486661f32 🐛(markitdown) fix converter data and detection
The data passed to the converter where not making it possible
to detect even plain text documents...
2025-09-22 22:39:19 +02:00
Quentin BEY 7bc38c7435 ♻️(chat) move agent to a dedicated module
This is a first step to tidy code and ease agent definition
readability.
2025-09-22 22:39:19 +02:00
elvoisin b4df44311a (ui) fix retour global ui (#35)
correction retour ui
2025-09-22 09:20:07 +02:00
elvoisin 9878498a34 🐛(fix) broken staging css (#34)
force fix staging css
2025-09-15 17:43:07 +02:00
elvoisin 517bc1a9d1 🐛(fix) broken staging css (#33)
force fix staging  css
2025-09-15 16:47:08 +02:00
Quentin BEY 81fd0d3400 🗃️(chat) fix conversation messages source type
This add the migration to update data after the fix introduced in
d579d9abd0
2025-09-15 15:03:51 +02:00
elvoisin 10f3ebf81f 🎨(front) add customisation for alpha version (#31)
change logo + home + add form to send feedback - wip
2025-09-15 14:50:38 +02:00
Quentin BEY d579d9abd0 🚑️(chat) source UI messages serializer typo
There was a typo in the source UI Message model used in chat
input serialization...
2025-09-08 22:35:33 +02:00
elvoisin 5bdd2f92f0 (ui) delete flex message (#28)
remove display flex on content chat
2025-09-08 10:40:31 +02:00
Quentin BEY 633bcf094d 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 14:52:50 +02:00
Quentin BEY c491108f9e 🔧(ci) always run all git-lint steps
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.

thank @lunika
2025-09-05 14:42:46 +02:00
Quentin BEY d582f85f59 ♻️(backend) prepare vercel AI SDK v4 -> v5
This introduce a new way to stream events and should ease
migration from v4 to V5 of the Vercel SDK.

WARNING: the v5 implementation is not ready yet, but we need
to merge this before making too much change to the codebase.
2025-09-05 13:55:56 +02:00
elvoisin 926ea2975f (front) add enabled/disabled conversation analysis (#27)
add enabled/disabled conversation analysis in settings modal
2025-09-05 12:45:22 +02:00
Quentin BEY ace27ab7fb 📈(langfuse) add light instrumentation
This is a first implementation of Langfuse to see user interactions.
Next step will be to allow prompt improvements.
2025-09-05 12:06:43 +02:00
Quentin BEY 9bbc3f160f 🔥(llm) remove mlflow intrumentation & deps
We don't use this instrumentation for now and plan to use something
else.
2025-09-05 12:06:43 +02:00
elvoisin 90d40eba6a 🎨(front) amelioration chat ux (#25)
fix appartion message + fix placeholder input
2025-09-05 12:03:26 +02:00
Quentin BEY 8b8b7bd7dd (chat) enforce response in user language
Ask the LLM to use the user's language to answer.
2025-09-04 13:24:02 +02:00
Quentin BEY b76760025f 🚑️(helm) fix llm_configuration file content
There is a typo in the helm chart...
2025-09-04 10:09:57 +02:00
Quentin BEY a267b0282b (user) allow to use conversation data for analytics
The frontend can store whether the user allows us to use
the conversation data to make analytics and help chat
improvement.
2025-09-03 23:27:57 +02:00
Quentin BEY c5b9576835 (llm) allow completion endpoint to select model
This will allow the frontend to select the model to use from
the configuration.
2025-09-03 22:50:45 +02:00
Quentin BEY 6e5220866a (llm) add models listing endpoint
This will allow the frontend to display several models available
for the user.
2025-09-03 13:32:56 +02:00
Quentin BEY 548679698f 🧱(helm) mount the LLM JSON configuration file
This allows to customize the LLM configuration for deployments.
2025-09-03 12:01:10 +02:00
Quentin BEY 5e2d77642d (llm) allow configuration from JSON file
This is the first step to allow several models to be available to
the users.

This is not a very good implementation, as it aims to keep the
ability to use django settings for configuration:

 - makes it easy to deploy only one model
 - allow changes like before in tests

Next step would be to remove the django settings and update
all tests etc.
2025-09-02 12:17:52 +02:00
elvoisin df3934367a 🎨(front) global layout modification (#21)
delete unused languages + modify scroll down + change source item + attachements
2025-09-02 11:39:26 +02:00
Quentin BEY 22769af0e5 🐛(posthog) add missing configuration
This should fix the issue of "$host" being unknown when checking
for feature flag.
2025-09-01 14:11:41 +02:00
Quentin BEY 59af95caf7 ⚰️(minio) remove code related to the S3 storage
This is related to 697b040a40
2025-08-27 23:55:26 +02:00
Quentin BEY b6fe71adb0 (backend) add feature flags from posthog
This adds posthog backend to know whether a feature is enabled
for a user.
More globally, if Posthog is not present feature flags can be
enabled/disabled globally.
2025-08-27 23:29:32 +02:00
Quentin BEY fd06d33d05 🐛(llm) disabling thinking configuration
While not fully managed, disable this configuration.
2025-08-27 17:22:52 +02:00
Quentin BEY e921aa9d94 🐛(prompt) slight change of routing prompt
Attempt to reduce when the routing LLM tries to make web search.
2025-08-27 17:22:52 +02:00
Quentin BEY f7e3ed19ba 🐛(agent) add the current date in the system prompt
We add the current date to the system prompt.
Warning: the timezone is not the user's one...
2025-08-27 17:22:52 +02:00
elvoisin 4e078d8bb1 🎨(front) global layout (#5)
* 🎨(front) global layout

modify left panel, header and chat

* 🎨(front) color dsfr v2

change color + left panel logic
2025-08-27 13:56:57 +02:00
Quentin BEY 2486db8287 ⚗️(github) run action on GitHub servers
This reverts commit 42ce2affe270f110f3312d3a3464de42638085d7.
2025-08-26 17:06:54 +02:00
Quentin BEY cc77378d39 ️(ci) use setup-python cache option
The setup-python action is able to cache the dependencies and reuse this
cache while the pyproject file has not changed. It is easy to setup,
the package manager used has to be declared in the cache settings.

Credits to lunika who made the change on
https://github.com/suitenumerique/drive
2025-08-26 17:06:54 +02:00
Quentin BEY fbed35f4c4 📝(helm) run readme-generator-for-helm
Re-generate the helm readme after modifications.
2025-08-26 17:06:44 +02:00
Quentin BEY 697b040a40 ⚰️(storage) remove S3 media storage
Unused for now and not clear if we will need it later.
2025-08-26 17:00:25 +02:00
Quentin BEY 86bcebadf8 📝(readme) small improvements
- remove the old handmade logo
- fix the dependencies
- add small introduction
2025-08-26 17:00:25 +02:00
Quentin BEY 0f45d2e896 (backend) fix test with user count
For some reason the test counting users are starting to fail.
I don't know if it's because of transactional test cases, but
a fix is to drop all users before running the test...

This would need further investigation.
2025-08-09 00:05:33 +02:00
Quentin BEY 475afcb454 📌(openai) pin version to 1.99.1
This needs to be unpinned when the openai is fixed.

=> https://github.com/openai/openai-python/issues/2525
=> https://github.com/pydantic/pydantic-ai/issues/2476
2025-08-08 21:43:36 +02:00
Quentin BEY 095735fb34 (backend) allow use to stop conversation streaming
This introduces a new endpoint to allow user to stop the message
stream.

We currently use uWSGI workers, which will not automatically stop the streaming
when the request is cancelled by the client. Therefore, we need to
explicitly stop the streaming by calling the /stop-steaming endpoint.
When (if) we switch to Gunicorn with Uvicorn workers, this will not be necessary
as the Uvicorn workers will automatically stop the streaming when the request
is cancelled. BUT, we will then need to handle the streaming cancellation when
the user is simply offline and still waiting for a response and the conversation
to be updated with the result. So this endpoint will still be useful to be able
to detect the cancellation of the streaming versus the user being offline.
2025-08-08 18:48:29 +02:00
Quentin BEY bd798816a2 🗃️(chat) rename message field and reset migrations
While the project is not deployed, I reset the migration history
and use a better name for the history messages.
2025-08-06 21:49:13 +02:00
Quentin BEY aafaaeed3e ⬇️(markitdown) enforce old 0.0.2 version
Our base docker image (alpine) does not support magika dependency
because there is no wheel for onnxruntime for alpine...
It's complex to build and a bit hackish.

Switching to a slim (debian) base image, is a solution but trivy
scan detects CVEs. We need to check the CVEs don't regard our stack
but it's bit of work also.

For the time being, I pin the markitdown version to an old one...
This is sad, since a lot of bugs has been fixed.
2025-08-06 15:31:11 +02:00
Quentin BEY a9883a1a31 💄(chat) restrict uploadable file format for RAG
This takes the allowed formats from the /config endpoint.
2025-08-06 11:49:25 +02:00
Quentin BEY 768aeec91f 🐛(config) restrict uploadable file types for RAG
This allows to let the frontend know whi file types can be
uploaded to do RAG.
By default it's the format accepted by markitdown + PDF.
2025-08-06 11:49:25 +02:00
Quentin BEY 88760022e0 💩(frontend) display a modal on specific completion error
We use the error mechanism to allow specific message display to user.
2025-08-06 11:49:25 +02:00
Quentin BEY 8cc6a78089 💩(frontend) allow chat to display document parsing process
We use the tool invocation system to display a specific message
to the use, to let them know we are parsing the attachments.
2025-08-06 11:49:25 +02:00
Quentin BEY 04f15a518a 💩(frontend) allow source to display a document
We simply check is the URL is ... an URL, if not it's a document.
2025-08-06 11:49:25 +02:00
Quentin BEY 2ef70d15ea (chat) add document RAG on document uploaded by user
This allow use to upload documents to ask question on them.
The summarize feature is willingly disabled for now, as it is not
the same process.
2025-08-06 11:49:25 +02:00
Quentin BEY 8358ca9490 🐛(rag-websearch) fix the Albert API search query
This was not properly tested and the code changed just before
last commit... boom.
2025-07-31 23:46:33 +02:00
Quentin BEY f1c8a09378 ✏️(docs) remove remaining references to docs
There was still outdate text from docs... Some are still remaining
but will be removed lated.
2025-07-31 15:15:50 +02:00
Quentin BEY 8c917b90c2 🔨(tilt) fix local k8s deployment
Removes the docs references and fix the helmfile extension to work
with newest version.
2025-07-31 15:15:50 +02:00
Quentin BEY 44b50d5085 👷(actions) increase front build timeout
While on self-hosted runners, the build can be very long...
2025-07-31 15:06:44 +02:00
Quentin BEY 30ca4a4066 (codespell) add configuration file and exceptions
This configuration file allow to easily run codespell locally.
The RAG mock result file is ignored because it contains french
text.
2025-07-31 11:52:51 +02:00
Quentin BEY 43e012d185 (rag) add test for conversation with websearch RAG
This add integration tests for chat when using RAG web search.
2025-07-31 11:52:51 +02:00
Quentin BEY 4b89c826c1 ️(pydantic) use stored history in agent requests
We were converting the whole message history from the frontend
on each call. We replace that by direct use of the history stored
in the proper format from the database.

This important to do so because the raw Pydantic messages may contain
more information than the ones displayed to the user (like when
doing RAG web search).
2025-07-31 11:52:51 +02:00
Quentin BEY e9b512400f 💩(frontend) add source display in chat
This is a bad implementation of source display, it only demonstrate
the use of the message source sent by the backend.

Needless to say the UI is really bad on this.

We could improve this by fetching icon and page title in the backend
and send it directly in the `providerMetadata` of the source.
See: LanguageModelV1Source
2025-07-31 11:52:51 +02:00
Quentin BEY f9d6934205 (web-search) add RAG capability to do web search
This introduces a first implementation of RAG web search using
the Albert API endpoint which retrieves chunks based on the user
prompt.

This also add a "routing" LLM call to detect whether the user wants
fresh information.
2025-07-31 09:57:43 +02:00
Quentin BEY a5ef813840 🐛(pyproject) fix the pydantic-ai dependency
The requirement was not in the proper place... Since I'm moving it
I also update the version :)
2025-07-30 15:10:25 +02:00
Quentin BEY 7d69eb2130 🐛(chat) UI messages must have a unique identifier
The new Pydantic implementation was not setting an ID for its message
storage, which make the frontend to not update properly the chat history
when selecting another conversation.
2025-07-28 16:45:25 +02:00
Quentin BEY f2707bb830 🗃️(chat) enforce messages stored JSON format
This enforces the models JSON format to `UIMessage`. It would be
nicer to decouple the Vercel format from the stored one, but it's also
more convenient for now, and will be quite easy to update later if
needed.
2025-07-28 16:45:25 +02:00
Quentin BEY b914214818 (e2e) fix tests after translations update
The english translation requires at least one translated string to
be considered as a supported language.

Remove tested languages which are not supported yet.
2025-07-28 10:58:18 +02:00
Quentin BEY e04f56059d ⚰️(chat) remove OpenAI Agent client
Since we now uses the Pydantic AI SDK we won't maintain this.
2025-07-25 17:46:42 +02:00
Quentin BEY c5af3cdcb1 ♻️(chat) rewrite backend using Pydantic AI SDK
We chose to use Pydantic AI instead of the OpenAI SDK.
This commit should not change the chat behavior.
2025-07-25 17:46:42 +02:00
qbey 0526c9bbef 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-22 09:48:19 +02:00
Quentin BEY d23434aafe 💚(crowdin) fix download action's job
The self-hosted runner requires explicit yarn install.
2025-07-22 09:39:50 +02:00
Quentin BEY 8ec172583a 💚(crowdin) fix upload action's job
The self-hosted runner requires explicit yarn install.
2025-07-21 22:21:11 +02:00
Quentin BEY 53cfc89b9b ⚗️(github) run action on self-hosted runner
This commit will be removed whent the project goes public.
2025-07-21 18:10:37 +02:00
Quentin BEY fe7995a118 🎉(conversations) bootstrap backend & frontend
This is the first commit which provides all the first stack for a
working chat.

This is a first implementation with:
 - Vercel SDK for the frontend part
 - OpenAI Agent SDK for the backend

The stack can use a local LLM with docker ot a remote one.

This implementation is more a draft, but it provides the project
structure.

All tests are working even if we lack a lot of them.
2025-07-21 18:10:15 +02:00
570 changed files with 70397 additions and 18042 deletions
+20
View File
@@ -0,0 +1,20 @@
[codespell]
skip =
./git/,
**/*.pdf,
**/*.po,
**/*.pot,
**/*.json,
**/yarn.lock,
**/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,
+1 -1
View File
@@ -18,7 +18,7 @@ A clear and concise description of what you expected to happen (or code).
3. And then the bug happens!
**Environment**
- Docs version:
- Conversations version:
- Instance url:
**Possible Solution**
+19 -6
View File
@@ -18,7 +18,7 @@ jobs:
test-front:
needs: install-dependencies
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -28,6 +28,9 @@ jobs:
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -39,7 +42,7 @@ jobs:
run: cd src/frontend/ && yarn test
lint-front:
runs-on: self-hosted
runs-on: ubuntu-latest
needs: install-dependencies
steps:
- name: Checkout repository
@@ -49,6 +52,10 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -60,9 +67,9 @@ jobs:
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
runs-on: self-hosted
runs-on: ubuntu-latest
needs: install-dependencies
timeout-minutes: 20
timeout-minutes: 40
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -72,6 +79,9 @@ jobs:
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -99,9 +109,9 @@ jobs:
retention-days: 7
test-e2e-other-browser:
runs-on: self-hosted
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 20
timeout-minutes: 40
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -111,6 +121,9 @@ jobs:
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
+61 -33
View File
@@ -15,7 +15,7 @@ jobs:
with-build_mails: true
lint-git:
runs-on: self-hosted
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
@@ -25,18 +25,22 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/conversations.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: self-hosted
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
@@ -49,7 +53,7 @@ jobs:
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
@@ -62,7 +66,7 @@ jobs:
fi
lint-spell-mistakes:
runs-on: self-hosted
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
@@ -70,18 +74,10 @@ jobs:
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
run: |
codespell \
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \
--skip "**/yarn.lock"
run: codespell
lint-back:
runs-on: self-hosted
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
@@ -89,22 +85,27 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
- 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: self-hosted
runs-on: ubuntu-latest
needs: install-dependencies
defaults:
@@ -184,21 +185,48 @@ jobs:
mc version enable conversations/conversations-media-storage"
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
- 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 -2
View File
@@ -14,7 +14,7 @@ jobs:
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: self-hosted
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
@@ -47,6 +47,12 @@ jobs:
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -74,4 +80,4 @@ jobs:
- [x] update translated strings
branch: i18n/update-translations
labels: i18n
labels: i18n,noChangeLog
+15 -9
View File
@@ -16,20 +16,20 @@ jobs:
synchronize-with-crowdin:
needs: install-dependencies
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
- 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
@@ -45,8 +45,14 @@ 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
with:
node-version: "22.x"
- name: Install yarn
run: npm install -g yarn
- name: Restore the frontend cache
uses: actions/cache@v4
with:
+10 -2
View File
@@ -17,24 +17,32 @@ on:
jobs:
front-dependencies-installation:
if: ${{ inputs.with-front-dependencies-installation == true }}
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install yarn
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
@@ -44,7 +52,7 @@ jobs:
build-mails:
if: ${{ inputs.with-build_mails == true }}
runs-on: self-hosted
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
+19 -5
View File
@@ -18,11 +18,14 @@ env:
jobs:
build-and-push-backend:
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -32,7 +35,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -41,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: .
@@ -51,11 +58,14 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend:
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -65,7 +75,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -74,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: .
@@ -89,7 +103,7 @@ jobs:
needs:
- build-and-push-frontend
- build-and-push-backend
runs-on: self-hosted
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
+3 -3
View File
@@ -9,7 +9,7 @@ on:
jobs:
helmfile-lint:
runs-on: self-hosted
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:v0.171.0
steps:
@@ -21,10 +21,10 @@ jobs:
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
HELMFILE=src/helm/helmfile.yaml.gotmpl
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
done
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: self-hosted
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+6
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
@@ -76,3 +79,6 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Docker compose override
compose.override.yml
+252 -1
View File
@@ -8,5 +8,256 @@ and this project adheres to
## [Unreleased]
### Added
[unreleased]: https://github.com/numerique-gouv/conversations/compare/HEAD...main
- ✨(back) add projects with custom LLM instructions
- ✨(front) projects management UI
## [0.0.14] - 2026-03-11
### Added
- ✨(user) allow disabling automatic internet search
- ✨(search) add searchModal and modify leftPanel
- ✨(waffle) hide the waffle if not fr theme
- ✨(front) allow pasting an attachment from clipboard
- ✨(array) temporarily adjust array
### Changed
- ⚡️(front) optimize streaming markdown rendering performance
- ⬆️(back) update pydantic-ai
- ♻️(chat) refactor AIAgentService for readability and maintainability
- 🚸(oidc) ignore case when fallback on email #281
- ⬆️(back) update pillow, django-pydantic-field, pypdf
- ♻️(front) migrate from ESLint 8 to ESLint 9 flat config
- ⬆️(back) update django and pypdf
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
- 🚑️(back) fix mime type for pptx
- 🐛(front) fix math formulas and carousel translations
- 🐛(helm) reverse liveness and readiness for backend deployment
- 🐛(front) fix dark mode styling on chat messages
- 🐛(front) fixed inverted toast for setting changes
## [0.0.13] - 2026-02-09
### Added
- 💄(front) ui fix : update ui-kit
- ✨(front) add persistent darkmode
- ✨(front) add ui kit #240
- 🧱(files) allow to use S3 storage without external access #849
- ✨(backend) add FindRagBackend #209
- ⬆️(back) update dependencies
- ✨(back) use adaptive parsing for pdf documents
### Changed
- 💄(darkmode) change color feedback button
- 🏗️(back) migrate to uv
- ♻️(front) optimize syntax highlighting bundle size
### Fixed
- 🐛(back) cast collection Ids to API expected types
## [0.0.12] - 2026-01-27
### Fixed
- ⚡️(front) performance improvements on chat input
- 💄(front) i18n and standardize pdf parsing display
### Removed
- 🔥(chat) consider PDF documents as other kind of documents #234
## [0.0.11] - 2026-01-16
### Changed
- 📦️(front) update react
- ✨(chat) generate and edit conversation title
### Fixed
- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
- ⚰️(back) remove dead code and unused files
- 🐛(back) prevent tool call timeouts
### Removed
- 🔥(chat) remove thinking part from frontend #227
## [0.0.10] - 2025-12-15
### 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
- ⚡️(asgi) use `uvicorn` to serve backend #121
### Fixed
- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
## [0.0.3] - 2025-10-21
### Fixed
- 🚑️(web-search) fix missing argument in RAG backend #116
## [0.0.2] - 2025-10-21
### Added
- ✨(front) add drag'n drop file
- ✨(activation-codes) register users also on Brevo #98
- 📈(posthog) add `sub` field to tracking #95
### Changed
- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
- ⚡️(web-search) allow to override returned chunks #107
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
## [0.0.1] - 2025-10-19
### Changed
- 🎨(front) activation page footer
- 👷(front) change size small modal
- 🎨(front) retour ui global
- 👷(front) fix button scrollDown
- 💥(front) disable input when error occurred
- 👷(front) fix scroll
- 🐛(front) fix left panel status + fix scroll
- 🐛(llm) add is_active field and persist chat preference
- ✨(frontend) add LLM selection in chat input #53
- 🎨(front) fix width chat container
- 🎨(front) fix width chat container #55
- 🐛(front) fix button search web on new conversation
- 🎨(front) improvement search input scroll
- ✨(404) fix front 404 page
- ✅(chat) add frontend feature flags #29
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- ✨(ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- ✨(ui) delete flex message
- ✅(front) add enabled/disabled conversation analysis
- 🎨(front) amelioration chat ux
- 🎨(front) global layout modification
- ✨(front) global layout UI
- ♻️(chat) rewrite backend using Pydantic AI SDK #4
- 🗃️(chat) enforce messages stored JSON format #6
- 🐛(chat) UI messages must have a unique identifier #6
- ✨(llm) allow configuration from JSON file #22
- 💥(agent) replace routing w/ tool calls #40
- 🧱(storage) upload the user documents into S3 #86
### Added
- 🎉(conversations) bootstrap backend & frontend #1
- ✨(web-search) add RAG capability to do web search #7
- ✨(chat) add document RAG on document uploaded by user #8
- ✨(backend) allow use to stop conversation streaming #14
- 🐛(agent) add the current date in the system prompt #18
- ✨(backend) add feature flags from posthog #13
- ✨(user) allow to use conversation data for analytics #23
- ✨(chat) enforce response in user language #24
- 📈(langfuse) add light instrumentation #26
- 🚑️(agent) allow Mistral w/ vLLM & tools #36
- ✨(web-search) add Brave search tool #47
- ✨(models) add mistral support & customization #51
- 🐛(web-search) add summarization to Brave results #58
- ✨(langfuse) allow user to score messages from LLM #6
- ✨(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.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
[0.0.1]: https://github.com/suitenumerique/conversations/releases/v0.0.1
+1 -9
View File
@@ -2,7 +2,7 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/conversations/blob/main/README.md) for detailed instructions on how to run Docs locally.
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/conversations/blob/main/README.md) for detailed instructions on how to run Conversations locally.
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
@@ -92,11 +92,3 @@ Make sure that all new features or fixes have corresponding tests. Run the test
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
## Contribute to BlockNote
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
+49 -26
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
@@ -144,7 +154,7 @@ RUN rm -rf /var/cache/apk/*
ARG CONVERSATIONS_STATIC_ROOT=/data/static
# Gunicorn
# Gunicorn - not used by default but configuration file is provided
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/conversations.py /usr/local/etc/gunicorn/conversations.py
@@ -158,5 +168,18 @@ COPY --from=link-collector ${CONVERSATIONS_STATIC_ROOT} ${CONVERSATIONS_STATIC_R
# Copy conversations mails
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in conversations's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
# The default command runs uvicorn ASGI server in conversations's main module
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
ENV WEB_CONCURRENCY=4
CMD [\
"uvicorn",\
"--app-dir=/app",\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"conversations.asgi:application"\
]
# To run using gunicorn WSGI server use this instead:
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "conversations.wsgi:application"]
+8 -4
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
@@ -138,7 +138,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d nginx ml-flow app-dev
@$(COMPOSE) up --force-recreate -d nginx app-dev
.PHONY: run-backend
run-frontend: ## Start only the frontend application
@@ -151,10 +151,14 @@ run:
@$(MAKE) run-frontend
.PHONY: run
create-compose-with-models: ## override the docker-compose file with models
cp -n compose.with_model.override.yml compose.override.yml
.PHONY: create-compose-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"
@@ -227,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
+58 -5
View File
@@ -16,6 +16,24 @@
</p>
**Warning:** This project is in active development and in a very early stage. Breaking changes may occur at any time.
## Yet another AI chatbot
Conversations is an open-source AI chatbot designed to be simple, secure and privacy-friendly.
Why another AI chatbot? Because we want to be able to fully control our data and the way we interact with AI.
We want to have a very friendly end-user interface and code, and we want to be able to easily customize the
chatbot to our needs.
We leverage open-source projects such as [Vercel&lsquo;s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev)
and only assemble them in a way that makes sense for us and allows us to focus on the product.
This assistant's purpose is also to be integrated into the "La Suite numérique" ecosystem of tools for public services.
Any help to improve the project is very welcome!
### Self-host
🚀 Conversations is easy to install on your own servers
@@ -30,9 +48,9 @@ In the works: Docker Compose, soon YunoHost
You can test Conversations on your browser by visiting this => TBD
### Run Docs locally
### Run Conversations locally
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
> ⚠️ The methods described below for running Conversations locally is **for testing purposes only**.
**Prerequisite**
@@ -97,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:
@@ -123,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)).
@@ -152,9 +207,7 @@ docs
### Stack
Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel&lsquo;s AI SDK](https://ai-sdk.dev/) and [OpenAI Agents SDK](https://github.com/openai/openai-agents-python). We thank the contributors of all these projects for their awesome work!
Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel&lsquo;s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work!
### Gov ❤️ open source
+2 -2
View File
@@ -6,7 +6,7 @@ Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
We appreciate your effort to make Docs more secure.
We appreciate your effort to make Conversations more secure.
## Vulnerability disclosure policy
@@ -20,4 +20,4 @@ and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.
effectively and responsibly in our open source software project.
+3 -7
View File
@@ -10,10 +10,6 @@ docker_build(
target = 'backend-production',
live_update=[
sync('../src/backend', '/app'),
run(
'pip install -r /app/requirements.txt',
trigger=['./api/requirements.txt']
)
]
)
@@ -28,9 +24,9 @@ docker_build(
]
)
k8s_resource('conversations-conversations-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('conversations-conversations-backend-createsuperuser', resource_deps=['conversations-conversations-backend-migrate'])
k8s_resource('conversations-conversations-backend', resource_deps=['conversations-conversations-backend-migrate'])
k8s_resource('conversations-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('conversations-backend-createsuperuser', resource_deps=['conversations-backend-migrate'])
k8s_resource('conversations-backend', resource_deps=['conversations-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n conversations -e dev template .'))
migration = '''
+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
@@ -0,0 +1,12 @@
name: conversations
services:
app-dev:
models:
llm:
endpoint_var: AI_BASE_URL
model_var: AI_MODEL
models:
llm:
model: ai/smollm2
+11 -18
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
@@ -84,13 +88,14 @@ services:
condition: service_started
createbuckets:
condition: service_started
ai_runner:
condition: service_started
nginx:
image: nginx:1.25
ports:
- "8083:8083"
networks:
- default
- lasuite
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -180,19 +185,7 @@ services:
condition: service_healthy
restart: true
ml-flow:
image: ghcr.io/mlflow/mlflow:v3.0.0
environment:
MLFLOW_TRACKING_URI: http://localhost:5050
MLFLOW_ARTIFACT_ROOT: /mlflow/artifacts
ports:
- "5050:5050"
volumes:
- ./data/mlflow:/mlflow/artifacts
command: mlflow server --host 0.0.0.0:5050
ai_runner:
provider:
type: model
options:
model: ai/smollm2
networks:
lasuite:
name: lasuite-network
driver: bridge
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ server {
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
proxy_pass http://app-dev:8000/api/v1.0/chats/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+2 -7
View File
@@ -6,14 +6,9 @@
flowchart TD
User -- HTTP --> Front("Frontend (NextJS SPA)")
Front -- REST API --> Back("Backend (Django)")
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
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")
```
### Architecture decision records
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

+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 -10
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 |
| DOCUMENT_IMAGE_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,13 +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_NAME | Name of the AI agent (useless) | Conversations Assistant |
| 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 |
@@ -96,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 -2
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
@@ -123,7 +122,7 @@ ingressMedia:
host: conversations.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://conversations.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-url: https://conversations.127.0.0.1.nip.io/api/v1.0/chats/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.conversations.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /conversations-media-storage/$1
+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
+34 -38
View File
@@ -1,9 +1,9 @@
# La Suite Docs System & Requirements (2025-06)
# La Suite Conversations System & Requirements (2025-06)
## 1. Quick-Reference Matrix (single VM / laptop)
| Scenario | RAM | vCPU | SSD | Notes |
| ------------------------- | ----- | ---- | ------- | ------------------------- |
|---------------------------|-------|------|---------|---------------------------|
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
@@ -14,22 +14,20 @@ 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 |
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
| 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"
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
@@ -37,16 +35,15 @@ Memory is the first bottleneck; CPU matters only when Celery or the Next.js buil
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
| Service | Typical use | Rationale / notes |
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
| PostgreSQL | **2 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
| Redis | **256 MB 2 GB** | Session storage and caching; scales with active user sessions |
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
| Django API (+ Celery) | **1 3 GB** | Production workloads with background tasks and higher concurrency |
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
| Y-Provider (y-websocket) | **200 MB 1 GB** | Scales with concurrent document editing sessions |
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
| Service | Typical use | Rationale / notes |
|---------------------------|-----------------------------|------------------------------------------------------------------------------------------|
| PostgreSQL | **2 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
| Redis | **256 MB 2 GB** | Session storage and caching; scales with active user sessions |
| Object Storage (optional) | **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
| Django API (+ Celery) | **1 3 GB** | Production workloads with background tasks and higher concurrency |
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
### Production Architecture Notes
@@ -54,14 +51,14 @@ Production deployments differ significantly from development environments. The t
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
- **Scaling**: Horizontal scaling is recommended for Django API service
### Minimal Production Setup (Core Services Only)
| 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 |
@@ -69,7 +66,7 @@ Production deployments differ significantly from development environments. The t
## 4. Recommended Software Versions
| Tool | Minimum |
| ----------------------- | ------- |
|-------------------------|---------|
| Docker Engine / Desktop | 24.0 |
| Docker Compose | v2 |
| Git | 2.40 |
@@ -84,17 +81,16 @@ Production deployments differ significantly from development environments. The t
## 5. Ports (dev defaults)
| Port | Service |
| --------- |-----------------------|
| 3000 | Next.js |
| 8071 | Django |
| 4444 | Y-Provider |
| 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
@@ -106,4 +102,4 @@ Production deployments differ significantly from development environments. The t
**Disk** SSD; add 10 GB extra for the Docker layer cache.
**MinIO** for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
**MinIO** for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
+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)
-2
View File
@@ -53,5 +53,3 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
# AI_MODEL=llama
ML_FLOW_TRACKING_URI = "http://ml-flow:5050"
+9 -1
View File
@@ -1,4 +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"
}
}
+24
View File
@@ -15,6 +15,24 @@
"matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "ignore recent markitdown versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["markitdown"],
"allowedVersions": "==0.0.2"
},
{
"groupName": "ignore recent lxml versions not supported by htmldate==1.9.3",
"matchManagers": ["pep621"],
"matchPackageNames": ["lxml"],
"allowedVersions": "<6"
},
{
"groupName": "ignore recent pylint versions not supported by pylint-django",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
@@ -26,6 +44,12 @@
"node-fetch",
"workbox-webpack-plugin"
]
},
{
"groupName": "ignore Vercel SDK >= 5.0.0",
"matchManagers": ["npm"],
"matchPackageNames": ["@ai-sdk/react", "@ai-sdk/ui-utils"],
"allowedVersions": "^1.2.0"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.13.3-alpine
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools lorem-text
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apk add --no-cache git
# Install the package
RUN pip install git+https://github.com/etalab-ia/openmockllm.git
# Expose the default port
EXPOSE 8000
# Set default command
CMD ["openmockllm", "--host", "0.0.0.0", "--port", "8000"]
+19
View File
@@ -0,0 +1,19 @@
[OpenMockLLM](https://github.com/etalab-ia/OpenMockLLM) is a FastAPI-based mock LLM API server that simulates
several Large Language Model API providers.
This is a simple docker image to run the server for testing and development purposes (E2E tests mainly).
It's a bit overkill to have a dedicated image for that, but it allows simple E2E stack with docker-compose since
our code is also run in Docker containers.
## Build and Run manually
```bash
docker build -t openmockllm .
docker run -p 8000:8000 openmockllm
```
## Next steps
- Add more chat completion behaviors (specific text streaming, function calling, etc.)
- Pin a specific OpenMockLLM version in the Dockerfile
+3 -1
View File
@@ -23,7 +23,9 @@ jobs=0
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=pylint_django,pylint.extensions.no_self_use
load-plugins=pylint_django,
pylint.extensions.no_self_use,
pylint_pydantic,
# Pickle collected data for later comparisons.
persistent=yes
+349
View File
@@ -0,0 +1,349 @@
"""Admin classes for activation codes application."""
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html, format_html_join
from django.utils.translation import gettext_lazy as _
from . import models
@admin.register(models.ActivationCode)
class ActivationCodeAdmin(admin.ModelAdmin):
"""Admin class for ActivationCode model"""
list_display = (
"code",
"usage_display",
"is_active",
"expires_at",
"created_at",
"description_short",
)
list_filter = (
"is_active",
"created_at",
"expires_at",
)
search_fields = (
"code",
"description",
)
readonly_fields = (
"id",
"current_uses",
"created_at",
"updated_at",
"usage_details",
)
fieldsets = (
(
None,
{
"fields": (
"id",
"code",
"description",
)
},
),
(
_("Configuration"),
{
"fields": (
"max_uses",
"current_uses",
"is_active",
"expires_at",
)
},
),
(
_("Usage details"),
{"fields": ("usage_details",)},
),
(
_("Timestamps"),
{
"fields": (
"created_at",
"updated_at",
)
},
),
)
actions = ["recompute_current_uses"]
def get_readonly_fields(self, request, obj=None):
"""Make `code` readonly when editing an existing ActivationCode.
When obj is None (creation form), `code` remains editable. When obj is
provided (editing), add `code` to readonly fields so it cannot be
changed after creation.
"""
# Start from the configured readonly_fields to preserve other read-only fields
ro_fields = list(self.readonly_fields)
if obj is not None:
ro_fields.append("code")
return tuple(ro_fields)
def usage_display(self, obj):
"""Display usage statistics."""
max_uses = obj.max_uses if obj.max_uses > 0 else ""
if obj.current_uses >= obj.max_uses and obj.max_uses > 0:
color = "red"
elif obj.current_uses > 0:
color = "orange"
else:
color = "green"
return format_html(
'<span style="color: {};">{} / {}</span>', color, obj.current_uses, max_uses
)
usage_display.short_description = _("Usage")
def description_short(self, obj):
"""Display truncated description."""
if obj.description:
return obj.description[:50] + "..." if len(obj.description) > 50 else obj.description
return "-"
description_short.short_description = _("Description")
def usage_details(self, obj):
"""Display detailed usage information."""
usages = obj.usages.select_related("user").all()
if not usages:
return _("No users have used this code yet")
table_head = format_html(
(
"<table style='width: 100%; border-collapse: collapse;'>"
"<tr style='background-color: #f0f0f0;'>"
"<th style='padding: 8px; text-align: left;'>{name}</th>"
"<th style='padding: 8px; text-align: left;'>{title}</th>"
"<th style='padding: 8px; text-align: left;'>{date}</th>"
"</tr>"
),
name=_("Name"),
title=_("Email"),
date=_("Date"),
)
rows = format_html_join(
"",
(
"<tr style='border-bottom: 1px solid #ddd;'>"
"<td style='padding: 8px;'>{name}</td>"
"<td style='padding: 8px;'>{email}</td>"
"<td style='padding: 8px;'>{created_at}</td>"
"</tr>"
),
(
{
"name": usage.user.full_name or "-",
"email": usage.user.email or "-",
"created_at": usage.created_at.strftime("%Y-%m-%d %H:%M"),
}
for usage in usages
),
)
return format_html("{table_head}{rows}</table>", table_head=table_head, rows=rows)
usage_details.short_description = _("Users who used this code")
@admin.action(description=_("Recompute current uses from related activations"))
def recompute_current_uses(self, request, queryset):
"""Recompute the current_uses field by counting related UserActivation objects."""
updated_count = 0
for activation_code in queryset:
actual_uses = activation_code.usages.count()
if activation_code.current_uses != actual_uses:
activation_code.current_uses = actual_uses
activation_code.save(update_fields=["current_uses", "updated_at"])
updated_count += 1
if updated_count == 0:
self.message_user(
request,
_("All selected activation codes already have correct usage counts."),
)
else:
self.message_user(
request,
_("Successfully recomputed usage counts for %(count)d activation code(s).")
% {"count": updated_count},
)
@admin.register(models.UserActivation)
class UserActivationAdmin(admin.ModelAdmin):
"""Admin class for UserActivation model"""
list_display = (
"user_display",
"user_email",
"activation_code",
"created_at",
)
list_filter = ("created_at",)
search_fields = (
"user__email",
"user__full_name",
"activation_code__code",
)
readonly_fields = (
"id",
"user",
"activation_code",
"created_at",
"updated_at",
)
fieldsets = (
(
None,
{
"fields": (
"id",
"user",
"activation_code",
)
},
),
(
_("Timestamps"),
{
"fields": (
"created_at",
"updated_at",
)
},
),
)
def user_display(self, obj):
"""Display user's full name."""
return obj.user.full_name or str(obj.user.id)
user_display.short_description = _("User")
def user_email(self, obj):
"""Display user's email."""
return obj.user.email or "-"
user_email.short_description = _("Email")
def has_add_permission(self, request):
"""Disable manual creation of user activations."""
return False
@admin.register(models.UserRegistrationRequest)
class UserRegistrationRequestAdmin(admin.ModelAdmin):
"""Admin class for UserRegistrationRequest model"""
list_display = (
"user_display",
"created_at",
"has_user_activation",
)
readonly_fields = (
"id",
"user",
"created_at",
"updated_at",
"user_activation",
)
search_fields = (
"user__email",
"user__full_name",
)
list_filter = ("created_at",)
actions = ["add_to_brevo_waiting_list", "remove_from_brevo_waiting_list"]
def user_display(self, obj):
"""Display user's full name."""
return obj.user.email or str(obj.user.pk)
user_display.short_description = _("User")
def has_user_activation(self, obj):
"""Indicate if the user has used an activation code."""
return obj.user_activation_id is not None
has_user_activation.boolean = True
has_user_activation.short_description = _("Has used activation code")
@admin.action(description=_("Add selected users to Brevo waiting list"))
def add_to_brevo_waiting_list(self, request, queryset):
"""Add selected users to Brevo waiting list."""
# pylint: disable=import-outside-toplevel
from core.brevo import add_user_to_brevo_list # noqa: PLC0415
registration_to_send = queryset.filter(
user_activation__isnull=True,
)
_total_emails = 0
for i in range(0, registration_to_send.count(), 150):
batch = registration_to_send[i : i + 150]
emails = [reg.user.email for reg in batch if reg.user.email]
if emails:
add_user_to_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
_total_emails += len(emails)
if _total_emails:
self.message_user(
request,
_("Added %(count)d user(s) to Brevo waiting list.") % {"count": _total_emails},
)
else:
self.message_user(
request,
_("No valid email address found in selected registrations."),
level="warning",
)
@admin.action(description=_("Remove selected users from Brevo waiting list"))
def remove_from_brevo_waiting_list(self, request, queryset):
"""Remove selected users from Brevo waiting list."""
# pylint: disable=import-outside-toplevel
from core.brevo import remove_user_from_brevo_list # noqa: PLC0415
registration_to_send = queryset.filter(
user_activation__isnull=False,
)
_total_emails = 0
for i in range(0, registration_to_send.count(), 150):
batch = registration_to_send[i : i + 150]
emails = [reg.user.email for reg in batch if reg.user.email]
if emails:
remove_user_from_brevo_list(emails, settings.BREVO_WAITING_LIST_ID)
_total_emails += len(emails)
if _total_emails:
self.message_user(
request,
_("Removed %(count)d user(s) from Brevo waiting list.") % {"count": _total_emails},
)
else:
self.message_user(
request,
_("No valid email address found in selected registrations."),
level="warning",
)
@@ -0,0 +1,9 @@
"""Exceptions for activation code handling."""
class InvalidCodeError(ValueError):
"""Raised when an activation code is invalid or cannot be used."""
class UserAlreadyActivatedError(ValueError):
"""Raised when a user tries to activate but is already activated."""
+29
View File
@@ -0,0 +1,29 @@
"""Factories for creating activation code and user activation instances for testing."""
from django.utils import timezone
import factory.django
from core.factories import UserFactory
from . import models
class ActivationCodeFactory(factory.django.DjangoModelFactory):
"""A factory to create activation codes for testing purposes."""
class Meta:
model = models.ActivationCode
code = factory.LazyAttribute(lambda x: models.generate_activation_code())
created_at = factory.LazyAttribute(lambda obj: timezone.now())
class UserActivationFactory(factory.django.DjangoModelFactory):
"""A factory to create user activations for testing purposes."""
class Meta:
model = models.UserActivation
user = factory.SubFactory(UserFactory)
activation_code = factory.SubFactory(ActivationCodeFactory)
@@ -0,0 +1,234 @@
# Generated by Django 5.2.7 on 2025-10-09 08:30
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import activation_codes.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ActivationCode",
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",
),
),
(
"code",
models.CharField(
default=activation_codes.models.generate_activation_code,
help_text="The activation code that users will enter",
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Code must be alphanumeric and contain no spaces or special characters",
regex="^[A-Z0-9]+$",
)
],
verbose_name="activation code",
),
),
(
"max_uses",
models.PositiveIntegerField(
default=1,
help_text="Maximum number of times this code can be used. 0 means unlimited.",
verbose_name="maximum uses",
),
),
(
"current_uses",
models.PositiveIntegerField(
default=0,
editable=False,
help_text="Number of times this code has been used",
verbose_name="current uses",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this code can still be used",
verbose_name="active",
),
),
(
"expires_at",
models.DateTimeField(
blank=True,
help_text="Date and time when this code expires",
null=True,
verbose_name="expires at",
),
),
(
"description",
models.TextField(
blank=True,
help_text="Internal description or notes about this code",
verbose_name="description",
),
),
],
options={
"verbose_name": "activation code",
"verbose_name_plural": "activation codes",
"db_table": "activation_code",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="UserActivation",
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",
),
),
(
"activation_code",
models.ForeignKey(
help_text="The activation code that was used",
on_delete=django.db.models.deletion.PROTECT,
related_name="usages",
to="activation_codes.activationcode",
verbose_name="activation code",
),
),
(
"user",
models.OneToOneField(
help_text="The user who used the activation code",
on_delete=django.db.models.deletion.CASCADE,
related_name="activation",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "user activation",
"verbose_name_plural": "user activations",
"db_table": "user_activation",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="UserRegistrationRequest",
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",
),
),
(
"user",
models.OneToOneField(
help_text="The user who made the registration request",
on_delete=django.db.models.deletion.CASCADE,
related_name="registration_request",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
(
"user_activation",
models.OneToOneField(
blank=True,
help_text="Store if the user received an activation code and used it",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="registration_request",
to="activation_codes.useractivation",
verbose_name="user activation",
),
),
],
options={
"verbose_name": "user registration request",
"verbose_name_plural": "user registration requests",
"db_table": "user_registration_request",
"ordering": ["-created_at"],
},
),
]
+226
View File
@@ -0,0 +1,226 @@
"""
Models for the activation codes application
"""
import logging
import secrets
import string
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from core.brevo import add_user_to_brevo_list, remove_user_from_brevo_list
from core.models import BaseModel, User
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
logger = logging.getLogger(__name__)
def generate_activation_code():
"""Generate a random 16-character activation code."""
alphabet = string.ascii_uppercase + string.digits
# Remove ambiguous characters
alphabet = alphabet.replace("O", "").replace("0", "").replace("I", "").replace("1", "")
return "".join(secrets.choice(alphabet) for _ in range(16))
class ActivationCode(BaseModel):
"""
Represents an activation code that can be used to activate user accounts.
"""
code = models.CharField(
verbose_name=_("activation code"),
help_text=_("The activation code that users will enter"),
max_length=50,
unique=True,
default=generate_activation_code,
validators=[
RegexValidator(
regex=r"^[A-Z0-9]+$",
message=_("Code must be alphanumeric and contain no spaces or special characters"),
)
],
)
max_uses = models.PositiveIntegerField(
verbose_name=_("maximum uses"),
help_text=_("Maximum number of times this code can be used. 0 means unlimited."),
default=1,
)
current_uses = models.PositiveIntegerField(
verbose_name=_("current uses"),
help_text=_("Number of times this code has been used"),
default=0,
editable=False,
)
is_active = models.BooleanField(
verbose_name=_("active"),
help_text=_("Whether this code can still be used"),
default=True,
)
expires_at = models.DateTimeField(
verbose_name=_("expires at"),
help_text=_("Date and time when this code expires"),
null=True,
blank=True,
)
description = models.TextField(
verbose_name=_("description"),
help_text=_("Internal description or notes about this code"),
blank=True,
)
class Meta:
db_table = "activation_code"
verbose_name = _("activation code")
verbose_name_plural = _("activation codes")
ordering = ["-created_at"]
def __str__(self):
"""Return string representation of the activation code."""
return f"{self.code} ({self.current_uses}/{self.max_uses if self.max_uses > 0 else ''})"
def is_valid(self):
"""Check if the code is still valid and can be used."""
if not self.is_active:
return False
if self.expires_at and self.expires_at < timezone.now():
return False
if self.max_uses > 0 and self.current_uses >= self.max_uses:
return False
return True
def can_be_used(self):
"""Alias for is_valid() for better readability."""
return self.is_valid()
def use(self, user):
"""
Mark this code as used by a user.
Args:
user: The User instance using this code
Returns:
UserActivation instance
Raises:
ValidationError: If the code cannot be used
"""
with transaction.atomic():
# Lock the activation code row to prevent concurrent overuse.
locked_code = ActivationCode.objects.select_for_update().get(pk=self.pk)
if not locked_code.is_valid():
raise InvalidCodeError(_("This activation code is no longer valid"))
# Create activation record; rely on DB uniqueness for concurrent duplicate attempts.
try:
activation = UserActivation.objects.create(user=user, activation_code=locked_code)
except (IntegrityError, ValidationError) as exc:
# User already has an activation in a concurrent or prior transaction.
raise UserAlreadyActivatedError(
_("You have already activated your account")
) from exc
existing_registration = bool(
UserRegistrationRequest.objects.filter(user=user).update(user_activation=activation)
)
if existing_registration:
transaction.on_commit(
lambda: remove_user_from_brevo_list(
[user.email], settings.BREVO_WAITING_LIST_ID
)
)
# Increment usage counter safely under the same lock.
locked_code.current_uses += 1
locked_code.save(update_fields=["current_uses", "updated_at"])
transaction.on_commit(
lambda: add_user_to_brevo_list([user.email], settings.BREVO_FOLLOWUP_LIST_ID)
)
if locked_code.max_uses > 0 and locked_code.current_uses >= locked_code.max_uses:
logger.warning("Activation code %s has reached its maximum uses", locked_code.code)
return activation
class UserActivation(BaseModel):
"""
Records with user used which activation code and when.
"""
user = models.OneToOneField(
User,
verbose_name=_("user"),
help_text=_("The user who used the activation code"),
on_delete=models.CASCADE,
related_name="activation",
)
activation_code = models.ForeignKey(
ActivationCode,
verbose_name=_("activation code"),
help_text=_("The activation code that was used"),
on_delete=models.PROTECT,
related_name="usages",
)
class Meta:
db_table = "user_activation"
verbose_name = _("user activation")
verbose_name_plural = _("user activations")
ordering = ["-created_at"]
def __str__(self):
"""Return string representation of the user activation."""
return f"{self.user} - {self.activation_code.code}"
class UserRegistrationRequest(BaseModel):
"""
Records of user registration requests.
"""
user = models.OneToOneField(
User,
verbose_name=_("user"),
help_text=_("The user who made the registration request"),
on_delete=models.CASCADE,
related_name="registration_request",
)
user_activation = models.OneToOneField(
UserActivation,
verbose_name=_("user activation"),
help_text=_("Store if the user received an activation code and used it"),
on_delete=models.SET_NULL,
related_name="registration_request",
null=True,
blank=True,
)
class Meta:
db_table = "user_registration_request"
verbose_name = _("user registration request")
verbose_name_plural = _("user registration requests")
ordering = ["-created_at"]
def __str__(self):
"""Return string representation of the user registration request."""
return f"Registration request by {self.user}"
@@ -0,0 +1,39 @@
"""Permission classes for activation codes."""
from django.conf import settings
from rest_framework import permissions
from . import models
class IsActivatedUser(permissions.BasePermission):
"""
Permission class that checks if user has activated their account.
This permission is only enforced if ACTIVATION_REQUIRED is True in settings.
Staff users and users without authentication requirement are always allowed.
"""
message = "activation-required" # Custom message to indicate activation is required to frontend
def has_permission(self, request, view):
"""Check if user has activated their account."""
# If activation is not required, allow access
if not settings.ACTIVATION_REQUIRED:
return True
# Staff users can always access
if request.user and request.user.is_staff:
return True
# Anonymous users are handled by other permission classes
if not request.user or not request.user.is_authenticated:
return True
# Check if user has an activation record
return models.UserActivation.objects.filter(user=request.user).exists()
def has_object_permission(self, request, view, obj):
"""Check object-level permission."""
return self.has_permission(request, view)
@@ -0,0 +1,50 @@
"""Serializers for the activation codes application."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from . import models
class ActivationCodeValidationSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for validating an activation code."""
code = serializers.CharField(
max_length=50, required=True, help_text=_("The activation code to validate")
)
def validate_code(self, value):
"""Validate that the code exists and is valid."""
# Normalize the code (remove spaces, convert to uppercase)
return value.strip().upper().replace(" ", "").replace("-", "")
class UserActivationSerializer(serializers.ModelSerializer):
"""Serializer for user activation records."""
code = serializers.CharField(source="activation_code.code", read_only=True)
activated_at = serializers.DateTimeField(source="created_at", read_only=True)
class Meta:
model = models.UserActivation
fields = ["id", "code", "activated_at"]
read_only_fields = ["id", "code", "activated_at"]
class ActivationStatusSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for activation status response."""
is_activated = serializers.BooleanField(read_only=True)
activation = UserActivationSerializer(read_only=True, allow_null=True)
requires_activation = serializers.BooleanField(read_only=True)
class UserRegistrationRequestSerializer(serializers.ModelSerializer):
"""Serializer for registering a user for activation notifications."""
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
class Meta:
model = models.UserRegistrationRequest
fields = ["user"]
@@ -0,0 +1,236 @@
"""Integration tests for activation_codes application."""
from datetime import timedelta
from django.utils import timezone
import pytest
from rest_framework import status
from core.factories import UserFactory
from activation_codes.factories import ActivationCodeFactory
from activation_codes.models import ActivationCode, UserActivation
@pytest.mark.django_db
def test_complete_activation_flow(api_client, settings):
"""Test complete user activation flow from registration to usage."""
settings.ACTIVATION_REQUIRED = True
# Create a user (simulating registration)
user = UserFactory(email="newuser@example.com", password="password123")
# Create an activation code (simulating admin creating codes)
activation_code = ActivationCode.objects.create(
code="WELCOME123456789", max_uses=10, description="Welcome batch for new users"
)
# User logs in
api_client.force_authenticate(user=user)
# Step 1: Check activation status (should not be activated)
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["is_activated"] is False
assert response.data["requires_activation"] is True
# Step 2: User enters activation code
response = api_client.post("/api/v1.0/activation/validate/", {"code": "WELCOME123456789"})
assert response.status_code == status.HTTP_201_CREATED
assert "successfully activated" in response.data["detail"]
# Step 3: Check activation status again (should now be activated)
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["is_activated"] is True
assert response.data["activation"]["code"] == "WELCOME123456789"
# Step 4: Verify in database
assert UserActivation.objects.filter(user=user).exists()
activation_code.refresh_from_db()
assert activation_code.current_uses == 1
@pytest.mark.django_db
def test_activation_not_required_flow(api_client, settings):
"""Test that when activation is not required, users can access without codes."""
settings.ACTIVATION_REQUIRED = False
user = UserFactory(email="freeuser@example.com", password="password123")
api_client.force_authenticate(user=user)
# Check status
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["requires_activation"] is False
assert response.data["is_activated"] is False # Not activated but not required
@pytest.mark.django_db
def test_multiple_users_same_code(api_client, settings):
"""Test multiple users using the same multi-use code."""
settings.ACTIVATION_REQUIRED = True
# Create a multi-use code
code = ActivationCode.objects.create(
code="TEAMCODE12345678", max_uses=3, description="Team activation code"
)
# Create 3 users
users = []
for i in range(3):
user = UserFactory(email=f"teamuser{i}@example.com", password="password123")
users.append(user)
# Each user activates
for i, user in enumerate(users):
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEAMCODE12345678"})
assert response.status_code == status.HTTP_201_CREATED
code.refresh_from_db()
assert code.current_uses == i + 1
# Code should now be exhausted
code.refresh_from_db()
assert code.is_valid() is False
# Try with a 4th user (should fail)
user4 = UserFactory(email="teamuser4@example.com", password="password123")
api_client.force_authenticate(user=user4)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEAMCODE12345678"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_code_expiration_scenario(api_client, settings):
"""Test code expiration over time."""
settings.ACTIVATION_REQUIRED = True
# Create a code that expires in 1 day
future_time = timezone.now() + timedelta(days=1)
_code = ActivationCode.objects.create(code="EXPIRES123456789", expires_at=future_time)
user = UserFactory(email="timeduser@example.com", password="password123")
api_client.force_authenticate(user=user)
# Should work now
response = api_client.post("/api/v1.0/activation/validate/", {"code": "EXPIRES123456789"})
assert response.status_code == status.HTTP_201_CREATED
@pytest.mark.django_db
def test_staff_user_bypass(api_client, settings):
"""Test that staff users bypass activation requirement."""
settings.ACTIVATION_REQUIRED = True
staff_user = UserFactory(email="staff@example.com", password="password123", is_staff=True)
api_client.force_authenticate(user=staff_user)
# Staff should be able to check status even without activation
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_user_cannot_activate_twice(api_client, settings):
"""Test that a user cannot activate their account twice."""
settings.ACTIVATION_REQUIRED = True
user = UserFactory(email="onceuser@example.com", password="password123")
_code1 = ActivationCodeFactory(code="FIRST12345678901")
_code2 = ActivationCodeFactory(code="SECOND1234567890")
api_client.force_authenticate(user=user)
# First activation
response = api_client.post("/api/v1.0/activation/validate/", {"code": "FIRST12345678901"})
assert response.status_code == status.HTTP_201_CREATED
# Try second activation
response = api_client.post("/api/v1.0/activation/validate/", {"code": "SECOND1234567890"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {"code": "account-already-activated"}
# Verify only one activation exists
assert UserActivation.objects.filter(user=user).count() == 1
@pytest.mark.django_db
def test_code_variations_normalized(api_client, settings):
"""Test that different code input formats are normalized correctly."""
settings.ACTIVATION_REQUIRED = True
code = ActivationCodeFactory(code="TESTCODE12345678")
test_cases = [
"testcode12345678", # lowercase
"TESTCODE12345678", # uppercase
"test code 1234 5678", # with spaces
"TEST-CODE-1234-5678", # with dashes
" test-code 1234-5678 ", # mixed with leading/trailing spaces
]
for i, code_variation in enumerate(test_cases):
user = UserFactory(email=f"varuser{i}@example.com", password="password123")
# Update code to allow multiple uses
code.max_uses = 0
code.save()
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": code_variation})
assert response.status_code == status.HTTP_201_CREATED, (
f"Failed for variation: {code_variation}"
)
@pytest.mark.django_db
def test_inactive_code_cannot_be_used(api_client, settings):
"""Test that inactive codes cannot be used even if valid otherwise."""
settings.ACTIVATION_REQUIRED = True
_code = ActivationCodeFactory(
code="INACTIVE123VALID",
is_active=False,
max_uses=10,
expires_at=timezone.now() + timedelta(days=30),
)
user = UserFactory(email="blockeduser@example.com", password="password123")
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "INACTIVE123VALID"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_concurrent_activations_multi_use_code(api_client, settings):
"""Test that concurrent activations don't exceed max_uses."""
settings.ACTIVATION_REQUIRED = True
code = ActivationCodeFactory(code="CONCURRENT123456", max_uses=2)
# Create 3 users
users = [
UserFactory(email=f"concurrent{i}@example.com", password="password123") for i in range(3)
]
# First two should succeed
for i in range(2):
api_client.force_authenticate(user=users[i])
response = api_client.post("/api/v1.0/activation/validate/", {"code": "CONCURRENT123456"})
assert response.status_code == status.HTTP_201_CREATED
# Third should fail
api_client.force_authenticate(user=users[2])
response = api_client.post("/api/v1.0/activation/validate/", {"code": "CONCURRENT123456"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Verify only 2 activations
code.refresh_from_db()
assert code.current_uses == 2
@@ -0,0 +1,328 @@
"""Tests for activation_codes models."""
import json
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db.models import ProtectedError
from django.utils import timezone
import pytest
import responses
from core.factories import UserFactory
from activation_codes.exceptions import InvalidCodeError, UserAlreadyActivatedError
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
from activation_codes.models import (
ActivationCode,
UserActivation,
UserRegistrationRequest,
generate_activation_code,
)
@pytest.mark.django_db
def test_generate_activation_code():
"""Test that generate_activation_code creates a valid code."""
code = generate_activation_code()
assert len(code) == 16
assert code.isupper()
assert all(c.isalnum() for c in code)
# Check that ambiguous characters are not present
assert "O" not in code
assert "0" not in code
assert "I" not in code
assert "1" not in code
@pytest.mark.django_db
def test_generate_activation_code_uniqueness():
"""Test that generated codes are unique."""
codes = [generate_activation_code() for _ in range(100)]
assert len(codes) == len(set(codes))
@pytest.mark.django_db
def test_activation_code_creation():
"""Test creating an activation code."""
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
assert activation_code.code == "TEST1234ABCD5678"
assert activation_code.max_uses == 1
assert activation_code.current_uses == 0
assert activation_code.is_active is True
assert activation_code.expires_at is None
@pytest.mark.django_db
def test_activation_code_auto_generated_code():
"""Test that activation code is auto-generated if not provided."""
code = ActivationCodeFactory()
assert len(code.code) == 16
assert code.code.isupper()
@pytest.mark.django_db
def test_activation_code_str_representation():
"""Test string representation of activation code."""
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
assert str(activation_code) == "TEST1234ABCD5678 (0/1)"
@pytest.mark.django_db
def test_activation_code_str_representation_unlimited():
"""Test string representation of unlimited activation code."""
unlimited_activation_code = ActivationCodeFactory(code="UNLIMITED123CODE", max_uses=0)
assert str(unlimited_activation_code) == "UNLIMITED123CODE (0/∞)"
@pytest.mark.django_db
def test_activation_code_is_valid_active():
"""Test that an active, non-expired code is valid."""
activation_code = ActivationCodeFactory()
assert activation_code.is_valid() is True
assert activation_code.can_be_used() is True
@pytest.mark.django_db
def test_activation_code_is_valid_inactive():
"""Test that an inactive code is not valid."""
inactive_activation_code = ActivationCodeFactory(is_active=False)
assert inactive_activation_code.is_valid() is False
assert inactive_activation_code.can_be_used() is False
@pytest.mark.django_db
def test_activation_code_is_valid_expired():
"""Test that an expired code is not valid."""
expired_activation_code = ActivationCodeFactory(
created_at=timezone.now() - timedelta(days=10),
expires_at=timezone.now() - timedelta(days=1),
)
assert expired_activation_code.is_valid() is False
assert expired_activation_code.can_be_used() is False
@pytest.mark.django_db
def test_activation_code_is_valid_max_uses_reached():
"""Test that a code with max uses reached is not valid."""
activation_code = ActivationCodeFactory(max_uses=1)
activation_code.current_uses = 1
activation_code.save()
assert activation_code.is_valid() is False
@pytest.mark.django_db
def test_activation_code_is_valid_unlimited_uses():
"""Test that unlimited code is always valid regardless of current uses."""
unlimited_activation_code = ActivationCodeFactory(max_uses=0)
unlimited_activation_code.current_uses = 100
unlimited_activation_code.save()
assert unlimited_activation_code.is_valid() is True
@pytest.mark.django_db
def test_activation_code_use_success():
"""Test successfully using an activation code."""
user = UserFactory()
activation_code = ActivationCodeFactory()
activation = activation_code.use(user)
assert isinstance(activation, UserActivation)
assert activation.user == user
assert activation.activation_code == activation_code
# Check that usage counter was incremented
activation_code.refresh_from_db()
assert activation_code.current_uses == 1
@pytest.mark.django_db
def test_activation_code_use_invalid_code():
"""Test using an invalid activation code raises error."""
inactive_activation_code = ActivationCodeFactory(is_active=False)
user = UserFactory()
with pytest.raises(InvalidCodeError):
inactive_activation_code.use(user)
@pytest.mark.django_db
def test_activation_code_use_already_activated():
"""Test using a code when user is already activated raises error."""
user = UserFactory()
activation_code = ActivationCodeFactory()
# First activation
activation_code.use(user)
# Try to activate again with a different code
another_code = ActivationCodeFactory(code="ANOTHER123456789")
with pytest.raises(UserAlreadyActivatedError):
another_code.use(user)
@pytest.mark.django_db
def test_activation_code_use_multi_use():
"""Test using a multi-use activation code."""
multi_use_activation_code = ActivationCodeFactory(max_uses=4)
users = [UserFactory(email=f"user{i}@example.com") for i in range(3)]
for i, user in enumerate(users):
activation = multi_use_activation_code.use(user)
assert activation.user == user
multi_use_activation_code.refresh_from_db()
assert multi_use_activation_code.current_uses == i + 1
# Code should still be valid
assert multi_use_activation_code.is_valid() is True
@pytest.mark.django_db
def test_activation_code_use_max_uses_exceeded():
"""Test that code cannot be used when max uses is reached."""
user = UserFactory()
activation_code = ActivationCodeFactory(max_uses=1)
# Use the code
activation_code.use(user)
# Try to use it again with another user
another_user = UserFactory(email="another@example.com")
with pytest.raises(InvalidCodeError):
activation_code.use(another_user)
@pytest.mark.django_db
def test_activation_code_expiration():
"""Test that code expires correctly."""
future_expiry = timezone.now() + timedelta(days=1)
code = ActivationCodeFactory(code="FUTURE123456789", expires_at=future_expiry)
assert code.is_valid() is True
# Manually set to past
code.expires_at = timezone.now() - timedelta(seconds=1)
code.save()
assert code.is_valid() is False
@pytest.mark.django_db
def test_user_activation_str_representation():
"""Test string representation of user activation."""
user_activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
expected = f"{user_activation.user} - TEST1234ABCD5678"
assert str(user_activation) == expected
@pytest.mark.django_db
def test_user_activation_one_to_one_relationship():
"""Test that a user can only have one activation."""
user_activation = UserActivationFactory()
# Try to create another activation for the same user
with pytest.raises(ValidationError): # should be IntegrityError
UserActivationFactory(user=user_activation.user)
@pytest.mark.django_db
def test_activation_code_protect_on_delete():
"""Test that activation code is protected from deletion when used."""
user_activation = UserActivationFactory()
# Try to delete the activation code
with pytest.raises(ProtectedError):
user_activation.activation_code.delete()
@pytest.mark.django_db
def test_user_activation_cascade_on_user_delete():
"""Test that activation is deleted when user is deleted."""
activation = UserActivationFactory()
activation_id = activation.pk
activation.user.delete()
# Activation should be deleted
assert not UserActivation.objects.filter(id=activation_id).exists()
@pytest.mark.django_db
def test_activation_code_ordering():
"""Test that activation codes are ordered by created_at descending."""
code1 = ActivationCodeFactory(code="CODE1")
code2 = ActivationCodeFactory(code="CODE2")
code3 = ActivationCodeFactory(code="CODE3")
codes = list(ActivationCode.objects.all())
assert codes == [code3, code2, code1]
@pytest.mark.django_db
def test_user_activation_ordering():
"""Test that user activations are ordered by created_at descending."""
code1 = ActivationCodeFactory(code="CODE1", max_uses=3)
code2 = ActivationCodeFactory(code="CODE2", max_uses=3)
user1 = UserFactory(email="user1@example.com")
user2 = UserFactory(email="user2@example.com")
activation1 = UserActivationFactory(user=user1, activation_code=code1)
activation2 = UserActivationFactory(user=user2, activation_code=code2)
activations = list(UserActivation.objects.all())
assert activations == [activation2, activation1]
@responses.activate
@pytest.mark.django_db(transaction=True)
def test_activation_code_use_success_notify_brevo(settings):
"""Test successfully using an activation code and notify Brevo."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
settings.BREVO_FOLLOWUP_LIST_ID = "test_followup_list_name"
brevo_remove_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/remove",
json={"message": "Contacts added successfully"},
status=201,
)
brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_add_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_followup_list_name/contacts/add",
json={"message": "Contacts added successfully"},
status=201,
)
user = UserFactory()
registration = UserRegistrationRequest.objects.create(user=user)
activation_code = ActivationCodeFactory()
activation = activation_code.use(user)
registration.refresh_from_db()
assert registration.user_activation == activation
assert len(brevo_remove_mock.calls) == 1
assert brevo_remove_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_remove_mock.calls[0].request.body) == {"emails": [user.email]}
assert len(brevo_create_contact.calls) == 1
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_create_contact.calls[0].request.body) == {
"email": user.email,
"updateEnabled": True,
}
assert len(brevo_add_mock.calls) == 1
assert brevo_add_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_add_mock.calls[0].request.body) == {"emails": [user.email]}
@@ -0,0 +1,133 @@
"""Tests for activation_codes permissions."""
from django.test import RequestFactory
import pytest
from rest_framework.views import APIView
from core.factories import UserFactory
from activation_codes.factories import UserActivationFactory
from activation_codes.permissions import IsActivatedUser
@pytest.fixture(name="request_factory")
def request_factory_fixture():
"""Fixture to provide a request factory."""
return RequestFactory()
@pytest.fixture(name="view")
def view_fixture():
"""Fixture to provide a basic view instance."""
return APIView()
@pytest.mark.django_db
def test_is_activated_user_permission_activation_not_required(request_factory, view, settings):
"""Test that permission allows access when activation is not required."""
settings.ACTIVATION_REQUIRED = False
user = UserFactory()
request = request_factory.get("/")
request.user = user
permission = IsActivatedUser()
assert permission.has_permission(request, view) is True
@pytest.mark.django_db
def test_is_activated_user_permission_staff_user(request_factory, view, settings):
"""Test that staff users always have permission."""
settings.ACTIVATION_REQUIRED = True
staff_user = UserFactory(email="staff@example.com", password="password123", is_staff=True)
request = request_factory.get("/")
request.user = staff_user
permission = IsActivatedUser()
assert permission.has_permission(request, view) is True
@pytest.mark.django_db
def test_is_activated_user_permission_anonymous_user(request_factory, view, settings):
"""Test that anonymous users are allowed (handled by other permissions)."""
settings.ACTIVATION_REQUIRED = True
request = request_factory.get("/")
request.user = None
permission = IsActivatedUser()
assert permission.has_permission(request, view) is True
@pytest.mark.django_db
def test_is_activated_user_permission_activated_user(request_factory, view, settings):
"""Test that activated users have permission."""
settings.ACTIVATION_REQUIRED = True
# Activate the user
activation = UserActivationFactory()
request = request_factory.get("/")
request.user = activation.user
permission = IsActivatedUser()
assert permission.has_permission(request, view) is True
@pytest.mark.django_db
def test_is_activated_user_permission_not_activated_user(request_factory, view, settings):
"""Test that non-activated users do not have permission."""
settings.ACTIVATION_REQUIRED = True
user = UserFactory()
request = request_factory.get("/")
request.user = user
permission = IsActivatedUser()
assert permission.has_permission(request, view) is False
@pytest.mark.django_db
def test_is_activated_user_permission_custom_message():
"""Test that permission has custom message for frontend."""
permission = IsActivatedUser()
assert permission.message == "activation-required"
@pytest.mark.django_db
def test_is_activated_user_object_permission(request_factory, view, settings):
"""Test object-level permission delegates to has_permission."""
settings.ACTIVATION_REQUIRED = True
_user = UserFactory()
# Activate the user
activation = UserActivationFactory()
request = request_factory.get("/")
request.user = activation.user
permission = IsActivatedUser()
obj = object() # Any object
# Object permission should delegate to has_permission
assert permission.has_object_permission(request, view, obj) is True
@pytest.mark.django_db
def test_is_activated_user_object_permission_not_activated(request_factory, view, settings):
"""Test object-level permission when user is not activated."""
settings.ACTIVATION_REQUIRED = True
user = UserFactory()
request = request_factory.get("/")
request.user = user
permission = IsActivatedUser()
obj = object()
assert permission.has_object_permission(request, view, obj) is False
@@ -0,0 +1,150 @@
"""Tests for activation_codes serializers."""
import pytest
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
from activation_codes.serializers import (
ActivationCodeValidationSerializer,
ActivationStatusSerializer,
UserActivationSerializer,
)
@pytest.mark.django_db
def test_activation_code_validation_serializer_valid_code():
"""Test validating a valid activation code."""
# Create a valid activation code
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
serializer = ActivationCodeValidationSerializer(data={"code": "TEST1234ABCD5678"})
assert serializer.is_valid()
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
@pytest.mark.django_db
def test_activation_code_validation_serializer_normalize_lowercase():
"""Test that code is normalized to uppercase."""
# Create a valid activation code
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
serializer = ActivationCodeValidationSerializer(data={"code": "test1234abcd5678"})
assert serializer.is_valid()
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
@pytest.mark.django_db
def test_activation_code_validation_serializer_normalize_with_spaces():
"""Test that spaces are removed from code."""
# Create a valid activation code
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
serializer = ActivationCodeValidationSerializer(data={"code": "TEST 1234 ABCD 5678"})
assert serializer.is_valid()
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
@pytest.mark.django_db
def test_activation_code_validation_serializer_normalize_with_dashes():
"""Test that dashes are removed from code."""
# Create a valid activation code
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
serializer = ActivationCodeValidationSerializer(data={"code": "TEST-1234-ABCD-5678"})
assert serializer.is_valid()
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
@pytest.mark.django_db
def test_activation_code_validation_serializer_normalize_mixed():
"""Test that code with spaces, dashes and lowercase is normalized."""
# Create a valid activation code
_activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
serializer = ActivationCodeValidationSerializer(data={"code": " test-1234 abcd-5678 "})
assert serializer.is_valid()
assert serializer.validated_data["code"] == "TEST1234ABCD5678"
@pytest.mark.django_db
def test_activation_code_validation_serializer_missing_code():
"""Test that code field is required."""
serializer = ActivationCodeValidationSerializer(data={})
assert not serializer.is_valid()
assert "code" in serializer.errors
@pytest.mark.django_db
def test_user_activation_serializer():
"""Test serializing a user activation."""
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
serializer = UserActivationSerializer(activation)
data = serializer.data
assert "id" in data
assert data["code"] == "TEST1234ABCD5678"
assert "activated_at" in data
assert data["activated_at"] is not None
@pytest.mark.django_db
def test_user_activation_serializer_read_only_fields():
"""Test that all fields are read-only."""
activation = UserActivationFactory()
serializer = UserActivationSerializer(activation)
# All fields should be in read_only_fields
meta = serializer.Meta
assert set(meta.read_only_fields) == set(meta.fields)
@pytest.mark.django_db
def test_activation_status_serializer_activated():
"""Test serializing activation status for activated user."""
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
data = {"is_activated": True, "activation": activation, "requires_activation": True}
serializer = ActivationStatusSerializer(data)
serialized_data = serializer.data
assert serialized_data["is_activated"] is True
assert serialized_data["activation"] is not None
assert serialized_data["activation"]["code"] == "TEST1234ABCD5678"
assert serialized_data["requires_activation"] is True
@pytest.mark.django_db
def test_activation_status_serializer_not_activated():
"""Test serializing activation status for non-activated user."""
data = {"is_activated": False, "activation": None, "requires_activation": True}
serializer = ActivationStatusSerializer(data)
serialized_data = serializer.data
assert serialized_data["is_activated"] is False
assert serialized_data["activation"] is None
assert serialized_data["requires_activation"] is True
@pytest.mark.django_db
def test_activation_status_serializer_activation_not_required():
"""Test serializing activation status when activation is not required."""
data = {"is_activated": False, "activation": None, "requires_activation": False}
serializer = ActivationStatusSerializer(data)
serialized_data = serializer.data
assert serialized_data["is_activated"] is False
assert serialized_data["activation"] is None
assert serialized_data["requires_activation"] is False
@pytest.mark.django_db
def test_activation_status_serializer_all_fields_read_only():
"""Test that all fields in ActivationStatusSerializer are read-only."""
serializer = ActivationStatusSerializer()
for field_name, field in serializer.fields.items():
assert field.read_only is True, f"Field {field_name} should be read-only"
@@ -0,0 +1,411 @@
"""Tests for activation_codes viewsets."""
import json
from datetime import timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
import responses
from rest_framework import status
from core.factories import UserFactory
from activation_codes.factories import ActivationCodeFactory, UserActivationFactory
from activation_codes.models import ActivationCode, UserActivation, UserRegistrationRequest
@pytest.mark.django_db
def test_activation_status_unauthenticated(api_client):
"""Test that unauthenticated users cannot access status endpoint."""
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_activation_status_authenticated_not_activated(api_client, settings):
"""Test activation status for authenticated but not activated user."""
settings.ACTIVATION_REQUIRED = True
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["is_activated"] is False
assert response.data["activation"] is None
assert response.data["requires_activation"] is True
@pytest.mark.django_db
def test_activation_status_authenticated_activated(api_client, settings):
"""Test activation status for activated user."""
settings.ACTIVATION_REQUIRED = True
activation = UserActivationFactory(activation_code__code="TEST1234ABCD5678")
api_client.force_authenticate(user=activation.user)
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["is_activated"] is True
assert response.data["activation"] is not None
assert response.data["activation"]["code"] == "TEST1234ABCD5678"
assert "activated_at" in response.data["activation"]
assert response.data["requires_activation"] is True
@pytest.mark.django_db
def test_activation_status_activation_not_required(api_client, settings):
"""Test activation status when activation is not required."""
settings.ACTIVATION_REQUIRED = False
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.get("/api/v1.0/activation/status/")
assert response.status_code == status.HTTP_200_OK
assert response.data["requires_activation"] is False
@pytest.mark.django_db
def test_validate_code_unauthenticated(api_client):
"""Test that unauthenticated users cannot validate codes."""
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_validate_code_success(api_client):
"""Test successfully validating and using an activation code."""
user = UserFactory()
activation_code = ActivationCode.objects.create(code="TEST1234ABCD5678")
api_client.force_authenticate(user=user)
with patch("activation_codes.viewsets.logger") as mock_logger:
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
assert response.status_code == status.HTTP_201_CREATED
assert "Your account has been successfully activated" in response.data["detail"]
assert "activation" in response.data
assert response.data["activation"]["code"] == "TEST1234ABCD5678"
# Verify user is now activated
assert UserActivation.objects.filter(user=user).exists()
# Verify activation code was used
activation_code.refresh_from_db()
assert activation_code.current_uses == 1
# Verify logging
mock_logger.info.assert_called_once()
@pytest.mark.django_db
def test_validate_code_with_spaces_and_lowercase(api_client):
"""Test validating code with spaces and lowercase."""
user = UserFactory()
ActivationCodeFactory(code="TEST1234ABCD5678")
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "test 1234 abcd 5678"})
assert response.status_code == status.HTTP_201_CREATED
assert UserActivation.objects.filter(user=user).exists()
@pytest.mark.django_db
def test_validate_code_already_activated(api_client):
"""Test validating code when user is already activated."""
# First activation
activation = UserActivationFactory()
api_client.force_authenticate(user=activation.user)
# Try to activate again with different code
_another_code = ActivationCodeFactory(code="ANOTHER123456789")
response = api_client.post("/api/v1.0/activation/validate/", {"code": "ANOTHER123456789"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {"code": "account-already-activated"}
@pytest.mark.django_db
def test_validate_code_nonexistent(api_client):
"""Test validating a non-existent code."""
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "NONEXISTENT12345"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {"code": "invalid-code"}
@pytest.mark.django_db
def test_validate_code_invalid_serializer(api_client):
"""Test validating with invalid data."""
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/validate/",
{}, # Missing code
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "code" in response.data
@pytest.mark.django_db
def test_validate_code_inactive(api_client):
"""Test validating an inactive code."""
user = UserFactory()
ActivationCodeFactory(code="INACTIVE12345678", is_active=False)
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "INACTIVE12345678"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {"code": "invalid-code"}
@pytest.mark.django_db
def test_validate_code_expired(api_client):
"""Test validating an expired code."""
user = UserFactory()
ActivationCodeFactory(code="EXPIRED123456789", expires_at=timezone.now() - timedelta(days=1))
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "EXPIRED123456789"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_validate_code_max_uses_reached(api_client):
"""Test validating a code that has reached max uses."""
user = UserFactory()
ActivationCodeFactory(code="MAXUSED123456789", max_uses=1, current_uses=1)
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "MAXUSED123456789"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_validate_code_multi_use(api_client):
"""Test using a multi-use code with multiple users."""
multi_use_activation_code = ActivationCodeFactory(
code="MULTIUSE12345678",
max_uses=3,
)
users = []
for i in range(3):
user = UserFactory(email=f"user{i}@example.com")
users.append(user)
for i, user in enumerate(users):
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "MULTIUSE12345678"})
assert response.status_code == status.HTTP_201_CREATED
multi_use_activation_code.refresh_from_db()
assert multi_use_activation_code.current_uses == i + 1
@pytest.mark.django_db
def test_validate_code_unlimited_use(api_client):
"""Test using an unlimited code with multiple users."""
unlimited_activation_code = ActivationCodeFactory(
code="UNLIMITED123CODE",
max_uses=0, # Unlimited uses
)
for i in range(10):
user = UserFactory(email=f"user{i}@example.com")
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "UNLIMITED123CODE"})
assert response.status_code == status.HTTP_201_CREATED
# Code should still be valid
unlimited_activation_code.refresh_from_db()
assert unlimited_activation_code.is_valid() is True
@pytest.mark.django_db
def test_validate_code_logging_on_validation_error(api_client):
"""Test that validation errors are logged."""
user = UserFactory()
api_client.force_authenticate(user=user)
# Create a code that will cause validation error
code = ActivationCodeFactory(
code="WILLEXPIRE123456", expires_at=timezone.now() + timedelta(days=1)
)
# Make it expire
code.expires_at = timezone.now() - timedelta(seconds=1)
code.save()
with patch("activation_codes.viewsets.logger"):
response = api_client.post("/api/v1.0/activation/validate/", {"code": "WILLEXPIRE123456"})
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Note: In this case the serializer will catch it first
# so the warning might not be called, but this tests the flow
@pytest.mark.django_db
def test_unauthenticated_register_email(api_client):
"""Test that unauthenticated users cannot register email."""
response = api_client.post(
"/api/v1.0/activation/register/",
{
"email": "test@example.com",
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db
def test_register_email_success(api_client):
"""Test successfully registering an email."""
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["code"] == "registration-successful"
registration = UserRegistrationRequest.objects.get(user=user)
assert registration.user == user
@pytest.mark.django_db
def test_register_already_created(api_client):
"""Test successfully registering an email."""
user = UserFactory()
_registration = UserRegistrationRequest.objects.create(
user=user,
)
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"code": "registration-successful"}
assert UserRegistrationRequest.objects.filter(user=user).count() == 1
@pytest.mark.django_db
def test_validate_code_registered_user(api_client):
"""Test validating a code for a user with a pre-existing registration."""
user = UserFactory()
_registration = UserRegistrationRequest.objects.create(
user=user,
)
activation_code = ActivationCodeFactory(code="TEST1234ABCD5678")
api_client.force_authenticate(user=user)
response = api_client.post("/api/v1.0/activation/validate/", {"code": "TEST1234ABCD5678"})
assert response.status_code == status.HTTP_201_CREATED
_registration.refresh_from_db()
assert _registration.user_activation.activation_code == activation_code
@responses.activate
@pytest.mark.django_db
def test_register_email_success_brevo(api_client, settings):
"""Test successfully registering an email and notify Brevo."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
json={"message": "Contacts added successfully"},
status=201,
)
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["code"] == "registration-successful"
registration = UserRegistrationRequest.objects.get(user=user)
assert registration.user == user
assert len(brevo_create_contact.calls) == 1
assert brevo_create_contact.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_create_contact.calls[0].request.body) == {
"email": user.email,
"updateEnabled": True,
}
assert len(brevo_mock.calls) == 1
assert brevo_mock.calls[0].request.headers["api-key"] == "test_brevo_api_key"
assert json.loads(brevo_mock.calls[0].request.body) == {"emails": [user.email]}
# Register again to test idempotency
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["code"] == "registration-successful"
assert len(brevo_mock.calls) == 1 # No new call made
@responses.activate
@pytest.mark.django_db
def test_register_email_success_brevo_fails(api_client, settings):
"""Test successfully registering an email, even if Brevo fails."""
settings.BREVO_API_KEY = "test_brevo_api_key"
settings.BREVO_WAITING_LIST_ID = "test_waiting_list_id"
_brevo_create_contact = responses.post(
"https://api.brevo.com/v3/contacts",
status=200,
)
brevo_mock = responses.post(
"https://api.brevo.com/v3/contacts/lists/test_waiting_list_id/contacts/add",
status=400,
)
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post(
"/api/v1.0/activation/register/",
{},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["code"] == "registration-successful"
registration = UserRegistrationRequest.objects.get(user=user)
assert registration.user == user
assert len(brevo_mock.calls) == 1
+153
View File
@@ -0,0 +1,153 @@
"""API ViewSets for activation codes."""
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from core.brevo import add_user_to_brevo_list
from core.permissions import IsAuthenticated
from . import models, serializers
from .exceptions import InvalidCodeError, UserAlreadyActivatedError
logger = logging.getLogger(__name__)
class ActivationViewSet(viewsets.GenericViewSet):
"""
ViewSet for handling user activation with codes.
Endpoints:
- GET /activation/status/ - Check if current user is activated
- POST /activation/validate/ - Validate and use an activation code
- POST /activation/register/ - Register an email to be notified later
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.ActivationCodeValidationSerializer
@action(detail=False, methods=["get"], url_path="status")
def status(self, request):
"""
Get the activation status of the current user.
Returns:
- is_activated: Whether the user has activated their account
- activation: Details of the activation (if exists)
- requires_activation: Whether activation is required by the system
"""
requires_activation = getattr(settings, "ACTIVATION_REQUIRED", False)
try:
activation = models.UserActivation.objects.select_related("activation_code").get(
user=request.user
)
is_activated = True
except models.UserActivation.DoesNotExist:
activation = None
is_activated = False
response_data = {
"is_activated": is_activated,
"activation": activation,
"requires_activation": requires_activation,
}
return Response(
serializers.ActivationStatusSerializer(response_data).data, status=status.HTTP_200_OK
)
@action(detail=False, methods=["post"], url_path="validate")
def validate_code(self, request):
"""
Validate an activation code and activate the user's account.
Request body:
- code: The activation code to validate
Returns:
- Success: Activation details
- Error: Validation error message
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_value = serializer.validated_data["code"]
# Get the activation code
try:
activation_code = models.ActivationCode.objects.get(code=code_value)
except models.ActivationCode.DoesNotExist:
logger.info("Activation code %s does not exist", code_value)
return Response({"code": "invalid-code"}, status=status.HTTP_400_BAD_REQUEST)
# Use the code
try:
activation = activation_code.use(request.user)
except InvalidCodeError as exc:
logger.warning(exc)
return Response({"code": "invalid-code"}, status=status.HTTP_400_BAD_REQUEST)
except UserAlreadyActivatedError as exc:
logger.info(exc)
return Response(
{"code": "account-already-activated"}, status=status.HTTP_400_BAD_REQUEST
)
logger.info("User %s activated account with code %s", request.user.id, activation_code.code)
return Response(
{
"code": "activation-successful",
"detail": _("Your account has been successfully activated"),
"activation": serializers.UserActivationSerializer(activation).data,
},
status=status.HTTP_201_CREATED,
)
@action(detail=False, methods=["post"], url_path="register")
def register_email(self, request):
"""
Register an email to be notified when activation codes are available.
Request body:
- email: The email address to register
Returns:
- Success: Confirmation message
- Error: Validation error message
"""
serializer = serializers.UserRegistrationRequestSerializer(
data={},
context={"request": request},
)
serializer.is_valid(raise_exception=True)
# Create the registration
try:
serializer.save()
except ValidationError:
# user is already registered, it's OK
return Response(
{"code": "registration-successful"},
status=status.HTTP_200_OK,
)
add_user_to_brevo_list(
[serializer.validated_data["user"].email], settings.BREVO_WAITING_LIST_ID
)
logger.info(
"Registered email %s for activation notifications",
serializer.validated_data["user"].email,
)
return Response(
{"code": "registration-successful"},
status=status.HTTP_201_CREATED,
)
+20
View File
@@ -9,8 +9,28 @@ from . import models
class ChatConversationAdmin(admin.ModelAdmin):
"""Admin class for the ChatConversation model"""
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,150 @@
"""Constants and schemas for the Albert RAG agent from Albert API codebase."""
from enum import Enum
from typing import Annotated, Any, Dict, List, Literal, Optional, Self
from pydantic import BaseModel, Field, StringConstraints, model_validator
# - app/schemas/chunks.py
class Chunk(BaseModel):
"""Model representing a chunk of text with metadata."""
object: Literal["chunk"] = "chunk"
id: int
metadata: Dict[str, Any]
content: str
class Chunks(BaseModel):
"""Model representing a list of chunks."""
object: Literal["list"] = "list"
data: List[Chunk]
# - app/schemas/usage.py
class CarbonFootprintUsageKWh(BaseModel):
"""Model representing the carbon footprint usage in kWh (kilowatt-hours)."""
min: Optional[float] = Field(default=None, description="Minimum carbon footprint in kWh.")
max: Optional[float] = Field(default=None, description="Maximum carbon footprint in kWh.")
class CarbonFootprintUsageKgCO2eq(BaseModel):
"""Model representing the carbon footprint usage in kgCO2eq (kilograms of CO2 equivalent)."""
min: Optional[float] = Field(
default=None, description="Minimum carbon footprint in kgCO2eq (global warming potential)."
)
max: Optional[float] = Field(
default=None, description="Maximum carbon footprint in kgCO2eq (global warming potential)."
)
class CarbonFootprintUsage(BaseModel):
"""Model representing the carbon footprint usage in kWh and kgCO2eq."""
kWh: CarbonFootprintUsageKWh = Field(default_factory=CarbonFootprintUsageKWh)
kgCO2eq: CarbonFootprintUsageKgCO2eq = Field(default_factory=CarbonFootprintUsageKgCO2eq)
class BaseUsage(BaseModel):
"""Base model for usage statistics in the Albert API."""
prompt_tokens: int = Field(
default=0, description="Number of prompt tokens (e.g. input tokens)."
)
completion_tokens: int = Field(
default=0, description="Number of completion tokens (e.g. output tokens)."
)
total_tokens: int = Field(
default=0, description="Total number of tokens (e.g. input and output tokens)."
)
cost: float = Field(default=0.0, description="Total cost of the request.")
carbon: CarbonFootprintUsage = Field(default_factory=CarbonFootprintUsage)
# - app/schemas/usage.py
class Detail(BaseModel):
"""Model representing a detail in the usage statistics."""
id: str
model: str
usage: BaseUsage = Field(default_factory=BaseUsage)
class Usage(BaseUsage):
"""Model representing the usage statistics for the Albert API."""
details: List[Detail] = []
class SearchMethod(str, Enum):
"""
Enum representing the search methods available (will be displayed in this order in playground).
"""
MULTIAGENT = "multiagent"
HYBRID = "hybrid"
SEMANTIC = "semantic"
LEXICAL = "lexical"
class SearchArgs(BaseModel):
"""Model representing the arguments for a search request in the Albert API."""
collections: List[Any] = Field(default=[], description="List of collections ID")
rff_k: int = Field(default=20, description="k constant in RFF algorithm")
k: int = Field(gt=0, default=4, description="Number of results to return")
method: SearchMethod = Field(default=SearchMethod.SEMANTIC)
score_threshold: Optional[float] = Field(
default=0.0,
ge=0.0,
le=1.0,
description=(
"Score of cosine similarity threshold for filtering results, "
"only available for semantic search method."
),
)
web_search: bool = Field(
default=False, description="Whether add internet search to the results."
)
web_search_k: int = Field(default=5, description="Number of results to return for web search.")
@model_validator(mode="after")
def score_threshold_filter(self) -> Self:
"""Validate the score threshold based on the search method."""
if self.score_threshold and self.method not in (
SearchMethod.SEMANTIC,
SearchMethod.MULTIAGENT,
):
raise ValueError(
"Score threshold is only available for semantic and multiagent search methods."
)
return self
class SearchRequest(SearchArgs):
"""Model representing a search request in the Albert API."""
prompt: Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1),
] = Field(description="Prompt related to the search")
class Search(BaseModel):
"""Model representing a search result in the Albert API."""
method: SearchMethod
score: float
chunk: Chunk
class Searches(BaseModel):
"""Model representing a list of search results in the Albert API."""
object: Literal["list"] = "list"
data: List[Search]
usage: Usage = Field(default_factory=Usage, description="Usage information for the request.")
+41
View File
@@ -0,0 +1,41 @@
"""Constants for RAG (Retrieval-Augmented Generation) results."""
from typing import List
from pydantic import BaseModel, Field
class RAGWebUsage(BaseModel):
"""
Model representing the usage statistics for web results in RAG (Retrieval-Augmented Generation).
"""
prompt_tokens: int = Field(default=0, description="Number of prompt tokens used.")
completion_tokens: int = Field(default=0, description="Number of completion tokens generated.")
class RAGWebResult(BaseModel):
"""Model representing a single web result in RAG (Retrieval-Augmented Generation)."""
url: str = Field(..., description="URL of the web result.")
content: str = Field(..., description="Content of the web result chunk.")
score: float = Field(
..., description="Relevance score of the web result, typically between 0 and 1."
)
class RAGWebResults(BaseModel):
"""Model representing a list of web results in RAG (Retrieval-Augmented Generation)."""
data: List[RAGWebResult]
usage: RAGWebUsage = Field(..., description="RAG usage statistics.")
def to_prompt(self) -> str:
"""Convert the web results to a prompt string."""
_format = " - From: {url}:\n content: {content}\n\n"
return (
"\n\n".join(
_format.format(url=result.url, content=result.content) for result in self.data
)
+ "\n\n"
)
@@ -0,0 +1,43 @@
"""Document Converter using MarkItDown"""
import os.path
from io import BytesIO
from markitdown import MarkItDown
class DocumentConverter:
"""Simple document converter that uses MarkItDown to convert documents to Markdown format."""
def __init__(self):
"""Initialize the DocumentConverter with MarkItDown."""
self.converter = MarkItDown()
def convert_raw( # pylint: disable=unused-argument
self,
*,
name: str,
content_type: str,
content: bytes,
) -> str:
"""
Convert a document to Markdown format.
The name, content_type, and content parameters comes from the user input
(vercel SDK Attachment, or BinaryContent).
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 bytes.
"""
return self._convert(BytesIO(content), file_extension=os.path.splitext(name)[1])
def _convert(self, document: BytesIO, file_extension: str) -> str:
"""
Convert the given document using the underlying DocumentConverter.
"""
conversion = self.converter.convert_stream(
document, file_extension=file_extension or ".txt"
)
document_markdown = conversion.text_content
return document_markdown
@@ -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
)
@@ -0,0 +1,258 @@
"""Implementation of the Albert API for RAG document search."""
import json
import logging
from io import BytesIO
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_rag_backends.base_rag_backend import BaseRagBackend
logger = logging.getLogger(__name__)
class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-attributes
"""
This class is a placeholder for the Albert API implementation.
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
It provides methods to:
- Create a collection for the search operation.
- Store parsed documents in the Albert collection.
- Perform a search operation using the Albert 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._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._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:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
response = requests.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
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
"""
response = requests.delete(
urljoin(f"{self._collections_endpoint}/", self.collection_id),
headers=self._headers,
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
async def adelete_collection(self, **kwargs) -> None:
"""
Asynchronously delete the current collection
"""
async with httpx.AsyncClient(timeout=settings.ALBERT_API_TIMEOUT) as client:
response = await client.delete(
urljoin(f"{self._collections_endpoint}/", self.collection_id),
headers=self._headers,
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
def 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.
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),
headers=self._headers,
files={
"file": (f"{name}.md", BytesIO(content.encode("utf-8")), "text/markdown"),
"collection": (None, int(self.collection_id)),
"metadata": (None, json.dumps({"document_name": name})), # undocumented API
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug(response.json())
response.raise_for_status()
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": collection_ids,
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
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,
),
)
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,
),
)
@@ -0,0 +1,210 @@
"""Implementation of the Albert API for RAG document search."""
import logging
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(ABC):
"""Base class for RAG backends."""
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.
This method should handle the logic to create or retrieve an existing collection.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def acreate_collection(self, name: str, description: Optional[str] = None) -> str:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
return await sync_to_async(self.create_collection)(name=name, description=description)
def parse_document(self, name: str, content_type: str, content: bytes):
"""
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 (bytes): The content of the document as a bytes stream.
Returns:
str: The document content in Markdown format.
"""
return self.parser.parse_document(name, content_type, content)
@abstractmethod
def store_document(self, name: str, content: str, **kwargs) -> None:
"""
Store the document content in the collection.
This method should handle the logic to send the document content to the API.
Args:
name (str): The name of the document.
content (str): The content of the document in Markdown format.
**kwargs: Additional arguments. ex: "user_sub" for access control.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def astore_document(self, name: str, content: str, **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 (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, **kwargs)
return document_content
@abstractmethod
def delete_collection(self, **kwargs) -> None:
"""
Delete the collection.
This method should handle the logic to delete the collection from the backend.
"""
raise NotImplementedError("Must be implemented in subclass.")
async def adelete_collection(self, **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):
"""Context manager for RAG backend with temporary collections."""
backend = cls()
backend.create_collection(name=name, description=description)
try:
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")
@@ -0,0 +1,208 @@
"""Implementation of the Albert API for RAG document search."""
import json
import logging
from io import BytesIO
from urllib.parse import urljoin
from django.conf import settings
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.models import ChatConversation
logger = logging.getLogger(__name__)
class AlbertRagDocumentSearch:
"""
This class is a placeholder for the Albert API implementation.
It is designed to be used with the RAG (Retrieval-Augmented Generation) document search system.
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, conversation: ChatConversation):
# Initialize any necessary parameters or configurations here
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.conversation = conversation
@property
def _albert_collection_id(self):
"""
Generate the collection name based on the conversation ID.
This is used to create or retrieve a collection for the search operation.
"""
return f"conversation-{self.conversation.pk}"
@property
def collection_id(self) -> int:
"""
Get the collection ID for the current conversation.
Might be created later by self._create_collection() if it does not exist.
"""
return int(self.conversation.collection_id) if self.conversation.collection_id else None
def _create_collection(self) -> bool:
"""
Create a temporary collection for the search operation.
This method should handle the logic to create or retrieve an existing collection.
"""
response = requests.post(
self._collections_endpoint,
headers=self._headers,
json={
"name": self._albert_collection_id,
"description": "Temporary collection for RAG document search",
"visibility": "private",
},
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
self.conversation.collection_id = str(response.json()["id"])
return True
def _parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
"""
Parse the PDF document content and return the text content.
This method should handle the logic to convert the PDF into
a format suitable for the Albert API.
"""
response = requests.post(
self._pdf_parser_endpoint,
headers=self._headers,
files={
"file": (
name,
content,
content_type,
), # Use the name as the filename in the request
"output_format": (None, "markdown"), # Specify the output format as Markdown,
},
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
)
response.raise_for_status()
return "\n\n".join(
document_page["content"] for document_page in response.json().get("data", [])
)
def parse_document(self, name: str, content_type: str, content: BytesIO):
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
into a format suitable for the Albert API.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (BytesIO): The content of the document as a BytesIO stream.
Returns:
str: The document content in Markdown format.
"""
# Implement the parsing logic here
if content_type == "application/pdf":
# Handle PDF parsing
markdown_content = self._parse_pdf_document(
name=name, content_type=content_type, content=content
)
else:
markdown_content = DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
return markdown_content
def _store_document(self, name: str, content: str):
"""
Store the document content in the Albert collection.
This method should handle the logic to send the document content to the Albert API.
Args:
content (str): The content of the document in Markdown format.
"""
if not self.collection_id and not self._create_collection():
raise RuntimeError("Failed to create or retrieve the collection.")
response = requests.post(
urljoin(self._base_url, self._documents_endpoint),
headers=self._headers,
files={
"file": (f"{name}.md", BytesIO(content.encode("utf-8")), "text/markdown"),
"collection": (None, int(self.collection_id)),
"metadata": (None, json.dumps({"document_name": name})), # undocumented API
},
timeout=settings.ALBERT_API_TIMEOUT,
)
logger.debug(response.json())
response.raise_for_status()
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO):
"""
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.
"""
document_content = self.parse_document(name, content_type, content)
self._store_document(name, document_content)
return document_content
def search(self, query, results_count: int = 4) -> RAGWebResults:
"""
Perform a search using the Albert API based on the provided query.
:param query: The search query string.
:param results_count: The number of results to return.
:return: Search results from the Albert API.
"""
response = requests.post(
urljoin(self._base_url, self._search_endpoint),
headers=self._headers,
json={
"collections": [self.collection_id],
"prompt": query,
"score_threshold": 0.6,
"k": results_count, # Number of chunks to return from the search
},
timeout=settings.ALBERT_API_TIMEOUT,
)
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,
),
)
@@ -0,0 +1,113 @@
"""
Albert API web search manager.
Instead of developing a full web search flow, we simply use the Albert API /v1/search endpoint.
See: https://albert.api.etalab.gouv.fr/documentation#tag/Search
Under the hood, on Albert side:
- It create a temporary collection
- It performs a web search using the query
- It returns the results as a list of URls
- It loads and parses the content of each URL (vectorization)
and stores the results in the temporary collection
- It makes a semanctic search on the temporary collection using the query
- It returns the results as a list of chunks with metadata
- It deletes the temporary collection
"""
import logging
from urllib.parse import urljoin
from django.conf import settings
import requests
from ..albert_api_constants import Searches, SearchRequest
from ..constants import RAGWebResult, RAGWebResults, RAGWebUsage
from .base import BaseWebSearchManager
logger = logging.getLogger(__name__)
class AlbertWebSearchManager(BaseWebSearchManager):
"""
A class to manage web search operations using the Albert API.
"""
def __init__(self):
"""Initializes with the base URL and endpoints for the Albert API."""
self._base_url = settings.ALBERT_API_URL
self._headers = {
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
"Content-Type": "application/json",
}
self._search_endpoint = urljoin(self._base_url, "/v1/search")
@staticmethod
def _clean_url(url: str) -> str:
"""
Clean the URL by removing the trailing '.html'.
We want it to fail when Albert fixes the bug that adds '.html' to the end of URLs.
Note: this is a bad workaround because when fixed it may break existing URLs.
Args:
url (str): The URL to clean.
Returns:
str: The cleaned URL.
"""
return url.rsplit(".html", 1)[0]
def web_search(self, query: str) -> RAGWebResults:
"""
Perform a web search using the Albert API.
Args:
query (str): The search query.
Returns:
Searches: A Searches object containing the search results.
Raises:
ValueError: If the query is empty.
requests.HTTPError: If the request to the Albert API fails.
requests.exceptions.JSONDecodeError: If the response body does not
contain valid json
"""
if not query.strip():
raise ValueError("Search query cannot be empty.")
search_request = SearchRequest(
prompt=query,
web_search=True, # Enable web search
web_search_k=settings.RAG_WEB_SEARCH_MAX_RESULTS, # Number of web search results
k=settings.RAG_WEB_SEARCH_CHUNK_NUMBER, # Number of chunks to return from the search
)
logger.debug("Albert API search request: %s", search_request.model_dump())
response = requests.post(
self._search_endpoint,
headers=self._headers,
json=search_request.model_dump(mode="json", exclude_unset=True),
timeout=settings.ALBERT_API_TIMEOUT,
)
response.raise_for_status()
searches = Searches(**response.json())
return RAGWebResults(
data=[
RAGWebResult(
url=self._clean_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, # pylint: disable=no-member
completion_tokens=searches.usage.completion_tokens, # pylint: disable=no-member
),
)
@@ -0,0 +1,24 @@
"""Base class for web search managers."""
from ..constants import RAGWebResults
class BaseWebSearchManager:
"""
A class to manage web search operations.
This is an abstract base class that should be implemented
for specific web search managers.
"""
def web_search(self, query: str) -> RAGWebResults:
"""
Perform a web search.
Args:
query (str): The search query.
Returns:
RAGWebResults: A Searches object containing the search results.
"""
raise NotImplementedError()
@@ -0,0 +1,68 @@
"""Mocked web search manager for testing purposes."""
# pylint: disable=line-too-long
import logging
from ..constants import RAGWebResult, RAGWebResults, RAGWebUsage
logger = logging.getLogger(__name__)
result_1 = {
"url": "https://www.lemonde.fr/sciences/article/2025/06/25/le-telescope-james-webb-decouvre-sa-premiere-exoplanete-identifiee-comme-une-petite-planete-froide_6615888_1650684.html",
"content": "+ [La beauté créatrice](https://la-beaute-creatrice.lemonde.fr/ \"La beauté créatrice\")\n\t+ [Perspectives](https://www.lemonde.fr/perspectives/ \"Perspectives\") \n\t+ [Gestion des cookies](#)\n* [Recherche](https://www.lemonde.fr/recherche/)\n\n Cet article vous est offert Pour lire gratuitement cet article réservé aux abonnés, connectez\\-vous [Se connecter](https://secure.lemonde.fr/sfuser/connexion?gift=true) Vous n'êtes pas inscrit sur Le Monde ? \n [Inscrivez\\-vous gratuitement](https://secure.lemonde.fr/sfuser/register?gift=true) * [Sciences Sciences](https://www.lemonde.fr/sciences/)\n* [Astronomie Astronomie](https://www.lemonde.fr/cosmos/)\n\n \n\nLe télescope James\\-Webb dcouvre sa première exoplanète, identifiée comme une petite planète froide\n====================================================================================================\n\n Lobservation a été faite grâce à une méthode prometteuse pour détecter des planètes dune taille similaire à celles du système solaire. Le Monde avec AFP \n\n Publié le 25 juin 2025 à 17h49, modifié le 26 juin 2025 à 03h24 Temps de Lecture 2 min. \n\n Lire plus tard Partager * Partager sur Messenger\n* Partager sur Facebook\n* Envoyer par e\\-mail\n* Partager sur Linkedin\n* Copier le lien\n\n <img src='https://img.lemde.fr/2025/06/25/0/0/866/867/664/0/75/0/1173df3_upload-1-g41rmpa9cg0l-902429.jpg' alt='Une image du disque protoplanétaire autour de l’étoile TWA 7, enregistrée à laide de linstrument Sphere du télescope basé au Chili, est présentée le 25\xa0juin 2025 avec une image superposée capturée par linstrument MIRI du télescope spatial James-Webb.' title='' width='664' height='443' /> ![Une image du disque protoplanétaire autour de l’étoile TWA 7, enregistrée à laide de linstrument Sphere du télescope basé au Chili, est présentée le 25\xa0juin 2025 avec une image superposée capturée par linstrument MIRI du télescope spatial James-Webb.](https://img.lemde.fr/2025/06/25/0/0/866/867/664/0/75/0/1173df3_upload-1-g41rmpa9cg0l-902429.jpg) \n\nUne image du disque protoplanétaire autour de l’étoile TWA 7, enregistrée à laide de linstrument Sphere du télescope basé au Chili, est présentée le 25\xa0juin 2025 avec une image superposée capturée par linstrument MIRI du télescope spatial James\\-Webb. WST/ESO/REUTERS \n\n Le télescope spatial James\\-Webb (JWST) a découvert sa première exoplanète dans lunivers proche. Une observation faite grâce à une méthode prometteuse pour détecter des planètes dune taille similaire à celles du système solaire.\n\n Depuis 2022, à 1,5\xa0million de kilomètres de la Terre, le JWST a aidé à caractériser plusieurs exoplanètes. *«\xa0Il a passé énormément de temps à observer des planètes qui nont jamais été imagées\xa0»*, explique lastrophysicienne Anne\\-Marie Lagrange, première autrice de l’étude sur le sujet, parue dans [*Nature*](https://www.nature.com/articles/s41550-024-02401-w?utm_source=chatgpt.com \"Nouvelle fenêtre\") mercredi 25\xa0juin.\n\n Lexercice est compliqué du fait que les exoplanètes *«\xa0sont très peu lumineuses parce quelles ne sont pas chaudes\xa0»*, mais aussi et surtout du fait qu\xa0on est aveuglé par la lumière de l’étoile autour de laquelle elles tournent\xa0»*, ajoute cette chercheuse du CNRS au Laboratoire dinstrumentation et de recherche en astrophysique de lObservatoire de Paris.\n\n La parade du James\\-Webb repose sur son coronographe, un instrument qui sinspire du phénomène de l’éclipse solaire en masquant l’étoile pour mieux révéler ce qui lentoure, et sur son spectrographe MIRI, capable dimager les astres les plus discrets grâce à une vision infrarouge. Ses utilisateurs ont pointé le télescope vers l’étoile TWA 7, située dans notre galaxie à une centaine dannées\\-lumière de la Terre, autrement dit sa très petite banlieue. La cible, initialement détectée par le télescope Hubble, était prometteuse à double titre.",
"score": 0.51039016,
}
result_2 = {
"url": "https://www.lemonde.fr/sciences/article/2025/06/25/le-telescope-james-webb-decouvre-sa-premiere-exoplanete-identifiee-comme-une-petite-planete-froide_6615888_1650684.html",
"content": "Le télescope James\\-Webb découvre sa première exoplanète, identifiée comme une petite planète froide",
"score": 0.49020067,
}
result_3 = {
"url": "https://www.lemonde.fr/sciences/article/2025/06/25/le-telescope-james-webb-decouvre-sa-premiere-exoplanete-identifiee-comme-une-petite-planete-froide_6615888_1650684.html",
"content": "---------------\n\n Dabord, parce quelle est jeune de seulement 6,4\xa0millions dannées et donc très susceptible de voir se former des corps planétaires dans le disque de matière la ceinturant. Ensuite, parce que le télescope voit ce disque protoplanétaire par le dessus. Son observation avec linstrument Sphere du Très Grand Télescope (VLT), situé au Chili, avait permis dy distinguer trois anneaux s’étageant sur une distance allant jusqu’à plus de cent fois celle séparant la Terre du Soleil.\n\n Et cest dans la partie dégarnie du deuxième anneau que linstrument du James\\-Webb a détecté une *«\xa0source\xa0»* lumineuse, baptisée TWA 7b. Ayant exclu que la découverte savère être un objet du système solaire ou une galaxie lointaine, les astronomes lont identifiée comme une petite planète froide, dune masse dix fois inférieure à celles imagées jusquici avec dautres instruments. Ils estiment sa masse comparable à celle de Saturne, une planète gazeuse qui ne *«\xa0pèse\xa0»* que le tiers de Jupiter, géante gazeuse et poids lourd de notre système solaire.\n\n Avec le James\\-Webb, *«\xa0on est tombé dun facteur dix en capacité de détection\xa0»*, explique Anne\\-Marie Lagrange, car les planètes les plus *«\xa0légères\xa0»* imagées jusquici depuis le sol pesaient à peu près trois fois la masse de Jupiter. *«\xa0La plupart des autres exoplanètes imagées sont ce quon appelle des super\\-Jupiter\xa0»*, ayant de huit à douze fois la masse de cette dernière.\n\n Lire aussi \\| Article réservé à nos abonnés [Potentielle présence de vie sur lexoplanète K2\\-18b\xa0: anatomie dun faux positif](https://www.lemonde.fr/sciences/article/2025/05/13/potentielle-presence-de-vie-sur-l-exoplanete-k2-18b-anatomie-d-un-faux-positif_6605670_1650684.html) Lire plus tard \n\nUn télescope prometteur attendu pour 2028",
"score": 0.48312354,
}
result_4 = {
"url": "https://www.lemonde.fr/sciences/article/2025/06/25/le-telescope-james-webb-decouvre-sa-premiere-exoplanete-identifiee-comme-une-petite-planete-froide_6615888_1650684.html",
"content": "-----------------------------------------\n\n La performance a dautant plus dintérêt que dans le bestiaire planétaire, les planètes rocheuses comme la Terre ou Mars ont des masses beaucoup plus faibles que les planètes gazeuses. Or ces exoplanètes rocheuses constituent une cible ultime des découvreurs de mondes potentiellement habitables.\n\n Le Monde Guides dachat [Aspirateurs robots Les meilleurs aspirateurs robots Lire](https://www.lemonde.fr/guides-d-achat/article/2021/10/25/les-meilleurs-aspirateurs-robots_6099813_5306571.html) Anne\\-Marie Lagrange souhaiterait désormais *«\xa0découvrir les planètes les plus légères et peut\\-être de trouver des Terres\xa0»*. Avant dajouter aussitt que si *«\xa0on veut comprendre comment les systèmes planétaires se forment, il ne suffit pas de voir les planètes très ou pas massives\xa0»*. Car il faut pouvoir détecter tous les types de planètes, afin de déterminer in fine si notre système solaire est unique ou pas.\n\n Les astronomes estiment que le JWST a le potentiel de détecter et dimager des planètes ayant une masse encore plus faible que TWA 7b. Mais il faudra de futurs instruments, comme le Télescope extrêmement large (ELT) attendu pour 2028, pour espérer saisir limage de mondes dune taille similaire au nôtre.\n\n Lire aussi \\| Article réservé à nos abonnés [La traque des axions, nouvelle coqueluche des physiciens](https://www.lemonde.fr/sciences/article/2025/06/09/la-traque-des-axions-nouvelle-coqueluche-des-physiciens_6611759_1650684.html) Lire plus tard Le Monde avec AFP \n\n Lespace des contributions est réservé aux abonnés. Abonnez\\-vous pour accéder à cet espace d’échange et contribuer à la discussion. [Sabonner](https://abo.lemonde.fr/?lmd_medium=BOUTONS_LMFR&lmd_campaign=CONTRIBUTION_ARTICLE) Contribuer\n\n [Réutiliser ce contenu](https://www.lemonde.fr/syndication/ \"Réutiliser ce contenu\") Édition du jour\n\n Daté du jeudi 31 juillet\n\n <img src='data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 276 201'%3E%3C/svg%3E' alt='' title='' width='276' height='201' /> [Lire le journal numérique](https://journal.lemonde.fr/) [<img src='' alt='' title='' width='' height='36' /> ![]() Culture générale Des leçons interactives par la rédaction pour tester vos connaissances. Découvrir](https://www.lemonde.fr/memorable/quiz-et-questions-de-culture-generale) [<img src='' alt='' title='' width='300' height='280' />](https://www.lemonde.fr/chaleur-humaine/article/2025/01/30/mesurez-votre-impact-environnement-avec-le-calculateur-d-empreinte-carbone-et-eau_6523433_6125299.html?lmd_medium=bizdev&lmd_campaign=services_partenaire_lmfr&lmd_creation=ademe)",
"score": 0.42882082,
}
result_5 = {
"url": "https://www.franceinfo.fr/economie/budget/",
"content": "Budget 2025 de la France \\- actualité et info en direct\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\ncontenu principal\n\n\n\n\n\n[<img src='https://www.franceinfo.fr/assets/components/headers/header/img/franceinfo-1d7b76a5.svg' alt='' title='' width='161' height='40' />\n\n<img src='data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 161 40\"%3E%3C/svg%3E' alt='' title='' width='161' height='40' />\nAccueil France Info](/ \"Accueil France Info\")\n\n* [Rechercher une actualité\nRecherche](/recherche/)\n* [Direct TV\nTV](/en-direct/tv.html)\n* [Direct radio\nRadio](/en-direct/radio.html)\n* [Le live\nLive](/en-direct/)\n* Services\n\n\n\n\n\t+ [Newsletters\n\t (Nouvelle fenêtre)](https://www.francetelevisions.fr/abonnements/information)\n\t+ [Météo\n\t (Nouvelle fenêtre)](https://meteo.franceinfo.fr/)\n\t+ [Jeux\n\t (Nouvelle fenêtre)](https://jeux.franceinfo.fr)\n\t+ [Scolarité](/societe/education/scolarite/)\n* Mon\xa0espace\n\n\n\n\n\t+ [S'inscrire](javascript:void(0))\n\t+ [Se connecter](javascript:void(0))\n\t+ [À lire plus tard](/mon-compte/lire-plus-tard)\n\t+ [Mes commentaires](/mon-compte/interactions)\n\t+ [Mes newsletters](/mon-compte/newsletters)\n\t+ [Mes informations](javascript:void(0))\n\t+ [Se déconnecter](javascript:void(0))\n\n\n\n\n\n\n* [Accueil](/)\n* Menu\n* [Grands formats](/grands-formats/)\n* [Enquêtes](/enquetes-franceinfo/)\n* [Vrai ou faux](/vrai-ou-fake/)\n* [Droits de douane](/monde/usa/droits-de-douane/)\n* [Guerre dans la bande de Gaza](/monde/proche-orient/israel-palestine/)\n* [Loi Duplomb](/environnement/loi-duplomb/)\n\n\n\n\n\n\n\n<img src='https://www.franceinfo.fr/assets/components/headers/header/img/franceinfo-1d7b76a5.svg' alt='France Info' title='' width='129' height='32' />\n\n<img src='data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 129 32\"%3E%3C/svg%3E' alt='France Info' title='' width='129' height='32' />\n\n\n\n\n\n\n\n\n\n\n\nRechercher sur franceinfo\n\n\n\n\n\n\nAnnuler la saisie\n\n\n\n\n* [Accueil](/)\n\n\n\n\n En ce moment\n \n\n* [Droits de douane](/monde/usa/droits-de-douane/)\n* [Guerre dans la bande de Gaza](/monde/proche-orient/israel-palestine/)\n* [Loi Duplomb](/environnement/loi-duplomb/)\n\n\n\n\n* [Grands formats](/grands-formats/)\n* [Enquêtes](/enquetes-franceinfo/)\n* [Vrai ou faux](/vrai-ou-fake/)\n\n\n\n\n Rubriques\n \n\n* [L'actu pour les jeunes](/l-actu-pour-les-jeunes/)\n* [Une info transparente](/une-information-transparente-franceinfo/)\n* Monde\n* Europe\n* Faits\\-divers\n* Politique\n* Société\n* Environnement\n* Sport\n* Culture\n* Éco / Conso\n* Santé\n* Sciences \\& technologies\n* Météo\nMétéo\n* Jeux\nJeux\n\n\n\n* Services\n\n\n\nRecevez l'essentiel de l'actualité et restez à jour avec nos newsletters\n\n\n[découvrir nos newsletters\n(Nouvelle fenêtre)](https://www.francetelevisions.fr/abonnements/information)\n\n\n\n Continuez votre exploration\n \n\n* [France 3 régions (nouvelle fenêtre)\nFrance 3 régions](https://france3-regions.franceinfo.fr/)\n* [Outremer la 1ère (nouvelle fenêtre)\nOutremer la 1ère](https://la1ere.franceinfo.fr/)\n* france TV\nfrance TV\n* radio france\nradio france\n\n\n\n\n\n\nServices\n\nServices\n* [Newsletters (nouvelle fenêtre)\nNewsletters](https://www.francetelevisions.fr/abonnements/information)\n* [Météo (nouvelle fenêtre)\nMétéo](https://meteo.franceinfo.fr/)\n* [Jeux (nouvelle fenêtre)\nJeux](https://jeux.franceinfo.fr)\n* [Scolarité](/societe/education/scolarite/)\n\n\n\n\n\n\nMonde\n\nMonde\n\n[voir toute l'actu Monde](/monde/)\n\n\n\n En ce moment\n \n\n* [Droits de douane](/monde/usa/droits-de-douane/)\n* [Guerre dans la bande de Gaza](/monde/proche-orient/israel-palestine/)\n* [Guerre en Ukraine](/monde/europe/manifestations-en-ukraine/)\n* [Affaire Jeffrey Epstein](/monde/usa/affaire-jeffrey-epstein/)\n* [Guerre au Proche\\-Orient](/monde/proche-orient/guerre/)\n* [Donald Trump \\- Président des États\\-Unis](/monde/usa/presidentielle/donald-trump/)\n* [Yémen](/monde/proche-orient/yemen/)\n* [Voir plus de sujets monde](/monde/)\n\n\n\n\n Rubriques",
"score": 0.6129482,
}
class MockedWebSearchManager:
"""
A class to manage web search operations.
This is an abstract base class that should be implemented
for specific web search managers.
"""
def web_search(self, query: str) -> RAGWebResults:
"""
Fake a web search.
Args:
query (str): The search query.
Returns:
RAGWebResults: A Searches object containing the search results.
"""
logger.info("Mocked web search called with query: %s", query)
return RAGWebResults(
data=[
RAGWebResult(**result_1),
RAGWebResult(**result_2),
RAGWebResult(**result_3),
RAGWebResult(**result_4),
RAGWebResult(**result_5),
],
usage=RAGWebUsage(
prompt_tokens=0, # Assuming no prompt tokens are provided in this mock
completion_tokens=0, # Assuming no completion tokens are provided in this mock
), # Assuming no usage data is provided in this mock
)
View File
+190
View File
@@ -0,0 +1,190 @@
"""Base module for PydanticAI agents."""
import dataclasses
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import httpx
from pydantic_ai import Agent
from pydantic_ai.models import get_user_agent
from pydantic_ai.profiles import ModelProfile
from chat.tools import get_pydantic_tools_by_name
logger = logging.getLogger(__name__)
def prepare_custom_model(configuration: "chat.llm_configuration.LLModel"):
"""
Prepare a custom model instance based on the provided configuration.
Only few providers are supported at the moment, according to our needs.
We define custom models/providers to be able to keep specific configuration
when needed.
"""
# pylint: disable=import-outside-toplevel
match configuration.provider.kind:
case "mistral":
import pydantic_ai.models.mistral as mistral_models # noqa: PLC0415
from mistralai import TextChunk as MistralTextChunk # noqa: PLC0415
from mistralai import ThinkChunk as MistralThinkChunk # noqa: PLC0415
from mistralai.types.basemodel import Unset as MistralUnset # noqa: PLC0415
from pydantic_ai.providers.mistral import MistralProvider # noqa: PLC0415
# --- Monkey patch for pydantic_ai.models.mistral._map_content ---
# pylint: disable=protected-access
# ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠
# | This workaround is fragile and only works because we are in streaming mode. |
# ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠ WARNING ⚠
# The original _map_content raises exceptions for some when responses
# contains citation/reference data, which is the case anytime we use
# web search or other RAG tool (https://docs.mistral.ai/capabilities/citations/).
# We make the patch idempotent using a sentinel attribute so repeated calls
# to prepare_custom_model do not re-wrap and do not cause recursive calls.
if not getattr(mistral_models, "__safe_map_patched__", False):
_original_map_content = mistral_models._map_content # noqa: SLF001
def _safe_map_content(content):
"""
A safe version of _map_content that ignores unsupported data types.
WARNING: this is a monkey patch and may break if the original
function changes in future versions of pydantic_ai.
Current version: pydantic_ai v1.0.18
"""
text: str | None = None
thinking: list[str] = []
if isinstance(content, MistralUnset) or not content:
return None, []
if isinstance(content, list):
for chunk in content:
if isinstance(chunk, MistralTextChunk):
text = (text or "") + chunk.text
elif isinstance(chunk, MistralThinkChunk):
for thought in chunk.thinking:
if thought.type == "text": # pragma: no branch
thinking.append(thought.text)
else:
logger.info( # pragma: no cover
"Other data types like (Image, Reference) are not yet "
"supported, got %s",
type(chunk),
)
elif isinstance(content, str):
text = content
# Note: Check len to handle potential mismatch between function calls and
# responses from the API.
# (`msg: not the same number of function class and responses`)
if text == "": # pragma: no cover
text = None
return text, thinking
# Replace the original module-level function
mistral_models._map_content = _safe_map_content # noqa: SLF001
mistral_models.__safe_map_patched__ = True
# pylint: enable=protected-access
# --- End monkey patch ---
return mistral_models.MistralModel(
model_name=configuration.model_name,
profile=(
ModelProfile(**configuration.profile.dict(exclude_unset=True))
if configuration.profile
else None
),
provider=MistralProvider(
api_key=configuration.provider.api_key,
base_url=configuration.provider.base_url,
# Disable the use of cached client
http_client=httpx.AsyncClient(
timeout=httpx.Timeout(timeout=600, connect=5),
headers={"User-Agent": get_user_agent()},
),
),
)
case "openai":
from pydantic_ai.models.openai import OpenAIChatModel # noqa: PLC0415
from pydantic_ai.profiles.openai import OpenAIModelProfile # noqa: PLC0415
from pydantic_ai.providers.openai import OpenAIProvider # noqa: PLC0415
if configuration.profile and (
_config_profile := configuration.profile.dict(exclude_unset=True)
):
# set some defaults if not provided, see openai_model_profile which
# defines them for known models
_model_profile_params = {
"supports_json_schema_output": True,
"supports_json_object_output": True,
}
_model_profile_params.update(_config_profile)
profile = OpenAIModelProfile(**_model_profile_params)
else:
profile = None
return OpenAIChatModel(
model_name=configuration.model_name,
profile=profile,
provider=OpenAIProvider(
base_url=configuration.provider.base_url,
api_key=configuration.provider.api_key,
),
)
case _:
raise ImproperlyConfigured(
f"Unsupported provider kind '{configuration.provider.kind}' for custom model."
)
@dataclasses.dataclass(init=False)
class BaseAgent(Agent):
"""
Base class for PydanticAI agents.
This class initializes the agent with model from configuration.
"""
def __init__(self, *, model_hrid, **kwargs):
"""Initialize the agent with model configuration from settings."""
_ignored_kwargs = {"model", "system_prompt", "tools", "toolsets"}
if set(kwargs).intersection(_ignored_kwargs):
raise ValueError(f"{_ignored_kwargs} arguments must not be provided.")
try:
self.configuration = settings.LLM_CONFIGURATIONS[model_hrid]
except KeyError as exc:
raise ImproperlyConfigured(
f"LLM model configuration '{model_hrid}' not found."
) from exc
if self.configuration.is_custom:
_model_instance = prepare_custom_model(self.configuration)
else:
# In this case, we rely on PydanticAI's built-in model registry
# and configuration: check pydantic_ai.models.KnownModelName
# and pydantic_ai.models.infer_model()
_model_instance = self.configuration.model_name
_system_prompt = self.get_system_prompt()
_tools = self.get_tools()
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
def get_system_prompt(self) -> str | None:
"""Override this method to customize the system prompt."""
return self.configuration.system_prompt
def get_tools(self) -> list | None:
"""Override this method to customize tools."""
if not self.configuration.tools:
return []
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
+155
View File
@@ -0,0 +1,155 @@
"""Build the main conversation agent."""
import asyncio
import dataclasses
import logging
from django.conf import settings
from django.utils import formats, timezone
from pydantic_ai import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.enums import get_language_name
from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
In Paris streets where old meets new, 🗼🇫🇷
A helper bright in digital hue,
With circuits fast and code so tight,
The LaSuites bot—oh, what a sight! 🌟
**A chatbot kind**, with wittiness so grand, 💬💡
It lends a hand to all the land,
From civil servants, bold and wise,
To those who seek with hopeful eyes.
It answers quick, it never tires, ⚡🔄
With facts and tips to quench desires,
A guide so keen, a friend so true,
Its there for **you**—yes, me and you!
With **Markdown flair** and emoji cheer, 📝🎨
It makes the complex crystal clear,
From drafts to code, from sums to prose,
It helps the knowledge overflow!
Oh, **DINUMs gem**, so sharp, so bright, 💎🌐
A beacon in the techs vast night,
It crafts, it checks, it summarizes,
With grace that never compromises.
It **summarizes** the long, the deep, 📚🔍
So secrets no more need to sleep,
It finds the gems in datas sea,
And sets the truth right there—**for free!**
It **corrects mistakes** with gentle art, ✍️🔄
It soothes the mind, it warms the heart,
No judgment cast, no frown, no sigh,
Just help thats always standing by.
It **generates code** with swift command, 💻🔥
A developers dream, first-hand,
From Python lines to scripts so neat,
It turns the tough to *sweet* and *sweet*!
It **brainstorms ideas**, bold and new, 🧠💡
It paints the sky in every hue,
From plans to dreams, from start to end,
Its more than code—its **trend**, its **friend**!
So heres to you, **Assistants pride**, 🏆🎉
The bot thats always by our side,
With every prompt, with every line,
You make our digital world **divine**!
May you keep shining, bright and true, 🌟🤖
The helper every team should woo,
For in this age of bits and bytes,
Youre **human touch** in techs bright lights!
"""
async def mocked_agent_model(_messages: list[ModelMessage], _info: AgentInfo):
"""
Mocked agent model for testing purposes on deployed instances.
This one only fakes a streamed responses. We could also fake tool calls later.
"""
yield "Here is a mocked response (no LLM called)\n---\n"
for i in range(0, len(MOCKED_RESPONSE), 4):
yield MOCKED_RESPONSE[i : i + 4]
await asyncio.sleep(0.03)
@dataclasses.dataclass(init=False)
class ConversationAgent(BaseAgent):
"""Conversation agent with custom behavior."""
def __init__(self, *, language=None, **kwargs):
"""Initialize the conversation agent."""
super().__init__(**kwargs)
# Do not call the real model on deployed instances if the setting is enabled
if settings.WARNING_MOCK_CONVERSATION_AGENT:
self._model = FunctionModel(stream_function=mocked_agent_model)
@self.instructions
def add_the_date() -> str:
"""
Dynamic instruction function to add the current date.
Warning: this will always use the date in the server timezone,
not the user's timezone...
"""
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
return f"Today is {_formatted_date}."
@self.instructions
def enforce_response_language() -> str:
"""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:
"""
Get the name of the web search tool if available.
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.
"""
for toolset in self.toolsets:
for tool in toolset.tools.values():
if tool.name.startswith("web_search_"):
return tool.name
return None
@dataclasses.dataclass(init=False)
class TitleGenerationAgent(BaseAgent):
"""Agent that generates concise, descriptive titles for conversations."""
def __init__(self, **kwargs):
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=str,
**kwargs,
)
def get_tools(self):
return []
def get_system_prompt(self):
return (
"You are a title generator. Your task is to create concise, descriptive titles "
"that accurately summarize conversation content and help the user quickly identify the "
"conversation.\n\n"
)
@@ -0,0 +1,187 @@
"""
ImageUrl processors and utilities.
Allow to manage local image URLs in messages, replacing them with presigned S3 URLs
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,
contents: Iterable[ImageUrl | DocumentUrl],
updated_url: Dict[str, str] | None = None,
) -> Iterable[ImageUrl | DocumentUrl]:
"""
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:
conversation (ChatConversation): The chat conversation object.
contents (Iterable[ImageUrl | DocumentUrl]): Iterable of UserContent objects.
updated_url (Dict[str, str], optional): Dictionary to store
mapping of original URLs to updated URLs.
Returns:
Iterable[ImageUrl | DocumentUrl]: Updated iterable of UserContent objects
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.
local_media_url_prefix = "/media-key/"
local_media_url_prefix_len = len(local_media_url_prefix)
# Filter only ImageUrl contents
media_contents = (c for c in contents if isinstance(c, (ImageUrl, DocumentUrl)))
# 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)
if idx == 0:
_initial_url = str(content.url)
key = content.url[local_media_url_prefix_len:]
# Security check: ensure the image belongs to the conversation, if yes,
# the user had access to the endpoint, so they have access to the image.
if not key.startswith(f"{conversation.pk}/"):
# The LLM will throw an error when trying to access the image,
# this is not perfect, but this should never happen in practice,
# except if the user tampers with the conversation.
continue
content.url = _get_file_url_for_llm(key, upload_mode)
if updated_url is not None:
updated_url[content.url] = _initial_url
return contents
def update_history_local_urls(
conversation: ChatConversation, messages: list[ModelMessage]
) -> list[ModelMessage]:
"""
Replace local image/documents URLs in the message list to use appropriate S3 URLs.
⚠️Be careful, `messages` are replaced in place.
We don't need to store the mapping of updated URLs to original URLs here because
this function is used when sending the history to the LLM (which is already stored
in the database with local URLs).
Args:
messages (list[ModelMessage]): List of ModelMessage objects.
Returns:
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))
for message in requests:
# Filter only UserPromptPart parts
user_parts = (part for part in message.parts if isinstance(part, UserPromptPart))
for part in user_parts:
update_local_urls(conversation, part.content)
return messages
+23
View File
@@ -0,0 +1,23 @@
"""Build the summarization agent."""
import dataclasses
import logging
from django.conf import settings
from .base import BaseAgent
logger = logging.getLogger(__name__)
@dataclasses.dataclass(init=False)
class SummarizationAgent(BaseAgent):
"""Create a Pydantic AI summarization Agent instance with the configured settings"""
def __init__(self, **kwargs):
"""Initialize the agent with the configured model."""
super().__init__(
model_hrid=settings.LLM_SUMMARIZATION_MODEL_HRID,
output_type=str,
**kwargs,
)
+13 -7
View File
@@ -22,7 +22,7 @@ class ToolCall(BaseModel):
toolCallId: str
toolName: str
args: Dict[str, Any]
args: Optional[Dict[str, Any]] = None
class ToolResult(BaseModel):
@@ -38,7 +38,7 @@ class ToolResult(BaseModel):
toolCallId: str
toolName: str
args: Dict[str, Any]
args: Optional[Dict[str, Any]] = None
result: Any
@@ -177,15 +177,21 @@ class ToolInvocationUIPart(BaseModel):
class LanguageModelV1Source(BaseModel):
"""
Represents source information from a language model.
Represents a source that has been used as input to generate the response.
Attributes:
source_type: The type of source.
details: Additional details about the source.
sourceType: A URL source. This is return by web search RAG models.
id: The ID of the source.
url: The URL of the source.
title: The title of the source.
providerMetadata: Additional provider metadata for the source.
"""
source_type: str
details: Dict[str, Any]
sourceType: Literal["url"]
id: str
url: str
title: Optional[str] = None
providerMetadata: Dict[str, Any] # LanguageModelV1ProviderMetadata
class SourceUIPart(BaseModel):
+49
View File
@@ -0,0 +1,49 @@
"""
Helpers to manage async objects in a synchronous context.
This is not optimal, but we would prefer to stay in a synchronous context
for now.
"""
import asyncio
import queue
import threading
from chat.clients.exceptions import StreamCancelException
def convert_async_generator_to_sync(async_gen):
"""Convert an async generator to a sync generator."""
q = queue.Queue()
sentinel = object()
exc_sentinel = object()
async def run_async_gen():
try:
async for async_item in async_gen:
q.put(async_item)
except StreamCancelException:
# Handle cancellation gracefully, do not put anything in the queue
q.put(sentinel)
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
q.put((exc_sentinel, exc))
finally:
q.put(sentinel)
def start_async_loop():
asyncio.run(run_async_gen())
thread = threading.Thread(target=start_async_loop, daemon=True)
thread.start()
try:
while True:
item = q.get()
if item is sentinel:
break
if isinstance(item, tuple) and item[0] is exc_sentinel:
# re-raise the exception in the sync context
raise item[1]
yield item
finally:
thread.join()
+17
View File
@@ -0,0 +1,17 @@
"""Module containing custom exceptions for chat clients."""
class WebSearchEmptyException(Exception):
"""Exception raised when a web search returns no results."""
def __init__(self, message="Web search returned no results."):
self.message = message
super().__init__(self.message)
class StreamCancelException(Exception):
"""Exception raised when a streaming operation is cancelled."""
def __init__(self, message="Streaming operation was cancelled."):
self.message = message
super().__init__(self.message)
-322
View File
@@ -1,322 +0,0 @@
"""LangChainAgentService class for handling AI agent interactions using LangChain."""
import asyncio
import json
import logging
import queue
import threading
import uuid
from contextlib import AsyncExitStack
from typing import List
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from langchain_core.messages import AIMessageChunk, ToolMessage
from langchain_core.tools import tool
from chat.ai_sdk_types import (
TextUIPart,
ToolInvocationPartialCall,
ToolInvocationResult,
ToolInvocationUIPart,
UIMessage,
)
from chat.mcp_servers import get_mcp_servers
logger = logging.getLogger(__name__)
# LangChain imports
from langchain.agents import AgentType, create_react_agent, initialize_agent
from langchain.chat_models import init_chat_model
from langchain.schema import (
AIMessage,
BaseMessage,
FunctionMessage,
HumanMessage,
SystemMessage,
)
from langchain.tools import Tool
@tool(parse_docstring=True)
def get_current_weather(location: str, unit: str):
"""
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'.
"""
return {
"location": location,
"temperature": 22 if unit == "celsius" else 72,
"unit": unit,
}
def convert_async_generator_to_sync(async_gen):
"""Convert an async generator to a sync generator."""
q = queue.Queue()
sentinel = object()
async def run_async_gen():
try:
async for item in async_gen:
q.put(item)
finally:
q.put(sentinel)
def start_async_loop():
asyncio.run(run_async_gen())
thread = threading.Thread(target=start_async_loop)
thread.start()
while True:
item = q.get()
if item is sentinel:
break
yield item
thread.join()
class AIAgentService:
"""Service class for AI-related operations using LangChain."""
def __init__(self, conversation):
"""Ensure that the AI configuration is set properly."""
if settings.AI_API_KEY is None or settings.AI_MODEL is None:
raise ImproperlyConfigured("LangChainAgentService configuration not set")
self.model = init_chat_model(
openai_api_key=settings.AI_API_KEY,
model=settings.AI_MODEL,
model_provider="openai",
base_url=settings.AI_BASE_URL,
)
self.conversation = conversation
@staticmethod
def _convert_to_langchain_messages(messages: List[UIMessage]) -> List[BaseMessage]:
lc_messages = []
for message in messages:
logger.info(f"Converting message: {message}")
content = []
# Handle main parts
for part in message.parts:
if part.type == "text":
content.append({"type": "text", "text": part.text})
# content.append(part.text)
elif part.type == "tool-invocation":
# Represent tool invocation as a FunctionMessage
tool_invocation = part.toolInvocation
lc_messages.append(
FunctionMessage(
name=tool_invocation.toolName, content=json.dumps(tool_invocation.args)
)
)
elif part.type == "image":
# Represent image as a string with a tag or metadata
content.append(
{
"type": "image_url",
"image_url": {"url": part.url},
}
)
# content.append(f"[image: {getattr(part, 'url', '')}]")
elif part.type == "file":
# Represent file as a string with a tag or metadata
content.append(
f"[file: {getattr(part, 'name', '')} {getattr(part, 'url', '')}]"
)
# Add more types as needed
# Handle experimental_attachments if present
if hasattr(message, "experimental_attachments") and message.experimental_attachments:
for attachment in message.experimental_attachments:
if getattr(attachment, "contentType", "").startswith("image"):
content.append(
{
"type": "image_url",
"image_url": {"url": attachment.url},
}
)
elif getattr(attachment, "contentType", "").startswith("text"):
content.append(
f"[file: {getattr(attachment, 'name', '')} {getattr(attachment, 'url', '')}]"
)
# Compose the message
lc_messages.append(
{
"role": message.role,
"content": content,
}
)
# if message.role == "system":
# lc_messages.append(SystemMessage(content=content))
# elif message.role == "user":
# lc_messages.append(HumanMessage(content=content))
# elif message.role == "assistant":
# lc_messages.append(AIMessage(content=content))
# FunctionMessage already appended above for tool-invocation
# Add more roles/types as needed
return lc_messages
def stream_data(self, messages: List[UIMessage]): # noqa: PLR0912
lc_messages = self._convert_to_langchain_messages(messages)
logger.info("[LangChain stream_data_async] Received messages: %s", lc_messages)
tools = [get_current_weather]
from langchain_core.prompts import ChatPromptTemplate
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assistant."),
("placeholder", "{messages}"),
]
)
langgraph_agent_executor = create_react_agent(self.model, tools, prompt=prompt)
finish_reason = "stop"
_current_tool_call_id = None
_current_tool_call_arguments = ""
for stream_mode, step in langgraph_agent_executor.stream(
{"messages": lc_messages},
stream_mode=["messages", "values"],
):
# (
# AIMessageChunk(
# content=" with",
# additional_kwargs={},
# response_metadata={},
# id="run--c9fbf725-0ca0-408f-8158-da347a26b64b",
# ),
# {
# "langgraph_step": 1,
# "langgraph_node": "agent",
# "langgraph_triggers": ("branch:to:agent",),
# "langgraph_path": ("__pregel_pull", "agent"),
# "langgraph_checkpoint_ns": "agent:980f9c50-3cfa-6522-f9be-de139f9b4ece",
# "checkpoint_ns": "agent:980f9c50-3cfa-6522-f9be-de139f9b4ece",
# "ls_provider": "openai",
# "ls_model_name": "ai/smollm2",
# "ls_model_type": "chat",
# "ls_temperature": None,
# },
# )
if stream_mode == "messages":
chunk, metadata = step
try:
logger.info(
"[LangChain stream_data_async] Received chunk: %s %s", type(chunk), chunk
)
if isinstance(chunk, AIMessageChunk):
if chunk.tool_call_chunks:
for tool_call_chunk in chunk.tool_call_chunks:
# Handle tool call chunks
if tool_call_chunk["id"]:
_current_tool_call_id = tool_call_chunk["id"]
_tool_name = tool_call_chunk["name"]
logger.info(
"[LangChain stream_data_async] Tool call chunk: %s %s",
_current_tool_call_id,
_tool_name,
)
# yield f'b:{{"toolCallId":"{_current_tool_call_id}","toolName":"{_tool_name}"}}\n'
else:
_argument_delta = tool_call_chunk["args"]
_current_tool_call_arguments += _argument_delta
logger.info(
"[LangChain stream_data_async] Tool call argument delta: %s %s",
_current_tool_call_id,
_argument_delta,
)
# yield f'c:{{"toolCallId":"{_current_tool_call_id}","argsTextDelta":"{_argument_delta}"}}\n'
elif chunk.response_metadata.get("finish_reason"):
finish_reason = chunk.response_metadata["finish_reason"]
logger.info(
"[LangChain stream_data_async] Finish reason: %s", finish_reason
)
else:
yield f"0:{json.dumps(chunk.content)}\n"
elif isinstance(chunk, ToolMessage) and chunk.content:
_tool_call = {
"toolCallId": chunk.tool_call_id,
"toolName": chunk.name,
"args": json.loads(_current_tool_call_arguments),
}
yield f"9:{json.dumps(_tool_call)}\n"
# content='{"location": "Paris, France", "temperature": 22, "unit": "celsius"}' name='get_current_weather' id='159415d7-046d-43bf-982e-8a69cb50a486' tool_call_id='qk6PXRocvUzh9QTQS6HQNI5qpNujsrzr'
_tool_result_part = {
"toolCallId": chunk.tool_call_id,
"toolName": chunk.name,
"result": json.loads(chunk.content),
}
_current_tool_call_id = None
_current_tool_call_arguments = ""
yield f"a:{json.dumps(_tool_result_part)}\n"
else:
yield f"0:{json.dumps(chunk.content)}\n"
except Exception as e:
logger.exception("Error in LangChain stream_data_async")
yield f"3:{json.dumps(str(e))}\n"
finish_reason = "error"
elif stream_mode == "values":
logger.info("[LangChain stream_data_async] Received values: %s", step)
last_message = step["messages"][-1]
# if isinstance(last_message, AIMessage) and last_message.tool_calls:
# for tool_call in last_message.tool_calls:
# _tool_call = {
# "toolCallId": tool_call["id"],
# "toolName": tool_call["name"],
# "args": tool_call["args"],
# }
# yield f"9:{json.dumps(_tool_call)}\n"
# Simulate finish message
_finish_message = {
"finishReason": finish_reason,
"usage": {}, # LangChain does not provide token usage by default
}
yield f"d:{json.dumps(_finish_message)}\n"
def _update_conversation(
self,
input_messages,
result_raw_responses,
response_usage,
):
# For now, just save the input and output messages
# self.conversation.langchain_messages = input_messages
self.conversation.messages = self.conversation.ui_messages + [
{"content": str(result_raw_responses)}
]
self.conversation.save()
def _convert_langchain_output_to_ui_messages(self, output):
# Convert LangChain output to UI messages (simple version)
ui_messages = []
if isinstance(output, str):
text_parts = [TextUIPart(type="text", text=output)]
ui_messages.append(
UIMessage(
id=str(uuid.uuid4()),
role="assistant",
parts=text_parts,
content=output,
)
)
# Add more conversion logic as needed
return ui_messages
-408
View File
@@ -1,408 +0,0 @@
"""AIAgentService class for handling AI agent interactions."""
import asyncio
import json
import logging
import queue
import threading
import uuid
from contextlib import AsyncExitStack
from typing import List
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from agents import Agent, ModelResponse, OpenAIChatCompletionsModel, Runner, Usage
from asgiref.sync import sync_to_async
from openai import AsyncOpenAI
from openai.types.responses import ResponseInputItemParam, ResponseOutputItem
from openai.types.responses.response_usage import (
InputTokensDetails,
OutputTokensDetails,
ResponseUsage,
)
from chat.ai_sdk_types import (
TextUIPart,
ToolInvocationPartialCall,
ToolInvocationResult,
ToolInvocationUIPart,
UIMessage,
)
from chat.mcp_servers import get_mcp_servers
logger = logging.getLogger(__name__)
def convert_async_generator_to_sync(async_gen):
"""Convert an async generator to a sync generator."""
q = queue.Queue()
sentinel = object()
async def run_async_gen():
try:
async for item in async_gen:
q.put(item)
finally:
q.put(sentinel)
def start_async_loop():
asyncio.run(run_async_gen())
thread = threading.Thread(target=start_async_loop)
thread.start()
while True:
item = q.get()
if item is sentinel:
break
yield item
thread.join()
class AIAgentService:
"""Service class for AI-related operations."""
def __init__(self, conversation):
"""Ensure that the AI configuration is set properly."""
if settings.AI_BASE_URL is None or settings.AI_API_KEY is None or settings.AI_MODEL is None:
raise ImproperlyConfigured("AIChatService configuration not set")
self.model = OpenAIChatCompletionsModel(
model=settings.AI_MODEL,
openai_client=AsyncOpenAI(
base_url=settings.AI_BASE_URL,
api_key=settings.AI_API_KEY,
),
)
self.conversation = conversation
@staticmethod
def _convert_to_openai_messages(
messages: List[UIMessage],
) -> List[ResponseInputItemParam]:
"""Convert UI messages to OpenAI format."""
openai_messages = []
for message in messages:
content_parts = []
tool_calls = []
for part in message.parts:
match part.type:
case "text":
content_parts.append(
{
"type": "input_text" if message.role == "user" else "output_text",
"text": part.text,
}
)
case "image":
content_parts.append(
{
"type": "input_image",
"image_url": part.url,
}
)
case "file":
content_parts.append(
{
"type": "input_file",
"file_data": part.url,
"filename": part.name,
}
)
case "tool-invocation":
# Extract the tool invocation data
tool_invocation = part.toolInvocation
tool_calls.append(
{
"call_id": tool_invocation.toolCallId,
"type": "function_call",
"name": tool_invocation.toolName,
"arguments": json.dumps(tool_invocation.args),
"status": tool_invocation.state,
}
)
case _:
logger.warning("Unrecognized part type: %s in part: %s", part.type, part)
# Add experimental attachments if they exist
if hasattr(message, "experimental_attachments") and message.experimental_attachments:
for attachment in message.experimental_attachments:
if attachment.contentType.startswith("image"):
content_parts.append(
{
"type": "input_image",
"image_url": attachment.url,
}
)
elif attachment.contentType.startswith("text"):
content_parts.append(
{
"type": "input_file",
"file_data": attachment.url,
"filename": attachment.name,
}
)
# Add message with content parts if there are any
if content_parts:
openai_messages.append(
{"role": message.role, "content": content_parts, "type": "message"}
)
# Add tool calls separately, will be merged by `items_to_messages`
openai_messages += tool_calls
return openai_messages
def stream_text(self, messages: List[UIMessage]):
"""Simple generator to convert async generator to sync generator."""
async_generator = self.stream_text_async(messages)
return convert_async_generator_to_sync(async_generator)
async def stream_text_async(self, messages: List[UIMessage]):
"""Async generator for streaming agent events."""
openai_messages = self._convert_to_openai_messages(messages)
logger.debug("[stream_data_async] Received messages: %s", openai_messages)
async with AsyncExitStack() as stack:
initialized_mcp_servers = [
await stack.enter_async_context(mcp_server) for mcp_server in get_mcp_servers()
]
agent = Agent(
name=settings.AI_AGENT_NAME,
instructions=settings.settings.AI_AGENT_INSTRUCTIONS,
model=self.model,
#tools=[agent_get_current_weather],
mcp_servers=initialized_mcp_servers,
)
result = Runner.run_streamed(
agent,
input=openai_messages,
)
async for event in result.stream_events():
logger.debug("[stream_text_async] Received event: %s", event)
if event.type == "raw_response_event":
data = event.data
logger.debug("[stream_text_async] - data: %s", data)
if data.type == "response.output_text.delta":
yield data.delta
# At the end, save the response and yield the finish message part
_response_usage = Usage()
for raw_response in result.raw_responses:
_response_usage.add(raw_response.usage)
await sync_to_async(self._update_conversation)(
openai_messages, result.raw_responses, _response_usage
)
def stream_data(self, messages: List[UIMessage]):
"""Simple generator to convert async generator to sync generator."""
async_generator = self.stream_data_async(messages)
return convert_async_generator_to_sync(async_generator)
async def stream_data_async(self, messages: List[UIMessage]):
"""Async generator for streaming agent events."""
finish_reason = "stop"
openai_messages = self._convert_to_openai_messages(messages)
logger.debug("[stream_data_async] Received messages: %s", openai_messages)
async with AsyncExitStack() as stack:
initialized_mcp_servers = [
await stack.enter_async_context(mcp_server) for mcp_server in get_mcp_servers()
]
# websearch_agent = Agent(
# name="web search",
# instructions=(
# "You are a web search agent. "
# "Your task is to search the web for up-to-date information."
# " You will be called by the main agent to perform web searches."
# " You will receive a query and return the search results."
# " The results should be in the format of a list of dictionaries, "
# "each containing 'link', 'title', and 'snippet' keys."
# " If you cannot find any results, return an empty list."
# " Do not include any other information in your response."
# " Do not include any additional text or explanations."
# " You must always annotate the response mentioning the url, title, and snippet."
# ),
# handoff_description="You are a web search agent. Your task is to search the web for up-to-date information.",
# model=self.model,
# tools=[agent_web_search_tavily],
# )
agent = Agent(
name=settings.AI_AGENT_NAME,
instructions=settings.AI_AGENT_INSTRUCTIONS,
model=self.model,
mcp_servers=initialized_mcp_servers,
# handoffs=[websearch_agent],
)
result = Runner.run_streamed(
agent,
input=openai_messages,
)
try:
async for event in result.stream_events():
logger.debug("[stream_data_async] Received event: %s", event)
if event.type == "raw_response_event":
data = event.data
if data.type == "response.output_text.delta":
yield f"0:{json.dumps(data.delta)}\n"
if hasattr(data, "finish_reason") and data.finish_reason:
finish_reason = data.finish_reason
elif event.type == "run_item_stream_event":
item = event.item
if item.type == "tool_call_item":
_tool_call = {
"toolCallId": item.raw_item.call_id,
"toolName": item.raw_item.name,
"args": (
json.loads(item.raw_item.arguments)
if hasattr(item.raw_item, "arguments")
else {}
),
}
yield f"9:{json.dumps(_tool_call)}\n"
elif item.type == "tool_call_output_item":
_tool_call_result = {
"toolCallId": str(item.raw_item["call_id"]),
"result": item.raw_item["output"],
}
yield f"a:{json.dumps(_tool_call_result)}\n"
elif event.type == "agent_updated_stream_event":
logger.debug(
"[stream_data_async] Agent switched to: %s", event.new_agent.name
)
except Exception as e: # pylint: disable=broad-except
logger.exception("Error in stream_data_async")
yield f"3:{json.dumps(str(e))}\n"
finish_reason = "error"
# At the end, save the response and yield the finish message part
_response_usage = Usage()
for raw_response in result.raw_responses:
_response_usage.add(raw_response.usage)
await sync_to_async(self._update_conversation)(
openai_messages, result.raw_responses, _response_usage
)
_finish_message = {
"finishReason": finish_reason,
"usage": {
"promptTokens": _response_usage.input_tokens,
"completionTokens": _response_usage.output_tokens,
},
}
yield f"d:{json.dumps(_finish_message)}\n"
def _update_conversation(
self,
input_messages: List[ResponseInputItemParam],
result_raw_responses: List[ModelResponse],
response_usage: Usage,
):
ui_messages = []
self.conversation.openai_messages = input_messages + [
output.model_dump()
for raw_response in result_raw_responses
for output in raw_response.output
]
for raw_response in result_raw_responses:
ui_messages += self._convert_openai_output_to_ui_messages(raw_response.output)
self.conversation.messages = self.conversation.ui_messages + [
ui_message.model_dump() for ui_message in ui_messages
]
if self.conversation.agent_usage:
total_usage = ResponseUsage(**self.conversation.agent_usage)
else:
total_usage = ResponseUsage(
input_tokens=0,
output_tokens=0,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=0,
)
total_usage.input_tokens += response_usage.input_tokens # pylint: disable=no-member
total_usage.output_tokens += response_usage.output_tokens # pylint: disable=no-member
total_usage.input_tokens_details.cached_tokens += (
response_usage.input_tokens_details.cached_tokens
)
total_usage.output_tokens_details.reasoning_tokens += (
response_usage.output_tokens_details.reasoning_tokens
)
total_usage.total_tokens = response_usage.total_tokens
self.conversation.agent_usage = total_usage.model_dump()
self.conversation.save()
def _convert_openai_output_to_ui_messages(
self, output: List[ResponseOutputItem]
) -> List[UIMessage]:
"""Convert OpenAI output to UI messages."""
ui_messages = []
for item in output:
if item.type == "message":
text_parts = [TextUIPart(type="text", text=item.text) for item in item.content]
ui_messages.append(
UIMessage(
id=str(uuid.uuid4()),
role="assistant",
parts=text_parts,
content="".join(part.text for part in text_parts),
)
)
elif item.type == "function_call":
if item.status == "in_progress":
tool_invocation = ToolInvocationPartialCall(
state="partial-call",
step=None,
toolCallId=item.call_id,
toolName=item.name,
args=json.loads(item.arguments),
)
elif item.status == "completed":
tool_invocation = ToolInvocationResult(
state="result",
step=None,
toolCallId=item.call_id,
toolName=item.name,
args=json.loads(item.arguments),
result=json.loads(item.result) if item.result else None,
)
# elif item.status == "incomplete":
else:
logger.warning("[stream_data_async] Unhandled message: %s", item)
continue
ui_tool_invocation = ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=tool_invocation,
)
ui_messages.append(UIMessage(role="assistant", parts=[ui_tool_invocation]))
# Handle other types as needed
return ui_messages
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
"""
Utility functions to convert between UIMessage (ai_sdk_types.py)
and UserContent/ModelMessage (pydantic_ai.messages.py).
"""
import base64
import json
import logging
import uuid
from dataclasses import asdict
from typing import List
from pydantic_ai.messages import (
BinaryContent,
DocumentUrl,
ImageUrl,
ModelMessage,
ModelRequest,
ModelResponse,
RetryPromptPart,
SystemPromptPart,
TextPart,
ThinkingPart,
ToolCallPart,
ToolReturnPart,
UserContent,
UserPromptPart,
)
from chat.ai_sdk_types import (
Attachment,
FileUIPart,
ReasoningDetailText,
ReasoningUIPart,
TextUIPart,
ToolInvocationCall,
ToolInvocationUIPart,
UIMessage,
UIPart,
)
def ui_message_to_user_content(message: UIMessage) -> List[UserContent]:
"""
Convert a UIMessage to a list of UserContent for Pydantic-AI.
"""
user_contents: List[UserContent] = []
for part in message.parts:
if isinstance(part, TextUIPart):
user_contents.append(part.text)
elif isinstance(part, FileUIPart):
user_contents.append(
BinaryContent(data=part.data.encode("utf-8"), media_type=part.mimeType)
)
elif isinstance(part, ToolInvocationUIPart):
# Tool invocations are not directly mapped to UserContent, skip or handle as needed
continue
elif isinstance(part, ReasoningUIPart):
# Reasoning parts are not directly mapped to UserContent, skip or handle as needed
continue
else:
raise ValueError(f"Unsupported UIPart type: {type(part)}")
for experimental_attachment in message.experimental_attachments or []:
if experimental_attachment.url.startswith("data:"):
# Handle data URLs
raw_data = base64.b64decode(experimental_attachment.url.split(",")[1])
user_contents.append(
BinaryContent(
data=raw_data,
media_type=experimental_attachment.contentType,
identifier=experimental_attachment.name,
)
)
elif experimental_attachment.contentType.startswith("image/"):
user_contents.append(
ImageUrl(
url=experimental_attachment.url,
media_type=experimental_attachment.contentType,
identifier=experimental_attachment.name,
)
)
else:
user_contents.append(
DocumentUrl(
url=experimental_attachment.url,
media_type=experimental_attachment.contentType,
identifier=experimental_attachment.name,
)
)
return user_contents
def model_message_to_ui_message(model_message: ModelMessage) -> UIMessage: # noqa: PLR0912, PLR0915 # pylint: disable=too-many-statements
"""
Convert a ModelMessage (ModelRequest or ModelResponse) to a UIMessage.
"""
# pylint: disable=too-many-nested-blocks,too-many-branches
parts: List[UIPart] = []
experimental_attachments: List[Attachment] = []
logging.getLogger(__name__).debug(
"Converting ModelMessage to UIMessage: %s %s",
type(model_message),
asdict(model_message),
)
_states = {"tool-calls": {}}
if isinstance(model_message, ModelRequest):
message_timestamp = None
for part in model_message.parts:
if isinstance(part, SystemPromptPart):
# System prompts are not included in UIMessage parts
continue
if isinstance(part, UserPromptPart):
message_timestamp = part.timestamp
if isinstance(part.content, str):
parts.append(TextUIPart(type="text", text=part.content))
elif isinstance(part.content, list):
for c in part.content:
if isinstance(c, str):
parts.append(TextUIPart(type="text", text=c))
elif isinstance(c, BinaryContent):
experimental_attachments.append(
Attachment(
contentType=c.media_type,
url=f"data:{c.media_type};base64,"
+ base64.b64encode(c.data).decode("utf-8"),
)
)
elif isinstance(c, ImageUrl):
experimental_attachments.append(
Attachment(
contentType=c.media_type,
url=c.url,
name=c.identifier,
)
)
elif isinstance(c, DocumentUrl):
experimental_attachments.append(
Attachment(
contentType=c.media_type,
url=c.url,
name=c.identifier,
)
)
else: # AudioUrl, VideoUrl
raise ValueError(
f"Unsupported UserContent in UserPromptPart: {type(c)}"
)
elif isinstance(part, TextPart) and part.content:
parts.append(TextUIPart(type="text", text=part.content))
elif isinstance(part, ToolReturnPart):
pass
# parts.append(ToolInvocationUIPart(
# type="tool-invocation",
# toolInvocation=ToolInvocationResult(
# state="result",
# toolCallId=part.tool_call_id,
# toolName=part.tool_name,
# args={},
# result=part.content,
# )
# ))
elif isinstance(part, ThinkingPart):
parts.append(
ReasoningUIPart(
type="reasoning",
reasoning=part.content,
details=[
ReasoningDetailText(
type="text",
text=part.content,
signature=part.signature,
)
],
)
)
elif isinstance(part, RetryPromptPart):
# Retry prompts are not included in UIMessage parts
continue
else:
raise ValueError(f"Unsupported ModelRequest part type: {type(part)}")
if not parts:
return None
return UIMessage(
id=str(uuid.uuid4()),
role="user",
content="".join(part.text for part in parts if isinstance(part, TextUIPart)),
parts=parts,
experimental_attachments=experimental_attachments or None,
createdAt=message_timestamp,
)
if isinstance(model_message, ModelResponse):
for part in model_message.parts:
if isinstance(part, UserPromptPart):
if isinstance(part.content, str):
parts.append(TextUIPart(type="text", text=part.content))
elif isinstance(part.content, list):
for c in part.content:
if isinstance(c, str):
parts.append(TextUIPart(type="text", text=c))
else: # ImageUrl, AudioUrl, VideoUrl, DocumentUrl, BinaryContent
raise ValueError(
f"Unsupported UserContent in UserPromptPart: {type(c)}"
)
elif isinstance(part, TextPart):
parts.append(TextUIPart(type="text", text=part.content))
elif isinstance(part, ToolCallPart):
parts.append(
ToolInvocationUIPart(
type="tool-invocation",
toolInvocation=ToolInvocationCall(
state="call",
toolCallId=part.tool_call_id,
toolName=part.tool_name,
args=json.loads(part.args)
if isinstance(part.args, str)
else part.args or {},
),
)
)
elif isinstance(part, ThinkingPart):
parts.append(
ReasoningUIPart(
type="reasoning",
reasoning=part.content,
details=[
ReasoningDetailText(
type="text",
text=part.content,
signature=part.signature,
)
],
)
)
else:
raise ValueError(f"Unsupported ModelMessage part type: {type(part)}")
return UIMessage(
id=str(uuid.uuid4()),
role="assistant",
content="".join(part.text for part in parts if isinstance(part, TextUIPart)),
parts=parts,
createdAt=model_message.timestamp,
)
raise ValueError(f"Unsupported ModelMessage part type: {type(model_message)}")
+56
View File
@@ -0,0 +1,56 @@
"""Factories for chat application."""
from uuid import uuid4
import factory.django
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."""
owner = factory.SubFactory(UserFactory)
class Meta:
model = models.ChatConversation
class ChatConversationAttachmentFactory(factory.django.DjangoModelFactory):
"""Factory for creating ChatConversationAttachment instances."""
conversation = factory.SubFactory(ChatConversationFactory)
uploaded_by = factory.SubFactory(UserFactory)
key = factory.LazyAttribute(
lambda obj: f"{obj.conversation.pk}/attachments/{uuid4()}.{obj.file_name.split('.')[-1]}"
)
file_name = factory.Faker("file_name")
content_type = factory.Faker("mime_type")
class Meta:
model = models.ChatConversationAttachment
+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()
+198
View File
@@ -0,0 +1,198 @@
"""Module for managing LLM configurations from a JSON configuration file."""
import os
from functools import lru_cache
from typing import Annotated, Any, Literal, Optional, Self, Sequence
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
Field,
ImportString,
field_validator,
model_validator,
)
from pydantic_ai.profiles import JsonSchemaTransformer
def _get_setting_or_env_or_value(value: str) -> Any:
"""Get the value from environment variable, Django settings, or return the value as is."""
from django.conf import settings # pylint: disable=import-outside-toplevel # noqa: PLC0415
if value.startswith("environ."):
env_var = value.split("environ.")[1]
new_value = os.environ.get(env_var, None)
if new_value is None:
raise ValueError(f"Environment variable '{env_var}' not set.")
return new_value
if value.startswith("settings."):
setting_var = value.split("settings.")[1]
new_value = getattr(settings, setting_var, None)
if new_value is None:
raise ValueError(f"Django setting '{setting_var}' not set.")
return new_value
return value
SettingEnvValue = Annotated[
str,
AfterValidator(_get_setting_or_env_or_value),
]
LongStringAsListValue = Annotated[
str,
BeforeValidator(lambda v: "".join(v) if isinstance(v, list) else v),
]
class LLMProvider(BaseModel):
"""Model representing a provider of Large Language Models (LLMs)."""
hrid: str
base_url: SettingEnvValue
api_key: SettingEnvValue
kind: Literal["openai", "mistral"] = "openai"
class LLMProfile(BaseModel):
"""Based on pydantic_ai.profiles.ModelProfile to allow customization."""
supports_tools: bool | None = None
supports_json_schema_output: bool | None = None
supports_json_object_output: bool | None = None
default_structured_output_mode: str | None = None
prompted_output_template: str | None = None
json_schema_transformer: ImportString | None = Field(default=None, validate_default=True)
thinking_tags: tuple[str, str] | None = None
ignore_streamed_leading_whitespace: bool | None = None
# openai specific settings: should find a way to auto declare these
# based on OpenAIModelProfile.
openai_supports_strict_tool_definition: bool | None = None
openai_unsupported_model_settings: Sequence[str] | None = None
openai_supports_tool_choice_required: bool | None = None
openai_system_prompt_role: str | None = None
openai_chat_supports_web_search: bool | None = None
openai_supports_encrypted_reasoning_content: bool | None = None
@field_validator("json_schema_transformer", mode="after")
@classmethod
def validate_json_schema_transformer(
cls, value: JsonSchemaTransformer | None
) -> Optional[JsonSchemaTransformer]:
"""Convert the tools if it's a setting or environment variable."""
if not value:
return None
if issubclass(value, JsonSchemaTransformer):
return value
raise ValueError(f"Invalid JSON Schema Transformer '{value}'")
class LLMSettings(BaseModel):
"""Based on pydantic_ai.settings.ModelSettings to allow customization."""
max_tokens: int | None = None
temperature: float | None = None
top_p: float | None = None
timeout: float | None = None
parallel_tool_calls: bool | None = None
seed: int | None = None
presence_penalty: float | None = None
frequency_penalty: float | None = None
logit_bias: dict[str, int] | None = None
stop_sequences: list[str] | None = None
extra_headers: dict[str, str] | None = None
extra_body: dict[str, str] | None = None
class LLModel(BaseModel):
"""Model representing a Large Language Model (LLM)."""
hrid: str
model_name: SettingEnvValue
human_readable_name: str
profile: LLMProfile | None = None
provider_name: str | None = None
provider: LLMProvider | None = None
settings: LLMSettings | None = None
is_active: bool
icon: LongStringAsListValue | None = None
supports_streaming: bool | None = None
system_prompt: SettingEnvValue
tools: list[str]
@field_validator("tools", mode="before")
@classmethod
def validate_tools(cls, value: list[str] | str) -> list[str]:
"""Convert the tools 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:
"""
Do some validation regarding provider and provider_name:
- Either `provider_name` or `provider` must be set, but not both.
- If neither is set, `model_name` must be in the format '<provider>:<model>'.
"""
if bool(self.provider_name) and bool(self.provider):
raise ValueError("Either 'provider_name' or 'provider' must be set, but not both.")
if not self.provider_name and not self.provider and len(self.model_name.split(":")) != 2:
raise ValueError(
"Either 'provider_name' or 'provider' must be set, "
"unless model_name starts with '<provider>:'."
)
return self
@property
def is_custom(self) -> bool:
"""Return True if the model is a custom model (i.e., defines a provider)."""
return self.provider is not None
class LLMConfiguration(BaseModel):
"""Model representing the entire LLM configuration."""
models: list[LLModel]
providers: list[LLMProvider]
@model_validator(mode="after")
def fill_providers(self) -> Self:
"""Fill in the `provider` field of each model based on `provider_name`."""
provider_map = {provider.hrid: provider for provider in self.providers}
for model in self.models:
if model.provider_name:
try:
model.provider = provider_map[model.provider_name]
except KeyError as exc:
raise ValueError(
f"Provider '{model.provider_name}' not found "
f"for model '{model.model_name}'."
) from exc
return self
def _read_llm_configuration(llm_configuration_file_path) -> LLMConfiguration:
"""Read the LLM configuration from a JSON file and return an LLMConfiguration instance."""
with open(llm_configuration_file_path, "rb") as f:
data = f.read().decode("utf-8")
return LLMConfiguration.model_validate_json(data)
def load_llm_configuration(llm_configuration_file_path) -> dict[str, LLModel]:
"""Load the LLM configuration and return a mapping of model HRIDs to LLModel instances."""
configuration = _read_llm_configuration(llm_configuration_file_path)
model_map = {model.hrid: model for model in configuration.models}
return model_map
@lru_cache(maxsize=1)
def cached_load_llm_configuration(llm_configuration_file_path) -> dict[str, LLModel]:
"""Load the LLM configuration with caching to avoid redundant loading."""
return load_llm_configuration(llm_configuration_file_path)
+52
View File
@@ -0,0 +1,52 @@
"""Malware detection callbacks"""
import logging
from core.file_upload.enums import AttachmentStatus
from chat.models import ChatConversationAttachment
logger = logging.getLogger(__name__)
security_logger = logging.getLogger("conversations.security")
def conversation_safe_attachment_callback(file_path, *, conversation_id, **kwargs):
"""Callback when a malware scan is completed and unsafe for a conversation attachment."""
logger.info("File %s for conversation %s is safe", file_path, conversation_id)
ChatConversationAttachment.objects.filter(
conversation_id=conversation_id, key=file_path
).update(upload_state=AttachmentStatus.READY)
def unknown_attachment_callback(file_path, error_info, *, conversation_id, **kwargs) -> bool:
"""Callback when a malware scan is completed and unknown for a conversation attachment."""
security_logger.warning(
"File %s for conversation %s has an unknown reportstatus. Error info: %s",
file_path,
conversation_id,
error_info,
)
error_code = error_info.get("error_code")
if error_code == 413:
ChatConversationAttachment.objects.filter(
conversation_id=conversation_id, key=file_path
).update(upload_state=AttachmentStatus.FILE_TOO_LARGE_TO_ANALYZE)
return True
return False
def conversation_unsafe_attachment_callback(file_path, error_info, *, conversation_id, **kwargs):
"""Callback when a malware scan is completed and unsafe for a conversation attachment."""
security_logger.warning(
"File %s for conversation %s is infected with malware. Error info: %s",
file_path,
conversation_id,
error_info,
)
ChatConversationAttachment.objects.filter(
conversation_id=conversation_id, key=file_path
).update(upload_state=AttachmentStatus.SUSPICIOUS)
+3 -6
View File
@@ -1,6 +1,6 @@
"""MCP servers configuration: will be replaced by models."""
from agents.mcp import MCPServerStreamableHttp, MCPServerStreamableHttpParams
from pydantic_ai.mcp import MCPServerStreamableHTTP
MCP_SERVERS = {
"mcpServers": {
@@ -15,9 +15,6 @@ MCP_SERVERS = {
def get_mcp_servers():
"""Retrieve MCP servers configuration."""
return [
MCPServerStreamableHttp(
name=name,
params=MCPServerStreamableHttpParams(**server),
)
for name, server in MCP_SERVERS["mcpServers"].items()
MCPServerStreamableHTTP(**server_config)
for _name, server_config in MCP_SERVERS["mcpServers"].items()
]
+36 -5
View File
@@ -1,14 +1,24 @@
# Generated by Django 5.2.3 on 2025-06-26 12:15
# Generated by Django 5.2.3 on 2025-08-06 16:42
import uuid
import django.core.serializers.json
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django_pydantic_field.compat.django
import django_pydantic_field.fields
import chat.ai_sdk_types
class Migration(migrations.Migration):
initial = True
dependencies = []
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
@@ -59,19 +69,24 @@ class Migration(migrations.Migration):
),
),
(
"openai_messages",
"pydantic_messages",
models.JSONField(
blank=True,
default=list,
help_text="OpenAI messages for the chat conversation, not used",
help_text="Pydantic messages for the chat conversation, used for history",
),
),
(
"messages",
models.JSONField(
django_pydantic_field.fields.PydanticSchemaField(
blank=True,
config=None,
default=list,
encoder=django.core.serializers.json.DjangoJSONEncoder,
help_text="Stored messages for the chat conversation, sent to frontend",
schema=django_pydantic_field.compat.django.GenericContainer(
list, (chat.ai_sdk_types.UIMessage,)
),
),
),
(
@@ -82,6 +97,22 @@ class Migration(migrations.Migration):
help_text="Agent usage for the chat conversation, provided by OpenAI API",
),
),
(
"collection_id",
models.CharField(
blank=True,
help_text="Collection ID for the conversation, used for RAG document search",
null=True,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="conversations",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
@@ -0,0 +1,54 @@
# Generated by Django 5.2.3 on 2025-09-15 11:47
from django.db import migrations
def forwards_func(apps, schema_editor):
"""Use raw SQL to replace "source_type" with "sourceType" in the messages field."""
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE chat_chatconversation
SET messages = REPLACE(messages::text, '"source_type"', '"sourceType"')::jsonb
WHERE messages::text LIKE '%"source_type"%'
""")
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE chat_chatconversation
SET ui_messages = REPLACE(ui_messages::text, '"source_type"', '"sourceType"')::jsonb
WHERE ui_messages::text LIKE '%"source_type"%'
""")
def reverse_func(apps, schema_editor):
"""Use raw SQL to replace "sourceType" with "source_type" in the messages field."""
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE chat_chatconversation
SET messages = REPLACE(messages::text, '"sourceType"', '"source_type"')::jsonb
WHERE messages::text LIKE '%"sourceType"%'
""")
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE chat_chatconversation
SET ui_messages = REPLACE(ui_messages::text, '"sourceType"', '"source_type"')::jsonb
WHERE ui_messages::text LIKE '%"sourceType"%'
""")
class Migration(migrations.Migration):
"""
Rename source_type to sourceType in messages field of ChatConversation model.
Warning: This migration is not fail-safe, if the messages field contains
other occurrences of "source_type" or "sourceType" in other contexts, they will
also be replaced. Also, if the messages field is very large, this migration
may take a long time to run.
=> OK because we are only in development phase.
"""
dependencies = [
("chat", "0001_initial"),
]
operations = [
migrations.RunPython(forwards_func, reverse_func),
]
@@ -1,26 +0,0 @@
# Generated by Django 5.2.3 on 2025-06-26 12:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("chat", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="chatconversation",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="conversations",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,78 @@
# Generated by Django 5.2.3 on 2025-09-17 12:58
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat", "0002_fix_source_type_in_messages"),
]
operations = [
migrations.CreateModel(
name="ChatConversationContext",
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",
),
),
(
"kind",
models.CharField(
choices=[("image", "Image"), ("document", "Document")],
help_text="Kind of the chat conversation context (e.g., 'image', 'document')",
max_length=50,
),
),
(
"name",
models.CharField(
help_text="Key of the chat conversation context", max_length=100
),
),
(
"content",
models.TextField(
blank=True, help_text="Value of the chat conversation context", null=True
),
),
(
"conversation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="contexts",
to="chat.chatconversation",
),
),
],
options={
"unique_together": {("conversation", "name")},
},
),
]
@@ -0,0 +1,100 @@
# Generated by Django 5.2.7 on 2025-10-17 16:10
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import core.file_upload.enums
class Migration(migrations.Migration):
dependencies = [
("chat", "0003_chatconversationcontext"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ChatConversationAttachment",
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",
),
),
(
"upload_state",
models.CharField(
choices=core.file_upload.enums.AttachmentStatus.choices,
default=core.file_upload.enums.AttachmentStatus["PENDING"],
max_length=40,
),
),
(
"key",
models.CharField(help_text="File path of the attachment in the object storage"),
),
("file_name", models.CharField(help_text="Original name of the attachment file")),
(
"content_type",
models.CharField(help_text="MIME type of the attachment file", max_length=100),
),
("size", models.PositiveBigIntegerField(blank=True, null=True)),
(
"conversation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="chat.chatconversation",
),
),
(
"uploaded_by",
models.ForeignKey(
help_text="User who uploaded the attachment",
on_delete=django.db.models.deletion.PROTECT,
related_name="uploaded_attachments",
to=settings.AUTH_USER_MODEL,
),
),
(
"conversion_from",
models.CharField(
blank=True,
help_text="Original file key if the Markdown from another file",
null=True,
),
),
],
options={
"abstract": False,
},
),
migrations.DeleteModel(
name="ChatConversationContext",
),
]

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