mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 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 保留。
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
)
|
||
|
||
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
|
||
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
|
||
type fakeDense struct {
|
||
byText map[string]vector.Vector
|
||
def vector.Vector
|
||
}
|
||
|
||
func (f fakeDense) Vectorize(text string) vector.Vector {
|
||
if v, ok := f.byText[text]; ok {
|
||
return v
|
||
}
|
||
return f.def
|
||
}
|
||
|
||
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
|
||
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
|
||
return f.def, nil
|
||
}
|
||
|
||
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
|
||
t.Helper()
|
||
st := NewStore(t.TempDir())
|
||
if dense != nil {
|
||
st.SetVectorizer(dense)
|
||
}
|
||
return st
|
||
}
|
||
|
||
func mustAdd(t *testing.T, st *Store, name, content string) {
|
||
t.Helper()
|
||
if err := st.Add(name, content); err != nil {
|
||
t.Fatalf("add %s: %v", name, err)
|
||
}
|
||
}
|
||
|
||
func names(hits []*Knowledge) []string {
|
||
out := make([]string, len(hits))
|
||
for i, h := range hits {
|
||
out[i] = h.Name
|
||
}
|
||
return out
|
||
}
|
||
|
||
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
|
||
func base36(n int) string {
|
||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
out := ""
|
||
for n > 0 {
|
||
out = string(digits[n%36]) + out
|
||
n /= 36
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
|
||
// 词法路必须把结果救回来——修复前这里直接返回空。
|
||
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
|
||
dense := fakeDense{def: vector.Vector{"0": 1}}
|
||
st := newTestStore(t, dense)
|
||
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
|
||
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
|
||
|
||
// 让"更新"的稠密向量为空(模拟停用词化)
|
||
st.SetVectorizer(fakeDense{
|
||
byText: map[string]vector.Vector{"更新": {}},
|
||
def: vector.Vector{"0": 1},
|
||
})
|
||
hits := st.Search("更新", 5)
|
||
if len(hits) == 0 {
|
||
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
|
||
}
|
||
if hits[0].Name != "changelog_v1" {
|
||
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
|
||
}
|
||
}
|
||
|
||
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
|
||
// 排序必须由词法路决定。
|
||
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
|
||
same := vector.Vector{"0": 1, "1": 1}
|
||
st := newTestStore(t, fakeDense{def: same})
|
||
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
|
||
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
|
||
mustAdd(t, st, "privacy_policy", "隐私政策")
|
||
|
||
hits := st.Search("hmapdev 构建", 3)
|
||
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
|
||
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits))
|
||
}
|
||
}
|
||
|
||
// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
|
||
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
|
||
//
|
||
// 判据分两层,各钉一半:
|
||
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
|
||
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
|
||
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
|
||
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词,
|
||
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27,
|
||
// 前提断言直接把这两版夹具否掉了)。
|
||
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
if got := st.lex.MinScore(); got != 0 {
|
||
t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got)
|
||
}
|
||
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
|
||
t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got)
|
||
}
|
||
}
|
||
|
||
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
|
||
// (或新条目只在其中一路可见)。
|
||
func TestAddRemoveKeepsBothPaths(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
|
||
mustAdd(t, st, "beta", "beta 独有词 betaonly")
|
||
|
||
has := func(q, want string) bool {
|
||
for _, h := range st.Search(q, 5) {
|
||
if h.Name == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
if !has("alphaonly", "alpha") {
|
||
t.Fatal("新增条目应可被检索到")
|
||
}
|
||
if err := st.Remove("alpha"); err != nil {
|
||
t.Fatalf("remove: %v", err)
|
||
}
|
||
if has("alphaonly", "alpha") {
|
||
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
|
||
}
|
||
if !has("betaonly", "beta") {
|
||
t.Fatal("删除其它条目不应影响 beta")
|
||
}
|
||
}
|
||
|
||
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
|
||
func TestSearchDeterministicOnTies(t *testing.T) {
|
||
same := vector.Vector{"0": 1}
|
||
st := newTestStore(t, fakeDense{def: same})
|
||
for _, n := range []string{"ccc", "aaa", "bbb"} {
|
||
mustAdd(t, st, n, "完全一样的内容")
|
||
}
|
||
first := names(st.Search("完全一样的内容", 3))
|
||
for i := 0; i < 5; i++ {
|
||
got := names(st.Search("完全一样的内容", 3))
|
||
for j := range first {
|
||
if got[j] != first[j] {
|
||
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 两路都空时不能 panic,且应返回空。
|
||
func TestSearchEmptyStore(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
if hits := st.Search("随便", 5); len(hits) != 0 {
|
||
t.Fatalf("空库应返回空,实际 %v", names(hits))
|
||
}
|
||
}
|