Skip to content

Commit e335f1c

Browse files
committed
✨(websearch) add Brave llm/context snippets
Use llm/context endpoint with snippets, change tool name for web_search Signed-off-by: camilleAND <camille.andre@modernisation.gouv.fr>
1 parent 6dd41e8 commit e335f1c

File tree

6 files changed

+224
-27
lines changed

6 files changed

+224
-27
lines changed

src/backend/chat/agents/conversation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ def get_web_search_tool_name(self) -> str | None:
128128
"""
129129
for toolset in self.toolsets:
130130
for tool in toolset.tools.values():
131-
if tool.name.startswith("web_search_"):
131+
# Support both legacy names (web_search_*) and the new generic "web_search"
132+
if tool.name.startswith("web_search"):
132133
return tool.name
133134
return None
134135

src/backend/chat/tests/clients/pydantic_ai/test_smart_web_search.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def _llm_config_with_websearch(settings):
2323
is_active=True,
2424
icon=None,
2525
system_prompt="You are an amazing assistant.",
26-
tools=["web_search_brave_with_document_backend"],
26+
tools=["web_search"],
2727
provider=LLMProvider(
2828
hrid="unused",
2929
base_url="https://example.com",
@@ -68,7 +68,7 @@ def test_smart_search_enabled_tool_is_called(_llm_config_with_websearch):
6868
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
6969
response = service.conversation_agent.run_sync("Search the web for something.")
7070

71-
assert "web_search_brave_with_document_backend" in response.output
71+
assert "web_search" in response.output
7272

7373

7474
def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websearch):
@@ -92,4 +92,4 @@ def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websea
9292
)
9393
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
9494
response = service.conversation_agent.run_sync("Search the web for something.")
95-
assert "web_search_brave_with_document_backend" in response.output
95+
assert "web_search" in response.output

src/backend/chat/tests/tools/test_web_search_brave.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
_query_brave_api_async,
2525
format_tool_return,
2626
web_search_brave,
27+
web_search_brave_configurable,
2728
web_search_brave_with_document_backend,
2829
)
2930

30-
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
31+
# Must match the URL used in _query_brave_api_async
32+
BRAVE_URL = "https://api.search.brave.com/res/v1/llm/context"
3133

3234

3335
@pytest.fixture(autouse=True)
@@ -42,6 +44,8 @@ def brave_settings(settings):
4244
settings.BRAVE_SEARCH_SPELLCHECK = True
4345
settings.BRAVE_SEARCH_EXTRA_SNIPPETS = True
4446
settings.BRAVE_SUMMARIZATION_ENABLED = False
47+
settings.BRAVE_USE_LLM_CONTEXT = True
48+
settings.BRAVE_USE_RAG = True
4549
settings.BRAVE_CACHE_TTL = 3600
4650
settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = 5
4751

@@ -1028,6 +1032,75 @@ async def test_web_search_brave_with_document_backend_rag_search_params(mocked_c
10281032
)
10291033

10301034

1035+
@pytest.mark.asyncio
1036+
async def test_web_search_brave_configurable_uses_non_rag_when_llm_context_enabled(
1037+
settings, mocked_context
1038+
):
1039+
"""LLM context must always bypass RAG regardless of BRAVE_USE_RAG."""
1040+
settings.BRAVE_USE_LLM_CONTEXT = True
1041+
settings.BRAVE_USE_RAG = True
1042+
1043+
with patch(
1044+
"chat.tools.web_search_brave.web_search_brave", new_callable=AsyncMock
1045+
) as mock_non_rag:
1046+
with patch(
1047+
"chat.tools.web_search_brave.web_search_brave_with_document_backend",
1048+
new_callable=AsyncMock,
1049+
) as mock_rag:
1050+
mock_non_rag.return_value = "non-rag"
1051+
result = await web_search_brave_configurable(mocked_context, "query")
1052+
1053+
assert result == "non-rag"
1054+
mock_non_rag.assert_called_once_with(mocked_context, "query")
1055+
mock_rag.assert_not_called()
1056+
1057+
1058+
@pytest.mark.asyncio
1059+
async def test_web_search_brave_configurable_uses_rag_when_classic_and_rag_enabled(
1060+
settings, mocked_context
1061+
):
1062+
"""Classic search + BRAVE_USE_RAG=True should use RAG implementation."""
1063+
settings.BRAVE_USE_LLM_CONTEXT = False
1064+
settings.BRAVE_USE_RAG = True
1065+
1066+
with patch(
1067+
"chat.tools.web_search_brave.web_search_brave", new_callable=AsyncMock
1068+
) as mock_non_rag:
1069+
with patch(
1070+
"chat.tools.web_search_brave.web_search_brave_with_document_backend",
1071+
new_callable=AsyncMock,
1072+
) as mock_rag:
1073+
mock_rag.return_value = "rag"
1074+
result = await web_search_brave_configurable(mocked_context, "query")
1075+
1076+
assert result == "rag"
1077+
mock_rag.assert_called_once_with(mocked_context, "query")
1078+
mock_non_rag.assert_not_called()
1079+
1080+
1081+
@pytest.mark.asyncio
1082+
async def test_web_search_brave_configurable_uses_non_rag_when_classic_and_rag_disabled(
1083+
settings, mocked_context
1084+
):
1085+
"""Classic search + BRAVE_USE_RAG=False should use non-RAG implementation."""
1086+
settings.BRAVE_USE_LLM_CONTEXT = False
1087+
settings.BRAVE_USE_RAG = False
1088+
1089+
with patch(
1090+
"chat.tools.web_search_brave.web_search_brave", new_callable=AsyncMock
1091+
) as mock_non_rag:
1092+
with patch(
1093+
"chat.tools.web_search_brave.web_search_brave_with_document_backend",
1094+
new_callable=AsyncMock,
1095+
) as mock_rag:
1096+
mock_non_rag.return_value = "non-rag"
1097+
result = await web_search_brave_configurable(mocked_context, "query")
1098+
1099+
assert result == "non-rag"
1100+
mock_non_rag.assert_called_once_with(mocked_context, "query")
1101+
mock_rag.assert_not_called()
1102+
1103+
10311104
@pytest.mark.asyncio
10321105
async def test_fetch_and_store_none_document():
10331106
"""Test _fetch_and_store_async when extraction returns None instead of empty string."""

src/backend/chat/tools/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
from .fake_current_weather import get_current_weather
66
from .web_seach_albert_rag import web_search_albert_rag
7-
from .web_search_brave import web_search_brave, web_search_brave_with_document_backend
7+
from .web_search_brave import (
8+
web_search_brave_configurable,
9+
)
810
from .web_search_tavily import web_search_tavily
911

1012

@@ -18,13 +20,22 @@ def get_pydantic_tools_by_name(name: str) -> Tool:
1820
tool_dict = {
1921
"get_current_weather": Tool(get_current_weather, takes_ctx=False),
2022
"web_search_brave": Tool(
21-
web_search_brave,
23+
web_search_brave_configurable,
2224
takes_ctx=True,
2325
prepare=only_if_web_search_enabled,
2426
max_retries=2,
2527
),
28+
# Backward-compatible alias (older settings may still reference this tool name).
2629
"web_search_brave_with_document_backend": Tool(
27-
web_search_brave_with_document_backend,
30+
web_search_brave_configurable,
31+
name="web_search_brave_with_document_backend",
32+
takes_ctx=True,
33+
prepare=only_if_web_search_enabled,
34+
max_retries=2,
35+
),
36+
"web_search": Tool(
37+
web_search_brave_configurable,
38+
name="web_search",
2839
takes_ctx=True,
2940
prepare=only_if_web_search_enabled,
3041
max_retries=2,

src/backend/chat/tools/web_search_brave.py

Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ async def _fetch_and_extract_async(url: str) -> str:
101101
raise DocumentFetchError(f"Failed to extract content from {url}: {e}") from e
102102

103103

104+
def _get_snippets_from_result(result: dict) -> List[str]:
105+
"""Return merged snippets/extra_snippets as a list, guarding against None."""
106+
snippets = result.get("snippets") or []
107+
extra_snippets = result.get("extra_snippets") or []
108+
# Both are expected to be lists of strings; fall back to one or the other if needed.
109+
if snippets and not extra_snippets:
110+
return snippets
111+
if extra_snippets and not snippets:
112+
return extra_snippets
113+
return snippets or extra_snippets
114+
115+
104116
async def _extract_and_summarize_snippets_async(query: str, url: str) -> List[str]:
105117
"""Fetch, extract and summarize text content from the URL.
106118
@@ -143,22 +155,45 @@ async def _fetch_and_store_async(url: str, document_store, **kwargs) -> None:
143155

