From 09a9b629eedebabd010f865de14a488e55776441 Mon Sep 17 00:00:00 2001 From: Brian Sparker Date: Wed, 19 Aug 2026 21:12:18 -0700 Subject: [PATCH] feat(chat): add You.com web search provider (#18478) ### Summary Adds You.com as a built-in Web Search provider for RAGFlow Chat, alongside Tavily and Querit, using the provider-neutral dispatch #17813 put in place. No changes to existing Tavily or Querit behaviour. You.com runs its own web index and returns several extracted passages per result rather than a single meta description, so retrieved chunks arrive with usable context. --------- Co-authored-by: Brian Sparker Co-authored-by: Claude Opus 5 (1M context) --- docs/references/http_api_reference.md | 3 +- internal/service/web_search_provider.go | 165 +++++++++++- internal/service/web_search_provider_test.go | 251 ++++++++++++++++++ rag/utils/web_search_conn.py | 18 +- rag/utils/youcom_conn.py | 152 +++++++++++ .../test_session_sdk_routes_unit.py | 17 ++ .../rag/utils/test_web_search_conn.py | 44 +++ test/unit_test/rag/utils/test_youcom_conn.py | 210 +++++++++++++++ web/src/assets/svg/youcom.svg | 10 + web/src/components/web-search-form-field.tsx | 14 + web/src/constants/chat.ts | 9 + web/src/interfaces/database/chat.ts | 1 + web/src/locales/en.ts | 3 + web/src/locales/zh.ts | 3 + .../app-settings/use-chat-setting-schema.tsx | 2 + .../next-chats/chat/use-show-internet.test.ts | 55 +++- .../next-chats/chat/use-show-internet.ts | 5 +- .../next-chats/chat/web-search-api-key.ts | 28 +- 18 files changed, 981 insertions(+), 9 deletions(-) create mode 100644 rag/utils/youcom_conn.py create mode 100644 test/unit_test/rag/utils/test_youcom_conn.py create mode 100644 web/src/assets/svg/youcom.svg diff --git a/docs/references/http_api_reference.md b/docs/references/http_api_reference.md index 5088f30ef2..25b1592dfc 100644 --- a/docs/references/http_api_reference.md +++ b/docs/references/http_api_reference.md @@ -3032,10 +3032,11 @@ 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"`, `"querit"`, and `"serply"`. Defaults to `"tavily"` when omitted. + - `"web_search_provider"`: `string` The web search service to use. Supported values are `"tavily"`, `"querit"`, `"serply"`, and `"youcom"`. If omitted, Tavily is selected only when `"tavily_api_key"` is configured; otherwise web search is disabled. - `"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. + - `"youcom_api_key"`: `string` The You.com API key. Set `web_search_provider` to `"youcom"` when using this field. Optional: You.com serves a rate-limited keyless endpoint, so `"youcom"` works with this field omitted, and a key lifts those limits. - `"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 9ee5b5e890..5cb7272b3d 100644 --- a/internal/service/web_search_provider.go +++ b/internal/service/web_search_provider.go @@ -24,6 +24,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" ) @@ -32,13 +33,25 @@ const ( webSearchProviderTavily = "tavily" webSearchProviderQuerit = "querit" webSearchProviderSerply = "serply" + webSearchProviderYouCom = "youcom" queritWebSearchEndpoint = "https://api.querit.ai/v1/search" serplyWebSearchEndpoint = "https://api.serply.io/v1/search/" + // You.com serves the same response shape from two endpoints. The keyless + // one is rate-limited but needs no credentials; the keyed one lifts those + // limits. The keyless endpoint rejects an X-API-Key header, so the endpoint + // and the headers are always chosen together. + youComWebSearchEndpoint = "https://api.you.com/v1/search" + youComKeylessWebSearchEndpoint = "https://api.you.com/v1/agents/search" + youComWebSearchResultCount = 6 + // Identifies RAGFlow to You.com. On the keyless endpoint there is no key to + // attribute traffic to, so this is the only signal available. + youComWebSearchUserAgent = "RAGFlow youdotcom-integration/infiniflow-ragflow" ) var ( queritWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} serplyWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} + youComWebSearchHTTPClient = &http.Client{Timeout: 30 * time.Second} ) type webSearchProviderConfig struct { @@ -61,6 +74,9 @@ func resolveWebSearchProvider(promptConfig map[string]interface{}) *webSearchPro } apiKeyField := "" + // You.com is usable with no credentials at all; every other provider here + // requires a key before it can be selected. + keyOptional := false switch provider { case webSearchProviderTavily: apiKeyField = "tavily_api_key" @@ -68,13 +84,16 @@ func resolveWebSearchProvider(promptConfig map[string]interface{}) *webSearchPro apiKeyField = "querit_api_key" case webSearchProviderSerply: apiKeyField = "serply_api_key" + case webSearchProviderYouCom: + apiKeyField = "youcom_api_key" + keyOptional = true default: return nil } apiKey, _ := promptConfig[apiKeyField].(string) apiKey = strings.TrimSpace(apiKey) - if apiKey == "" { + if apiKey == "" && !keyOptional { return nil } return &webSearchProviderConfig{ @@ -110,6 +129,14 @@ func (s *ChatPipelineService) retrieveWebSearch( provider.APIKey, question, ) + case webSearchProviderYouCom: + return retrieveYouComWebSearch( + ctx, + youComWebSearchHTTPClient, + youComEndpointFor(provider.APIKey), + provider.APIKey, + question, + ) default: return nil, fmt.Errorf("unsupported web search provider %q", provider.Provider) } @@ -142,6 +169,14 @@ func (dr *DeepResearcher) retrieveWebSearch( provider.APIKey, query, ) + case webSearchProviderYouCom: + return retrieveYouComWebSearch( + ctx, + youComWebSearchHTTPClient, + youComEndpointFor(provider.APIKey), + provider.APIKey, + query, + ) default: return nil, fmt.Errorf("unsupported web search provider %q", provider.Provider) } @@ -376,3 +411,131 @@ func decodeSerplyWebSearchResults(responseBody []byte) ([]serplyWebSearchResult, } return results, nil } + +type youComWebSearchResult struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Snippets []string `json:"snippets"` +} + +type youComWebSearchResponse struct { + Results struct { + Web []youComWebSearchResult `json:"web"` + News []youComWebSearchResult `json:"news"` + } `json:"results"` +} + +// youComEndpointFor picks the keyless endpoint when no key is configured. The +// keyless endpoint rejects an X-API-Key header, so callers must never send a +// key to it. +func youComEndpointFor(apiKey string) string { + if strings.TrimSpace(apiKey) == "" { + return youComKeylessWebSearchEndpoint + } + return youComWebSearchEndpoint +} + +// youComContent prefers the extracted page passages. News hits carry only a +// description. +func youComContent(result youComWebSearchResult) string { + passages := make([]string, 0, len(result.Snippets)) + for _, snippet := range result.Snippets { + if strings.TrimSpace(snippet) != "" { + passages = append(passages, snippet) + } + } + if len(passages) > 0 { + return strings.Join(passages, "\n") + } + return strings.TrimSpace(result.Description) +} + +func retrieveYouComWebSearch( + ctx context.Context, + client *http.Client, + endpoint string, + apiKey string, + query string, +) (map[string]interface{}, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("youcom: new request: %w", err) + } + queryParams := request.URL.Query() + queryParams.Set("query", query) + queryParams.Set("count", strconv.Itoa(youComWebSearchResultCount)) + request.URL.RawQuery = queryParams.Encode() + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", youComWebSearchUserAgent) + if trimmedKey := strings.TrimSpace(apiKey); trimmedKey != "" { + request.Header.Set("X-API-Key", trimmedKey) + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("youcom: do request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("youcom: status %d", response.StatusCode) + } + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("youcom: read response: %w", err) + } + + var decoded youComWebSearchResponse + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return nil, fmt.Errorf("youcom: decode response: %w", err) + } + + // `count` applies per response section, so web and news together can exceed + // it. Web results lead; the merged list is trimmed back afterwards. + merged := make([]youComWebSearchResult, 0, len(decoded.Results.Web)+len(decoded.Results.News)) + merged = append(merged, decoded.Results.Web...) + merged = append(merged, decoded.Results.News...) + + chunks := make([]map[string]interface{}, 0, len(merged)) + docAggs := make([]interface{}, 0, len(merged)) + for _, result := range merged { + if len(chunks) >= youComWebSearchResultCount { + break + } + content := youComContent(result) + if content == "" { + continue + } + chunkID := "youcom-" + result.URL + chunks = append(chunks, map[string]interface{}{ + "chunk_id": chunkID, + "content_ltks": tokenizeText(content), + "content_with_weight": content, + "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.URL, + }) + docAggs = append(docAggs, map[string]interface{}{ + "doc_name": result.Title, + "doc_id": chunkID, + "count": 1, + "url": result.URL, + }) + } + + return map[string]interface{}{ + "chunks": chunks, + "doc_aggs": docAggs, + }, nil +} diff --git a/internal/service/web_search_provider_test.go b/internal/service/web_search_provider_test.go index 3fd0870c90..7e9c6812ca 100644 --- a/internal/service/web_search_provider_test.go +++ b/internal/service/web_search_provider_test.go @@ -18,6 +18,7 @@ package service import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -371,3 +372,253 @@ func TestDecodeSerplyWebSearchResultsAcceptsMissingResults(t *testing.T) { t.Fatalf("results = %#v, want empty", results) } } + +func TestResolveWebSearchProviderSelectsYouComWithoutAKey(t *testing.T) { + // You.com is the only provider usable with no credentials at all. + provider := resolveWebSearchProvider(map[string]interface{}{ + "web_search_provider": "youcom", + }) + + if provider == nil { + t.Fatal("provider is nil") + } + if provider.Provider != webSearchProviderYouCom { + t.Fatalf("provider = %q, want %q", provider.Provider, webSearchProviderYouCom) + } + if provider.APIKey != "" { + t.Fatalf("api key = %q, want empty", provider.APIKey) + } +} + +func TestResolveWebSearchProviderTrimsOptionalYouComKey(t *testing.T) { + provider := resolveWebSearchProvider(map[string]interface{}{ + "web_search_provider": "youcom", + "youcom_api_key": " ydc-test ", + "tavily_api_key": "tvly-test", + }) + + if provider == nil { + t.Fatal("provider is nil") + } + if provider.APIKey != "ydc-test" { + t.Fatalf("api key = %q, want %q", provider.APIKey, "ydc-test") + } +} + +func TestResolveWebSearchProviderStillRequiresKeysForKeyedProviders(t *testing.T) { + // The You.com carve-out must not relax any other provider. + for _, provider := range []string{"tavily", "querit", "serply"} { + t.Run(provider, func(t *testing.T) { + if got := resolveWebSearchProvider(map[string]interface{}{ + "web_search_provider": provider, + }); got != nil { + t.Fatalf("provider = %+v, want nil", got) + } + }) + } +} + +func TestYouComEndpointForPicksKeylessWithoutAKey(t *testing.T) { + cases := []struct { + name string + apiKey string + want string + }{ + {name: "no key", apiKey: "", want: youComKeylessWebSearchEndpoint}, + {name: "whitespace key", apiKey: " ", want: youComKeylessWebSearchEndpoint}, + {name: "key set", apiKey: "ydc-test", want: youComWebSearchEndpoint}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := youComEndpointFor(tc.apiKey); got != tc.want { + t.Fatalf("endpoint = %q, want %q", got, tc.want) + } + }) + } +} + +func TestYouComContentDiscardsBlankSnippetsAndDescriptions(t *testing.T) { + cases := []struct { + name string + result youComWebSearchResult + want string + }{ + { + name: "joins non-blank passages", + result: youComWebSearchResult{Snippets: []string{"a", " ", "b"}, Description: "ignored"}, + want: "a\nb", + }, + { + name: "falls back to the description", + result: youComWebSearchResult{Snippets: []string{" "}, Description: " desc "}, + want: "desc", + }, + { + name: "whitespace-only description yields nothing", + result: youComWebSearchResult{Description: " "}, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := youComContent(tc.result); got != tc.want { + t.Fatalf("content = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRetrieveYouComWebSearchSendsNoAuthHeaderWhenKeyless(t *testing.T) { + var gotAuth string + var gotUserAgent string + var gotQuery string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("X-API-Key") + gotUserAgent = r.Header.Get("User-Agent") + gotQuery = r.URL.Query().Get("query") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":{"web":[]}}`)) + })) + defer server.Close() + + if _, err := retrieveYouComWebSearch( + t.Context(), + server.Client(), + server.URL, + "", + "What is RAGFlow?", + ); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The keyless endpoint rejects an auth header, so none may be sent. + if gotAuth != "" { + t.Fatalf("X-API-Key = %q, want empty", gotAuth) + } + if gotUserAgent != youComWebSearchUserAgent { + t.Fatalf("User-Agent = %q, want %q", gotUserAgent, youComWebSearchUserAgent) + } + if gotQuery != "What is RAGFlow?" { + t.Fatalf("query = %q, want %q", gotQuery, "What is RAGFlow?") + } +} + +func TestRetrieveYouComWebSearchReturnsReferenceShape(t *testing.T) { + var gotAuth string + var gotCount string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("X-API-Key") + gotCount = r.URL.Query().Get("count") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":{"web":[{"url":"https://example.com/ragflow","title":"RAGFlow","description":"Meta description.","snippets":["First passage.","Second passage."]}],"news":[{"url":"https://news.example.com/ragflow","title":"RAGFlow ships","description":"News description only."}]}}`)) + })) + defer server.Close() + + result, err := retrieveYouComWebSearch( + t.Context(), + server.Client(), + server.URL, + "ydc-test", + "What is RAGFlow?", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotAuth != "ydc-test" { + t.Fatalf("X-API-Key = %q, want %q", gotAuth, "ydc-test") + } + if gotCount != "6" { + t.Fatalf("count = %q, want %q", gotCount, "6") + } + + chunks, ok := result["chunks"].([]map[string]interface{}) + if !ok { + t.Fatalf("chunks type = %T, want []map[string]interface{}", result["chunks"]) + } + if len(chunks) != 2 { + t.Fatalf("chunks = %d, want 2", len(chunks)) + } + // Web hits carry extracted passages; news hits fall back to the description. + if got := chunks[0]["content_with_weight"]; got != "First passage.\nSecond passage." { + t.Fatalf("web content = %q", got) + } + if got := chunks[1]["content_with_weight"]; got != "News description only." { + t.Fatalf("news content = %q", got) + } + if got := chunks[0]["url"]; got != "https://example.com/ragflow" { + t.Fatalf("url = %q", got) + } + + docAggs, ok := result["doc_aggs"].([]interface{}) + if !ok { + t.Fatalf("doc_aggs type = %T, want []interface{}", result["doc_aggs"]) + } + if len(docAggs) != 2 { + t.Fatalf("doc_aggs = %d, want 2", len(docAggs)) + } +} + +func TestRetrieveYouComWebSearchSkipsBlankContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":{"web":[{"url":"https://example.com/blank","title":"Blank","description":" "}]}}`)) + })) + defer server.Close() + + result, err := retrieveYouComWebSearch(t.Context(), server.Client(), server.URL, "", "q") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if chunks := result["chunks"].([]map[string]interface{}); len(chunks) != 0 { + t.Fatalf("chunks = %d, want 0", len(chunks)) + } +} + +func TestRetrieveYouComWebSearchCapsMergedSections(t *testing.T) { + web := make([]map[string]string, 0, 6) + news := make([]map[string]string, 0, 6) + for i := 0; i < 6; i++ { + web = append(web, map[string]string{"url": fmt.Sprintf("https://example.com/w%d", i), "description": "d"}) + news = append(news, map[string]string{"url": fmt.Sprintf("https://example.com/n%d", i), "description": "d"}) + } + payload, err := json.Marshal(map[string]interface{}{ + "results": map[string]interface{}{"web": web, "news": news}, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(payload) + })) + defer server.Close() + + result, err := retrieveYouComWebSearch(t.Context(), server.Client(), server.URL, "", "q") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // `count` applies per section, so the merged list is trimmed back to 6. + chunks := result["chunks"].([]map[string]interface{}) + if len(chunks) != youComWebSearchResultCount { + t.Fatalf("chunks = %d, want %d", len(chunks), youComWebSearchResultCount) + } +} + +func TestRetrieveYouComWebSearchRejectsErrorStatuses(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + })) + defer server.Close() + + if _, err := retrieveYouComWebSearch(t.Context(), server.Client(), server.URL, "", "q"); err == nil { + t.Fatal("expected an error for a non-2xx status") + } +} diff --git a/rag/utils/web_search_conn.py b/rag/utils/web_search_conn.py index 6e6e428766..08d9749441 100644 --- a/rag/utils/web_search_conn.py +++ b/rag/utils/web_search_conn.py @@ -20,10 +20,16 @@ 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 +from rag.utils.youcom_conn import YouCom WEB_SEARCH_PROVIDER_TAVILY = "tavily" WEB_SEARCH_PROVIDER_QUERIT = "querit" WEB_SEARCH_PROVIDER_SERPLY = "serply" +WEB_SEARCH_PROVIDER_YOUCOM = "youcom" + +# You.com serves a keyless endpoint, so it is usable with no credentials at all. +# Every other provider here requires a key before it can be selected. +KEYLESS_WEB_SEARCH_PROVIDERS = frozenset({WEB_SEARCH_PROVIDER_YOUCOM}) logger = logging.getLogger(__name__) @@ -42,6 +48,8 @@ def has_web_search_provider(prompt_config: dict | None) -> bool: if not prompt_config: return False provider = prompt_config.get("web_search_provider", WEB_SEARCH_PROVIDER_TAVILY) + if provider in KEYLESS_WEB_SEARCH_PROVIDERS: + return True if provider == WEB_SEARCH_PROVIDER_TAVILY: return bool(_get_api_key(prompt_config, "tavily_api_key")) if provider == WEB_SEARCH_PROVIDER_QUERIT: @@ -57,7 +65,12 @@ 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, WEB_SEARCH_PROVIDER_SERPLY): + if provider not in ( + WEB_SEARCH_PROVIDER_TAVILY, + WEB_SEARCH_PROVIDER_QUERIT, + WEB_SEARCH_PROVIDER_SERPLY, + WEB_SEARCH_PROVIDER_YOUCOM, + ): logger.debug("Web search provider resolution: provider=%s status=invalid", provider) return None if not has_web_search_provider(prompt_config): @@ -69,4 +82,7 @@ def create_web_search_provider(prompt_config: dict | None) -> WebSearchProvider 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")) + if provider == WEB_SEARCH_PROVIDER_YOUCOM: + # The key is optional: an empty one selects the keyless endpoint. + return YouCom(_get_api_key(prompt_config, "youcom_api_key")) return Tavily(_get_api_key(prompt_config, "tavily_api_key")) diff --git a/rag/utils/youcom_conn.py b/rag/utils/youcom_conn.py new file mode 100644 index 0000000000..ed7202eddd --- /dev/null +++ b/rag/utils/youcom_conn.py @@ -0,0 +1,152 @@ +# +# 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__) + +# You.com serves the same response shape from two endpoints. The keyless one is +# rate-limited but needs no credentials; the keyed one lifts those limits and +# exposes the full Search API. The keyless endpoint rejects an X-API-Key header, +# so the endpoint and the headers are always chosen together. +YOUCOM_SEARCH_URL = "https://api.you.com/v1/search" +YOUCOM_KEYLESS_SEARCH_URL = "https://api.you.com/v1/agents/search" +YOUCOM_RESULT_COUNT = 6 +# Identifies RAGFlow to You.com. On the keyless endpoint there is no key to +# attribute traffic to, so this is the only signal available. +YOUCOM_USER_AGENT = "RAGFlow youdotcom-integration/infiniflow-ragflow" + + +class YouCom: + def __init__(self, api_key: str = ""): + self.api_key = api_key.strip() if isinstance(api_key, str) else "" + + def search(self, query: str) -> list[dict[str, Any]]: + headers = { + "Accept": "application/json", + "User-Agent": YOUCOM_USER_AGENT, + } + url = YOUCOM_KEYLESS_SEARCH_URL + if self.api_key: + url = YOUCOM_SEARCH_URL + headers["X-API-Key"] = self.api_key + + try: + response = requests.get( + url, + headers=headers, + params={"query": query, "count": YOUCOM_RESULT_COUNT}, + timeout=DEFAULT_TIMEOUT, + ) + response.raise_for_status() + response_data = response.json() + if not isinstance(response_data, dict): + raise TypeError("You.com API response must be a JSON object.") + + results_container = response_data.get("results", {}) + if not isinstance(results_container, dict): + raise TypeError("You.com API response field results must be an object.") + + normalized_results = [] + # `count` applies per section, so web and news together can exceed + # it. Web results lead; the merged list is trimmed back afterwards. + for section in ("web", "news"): + section_results = results_container.get(section, []) + if section_results is None: + continue + if not isinstance(section_results, list): + raise TypeError(f"You.com API response field results.{section} must be an array.") + for result in section_results: + if not isinstance(result, dict): + continue + content = _youcom_content(result) + if not content: + continue + normalized_results.append( + { + "url": _youcom_text(result.get("url")), + "title": _youcom_text(result.get("title")), + "content": content, + "score": 1.0, + } + ) + return normalized_results[:YOUCOM_RESULT_COUNT] + except requests.HTTPError as error: + # Never log the exception message: requests builds it from the + # response URL, and the query is a URL parameter here. + status = error.response.status_code if error.response is not None else "unknown" + logger.error("You.com search failed: HTTP %s", status) + return [] + except (requests.RequestException, TypeError, ValueError) as error: + logger.error("You.com search failed: %s", type(error).__name__) + return [] + + def retrieve_chunks(self, question: str) -> dict[str, list]: + chunks = [] + doc_aggs = [] + for result in self.search(question): + 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"], + } + ) + # Deliberately logs counts only: the query and the retrieved page text + # are user data and must not reach the logs. + logger.info("[YouCom] retrieved %s chunks (keyed=%s)", len(chunks), bool(self.api_key)) + return {"chunks": chunks, "doc_aggs": doc_aggs} + + +def _youcom_content(result: dict[str, Any]) -> str: + """Prefer the extracted page passages; news hits only carry a description.""" + snippets = result.get("snippets") + if isinstance(snippets, list): + joined = "\n".join(_youcom_text(snippet) for snippet in snippets if _youcom_text(snippet).strip()) + if joined: + return joined + return _youcom_text(result.get("description")) + + +def _youcom_text(value: Any) -> str: + return "" if value is None else str(value) 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 75e368de30..b8dd422dd4 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 @@ -1706,6 +1706,23 @@ def test_chatbot_routes_auth_stream_nonstream_unit(monkeypatch): assert res["code"] == 0 assert res["data"]["has_web_search_provider"] is True + # You.com is keyless, so selecting it enables the flag with no key set. + youcom_dialog = SimpleNamespace( + name="My You.com Bot", + icon="avatar.png", + tenant_id="tenant-1", + status="1", + llm_id="", + prompt_config={ + "prologue": "Hello!", + "web_search_provider": "youcom", + }, + ) + monkeypatch.setattr(module.DialogService, "get_by_id", lambda _dialog_id: (True, youcom_dialog)) + res = _run(inspect.unwrap(module.chatbots_inputs)("dialog-youcom")) + 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_web_search_conn.py b/test/unit_test/rag/utils/test_web_search_conn.py index eb66f2d4d1..9c61bd4fcb 100644 --- a/test/unit_test/rag/utils/test_web_search_conn.py +++ b/test/unit_test/rag/utils/test_web_search_conn.py @@ -135,3 +135,47 @@ def test_has_web_search_provider_follows_selected_provider(): "tavily_api_key": "tvly-test", } ) + + +def test_create_web_search_provider_selects_youcom_without_a_key(monkeypatch): + """You.com is the only provider usable with no credentials at all.""" + created_with = [] + provider = object() + + monkeypatch.setattr(web_search_conn, "YouCom", lambda api_key: created_with.append(api_key) or provider) + + result = web_search_conn.create_web_search_provider({"web_search_provider": "youcom"}) + + assert result is provider + assert created_with == [""] + + +def test_create_web_search_provider_passes_the_optional_youcom_key(monkeypatch): + created_with = [] + provider = object() + + monkeypatch.setattr(web_search_conn, "YouCom", lambda api_key: created_with.append(api_key) or provider) + + result = web_search_conn.create_web_search_provider( + { + "web_search_provider": "youcom", + "youcom_api_key": " ydc-test ", + "tavily_api_key": "tvly-test", + } + ) + + assert result is provider + assert created_with == ["ydc-test"] + + +def test_has_web_search_provider_is_true_for_keyless_youcom(): + assert web_search_conn.has_web_search_provider({"web_search_provider": "youcom"}) + assert web_search_conn.has_web_search_provider({"web_search_provider": "youcom", "youcom_api_key": ""}) + assert web_search_conn.has_web_search_provider({"web_search_provider": "youcom", "youcom_api_key": "ydc-test"}) + + +def test_keyless_carve_out_does_not_relax_keyed_providers(): + """The You.com carve-out must not make any other provider key-optional.""" + for provider in ("tavily", "querit", "serply"): + assert not web_search_conn.has_web_search_provider({"web_search_provider": provider}) + assert web_search_conn.create_web_search_provider({"web_search_provider": provider}) is None diff --git a/test/unit_test/rag/utils/test_youcom_conn.py b/test/unit_test/rag/utils/test_youcom_conn.py new file mode 100644 index 0000000000..7dc6fddfb0 --- /dev/null +++ b/test/unit_test/rag/utils/test_youcom_conn.py @@ -0,0 +1,210 @@ +# +# 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 youcom_conn + + +class _Response: + status_code = 200 + + def __init__(self, payload=None): + self._payload = payload if payload is not None else _default_payload() + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +def _default_payload(): + return { + "results": { + "web": [ + { + "title": "RAGFlow", + "url": "https://example.com/ragflow", + "description": "Meta description.", + "snippets": ["RAGFlow is an open-source RAG engine.", "It does deep document understanding."], + } + ], + "news": [ + { + "title": "RAGFlow ships", + "url": "https://news.example.com/ragflow", + "description": "News description only.", + } + ], + } + } + + +def _capture_get(monkeypatch, response=None): + request = {} + + def fake_get(url, *, headers, params, timeout): + request.update(url=url, headers=headers, params=params, timeout=timeout) + return response if response is not None else _Response() + + monkeypatch.setattr(youcom_conn.requests, "get", fake_get) + return request + + +def test_youcom_search_uses_keyless_endpoint_without_a_key(monkeypatch): + request = _capture_get(monkeypatch) + + results = youcom_conn.YouCom("").search("What is RAGFlow?") + + assert request["url"] == "https://api.you.com/v1/agents/search" + # The keyless endpoint rejects an auth header, so none may be sent. + assert "X-API-Key" not in request["headers"] + assert request["params"] == {"query": "What is RAGFlow?", "count": 6} + assert len(results) == 2 + + +def test_youcom_search_uses_keyed_endpoint_when_a_key_is_set(monkeypatch): + request = _capture_get(monkeypatch) + + youcom_conn.YouCom("ydc-test").search("What is RAGFlow?") + + assert request["url"] == "https://api.you.com/v1/search" + assert request["headers"]["X-API-Key"] == "ydc-test" + + +def test_youcom_search_trims_the_key_and_identifies_ragflow(monkeypatch): + request = _capture_get(monkeypatch) + + youcom_conn.YouCom(" ydc-test ").search("What is RAGFlow?") + + assert request["headers"]["X-API-Key"] == "ydc-test" + assert request["headers"]["User-Agent"] == "RAGFlow youdotcom-integration/infiniflow-ragflow" + + +def test_youcom_search_prefers_passages_and_falls_back_to_description(monkeypatch): + _capture_get(monkeypatch) + + results = youcom_conn.YouCom("").search("What is RAGFlow?") + + assert results == [ + { + "url": "https://example.com/ragflow", + "title": "RAGFlow", + "content": "RAGFlow is an open-source RAG engine.\nIt does deep document understanding.", + "score": 1.0, + }, + { + "url": "https://news.example.com/ragflow", + "title": "RAGFlow ships", + "content": "News description only.", + "score": 1.0, + }, + ] + + +def test_youcom_search_skips_results_without_content(monkeypatch): + payload = {"results": {"web": [{"title": "No content", "url": "https://example.com/empty", "snippets": [" "]}]}} + _capture_get(monkeypatch, _Response(payload)) + + assert youcom_conn.YouCom("").search("What is RAGFlow?") == [] + + +def test_youcom_search_caps_the_merged_sections(monkeypatch): + payload = { + "results": { + "web": [{"title": f"w{i}", "url": f"https://example.com/w{i}", "description": "d"} for i in range(6)], + "news": [{"title": f"n{i}", "url": f"https://example.com/n{i}", "description": "d"} for i in range(6)], + } + } + _capture_get(monkeypatch, _Response(payload)) + + results = youcom_conn.YouCom("").search("What is RAGFlow?") + + # `count` applies per section, so the merged list is trimmed back to 6. + assert len(results) == 6 + assert [result["title"] for result in results] == ["w0", "w1", "w2", "w3", "w4", "w5"] + + +def test_youcom_search_returns_empty_on_malformed_payloads(monkeypatch): + _capture_get(monkeypatch, _Response(["not", "an", "object"])) + assert youcom_conn.YouCom("").search("q") == [] + + _capture_get(monkeypatch, _Response({"results": "not-an-object"})) + assert youcom_conn.YouCom("").search("q") == [] + + _capture_get(monkeypatch, _Response({"results": {"web": "not-an-array"}})) + assert youcom_conn.YouCom("").search("q") == [] + + +class _ErrorResponse: + """A response whose raise_for_status() carries the full request URL, the way + requests builds it — including the query string.""" + + status_code = 402 + url = "https://api.you.com/v1/agents/search?query=my%20private%20query&count=6" + + def raise_for_status(self): + raise youcom_conn.requests.HTTPError( + f"402 Client Error: Payment Required for url: {self.url}", + response=self, + ) + + def json(self): # pragma: no cover - never reached + return {} + + +def test_youcom_search_never_logs_the_query_or_key_on_http_errors(monkeypatch, caplog): + """The query is a URL parameter, so the requests error message contains it.""" + monkeypatch.setattr(youcom_conn.requests, "get", lambda *a, **k: _ErrorResponse()) + + with caplog.at_level("ERROR"): + assert youcom_conn.YouCom("ydc-secret").search("my private query") == [] + + assert "my private query" not in caplog.text + assert "my%20private%20query" not in caplog.text + assert "ydc-secret" not in caplog.text + # Only the status code is useful and safe to record. + assert "402" in caplog.text + + +def test_youcom_search_logs_only_the_exception_type_on_transport_errors(monkeypatch, caplog): + def fake_get(url, *, headers, params, timeout): + raise youcom_conn.requests.ConnectionError(f"failed connecting to {url}?query=my%20private%20query") + + monkeypatch.setattr(youcom_conn.requests, "get", fake_get) + + with caplog.at_level("ERROR"): + assert youcom_conn.YouCom("ydc-secret").search("my private query") == [] + + assert "my%20private%20query" not in caplog.text + assert "ydc-secret" not in caplog.text + assert "ConnectionError" in caplog.text + + +def test_youcom_retrieve_chunks_returns_ragflow_chunk_shape(monkeypatch): + _capture_get(monkeypatch) + + retrieved = youcom_conn.YouCom("").retrieve_chunks("What is RAGFlow?") + + assert set(retrieved) == {"chunks", "doc_aggs"} + assert len(retrieved["chunks"]) == 2 + chunk = retrieved["chunks"][0] + assert chunk["url"] == "https://example.com/ragflow" + assert chunk["docnm_kwd"] == "RAGFlow" + assert chunk["content_with_weight"].startswith("RAGFlow is an open-source RAG engine.") + assert chunk["similarity"] == 1.0 + doc_agg = retrieved["doc_aggs"][0] + assert doc_agg["doc_name"] == "RAGFlow" + assert doc_agg["doc_id"] == chunk["chunk_id"] diff --git a/web/src/assets/svg/youcom.svg b/web/src/assets/svg/youcom.svg new file mode 100644 index 0000000000..9a5a676a84 --- /dev/null +++ b/web/src/assets/svg/youcom.svg @@ -0,0 +1,10 @@ + + You.com + + + + + + + + diff --git a/web/src/components/web-search-form-field.tsx b/web/src/components/web-search-form-field.tsx index dda7515572..903698ebdf 100644 --- a/web/src/components/web-search-form-field.tsx +++ b/web/src/components/web-search-form-field.tsx @@ -17,6 +17,7 @@ import queritLogo from '@/assets/querit.png'; import serplyLogo from '@/assets/serply.png'; import tavilyLogo from '@/assets/svg/tavily.svg'; +import youcomLogo from '@/assets/svg/youcom.svg'; import { RAGFlowSelect } from '@/components/ui/select'; import { WebSearchProvider } from '@/constants/chat'; import { useTranslate } from '@/hooks/common-hooks'; @@ -52,6 +53,11 @@ const providerOptions = [ logo: serplyLogo, value: WebSearchProvider.Serply, }, + { + name: 'You.com', + logo: youcomLogo, + value: WebSearchProvider.YouCom, + }, ] .sort((left, right) => left.name.localeCompare(right.name)) .map(({ name, logo, value }) => ({ @@ -91,6 +97,14 @@ const providerKeyConfig = { placeholder: 'serplyApiKeyMessage', helpUrl: 'https://serply.io', }, + [WebSearchProvider.YouCom]: { + name: 'prompt_config.youcom_api_key', + label: 'You.com API Key', + tip: 'youcomApiKeyTip', + placeholder: 'youcomApiKeyMessage', + helpUrl: + 'https://you.com/platform?utm_source=infiniflow-ragflow&utm_medium=oss_integration&utm_campaign=2026-08-oss-integrations&utm_content=app', + }, } as const; export function WebSearchFormField({ prefix = '' }: IProps) { diff --git a/web/src/constants/chat.ts b/web/src/constants/chat.ts index 021ce50cef..a898b983fc 100644 --- a/web/src/constants/chat.ts +++ b/web/src/constants/chat.ts @@ -60,4 +60,13 @@ export enum WebSearchProvider { Tavily = 'tavily', Querit = 'querit', Serply = 'serply', + YouCom = 'youcom', } + +/** + * Providers usable with no credentials at all. You.com serves a rate-limited + * keyless endpoint; every other provider requires a key before it can be used. + */ +export const KEYLESS_WEB_SEARCH_PROVIDERS: readonly WebSearchProvider[] = [ + WebSearchProvider.YouCom, +]; diff --git a/web/src/interfaces/database/chat.ts b/web/src/interfaces/database/chat.ts index f981963daf..fea56e8593 100644 --- a/web/src/interfaces/database/chat.ts +++ b/web/src/interfaces/database/chat.ts @@ -23,6 +23,7 @@ export interface PromptConfig { tavily_api_key?: string; querit_api_key?: string; serply_api_key?: string; + youcom_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 e338e356c8..95563f3759 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1206,6 +1206,9 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s serplyApiKeyTip: 'When Serply is selected, its web search results supplement dataset retrieval.', serplyApiKeyMessage: 'Please enter your Serply API Key', + youcomApiKeyTip: + 'Optional. You.com works without a key on its rate-limited endpoint; add a key to lift those limits.', + youcomApiKeyMessage: 'Optional — leave blank to use the free tier', 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 217b942591..308630b031 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1094,6 +1094,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R queritApiKeyMessage: '请输入你的 Querit API Key', serplyApiKeyTip: '选择 Serply 后,将使用 Serply 的网络搜索结果补充知识库检索。', serplyApiKeyMessage: '请输入你的 Serply API Key', + youcomApiKeyTip: + '可选。You.com 在限速端点上无需 API Key 即可使用;填写 Key 可解除限速。', + youcomApiKeyMessage: '可选 —— 留空则使用免费额度', 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 6f0fba63b3..69d59187eb 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 @@ -36,11 +36,13 @@ export function useChatSettingSchema(staleDatasetIds: Set) { tavily_api_key: z.string().optional(), querit_api_key: z.string().optional(), serply_api_key: z.string().optional(), + youcom_api_key: z.string().optional(), web_search_provider: z .enum([ WebSearchProvider.Tavily, WebSearchProvider.Querit, WebSearchProvider.Serply, + WebSearchProvider.YouCom, ]) .optional(), reasoning: z.boolean().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 34bf3f653a..1d4e28580c 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 @@ -1,6 +1,10 @@ import { WebSearchProvider } from '@/constants/chat'; import type { PromptConfig } from '@/interfaces/database/chat'; -import { getWebSearchApiKey, getWebSearchProvider } from './web-search-api-key'; +import { + getWebSearchApiKey, + getWebSearchProvider, + hasWebSearchProvider, +} from './web-search-api-key'; describe('getWebSearchProvider', () => { it('does not select a provider for a new unconfigured dialog', () => { @@ -90,3 +94,52 @@ describe('getWebSearchApiKey', () => { expect(getWebSearchApiKey(promptConfig)).toBeUndefined(); }); }); + +describe('hasWebSearchProvider', () => { + it('is false for a new unconfigured dialog', () => { + expect(hasWebSearchProvider({} as PromptConfig)).toBe(false); + }); + + it('requires a key for providers that need one', () => { + expect( + hasWebSearchProvider({ + web_search_provider: WebSearchProvider.Querit, + } as PromptConfig), + ).toBe(false); + + expect( + hasWebSearchProvider({ + web_search_provider: WebSearchProvider.Querit, + querit_api_key: 'querit-test', + } as PromptConfig), + ).toBe(true); + }); + + it('is true for keyless You.com with no key configured', () => { + expect( + hasWebSearchProvider({ + web_search_provider: WebSearchProvider.YouCom, + } as PromptConfig), + ).toBe(true); + + expect( + hasWebSearchProvider({ + web_search_provider: WebSearchProvider.YouCom, + youcom_api_key: '', + } as PromptConfig), + ).toBe(true); + }); +}); + +describe('You.com key selection', () => { + it('uses only the selected You.com key', () => { + const promptConfig = { + web_search_provider: WebSearchProvider.YouCom, + youcom_api_key: 'ydc-test', + tavily_api_key: 'tvly-test', + } as PromptConfig; + + expect(getWebSearchProvider(promptConfig)).toBe(WebSearchProvider.YouCom); + expect(getWebSearchApiKey(promptConfig)).toBe('ydc-test'); + }); +}); diff --git a/web/src/pages/next-chats/chat/use-show-internet.ts b/web/src/pages/next-chats/chat/use-show-internet.ts index 3db50dd0a9..56c19c9496 100644 --- a/web/src/pages/next-chats/chat/use-show-internet.ts +++ b/web/src/pages/next-chats/chat/use-show-internet.ts @@ -1,9 +1,8 @@ import { useFetchChat } from '@/hooks/use-chat-request'; -import { isEmpty } from 'lodash'; -import { getWebSearchApiKey } from './web-search-api-key'; +import { hasWebSearchProvider } from './web-search-api-key'; export function useShowInternet() { const { data: currentDialog } = useFetchChat(); - return !isEmpty(getWebSearchApiKey(currentDialog?.prompt_config)); + return hasWebSearchProvider(currentDialog?.prompt_config); } 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 6a7aabf298..b45752a0fb 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 @@ -1,4 +1,7 @@ -import { WebSearchProvider } from '@/constants/chat'; +import { + KEYLESS_WEB_SEARCH_PROVIDERS, + WebSearchProvider, +} from '@/constants/chat'; import type { PromptConfig } from '@/interfaces/database/chat'; export function getWebSearchProvider(promptConfig?: PromptConfig) { @@ -7,7 +10,8 @@ export function getWebSearchProvider(promptConfig?: PromptConfig) { if ( provider === WebSearchProvider.Tavily || provider === WebSearchProvider.Querit || - provider === WebSearchProvider.Serply + provider === WebSearchProvider.Serply || + provider === WebSearchProvider.YouCom ) { return provider; } @@ -37,9 +41,29 @@ export function getWebSearchApiKey(promptConfig?: PromptConfig) { case WebSearchProvider.Serply: apiKey = promptConfig?.serply_api_key; break; + case WebSearchProvider.YouCom: + apiKey = promptConfig?.youcom_api_key; + break; default: return undefined; } return typeof apiKey === 'string' ? apiKey.trim() : undefined; } + +/** + * Whether web search is usable as configured. Most providers need a key; a + * keyless provider is usable as soon as it is selected. + */ +export function hasWebSearchProvider(promptConfig?: PromptConfig) { + const provider = getWebSearchProvider(promptConfig); + + if (provider === undefined) { + return false; + } + if (KEYLESS_WEB_SEARCH_PROVIDERS.includes(provider)) { + return true; + } + + return Boolean(getWebSearchApiKey(promptConfig)); +}