diff --git a/internal/handler/web_search_provider.go b/internal/handler/web_search_provider.go index 7c5d3853..730b9008 100644 --- a/internal/handler/web_search_provider.go +++ b/internal/handler/web_search_provider.go @@ -422,8 +422,9 @@ func (h *WebSearchProviderHandler) doTestSearch(ctx context.Context, providerTyp return err } if len(results) == 0 { - logger.Warnf(ctx, "[WebSearch][Test] search returned 0 results — API key or configuration may be invalid") - return fmt.Errorf("search returned 0 results, please verify your API key and configuration") + err := infra_web_search.EmptyTestResultsError(providerType, searchProvider) + logger.Warnf(ctx, "[WebSearch][Test] %v", err) + return err } logger.Infof(ctx, "[WebSearch][Test] succeeded: type=%s, results=%d", providerType, len(results)) return nil diff --git a/internal/infrastructure/web_search/searxng.go b/internal/infrastructure/web_search/searxng.go index b9d505fb..7e57c738 100644 --- a/internal/infrastructure/web_search/searxng.go +++ b/internal/infrastructure/web_search/searxng.go @@ -28,8 +28,9 @@ const defaultSearxngTimeout = 12 * time.Second // validated with utils.ValidateURLForSSRF; private/loopback hosts must be added // to the SSRF_WHITELIST environment variable. type SearxngProvider struct { - client *http.Client - baseURL string + client *http.Client + baseURL string + lastUnresponsive [][]string } // ValidateSearxngBaseURL validates a SearXNG instance URL: must be a non-empty, @@ -77,6 +78,15 @@ func NewSearxngProvider(params types.WebSearchProviderParameters) (interfaces.We // Name returns the provider name. func (p *SearxngProvider) Name() string { return "searxng" } +// EmptyResultDiagnostics explains why the most recent search returned no +// usable results. Used by the settings "test connection" flow. +func (p *SearxngProvider) EmptyResultDiagnostics() string { + if detail := formatUnresponsiveEngines(p.lastUnresponsive); detail != "" { + return detail + "; check that upstream search engines can reach the internet" + } + return "verify the instance URL is reachable and JSON format is enabled in settings.yml" +} + // Search performs a metasearch query against the configured SearXNG instance. // SearXNG must have `search.formats: [json]` enabled in settings.yml. func (p *SearxngProvider) Search( @@ -124,8 +134,10 @@ func (p *SearxngProvider) Search( var data searxngResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + p.lastUnresponsive = nil return nil, fmt.Errorf("failed to decode SearXNG response (ensure JSON format is enabled in settings.yml): %w", err) } + p.lastUnresponsive = data.UnresponsiveEngines results := make([]*types.WebSearchResult, 0, maxResults) for _, r := range data.Results { diff --git a/internal/infrastructure/web_search/searxng_test.go b/internal/infrastructure/web_search/searxng_test.go index f7df650b..09e1ae45 100644 --- a/internal/infrastructure/web_search/searxng_test.go +++ b/internal/infrastructure/web_search/searxng_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -127,3 +128,40 @@ func TestSearxngProvider_Search(t *testing.T) { t.Fatalf("unexpected source: %q", got) } } + +func TestSearxngProvider_Search_EmptyWithUnresponsiveEngines(t *testing.T) { + utils.ResetSSRFWhitelistForTest() + os.Setenv("SSRF_WHITELIST", "127.0.0.1,localhost") + defer func() { + os.Unsetenv("SSRF_WHITELIST") + utils.ResetSSRFWhitelistForTest() + }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []any{}, + "unresponsive_engines": [][]string{{"google", "timeout"}}, + }) + })) + defer srv.Close() + + provider, err := NewSearxngProvider(types.WebSearchProviderParameters{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("NewSearxngProvider: %v", err) + } + sp := provider.(*SearxngProvider) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + results, err := sp.Search(ctx, "test", 1, false) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } + if got := sp.EmptyResultDiagnostics(); !strings.Contains(got, "google (timeout)") { + t.Fatalf("EmptyResultDiagnostics() = %q", got) + } +} diff --git a/internal/infrastructure/web_search/test_errors.go b/internal/infrastructure/web_search/test_errors.go new file mode 100644 index 00000000..7b4536ac --- /dev/null +++ b/internal/infrastructure/web_search/test_errors.go @@ -0,0 +1,58 @@ +package web_search + +import ( + "fmt" + "strings" + + "github.com/Tencent/WeKnora/internal/types" +) + +// emptyResultDiagnostics is implemented by providers that can explain an empty +// search result set (used by the settings "test connection" flow). +type emptyResultDiagnostics interface { + EmptyResultDiagnostics() string +} + +// EmptyTestResultsError builds a provider-specific message when a connectivity +// test search succeeds but returns no usable results. +func EmptyTestResultsError(providerType string, provider any) error { + detail := "" + if dr, ok := provider.(emptyResultDiagnostics); ok { + detail = dr.EmptyResultDiagnostics() + } + + switch types.WebSearchProviderType(providerType) { + case types.WebSearchProviderTypeSearxng: + if detail != "" { + return fmt.Errorf("searxng returned 0 results: %s", detail) + } + return fmt.Errorf( + "searxng returned 0 results; verify the instance URL, JSON format in settings.yml, and upstream search engine connectivity", + ) + case types.WebSearchProviderTypeDuckDuckGo: + return fmt.Errorf("duckduckgo returned 0 results; verify network connectivity and proxy settings") + default: + return fmt.Errorf("search returned 0 results, please verify your API key and configuration") + } +} + +func formatUnresponsiveEngines(engines [][]string) string { + if len(engines) == 0 { + return "" + } + parts := make([]string, 0, len(engines)) + for _, e := range engines { + if len(e) == 0 { + continue + } + if len(e) == 1 { + parts = append(parts, e[0]) + continue + } + parts = append(parts, e[0]+" ("+e[1]+")") + } + if len(parts) == 0 { + return "" + } + return "unresponsive engines: " + strings.Join(parts, ", ") +} diff --git a/internal/infrastructure/web_search/test_errors_test.go b/internal/infrastructure/web_search/test_errors_test.go new file mode 100644 index 00000000..77f3b196 --- /dev/null +++ b/internal/infrastructure/web_search/test_errors_test.go @@ -0,0 +1,74 @@ +package web_search + +import ( + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestEmptyTestResultsError_SearxngWithUnresponsiveEngines(t *testing.T) { + provider := &SearxngProvider{ + lastUnresponsive: [][]string{ + {"google", "timeout"}, + {"bing", "blocked"}, + }, + } + err := EmptyTestResultsError(string(types.WebSearchProviderTypeSearxng), provider) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "searxng returned 0 results") { + t.Fatalf("unexpected message: %q", msg) + } + if !strings.Contains(msg, "google (timeout)") || !strings.Contains(msg, "bing (blocked)") { + t.Fatalf("expected unresponsive engine details, got: %q", msg) + } +} + +func TestEmptyTestResultsError_SearxngWithoutDiagnostics(t *testing.T) { + provider := &SearxngProvider{} + err := EmptyTestResultsError(string(types.WebSearchProviderTypeSearxng), provider) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "JSON format is enabled in settings.yml") { + t.Fatalf("unexpected message: %q", msg) + } + if strings.Contains(msg, "API key") { + t.Fatalf("searxng message must not mention API key: %q", msg) + } +} + +func TestEmptyTestResultsError_DuckDuckGo(t *testing.T) { + err := EmptyTestResultsError(string(types.WebSearchProviderTypeDuckDuckGo), nil) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "duckduckgo returned 0 results") { + t.Fatalf("unexpected message: %q", err.Error()) + } +} + +func TestEmptyTestResultsError_APIKeyProviders(t *testing.T) { + err := EmptyTestResultsError(string(types.WebSearchProviderTypeBing), nil) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "API key") { + t.Fatalf("bing message should mention API key: %q", err.Error()) + } +} + +func TestFormatUnresponsiveEngines(t *testing.T) { + got := formatUnresponsiveEngines([][]string{ + {"google", "timeout"}, + {"wikipedia"}, + }) + want := "unresponsive engines: google (timeout), wikipedia" + if got != want { + t.Fatalf("formatUnresponsiveEngines() = %q, want %q", got, want) + } +}