Files
HomeAgent/providers/chineseclip/embedder_onnx_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

290 lines
9.4 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.

//go:build onnxruntime
package chineseclip
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/png"
"math"
"os"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// cosine 计算两个向量的**真余弦**:两边都先归一化。
//
// 参考向量存的是 ONNX 的原始输出(未归一化,模长 10~36),而 provider 的输出是
// L2 归一化后的。直接点积会得到参考向量的模长(例如 13.6),既不是余弦,
// 也会让单侧阈值判定变成假通过。
func cosine(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return math.NaN()
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return math.NaN()
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// solidPNG 生成与导出脚本 FIXTURE_IMAGES 一致的纯色图(320×320)。
func solidPNG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 320, 320))
for y := 0; y < 320; y++ {
for x := 0; x < 320; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("生成测试图: %v", err)
}
return buf.Bytes()
}
func openTestProvider(t *testing.T) embedding.Provider {
t.Helper()
dir := modelDir(t)
p, err := embedding.Open("chineseclip", embedding.Config{
Options: map[string]string{"model_dir": dir},
})
if err != nil {
t.Fatalf("embedding.Open(chineseclip): %v", err)
}
t.Cleanup(p.Close)
return p
}
// provider 必须通过公共 SPI 可见,并声明正确的向量空间身份。
func TestProviderInfo(t *testing.T) {
p := openTestProvider(t)
info := p.Info()
if info.Dimension != 512 {
t.Errorf("维度应为 512,得到 %d", info.Dimension)
}
if len(info.Fingerprint) != 64 {
t.Errorf("指纹应为 64 位十六进制,得到 %q", info.Fingerprint)
}
want := map[embedding.Modality]bool{embedding.ModalityText: true, embedding.ModalityImage: true}
if len(info.Modalities) != len(want) {
t.Fatalf("模态应为 %v,得到 %v", want, info.Modalities)
}
for _, m := range info.Modalities {
if !want[m] {
t.Errorf("声明了未支持的模态 %q", m)
}
}
}
// 文本向量必须与官方 PyTorch 参考一致。
//
// 文本侧没有预处理歧义(分词器已逐 token 对齐),所以要求非常严:
// 余弦与参考的偏差应小于 1e-9。
func TestEmbedTextMatchesReference(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
if len(ref.Texts) == 0 {
t.Fatal("参考里没有文本用例")
}
ctx := context.Background()
for _, c := range ref.Texts {
got, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityText,
Purpose: embedding.PurposeQuery,
Text: c.Text,
})
if err != nil {
t.Fatalf("Embed(text=%q): %v", c.Text, err)
}
if err := embedding.ValidateVector(got, 512); err != nil {
t.Fatalf("向量不合法 %q: %v", c.Text, err)
}
cos := cosine(got, c.Vector)
if math.Abs(cos-1.0) > 1e-9 {
t.Errorf("文本向量与参考不一致 %q: cos=%.12f", c.Text, cos)
}
t.Logf("文本 cos=%.12f %s", cos, c.Text[:min(len(c.Text), 24)])
}
}
// 图像向量与官方参考一致(容忍缩放实现差异)。
//
// Go 侧自写 bicubic(PIL 系数)与官方预处理不会逐位相同,故用余弦阈值;
// 0.999 足以证明「同一条管线」,同时不会掩盖把像素顺序或归一化写错这类错误
// (那类错误会直接掉到 0.9 以下)。
func TestEmbedImageMatchesReference(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
ctx := context.Background()
colors := map[string]color.RGBA{
"red": {R: 220, G: 30, B: 30, A: 255},
"green": {R: 60, G: 120, B: 60, A: 255},
"blue": {R: 30, G: 30, B: 220, A: 255},
"gray": {R: 128, G: 128, B: 128, A: 255},
}
checked := 0
for _, c := range ref.Images {
rgba, ok := colors[c.Name]
if !ok {
continue // gradient 在 Go 侧不便逐位复刻,跳过(仍由导出脚本覆盖)
}
got, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityImage,
Purpose: embedding.PurposeDocument,
Data: solidPNG(t, rgba),
MIME: "image/png",
})
if err != nil {
t.Fatalf("Embed(image=%s): %v", c.Name, err)
}
cos := cosine(got, c.Vector)
// 双侧判定:单侧下界挡不住「模长缩放」这类错误(未归一化的参考向量
// 会让点积恰好远大于 1 而“通过”)。
if math.Abs(cos-1.0) > 1e-3 {
t.Errorf("图像向量与参考不一致 %s: cos=%.6f", c.Name, cos)
}
t.Logf("图像 cos=%.6f %s", cos, c.Name)
checked++
}
if checked == 0 {
t.Fatal("没有比对任何图像用例(参考里缺少纯色样例)")
}
}
// 跨模态必须真的有区分度:红图对"红色"文本应高于"蓝色"文本。
// 这条防的是「向量塌缩但 cos 检查全过」那类假通过。
func TestCrossModalDiscrimination(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
ctx := context.Background()
var redText, blueText string
for _, c := range ref.Texts {
switch c.Text {
case "一张红色方块的图片":
redText = c.Text
case "蓝色的天空":
blueText = c.Text
}
}
if redText == "" || blueText == "" {
t.Skip("参考里缺少用于跨模态判别的文本")
}
imgVec, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityImage, Data: solidPNG(t, color.RGBA{R: 220, G: 30, B: 30, A: 255}),
MIME: "image/png",
})
if err != nil {
t.Fatalf("Embed(image): %v", err)
}
redVec, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: redText})
if err != nil {
t.Fatalf("Embed(red text): %v", err)
}
blueVec, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: blueText})
if err != nil {
t.Fatalf("Embed(blue text): %v", err)
}
if cosine(imgVec, redVec) <= cosine(imgVec, blueVec) {
t.Errorf("跨模态判别失败:红图-红文本 %.4f 应高于 红图-蓝文本 %.4f",
cosine(imgVec, redVec), cosine(imgVec, blueVec))
}
}
// 音频/视频必须明确拒绝,绝不用别的模型向量冒充。
func TestEmbedRejectsUnsupportedModalities(t *testing.T) {
p := openTestProvider(t)
ctx := context.Background()
for _, m := range []embedding.Modality{embedding.ModalityAudio, embedding.ModalityVideo} {
_, err := p.Embed(ctx, embedding.Input{Modality: m, Data: []byte("x"), MIME: "application/octet-stream"})
if err == nil {
t.Fatalf("模态 %s 应被拒绝", m)
}
if !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Errorf("模态 %s 的错误应可判定为 ErrUnsupportedModality,得到: %v", m, err)
}
}
}
// 空输入必须报错而不是产出垃圾向量。
func TestEmbedRejectsEmptyInput(t *testing.T) {
p := openTestProvider(t)
ctx := context.Background()
if _, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: " "}); err == nil {
t.Error("空文本应报错")
}
if _, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityImage}); err == nil {
t.Error("空图像应报错")
}
}
// 指纹必须稳定且对产物内容敏感(同目录两次打开一致)。
func TestFingerprintStable(t *testing.T) {
dir := modelDir(t)
first, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err != nil {
t.Fatalf("首次打开: %v", err)
}
defer first.Close()
second, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err != nil {
t.Fatalf("二次打开: %v", err)
}
defer second.Close()
if first.Info().Fingerprint != second.Info().Fingerprint {
t.Errorf("同一产物两次打开指纹不一致: %s vs %s",
first.Info().Fingerprint, second.Info().Fingerprint)
}
}
// 缺 model_dir 时不得静默成功打开一个“空产物”。
//
// 2026-10-01 语义变更:New 增加了 findModelDir 回退链(configured →
// /usr/lib/homeagent/models)。若本机装了系统级产物,回退命中属合法行为,
// 但打开的必须是那个真实产物(指纹非空);若系统目录也没有,则必须报错,
// 且错误里要带可执行指引(export 脚本名)。
func TestOpenRejectsMissingModelDir(t *testing.T) {
got, err := embedding.Open("chineseclip", embedding.Config{})
if err == nil {
// 回退命中了系统目录:允许,但必须是真的产物而不是空壳。
defer got.Close()
if got.Info().Fingerprint == "" {
t.Fatal("回退打开成功但指纹为空(打开了个空壳?)")
}
return
}
// 未命中回退 ⇒ 必须报错且给可执行指引。
if !strings.Contains(err.Error(), "export_chineseclip_onnx.py") {
t.Fatalf("错误应带可执行指引(export 脚本名),got: %v", err)
}
}
// 目录存在但不是本模型产物时,必须报出缺哪个文件,而不是静默用默认值。
func TestOpenRejectsIncompleteArtifacts(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/embed_config.json", []byte(`{"dim":512,"max_length":52,"image_size":224,"image_mean":[0.5,0.5,0.5],"image_std":[0.5,0.5,0.5],"text_onnx":"TextEncoder.onnx","vision_onnx":"VisionEncoder.onnx"}`), 0o644); err != nil {
t.Fatal(err)
}
_, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err == nil {
t.Fatal("产物不完整时应打开失败")
}
}