Files
HomeAgent/internal/knowledge/rankdiag_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

156 lines
4.5 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 (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// 知识库检索质量判据(真实数据,默认跳过)。
//
// KB_DIAG=1 → 跑并打印指标
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用)
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
//
// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
// 排序等于噪声;这一项掉下来就说明检索坏了。
//
// 实测(33 条真实 KB):
//
// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133
// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
func TestRankingQualityOnRealKB(t *testing.T) {
if os.Getenv("KB_DIAG") == "" {
t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)")
}
srcRoot := envOr("KB_DIAG_ROOT", envOr("KB_DIAG_ROOT", "/data/knowledge"))
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
// 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据
tmp := t.TempDir()
entries, err := os.ReadDir(srcRoot)
if err != nil {
t.Fatalf("读取 %s: %v", srcRoot, err)
}
names := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
src := filepath.Join(srcRoot, e.Name(), "content.md")
in, err := os.Open(src)
if err != nil {
continue
}
dst := filepath.Join(tmp, e.Name(), "content.md")
os.MkdirAll(filepath.Dir(dst), 0755)
out, _ := os.Create(dst)
io.Copy(out, in)
out.Close()
in.Close()
names = append(names, e.Name())
}
if len(names) == 0 {
t.Fatal("没有可用的知识条目")
}
st := NewStore(tmp)
st.SetVectorizer(emb)
if err := st.Start(); err != nil {
t.Fatalf("start: %v", err)
}
top1, mrr, missed := 0, 0.0, []string{}
for _, name := range names {
// 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
// (我第一版就是这么写的,把 0.376 误报成 0.197)
hits := st.Search(name, len(names))
if len(hits) == 0 {
missed = append(missed, name+"(无结果)")
continue
}
if hits[0].Name == name {
top1++
} else {
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
}
for i, h := range hits {
if h.Name == name {
mrr += 1.0 / float64(i+1)
break
}
}
}
n := float64(len(names))
rate := 100 * float64(top1) / n
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
if len(missed) > 0 {
fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10))
}
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
hits := st.Search(q, 2)
got := []string{}
for _, h := range hits {
got = append(got, h.Name)
}
fmt.Printf(" 查询「%s」→ %v\n", q, got)
}
if os.Getenv("KB_DIAG_SWEEP") != "" {
fmt.Printf("\n === 稀疏融合权重扫描(1.0 = 只用语义路,0.0 = 只用词法路)===\n")
fmt.Printf(" (无多模态稠密路接入时,本扫描直接对应历史 densePathWeight 的语义)\n")
saved := sparseSemWeight
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
sparseSemWeight = w
t1, m := 0, 0.0
for _, name := range names {
hits := st.Search(name, len(names))
for i, h := range hits {
if h.Name == name {
if i == 0 {
t1++
}
m += 1.0 / float64(i+1)
break
}
}
}
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
}
sparseSemWeight = saved
}
if os.Getenv("KB_DIAG_ASSERT") != "" {
if mrr/n < 0.34 {
t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n)
}
if rate < 18 {
t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate)
}
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func firstN(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}