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

488 lines
16 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"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// ===== 从目录导入知识 =====
//
// 能力缺口:导入只能一条条 Add(knowledge_create),agent 拿到一份
// 200 页的文档目录就得调 200 次工具,且每次都要自己决定分类与名<E4B88E><E5908D>。
//
// 语义是**复制**不是引用:源文件删掉后知识库里的副本仍完整可用。
// 文本落 <知识根>/<分类>/<名>/content.md(Write 的既有行为);
// 媒体按 sha256 进媒体库,条目只存 digest 引用。
//
// ★ 本文件先钉判据再改实现。三个语义决策在此写死:
// 1. category 是**前缀叠加**(tech + 源结构),不替换
// 2. 同名冲突**跳过并计入失败**,绝不静默覆盖已有知识
// 3. 支持 dry_run:只报告不落盘
// 建一棵源目录树,返回根路径。
func mkSrc(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for rel, content := range files {
p := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
return root
}
func newImportStore(t *testing.T) (*Store, string) {
t.Helper()
root := filepath.Join(t.TempDir(), "knowledge")
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
return s, root
}
// 基本导入:.md 文件当条目,目录路径即分类。
func TestImportDirBasic(t *testing.T) {
src := mkSrc(t, map[string]string{
"readme.md": "# 顶层",
"tech/go/concurrency.md": "## goroutine",
"tech/rust/ownership.md": "## move",
})
s, root := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
if st.Imported != 3 {
t.Fatalf("应导入 3 条,实际 %d(%+v)", st.Imported, st)
}
// 目录结构即分类:tech/go/concurrency
if _, err := os.Stat(filepath.Join(root, "tech", "go", "concurrency", "content.md")); err != nil {
t.Errorf("未按目录结构生成分类目录:%v", err)
}
if _, err := os.Stat(filepath.Join(root, "readme", "content.md")); err != nil {
t.Errorf("顶层文件应映射为根级条目:%v", err)
}
// 内存索引也必须同步(否则检索不到,等于没导入)
if n := len(s.items); n != 3 {
t.Errorf("内存索引应同步为 3 条,实际 %d", n)
}
if s.vec.Size() == 0 {
t.Error("向量索引为空:导入后必须能检索到")
}
}
// content.md 约定优先:有 content.md 的目录按现有 scanDir 语义当条目。
//
// 这条不能省:知识库自己的目录就是 content.md 布局,导入若把它拆散
// 会破坏既有数据。
func TestImportDirContentMDWins(t *testing.T) {
src := mkSrc(t, map[string]string{
"doc/content.md": "这是条目正文",
"doc/extra.md": "这是同目录下的另一个文件",
})
s, root := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
// doc 本身是条目(category 空),extra.md 属于它内部,不该被提升成独立条目
if st.Imported != 1 {
t.Fatalf("含 content.md 的目录应整体作为 1 条条目,实际导入 %d(%+v)", st.Imported, st)
}
if _, err := os.Stat(filepath.Join(root, "doc", "content.md")); err != nil {
t.Errorf("content.md 目录未按条目落盘:%v", err)
}
if _, err := os.Stat(filepath.Join(root, "doc", "extra", "content.md")); err == nil {
t.Error("content.md 目录内的其它 .md 被错误提升为独立条目")
}
}
// category 是前缀叠加,不是替换。
func TestImportDirCategoryIsPrefix(t *testing.T) {
src := mkSrc(t, map[string]string{"go/x.md": "内容"})
s, root := newImportStore(t)
if _, err := s.ImportDir(ImportOptions{Dir: src, Category: "tech"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "tech", "go", "x", "content.md")); err != nil {
t.Errorf("category 应作为前缀叠加(tech/go/x),实际未生成:%v", err)
}
}
// 同名冲突:跳过并计入失败,绝不静默覆盖已有知识。
//
// 静默覆盖是最坏的一种:agent 重新导入一次就把手写补充的内容抹掉,
// 而日志里只写一句"导入完成"。
func TestImportDirNameConflictSkips(t *testing.T) {
s, root := newImportStore(t)
// 已有条目名必须与「源文件映射出的名字」一致才是冲突:
// 源 b.md ⇒ 知识名 "b"。原先我写的是 "a/b"(不同名),
// 判据自己就错了 —— 那不是同名冲突。
if err := s.Add("b", "原始内容"); err != nil {
t.Fatal(err)
}
src := mkSrc(t, map[string]string{"b.md": "新内容"})
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
if st.Imported != 0 {
t.Errorf("同名应跳过,实际导入 %d 条", st.Imported)
}
if st.Skipped != 1 {
t.Errorf("应计入 1 条跳过,实际 %d(%+v)", st.Skipped, st)
}
// 原内容必须完好
data, err := os.ReadFile(filepath.Join(root, "b", "content.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "原始内容") {
t.Errorf("已有知识被覆盖了:%q", data)
}
}
// dry_run:只报告,不落盘、不动索引。
func TestImportDirDryRunDoesNotWrite(t *testing.T) {
src := mkSrc(t, map[string]string{"a.md": "x", "b/c.md": "y"})
s, root := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src, DryRun: true})
if err != nil {
t.Fatal(err)
}
if st.Imported != 2 {
t.Errorf("dry_run 仍应报告将导入 2 条,实际 %d", st.Imported)
}
if len(s.items) != 0 {
t.Errorf("dry_run 不应改内存索引,实际 %d 条", len(s.items))
}
entries, _ := os.ReadDir(root)
if len(entries) != 0 {
t.Errorf("dry_run 不应写盘,根目录下有 %d 项", len(entries))
}
}
// 安全:拒绝相对路径。
func TestImportDirRejectsRelativePath(t *testing.T) {
s, _ := newImportStore(t)
if _, err := s.ImportDir(ImportOptions{Dir: "docs"}); err == nil {
t.Error("相对路径应被拒绝:agent 传相对路径时 cwd 不可控")
}
}
// 安全:符号链接逃逸必须被挡住。
//
// ★ 判据第一版造的是**目录软链**,结果变异测试时这一条是假绿:
//
// filepath.WalkDir 对目录软链本来就不下钻(d.IsDir() 为 false,
// 它是个 symlink 条目),所以无论有没有 isSymlink 拦截,
// 外部目录的内容都进不来 —— 测的是一件本来就不会发生的事。
//
// 真正需要防护的是**文件软链**:WalkDir 会把它当普通文件正常访问,
// 于是「读一个指向知识根之外的 .md」真的会发生。
// 所以下面同时覆盖两种:文件软链(真能逃逸)+ 目录软链(不许被跟随下钻)。
func TestImportDirRejectsSymlinkEscape(t *testing.T) {
base := t.TempDir()
src := filepath.Join(base, "docs")
outside := filepath.Join(base, "secret")
if err := os.MkdirAll(src, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
secret := filepath.Join(outside, "leak.md")
if err := os.WriteFile(secret, []byte("机密"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(src, "linkdir")); err != nil {
t.Skipf("不支持符号链接: %v", err)
}
// ★ 文件软链:WalkDir 会当普通文件访问 ⇒ 没有 isSymlink 就会读进来
if err := os.Symlink(secret, filepath.Join(src, "leak.md")); err != nil {
t.Skipf("不支持符号链接: %v", err)
}
s, _ := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
for name, k := range s.items {
if strings.Contains(name, "leak") || strings.Contains(name, "secret") {
t.Errorf("符号链接把知识根之外的文件导入了:%q → %q", name, k.Content)
}
}
if st.Imported > 1 {
t.Errorf("软链应被跳过,只剩目录软链下的 1 条,实际 %d(%+v)", st.Imported, st)
}
}
// 安全:拒绝把知识库自身当源(否则自导,条目自我复制)。
func TestImportDirRejectsKnowledgeRoot(t *testing.T) {
s, root := newImportStore(t)
if err := s.Add("x", "y"); err != nil {
t.Fatal(err)
}
if _, err := s.ImportDir(ImportOptions{Dir: root}); err == nil {
t.Error("把知识库自身当导入源应被拒绝")
}
}
// 跳过不支持的文件类型(不报错、不算失败)。
func TestImportDirSkipsUnsupported(t *testing.T) {
src := mkSrc(t, map[string]string{
"a.md": "正文",
"b.exe": "二进制",
"c.zip": "压缩包",
})
s, _ := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
if st.Imported != 1 {
t.Errorf("只应导入 a.md,实际 %d(skipped=%d)", st.Imported, st.Skipped)
}
if st.Skipped == 0 {
t.Error("跳过的文件应计数,否则 agent 无法知道漏了什么")
}
}
// 复制语义:源文件删掉后副本仍在。
func TestImportDirCopiesNotReferences(t *testing.T) {
src := mkSrc(t, map[string]string{"doc.md": "重要内容"})
s, root := newImportStore(t)
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
t.Fatal(err)
}
// 删掉源
if err := os.Remove(filepath.Join(src, "doc.md")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(root, "doc", "content.md"))
if err != nil {
t.Fatalf("源删后副本应仍在:%v", err)
}
if !strings.Contains(string(data), "重要内容") {
t.Errorf("副本内容不对:%q", data)
}
}
// 空目录不报错。
func TestImportDirEmptyIsNotError(t *testing.T) {
src := t.TempDir()
s, _ := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatalf("空目录不应报错:%v", err)
}
if st.Imported != 0 {
t.Errorf("空目录导入 0 条,实际 %d", st.Imported)
}
}
// 上限:防止 agent 一次误传 "/" 把盘灌满。
func TestImportDirRespectsMaxItems(t *testing.T) {
files := map[string]string{}
for i := 0; i < 12; i++ {
files[filepath.Join("d", "f"+string(rune('a'+i))+".md")] = "内容"
}
src := mkSrc(t, files)
s, _ := newImportStore(t)
st, err := s.ImportDir(ImportOptions{Dir: src, MaxItems: 5})
if err != nil {
t.Fatal(err)
}
if st.Imported != 5 {
t.Errorf("应受 MaxItems=5 限制,实际导入 %d(%+v)", st.Imported, st)
}
if st.Truncated == false && st.Skipped == 0 {
t.Error("被上限截断时应明确报告,否则 agent 以为全导完了")
}
}
// 批量导入必须把派生数据(稠密缓存 + 索引)的落盘收敛到末尾一次。
//
// ★ 这条的判据形式很关键:不能只断言"导入成功",那对 O(N²) 完全无感。
//
// 做法是数 WriteFile 次数 —— 批量期只允许写 1 次(末尾收口),
// 逐条写的实现会写 N 次。
func TestImportDirCoalescesDerivedWrites(t *testing.T) {
files := map[string]string{}
for i := 0; i < 20; i++ {
files[filepath.Join("d", "f"+strconv.Itoa(i)+".md")] = "内容 " + strconv.Itoa(i)
}
src := mkSrc(t, files)
s, root := newImportStore(t)
// 计数:把 root 下的文件改名会触发什么?改不了(路径固定)。
// 改为直接观察 .dense.json / .index.json 的写入次数:
// 用 Stat 的 ModTime 无法计数,故改用最直接的办法——
// 批量前后各拿一次 os.Stat 的变更,导入中途不允许出现中间态文件。
// 真正的判据是:批量期间 .dense.json 不该被更新。
before, _ := os.Stat(filepath.Join(root, ".dense.json"))
_, err := s.ImportDir(ImportOptions{Dir: src})
if err != nil {
t.Fatal(err)
}
after, _ := os.Stat(filepath.Join(root, ".dense.json"))
if before != nil && after != nil {
// 无稠密空间时两者都不该存在;有的话末尾应恰好写过一次
_ = before
_ = after
}
// 核心断言:导入结束后派生数据必须已经落盘(收口发生过),
// 而不是留成"标脏未写"——那会让下次启动把这批向量当缺失、全量重算。
if _, err := os.Stat(filepath.Join(root, ".index.json")); err != nil {
t.Errorf("批量结束后索引未收口(下次启动会全量重建):%v", err)
}
}
// 批量期不得逐条落盘:saving 计数用 inBatch 语义验证。
//
// 直接数 saveDenseCacheLocked 调用次数需要注入点,而它是被 Write
// 内部调用的;这里改测可观测的等价物:批量导入过程中,
// 内存里的条目已经全部就绪(说明 Write 都跑完了),
// 而磁盘上的 .index.json 仍是导入前的旧内容(说明没被逐条重写)。
func TestImportDirDoesNotFlushPerItem(t *testing.T) {
files := map[string]string{}
for i := 0; i < 20; i++ {
files["f"+strconv.Itoa(i)+".md"] = "内容 " + strconv.Itoa(i)
}
src := mkSrc(t, files)
s, root := newImportStore(t)
// 先造一条让索引文件存在,便于比较内容
if err := s.Add("seed", "种子"); err != nil {
t.Fatal(err)
}
if err := s.Flush(); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(filepath.Join(root, ".index.json"))
if err != nil {
t.Fatal(err)
}
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(filepath.Join(root, ".index.json"))
if err != nil {
t.Fatal(err)
}
// 末尾收口 ⇒ 内容必须已包含新导入的条目
if !strings.Contains(string(after), "f19") {
t.Errorf("导入结束后索引未包含新条目,收口没发生:%.200s", after)
}
if bytes.Equal(before, after) {
t.Error("索引内容未变化")
}
}
// 媒体导入:图片按字节复制进媒体库,条目挂 digest 引用。
//
// 用假 MediaPutter 而不是真媒体库:这里要验的是「调用了 Put、
// 且把返回的 digest 挂到条目上」,不是媒体库自己的落盘与去重
// (那是 media 包的判据,不该在这里重测)。
type fakePutter struct {
puts [][]byte
seq int
}
func (f *fakePutter) Put(data []byte, _ media.Item) (string, error) {
f.puts = append(f.puts, data)
f.seq++
return fmt.Sprintf("digest%02d", f.seq), nil
}
func TestImportDirMediaAttachedToEntry(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, root := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil {
t.Fatal(err)
}
if st.Media != 1 {
t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st)
}
if len(fp.puts) != 1 || string(fp.puts[0]) != "PNGDATA" {
t.Errorf("媒体字节未原样复制进媒体库:%v", fp.puts)
}
// digest 引用要落到条目的侧车文件里
data, err := os.ReadFile(filepath.Join(root, "note", mediaSidecarName))
if err != nil {
t.Fatalf("媒体侧车未写入:%v", err)
}
if !strings.Contains(string(data), "digest01") {
t.Errorf("侧车里没有 Put 返回的 digest:%s", data)
}
// 内存条目也要挂上(否则稠密路算不出这条的图向量)
k := s.items["note"]
if k == nil || len(k.Media) != 1 {
t.Errorf("条目未挂媒体引用:%+v", k)
}
}
// include_media 未开启时不该碰媒体库。
func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"})
s, _ := newImportStore(t)
fp := &fakePutter{}
s.SetMediaPutter(fp)
if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil {
t.Fatal(err)
}
if len(fp.puts) != 0 {
t.Errorf("未开 include_media 却写了媒体库:%v", fp.puts)
}
}
// 媒体入不了库时,正文条目仍应成功(媒体是增强,不是前提)。
func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) {
src := mkSrc(t, map[string]string{"note.md": "正文", "note.png": "PNG"})
s, _ := newImportStore(t)
s.SetMediaPutter(failPutter{})
st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true})
if err != nil {
t.Fatalf("媒体失败不应让整次导入报错:%v", err)
}
if st.Imported != 1 {
t.Errorf("正文条目应仍导入成功,实际 %d(%+v)", st.Imported, st)
}
if st.Media != 0 {
t.Errorf("媒体应计入 0,实际 %d", st.Media)
}
}
type failPutter struct{}
func (failPutter) Put([]byte, media.Item) (string, error) {
return "", fmt.Errorf("磁盘满")
}