diff --git a/docs/plugins.md b/docs/plugins.md index 0af63b3..d6df944 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1340,8 +1340,8 @@ The `!ask` plugin uses DuckDuckGo Search Assist first when Search Assist request used by DuckDuckGo's web results, not a documented public API; it may be disabled if DuckDuckGo changes the page or endpoint. The plugin then uses a local Chromium/Chrome browser to read the rendered Search Assist -card when the lightweight request has no answer, followed by DuckDuckGo's -keyless Instant Answer endpoint and Wikidata as fallbacks. Search Assist and +card when the lightweight request has no answer, with DuckDuckGo's +keyless Instant Answer endpoint and Wikidata as independent fallbacks. Search Assist and DuckDuckGo summaries may be backed by encyclopedia sources; GoBot presents them as bounded answers and links to a cited source when one is provided. Query framing is normalized across common definition, @@ -1377,12 +1377,21 @@ for `!assist`. The no-answer search link requests it too. This avoids relying on the normal search page's automatic answer-display setting, but is not a public Search Assist API, a challenge bypass, or a guarantee of an answer for every query. -Initial lightweight attempts are spaced by 300 ms. If all providers miss, GoBot -waits 500 ms and makes one final lightweight request for the original question -within the same command. Requests with at least 12 seconds remaining reserve -three seconds for this recovery step; shorter deadlines are not shortened. -Successful answers return immediately without the final retry. All waits and -requests respect the overall timeout, and failed lookups are not cached. +The lightweight request gets a 650 ms head start, and inline Search Assist +payloads are accepted without an extra script fetch. If it has not returned an +answer, browser and intent-appropriate independent fallbacks run concurrently +instead of waiting through each other's timeouts. A ready fallback waits up to +200 ms for a Search Assist answer, then cancels slow losing providers instead +of waiting for their full timeouts. Losing work is joined, including browser cleanup; the +single-browser limit still applies across networks. A search-result excerpt +with an existing snippet gets at most 600 ms to refresh from its origin before +returning the attributed snippet. Sources without snippets retain their normal +bounded fetch budget. + +Lightweight attempts still retain the 300 ms normalized retry and a final +original-question recovery request after 500 ms. Their original budget reserve +is confined to that lane; it no longer delays independent providers. All waits +and requests respect the overall timeout, and failed lookups are not cached. The plugin is configured under `plugins.ask`: @@ -1421,8 +1430,11 @@ Each provider has a bounded sub-timeout so a slow Search Assist request cannot consume the entire lookup window; transient DuckDuckGo Instant Answer failures are retried within the same invocation. Successful answers are cached in memory for `cache_seconds` (default five minutes), keyed by normalized question text. -If no provider has a usable answer, GoBot returns a bounded DuckDuckGo search link -instead of inventing one. +If no provider has a usable answer, GoBot returns a clearly labeled +`Search Assist / web results` link, not a generic "can't answer" message or a +fabricated answer. Long queries are shortened before URL encoding so the link +remains valid within the IRC byte budget. This is still a best-effort service, +not a guarantee that every question has an available sourced answer. ## Daily bonus diff --git a/plugins/ask.go b/plugins/ask.go index 90add59..ba6a947 100644 --- a/plugins/ask.go +++ b/plugins/ask.go @@ -242,7 +242,7 @@ type askSource struct { Provider string } -func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) { +func (p *Ask) findSourceSequential(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) { if !cfg.Bool("search_assist_enabled", true) { return p.findSourceOnce(ctx, question, cfg) } @@ -524,9 +524,12 @@ func askNeedsWebResultAnswer(question string) bool { for _, phrase := range []string{ "why ", "why is ", "why are ", "why was ", "why were ", "how does ", "how do ", "how can ", "how should ", + "how to ", "what kind of ", "what type of ", "what types of ", + "what genre ", "what music genre ", "what musical genre ", "how much ", "what does it cost", "what is the price", "what are the prices", "what makes ", "what do people think", "why do people ", "why do some ", "what are the disadvantages", "what are the benefits", "what are the alternatives", + "what is an alternative", "what are alternatives", "is it true", "is it worth", "is it safe", "should i ", "should we ", "considered", "controversial", "opinion", "review", "experience", } { @@ -896,6 +899,9 @@ func askDuckDuckGoSearchAssistOnce(ctx context.Context, question, fallbackURL st if err != nil { return askSource{}, false } + if source, ok := parseDuckDuckGoSearchAssist(string(page), fallbackURL); ok { + return source, true + } assistURL := askSearchAssistPreloadURL(page, pageURL) assistParsed, parseErr := url.Parse(assistURL) if parseErr != nil || assistParsed.Scheme != "https" || !isDuckDuckGoHost(assistParsed.Hostname()) || assistParsed.User != nil || assistParsed.Port() != "" { @@ -1276,6 +1282,13 @@ func fetchAskSearchResult(ctx context.Context, result askRenderedSearchResult, q if !validPublicHTTPURL(result.URL) { return askSource{}, false } + if strings.TrimSpace(result.Snippet) != "" { + // An attributed snippet is already usable. Refresh it briefly, but do + // not spend the whole fallback budget waiting for its origin site. + step, cancel := context.WithTimeout(ctx, 600*time.Millisecond) + defer cancel() + ctx = step + } parsedResultURL, _ := url.Parse(result.URL) if parsedResultURL != nil && isRedditHost(parsedResultURL.Hostname()) { if source, ok := fetchAskRedditResult(ctx, result); ok { @@ -2113,7 +2126,16 @@ func formatAskResponse(nick, answer, sourceURL string, maxLength, maxResponseCha func formatAskNoAnswer(question string, maxLength int) string { maxLength = clampAskLength(maxLength, 120, 450, 360) - return truncateAskBytes("I couldn't find a reliable answer — search: "+duckDuckGoSearchAssistURL(question), maxLength) + const prefix = "Search Assist / web results: " + query := []rune(cleanExternalText(question)) + for { + result := prefix + duckDuckGoSearchAssistURL(string(query)) + if len(result) <= maxLength { + return result + } + // Shorten search terms, never the encoded URL or a percent escape. + query = query[:len(query)-1] + } } func clampAskLength(value, min, max, fallback int) int { diff --git a/plugins/ask_live_latency_test.go b/plugins/ask_live_latency_test.go new file mode 100644 index 0000000..413fa68 --- /dev/null +++ b/plugins/ask_live_latency_test.go @@ -0,0 +1,50 @@ +package plugins + +import ( + "context" + "encoding/json" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/variablenix/GoBot/bot" +) + +// This opt-in check never connects to IRC or logs question text or answers. +func TestLiveAskLatency(t *testing.T) { + if os.Getenv("GOBOT_LIVE_ASK") != "1" { + t.Skip("opt-in live Ask latency and answer check") + } + queries := []string{"what is Debian?", "who created Linux?", "how much do dirt bikes cost vs GoKarts?"} + if raw := os.Getenv("GOBOT_LIVE_ASK_QUERIES"); raw != "" { + if json.Unmarshal([]byte(raw), &queries) != nil || len(queries) == 0 || len(queries) > 8 { + t.Fatal("invalid live question list") + } + } + for index, question := range queries { + t.Run("query-"+strconv.Itoa(index+1), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + started := time.Now() + source, found := (&Ask{}).findSource(ctx, question, bot.PluginConfig{}) + t.Logf("provider=%s found=%t elapsed_ms=%d", source.Provider, found, time.Since(started).Milliseconds()) + if !found || source.Summary == "" { + t.Fatal("no sourced answer within the command deadline") + } + // A nonempty but unrelated entity description is not a successful + // answer to these baseline questions. Keep answers out of logs. + markers := map[string][]string{ + "what is Debian?": {"debian"}, + "who created Linux?": {"torvalds"}, + "how much do dirt bikes cost vs GoKarts?": {"bike", "kart"}, + } + for _, marker := range markers[question] { + if !strings.Contains(strings.ToLower(source.Summary), marker) { + t.Fatal("sourced answer is missing an expected subject or relationship") + } + } + }) + } +} diff --git a/plugins/ask_lookup.go b/plugins/ask_lookup.go new file mode 100644 index 0000000..aed745a --- /dev/null +++ b/plugins/ask_lookup.go @@ -0,0 +1,127 @@ +package plugins + +import ( + "context" + "sync" + "time" + + "github.com/variablenix/GoBot/bot" +) + +const ( + askHedgeDelay = 650 * time.Millisecond + askAssistPreference = 200 * time.Millisecond +) + +type askLookupTask struct { + delay time.Duration + lookup func(context.Context) (askSource, bool) +} + +func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) { + if !cfg.Bool("search_assist_enabled", true) { + return p.findSourceSequential(ctx, question, cfg) + } + // Give a healthy lightweight Search Assist request a head start. Its + // existing normalized retries and final original-question recovery remain + // available, but cannot hold independent answer providers behind a slow + // request or an unavailable browser. + tasks := []askLookupTask{{lookup: func(ctx context.Context) (askSource, bool) { + return p.findSourceSequential(ctx, question, bot.PluginConfig{ + "search_assist_browser_enabled": false, "search_results_enabled": false, + "duckduckgo_enabled": false, "wikidata_fallback": false, + }) + }}} + if cfg.Bool("search_assist_browser_enabled", true) { + tasks = append(tasks, askLookupTask{delay: askHedgeDelay, lookup: func(ctx context.Context) (askSource, bool) { + step, cancel := askBrowserStepContext(ctx) + defer cancel() + return askDuckDuckGoRenderedSearchAssist(step, question, cfg.String("browser_path", ""), cfg.Bool("search_results_enabled", true)) + }}) + } + if cfg.Bool("duckduckgo_enabled", true) || cfg.Bool("wikidata_fallback", true) || cfg.Bool("search_results_enabled", true) { + fallbackCfg := make(bot.PluginConfig, len(cfg)+2) + for key, value := range cfg { + fallbackCfg[key] = value + } + fallbackCfg["search_assist_enabled"] = false + fallbackCfg["search_assist_browser_enabled"] = false + tasks = append(tasks, askLookupTask{delay: askHedgeDelay, lookup: func(ctx context.Context) (askSource, bool) { + return p.findSourceOnce(ctx, question, fallbackCfg) + }}) + } + return askRaceSources(ctx, tasks, askAssistPreference) +} + +// askRaceSources returns the first usable answer, with a short preference +// window for Search Assist. It cancels and joins losing tasks before returning +// so lookups cannot outlive a request or accumulate orphaned browser workers. +func askRaceSources(parent context.Context, tasks []askLookupTask, preference time.Duration) (askSource, bool) { + if parent.Err() != nil || len(tasks) == 0 { + return askSource{}, false + } + ctx, cancel := context.WithCancel(parent) + type outcome struct { + source askSource + found bool + } + results := make(chan outcome, len(tasks)) + var workers sync.WaitGroup + defer func() { + cancel() + workers.Wait() + }() + for _, task := range tasks { + workers.Add(1) + go func() { + defer workers.Done() + if !askWaitForRetry(ctx, task.delay) { + results <- outcome{} + return + } + source, found := runAskLookup(ctx, task.lookup) + results <- outcome{source, found && source.Summary != ""} + }() + } + var fallback askSource + found := false + var timer *time.Timer + var preferenceExpired <-chan time.Time + defer func() { + if timer != nil { + timer.Stop() + } + }() + for remaining := len(tasks); remaining > 0; { + select { + case result := <-results: + remaining-- + if result.found { + if result.source.Provider == "search_assist" { + return result.source, true + } + if !found { + fallback, found = result.source, true + timer = time.NewTimer(preference) + preferenceExpired = timer.C + } + } + case <-preferenceExpired: + return fallback, found + case <-ctx.Done(): + return fallback, found + } + } + return fallback, found +} + +// Provider workers run outside the bot's normal command-handler panic guard. +// Keep an upstream failure confined to its lane, without logging query data. +func runAskLookup(ctx context.Context, lookup func(context.Context) (askSource, bool)) (source askSource, found bool) { + defer func() { + if recover() != nil { + source, found = askSource{}, false + } + }() + return lookup(ctx) +} diff --git a/plugins/ask_lookup_test.go b/plugins/ask_lookup_test.go new file mode 100644 index 0000000..6d184a6 --- /dev/null +++ b/plugins/ask_lookup_test.go @@ -0,0 +1,212 @@ +package plugins + +import ( + "context" + "net/http" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "github.com/variablenix/GoBot/bot" +) + +func TestAskRaceHealthyAssistSkipsDelayedProviders(t *testing.T) { + var delayedCalls atomic.Int32 + want := askSource{Summary: "A sourced answer.", Provider: "search_assist"} + started := time.Now() + source, found := askRaceSources(t.Context(), []askLookupTask{ + {lookup: func(context.Context) (askSource, bool) { return want, true }}, + {delay: time.Second, lookup: func(context.Context) (askSource, bool) { + delayedCalls.Add(1) + return askSource{}, false + }}, + }, time.Second) + if !found || source != want || delayedCalls.Load() != 0 || time.Since(started) > 500*time.Millisecond { + t.Fatalf("healthy answer was delayed or started unnecessary work: %+v, %t", source, found) + } +} + +func TestAskRaceFastFallbackCancelsAndJoinsSlowProvider(t *testing.T) { + stopped := make(chan struct{}) + want := askSource{Summary: "A relevant public excerpt.", Provider: "search_result"} + started := time.Now() + source, found := askRaceSources(t.Context(), []askLookupTask{ + {lookup: func(ctx context.Context) (askSource, bool) { + defer close(stopped) + <-ctx.Done() + return askSource{}, false + }}, + {lookup: func(context.Context) (askSource, bool) { return want, true }}, + }, 20*time.Millisecond) + if !found || source != want || time.Since(started) > 500*time.Millisecond { + t.Fatalf("slow provider held a ready answer: %+v, %t", source, found) + } + select { + case <-stopped: + default: + t.Fatal("losing worker outlived the lookup") + } +} + +func TestAskRacePrefersAssistWithinShortWindow(t *testing.T) { + want := askSource{Summary: "The generated sourced answer.", Provider: "search_assist"} + source, found := askRaceSources(t.Context(), []askLookupTask{ + {delay: 20 * time.Millisecond, lookup: func(context.Context) (askSource, bool) { return want, true }}, + {lookup: func(context.Context) (askSource, bool) { + return askSource{Summary: "A relevant excerpt.", Provider: "search_result"}, true + }}, + }, 500*time.Millisecond) + if !found || source != want { + t.Fatalf("lost the preferred answer within the grace period: %+v, %t", source, found) + } +} + +func TestAskRaceDoesNotInventAnswers(t *testing.T) { + for _, tasks := range [][]askLookupTask{nil, { + {lookup: func(context.Context) (askSource, bool) { return askSource{}, false }}, + {lookup: func(context.Context) (askSource, bool) { return askSource{Title: "No answer"}, true }}, + }} { + if source, found := askRaceSources(t.Context(), tasks, time.Second); found || source.Summary != "" { + t.Fatalf("an empty provider response became an answer: %+v", source) + } + } +} + +func TestAskRaceProviderPanicDoesNotLoseOtherAnswer(t *testing.T) { + want := askSource{Summary: "A sourced fallback remains available.", Provider: "search_result"} + source, found := askRaceSources(t.Context(), []askLookupTask{ + {lookup: func(context.Context) (askSource, bool) { panic("simulated provider failure") }}, + {lookup: func(context.Context) (askSource, bool) { return want, true }}, + }, time.Second) + if !found || source != want { + t.Fatalf("one provider failure discarded the healthy result: %+v, %t", source, found) + } +} + +func TestAskRaceCanceledParentDoesNotStartWork(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, found := askRaceSources(ctx, []askLookupTask{{lookup: func(context.Context) (askSource, bool) { + t.Fatal("lookup started after cancellation") + return askSource{}, false + }}}, time.Second) + if found { + t.Fatal("canceled lookup unexpectedly succeeded") + } +} + +func TestAskRaceDeadlineKeepsAlreadyAvailableAnswer(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond) + defer cancel() + want := askSource{Summary: "Already available public information.", Provider: "search_result"} + source, found := askRaceSources(ctx, []askLookupTask{ + {lookup: func(ctx context.Context) (askSource, bool) { <-ctx.Done(); return askSource{}, false }}, + {lookup: func(context.Context) (askSource, bool) { return want, true }}, + }, time.Second) + if !found || source != want { + t.Fatalf("deadline discarded a usable answer: %+v, %t", source, found) + } +} + +func TestAskFastFallbackIsIndependentOfStalledAssist(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "api.duckduckgo.com" { + return newPluginResponse(200, `{"Heading":"Linux","AbstractText":"Linux is a family of free and open-source operating systems."}`), nil + } + <-r.Context().Done() + return nil, r.Context().Err() + })} + cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "search_results_enabled": false, "wikidata_fallback": false} + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + started := time.Now() + source, found := (&Ask{}).findSource(ctx, "what is Linux?", cfg) + if !found || !strings.Contains(source.Summary, "operating systems") || time.Since(started) > 2*time.Second { + t.Fatalf("independent fallback was blocked: %+v, %t", source, found) + } + if cfg["search_assist_enabled"] != nil || cfg["search_assist_browser_enabled"] != false { + t.Fatal("lookup modified the caller's configuration") + } +} + +func TestAskInlineAssistPayloadNeedsOnlyOneRequest(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + requests := 0 + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + requests++ + return newPluginResponse(200, ``), nil + })} + source, found := (&Ask{}).findSource(t.Context(), "an example question?", bot.PluginConfig{}) + if !found || source.Provider != "search_assist" || requests != 1 || source.URL != "https://example.org/answer" { + t.Fatalf("inline answer was missed or required extra requests: %+v, %t, %d", source, found, requests) + } +} + +func TestAskSpecificWebQuestionsDoNotRaceGenericEntityAnswers(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("generic entity lookup attempted for a specific web question") + return nil, context.Canceled + })} + for _, question := range []string{ + `what music genre is the band "Example Band"?`, "what genre is the band Example Band?", + "what kind of vehicle does Example Character use?", "how to install Example OS?", + "what is an alternative to Example Service?", + } { + if !askNeedsWebResultAnswer(question) { + t.Fatalf("specific question misclassified: %q", question) + } + if _, found := (&Ask{}).findSource(t.Context(), question, bot.PluginConfig{ + "search_assist_enabled": false, "search_assist_browser_enabled": false, "search_results_enabled": false, + }); found { + t.Fatal("specific question received a generic entity description") + } + } +} + +func TestAskSnippetSurvivesSlowOrigin(t *testing.T) { + old := askWebHTTPClient + t.Cleanup(func() { askWebHTTPClient = old }) + askWebHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + deadline, ok := r.Context().Deadline() + if !ok || time.Until(deadline) > 600*time.Millisecond { + t.Fatal("snippet refresh lacks a bounded deadline") + } + <-r.Context().Done() + return nil, r.Context().Err() + })} + started := time.Now() + source, found := fetchAskSearchResult(t.Context(), askRenderedSearchResult{ + Title: "Example comparison", URL: "https://example.org/comparison", Snippet: "Compare equivalent models and ongoing maintenance costs.", + }, "compare the costs of bikes and karts") + if !found || source.Provider != "search_result" || !strings.Contains(source.Summary, "maintenance costs") || time.Since(started) > 1500*time.Millisecond { + t.Fatalf("slow origin lost or delayed the snippet: %+v, %t", source, found) + } +} + +func TestAskSearchFallbackKeepsCompleteEncodedURL(t *testing.T) { + for _, question := range []string{"a simple question?", strings.Repeat("日本語 🎥 & C++? ", 40)} { + for _, limit := range []int{120, 360, 450} { + text := formatAskNoAnswer(question, limit) + _, rawURL, ok := strings.Cut(text, ": ") + parsed, err := url.Parse(rawURL) + if !ok || err != nil || len(text) > limit || !utf8.ValidString(text) || strings.ContainsAny(text, "\r\n\uFE0F") { + t.Fatalf("unsafe or truncated fallback: %q, %v", text, err) + } + query := parsed.Query().Get("q") + if parsed.Host != "duckduckgo.com" || parsed.Scheme != "https" || parsed.Query().Get("assiston") != "1" || query == "" || !utf8.ValidString(query) || !strings.HasPrefix(question, query) { + t.Fatalf("fallback no longer contains a valid query: %q", text) + } + if !strings.HasPrefix(text, "Search Assist / web results: ") { + t.Fatalf("fallback is not clearly labeled as search results: %q", text) + } + } + } +} diff --git a/plugins/ask_test.go b/plugins/ask_test.go index 7bee0f0..440861d 100644 --- a/plugins/ask_test.go +++ b/plugins/ask_test.go @@ -484,7 +484,7 @@ func TestAskResponseDropsInvalidSourceURL(t *testing.T) { func TestAskNoAnswerProvidesSearchFallback(t *testing.T) { got := formatAskNoAnswer("Is the Earth flat?", 180) - if !strings.Contains(got, "I couldn't find a reliable answer") || !strings.Contains(got, "https://duckduckgo.com/?assiston=1&q=Is+the+Earth+flat%3F") { + if !strings.HasPrefix(got, "Search Assist / web results: ") || !strings.Contains(got, "https://duckduckgo.com/?assiston=1&q=Is+the+Earth+flat%3F") { t.Fatalf("formatAskNoAnswer() = %q, want bounded search fallback", got) } if strings.ContainsAny(got, "\r\n") || len([]byte(got)) > 180 {