144156

145157
async def _query_brave_api_async(query: str) -> List[dict]:
146-
"""Query the Brave Search API and return the raw results."""
147-
url = "https://api.search.brave.com/res/v1/web/search"
158+
"""Query the Brave Search API and return the raw results.
159+
160+
Uses either the LLM context endpoint (res/v1/llm/context) or the classic web search
161+
endpoint (res/v1/web/search) depending on the BRAVE_USE_LLM_CONTEXT setting.
162+
"""
163+
if settings.BRAVE_USE_LLM_CONTEXT:
164+
logger.debug("Using LLM context endpoint")
165+
url = "https://api.search.brave.com/res/v1/llm/context"
166+
data = {
167+
"q": query,
168+
"country": settings.BRAVE_SEARCH_COUNTRY,
169+
"search_lang": settings.BRAVE_SEARCH_LANG,
170+
"count": settings.BRAVE_MAX_RESULTS,
171+
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
172+
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
173+
"result_filter": "web,faq,query",
174+
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
175+
"maximum_number_of_urls": settings.BRAVE_MAX_RESULTS,
176+
"maximum_number_of_tokens": settings.BRAVE_MAX_TOKENS,
177+
"maximum_number_of_snippets": settings.BRAVE_MAX_SNIPPETS,
178+
"maximum_number_of_snippets_per_url": settings.BRAVE_MAX_SNIPPETS_PER_URL,
179+
}
180+
else:
181+
logger.debug("Using classic web search endpoint")
182+
url = "https://api.search.brave.com/res/v1/web/search"
183+
data = {
184+
"q": query,
185+
"country": settings.BRAVE_SEARCH_COUNTRY,
186+
"search_lang": settings.BRAVE_SEARCH_LANG,
187+
"count": settings.BRAVE_MAX_RESULTS,
188+
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
189+
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
190+
"result_filter": "web,faq,query",
191+
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
192+
}
148193
headers = {
149194
"Accept": "application/json",
150195
"X-Subscription-Token": settings.BRAVE_API_KEY,
151196
}
152-
data = {
153-
"q": query,
154-
"country": settings.BRAVE_SEARCH_COUNTRY,
155-
"search_lang": settings.BRAVE_SEARCH_LANG,
156-
"count": settings.BRAVE_MAX_RESULTS,
157-
"safesearch": settings.BRAVE_SEARCH_SAFE_SEARCH,
158-
"spellcheck": settings.BRAVE_SEARCH_SPELLCHECK,
159-
"result_filter": "web,faq,query",
160-
"extra_snippets": settings.BRAVE_SEARCH_EXTRA_SNIPPETS,
161-
}
162197
params = {k: v for k, v in data.items() if v is not None}
163198

