Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`:

Expand Down Expand Up @@ -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

Expand Down
26 changes: 24 additions & 2 deletions plugins/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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",
} {
Expand Down Expand Up @@ -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() != "" {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions plugins/ask_live_latency_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
})
}
}
127 changes: 127 additions & 0 deletions plugins/ask_lookup.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading