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

253 lines
7.7 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"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 运行时新增的知识必须**当场**可检索,不依赖重启。
//
// 这是一次真实功能缺陷:TFIDFVectorizer.Vectorize 会跳过 df<=0 的特征,
// 而 Add 此前只把文本追进一个 summaries 切片、不更新 DF。于是
// 「重启后(已 Train 过 ≥3 篇)→ 新增一条含全新词的知识 → 查它」
// 返回空结果,重启一次才恢复。实测曾得到 Search("量子纠缠") == []。
//
// 之所以容易漏:全新 Store 语料不足 3 篇时 Vectorize 走
// 「totalDocs < 3 不乘 IDF」的退化分支,新词照样能搜到 —— 缺陷只在
// 「库已满、且用的是全新词」时显形。
func TestNewTermSearchableImmediatelyAfterAdd(t *testing.T) {
dir := t.TempDir()
// 先用 5 篇把语料喂到 totalDocs >= 3(走真实 IDF 分支)
{
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
if err := s.Add(fmt.Sprintf("旧知识%d", i), "咖啡 睡眠 架构 记忆 插件 内核 事件 总线 索引"); err != nil {
t.Fatal(err)
}
}
s.Stop()
}
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 运行时新增一条含**全新词**的知识
if err := s.Add("新知识", "量子纠缠 拓扑绝缘体 简并态 莫尔条纹"); err != nil {
t.Fatal(err)
}
q := "量子纠缠"
var names []string
for _, k := range s.Search(q, 5) {
names = append(names, k.Name)
}
if !contains(names, "新知识") {
t.Fatalf("Add 后新知识应立刻可检索(query=%q),实得 %v —— IDF 未随写入更新", q, names)
}
// 新词向量不应为空(空 = 被 df<=0 过滤掉)
v := s.veczer.Vectorize(q)
if len(v) == 0 {
t.Errorf("新词向量为空:df=0 被过滤,query=%q", q)
}
// 另一路(稀疏语义路)也应能命中
if got := s.vec.SearchScored(s.vectorize(q), s.vec.Size()); len(got) == 0 {
t.Logf("注:稀疏语义路对 %q 无命中(取决于是否注入了词向量),不影响本用例结论", q)
}
}
// 覆盖写同名条目时,IDF 不能把同一篇重复计入,否则 df 虚高、IDF 虚低。
func TestOverwriteDoesNotDoubleCountDF(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
for i := 0; i < 5; i++ {
if err := s.Add(fmt.Sprintf("k%d", i), "共同词"+strings.Repeat("x", i)); err != nil {
t.Fatal(err)
}
}
// 记录覆盖前 totalDocs 语料状态
before := len(s.List())
if err := s.Add("k0", "改写后的内容 独特词9z"); err != nil {
t.Fatal(err)
}
if after := len(s.List()); after != before {
t.Fatalf("覆盖写不应改变条目数:%d → %d", before, after)
}
// 旧内容里的 "共同词" 不应因为被覆盖而消失(旧版正文里也有它?不,
// 这里断言的是:新版内容里的词能被搜到,即 DF 已切到新版)
if !s.hasLexHit("k0", "独特词9z") {
t.Error("覆盖写后新版内容的词应可检索")
}
}
// 删除条目后,IDF 语料必须同步收缩(否则 DF 表与真实条目脱钩,越用越偏)。
func TestRemoveShrinksIDFCorpus(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 造 3 篇共享词,删掉其中唯一含某词的篇
for i := 0; i < 3; i++ {
body := "共享词"
if i == 0 {
body += " 独占词zzz"
}
if err := s.Add(fmt.Sprintf("k%d", i), body); err != nil {
t.Fatal(err)
}
}
// 未删前,k0 在词法路里
if !s.hasLexHit("k0", "独占词zzz") {
t.Fatal("前置条件不成立:k0 应命中独占词")
}
if err := s.Remove("k0"); err != nil {
t.Fatal(err)
}
// 删除后,k0 不得再出现在任何一路
if s.hasLexHit("k0", "共享词") {
t.Error("已删除条目仍在词法路索引里")
}
// 剩余条目的检索必须仍工作(IDF 没被清成空)
if got := s.Search("共享词", 5); len(got) != 2 {
t.Errorf("删除后其余条目应仍可检索 2 条,实为 %d", len(got))
}
}
// 词法路与 IDF 必须始终以 items 的**规范名**为准,与重启后的重建一致。
// 否则运行时增量维护的 DF 与重启后 Train 的 DF 不同,IDF 悄悄漂移。
func TestLexTextUsesCanonicalName(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
// 用带空格/大写的名字,看 DF 记账用的是不是规范名
if err := s.Add("Tech/Upper", "共享语料词"); err != nil {
t.Fatal(err)
}
if err := s.Add("b", "共享语料词"); err != nil {
t.Fatal(err)
}
if err := s.Add("c", "共享语料词"); err != nil {
t.Fatal(err)
}
// 覆盖写:规范名一致才能正确 RemoveDoc 旧文本
if err := s.Add("Tech/Upper", "改写 共享语料词"); err != nil {
t.Fatal(err)
}
s.Stop()
// 重启后应与运行时增量维护的 DF 数值一致(若不一致,说明 lexText 口径漂了)
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
defer s2.Stop()
// 三篇都含"共享语料词" ⇒ df=3 ⇒ IDF = log((3+1)/(3+1)) = 0 ⇒ 被丢弃
// (与删改无关,是 IDF 本身的定义)。改为查一个只在一篇里出现的词。
if err := s2.Add("d", "绝无仅有词qqq"); err != nil {
t.Fatal(err)
}
if !s2.hasLexHit("d", "绝无仅有词qqq") {
t.Error("重启后新词仍不可检索,IDF 记账有问题")
}
}
// 大量增删后,词法路索引与 items 数量必须始终一致(不漏删、不留孤儿)。
func TestLexIndexStaysInSyncWithItems(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
for i := 0; i < 30; i++ {
if err := s.Add(fmt.Sprintf("k%02d", i), fmt.Sprintf("内容 %d", i)); err != nil {
t.Fatal(err)
}
}
// 删一半
for i := 0; i < 30; i += 2 {
if err := s.Remove(fmt.Sprintf("k%02d", i)); err != nil {
t.Fatal(err)
}
}
if lexN, itemN := s.lex.Size(), len(s.items); lexN != itemN {
t.Errorf("词法路索引与条目数不一致:lex=%d items=%d", lexN, itemN)
}
}
// hasLexHit 报某条目在词法路里是否含有给定词的向量。
func (s *Store) hasLexHit(id, probe string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, h := range s.lex.SearchScored(s.veczer.Vectorize(probe), s.lex.Size()) {
if h.Doc.ID == id && h.Score > 0 {
return true
}
}
return false
}
func contains(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// TFIDFVectorizer 的增量接口本身也要守规矩:AddDoc/RemoveDoc 必须与
// Train 给出**相同**的 DF(文档级去重),否则两条路径会漂移。
func TestTFIDFIncrementalMatchesTrain(t *testing.T) {
tok := func(s string) []string { return strings.Fields(s) }
full := vector.NewTFIDFVectorizer(tok)
full.Train([]string{"a b c", "b c d", "c d e"})
inc := vector.NewTFIDFVectorizer(tok)
inc.AddDoc("a b c")
inc.AddDoc("b c d")
inc.AddDoc("c d e")
for _, probe := range []string{"a", "b", "c", "d", "e"} {
fv, iv := full.Vectorize(probe), inc.Vectorize(probe)
if len(fv) != len(iv) {
t.Errorf("探针 %q:Train 得 %d 维,AddDoc 得 %d 维(DF 不一致)", probe, len(fv), len(iv))
continue
}
for f, w := range fv {
if diff := w - iv[f]; diff > 1e-9 || diff < -1e-9 {
t.Errorf("探针 %q 特征 %q 权重不一致:Train=%g AddDoc=%g", probe, f, w, iv[f])
}
}
}
// RemoveDoc 后 totalDocs 不得为负
inc.RemoveDoc("a b c")
inc.RemoveDoc("a b c")
inc.RemoveDoc("a b c")
inc.AddDoc("x y")
if got := inc.Vectorize("x"); len(got) == 0 {
t.Error("totalDocs 变负后 Vectorize 行为异常")
}
}