Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4d26517b |
@@ -13,14 +13,6 @@ and this project adheres to
|
||||
- ✨(back) add projects with custom LLM instructions
|
||||
- ✨(front) projects management UI
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(back) add missing color option for project colors
|
||||
|
||||
## [0.0.14] - 2026-03-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -117,14 +117,20 @@ class ConversationAgent(BaseAgent):
|
||||
"""Dynamic instruction function to set the expected language to use."""
|
||||
return f"Answer in {get_language_name(language).lower()}." if language else ""
|
||||
|
||||
def is_web_search_configured(self) -> bool:
|
||||
def get_web_search_tool_name(self) -> str | None:
|
||||
"""
|
||||
Return True when a web search backend is configured on this model.
|
||||
Get the name of the web search tool if available.
|
||||
|
||||
This does not mean web search is enabled for the current conversation
|
||||
(feature flags and runtime deps still apply).
|
||||
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.
|
||||
"""
|
||||
return bool(getattr(self.configuration, "web_search", None))
|
||||
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)
|
||||
|
||||
@@ -251,7 +251,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
session=session,
|
||||
web_search_enabled=self._is_web_search_enabled and self._is_smart_search_enabled,
|
||||
)
|
||||
self._web_search_tool_registered = False
|
||||
|
||||
self.conversation_agent = ConversationAgent(
|
||||
model_hrid=self.model_hrid,
|
||||
@@ -441,7 +440,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
logger.warning("Web search is forced but the feature is disabled, ignoring.")
|
||||
return False
|
||||
|
||||
if not self.conversation_agent.is_web_search_configured():
|
||||
web_search_tool_name = self.conversation_agent.get_web_search_tool_name()
|
||||
if not web_search_tool_name:
|
||||
logger.warning("Web search is forced but no web search tool is available, ignoring.")
|
||||
return False
|
||||
|
||||
@@ -450,7 +450,9 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
@self.conversation_agent.instructions
|
||||
def force_web_search_prompt() -> str:
|
||||
"""Dynamic system prompt function to force web search."""
|
||||
return "You must call the web_search tool before answering the user request."
|
||||
return (
|
||||
f"You must call the {web_search_tool_name} tool before answering the user request."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -697,33 +699,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
"""Wrap the document_summarize tool to provide context and add the tool."""
|
||||
return await document_summarize(ctx, *args, **kwargs)
|
||||
|
||||
def _setup_web_search_tool(self) -> None:
|
||||
"""Register model-specific web search tool when configured."""
|
||||
if self._web_search_tool_registered:
|
||||
return
|
||||
configuration = self.conversation_agent.configuration
|
||||
if not getattr(configuration, "web_search", None):
|
||||
return
|
||||
|
||||
async def only_if_web_search_enabled(ctx, tool_def):
|
||||
"""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
|
||||
|
||||
web_search_impl = import_string(configuration.web_search)
|
||||
|
||||
@self.conversation_agent.tool(
|
||||
name="web_search",
|
||||
retries=1,
|
||||
prepare=only_if_web_search_enabled,
|
||||
description="Search the web for up-to-date information",
|
||||
)
|
||||
@functools.wraps(web_search_impl)
|
||||
async def web_search(ctx: RunContext, *args, **kwargs) -> ToolReturn:
|
||||
"""Wrap the web_search tool to provide context and add the tool."""
|
||||
return await web_search_impl(ctx, *args, **kwargs)
|
||||
|
||||
self._web_search_tool_registered = True
|
||||
|
||||
async def _handle_input_documents(
|
||||
self,
|
||||
input_documents: List[BinaryContent | DocumentUrl],
|
||||
@@ -1041,7 +1016,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
conversation_has_documents = doc_result.has_documents
|
||||
|
||||
await self._agent_stop_streaming(force_cache_check=True)
|
||||
self._setup_web_search_tool()
|
||||
self._setup_web_search(force_web_search)
|
||||
|
||||
if await self._check_should_enable_rag(conversation_has_documents):
|
||||
|
||||
@@ -125,7 +125,6 @@ class LLModel(BaseModel):
|
||||
supports_streaming: bool | None = None
|
||||
system_prompt: SettingEnvValue
|
||||
tools: list[str]
|
||||
web_search: SettingEnvValue | None = None
|
||||
|
||||
@field_validator("tools", mode="before")
|
||||
@classmethod
|
||||
@@ -135,14 +134,6 @@ class LLModel(BaseModel):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@field_validator("web_search", mode="before")
|
||||
@classmethod
|
||||
def validate_web_search(cls, value: str | None) -> str | None:
|
||||
"""Convert web_search path if it's a setting or environment variable."""
|
||||
if isinstance(value, str):
|
||||
return _get_setting_or_env_or_value(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_provider_or_provider_name(self) -> Self:
|
||||
"""
|
||||
|
||||
@@ -87,7 +87,6 @@ class Migration(migrations.Migration):
|
||||
("color_7", "Color 7"),
|
||||
("color_8", "Color 8"),
|
||||
("color_9", "Color 9"),
|
||||
("color_10", "Color 10"),
|
||||
],
|
||||
help_text="Project icon color",
|
||||
max_length=20,
|
||||
|
||||
@@ -52,7 +52,6 @@ class ChatProjectColor(models.TextChoices):
|
||||
COLOR_7 = "color_7", "Color 7"
|
||||
COLOR_8 = "color_8", "Color 8"
|
||||
COLOR_9 = "color_9", "Color 9"
|
||||
COLOR_10 = "color_10", "Color 10"
|
||||
|
||||
|
||||
class ChatProject(BaseModel):
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
# pylint:disable=protected-access
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from freezegun import freeze_time
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.models.test import TestModel
|
||||
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.llm_configuration import LLModel, LLMProvider
|
||||
from chat.clients.pydantic_ai import ContextDeps
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -85,30 +87,47 @@ def test_add_dynamic_system_prompt():
|
||||
assert agent._instructions[2]() == "Answer in french."
|
||||
|
||||
|
||||
def test_agent_is_web_search_configured():
|
||||
"""Test whether web search backend is configured on the model."""
|
||||
def test_agent_get_web_search_tool_name(settings):
|
||||
"""Test the web_search_available method."""
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_albert_rag"]
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.is_web_search_configured() is False
|
||||
assert agent.get_web_search_tool_name() == "web_search_albert_rag"
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather"]
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() is None
|
||||
|
||||
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_tavily", "web_search_albert_rag"]
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.get_web_search_tool_name() == "web_search_tavily"
|
||||
|
||||
|
||||
def test_agent_is_web_search_configured_when_defined_in_model_config(settings):
|
||||
"""Web search is configured when LLModel.web_search is set."""
|
||||
settings.LLM_CONFIGURATIONS = {
|
||||
"default-model": LLModel(
|
||||
hrid="default-model",
|
||||
model_name="model-123",
|
||||
human_readable_name="Default Model",
|
||||
is_active=True,
|
||||
icon=None,
|
||||
system_prompt="You are a helpful assistant",
|
||||
tools=[],
|
||||
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
provider=LLMProvider(
|
||||
hrid="default-provider",
|
||||
base_url="https://api.llm.com/v1/",
|
||||
api_key="test-key",
|
||||
),
|
||||
),
|
||||
}
|
||||
@responses.activate
|
||||
def test_web_search_tool_avalability(settings):
|
||||
"""Test the web search tool availability according to context."""
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://api.tavily.com/search",
|
||||
json={"results": []},
|
||||
status=200,
|
||||
)
|
||||
context_deps = ContextDeps(conversation=None, user=None, web_search_enabled=True)
|
||||
|
||||
# No tools (context allows web search, but no tool configured)
|
||||
agent = ConversationAgent(model_hrid="default-model")
|
||||
assert agent.is_web_search_configured() is True
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
# Tool configured, context allows web search
|
||||
settings.AI_AGENT_TOOLS = ["web_search_tavily"]
|
||||
agent = ConversationAgent(model_hrid="default-model") # re-init to pick up new settings
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == '{"web_search_tavily":[]}'
|
||||
|
||||
# Tool configured, context disables web search
|
||||
context_deps.web_search_enabled = False
|
||||
with agent.override(model=TestModel(), deps=context_deps):
|
||||
response = agent.run_sync("What tools do you have?")
|
||||
assert response.output == "success (no tool calls)"
|
||||
|
||||
@@ -23,8 +23,7 @@ def _llm_config_with_websearch(settings):
|
||||
is_active=True,
|
||||
icon=None,
|
||||
system_prompt="You are an amazing assistant.",
|
||||
tools=[],
|
||||
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
|
||||
tools=["web_search_brave_with_document_backend"],
|
||||
provider=LLMProvider(
|
||||
hrid="unused",
|
||||
base_url="https://example.com",
|
||||
@@ -49,7 +48,6 @@ def test_smart_search_disabled_suppresses_tool_at_runtime(_llm_config_with_webse
|
||||
if not service._is_smart_search_enabled and service._is_web_search_enabled:
|
||||
service._context_deps.web_search_enabled = False
|
||||
|
||||
service._setup_web_search_tool()
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
@@ -67,11 +65,10 @@ def test_smart_search_enabled_tool_is_called(_llm_config_with_websearch):
|
||||
assert service._is_smart_search_enabled is True
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
|
||||
service._setup_web_search_tool()
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
|
||||
assert "web_search" in response.output
|
||||
assert "web_search_brave_with_document_backend" in response.output
|
||||
|
||||
|
||||
def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websearch):
|
||||
@@ -85,16 +82,14 @@ def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websea
|
||||
assert service._is_smart_search_enabled is False
|
||||
assert service._context_deps.web_search_enabled is False
|
||||
|
||||
# Match _run_agent: register the tool first, then enable deps + forced prompt.
|
||||
service._setup_web_search_tool()
|
||||
service._setup_web_search(force_web_search=True)
|
||||
|
||||
assert service.conversation_agent.is_web_search_configured() is True
|
||||
web_search_tool_name = service.conversation_agent.get_web_search_tool_name()
|
||||
assert service._context_deps.web_search_enabled is True
|
||||
assert any(
|
||||
callable(instr) and "web_search" in instr()
|
||||
callable(instr) and web_search_tool_name in instr()
|
||||
for instr in service.conversation_agent._instructions
|
||||
)
|
||||
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
|
||||
response = service.conversation_agent.run_sync("Search the web for something.")
|
||||
assert "web_search" in response.output
|
||||
assert "web_search_brave_with_document_backend" in response.output
|
||||
|
||||
@@ -21,14 +21,13 @@ from chat.tools.web_search_brave import (
|
||||
_extract_and_summarize_snippets_async,
|
||||
_fetch_and_extract_async,
|
||||
_fetch_and_store_async,
|
||||
_query_brave_llm_context_api_async,
|
||||
_query_brave_api_async,
|
||||
format_tool_return,
|
||||
web_search_brave,
|
||||
web_search_brave_with_document_backend,
|
||||
)
|
||||
|
||||
BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_LLM_CONTEXT_URL = "https://api.search.brave.com/res/v1/llm/context"
|
||||
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -68,7 +67,7 @@ def fixture_mocked_context():
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results with extra_snippets."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -123,7 +122,7 @@ async def test_agent_web_search_brave_success_with_extra_snippets(mocked_context
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_success_without_extra_snippets(mocked_context):
|
||||
"""Test when the Brave search returns results without extra_snippets."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -169,7 +168,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
"""Test when the Brave search returns results without extra_snippets with summarization."""
|
||||
settings.BRAVE_SUMMARIZATION_ENABLED = True
|
||||
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -217,7 +216,7 @@ async def test_agent_web_search_brave_success_without_extra_snippets_summarizati
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
"""Test when the Brave search returns no results."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -234,7 +233,7 @@ async def test_agent_web_search_brave_empty_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_http_error(mocked_context):
|
||||
"""Test handling of HTTP errors from Brave API."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
@@ -257,7 +256,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
settings.BRAVE_SEARCH_COUNTRY = None
|
||||
settings.BRAVE_SEARCH_LANG = None
|
||||
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {"results": []}},
|
||||
@@ -291,7 +290,7 @@ async def test_agent_web_search_brave_params_exclude_none(settings, mocked_conte
|
||||
@respx.mock
|
||||
async def test_agent_web_search_brave_concurrent_processing(mocked_context):
|
||||
"""Test concurrent processing with asyncio.gather."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -402,7 +401,7 @@ async def test_extract_and_summarize_snippets_summarization_failure(settings):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend with successful RAG search."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -453,7 +452,7 @@ async def test_web_search_brave_with_document_backend_success(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_fetch_error(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document fetching fails."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -496,7 +495,7 @@ async def test_web_search_brave_with_document_backend_no_matching_rag_results(mo
|
||||
|
||||
This is actually a problematic scenario, but we want to ensure graceful handling.
|
||||
"""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -566,37 +565,37 @@ async def test_fetch_and_store_empty_document():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_web_key():
|
||||
"""Test _query_brave_llm_context_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
"""Test _query_brave_api_async when response doesn't contain 'web' key."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"error": "no web results"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
result = await _query_brave_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_missing_results_key():
|
||||
"""Test _query_brave_llm_context_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
"""Test _query_brave_api_async when 'web' exists but 'results' is missing."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={"web": {}},
|
||||
)
|
||||
)
|
||||
result = await _query_brave_llm_context_api_async("query")
|
||||
result = await _query_brave_api_async("query")
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_rate_limit():
|
||||
"""Test _query_brave_llm_context_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
"""Test _query_brave_api_async handles 429 rate limit errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=429,
|
||||
json={"error": "Rate limit exceeded"},
|
||||
@@ -604,7 +603,7 @@ async def test_query_brave_api_rate_limit():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
await _query_brave_api_async("query")
|
||||
|
||||
assert "rate limited" in str(exc.value).lower()
|
||||
|
||||
@@ -612,8 +611,8 @@ async def test_query_brave_api_rate_limit():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_client_error():
|
||||
"""Test _query_brave_llm_context_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(
|
||||
"""Test _query_brave_api_async handles 4xx client errors."""
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=400,
|
||||
json={"error": "Bad request"},
|
||||
@@ -621,7 +620,7 @@ async def test_query_brave_api_client_error():
|
||||
)
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
await _query_brave_api_async("query")
|
||||
|
||||
assert "client error" in str(exc.value).lower()
|
||||
|
||||
@@ -629,11 +628,11 @@ async def test_query_brave_api_client_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_timeout():
|
||||
"""Test _query_brave_llm_context_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
"""Test _query_brave_api_async handles timeout errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
await _query_brave_api_async("query")
|
||||
|
||||
assert "timed out" in str(exc.value).lower()
|
||||
|
||||
@@ -641,11 +640,11 @@ async def test_query_brave_api_timeout():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_generic_http_error():
|
||||
"""Test _query_brave_llm_context_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
"""Test _query_brave_api_async handles generic HTTP errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=httpx.ConnectError("Connection failed"))
|
||||
|
||||
with pytest.raises(ModelRetry) as exc:
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
await _query_brave_api_async("query")
|
||||
|
||||
assert "connection error" in str(exc.value).lower()
|
||||
|
||||
@@ -653,11 +652,11 @@ async def test_query_brave_api_generic_http_error():
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_query_brave_api_unexpected_error():
|
||||
"""Test _query_brave_llm_context_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_LLM_CONTEXT_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
"""Test _query_brave_api_async handles unexpected errors."""
|
||||
respx.get(BRAVE_URL).mock(side_effect=ValueError("Unexpected value error"))
|
||||
|
||||
with pytest.raises(ModelCannotRetry) as exc:
|
||||
await _query_brave_llm_context_api_async("query")
|
||||
await _query_brave_api_async("query")
|
||||
|
||||
assert "unexpected error" in str(exc.value).lower()
|
||||
|
||||
@@ -769,7 +768,7 @@ async def test_fetch_and_store_extraction_error():
|
||||
@respx.mock
|
||||
async def test_web_search_brave_mixed_results(mocked_context):
|
||||
"""Test web_search_brave with mixed results (some with snippets, some without)."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -813,7 +812,7 @@ async def test_web_search_brave_mixed_results(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
"""Test web_search_brave when extraction fails for all results without snippets."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -846,7 +845,7 @@ async def test_web_search_brave_extraction_fails_for_all(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
"""Test web_search_brave handling of ModelCannotRetry from API."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=403,
|
||||
json={"error": "Forbidden"},
|
||||
@@ -864,7 +863,7 @@ async def test_web_search_brave_model_cannot_retry_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -895,7 +894,7 @@ async def test_web_search_brave_unexpected_exception(mocked_context):
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when RAG search returns empty results."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -930,7 +929,7 @@ async def test_web_search_brave_with_document_backend_empty_rag_results(mocked_c
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_store_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend when document store raises exception."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
@@ -959,7 +958,7 @@ async def test_web_search_brave_with_document_backend_store_exception(mocked_con
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_unexpected_exception(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of unexpected exceptions."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
respx.get(BRAVE_URL).mock(side_effect=TypeError("Unexpected type error"))
|
||||
|
||||
result = await web_search_brave_with_document_backend(mocked_context, "error query")
|
||||
assert result == (
|
||||
@@ -972,7 +971,7 @@ async def test_web_search_brave_with_document_backend_unexpected_exception(mocke
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend handling of ModelCannotRetry."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=401,
|
||||
json={"error": "Unauthorized"},
|
||||
@@ -990,7 +989,7 @@ async def test_web_search_brave_with_document_backend_model_cannot_retry(mocked_
|
||||
@respx.mock
|
||||
async def test_web_search_brave_with_document_backend_rag_search_params(mocked_context):
|
||||
"""Test web_search_brave_with_document_backend passes correct parameters to RAG search."""
|
||||
respx.get(BRAVE_WEB_SEARCH_URL).mock(
|
||||
respx.get(BRAVE_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
json={
|
||||
|
||||
@@ -78,24 +78,6 @@ def test_create_project_with_llm_instructions(api_client):
|
||||
assert project.llm_instructions == "Always answer in French."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ChatProjectColor)
|
||||
def test_create_project_with_each_color(api_client, color):
|
||||
"""Test creating a project with each available color."""
|
||||
user = UserFactory()
|
||||
url = "/api/v1.0/projects/"
|
||||
data = {
|
||||
"title": "Color Project",
|
||||
"icon": ChatProjectIcon.FOLDER,
|
||||
"color": color,
|
||||
}
|
||||
api_client.force_login(user)
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
project = ChatProject.objects.get(id=response.data["id"])
|
||||
assert project.color == color
|
||||
|
||||
|
||||
def test_create_project_anonymous(api_client):
|
||||
"""Test creating a project as an anonymous user returns a 401 error."""
|
||||
url = "/api/v1.0/projects/"
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
"""Tools for the chat agent."""
|
||||
|
||||
from pydantic_ai import Tool # noqa: I001
|
||||
from pydantic_ai import Tool, ToolDefinition
|
||||
|
||||
from .fake_current_weather import get_current_weather
|
||||
from .web_seach_albert_rag import web_search_albert_rag
|
||||
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
|
||||
from .web_search_tavily import web_search_tavily
|
||||
|
||||
|
||||
async def only_if_web_search_enabled(ctx, tool_def: ToolDefinition) -> ToolDefinition | None:
|
||||
"""Prepare function to include a tool only if web search is enabled in the context."""
|
||||
return tool_def if ctx.deps.web_search_enabled else None
|
||||
|
||||
|
||||
def get_pydantic_tools_by_name(name: str) -> Tool:
|
||||
"""Get a tool by its name."""
|
||||
tool_dict = {
|
||||
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
|
||||
"web_search_brave": Tool(
|
||||
web_search_brave,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_brave_with_document_backend": Tool(
|
||||
web_search_brave_with_document_backend,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_tavily": Tool(
|
||||
web_search_tavily,
|
||||
takes_ctx=False,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
"web_search_albert_rag": Tool(
|
||||
web_search_albert_rag,
|
||||
takes_ctx=True,
|
||||
prepare=only_if_web_search_enabled,
|
||||
max_retries=2,
|
||||
),
|
||||
}
|
||||
|
||||
return tool_dict[name] # will raise on purpose if name is not found
|
||||
|
||||
@@ -142,33 +142,23 @@ async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
|
||||
# Continue with other documents
|
||||
|
||||
|
||||
def _normalize_llm_context_results(json_response: dict) -> List[dict]:
|
||||
"""Normalize Brave LLM context payload into our common result shape."""
|
||||
generic_results = json_response.get("grounding", {}).get("generic", []) or []
|
||||
normalized_results: List[dict] = []
|
||||
for item in generic_results:
|
||||
item_url = item.get("url")
|
||||
if not item_url:
|
||||
continue
|
||||
|
||||
normalized_results.append(
|
||||
{
|
||||
"url": item_url,
|
||||
# Fallback to URL if no title is provided
|
||||
"title": item.get("title") or item_url,
|
||||
# `snippets` is already a list
|
||||
"snippets": item.get("snippets") or [],
|
||||
}
|
||||
)
|
||||
return normalized_results
|
||||
|
||||
|
||||
async def _query_brave_api_with_endpoint_async(url: str, data: dict) -> List[dict]:
|
||||
"""Query a Brave endpoint and return raw results normalized to our schema."""
|
||||
async def _query_brave_api_async(query: str) -> List[dict]:
|
||||
"""Query the Brave Search API and return the raw results."""
|
||||
url = "https://api.search.brave.com/res/v1/web/search"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Subscription-Token": settings.BRAVE_API_KEY,
|
||||
}
|
||||
data = {
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
}
|
||||
params = {k: v for k, v in data.items() if v is not None}
|
||||
|
||||
try:
|
||||
@@ -177,12 +167,6 @@ async def _query_brave_api_with_endpoint_async(url: str, data: dict) -> List[dic
|
||||
response.raise_for_status()
|
||||
json_response = response.json()
|
||||
|
||||
# LLM context API: results are under `grounding.generic`
|
||||
# See: https://api-dashboard.search.brave.com/documentation/services/llm-context
|
||||
if "grounding" in json_response:
|
||||
return _normalize_llm_context_results(json_response)
|
||||
|
||||
# Fallback for classic web search JSON shape
|
||||
# https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
|
||||
return json_response.get("web", {}).get("results", [])
|
||||
|
||||
@@ -225,99 +209,44 @@ async def _query_brave_api_with_endpoint_async(url: str, data: dict) -> List[dic
|
||||
) from e
|
||||
|
||||
|
||||
async def _query_brave_llm_context_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave LLM context endpoint and return normalized results."""
|
||||
logger.debug("Using LLM context endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/llm/context",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
"maximum_number_of_urls": settings.BRAVE_MAX_RESULTS,
|
||||
"maximum_number_of_tokens": settings.BRAVE_MAX_TOKENS,
|
||||
"maximum_number_of_snippets": settings.BRAVE_MAX_SNIPPETS,
|
||||
"maximum_number_of_snippets_per_url": settings.BRAVE_MAX_SNIPPETS_PER_URL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _query_brave_web_search_api_async(query: str) -> List[dict]:
|
||||
"""Query Brave classic web search endpoint and return normalized results."""
|
||||
logger.debug("Using classic web search endpoint")
|
||||
return await _query_brave_api_with_endpoint_async(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
{
|
||||
"q": query,
|
||||
"country": settings.BRAVE_SEARCH_COUNTRY,
|
||||
"search_lang": settings.BRAVE_SEARCH_LANG,
|
||||
"count": settings.BRAVE_MAX_RESULTS,
|
||||
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
|
||||
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
|
||||
"result_filter": "web,faq,query",
|
||||
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
|
||||
"""Build the tool payload from Brave results.
|
||||
|
||||
Keep only sources that have non-empty snippets and prefer `snippets` over
|
||||
`extra_snippets` when both are present.
|
||||
"""
|
||||
formatted_results = {}
|
||||
sources = set()
|
||||
|
||||
for idx, result in enumerate(raw_search_results):
|
||||
logger.debug("Formatting result: %s", result)
|
||||
snippets = result.get("snippets") or result.get("extra_snippets") or []
|
||||
if not snippets:
|
||||
continue
|
||||
|
||||
formatted_results[str(idx)] = {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": snippets,
|
||||
}
|
||||
sources.add(result["url"])
|
||||
|
||||
"""Format the raw search results into a ToolReturn object."""
|
||||
return ToolReturn(
|
||||
return_value=formatted_results,
|
||||
metadata={"sources": sources},
|
||||
# Format return value "mistral-like": https://docs.mistral.ai/capabilities/citations/
|
||||
return_value={
|
||||
str(idx): {
|
||||
"url": result["url"],
|
||||
"title": result["title"],
|
||||
"snippets": result.get("extra_snippets", []),
|
||||
}
|
||||
for idx, result in enumerate(raw_search_results)
|
||||
if result.get("extra_snippets", [])
|
||||
},
|
||||
metadata={
|
||||
"sources": {
|
||||
result["url"] for result in raw_search_results if result.get("extra_snippets", [])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information.
|
||||
This function use the classic websearch endpoint of the Brave API.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then summarized using the LLM summarization agent.
|
||||
The results are then formatted and returned.
|
||||
Search the web for up-to-date information
|
||||
|
||||
Args:
|
||||
_ctx (RunContext): The run context, used by the wrapper.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.debug("Starting classic web search without RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
|
||||
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
|
||||
|
||||
# Parallelize fetch/extract only for results that don't already include any snippets
|
||||
# (neither Brave `snippets` nor `extra_snippets`).
|
||||
# Parallelize fetch/extract for results that don't include extra_snippets
|
||||
to_process = [
|
||||
(idx, r)
|
||||
for idx, r in enumerate(raw_search_results)
|
||||
if not r.get("extra_snippets") and not r.get("snippets")
|
||||
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
|
||||
]
|
||||
|
||||
if to_process:
|
||||
@@ -354,45 +283,18 @@ async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_llm_context(_ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web using Brave LLM context endpoint (no RAG post-processing).
|
||||
This function use the LLM context endpoint of the Brave API.
|
||||
The results are then formatted and returned.
|
||||
"""
|
||||
logger.debug("Starting web search with LLM context endpoint for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_llm_context_api_async(query)
|
||||
formatted_result = format_tool_return(raw_search_results)
|
||||
if not formatted_result.return_value:
|
||||
raise ModelRetry("No valid search results were extracted from Brave LLM context.")
|
||||
return formatted_result
|
||||
except (ModelCannotRetry, ModelRetry):
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error in web_search_brave_llm_context: %s", exc)
|
||||
raise ModelCannotRetry(
|
||||
f"An unexpected error occurred during web search: {type(exc).__name__}. "
|
||||
"You must explain this to the user and not try to answer based on your knowledge."
|
||||
) from exc
|
||||
|
||||
|
||||
@last_model_retry_soft_fail
|
||||
async def web_search_brave_with_document_backend(ctx: RunContext, query: str) -> ToolReturn:
|
||||
"""
|
||||
Search the web for up-to-date information using RAG backend.
|
||||
URLs are then fetched and extracted using trafilatura.
|
||||
The extracted text is then stored in a temporary document store for RAG search.
|
||||
The RAG search is then performed and the results are returned.
|
||||
Search the web for up-to-date information using RAG backend
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
query (str): The query to search for.
|
||||
"""
|
||||
logger.debug("Starting web search with RAG backend for query: %s", query)
|
||||
logger.info("Starting web search with RAG backend for query: %s", query)
|
||||
try:
|
||||
raw_search_results = await _query_brave_web_search_api_async(query)
|
||||
raw_search_results = await _query_brave_api_async(query)
|
||||
|
||||
# Clear trafilatura caches in thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -426,7 +328,7 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
|
||||
session=ctx.deps.session,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
)
|
||||
logger.debug("RAG search returned: %s", rag_results)
|
||||
logger.info("RAG search returned: %s", rag_results)
|
||||
|
||||
ctx.usage += RunUsage(
|
||||
input_tokens=rag_results.usage.prompt_tokens,
|
||||
|
||||
@@ -74,20 +74,3 @@ class BraveSettings:
|
||||
environ_name="BRAVE_SEARCH_EXTRA_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# LLM context endpoint limits
|
||||
BRAVE_MAX_TOKENS = values.IntegerValue(
|
||||
default=8192,
|
||||
environ_name="BRAVE_MAX_TOKENS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS = values.IntegerValue(
|
||||
default=50,
|
||||
environ_name="BRAVE_MAX_SNIPPETS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BRAVE_MAX_SNIPPETS_PER_URL = values.IntegerValue(
|
||||
default=10,
|
||||
environ_name="BRAVE_MAX_SNIPPETS_PER_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
@@ -368,7 +368,6 @@ class Base(BraveSettings, Configuration):
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
]
|
||||
|
||||
# Cache
|
||||
|
||||
+30
-31
@@ -23,15 +23,15 @@ description = "An application to chat with your own AI."
|
||||
keywords = ["Django", "AI", "Chatbot", "OpenAI", "Pydantic AI", "Conversations"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = "~=3.13.0"
|
||||
requires-python = "~=3.14.3"
|
||||
dependencies = [
|
||||
"deprecated",
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.73",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.68",
|
||||
"Brotli==1.2.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-parler==2.3",
|
||||
@@ -39,35 +39,35 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.12",
|
||||
"django==6.0.3",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"gunicorn==25.1.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.10.0",
|
||||
"jsonschema==4.26.0",
|
||||
"langfuse==4.0.0",
|
||||
"lxml==5.4.0",
|
||||
"markdown==3.10",
|
||||
"markdown==3.10.2",
|
||||
"markitdown==0.0.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.0.0",
|
||||
"pydantic==2.12.4",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.62.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"PyJWT==2.12.0",
|
||||
"posthog==7.9.12",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.68.0",
|
||||
"psycopg[binary]==3.3.3",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"semchunk==3.2.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"sentry-sdk==2.54.0",
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"pypdf==6.9.1",
|
||||
"uvicorn==0.41.0",
|
||||
"whitenoise==6.12.0",
|
||||
"pypdf==6.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -78,27 +78,27 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"dirty-equals==0.10.0",
|
||||
"dirty-equals==0.11",
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"pylint-django==2.6.1",
|
||||
"ipython==9.11.0",
|
||||
"pyfakefs==6.1.5",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint==3.3.9",
|
||||
"pylint-pydantic==0.4.1",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.1",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"responses==0.26.0",
|
||||
"respx==0.22.0",
|
||||
"ruff==0.14.5",
|
||||
"types-requests==2.32.4.20250913",
|
||||
"ruff==0.15.6",
|
||||
"types-requests==2.32.4.20260107",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -115,7 +115,6 @@ required-environments = [
|
||||
]
|
||||
override-dependencies = [
|
||||
"cryptography>=46.0.5", # CVE-2026-26007
|
||||
"joserfc>=1.6.3", # CVE-2026-27932
|
||||
"pillow>=12.1.1", #CVE-2026-25990
|
||||
]
|
||||
|
||||
|
||||
Generated
+502
-469
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
|
||||
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -10,22 +12,44 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
},
|
||||
generateBuildId: () => buildId,
|
||||
env: {
|
||||
NEXT_PUBLIC_BUILD_ID: buildId,
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
'*.svg': {
|
||||
loaders: ['@svgr/webpack'],
|
||||
as: '*.js',
|
||||
webpack(config, { isServer }) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
);
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
},
|
||||
},
|
||||
resolveAlias: {
|
||||
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
);
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
// Formula rendering in markdown, replace dollar-sign with \(...\) and \[...\]
|
||||
// https://github.com/remarkjs/remark-math/issues/39#issuecomment-2636184992
|
||||
config.resolve.alias['micromark-extension-math'] =
|
||||
'micromark-extension-llm-math';
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@
|
||||
"lottie-react": "^2.4.1",
|
||||
"luxon": "3.6.1",
|
||||
"micromark-extension-llm-math": "3.1.1-20250610",
|
||||
"next": "16.2.0",
|
||||
"next": "15.3.9",
|
||||
"posthog-js": "1.249.3",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.1",
|
||||
"react-aria-components": "1.9.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.1",
|
||||
"react-i18next": "15.5.2",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-markdown": "10.1.0",
|
||||
@@ -65,23 +65,22 @@
|
||||
"@types/lodash": "4.17.17",
|
||||
"@types/luxon": "3.6.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.5.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-conversations": "*",
|
||||
"eslint-config-next": "16.2.0",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"sass": "^1.97.3",
|
||||
"stylelint": "16.20.0",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
"typescript": "*",
|
||||
"webpack": "5.99.9",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.0",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-config-conversations": "*",
|
||||
"typescript": "*"
|
||||
},
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"pdf-parse": "1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module 'convert-stream' {
|
||||
export function toBuffer(
|
||||
readableStream: NodeJS.ReadableStream,
|
||||
): Promise<Buffer>;
|
||||
}
|
||||
@@ -27,17 +27,14 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "22.15.29",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.33.1",
|
||||
"@typescript-eslint/parser": "8.33.1",
|
||||
"eslint": "^9.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"typescript": "5.8.3",
|
||||
"minimatch": ">=10.2.1",
|
||||
"flatted": ">=3.4.0",
|
||||
"rollup": ">=4.59.0",
|
||||
"undici": ">=7.24.0"
|
||||
"minimatch": ">=10.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
+1182
-548
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user