-
Notifications
You must be signed in to change notification settings - Fork 23
✨(back) add Staan web search tool #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
camilleAND
wants to merge
1
commit into
main
Choose a base branch
from
camand/feat_staan_websearch
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"]}) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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, whileformat_staan(line 137) uses.get("web", {}).get("results", []). If the API returns an unexpected structure, this raisesKeyError, caught by the generic handler and surfaced asModelCannotRetry— but the inconsistency is a latent bug. Additionally, the set comprehension{...}loses ordering and includes empty URL strings.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents