Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to
- ✨(back) add model fallback mechanism
- ✨(back) add celery for running background tasks
- 🧱(helm) add celery worker and beat deployments
- ✨(back) add Staan web search tool

### Changed

Expand Down
3 changes: 2 additions & 1 deletion env.d/development/kube-secret.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ AI_BASE_URL=changeme
AI_API_KEY=changeme
ALBERT_API_URL=changeme
ALBERT_API_KEY=changeme
BRAVE_API_KEY=changeme
BRAVE_API_KEY=changeme
STAAN_API_KEY=changeme
1 change: 1 addition & 0 deletions src/backend/chat/clients/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument
user=user,
session=session,
web_search_enabled=self._is_web_search_enabled and self._is_smart_search_enabled,
language=self.language,
)
self._web_search_tool_registered = False
self._self_documentation_tool_registered = False
Expand Down
1 change: 1 addition & 0 deletions src/backend/chat/clients/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ContextDeps:
user: User
session: Optional[Dict] = None
web_search_enabled: bool = False
language: str | None = None


@dataclasses.dataclass
Expand Down
76 changes: 76 additions & 0 deletions src/backend/chat/tests/tools/test_web_search_staan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Tests for the Staan web search tool."""

from unittest.mock import Mock, patch

import pytest

from chat.tools.web_search_staan import resolve_staan_market, staan_search

STAAN_WEB_SEARCH_URL = "https://api.staan.ai/v2/search/web"


@pytest.fixture(autouse=True)
def staan_settings(settings):
"""Define Staan settings for tests."""
settings.STAAN_API_KEY = "test-staan-key"
settings.STAAN_SEARCH_ENDPOINT = STAAN_WEB_SEARCH_URL
settings.STAAN_SEARCH_EXTRA_SNIPPETS = True
settings.STAAN_API_TIMEOUT = 5
settings.STAAN_MAX_RESULTS = 10
settings.STAAN_MAX_SNIPPET_LENGTH = 5000


@pytest.mark.parametrize(
("language", "expected_market"),
[
("fr-fr", "fr-fr"),
("en-us", "en-us"),
("de-de", "de-de"),
("FR-FR", "fr-fr"),
("nl-nl", "en-us"),
],
)
def test_resolve_staan_market_from_language(settings, language, expected_market):
"""Language should map to a supported Staan market."""
settings.LANGUAGE_CODE = "en-us"

assert resolve_staan_market(language) == expected_market


def test_resolve_staan_market_falls_back_to_language_code(settings):
"""Missing language should fall back to Django LANGUAGE_CODE."""
settings.LANGUAGE_CODE = "en-us"

assert resolve_staan_market(None) == "en-us"


def test_resolve_staan_market_falls_back_to_english_for_unsupported_language(settings):
"""Unsupported languages should fall back to English."""
settings.LANGUAGE_CODE = "nl-nl"

assert resolve_staan_market(None) == "en-us"


@patch("chat.tools.web_search_staan.requests.get")
def test_staan_search_sends_market_query_param(mock_get):
"""Market must be forwarded to the Staan API as a query parameter."""
mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_response.json.return_value = {
"query": {"q": "climate tech", "market": "en-us"},
"web": {"results": []},
}
mock_get.return_value = mock_response

staan_search("climate tech", "en-us")

mock_get.assert_called_once_with(
STAAN_WEB_SEARCH_URL,
params={
"q": "climate tech",
"market": "en-us",
"extra_snippets": "true",
},
headers={"Authorization": "Bearer test-staan-key"},
timeout=5,
)
2 changes: 2 additions & 0 deletions src/backend/chat/tools/descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
- "Explique-moi comment fonctionne une boucle for"
- "Écris-moi un poème sur l'automne"
- "Résume ce texte"

When using web_search tool, you can retry the search with a different query if the first one didn't return any relevant results.
"""