164199
try:
@@ -167,6 +202,29 @@ async def _query_brave_api_async(query: str) -> List[dict]:
167202
response.raise_for_status()
168203
json_response = response.json()
169204

205+
# LLM context API: results are under `grounding.generic`
206+
# See: https://api-dashboard.search.brave.com/documentation/services/llm-context
207+
if "grounding" in json_response:
208+
generic_results = json_response.get("grounding", {}).get("generic", []) or []
209+
normalized_results: List[dict] = []
210+
for item in generic_results:
211+
item_url = item.get("url")
212+
if not item_url:
213+
continue
214+
215+
normalized_results.append(
216+
{
217+
"url": item_url,
218+
# Fallback to URL if no title is provided
219+
"title": item.get("title") or item_url,
220+
# `snippets` is already a list
221+
"snippets": item.get("snippets") or [],
222+
}
223+
)
224+
225+
return normalized_results
226+
227+
# Fallback for classic web search JSON shape, if we ever switch back
170228
# https://api-dashboard.search.brave.com/app/documentation/web-search/responses#Result
171229
return json_response.get("web", {}).get("results", [])
172230

@@ -217,14 +275,14 @@ def format_tool_return(raw_search_results: List[dict]) -> ToolReturn:
217275
str(idx): {
218276
"url": result["url"],
219277
"title": result["title"],
220-
"snippets": result.get("extra_snippets", []),
278+
"snippets": _get_snippets_from_result(result),
221279
}
222280
for idx, result in enumerate(raw_search_results)
223-
if result.get("extra_snippets", [])
281+
if _get_snippets_from_result(result)
224282
},
225283
metadata={
226284
"sources": {
227-
result["url"] for result in raw_search_results if result.get("extra_snippets", [])
285+
result["url"] for result in raw_search_results if _get_snippets_from_result(result)
228286
}
229287
},
230288
)
@@ -239,14 +297,18 @@ async def web_search_brave(_ctx: RunContext, query: str) -> ToolReturn:
239297
_ctx (RunContext): The run context, used by the wrapper.
240298
query (str): The query to search for.
241299
"""
300+
logger.debug("Starting web search without RAG backend for query: %s", query)
242301
try:
243302
raw_search_results = await _query_brave_api_async(query)
244303

245304
await sync_to_async(reset_caches)() # Clear trafilatura caches to avoid memory bloat/leaks
246305

247-
# Parallelize fetch/extract for results that don't include extra_snippets
306+
# Parallelize fetch/extract only for results that don't already include any snippets
307+
# (neither Brave `snippets` nor `extra_snippets`).
248308
to_process = [
249-
(idx, r) for idx, r in enumerate(raw_search_results) if not r.get("extra_snippets")
309+
(idx, r)
310+
for idx, r in enumerate(raw_search_results)
311+
if not r.get("extra_snippets") and not r.get("snippets")
250312
]
251313

252314
if to_process:
@@ -292,7 +354,7 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
292354
ctx (RunContext): The run context containing the conversation.
293355
query (str): The query to search for.
294356
"""
295-
logger.info("Starting web search with RAG backend for query: %s", query)
357+
logger.debug("Starting web search with RAG backend for query: %s", query)
296358
try:
297359
raw_search_results = await _query_brave_api_async(query)
298360

