Skip to content

✨(back) add Staan web search tool#595

Open
camilleAND wants to merge 1 commit into
mainfrom
camand/feat_staan_websearch
Open

✨(back) add Staan web search tool#595
camilleAND wants to merge 1 commit into
mainfrom
camand/feat_staan_websearch

Conversation

@camilleAND

@camilleAND camilleAND commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Purpose

Add Staan as an alternative web search provider for the chat agent, alongside the existing Brave integration. Staan is a European search API suitable for LLM/RAG use cases, with support for enriched snippets.

The provider is selectable per model via the existing web_search LLM configuration field, without changing the agent tool interface (web_search).

Proposal

  • Add web_search_staan tool calling the Staan Web Search for AI API (/v2/search/web)
  • Add StaanSettings (API key, endpoint, timeout, extra snippets, max results, etc.) and wire it into Django settings
  • Resolve the Staan search market automatically from the user's UI language (ContextDeps.language), with fallback to en-us for unsupported languages
  • Format API results (title, URL, snippet, published date, optional extra snippets) and return sources in tool metadata
  • Handle HTTP errors (rate limit, 5xx retry, client errors) consistently with other web search tools
  • Pass the resolved conversation language through ContextDeps so tools use the same locale as the chat view
  • Add unit tests for market resolution and API query parameters

Test plan

  • Set STAAN_API_KEY in your environment
  • Point a model's web_search config to chat.tools.web_search_staan.web_search_staan
  • Enable web_search feature flag and allow_smart_web_search for the test user
  • Run pytest chat/tests/tools/test_web_search_staan.py
  • Trigger a chat with forced web search and verify logs show market=... and Staan API confirmed market=...
  • Switch UI language (fr / en) and confirm the Staan market changes accordingly

Summary by CodeRabbit

  • New Features

    • Added a new web search option that can use the conversation language to better match search results.
    • Search results may now include extra snippets and improved source metadata.
  • Bug Fixes

    • When no useful results are found, the search tool can retry with different wording.
    • Improved handling for search service failures so users get clearer, more reliable responses.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@camilleAND, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b5841e67-8ae6-4929-81ba-42026ec49cdb

📥 Commits

Reviewing files that changed from the base of the PR and between 40a4729 and cc08a8a.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • env.d/development/kube-secret.dist
  • src/backend/chat/clients/pydantic_ai.py
  • src/backend/chat/clients/schema.py
  • src/backend/chat/tests/tools/test_web_search_staan.py
  • src/backend/chat/tools/descriptions.py
  • src/backend/chat/tools/web_search_staan.py
  • src/backend/conversations/settings.py
  • src/backend/conversations/staan_settings.py

Walkthrough

This PR adds a new "Staan" web search tool integrated with PydanticAI, including market resolution from language, an authenticated Staan API client, response formatting/snippet filtering, and a tool entrypoint with retry-aware error handling. It also adds StaanSettings configuration, propagates conversation language into ContextDeps, updates tool description text, and adds tests.

Changes

Staan web search tool

Layer / File(s) Summary
Settings and configuration wiring
src/backend/conversations/staan_settings.py, src/backend/conversations/settings.py, env.d/development/kube-secret.dist
Adds StaanSettings mixin with API key, timeout, endpoint, market, snippet, and result-count settings; wires it into Base config; adds STAAN_API_KEY placeholder to secrets template.
Language propagation into context
src/backend/chat/clients/schema.py, src/backend/chat/clients/pydantic_ai.py
Adds language field to ContextDeps and passes self.language into it during AIAgentService initialization.
Core search implementation
src/backend/chat/tools/web_search_staan.py
Implements resolve_staan_market, staan_search, and format_staan for market mapping, authenticated GET requests, and result/snippet formatting.
Tool entrypoint and error handling
src/backend/chat/tools/web_search_staan.py, src/backend/chat/tools/descriptions.py
Adds async web_search_staan tool returning ToolReturn with source metadata, maps HTTP errors to ModelRetry/ModelCannotRetry, and updates tool description with retry guidance.
Tests
src/backend/chat/tests/tools/test_web_search_staan.py
Adds tests for market resolution fallback logic and a respx-mocked test verifying search request query parameters.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: noChangeLog

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding the Staan web search tool.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch camand/feat_staan_websearch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/backend/conversations/staan_settings.py (2)

