fix: the assistant is told which search engine it has, results say which one they came from (and why, when DuckDuckGo stood in), the stand-in is logged

This commit is contained in:
redtea committed 2026-10-16 10:59:39 +03:00
1 parent 060975a443
commit 74a22ab57f
4 files changed
+60 -23

No files matched your search

+1 -5
View File
@@ -363,10 +363,6 @@ const aiAgent = `
Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг. Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг.
Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.` Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.`
const aiSearch = `
Поиск в интернете включён: блок ` + "```search\nзапрос\n```" + ` - панель найдёт его в DuckDuckGo и пришлёт результаты (заголовок, адрес, отрывок) следующим сообщением.
Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. Одно действие за ответ. Ссылки не выдумывай.`
const aiThink = ` const aiThink = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
@@ -552,7 +548,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
system += aiAgent system += aiAgent
} }
if in.Search { if in.Search {
system += aiSearch system += searchPrompt(s.searchConf())
} }
if in.Think { if in.Think {
system += aiThink system += aiThink
+39 -8
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"html" "html"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@@ -99,8 +100,23 @@ var (
ddgSearch = webSearch ddgSearch = webSearch
) )
// searchWith asks the engine c names; DuckDuckGo if that one fails. // engineName is how the engine is called to people and to the model.
func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) { func engineName(e string) string {
switch e {
case "searxng":
return "SearXNG"
case "brave":
return "Brave Search"
case "tavily":
return "Tavily"
}
return "DuckDuckGo"
}
// searchWith asks the engine c names; DuckDuckGo if that one fails. It
// tells which engine the results came from (and why, when it was not the
// chosen one).
func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, string, error) {
var rs []searchResult var rs []searchResult
var err error var err error
switch c.Engine { switch c.Engine {
@@ -111,15 +127,30 @@ func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchRes
case "tavily": case "tavily":
rs, err = searchTavily(ctx, c, q, n) rs, err = searchTavily(ctx, c, q, n)
default: default:
return ddgSearch(ctx, q, n) rs, err = ddgSearch(ctx, q, n)
return rs, "DuckDuckGo", err
} }
if err != nil || len(rs) == 0 { if err != nil || len(rs) == 0 {
return ddgSearch(ctx, q, n) why := "ничего не нашёл"
if err != nil {
why = err.Error()
}
log.Printf("search: %s failed (%s), DuckDuckGo instead", engineName(c.Engine), why)
rs, err = ddgSearch(ctx, q, n)
return rs, "DuckDuckGo (" + engineName(c.Engine) + " не ответил: " + why + ")", err
} }
if len(rs) > n { if len(rs) > n {
rs = rs[:n] rs = rs[:n]
} }
return rs, nil return rs, engineName(c.Engine), nil
}
// searchPrompt tells the model it can search, and with what.
func searchPrompt(c searchConf) string {
return "\nПоиск в интернете включён (поисковик: " + engineName(c.Engine) + "): блок ```search\nзапрос\n``` - панель найдёт и пришлёт результаты " +
"(заголовок, адрес, отрывок) следующим сообщением, с пометкой, через какой поисковик они пришли.\n" +
"Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. " +
"Одно действие за ответ. Ссылки не выдумывай."
} }
// getJSON fetches url (with the headers) into v. // getJSON fetches url (with the headers) into v.
@@ -241,10 +272,10 @@ func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"}) writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
return return
} }
rs, err := searchWith(r.Context(), s.searchConf(), strings.TrimSpace(in.Q), 6) rs, used, err := searchWith(r.Context(), s.searchConf(), strings.TrimSpace(in.Q), 6)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "engine": used})
return return
} }
writeJSON(w, map[string]any{"ok": true, "data": rs}) writeJSON(w, map[string]any{"ok": true, "data": rs, "engine": used})
} }
+18 -8
View File
@@ -54,24 +54,24 @@ func TestSearchProviders(t *testing.T) {
braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search" braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search"
ctx := context.Background() ctx := context.Background()
rs, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6) rs, used, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6)
if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" { if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" || used != "SearXNG" {
t.Fatalf("searxng: %+v %v", rs, err) t.Fatalf("searxng: %+v %v", rs, err)
} }
rs, err = searchWith(ctx, searchConf{Engine: "brave", Key: "bk"}, "q2", 6) rs, _, err = searchWith(ctx, searchConf{Engine: "brave", Key: "bk"}, "q2", 6)
if err != nil || rs[0].Snippet != "from brave" || gotKey != "bk" || gotQ != "q2" { if err != nil || rs[0].Snippet != "from brave" || gotKey != "bk" || gotQ != "q2" {
t.Fatalf("brave: %+v %v %q", rs, err, gotKey) t.Fatalf("brave: %+v %v %q", rs, err, gotKey)
} }
rs, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6) rs, used, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6)
if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" { if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" || used != "Tavily" {
t.Fatalf("tavily: %+v %v %q", rs, err, gotKey) t.Fatalf("tavily: %+v %v %q", rs, err, gotKey)
} }
// The chosen one fails: DuckDuckGo instead (stood in for here). // The chosen one fails: DuckDuckGo instead (stood in for here).
ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) { ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) {
return []searchResult{{Title: "DDG"}}, nil return []searchResult{{Title: "DDG"}}, nil
} }
rs, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6) rs, used, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6)
if err != nil || rs[0].Title != "DDG" { if err != nil || rs[0].Title != "DDG" || !strings.Contains(used, "DuckDuckGo") || !strings.Contains(used, "SearXNG не ответил") {
t.Fatalf("fallback: %+v %v", rs, err) t.Fatalf("fallback: %+v %v", rs, err)
} }
} }
@@ -89,9 +89,19 @@ func TestSearchLimitFallsBack(t *testing.T) {
} }
braveURL, tavilyURL = srv.URL, srv.URL braveURL, tavilyURL = srv.URL, srv.URL
for _, e := range []string{"brave", "tavily", "searxng"} { for _, e := range []string{"brave", "tavily", "searxng"} {
rs, err := searchWith(context.Background(), searchConf{Engine: e, Key: "k", URL: srv.URL}, "x", 6) rs, _, err := searchWith(context.Background(), searchConf{Engine: e, Key: "k", URL: srv.URL}, "x", 6)
if err != nil || len(rs) != 1 || rs[0].Title != "DDG x" { if err != nil || len(rs) != 1 || rs[0].Title != "DDG x" {
t.Fatalf("%s: %+v %v", e, rs, err) t.Fatalf("%s: %+v %v", e, rs, err)
} }
} }
} }
// The model is told which engine it searches with.
func TestSearchPromptNamesEngine(t *testing.T) {
if p := searchPrompt(searchConf{Engine: "tavily"}); !strings.Contains(p, "Tavily") || strings.Contains(p, "DuckDuckGo") {
t.Fatalf("tavily: %s", p)
}
if p := searchPrompt(searchConf{}); !strings.Contains(p, "DuckDuckGo") {
t.Fatalf("default: %s", p)
}
}
+2 -2
View File
@@ -321,8 +321,8 @@
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»'; if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'})); body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
if (!r.ok) return 'Ошибка поиска «' + a.body + '»: ' + r.error; if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error;
return 'Результаты поиска «' + a.body + '»:\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n'); return 'Результаты поиска «' + a.body + '» через ' + (r.engine || 'поисковик') + ':\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
} }
await loadMachines(); await loadMachines();
const m = machineOf(a.machine); const m = machineOf(a.machine);