@@ -328,7 +390,7 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
328390
session=ctx.deps.session,
329391
user_sub=ctx.deps.user.sub,
330392
)
331-
logger.info("RAG search returned: %s", rag_results)
393+
logger.debug("RAG search returned: %s", rag_results)
332394

333395
ctx.usage += RunUsage(
334396
input_tokens=rag_results.usage.prompt_tokens,
@@ -366,3 +428,21 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
366428
f"An unexpected error occurred during web search with RAG: {type(e).__name__}. "
367429
"You must explain this to the user and not try to answer based on your knowledge."
368430
) from e
431+
432+
433+
@last_model_retry_soft_fail
434+
async def web_search_brave_configurable(ctx: RunContext, query: str) -> ToolReturn:
435+
"""Route web search implementation based on Brave settings.
436+
437+
Priority:
438+
1) BRAVE_USE_LLM_CONTEXT=True => always use non-RAG flow.
439+
2) BRAVE_USE_RAG=True => use document-backend (RAG) flow.
440+
3) Otherwise => use non-RAG flow.
441+
"""
442+
if settings.BRAVE_USE_LLM_CONTEXT:
443+
return await web_search_brave(ctx, query)
444+
445+
if settings.BRAVE_USE_RAG:
446+
return await web_search_brave_with_document_backend(ctx, query)
447+
448+
return await web_search_brave(ctx, query)

src/backend/conversations/brave_settings.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ class BraveSettings:
2323
environ_prefix=None,
2424
)
2525

26+
# Enable RAG processing for Brave web search.
27+
# If BRAVE_USE_LLM_CONTEXT is true, RAG is disabled regardless
28+
BRAVE_USE_RAG = values.BooleanValue(
29+
default=True,
30+
environ_name="BRAVE_USE_RAG",
31+
environ_prefix=None,
32+
)
33+
2634
# For web_search_brave_with_document_backend: number of chunks to retrieve RAG search
2735
BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER = values.IntegerValue(
2836
default=10,
@@ -74,3 +82,27 @@ class BraveSettings:
7482
environ_name="BRAVE_SEARCH_EXTRA_SNIPPETS",
7583
environ_prefix=None,
7684
)
85+
86+
# Whether to use the LLM context endpoint or the classic search
87+
BRAVE_USE_LLM_CONTEXT = values.BooleanValue(
88+
default=True,
89+
environ_name="BRAVE_USE_LLM_CONTEXT",
90+
environ_prefix=None,
91+
)
92+
93+
# LLM context endpoint limits
94+
BRAVE_MAX_TOKENS = values.IntegerValue(
95+
default=8192,
96+
environ_name="BRAVE_MAX_TOKENS",
97+
environ_prefix=None,
98+
)
99+
BRAVE_MAX_SNIPPETS = values.IntegerValue(
100+
default=50,
101+
environ_name="BRAVE_MAX_SNIPPETS",
102+
environ_prefix=None,
103+
)
104+
BRAVE_MAX_SNIPPETS_PER_URL = values.IntegerValue(
105+
default=10,
106+
environ_name="BRAVE_MAX_SNIPPETS_PER_URL",
107+
environ_prefix=None,
108+
)

0 commit comments

Comments
 (0)