SELF_DOCUMENTATION_SYSTEM_PROMPT = (
Expand Down
203 changes: 203 additions & 0 deletions src/backend/chat/tools/web_search_staan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Web juridique tool for the chat agent."""

import json
import logging

from django.conf import settings

import requests
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn

from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail

logger = logging.getLogger(__name__)

STAAN_MARKETS = frozenset({"fr-fr", "en-us", "de-de"})
STAAN_DEFAULT_MARKET = "en-us"

_STAAN_MARKET_BY_LANGUAGE_PREFIX = {
"fr": "fr-fr",
"en": "en-us",
"de": "de-de",
}


def resolve_staan_market(language: str | None) -> str:
"""Map a UI language code to a supported Staan search market."""
user_lang = (language or settings.LANGUAGE_CODE or "").lower()
if user_lang in STAAN_MARKETS:
return user_lang
prefix = user_lang.split("-")[0] if user_lang else ""
if prefix in _STAAN_MARKET_BY_LANGUAGE_PREFIX:
return _STAAN_MARKET_BY_LANGUAGE_PREFIX[prefix]
return STAAN_DEFAULT_MARKET


def _resolve_staan_market(ctx: RunContext) -> str:
"""Resolve the Staan market from the conversation context language."""
return resolve_staan_market(getattr(ctx.deps, "language", None))


def staan_search(query: str, market: str) -> requests.Response:
"""
Performs a search using the Staan API.

Args:
query: User query string.
market: Staan search market (e.g. fr-fr, en-us, de-de).

Returns:
requests.Response: Raw HTTP response from the Staan API.

"""
if not settings.STAAN_API_KEY:
raise ValueError("Clé API Staan manquante (variable d'env STAAN_API_KEY)")

params = {
"q": query,
"market": market,
"extra_snippets": "true" if settings.STAAN_SEARCH_EXTRA_SNIPPETS else "false",
}

headers = {
"Authorization": f"Bearer {settings.STAAN_API_KEY}",
}

response = requests.get(
settings.STAAN_SEARCH_ENDPOINT,
params=params,
headers=headers,
timeout=settings.STAAN_API_TIMEOUT,
)
response.raise_for_status()
return response


def _collect_extra_snippets(
result: dict,
*,
max_len_snippet: int,
min_score: float,
) -> list[str]:
"""Extract extra snippet chunks from a Staan result, filtering by score and length."""
raw_snippets = result.get("extra_snippets") or []
if not raw_snippets:
return []

extra_snippets: list[str] = []
for item in raw_snippets:
if isinstance(item, dict):
chunk = item.get("chunk", "")
score = float(item.get("score", 0))
else:
chunk = str(item)
score = min_score

if score < min_score:
continue

current_length = len(" ".join(extra_snippets))
if current_length + len(chunk) >= max_len_snippet:
break
extra_snippets.append(chunk)

return extra_snippets


def format_staan(
response: requests.Response,
n_results: int | None = None,
max_len_snippet: int | None = None,
min_score: float | None = None,
) -> str:
"""
Format a Staan API response to extract web results with snippets.

Works whether or not ``extra_snippets`` was requested from the API:
- without: uses the main ``snippet`` field only
- with: adds filtered ``extra_snippets`` chunks (dict items with ``chunk`` / ``score``)

Args:
response: requests.Response object from Staan API
n_results: Maximum number of web results to include
max_len_snippet: Maximum total length of concatenated extra snippets per result
min_score: Minimum relevance score for an extra snippet chunk to be included

Returns:
str: JSON string of cleaned results with title, url, snippet,
published_date and extra_snippets
"""
n_results = n_results if n_results is not None else settings.STAAN_MAX_RESULTS
max_len_snippet = (
max_len_snippet if max_len_snippet is not None else settings.STAAN_MAX_SNIPPET_LENGTH
)
min_score = min_score if min_score is not None else 0

data = response.json().get("web", {}).get("results", [])
results = []
for result in data[:n_results]:
output = {
"title": result.get("title", ""),
"url": result.get("url", ""),
"snippet": result.get("snippet", ""),
"published_date": result.get("published_date", ""),
"extra_snippets": _collect_extra_snippets(
result,
max_len_snippet=max_len_snippet,
min_score=min_score,
),
}
results.append(output)
return json.dumps(results, ensure_ascii=False, indent=2)


@last_model_retry_soft_fail
async def web_search_staan(ctx: RunContext, query: str) -> ToolReturn:
"""
Search the web using the Staan API.

Args:
ctx: Execution context used to resolve the user's search market.
query: Search query. Max 400 characters. Use site:example.com to restrict to a domain.

Returns:
ToolReturn: Result of the search.
"""
market = _resolve_staan_market(ctx)
logger.info("Staan web search: query=%r market=%s", query, market)
try:
response = staan_search(query, market)
response_data = response.json()
api_market = response_data.get("query", {}).get("market")
logger.info("Staan API confirmed market=%s (requested=%s)", api_market, market)
sources = list({resp.get("url", "") for resp in response_data["web"]["results"]})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use defensive key access and preserve source ordering.

Line 174 accesses response_data["web"]["results"] with direct indexing, while format_staan (line 137) uses .get("web", {}).get("results", []). If the API returns an unexpected structure, this raises KeyError, caught by the generic handler and surfaced as ModelCannotRetry — but the inconsistency is a latent bug. Additionally, the set comprehension {...} loses ordering and includes empty URL strings.

🛡️ Proposed fix
-        sources = list({resp.get("url", "") for resp in response_data["web"]["results"]})
+        results = response_data.get("web", {}).get("results", [])
+        sources = list(dict.fromkeys(
+            resp.get("url", "") for resp in results if resp.get("url")
+        ))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sources = list({resp.get("url", "") for resp in response_data["web"]["results"]})
results = response_data.get("web", {}).get("results", [])
sources = list(dict.fromkeys(
resp.get("url", "") for resp in results if resp.get("url")
))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/chat/tools/web_search_staan.py` at line 174, The sources
extraction in format_staan is using direct indexing on
response_data["web"]["results"] and a set comprehension, which can raise
KeyError on unexpected payloads and also drops ordering while keeping empty
URLs. Update the logic to use the same defensive access pattern as the rest of
format_staan, iterate over the results safely, and build sources in original
order while skipping empty URLs. Keep the fix localized around format_staan and
the sources assignment so the behavior stays consistent with the existing
response parsing.

return ToolReturn(
return_value=format_staan(response),
metadata={"sources": sources},
)
except requests.HTTPError as exc:
status_code = exc.response.status_code if exc.response is not None else None
logger.warning("Staan API HTTP error: status=%s market=%s", status_code, market)
if status_code == 429:
raise ModelRetry(
"The search API is rate limited. Please wait a moment and try again."
) from exc
if status_code is not None and status_code >= 500:
raise ModelRetry(
"The search service is temporarily unavailable due to a server error. Retrying..."
) from exc
raise ModelCannotRetry(
f"Web search failed with a client error (status {status_code}). "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
except ModelCannotRetry, ModelRetry:
raise
except Exception as exc:
logger.exception("Unexpected error in web_search_staan: %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
3 changes: 2 additions & 1 deletion src/backend/conversations/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from chat.llm_configuration import cached_load_llm_configuration, load_llm_configuration
from conversations.brave_settings import BraveSettings
from conversations.staan_settings import StaanSettings

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
Expand All @@ -48,7 +49,7 @@ def get_release():
return "NA" # Default: not available


class Base(BraveSettings, Configuration):
class Base(BraveSettings, StaanSettings, Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
is recommended to configure third-party applications by creating a configuration mixins in
Expand Down
44 changes: 44 additions & 0 deletions src/backend/conversations/staan_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Django configuration mixin for Staan settings."""

from configurations import values


class StaanSettings:
"""Staan settings for web_search_staan tool."""

STAAN_API_KEY = values.Value(
default=None,
environ_name="STAAN_API_KEY",
environ_prefix=None,
)
STAAN_API_TIMEOUT = values.IntegerValue(
default=20,
environ_name="STAAN_API_TIMEOUT",
environ_prefix=None,
)
STAAN_SEARCH_ENDPOINT = values.Value(
default="https://api.staan.ai/v2/search/web",
environ_name="STAAN_SEARCH_ENDPOINT",
environ_prefix=None,
)
STAAN_SEARCH_MARKET = values.Value(
default="fr-fr",
environ_name="STAAN_SEARCH_MARKET",
environ_prefix=None,
)
STAAN_SEARCH_EXTRA_SNIPPETS = values.BooleanValue(
default=True,
environ_name="STAAN_SEARCH_EXTRA_SNIPPETS",
environ_prefix=None,
)
STAAN_MAX_RESULTS = values.IntegerValue(
default=10,
environ_name="STAAN_MAX_RESULTS",
environ_prefix=None,
)
STAAN_MAX_SNIPPET_LENGTH = values.IntegerValue(
default=5000,
help_text="Maximum length of the snippets per url to return (in characters)",
environ_name="STAAN_MAX_SNIPPET_LENGTH",
environ_prefix=None,
)
Loading