Files
Cyrene_v2/cmd/cyrene/wire_web_search_test.go
T
AskaEth 120d926cf8 feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
  (本片自己不开路由)

⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
  另有 design/28 的相关注册留给主会话。
2026-10-06 09:04:04 +08:00

385 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT`
// 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。
//
// 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 ——
// 源码接缝只作为最后一段的防回归补充。
// wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env,
// 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。
func wsTestEnvFile(t *testing.T, extra ...string) string {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return path
}
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
//
// DataDir 一律指到本用例自己的临时目录:web-search 的可变源
// (`$DATA_DIR/config/plugins/web-search.json`,PLG-4)会**按启动期语义**读/播种它,
// 默认的 `./data` 会让并行用例互相踩同一份文件(且把仓库工作目录写脏)。
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
t.Helper()
cfg, err := config.Load(wsTestEnvFile(t, extra...))
if err != nil {
t.Fatalf("config.Load: %v", err)
}
cfg.DataDir = t.TempDir()
return cfg
}
// wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions →
// iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。
func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app {
t.Helper()
caps := registry.New()
dispatcher, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{cfg: cfg, log: testLog()}
if logOut != nil {
a.log = logger.New(logger.Options{Out: logOut, Level: "debug"})
}
set, err := iotools.New(cfg, a.ioToolOptions()...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tl := range set {
if rerr := dispatcher.Register(tl); rerr != nil {
t.Fatalf("register %s: %v", tl.Name(), rerr)
}
}
a.caps = caps
a.dispatcher = dispatcher
return a
}
// wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。
func wsHasTool(a *app, name string) bool {
for _, n := range toolNames(a) {
if n == name {
return true
}
}
return false
}
// isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。
func isOffSentinel(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none":
return true
default:
return false
}
}
// TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时
// 默认值生效(本机兜底),web-search 出现在注册面里。
func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
const want = "http://127.0.0.1:38471"
if cfg.WebSearch.Endpoint != want {
t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name)
}
}
}
// TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭
// (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** ——
// 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下
// 一条带键名的 WARN:配了却没用,绝不静默。
func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
envLine string
wantValue string
}{
{"显式留空", "WEB_SEARCH_ENDPOINT=", ""},
{"off", "WEB_SEARCH_ENDPOINT=off", "off"},
{"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"},
{"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"},
{"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := wsLoadConfig(t, tc.envLine)
if cfg.WebSearch.Endpoint != tc.wantValue {
t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue)
}
a := wsSurfaceApp(t, cfg, &buf)
if wsHasTool(a, "web-search") {
t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a))
}
// 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("%q 不得因 web-search 关闭而消失", name)
}
}
// WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。
out := buf.String()
if !strings.Contains(out, webSearchEndpointEnv) {
t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out)
}
if !strings.Contains(out, "web-search tool not registered") {
t.Fatalf("日志里没有说明 web-search 未注册:%q", out)
}
// 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对
// 「像端点/主机」的那几个取值断言它没被打印。
if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) {
t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out)
}
})
}
}
// TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是
// **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。
func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) {
t.Parallel()
hits := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
}))
defer srv.Close()
cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search")
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
toolValue, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil {
t.Fatalf("Execute: %v", err)
}
select {
case r := <-hits:
if r.URL.Path != "/search" {
t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path)
}
if got := r.URL.Query().Get("q"); got != "昔涟" {
t.Errorf("q = %q", got)
}
if got := r.URL.Query().Get("format"); got != "json" {
t.Errorf("format = %q, want json", got)
}
case <-time.After(2 * time.Second):
t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具")
}
}
// TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上:
// 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自
// 这个配置)。负值按未配置处理并记一条 WARN。
func TestWireIOTools_TimeoutIsWired(t *testing.T) {
t.Parallel()
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
defer close(release)
cfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=100ms",
)
if cfg.WebSearch.Timeout != 100*time.Millisecond {
t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout)
}
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil {
t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)")
}
// 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。
var buf bytes.Buffer
negCfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=-5s",
)
negApp := wsSurfaceApp(t, negCfg, &buf)
if !wsHasTool(negApp, "web-search") {
t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉")
}
if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) {
t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out)
}
}
// wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻
// 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游
// 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。
func wsSearxngResultCount(endpoint, query string) int {
client := &http.Client{Timeout: 25 * time.Second}
resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json")
if err != nil {
return -1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return -1
}
var payload struct {
Results []struct {
URL string `json:"url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return -1
}
return len(payload.Results)
}
// TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_*
// 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。
// 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出
// 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。
func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
t.Parallel()
const query = "昔涟 崩坏星穹铁道"
cfg := wsLoadConfig(t)
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
if upstream < 0 {
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
}
if upstream == 0 {
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("caps.Get(web-search): %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("默认端点真机查询失败: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
count, _ := m["count"].(int)
if count == 0 {
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
}
t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count)
}
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
// env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。
"iotools.New(a.cfg, a.ioToolOptions()...)",
},
"wire_io_tools.go": {
// WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。
"iotools.WithSearchProviders(specs...)",
"iotools.WithSearchSelection(ws.Provider, ws.Fallback)",
"iotools.WithSearchMaxResults(ws.MaxResults)",
"iotools.WithoutSearch()",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
template := string(raw)
for _, line := range []string{
webSearchProviderEnv + "=",
webSearchEndpointEnv + "=",
webSearchTimeoutEnv + "=",
webSearchMaxResultsEnv + "=",
webSearchFallbackEnv + "=",
// 默认值必须在模板里如实写出(本机兜底 + searxng 主后端),否则照模板部署的人
// 看不出它接的是什么。
webSearchEndpointEnv + "=http://127.0.0.1:38471",
webSearchProviderEnv + "=searxng",
} {
if !strings.Contains(template, line) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line)
}
}
}