diff --git a/docs/configuration.md b/docs/configuration.md index ef912b8..d2290d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -203,6 +203,16 @@ challenges or guarantee a Search Assist answer. Entity questions can also fall back to a matching Wikipedia summary; procedural and opinion questions cannot use an unrelated encyclopedia summary as an answer. +The lightweight Search Assist stage allows up to four seconds; the browser +stage allows up to ten seconds including startup and reserves time for the +remaining search fallback. Both respect the overall `timeout_seconds` limit. +For complex comparisons on small VPS hosts, 20 seconds is a useful setting. +Only one browser lookup runs at a time across networks; waiting for that slot +also counts against the request deadline. Cost/comparison and other open-ended +questions skip generic Instant Answer/Wikidata entity summaries and use the +web-result fallback when Search Assist has no answer. Page challenges and +provider outages can still prevent an answer even when a personal browser works. + `define` uses the public English dictionary service with an independent English Wiktionary fallback. The primary request is capped at two seconds or one third of the remaining deadline, leaving time for fallback. Wiktionary responses link diff --git a/docs/plugins.md b/docs/plugins.md index f1ce96b..1c739e8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1346,6 +1346,14 @@ unavailable or presents an anti-automation challenge, open-ended, procedural, opinion, comparison, and controversy questions also use the bounded Bing public-search fallback described below. +Price and comparison questions such as `!ask how much do dirt bikes cost vs GoKarts?` +use Search Assist or attributed web excerpts, never a generic entity description +as a substitute for the comparison. A bounded comparison retry normalizes `vs` +and compound word boundaries while retaining both subjects. Browser extraction +waits for answer text with a source link instead of stopping at the empty Search +Assist heading, and supports expanded answer cards. Browser requests share one +slot across networks to limit memory use on small hosts. + The plugin is configured under `plugins.ask`: ~~~yaml diff --git a/plugins/ask.go b/plugins/ask.go index 9a21e72..27df97f 100644 --- a/plugins/ask.go +++ b/plugins/ask.go @@ -248,7 +248,7 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon var deferredWebResult askSource deferredWebResultFound := false if cfg.Bool("search_assist_enabled", true) { - stepCtx, cancel := askStepContext(ctx, 1500*time.Millisecond) + stepCtx, cancel := askStepContext(ctx, 4*time.Second) if source, ok := askDuckDuckGoSearchAssist(stepCtx, question); ok { cancel() return source, true @@ -257,7 +257,7 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon if cfg.Bool("search_assist_browser_enabled", true) { fetchResults := cfg.Bool("search_results_enabled", true) webResultTried = fetchResults - stepCtx, cancel = askStepContext(ctx, 5*time.Second) + stepCtx, cancel = askBrowserStepContext(ctx) if source, ok := askDuckDuckGoRenderedSearchAssist(stepCtx, question, cfg.String("browser_path", ""), fetchResults); ok { cancel() if source.Provider == "search_result" { @@ -271,7 +271,7 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon } } } - if cfg.Bool("duckduckgo_enabled", true) { + if cfg.Bool("duckduckgo_enabled", true) && !askNeedsWebResultAnswer(question) { stepCtx, cancel := askStepContext(ctx, 1600*time.Millisecond) if source, ok := askDuckDuckGoWithRetry(stepCtx, question); ok { if refined, ok := refineFocusedAskSource(question, source); ok { @@ -311,7 +311,7 @@ func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginCon } cancel() } - if cfg.Bool("wikidata_fallback", true) && focused != "" { + if cfg.Bool("wikidata_fallback", true) && focused != "" && !askNeedsWebResultAnswer(question) { stepCtx, cancel := askStepContext(ctx, 2500*time.Millisecond) switch { case askNeedsRelationshipAnswer(question): @@ -364,6 +364,23 @@ func askStepContext(parent context.Context, max time.Duration) (context.Context, return context.WithTimeout(parent, max) } +// Reserve a small, fixed tail for public search. Halving every stage's +// remaining time gave Chromium too little time to start and render an answer. +func askBrowserStepContext(parent context.Context) (context.Context, context.CancelFunc) { + budget := 10 * time.Second + if deadline, ok := parent.Deadline(); ok { + remaining := time.Until(deadline) + reserve := 2 * time.Second + if remaining/4 < reserve { + reserve = remaining / 4 + } + if remaining-reserve < budget { + budget = remaining - reserve + } + } + return context.WithTimeout(parent, budget) +} + func askLocalAnswer(question string) (string, bool) { normalized := strings.ToLower(strings.TrimSpace(question)) normalized = strings.Trim(normalized, " \t\r\n?%!.") @@ -472,9 +489,13 @@ func askNeedsTemporalAnswer(question string) bool { func askNeedsWebResultAnswer(question string) bool { lower := strings.ToLower(strings.TrimSpace(question)) + if askComparisonPattern.MatchString(lower) { + return true + } for _, phrase := range []string{ "why ", "why is ", "why are ", "why was ", "why were ", "how does ", "how do ", "how can ", "how should ", + "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", "is it true", "is it worth", "is it safe", "should i ", "should we ", @@ -657,9 +678,9 @@ type duckDuckGoSearchAssistPayload struct { } `json:"instantAnswers"` } -var askSearchAssistScriptPattern = regexp.MustCompile(`(?s)`, + "", + } { + if got := askSearchAssistPreloadURL([]byte(markup), "https://duckduckgo.com/?q=test"); got != "https://duckduckgo.com/assist.js?q=a&x=b" { + t.Fatalf("preload=%q", got) + } + } + if got := askSearchAssistPreloadURL([]byte(``), "https://duckduckgo.com/"); got != "" { + t.Fatal("unrelated script accepted") + } +} + +func TestSearchAssistPayloadWhitespaceAndTrailingCode(t *testing.T) { + for _, body := range []string{ + `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"A sourced comparison.","sources":[]}}]};differentCallback();`, + "DDG.deep.deepPayload\n=\n{\"instantAnswers\":[{\"data\":{\"answer\":\"A sourced comparison.\"}}]};", + } { + got, ok := parseDuckDuckGoSearchAssist(body, "https://duckduckgo.com/?q=test") + if !ok || got.Summary != "A sourced comparison." { + t.Fatalf("%+v %v", got, ok) + } + } + if _, ok := parseDuckDuckGoSearchAssist(`DDG.deep.deepPayload={broken`, ""); ok { + t.Fatal("malformed JSON accepted") + } +} + +func TestAskComparisonRecoversOnNormalizedRetry(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + var queries []string + 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":"Compare equivalent dirt bikes and go-karts, including maintenance costs.","sources":[{"article":{"link":"https://example.org/comparison"}}]}}]};callback();`), nil + } + queries = append(queries, r.URL.Query().Get("q")) + if len(queries) == 1 { + return newPluginResponse(200, `
No answer yet`), nil + } + return newPluginResponse(200, ``), nil + })} + got, ok := (&Ask{}).findSource(t.Context(), "how much do dirt bikes cost vs GoKarts?", bot.PluginConfig{}) + if !ok || got.Provider != "search_assist" || got.URL != "https://example.org/comparison" { + t.Fatalf("retry failed: %+v %v", got, ok) + } + if !reflect.DeepEqual(queries, []string{"how much do dirt bikes cost vs GoKarts?", "how much do dirt bikes cost versus go karts?"}) { + t.Fatalf("unexpected requests: %q", queries) + } +} + +func TestAskComparisonDoesNotReturnEntityDescription(t *testing.T) { + old := askHTTPClient + t.Cleanup(func() { askHTTPClient = old }) + askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) { + t.Fatalf("entity provider should not be called for a cost comparison: %s", r.URL.Host) + return nil, context.Canceled + })} + cfg := bot.PluginConfig{"search_assist_enabled": false, "search_assist_browser_enabled": false, "search_results_enabled": false} + if _, ok := (&Ask{}).findSource(t.Context(), "how much do dirt bikes cost vs GoKarts?", cfg); ok { + t.Fatal("generic answer substituted for comparison") + } +} + +func TestAskBrowserBudgetLeavesFallbackTime(t *testing.T) { + parent, cancel := context.WithTimeout(t.Context(), 8*time.Second) + defer cancel() + ctx, done := askBrowserStepContext(parent) + defer done() + deadline, _ := ctx.Deadline() + remaining := time.Until(deadline) + if remaining < 5*time.Second || remaining > 6*time.Second { + t.Fatalf("browser budget=%s", remaining) + } +} + +// Run with GOBOT_TEST_BROWSER=/path/to/chromium. This fixture runs entirely +// offline and verifies the DOM code against a delayed, expanded answer card. +func TestAskRenderedCardWaitsForAnswer(t *testing.T) { + executable := os.Getenv("GOBOT_TEST_BROWSER") + if executable == "" { + t.Skip("set GOBOT_TEST_BROWSER for offline browser fixture") + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + allocator, stop := chromedp.NewExecAllocator(ctx, append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(executable))...) + defer stop() + page, closePage := chromedp.NewContext(allocator) + defer closePage() + var before *askRenderedSearchAssistData + var answer askRenderedSearchAssistData + const fixture = `document.body.innerHTML='2002
Source'`, nil), chromedp.Evaluate("("+askRenderedSearchAssistScript+")()", &answer)); err != nil { + t.Fatal(err) + } + if answer.Text != "2002" { + t.Fatalf("short answer lost: %+v", answer) + } +} + +func TestLiveAskCostComparison(t *testing.T) { + if os.Getenv("GOBOT_LIVE_LOOKUPS") != "1" { + t.Skip("opt-in live provider smoke test") + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + source, ok := (&Ask{}).findSource(ctx, "how much do dirt bikes cost vs GoKarts?", bot.PluginConfig{}) + if !ok { + t.Fatal("no comparison answer") + } + t.Logf("provider=%s source=%s answer=%s", source.Provider, source.URL, source.Summary) + if source.Provider != "search_assist" && source.Provider != "search_result" { + t.Fatalf("wrong provider for comparison: %s", source.Provider) + } +}