From 808b4bb04e16963784c510cf2ffa958a7ba6a329 Mon Sep 17 00:00:00 2001 From: ak Date: Fri, 25 Sep 2026 23:59:58 -0700 Subject: [PATCH] Recover delayed Search Assist answers within the original request --- docs/plugins.md | 7 ++ plugins/ask.go | 46 +++++++++++++ plugins/ask_resilience_test.go | 115 +++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) diff --git a/docs/plugins.md b/docs/plugins.md index 6adce7e..6cc1ef2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1360,6 +1360,13 @@ 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 plugin is configured under `plugins.ask`: ~~~yaml diff --git a/plugins/ask.go b/plugins/ask.go index 10c73ee..df091cc 100644 --- a/plugins/ask.go +++ b/plugins/ask.go @@ -243,6 +243,35 @@ type askSource struct { } func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) { + if !cfg.Bool("search_assist_enabled", true) { + return p.findSourceOnce(ctx, question, cfg) + } + lookupCtx, cancel := askInitialLookupContext(ctx) + source, ok := p.findSourceOnce(lookupCtx, question, cfg) + cancel() + if ok || ctx.Err() != nil { + return source, ok + } + // An answer may become available after the initial requests. Revisit the + // original question within this command, not only when the user asks again. + retryCtx, retryCancel := context.WithTimeout(ctx, 3*time.Second) + defer retryCancel() + if !askWaitForRetry(retryCtx, 500*time.Millisecond) { + return askSource{}, false + } + return askDuckDuckGoSearchAssistOnce(retryCtx, question, duckDuckGoSearchAssistURL(question)) +} + +func askInitialLookupContext(ctx context.Context) (context.Context, context.CancelFunc) { + // Reserve a recovery window on normal request budgets. Short user-defined + // deadlines retain the existing provider budget rather than being squeezed. + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) >= 12*time.Second { + return context.WithDeadline(ctx, deadline.Add(-3*time.Second)) + } + return context.WithCancel(ctx) +} + +func (p *Ask) findSourceOnce(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) { focused := askFocusedTerm(question) webResultTried := false var deferredWebResult askSource @@ -776,6 +805,9 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource, // and request context limits. queries := askSearchAssistQueryVariants(question) for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 && !askWaitForRetry(ctx, 300*time.Millisecond) { + break + } query := queries[0] if attempt < len(queries) { query = queries[attempt] @@ -790,6 +822,20 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource, return askSource{}, false } +func askWaitForRetry(ctx context.Context, delay time.Duration) bool { + if ctx.Err() != nil { + return false + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return ctx.Err() == nil + } +} + func askSearchAssistQueryVariants(question string) []string { original := strings.TrimSpace(question) queries := []string{original} diff --git a/plugins/ask_resilience_test.go b/plugins/ask_resilience_test.go index 7a4bb6b..7a601d4 100644 --- a/plugins/ask_resilience_test.go +++ b/plugins/ask_resilience_test.go @@ -103,6 +103,121 @@ func TestAskExplicitAssistURL(t *testing.T) { } } +func TestAskRecoversDelayedAnswerInOriginalCommand(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} { + t.Run(question, func(t *testing.T) { + started := time.Now() + pages := 0 + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/assist.js" { + return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"The available kart depends on the game.","sources":[{"article":{"link":"https://example.org/karts"}}]}}]};`), nil + } + pages++ + if r.URL.Query().Get("q") != question { + t.Fatal("recovery changed the original question") + } + if pages <= 2 || time.Since(started) < 750*time.Millisecond { + return newPluginResponse(200, `No generated answer yet`), nil + } + return newPluginResponse(200, ``), nil + })} + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false} + source, ok := (&Ask{}).findSource(ctx, question, cfg) + if !ok || source.Provider != "search_assist" || pages != 3 { + t.Fatalf("original command did not recover: source=%+v ok=%v requests=%d", source, ok, pages) + } + }) + } +} + +func TestAskRecoveryIsBoundedAndCancelable(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + for _, timeout := range []time.Duration{50 * time.Millisecond, 2 * time.Second} { + t.Run(timeout.String(), func(t *testing.T) { + pages := 0 + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.Context().Err() != nil { + t.Fatal("request made after cancellation") + } + pages++ + return newPluginResponse(200, `No answer`), nil + })} + ctx, cancel := context.WithTimeout(t.Context(), timeout) + defer cancel() + cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false} + if _, ok := (&Ask{}).findSource(ctx, "what kind of kart?", cfg); ok { + t.Fatal("fabricated an answer from an empty response") + } + want := 3 + if timeout < time.Second { + want = 1 + } + if pages != want { + t.Fatalf("requests=%d want=%d", pages, want) + } + }) + } +} + +func TestAskSuccessDoesNotTriggerRecovery(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + requests := 0 + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + requests++ + if r.URL.Path == "/assist.js" { + return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"An existing successful answer."}}]};`), nil + } + return newPluginResponse(200, ``), nil + })} + if _, ok := (&Ask{}).findSource(t.Context(), "a question", bot.PluginConfig{}); !ok || requests != 2 { + t.Fatalf("successful path changed: ok=%v requests=%d", ok, requests) + } +} + +func TestAskReservesRecoveryBudgetWithoutExtendingDeadline(t *testing.T) { + for _, budget := range []time.Duration{8 * time.Second, 20 * time.Second} { + parent, cancel := context.WithTimeout(t.Context(), budget) + ctx, done := askInitialLookupContext(parent) + parentDeadline, _ := parent.Deadline() + deadline, _ := ctx.Deadline() + want := time.Duration(0) + if budget == 20*time.Second { + want = 3 * time.Second + } + if parentDeadline.Sub(deadline) != want { + t.Errorf("budget=%s reserved=%s want=%s", budget, parentDeadline.Sub(deadline), want) + } + done() + if parent.Err() != nil { + t.Fatal("canceling initial lookup canceled recovery parent") + } + cancel() + } +} + +func TestLiveAskKartQuestions(t *testing.T) { + if os.Getenv("GOBOT_LIVE_LOOKUPS") != "1" { + t.Skip("opt-in live provider smoke test") + } + for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} { + t.Run(question, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + source, ok := (&Ask{}).findSource(ctx, question, bot.PluginConfig{}) + if !ok { + t.Fatal("no answer within the original request") + } + t.Logf("provider=%s source=%s", source.Provider, source.URL) + }) + } +} + func TestAskComparisonDoesNotReturnEntityDescription(t *testing.T) { old := askHTTPClient t.Cleanup(func() { askHTTPClient = old })