Compare commits

...

4 Commits

Author SHA1 Message Date
camilleAND bfb374664a test prompts management via github 2026-01-08 11:29:17 +01:00
Laurent Paoletti 5676ce68c0 🐛(back) fix system prompt compatibility with self-hosted models
Pydantic AI allows setting multiple static and dynamic system prompts
to define conversation context and rules. Previously, these were sent
to the model API as separate messages, which caused compatibility
issues with some self-hosted models (e.g., Gemma3/vLLM).

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

Reference: https://ai.pydantic.dev/agents/#system-prompts
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-01-05 18:43:38 +01:00
Eléonore Voisin 50a395c546 Revert "🐛(front) optimize chat"
This reverts commit 69bf2cab5d.
2025-12-30 13:46:04 +01:00
Eléonore Voisin 69bf2cab5d 🐛(front) optimize chat
Simplified chat rendering
2025-12-19 17:12:53 +01:00
18 changed files with 570 additions and 547 deletions
+5 -10
View File
@@ -14,7 +14,8 @@ and this project adheres to
### Fixed
- 🐛(e2e) fix test-e2e-chronium
- 🐛(e2e) fix test-e2e-chromium
- 🐛(back) fix system prompt compatibility with self-hosted models #200
## [0.0.10] - 2025-12-15
@@ -34,6 +35,7 @@ and this project adheres to
## [0.0.9] - 2025-11-17
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
@@ -41,7 +43,6 @@ and this project adheres to
- 🔊(langfuse) enable tracing with redacted content #162
## [0.0.8] - 2025-11-10
### Fixed
@@ -56,28 +57,24 @@ and this project adheres to
- 🔥(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
@@ -94,14 +91,12 @@ and this project adheres to
- 🐛(front) fix mobile source
- 🐛(attachments) reject the whole drag&drop if unsupported formats #123
## [0.0.3] - 2025-10-21
### Fixed
- 🚑️(web-search) fix missing argument in RAG backend #116
## [0.0.2] - 2025-10-21
### Added
@@ -111,6 +106,7 @@ and this project adheres to
- 📈(posthog) add `sub` field to tracking #95
### Changed
- 🔧(front) change links feedback tchap + settings popup
- 🐛(front) code activation fix session end #93
- 💬(wording) error page wording #102
@@ -118,7 +114,6 @@ and this project adheres to
- 🐛(activation-codes) create contact in brevo before add to list #108
- ⚗️(summarization) add system prompt to handle tool #112
## [0.0.1] - 2025-10-19
### Changed
@@ -141,7 +136,7 @@ and this project adheres to
- 🎨(front) change list attachment in chat
- 🎨(front) move emplacement for attachment
- 🎨(ui) retour ui sources files
- ✨(ui) fix retour global ui
- ✨(ui) fix retour global ui
- 🐛(fix) broken staging css
- 🎨(alpha) adjustment for alpha version
- ✨(ui) delete flex message
+19 -7
View File
@@ -153,9 +153,16 @@ class BaseAgent(Agent):
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"}
def __init__(self, *, model_hrid, use_configuration_system_prompt: bool = True, **kwargs):
"""Initialize the agent with model configuration from settings.
Args:
model_hrid: HRID of the model to use, as defined in LLM_CONFIGURATIONS.
use_configuration_system_prompt: If False, the system prompt defined in the
LLM configuration will be ignored, allowing subclasses to define all
instructions dynamically.
"""
_ignored_kwargs = {"model", "system_prompt", "tools", "toolsets", "instructions"}
if set(kwargs).intersection(_ignored_kwargs):
raise ValueError(f"{_ignored_kwargs} arguments must not be provided.")
@@ -174,7 +181,7 @@ class BaseAgent(Agent):
# and pydantic_ai.models.infer_model()
_model_instance = self.configuration.model_name
_system_prompt = self.configuration.system_prompt
_system_prompt = self.configuration.system_prompt if use_configuration_system_prompt else None
_base_toolset = (
[
FunctionToolset(
@@ -190,6 +197,11 @@ class BaseAgent(Agent):
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
super().__init__(
model=_model_instance, system_prompt=_system_prompt, tools=_tools, **kwargs
)
agent_kwargs = {
"model": _model_instance,
"tools": _tools,
}
if _system_prompt is not None:
agent_kwargs["instructions"] = _system_prompt
super().__init__(**agent_kwargs, **kwargs)
+19 -6
View File
@@ -11,12 +11,12 @@ from pydantic_ai import ModelMessage
from pydantic_ai.models.function import AgentInfo, FunctionModel
from core.enums import get_language_name
from chat.prompts import get_prompt
from .base import BaseAgent
logger = logging.getLogger(__name__)
MOCKED_RESPONSE = """
# **Ode to the AI Assistant** 🤖✨
@@ -96,16 +96,29 @@ class ConversationAgent(BaseAgent):
def __init__(self, *, language=None, **kwargs):
"""Initialize the conversation agent."""
super().__init__(**kwargs)
# Ignore the static system_prompt from configuration; rely entirely on
# dynamically registered instructions, including the external prompt.
super().__init__(use_configuration_system_prompt=False, **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.system_prompt
@self.instructions
def assistant_system_prompt() -> str:
"""
Main system prompt for the assistant, loaded from the synced Prompt store.
This uses the `assistant_system` prompt from the external repository if
available, with a fallback to settings.AI_AGENT_INSTRUCTIONS.
"""
return get_prompt("assistant_system")
@self.instructions
def add_the_date() -> str:
"""
Dynamic system prompt function to add the current date.
Dynamic instruction function to add the current date.
Warning: this will always use the date in the server timezone,
not the user's timezone...
@@ -113,9 +126,9 @@ class ConversationAgent(BaseAgent):
_formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
return f"Today is {_formatted_date}."
@self.system_prompt
@self.instructions
def enforce_response_language() -> str:
"""Dynamic system prompt function to set the expected language to use."""
"""Dynamic instruction function to set the expected language to use."""
return f"Answer in {get_language_name(language).lower()}." if language else ""
def get_web_search_tool_name(self) -> str | None:
+7 -5
View File
@@ -78,6 +78,9 @@ from chat.tools.document_summarize import document_summarize
from chat.vercel_ai_sdk.core import events_v4, events_v5
from chat.vercel_ai_sdk.encoder import EventEncoder
# Keep at the top of the file to avoid mocking issues
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -236,6 +239,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
Parse and store input documents in the conversation's document store.
"""
# Early external document URL rejection
if any(
not document.url.startswith("/media-key/")
for document in documents
@@ -249,8 +253,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
):
raise ValueError("Document URL does not belong to the conversation.")
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
document_store = document_store_backend(self.conversation.collection_id)
if not document_store.collection_id:
# Create a new collection for the conversation
@@ -420,6 +422,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
],
},
)
try:
await self.parse_input_documents(input_documents)
except Exception as exc: # pylint: disable=broad-except
@@ -457,7 +460,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
if force_web_search:
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def force_web_search_prompt() -> str:
"""Dynamic system prompt function to force web search."""
return (
@@ -505,7 +508,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
)
# Inform the model (system-level) that documents are attached and available
@self.conversation_agent.system_prompt
@self.conversation_agent.instructions
def attached_documents_note() -> str:
return (
"[Internal context] User documents are attached to this conversation. "
@@ -731,7 +734,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
)
# Vercel finish message
yield events_v4.FinishMessagePart(
finish_reason=events_v4.FinishReason.STOP,
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-07 10:20
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0004_chatconversationattachment_and_more'),
]
operations = [
migrations.CreateModel(
name='Prompt',
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')),
('name', models.CharField(max_length=100, unique=True)),
('version', models.CharField(help_text='Git commit SHA from which this prompt was synced.', max_length=40)),
('content', models.TextField(help_text='Jinja template content of the prompt.')),
('metadata', models.JSONField(blank=True, help_text='Optional JSON metadata loaded from the sidecar .meta.json file.', null=True)),
],
options={
'abstract': False,
},
),
]
+24
View File
@@ -131,3 +131,27 @@ class ChatConversationAttachment(BaseModel):
null=True,
help_text="Original file key if the Markdown from another file",
)
class Prompt(BaseModel):
"""
Model storing LLM prompts synced from an external repository.
Each prompt is identified by a unique `name` (e.g. "assistant_system") and
optionally linked to metadata coming from a sidecar JSON file.
"""
name = models.CharField(max_length=100, unique=True)
version = models.CharField(
max_length=40,
help_text="Git commit SHA from which this prompt was synced.",
)
content = models.TextField(help_text="Jinja template content of the prompt.")
metadata = models.JSONField(
null=True,
blank=True,
help_text="Optional JSON metadata loaded from the sidecar .meta.json file.",
)
def __str__(self) -> str:
return f"{self.name}@{self.version}"
+97
View File
@@ -0,0 +1,97 @@
"""Synchronization helpers for external prompt repositories."""
from __future__ import annotations
import json
from typing import Iterable
import requests
from django.conf import settings
from chat.models import Prompt
def _raw_url(path: str, commit_sha: str) -> str:
"""
Build a raw.githubusercontent.com URL for a given file at a specific commit.
"""
base_url = getattr(settings, "PROMPT_BASE_URL", "https://raw.githubusercontent.com")
owner = settings.PROMPT_REPO_OWNER
repo = settings.PROMPT_REPO_NAME
return f"{base_url}/{owner}/{repo}/{commit_sha}/{path}"
def _fetch_text_file(path: str, commit_sha: str) -> str | None:
"""
Fetch a text file from the prompts repository at the given commit.
"""
url = _raw_url(path, commit_sha)
resp = requests.get(url, timeout=5)
if resp.status_code == 200:
return resp.text
return None
def handle_prompt_sync(payload: dict) -> None:
"""
Synchronize prompts from the external repository based on a GitHub push payload.
Strategy V1: full sync for a static list of prompt names.
This is simple and robust, and prompt files are typically small.
"""
commit_sha = payload.get("after")
if not commit_sha:
# Nothing to do if we don't know which commit to sync from
return
# For now, we rely on a static list of prompt names from settings.
# Example: PROMPT_NAMES = ["assistant_system", "summarizer"]
prompt_names: Iterable[str] = getattr(
settings,
"PROMPT_NAMES",
("assistant_system",),
)
for name in prompt_names:
# Try prompts/ subdirectory first, then root as fallback
jinja_paths = [f"prompts/{name}.jinja", f"{name}.jinja"]
meta_paths = [f"prompts/{name}.meta.json", f"{name}.meta.json"]
content = None
for jinja_path in jinja_paths:
content = _fetch_text_file(jinja_path, commit_sha)
if content is not None:
break
if content is None:
# Prompt may have been removed or file missing; we simply skip it for now.
continue
metadata = None
for meta_path in meta_paths:
metadata_text = _fetch_text_file(meta_path, commit_sha)
if metadata_text:
break
else:
metadata_text = None
if metadata_text:
try:
metadata = json.loads(metadata_text)
except json.JSONDecodeError:
# Ignore invalid metadata for robustness
metadata = None
Prompt.objects.update_or_create(
name=name,
defaults={
"version": commit_sha,
"content": content,
"metadata": metadata,
},
)
+32
View File
@@ -0,0 +1,32 @@
"""Runtime access helpers for synced prompts."""
from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from chat.models import Prompt
def get_prompt(name: str) -> str:
"""
Return the content of a prompt by name.
Fallback strategy:
- if the prompt is not found in DB, return the legacy setting-based prompt
for known names (e.g. assistant_system).
"""
try:
prompt = Prompt.objects.get(name=name)
return prompt.content
except ObjectDoesNotExist:
# Fallbacks to keep backward compatibility and avoid hard failures.
if name == "assistant_system":
return settings.AI_AGENT_INSTRUCTIONS
if name == "summarizer":
return settings.SUMMARIZATION_SYSTEM_PROMPT
raise
@@ -27,9 +27,14 @@ def test_build_pydantic_agent_success_no_tools():
"""Test successful agent creation without tools."""
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ()
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[2].__name__ == "enforce_response_language"
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -37,6 +42,7 @@ def test_build_pydantic_agent_success_no_tools():
assert agent._function_toolset.tools == {}
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_build_pydantic_agent_with_tools(settings):
"""Test successful agent creation with tools."""
settings.AI_AGENT_TOOLS = ["get_current_weather"]
@@ -44,8 +50,14 @@ def test_build_pydantic_agent_with_tools(settings):
agent = ConversationAgent(model_hrid="default-model")
assert isinstance(agent, Agent)
assert agent._system_prompts == ("You are a helpful assistant",)
assert agent._instructions == []
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
assert isinstance(agent.model, OpenAIChatModel)
assert agent.model.model_name == "model-123"
assert str(agent.model.client.base_url) == "https://api.llm.com/v1/"
@@ -56,21 +68,23 @@ def test_build_pydantic_agent_with_tools(settings):
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_add_dynamic_system_prompt():
"""
Ensure add_the_date and enforce_response_language system prompt are registered
Ensure add_the_date and enforce_response_language instructions are registered
and returns proper values.
"""
agent = ConversationAgent(model_hrid="default-model")
assert len(agent._system_prompt_functions) == 2
assert len(agent._system_prompt_functions) == 0
assert agent._system_prompt_functions[0].function.__name__ == "add_the_date"
assert agent._system_prompt_functions[0].function() == "Today is Friday 25/07/2025."
assert agent._system_prompt_functions[1].function.__name__ == "enforce_response_language"
assert agent._system_prompt_functions[1].function() == ""
instructions = agent._instructions
assert len(instructions) == 3
assert instructions[0] == "You are a helpful assistant"
assert instructions[1].__name__ == "add_the_date"
assert instructions[1]() == "Today is Friday 25/07/2025."
assert instructions[2].__name__ == "enforce_response_language"
assert instructions[2]() == ""
agent = ConversationAgent(model_hrid="default-model", language="fr-fr")
assert agent._system_prompt_functions[1].function() == "Answer in french."
assert agent._instructions[2]() == "Answer in french."
def test_agent_get_web_search_tool_name(settings):
@@ -130,6 +130,16 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
{
@@ -170,29 +180,15 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -255,6 +251,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
assert response_content == "Hello there"
assert mock_openai_stream.called
# ensure instructions are merged as a system prompt
last_request_payload = json.loads(respx.calls.last.request.content)
assert last_request_payload["messages"][0] == {
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n"
"Answer in english."
),
"role": "system",
}
chat_conversation.refresh_from_db()
assert chat_conversation.ui_messages == [
@@ -296,29 +301,15 @@ def test_post_conversation_text_protocol(api_client, mock_openai_stream):
)
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -409,11 +400,12 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
# Check the exact structure expected by the AI service
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025."
"\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{
"content": [
{"text": "Hello, what do you see on this picture?", "type": "text"},
@@ -498,27 +490,12 @@ def test_post_conversation_with_image(api_client, mock_openai_stream_image):
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": [
"Hello, what do you see on this picture?",
@@ -616,11 +593,12 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in english.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -678,27 +656,12 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Weather in Paris?"],
"part_kind": "user-prompt",
@@ -737,7 +700,10 @@ def test_post_conversation_tool_call(api_client, mock_openai_stream_tool, settin
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
@@ -829,11 +795,12 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
assert body["messages"] == [
{
"content": "You are a helpful test assistant :)",
"content": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"role": "system",
},
{"content": "Today is Friday 25/07/2025.", "role": "system"},
{"content": "Answer in french.", "role": "system"},
{"content": [{"text": "Weather in Paris?", "type": "text"}], "role": "user"},
]
@@ -891,27 +858,12 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in french.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Weather in Paris?"],
"part_kind": "user-prompt",
@@ -950,7 +902,10 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool,
"run_id": _run_id,
},
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in french."
),
"kind": "request",
"parts": [
{
@@ -1214,27 +1169,11 @@ def test_post_conversation_data_protocol_no_stream(
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are an amazing assistant.\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are an amazing assistant.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Why the sky is blue?"],
"part_kind": "user-prompt",
@@ -1369,27 +1308,12 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": (
"You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\nAnswer in english."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Today is Friday 25/07/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-07-25T10:36:35.297675Z",
},
{
"content": ["Hello"],
"part_kind": "user-prompt",
@@ -216,7 +216,8 @@ def fixture_mock_openai_stream():
@responses.activate
@respx.mock
@freeze_time()
def test_post_conversation_with_document_upload( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
mock_albert_api, # pylint: disable=unused-argument
sample_pdf_content,
@@ -353,53 +354,25 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
assert len(chat_conversation.pydantic_messages) == 4
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"modification, paraphrasing, or additional summarization.The "
"tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if "
"required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.",
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result from the "
"summarization tool, you MUST return it directly to the user without "
"any modification, paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if required, "
"but you MUST preserve all the information from the original summary."
"You may add a follow-up question after the summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["What does the document say?"],
"part_kind": "user-prompt",
@@ -439,14 +412,21 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result from the "
"summarization tool, you MUST return it directly to the user without "
"any modification, paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if required, "
"but you MUST preserve all the information from the original summary."
"You may add a follow-up question after the summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
@@ -499,7 +479,8 @@ def test_post_conversation_with_document_upload( # pylint: disable=too-many-arg
@responses.activate
@respx.mock
@freeze_time("2025-07-25T10:36:35.297675Z")
def test_post_conversation_with_document_upload_feature_disabled( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_document_upload_feature_disabled(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
caplog,
mock_openai_stream, # pylint: disable=unused-argument
@@ -552,14 +533,12 @@ def test_post_conversation_with_document_upload_feature_disabled( # pylint: dis
# Replace UUIDs with placeholders for assertion
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"From the document, I can see that "\n'
"0:\"it says 'Hello PDF'.\"\n"
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":150,"completionTokens":25}}\n'
)
# This behavior must be improved in the future to inform the user properly
assert "Document upload feature is disabled, ignoring input documents." in caplog.text
@@ -582,6 +561,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
api_client.force_authenticate(user=chat_conversation.owner)
pdf_base64 = base64.b64encode(sample_pdf_content.read()).decode("utf-8")
message = UIMessage(
id="1",
role="user",
@@ -643,7 +623,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
'document discusses various topics."}\n'
'0:"The document discusses various topics."\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":317,"completionTokens":19}}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":287,"completionTokens":19}}\n'
)
# Check that the conversation was updated
@@ -705,52 +685,25 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages[0] == {
"instructions": "When you receive a result from the summarization tool, you "
"MUST return it directly to the user without any "
"modification, paraphrasing, or additional summarization.The "
"tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if "
"required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.",
"instructions": (
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result from the "
"summarization tool, you MUST return it directly to the user without "
"any modification, paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if required, "
"but you MUST preserve all the information from the original summary."
"You may add a follow-up question after the summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": "[Internal context] User documents are attached to this "
"conversation. Do not request re-upload of documents; "
"consider them already available via the internal "
"store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timezone_now,
},
{
"content": ["Make a summary of this document."],
"part_kind": "user-prompt",
@@ -790,14 +743,21 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
}
assert chat_conversation.pydantic_messages[2] == {
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result from the "
"summarization tool, you MUST return it directly to the user without "
"any modification, paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should be "
"presented verbatim.You may translate the summary if required, "
"but you MUST preserve all the information from the original summary."
"You may add a follow-up question after the summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
"kind": "request",
"parts": [
@@ -17,7 +17,6 @@ from pydantic_ai.messages import (
DocumentUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -61,7 +60,8 @@ def fixture_sample_document_content():
@responses.activate
@freeze_time()
def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
sample_document_content,
today_promt_date,
@@ -120,7 +120,7 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -129,11 +129,6 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this document?",
@@ -146,6 +141,8 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
timestamp=timezone.now(),
),
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -221,27 +218,11 @@ def test_post_conversation_with_local_pdf_document_url( # pylint: disable=too-m
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -429,7 +410,6 @@ def test_post_conversation_with_remote_document_url(
@freeze_time("2025-10-18T20:48:20.286204Z")
def test_post_conversation_with_local_document_url_in_history( # pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_ai_agent_service,
):
"""
@@ -437,6 +417,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
"""
chat_conversation_pk = "0be55da5-8eb7-4dad-aa0f-fea454bd5809"
document_url = f"/media-key/{chat_conversation_pk}/sample.pdf"
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
chat_conversation = ChatConversationFactory(
pk=chat_conversation_pk,
owner__language="en-us",
@@ -472,27 +454,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
],
pydantic_messages=[
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
f"Today is {formatted_date}.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -555,7 +521,7 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -564,18 +530,6 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -588,6 +542,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[0].run_id,
),
ModelResponse(
@@ -606,6 +563,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
timestamp=timezone.now(),
)
],
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
run_id=messages[2].run_id,
),
]
@@ -705,27 +665,11 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this document?",
@@ -772,7 +716,9 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
# no run_id here
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\n"
"Answer in english.",
"kind": "request",
"parts": [
{
@@ -823,7 +769,8 @@ def test_post_conversation_with_local_document_url_in_history( # pylint: disabl
("data.csv", "text/csv"),
],
)
def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_post_conversation_with_local_not_pdf_document_url(
# pylint: disable=too-many-arguments,too-many-positional-arguments
api_client,
today_promt_date,
mock_ai_agent_service,
@@ -886,27 +833,6 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
SystemPromptPart(
content=(
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead."
),
timestamp=timezone.now(),
),
SystemPromptPart(
content=(
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; consider them already "
"available via the internal store."
),
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this document?",
@@ -916,14 +842,22 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
),
],
instructions=(
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result "
"from the summarization tool, you MUST return it directly to "
"the user without any modification, paraphrasing, or additional "
"summarization.The tool already produces optimized summaries "
"that should be presented verbatim.You may translate the summary "
"if required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; "
"consider them already available via the internal store."
),
run_id=messages[0].run_id,
)
@@ -999,53 +933,25 @@ def test_post_conversation_with_local_not_pdf_document_url( # pylint: disable=t
assert chat_conversation.pydantic_messages == [
{
"instructions": (
"When you receive a result from the summarization tool, you MUST "
"return it directly to the user without any modification, "
"paraphrasing, or additional summarization."
"The tool already produces optimized summaries that should "
"be presented verbatim."
"You may translate the summary if required, but you MUST preserve "
"all the information from the original summary."
"You may add a follow-up question after the summary if needed."
"You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\n"
"Answer in english.\n\n"
"Use document_search_rag ONLY to retrieve specific passages from "
"attached documents. Do NOT use it to summarize; for summaries, "
"call the summarize tool instead.\n\nWhen you receive a result "
"from the summarization tool, you MUST return it directly to "
"the user without any modification, paraphrasing, or additional "
"summarization.The tool already produces optimized summaries "
"that should be presented verbatim.You may translate the summary "
"if required, but you MUST preserve all the information from the "
"original summary.You may add a follow-up question after the "
"summary if needed.\n\n"
"[Internal context] User documents are attached to this conversation. "
"Do not request re-upload of documents; "
"consider them already available via the internal store."
),
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "Use document_search_rag ONLY to retrieve specific "
"passages from attached documents. Do NOT use it to "
"summarize; for summaries, call the summarize tool "
"instead.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": "[Internal context] User documents are attached to "
"this conversation. Do not request re-upload of "
"documents; consider them already available via the "
"internal store.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": timestamp,
},
{
"content": [
"What is in this document?",
@@ -919,7 +919,7 @@ def history_conversation_with_tool_fixture():
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
# Create a conversation with pre-existing messages including a tool invocation
conversation = ChatConversationFactory()
conversation = ChatConversationFactory(owner__language="nl-nl")
# Add previous user and assistant messages with tool invocation
conversation.messages = [
@@ -1377,7 +1377,9 @@ def test_post_conversation_with_existing_tool_history(
# Verify the new tool call request is included
assert history_conversation_with_tool.pydantic_messages[8] == {
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -1420,7 +1422,9 @@ def test_post_conversation_with_existing_tool_history(
}
assert history_conversation_with_tool.pydantic_messages[10] == {
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Friday 25/07/2025.\n\n"
"Answer in dutch.",
"kind": "request",
"parts": [
{
@@ -2,7 +2,7 @@
import uuid
from django.utils import timezone
from django.utils import formats, timezone
import pytest
from dirty_equals import IsUUID
@@ -12,7 +12,6 @@ from pydantic_ai.messages import (
ImageUrl,
ModelMessage,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
@@ -87,22 +86,15 @@ def test_post_conversation_with_local_image_url(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
presigned_url = messages[0].parts[0].content[1].url
# assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
assert presigned_url.find("X-Amz-Expires=") != -1
formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False)
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(
content="Today is Saturday 18/10/2025.", timestamp=timezone.now()
),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -115,6 +107,8 @@ def test_post_conversation_with_local_image_url(
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\nToday is "
f"{formatted_date}.\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -184,27 +178,10 @@ def test_post_conversation_with_local_image_url(
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Today is Saturday 18/10/2025.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -286,11 +263,6 @@ def test_post_conversation_with_local_image_wrong_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -303,6 +275,8 @@ def test_post_conversation_with_local_image_wrong_url(
timestamp=timezone.now(),
),
],
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -374,11 +348,6 @@ def test_post_conversation_with_remote_image_url(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)", timestamp=timezone.now()
),
SystemPromptPart(content=today_promt_date, timestamp=timezone.now()),
SystemPromptPart(content="Answer in english.", timestamp=timezone.now()),
UserPromptPart(
content=[
"What is in this image?",
@@ -391,6 +360,8 @@ def test_post_conversation_with_remote_image_url(
timestamp=timezone.now(),
),
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
run_id=messages[0].run_id,
)
]
@@ -504,27 +475,10 @@ def test_post_conversation_with_local_image_url_in_history(
],
pydantic_messages=[
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -587,7 +541,7 @@ def test_post_conversation_with_local_image_url_in_history(
)
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
presigned_url = messages[0].parts[3].content[1].url
presigned_url = messages[0].parts[0].content[1].url
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
assert presigned_url.find("X-Amz-Signature=") != -1
assert presigned_url.find("X-Amz-Date=") != -1
@@ -596,18 +550,6 @@ def test_post_conversation_with_local_image_url_in_history(
assert messages == [
ModelRequest(
parts=[
SystemPromptPart(
content="You are a helpful test assistant :)",
timestamp=timezone.now(),
),
SystemPromptPart(
content=today_promt_date,
timestamp=timezone.now(),
),
SystemPromptPart(
content="Answer in english.",
timestamp=timezone.now(),
),
UserPromptPart(
content=[
"What is in this image?",
@@ -619,7 +561,9 @@ def test_post_conversation_with_local_image_url_in_history(
],
timestamp=timezone.now(),
),
]
],
instructions="You are a helpful test assistant :)\n\n"
f"{today_promt_date}\n\nAnswer in english.",
),
ModelResponse(
parts=[TextPart(content="This is an image of a single pixel.")],
@@ -637,6 +581,8 @@ def test_post_conversation_with_local_image_url_in_history(
)
],
run_id=messages[2].run_id,
instructions="You are a helpful test assistant :)\n\n"
"Today is Saturday 18/10/2025.\n\nAnswer in english.",
),
]
yield "This is an image of square, very small and nice."
@@ -735,27 +681,10 @@ def test_post_conversation_with_local_image_url_in_history(
_run_id = chat_conversation.pydantic_messages[2]["run_id"]
assert chat_conversation.pydantic_messages == [
{
"instructions": None,
"instructions": f"You are a helpful test assistant :)\n\n{today_promt_date}"
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
"content": "You are a helpful test assistant :)",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": today_promt_date,
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": "Answer in english.",
"dynamic_ref": None,
"part_kind": "system-prompt",
"timestamp": "2025-10-18T20:48:20.286204Z",
},
{
"content": [
"What is in this image?",
@@ -796,7 +725,8 @@ def test_post_conversation_with_local_image_url_in_history(
},
},
{
"instructions": None,
"instructions": "You are a helpful test assistant :)\n\nToday is Saturday 18/10/2025."
"\n\nAnswer in english.",
"kind": "request",
"parts": [
{
@@ -39,7 +39,7 @@ def add_document_rag_search_tool(agent: Agent) -> None:
metadata={"sources": {result.url for result in rag_results.data}},
)
@agent.system_prompt
@agent.instructions
def document_rag_instructions() -> str:
"""Dynamic system prompt function to add RAG instructions if any."""
return (
+40 -1
View File
@@ -6,7 +6,8 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.http import Http404, StreamingHttpResponse
from django.http import Http404, HttpResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
import langfuse
import magic
@@ -26,6 +27,7 @@ from core.filters import remove_accents
from activation_codes.permissions import IsActivatedUser
from chat import models, serializers
from chat.clients.pydantic_ai import AIAgentService
from chat.prompt_sync import handle_prompt_sync
from chat.serializers import ChatConversationRequestSerializer
logger = logging.getLogger(__name__)
@@ -435,3 +437,40 @@ class ChatConversationAttachmentViewSet(
)
return Response(serializer.data, status=status.HTTP_200_OK)
@csrf_exempt
def github_prompt_webhook(request):
"""
GitHub webhook endpoint to synchronize assistant prompts.
This endpoint expects a GitHub push payload and validates the request
using the X-Hub-Signature-256 header and a shared secret.
"""
signature = request.headers.get("X-Hub-Signature-256")
secret = getattr(settings, "PROMPT_WEBHOOK_SECRET", None)
if not secret or not signature:
return HttpResponse(status=403)
import hashlib
import hmac
import json as _json
mac = hmac.new(
secret.encode(),
msg=request.body,
digestmod=hashlib.sha256,
)
expected = "sha256=" + mac.hexdigest()
if not hmac.compare_digest(expected, signature):
return HttpResponse(status=403)
try:
payload = _json.loads(request.body)
except _json.JSONDecodeError:
return HttpResponse(status=400)
handle_prompt_sync(payload)
return HttpResponse(status=200)
+32
View File
@@ -663,6 +663,38 @@ class Base(BraveSettings, Configuration):
environ_prefix=None,
)
# External prompt repository (for agent system prompts, etc.)
PROMPT_REPO_OWNER = values.Value(
None,
environ_name="PROMPT_REPO_OWNER",
environ_prefix=None,
)
PROMPT_REPO_NAME = values.Value(
None,
environ_name="PROMPT_REPO_NAME",
environ_prefix=None,
)
PROMPT_REPO_BRANCH = values.Value(
"main",
environ_name="PROMPT_REPO_BRANCH",
environ_prefix=None,
)
PROMPT_WEBHOOK_SECRET = values.Value(
None,
environ_name="PROMPT_WEBHOOK_SECRET",
environ_prefix=None,
)
PROMPT_BASE_URL = values.Value(
"https://raw.githubusercontent.com",
environ_name="PROMPT_BASE_URL",
environ_prefix=None,
)
PROMPT_NAMES = values.ListValue(
default=["assistant_system"],
environ_name="PROMPT_NAMES",
environ_prefix=None,
)
# Tools
AI_AGENT_TOOLS = values.ListValue(
default=[],
+11 -1
View File
@@ -9,7 +9,12 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from activation_codes import viewsets as activation_viewsets
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
from chat.views import (
ChatConversationAttachmentViewSet,
ChatViewSet,
LLMConfigurationView,
github_prompt_webhook,
)
# - Main endpoints
router = DefaultRouter()
@@ -40,4 +45,9 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
path(
"webhooks/github/prompts",
github_prompt_webhook,
name="github-prompts-webhook",
),
]