34-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use PositiveIntegerValue for STAAN_MAX_RESULTS and STAAN_MAX_SNIPPET_LENGTH.

Both represent counts/lengths that should never be zero or negative. IntegerValue permits invalid values. Other comparable settings (RAG_WEB_SEARCH_MAX_RESULTS, TAVILY_MAX_RESULTS) use PositiveIntegerValue.

♻️ Proposed fix
-    STAAN_MAX_RESULTS = values.IntegerValue(
+    STAAN_MAX_RESULTS = values.PositiveIntegerValue(
         default=10,
         environ_name="STAAN_MAX_RESULTS",
         environ_prefix=None,
     )
-    STAAN_MAX_SNIPPET_LENGTH = values.IntegerValue(
+    STAAN_MAX_SNIPPET_LENGTH = values.PositiveIntegerValue(
         default=5000,
         help_text="Maximum length of the snippets per url to return (in characters)",
         environ_name="STAAN_MAX_SNIPPET_LENGTH",
         environ_prefix=None,
     )
🤖 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/conversations/staan_settings.py` around lines 34 - 44, Use
PositiveIntegerValue for STAAN_MAX_RESULTS and STAAN_MAX_SNIPPET_LENGTH in
staan_settings.py because both settings must stay strictly positive. Update the
two declarations in the settings class to match the pattern used by
RAG_WEB_SEARCH_MAX_RESULTS and TAVILY_MAX_RESULTS, keeping the same defaults and
environment names while switching from IntegerValue to PositiveIntegerValue.

14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use PositiveIntegerValue for STAAN_API_TIMEOUT for consistency and safety.

Other timeout settings in the codebase (TAVILY_API_TIMEOUT, ALBERT_API_TIMEOUT, FIND_API_TIMEOUT) all use PositiveIntegerValue. Using IntegerValue allows zero or negative timeouts, which would cause undefined behavior in the HTTP request.

♻️ Proposed fix
-    STAAN_API_TIMEOUT = values.IntegerValue(
+    STAAN_API_TIMEOUT = values.PositiveIntegerValue(
         default=20,
         environ_name="STAAN_API_TIMEOUT",
         environ_prefix=None,
     )
🤖 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/conversations/staan_settings.py` around lines 14 - 18,
STAAN_API_TIMEOUT is using IntegerValue, which allows invalid zero or negative
timeout values. Update the STAAN_API_TIMEOUT setting in the
standings/conversations config to use PositiveIntegerValue, matching the
existing timeout fields like TAVILY_API_TIMEOUT, ALBERT_API_TIMEOUT, and
FIND_API_TIMEOUT, so the timeout validation stays consistent and safe.
src/backend/chat/tools/web_search_staan.py (2)

68-73: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider using an async HTTP client to avoid blocking the event loop.

staan_search performs a synchronous requests.get call, but it's invoked from the async web_search_staan function. This blocks the event loop for the duration of the HTTP request (up to STAAN_API_TIMEOUT seconds). If this is consistent with the existing Brave web search tool's pattern, this can be deferred.

🤖 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` around lines 68 - 73, The
synchronous requests.get call inside staan_search is blocking the event loop
when invoked from web_search_staan. Update this flow to use an async HTTP client
or otherwise run the network call without blocking, and keep the existing
timeout/header/params behavior intact. Use staan_search and web_search_staan as
the main entry points to locate the change, and mirror the non-blocking pattern
used by the Brave web search tool if that is the intended standard.

101-102: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Avoid O(n²) string join inside the loop.

len(" ".join(extra_snippets)) is recomputed on every iteration, joining the entire list each time. For a small number of snippets this is negligible, but a running length counter is simpler and O(n).

♻️ Proposed refactor
     extra_snippets: list[str] = []
+    running_length = 0
     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:
+        if running_length + len(chunk) + (1 if extra_snippets else 0) >= max_len_snippet:
             break
         extra_snippets.append(chunk)
+        running_length += len(chunk) + (1 if extra_snippets else 0)
🤖 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` around lines 101 - 102, The
snippet-length check in the extra_snippets loop recomputes the full joined
string on every iteration, making it O(n²). Replace the repeated len("
".join(extra_snippets)) call with a running length counter that is updated as
chunks are appended, and use that counter in the max_len_snippet comparison.
Keep the change localized to the loop that manages current_length,
extra_snippets, chunk, and max_len_snippet.
src/backend/chat/tests/tools/test_web_search_staan.py (1)

24-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test case for prefix-only language mapping.

The parametrized cases cover exact market matches and unsupported fallback, but the prefix-mapping branch ("fr""fr-fr", "en""en-us", "de""de-de") is never exercised. Adding a prefix-only case would cover that code path.

✏️ Proposed addition
         ("nl-nl", "en-us"),
+        ("fr", "fr-fr"),
+        ("de", "de-de"),
     ],
 )
🤖 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/tests/tools/test_web_search_staan.py` around lines 24 - 32,
Add a parametrized test case in test_web_search_staan.py to cover the
prefix-only language mapping branch in the language-to-market logic. Extend the
existing test case list used by the test around the language parameterization so
it includes a prefix-only input such as "fr", "en", or "de" and asserts the
mapped market value returned by the relevant Web Search/Staan helper. This
should exercise the branch that maps language prefixes to their corresponding
markets rather than exact matches or unsupported fallbacks.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/backend/chat/tools/web_search_staan.py`:
- 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.

---

Nitpick comments:
In `@src/backend/chat/tests/tools/test_web_search_staan.py`:
- Around line 24-32: Add a parametrized test case in test_web_search_staan.py to
cover the prefix-only language mapping branch in the language-to-market logic.
Extend the existing test case list used by the test around the language
parameterization so it includes a prefix-only input such as "fr", "en", or "de"
and asserts the mapped market value returned by the relevant Web Search/Staan
helper. This should exercise the branch that maps language prefixes to their
corresponding markets rather than exact matches or unsupported fallbacks.

In `@src/backend/chat/tools/web_search_staan.py`:
- Around line 68-73: The synchronous requests.get call inside staan_search is
blocking the event loop when invoked from web_search_staan. Update this flow to
use an async HTTP client or otherwise run the network call without blocking, and
keep the existing timeout/header/params behavior intact. Use staan_search and
web_search_staan as the main entry points to locate the change, and mirror the
non-blocking pattern used by the Brave web search tool if that is the intended
standard.
- Around line 101-102: The snippet-length check in the extra_snippets loop
recomputes the full joined string on every iteration, making it O(n²). Replace
the repeated len(" ".join(extra_snippets)) call with a running length counter
that is updated as chunks are appended, and use that counter in the
max_len_snippet comparison. Keep the change localized to the loop that manages
current_length, extra_snippets, chunk, and max_len_snippet.

In `@src/backend/conversations/staan_settings.py`:
- Around line 34-44: Use PositiveIntegerValue for STAAN_MAX_RESULTS and
STAAN_MAX_SNIPPET_LENGTH in staan_settings.py because both settings must stay
strictly positive. Update the two declarations in the settings class to match
the pattern used by RAG_WEB_SEARCH_MAX_RESULTS and TAVILY_MAX_RESULTS, keeping
the same defaults and environment names while switching from IntegerValue to
PositiveIntegerValue.
- Around line 14-18: STAAN_API_TIMEOUT is using IntegerValue, which allows
invalid zero or negative timeout values. Update the STAAN_API_TIMEOUT setting in
the standings/conversations config to use PositiveIntegerValue, matching the
existing timeout fields like TAVILY_API_TIMEOUT, ALBERT_API_TIMEOUT, and
FIND_API_TIMEOUT, so the timeout validation stays consistent and safe.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f202ac06-fd3a-4bca-a332-61d33c9db9b3

📥 Commits

Reviewing files that changed from the base of the PR and between 7a9b58b and 40a4729.

📒 Files selected for processing (8)
  • env.d/development/kube-secret.dist
  • src/backend/chat/clients/pydantic_ai.py
  • src/backend/chat/clients/schema.py
  • src/backend/chat/tests/tools/test_web_search_staan.py
  • src/backend/chat/tools/descriptions.py
  • src/backend/chat/tools/web_search_staan.py
  • src/backend/conversations/settings.py
  • src/backend/conversations/staan_settings.py

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.

@camilleAND
camilleAND force-pushed the camand/feat_staan_websearch branch 2 times, most recently from ee47bcf to eeac4c4 Compare July 9, 2026 15:07
Add Stan as a configurable websearch
@camilleAND
camilleAND force-pushed the camand/feat_staan_websearch branch from eeac4c4 to cc08a8a Compare July 9, 2026 15:12
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants