From 74a22ab57f48964e28ec2d17af84093b4718f1cd Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 10:59:39 +0300 Subject: [PATCH] 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 --- internal/server/ai.go | 6 +--- internal/server/search.go | 47 ++++++++++++++++++++++++++------ internal/server/search_test.go | 26 ++++++++++++------ internal/server/web/static/ai.js | 4 +-- 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/internal/server/ai.go b/internal/server/ai.go index 889cb99..8da08c8 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -363,10 +363,6 @@ const aiAgent = ` Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг. Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.` -const aiSearch = ` -Поиск в интернете включён: блок ` + "```search\nзапрос\n```" + ` - панель найдёт его в DuckDuckGo и пришлёт результаты (заголовок, адрес, отрывок) следующим сообщением. -Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. Одно действие за ответ. Ссылки не выдумывай.` - const aiThink = ` Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.` @@ -552,7 +548,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { system += aiAgent } if in.Search { - system += aiSearch + system += searchPrompt(s.searchConf()) } if in.Think { system += aiThink diff --git a/internal/server/search.go b/internal/server/search.go index 2fa0306..62d4f41 100644 --- a/internal/server/search.go +++ b/internal/server/search.go @@ -6,6 +6,7 @@ import ( "errors" "html" "io" + "log" "net/http" "net/url" "regexp" @@ -99,8 +100,23 @@ var ( ddgSearch = webSearch ) -// searchWith asks the engine c names; DuckDuckGo if that one fails. -func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) { +// engineName is how the engine is called to people and to the model. +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 err error switch c.Engine { @@ -111,15 +127,30 @@ func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchRes case "tavily": rs, err = searchTavily(ctx, c, q, n) default: - return ddgSearch(ctx, q, n) + rs, err = ddgSearch(ctx, q, n) + return rs, "DuckDuckGo", err } 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 { 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. @@ -241,10 +272,10 @@ func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"}) 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 { - writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "engine": used}) return } - writeJSON(w, map[string]any{"ok": true, "data": rs}) + writeJSON(w, map[string]any{"ok": true, "data": rs, "engine": used}) } diff --git a/internal/server/search_test.go b/internal/server/search_test.go index a5fdf1c..e3547a2 100644 --- a/internal/server/search_test.go +++ b/internal/server/search_test.go @@ -54,24 +54,24 @@ func TestSearchProviders(t *testing.T) { braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search" ctx := context.Background() - rs, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6) - if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" { + 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" || used != "SearXNG" { 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" { t.Fatalf("brave: %+v %v %q", rs, err, gotKey) } - rs, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6) - if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" { + rs, used, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6) + if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" || used != "Tavily" { t.Fatalf("tavily: %+v %v %q", rs, err, gotKey) } // The chosen one fails: DuckDuckGo instead (stood in for here). ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) { return []searchResult{{Title: "DDG"}}, nil } - rs, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6) - if err != nil || rs[0].Title != "DDG" { + rs, used, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6) + if err != nil || rs[0].Title != "DDG" || !strings.Contains(used, "DuckDuckGo") || !strings.Contains(used, "SearXNG не ответил") { t.Fatalf("fallback: %+v %v", rs, err) } } @@ -89,9 +89,19 @@ func TestSearchLimitFallsBack(t *testing.T) { } braveURL, tavilyURL = srv.URL, srv.URL 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" { 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) + } +} diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 755151b..2b3c4e4 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -321,8 +321,8 @@ if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»'; 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: 'нет связи'})); - if (!r.ok) return 'Ошибка поиска «' + a.body + '»: ' + r.error; - return 'Результаты поиска «' + a.body + '»:\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n'); + if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error; + 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(); const m = machineOf(a.machine);