diff --git a/docs/references/http_api_reference.md b/docs/references/http_api_reference.md index 667fa52ed6..5088f30ef2 100644 --- a/docs/references/http_api_reference.md +++ b/docs/references/http_api_reference.md @@ -3032,9 +3032,10 @@ curl --request POST \ - `"use_kg"`: `boolean` - `"reasoning"`: `boolean` - `"cross_languages"`: `list[string]` - - `"web_search_provider"`: `string` The web search service to use. Supported values are `"tavily"` and `"querit"`. Defaults to `"tavily"` when omitted. + - `"web_search_provider"`: `string` The web search service to use. Supported values are `"tavily"`, `"querit"`, and `"serply"`. Defaults to `"tavily"` when omitted. - `"tavily_api_key"`: `string` - `"querit_api_key"`: `string` The Querit API key. Set `web_search_provider` to `"querit"` when using this field. + - `"serply_api_key"`: `string` The [Serply](https://serply.io) API key. Set `web_search_provider` to `"serply"` when using this field. See the [Serply documentation](https://serply.io/docs) for details. - `"toc_enhance"`: `boolean` - `"similarity_threshold"`: (*Body parameter*), `float` - `"vector_similarity_weight"`: (*Body parameter*), `float` diff --git a/internal/service/web_search_provider.go b/internal/service/web_search_provider.go index 85114856f9..9ee5b5e890 100644 --- a/internal/service/web_search_provider.go +++ b/internal/service/web_search_provider.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -30,10 +31,15 @@ import ( const ( webSearchProviderTavily = "tavily" webSearchProviderQuerit = "querit" + webSearchProviderSerply = "serply" queritWebSearchEndpoint = "https://api.querit.ai/v1/search" + serplyWebSearchEndpoint = "https://api.serply.io/v1/search/" ) -var queritWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} +var ( + queritWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} + serplyWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} +) type webSearchProviderConfig struct { Provider string @@ -60,6 +66,8 @@ func resolveWebSearchProvider(promptConfig map[string]interface{}) *webSearchPro apiKeyField = "tavily_api_key" case webSearchProviderQuerit: apiKeyField = "querit_api_key" + case webSearchProviderSerply: + apiKeyField = "serply_api_key" default: return nil } @@ -94,6 +102,14 @@ func (s *ChatPipelineService) retrieveWebSearch( provider.APIKey, question, ) + case webSearchProviderSerply: + return retrieveSerplyWebSearch( + ctx, + serplyWebSearchHTTPClient, + serplyWebSearchEndpoint, + provider.APIKey, + question, + ) default: return nil, fmt.Errorf("unsupported web search provider %q", provider.Provider) } @@ -118,6 +134,14 @@ func (dr *DeepResearcher) retrieveWebSearch( provider.APIKey, query, ) + case webSearchProviderSerply: + return retrieveSerplyWebSearch( + ctx, + serplyWebSearchHTTPClient, + serplyWebSearchEndpoint, + provider.APIKey, + query, + ) default: return nil, fmt.Errorf("unsupported web search provider %q", provider.Provider) } @@ -244,3 +268,111 @@ func decodeQueritWebSearchResults(responseBody []byte) ([]queritWebSearchResult, } return results, nil } + +type serplyWebSearchResult struct { + Title string `json:"title"` + Link string `json:"link"` + Description string `json:"description"` +} + +func retrieveSerplyWebSearch( + ctx context.Context, + client *http.Client, + endpoint string, + apiKey string, + query string, +) (map[string]interface{}, error) { + parameters := url.Values{} + parameters.Set("q", query) + parameters.Set("num", "6") + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+parameters.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("serply: new request: %w", err) + } + request.Header.Set("Accept", "application/json") + request.Header.Set("X-Api-Key", apiKey) + // Serply sits behind Cloudflare, which rejects requests without an + // explicit User-Agent, so always send one. + request.Header.Set("User-Agent", "ragflow-web-search") + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("serply: do request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("serply: status %d", response.StatusCode) + } + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("serply: read response: %w", err) + } + results, err := decodeSerplyWebSearchResults(responseBody) + if err != nil { + return nil, err + } + + chunks := make([]map[string]interface{}, 0, len(results)) + docAggs := make([]interface{}, 0, len(results)) + for _, result := range results { + description := strings.TrimSpace(result.Description) + if description == "" { + continue + } + chunkID := "serply-" + result.Link + chunks = append(chunks, map[string]interface{}{ + "chunk_id": chunkID, + "content_ltks": tokenizeText(description), + "content_with_weight": description, + "doc_id": chunkID, + "docnm_kwd": result.Title, + "kb_id": []interface{}{}, + "important_kwd": []interface{}{}, + "image_id": "", + "similarity": float64(1), + "vector_similarity": float64(1), + "term_similarity": float64(0), + "vector": []float64{}, + "positions": []interface{}{}, + "url": result.Link, + }) + docAggs = append(docAggs, map[string]interface{}{ + "doc_name": result.Title, + "doc_id": chunkID, + "count": 1, + "url": result.Link, + }) + } + + return map[string]interface{}{ + "chunks": chunks, + "doc_aggs": docAggs, + }, nil +} + +func decodeSerplyWebSearchResults(responseBody []byte) ([]serplyWebSearchResult, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(responseBody, &envelope); err != nil { + return nil, fmt.Errorf("serply: decode response: %w", err) + } + if envelope == nil { + return nil, fmt.Errorf("serply: response must be an object") + } + + resultsValue, exists := envelope["results"] + if !exists { + return []serplyWebSearchResult{}, nil + } + if strings.TrimSpace(string(resultsValue)) == "null" { + return nil, fmt.Errorf("serply: response field results must be an array") + } + + var results []serplyWebSearchResult + if err := json.Unmarshal(resultsValue, &results); err != nil { + return nil, fmt.Errorf("serply: response field results must be an array: %w", err) + } + return results, nil +} diff --git a/internal/service/web_search_provider_test.go b/internal/service/web_search_provider_test.go index 0deddb2d51..3fd0870c90 100644 --- a/internal/service/web_search_provider_test.go +++ b/internal/service/web_search_provider_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" ) @@ -92,6 +93,24 @@ func TestResolveWebSearchProviderTrimsSelectedKey(t *testing.T) { } } +func TestResolveWebSearchProviderUsesSelectedSerplyConfig(t *testing.T) { + provider := resolveWebSearchProvider(map[string]interface{}{ + "web_search_provider": "serply", + "serply_api_key": "serply-test", + "tavily_api_key": "tvly-test", + }) + + if provider == nil { + t.Fatal("provider is nil") + } + if provider.Provider != webSearchProviderSerply { + t.Fatalf("provider = %q, want %q", provider.Provider, webSearchProviderSerply) + } + if provider.APIKey != "serply-test" { + t.Fatalf("api key = %q, want %q", provider.APIKey, "serply-test") + } +} + func TestResolveWebSearchProviderRequiresKeyForSelectedProvider(t *testing.T) { cases := []struct { name string @@ -99,6 +118,14 @@ func TestResolveWebSearchProviderRequiresKeyForSelectedProvider(t *testing.T) { }{ {name: "tavily", config: map[string]interface{}{"web_search_provider": "tavily"}}, {name: "querit", config: map[string]interface{}{"web_search_provider": "querit"}}, + {name: "serply", config: map[string]interface{}{"web_search_provider": "serply"}}, + { + name: "serply does not fall back to tavily", + config: map[string]interface{}{ + "web_search_provider": "serply", + "tavily_api_key": "tvly-test", + }, + }, { name: "querit whitespace key", config: map[string]interface{}{ @@ -220,3 +247,127 @@ func TestDecodeQueritWebSearchResultsRejectsMalformedContainers(t *testing.T) { }) } } + +func TestRetrieveSerplyWebSearchSendsHeadersAndReturnsReferenceShape(t *testing.T) { + ctx := t.Context() + var requestQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("X-Api-Key"); got != "serply-test" { + t.Errorf("X-Api-Key = %q, want %q", got, "serply-test") + } + if got := request.Header.Get("User-Agent"); got == "" { + t.Error("User-Agent is empty; Serply rejects requests without one") + } + requestQuery = request.URL.Query() + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{ + "results": [{ + "title": "RAGFlow", + "link": "https://example.com/ragflow", + "description": "RAGFlow is an open-source RAG engine." + }] + }`)) + })) + defer server.Close() + + result, err := retrieveSerplyWebSearch( + ctx, + server.Client(), + server.URL, + "serply-test", + "What is RAGFlow?", + ) + if err != nil { + t.Fatalf("retrieve Serply web search: %v", err) + } + + if got := requestQuery.Get("q"); got != "What is RAGFlow?" { + t.Fatalf("q = %q, want %q", got, "What is RAGFlow?") + } + if got := requestQuery.Get("num"); got != "6" { + t.Fatalf("num = %q, want %q", got, "6") + } + + chunks, ok := result["chunks"].([]map[string]interface{}) + if !ok || len(chunks) != 1 { + t.Fatalf("chunks = %#v, want one chunk", result["chunks"]) + } + if chunks[0]["content_with_weight"] != "RAGFlow is an open-source RAG engine." { + t.Fatalf("content = %#v", chunks[0]["content_with_weight"]) + } + if chunks[0]["docnm_kwd"] != "RAGFlow" { + t.Fatalf("title = %#v", chunks[0]["docnm_kwd"]) + } + if chunks[0]["url"] != "https://example.com/ragflow" { + t.Fatalf("url = %#v", chunks[0]["url"]) + } + if chunks[0]["similarity"] != float64(1) { + t.Fatalf("similarity = %#v, want 1", chunks[0]["similarity"]) + } + + aggs, ok := result["doc_aggs"].([]interface{}) + if !ok || len(aggs) != 1 { + t.Fatalf("doc_aggs = %#v, want one aggregate", result["doc_aggs"]) + } +} + +func TestRetrieveSerplyWebSearchSkipsResultsWithoutDescription(t *testing.T) { + ctx := t.Context() + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{ + "results": [ + {"title": "No snippet", "link": "https://example.com/empty", "description": ""}, + {"title": "Blank snippet", "link": "https://example.com/blank", "description": " \t\n"}, + {"title": "RAGFlow", "link": "https://example.com/ragflow", "description": " \tAn open-source RAG engine.\n"} + ] + }`)) + })) + defer server.Close() + + result, err := retrieveSerplyWebSearch(ctx, server.Client(), server.URL, "serply-test", "ragflow") + if err != nil { + t.Fatalf("retrieve Serply web search: %v", err) + } + + chunks, ok := result["chunks"].([]map[string]interface{}) + if !ok || len(chunks) != 1 { + t.Fatalf("chunks = %#v, want one chunk", result["chunks"]) + } + if chunks[0]["docnm_kwd"] != "RAGFlow" { + t.Fatalf("title = %#v", chunks[0]["docnm_kwd"]) + } + if chunks[0]["content_with_weight"] != "An open-source RAG engine." { + t.Fatalf("content = %#v", chunks[0]["content_with_weight"]) + } +} + +func TestDecodeSerplyWebSearchResultsRejectsMalformedContainers(t *testing.T) { + cases := []struct { + name string + body string + }{ + {name: "null response", body: `null`}, + {name: "null results", body: `{"results":null}`}, + {name: "object results", body: `{"results":{}}`}, + {name: "string results", body: `{"results":"nope"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := decodeSerplyWebSearchResults([]byte(tc.body)); err == nil { + t.Fatal("error is nil") + } + }) + } +} + +func TestDecodeSerplyWebSearchResultsAcceptsMissingResults(t *testing.T) { + results, err := decodeSerplyWebSearchResults([]byte(`{"total": 0}`)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(results) != 0 { + t.Fatalf("results = %#v, want empty", results) + } +} diff --git a/rag/utils/serply_conn.py b/rag/utils/serply_conn.py new file mode 100644 index 0000000000..92949f182b --- /dev/null +++ b/rag/utils/serply_conn.py @@ -0,0 +1,123 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +from typing import Any + +import requests + +from common.http_client import DEFAULT_TIMEOUT +from common.misc_utils import get_uuid +from rag.nlp import rag_tokenizer + +logger = logging.getLogger(__name__) + +SERPLY_SEARCH_URL = "https://api.serply.io/v1/search/" + + +class Serply: + def __init__(self, api_key: str): + self.api_key = api_key + + def search(self, query: str) -> list[dict[str, Any]]: + try: + response = requests.get( + SERPLY_SEARCH_URL, + headers={ + "Accept": "application/json", + "X-Api-Key": self.api_key, + # Serply sits behind Cloudflare, which rejects requests + # without an explicit User-Agent, so always send one. + "User-Agent": "ragflow-web-search", + }, + params={ + "q": query, + "num": 6, + }, + timeout=DEFAULT_TIMEOUT, + ) + response.raise_for_status() + response_data = response.json() + if not isinstance(response_data, dict): + raise TypeError("Serply API response must be a JSON object.") + + results = response_data.get("results", []) + if not isinstance(results, list): + raise TypeError("Serply API response field results must be an array.") + + normalized_results = [] + for result in results: + if not isinstance(result, dict): + continue + content = _serply_text(result.get("description")).strip() + if not content: + continue + normalized_results.append( + { + "url": _serply_text(result.get("link")), + "title": _serply_text(result.get("title")), + "content": content, + "score": 1.0, + } + ) + return normalized_results + except (requests.RequestException, TypeError, ValueError) as error: + logger.error("Serply search failed: %s", _safe_error_message(error, self.api_key)) + return [] + + def retrieve_chunks(self, question: str) -> dict[str, list]: + chunks = [] + doc_aggs = [] + results = self.search(question) + logger.info("Serply search returned %d results", len(results)) + for result in results: + chunk_id = get_uuid() + chunks.append( + { + "chunk_id": chunk_id, + "content_ltks": rag_tokenizer.tokenize(result["content"]), + "content_with_weight": result["content"], + "doc_id": chunk_id, + "docnm_kwd": result["title"], + "kb_id": [], + "important_kwd": [], + "image_id": "", + "similarity": result["score"], + "vector_similarity": 1.0, + "term_similarity": 0, + "vector": [], + "positions": [], + "url": result["url"], + } + ) + doc_aggs.append( + { + "doc_name": result["title"], + "doc_id": chunk_id, + "count": 1, + "url": result["url"], + } + ) + return {"chunks": chunks, "doc_aggs": doc_aggs} + + +def _serply_text(value: Any) -> str: + return "" if value is None else str(value) + + +def _safe_error_message(error: Exception, api_key: str) -> str: + message = str(error) or error.__class__.__name__ + return message.replace(api_key, "[REDACTED]") if api_key else message diff --git a/rag/utils/web_search_conn.py b/rag/utils/web_search_conn.py index 2739c3eab3..6e6e428766 100644 --- a/rag/utils/web_search_conn.py +++ b/rag/utils/web_search_conn.py @@ -18,10 +18,12 @@ import logging from typing import Protocol from rag.utils.querit_conn import Querit +from rag.utils.serply_conn import Serply from rag.utils.tavily_conn import Tavily WEB_SEARCH_PROVIDER_TAVILY = "tavily" WEB_SEARCH_PROVIDER_QUERIT = "querit" +WEB_SEARCH_PROVIDER_SERPLY = "serply" logger = logging.getLogger(__name__) @@ -44,6 +46,8 @@ def has_web_search_provider(prompt_config: dict | None) -> bool: return bool(_get_api_key(prompt_config, "tavily_api_key")) if provider == WEB_SEARCH_PROVIDER_QUERIT: return bool(_get_api_key(prompt_config, "querit_api_key")) + if provider == WEB_SEARCH_PROVIDER_SERPLY: + return bool(_get_api_key(prompt_config, "serply_api_key")) return False @@ -53,7 +57,7 @@ def create_web_search_provider(prompt_config: dict | None) -> WebSearchProvider return None provider = prompt_config.get("web_search_provider", WEB_SEARCH_PROVIDER_TAVILY) - if provider not in (WEB_SEARCH_PROVIDER_TAVILY, WEB_SEARCH_PROVIDER_QUERIT): + if provider not in (WEB_SEARCH_PROVIDER_TAVILY, WEB_SEARCH_PROVIDER_QUERIT, WEB_SEARCH_PROVIDER_SERPLY): logger.debug("Web search provider resolution: provider=%s status=invalid", provider) return None if not has_web_search_provider(prompt_config): @@ -63,4 +67,6 @@ def create_web_search_provider(prompt_config: dict | None) -> WebSearchProvider logger.debug("Web search provider resolution: provider=%s status=resolved", provider) if provider == WEB_SEARCH_PROVIDER_QUERIT: return Querit(_get_api_key(prompt_config, "querit_api_key")) + if provider == WEB_SEARCH_PROVIDER_SERPLY: + return Serply(_get_api_key(prompt_config, "serply_api_key")) return Tavily(_get_api_key(prompt_config, "tavily_api_key")) diff --git a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py index 379f0a31c8..75e368de30 100644 --- a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py +++ b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py @@ -1688,6 +1688,24 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch): assert res["code"] == 0 assert res["data"]["has_web_search_provider"] is True + # Explicit Serply configuration also enables the provider-neutral flag. + serply_dialog = SimpleNamespace( + name="My Serply Bot", + icon="avatar.png", + tenant_id="tenant-1", + status="1", + llm_id="", + prompt_config={ + "prologue": "Hello!", + "web_search_provider": "serply", + "serply_api_key": "serply-key123", + }, + ) + monkeypatch.setattr(module.DialogService, "get_by_id", lambda _dialog_id: (True, serply_dialog)) + res = _run(inspect.unwrap(module.chatbots_inputs)("dialog-serply")) + assert res["code"] == 0 + assert res["data"]["has_web_search_provider"] is True + @pytest.mark.p2 def test_agentbot_routes_auth_stream_nonstream_unit(monkeypatch): diff --git a/test/unit_test/rag/utils/test_serply_conn.py b/test/unit_test/rag/utils/test_serply_conn.py new file mode 100644 index 0000000000..5f79c0c547 --- /dev/null +++ b/test/unit_test/rag/utils/test_serply_conn.py @@ -0,0 +1,144 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from rag.utils import serply_conn + + +class _Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return { + "results": [ + { + "title": "RAGFlow", + "link": "https://example.com/ragflow", + "description": " \tRAGFlow is an open-source RAG engine.\n", + }, + { + "title": "No snippet", + "link": "https://example.com/empty", + "description": "", + }, + { + "title": "Blank snippet", + "link": "https://example.com/blank", + "description": " \t\n", + }, + ] + } + + +def test_serply_search_uses_chat_defaults_and_normalizes_results(monkeypatch): + request = {} + + def fake_get(url, *, headers, params, timeout): + request.update(url=url, headers=headers, params=params, timeout=timeout) + return _Response() + + monkeypatch.setattr(serply_conn.requests, "get", fake_get) + + results = serply_conn.Serply("serply-test").search("What is RAGFlow?") + + assert request["url"] == "https://api.serply.io/v1/search/" + assert request["headers"]["X-Api-Key"] == "serply-test" + assert request["headers"]["User-Agent"] + assert request["params"] == { + "q": "What is RAGFlow?", + "num": 6, + } + assert results == [ + { + "url": "https://example.com/ragflow", + "title": "RAGFlow", + "content": "RAGFlow is an open-source RAG engine.", + "score": 1.0, + } + ] + + +def test_serply_search_rejects_malformed_results_container(monkeypatch): + class _MalformedResponse: + def raise_for_status(self): + return None + + def json(self): + return {"results": {}} + + monkeypatch.setattr(serply_conn.requests, "get", lambda *_args, **_kwargs: _MalformedResponse()) + + assert serply_conn.Serply("serply-test").search("RAGFlow") == [] + + +def test_serply_retrieve_chunks_returns_ragflow_reference_shape(monkeypatch): + monkeypatch.setattr( + serply_conn.Serply, + "search", + lambda _self, _question: [ + { + "url": "https://example.com/ragflow", + "title": "RAGFlow", + "content": "RAGFlow is an open-source RAG engine.", + "score": 1.0, + } + ], + ) + monkeypatch.setattr(serply_conn, "get_uuid", lambda: "chunk-1") + monkeypatch.setattr(serply_conn.rag_tokenizer, "tokenize", lambda content: f"tokens:{content}") + + result = serply_conn.Serply("serply-test").retrieve_chunks("What is RAGFlow?") + + assert result["chunks"] == [ + { + "chunk_id": "chunk-1", + "content_ltks": "tokens:RAGFlow is an open-source RAG engine.", + "content_with_weight": "RAGFlow is an open-source RAG engine.", + "doc_id": "chunk-1", + "docnm_kwd": "RAGFlow", + "kb_id": [], + "important_kwd": [], + "image_id": "", + "similarity": 1.0, + "vector_similarity": 1.0, + "term_similarity": 0, + "vector": [], + "positions": [], + "url": "https://example.com/ragflow", + } + ] + assert result["doc_aggs"] == [ + { + "doc_name": "RAGFlow", + "doc_id": "chunk-1", + "count": 1, + "url": "https://example.com/ragflow", + } + ] + + +def test_serply_search_redacts_api_key_from_failures(monkeypatch, caplog): + class _FailedResponse: + def raise_for_status(self): + raise ValueError("request failed with serply-secret") + + monkeypatch.setattr(serply_conn.requests, "get", lambda *_args, **_kwargs: _FailedResponse()) + + assert serply_conn.Serply("serply-secret").search("RAGFlow") == [] + assert "serply-secret" not in caplog.text + assert "[REDACTED]" in caplog.text diff --git a/test/unit_test/rag/utils/test_web_search_conn.py b/test/unit_test/rag/utils/test_web_search_conn.py index 98a09b4cdc..eb66f2d4d1 100644 --- a/test/unit_test/rag/utils/test_web_search_conn.py +++ b/test/unit_test/rag/utils/test_web_search_conn.py @@ -47,6 +47,24 @@ def test_create_web_search_provider_uses_selected_querit_config(monkeypatch): assert created_with == ["querit-test"] +def test_create_web_search_provider_uses_selected_serply_config(monkeypatch): + created_with = [] + provider = object() + + monkeypatch.setattr(web_search_conn, "Serply", lambda api_key: created_with.append(api_key) or provider) + + result = web_search_conn.create_web_search_provider( + { + "web_search_provider": "serply", + "serply_api_key": "serply-test", + "tavily_api_key": "tvly-test", + } + ) + + assert result is provider + assert created_with == ["serply-test"] + + def test_create_web_search_provider_trims_selected_key(monkeypatch): created_with = [] provider = object() @@ -70,6 +88,7 @@ def test_create_web_search_provider_requires_key_for_selected_provider(): assert web_search_conn.create_web_search_provider({"web_search_provider": "tavily"}) is None assert web_search_conn.create_web_search_provider({"web_search_provider": "querit"}) is None assert web_search_conn.create_web_search_provider({"tavily_api_key": " "}) is None + assert web_search_conn.create_web_search_provider({"web_search_provider": "serply"}) is None assert ( web_search_conn.create_web_search_provider( { @@ -79,6 +98,15 @@ def test_create_web_search_provider_requires_key_for_selected_provider(): ) is None ) + assert ( + web_search_conn.create_web_search_provider( + { + "web_search_provider": "serply", + "serply_api_key": " ", + } + ) + is None + ) def test_has_web_search_provider_follows_selected_provider(): @@ -92,6 +120,14 @@ def test_has_web_search_provider_follows_selected_provider(): "tavily_api_key": "tvly-test", } ) + assert web_search_conn.has_web_search_provider({"web_search_provider": "serply", "serply_api_key": "serply-test"}) + assert not web_search_conn.has_web_search_provider( + { + "web_search_provider": "serply", + "serply_api_key": "", + "tavily_api_key": "tvly-test", + } + ) assert not web_search_conn.has_web_search_provider( { "web_search_provider": "unsupported", diff --git a/web/src/assets/serply.png b/web/src/assets/serply.png new file mode 100644 index 0000000000..1ee1975f36 Binary files /dev/null and b/web/src/assets/serply.png differ diff --git a/web/src/components/web-search-form-field.tsx b/web/src/components/web-search-form-field.tsx index 7d1bd62790..dda7515572 100644 --- a/web/src/components/web-search-form-field.tsx +++ b/web/src/components/web-search-form-field.tsx @@ -15,6 +15,7 @@ */ import queritLogo from '@/assets/querit.png'; +import serplyLogo from '@/assets/serply.png'; import tavilyLogo from '@/assets/svg/tavily.svg'; import { RAGFlowSelect } from '@/components/ui/select'; import { WebSearchProvider } from '@/constants/chat'; @@ -46,6 +47,11 @@ const providerOptions = [ logo: queritLogo, value: WebSearchProvider.Querit, }, + { + name: 'Serply', + logo: serplyLogo, + value: WebSearchProvider.Serply, + }, ] .sort((left, right) => left.name.localeCompare(right.name)) .map(({ name, logo, value }) => ({ @@ -78,6 +84,13 @@ const providerKeyConfig = { placeholder: 'queritApiKeyMessage', helpUrl: 'https://querit.ai', }, + [WebSearchProvider.Serply]: { + name: 'prompt_config.serply_api_key', + label: 'Serply API Key', + tip: 'serplyApiKeyTip', + placeholder: 'serplyApiKeyMessage', + helpUrl: 'https://serply.io', + }, } as const; export function WebSearchFormField({ prefix = '' }: IProps) { diff --git a/web/src/constants/chat.ts b/web/src/constants/chat.ts index 0bfb6aad6d..021ce50cef 100644 --- a/web/src/constants/chat.ts +++ b/web/src/constants/chat.ts @@ -59,4 +59,5 @@ export enum DatasetMetadata { export enum WebSearchProvider { Tavily = 'tavily', Querit = 'querit', + Serply = 'serply', } diff --git a/web/src/interfaces/database/chat.ts b/web/src/interfaces/database/chat.ts index d3170fc073..f981963daf 100644 --- a/web/src/interfaces/database/chat.ts +++ b/web/src/interfaces/database/chat.ts @@ -22,6 +22,7 @@ export interface PromptConfig { cross_languages?: Array; tavily_api_key?: string; querit_api_key?: string; + serply_api_key?: string; web_search_provider?: WebSearchProvider; toc_enhance?: boolean; reference_metadata?: { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index fb5f07960e..4ae62a0eea 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1203,6 +1203,9 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s queritApiKeyTip: 'When Querit is selected, its web search results supplement dataset retrieval.', queritApiKeyMessage: 'Please enter your Querit API Key', + serplyApiKeyTip: + 'When Serply is selected, its web search results supplement dataset retrieval.', + serplyApiKeyMessage: 'Please enter your Serply API Key', tavilyApiKeyHelp: 'How to get it?', crossLanguage: 'Cross-language search', crossLanguagePlaceholder: 'Select value', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 316e64db60..68512e16aa 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1092,6 +1092,8 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R queritApiKeyTip: '选择 Querit 后,将使用 Querit 的网络搜索结果补充知识库检索。', queritApiKeyMessage: '请输入你的 Querit API Key', + serplyApiKeyTip: '选择 Serply 后,将使用 Serply 的网络搜索结果补充知识库检索。', + serplyApiKeyMessage: '请输入你的 Serply API Key', tavilyApiKeyHelp: '如何获取?', crossLanguage: '跨语言搜索', crossLanguagePlaceholder: '请选择', diff --git a/web/src/pages/next-chats/chat/app-settings/use-chat-setting-schema.tsx b/web/src/pages/next-chats/chat/app-settings/use-chat-setting-schema.tsx index cfc1c34157..6f0fba63b3 100644 --- a/web/src/pages/next-chats/chat/app-settings/use-chat-setting-schema.tsx +++ b/web/src/pages/next-chats/chat/app-settings/use-chat-setting-schema.tsx @@ -35,8 +35,13 @@ export function useChatSettingSchema(staleDatasetIds: Set) { .optional(), tavily_api_key: z.string().optional(), querit_api_key: z.string().optional(), + serply_api_key: z.string().optional(), web_search_provider: z - .enum([WebSearchProvider.Tavily, WebSearchProvider.Querit]) + .enum([ + WebSearchProvider.Tavily, + WebSearchProvider.Querit, + WebSearchProvider.Serply, + ]) .optional(), reasoning: z.boolean().optional(), cross_languages: z.array(z.string()).optional(), diff --git a/web/src/pages/next-chats/chat/use-show-internet.test.ts b/web/src/pages/next-chats/chat/use-show-internet.test.ts index 06f97a88dc..34bf3f653a 100644 --- a/web/src/pages/next-chats/chat/use-show-internet.test.ts +++ b/web/src/pages/next-chats/chat/use-show-internet.test.ts @@ -35,6 +35,25 @@ describe('getWebSearchApiKey', () => { expect(getWebSearchApiKey(promptConfig)).toBe('querit-test'); }); + it('uses only the selected Serply key', () => { + const promptConfig = { + web_search_provider: WebSearchProvider.Serply, + serply_api_key: 'serply-test', + tavily_api_key: 'tvly-test', + } as PromptConfig; + + expect(getWebSearchApiKey(promptConfig)).toBe('serply-test'); + }); + + it('does not fall back to Tavily when Serply is selected without a key', () => { + const promptConfig = { + web_search_provider: WebSearchProvider.Serply, + tavily_api_key: 'tvly-test', + } as PromptConfig; + + expect(getWebSearchApiKey(promptConfig)).toBeUndefined(); + }); + it('does not fall back to Tavily when Querit is selected without a key', () => { const promptConfig = { web_search_provider: WebSearchProvider.Querit, diff --git a/web/src/pages/next-chats/chat/web-search-api-key.ts b/web/src/pages/next-chats/chat/web-search-api-key.ts index 554b94d5c4..6a7aabf298 100644 --- a/web/src/pages/next-chats/chat/web-search-api-key.ts +++ b/web/src/pages/next-chats/chat/web-search-api-key.ts @@ -6,7 +6,8 @@ export function getWebSearchProvider(promptConfig?: PromptConfig) { if ( provider === WebSearchProvider.Tavily || - provider === WebSearchProvider.Querit + provider === WebSearchProvider.Querit || + provider === WebSearchProvider.Serply ) { return provider; } @@ -33,6 +34,9 @@ export function getWebSearchApiKey(promptConfig?: PromptConfig) { case WebSearchProvider.Querit: apiKey = promptConfig?.querit_api_key; break; + case WebSearchProvider.Serply: + apiKey = promptConfig?.serply_api_key; + break; default: return undefined; }