Files
HomeAgent/internal/knowledge/multimodal_e2e_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

198 lines
5.9 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 knowledge
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// fakeEmbedSvc 按内核契约提供 text/image 的 4 维向量:
// 「偏红的图」与「红色的文字」落在同一方向,用于验证真正的跨模态召回。
// 本测试用「内置 http provider + 一个最小契约服务」跑完整真实链路:
// embedding.Open → AdaptProvider → media CAS → SetDenseSpace/SetMediaGetter
// → AddWithMedia(文本⊕图片融合)→ 以图搜知识 → 落盘 → 重启命中缓存。
//
// 为何不用 ONNX provider:真模型要 200MB 权重 + 2~3 分钟加载,不能进 CI。
// 但这条链路是 provider 无关的——AdaptProvider 之后内核只认
// MultimodalEmbedder 接口。ONNX 路径已用本机 qwen3-vl 实测通过
// (dim=2048,以图搜知识 score=0.757),见本文件末尾注释。
func fakeEmbedSvc() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Modality string `json:"modality"`
Text string `json:"text"`
MIME string `json:"mime"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// 极简但**有区分度**的"语义":图片统一落方向 0;文本按长度奇偶
// 落方向 0 或 1。此前用长度/2 决定,导致 photo 与 plain 拿到同一个
// 向量形成同分,测试开始依赖并列先后——而并列是不确定的。
vec := []float64{0, 1, 0, 0}
if req.Modality == "text" {
if len([]rune(req.Text))%2 == 0 {
vec = []float64{1, 0, 0, 0}
}
} else {
vec = []float64{1, 0, 0, 0}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"embedding": vec})
}))
}
func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
svc := fakeEmbedSvc()
defer svc.Close()
p, err := embedding.Open("http", embedding.Config{Options: map[string]string{
"endpoint": svc.URL,
"dimension": "4",
"fingerprint": "test-http-4d",
}})
if err != nil {
t.Fatalf("Open(http) 失败: %v", err)
}
defer p.Close()
ds, err := vector.AdaptProvider(p)
if err != nil {
t.Fatalf("AdaptProvider: %v", err)
}
t.Logf("真实 provider: dim=%d fp=%s loaded=%v", ds.Dim(), ds.Fingerprint(), ds.Loaded())
ms, err := media.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
s.SetDenseSpace(ds)
s.SetMediaGetter(ms)
img := mmTinyPNG(t)
digest, err := ms.Put(img, media.Item{MIME: "image/png", Tool: "test"})
if err != nil {
t.Fatal(err)
}
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
{Digest: digest, MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
if err := s.Add("plain", "无关的纯文本知识"); err != nil {
t.Fatal(err)
}
t.Logf("DenseStats: %v", s.DenseStats())
k := s.items["photo"]
if k == nil || len(k.Dense) != 4 {
t.Fatalf("photo 稠密向量异常: %+v", k)
}
t.Logf("photo.Dense=%v(文本⊕图片融合后应偏向方向0)", k.Dense)
// 以图搜知识:图片向量 = 方向0,photo 的融合向量也应偏向方向0
qv, err := ds.EmbedImageDense(img, "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
hits := s.denseHits(qv)
if len(hits) == 0 {
t.Fatal("以图搜知识无命中")
}
t.Logf("以图搜知识: top1=%s score=%.4f", hits[0].id, hits[0].score)
if hits[0].id != "photo" {
t.Errorf("以图搜知识首位应为 photo,实为 %s", hits[0].id)
}
// 缓存 + 重启
if err := s.Flush(); err != nil {
t.Fatal(err)
}
s.Stop()
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
s2.SetDenseSpace(ds)
s2.SetMediaGetter(ms)
built, _ := s2.ReindexDense()
t.Logf("重启后 ReindexDense built=%d(0=命中缓存)", built)
if built != 0 {
t.Errorf("重启后应命中缓存,实为 %d", built)
}
if len(s2.items["photo"].Media) != 1 {
t.Error("媒体引用未跨重启存活")
}
s2.Stop()
}
// 稠密路同分时的次序必须可重复。
//
// denseHits 曾用 map 迭代 + 只按分数排序:同分条目的相对次序随机,
// 表现为「同样的查询两次给出不同首位」。Search 的主排序早有 tie-break,
// 这条漏了。
func TestDenseHitsTieIsDeterministic(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// fakeMM 缺省给同一个向量 ⇒ 查询与全部条目同向,全部同分。
// 注意必须走 SetDenseSpace(稠密路),不是 SetVectorizer(稀疏路)。
mm := &fakeMM{
dim: 8, fp: "tie-fp", loaded: true,
text: map[string][]float64{"__default": {1, 1, 1, 1, 1, 1, 1, 1}},
img: map[string][]float64{"__default": {1, 1, 1, 1, 1, 1, 1, 1}},
}
s.SetDenseSpace(mm)
_ = s.Add("zeta", "同分内容")
_ = s.Add("alpha", "同分内容")
_ = s.Add("mid", "同分内容")
qv := []float64{1, 1, 1, 1, 1, 1, 1, 1}
var first string
for i := 0; i < 30; i++ {
hits := s.denseHits(qv)
if len(hits) == 0 {
t.Fatal("无命中")
}
if i == 0 {
first = hits[0].id
continue
}
if hits[0].id != first {
t.Fatalf("第 %d 次首位变了: %s → %s(同分次序不确定)", i, first, hits[0].id)
}
}
// 且必须按名字定序
hits := s.denseHits(qv)
if hits[0].id != "alpha" {
t.Errorf("同分应按名字定序,首位应为 alpha,实为 %s", hits[0].id)
}
}
// mmTinyPNG 造一张 8x8 的真 PNG(需为合法 PNG 才能被 provider 当图片处理)。
func mmTinyPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
img.Set(3, 3, color.RGBA{R: 200, G: 40, B: 40, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}