mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
一次知识库子系统的集中加固,四类缺陷各有实测复现:
1. 名称与路径(数据安全,最严重)
- sanitize 不过滤 .. ⇒ Remove("..") 直接 RemoveAll 掉整个数据目录
(实测把 <data> 整棵删掉,含 memory/documents/media),且返回 nil,
工具层回报"已删除";Add("../../x") 写到知识根外,重启扫不回来
⇒ 幽灵条目。
- Add 整串 sanitize 而建目录逐段 sanitize,内存键与盘上目录从**第一次
落盘起**就不一致;重启后 name 漂移,knowledge_delete 静默删不掉
(RemoveAll 删空目录返 nil)。同一个根因。
- 修法:新增 normalizeName 作为唯一入口(逐段 + 拒绝空段/点段/隐藏段);
Remove 改为取条目自记的 Path(不再用名字重拼)+ 返回 ErrNotFound。
- resolve 三层退让(原样 → 规范名 → 叶名大小写不敏感,唯一命中才接受):
scanDir 按盘上目录原样建键,遗留大写目录若只查规范名会变成
"List 看得到、Remove 报不存在"。删的路径仍取自 Path,退让无风险。
2. IDF 与索引不同步(功能缺陷,非优化)
- TFIDF Vectorize 跳过 df<=0 的特征,而 Add 只往只增不减的 summaries
追文本、从不更新 DF ⇒ 库满(≥3篇) + 新词时,新知识**当场搜不到**,
重启才恢复(实测 Search("量子纠缠") == [])。
- 修法:vector.Store 新增 AddDoc/RemoveDoc(文档级去重口径与 Train 一致,
totalDocs 下界守卫,零频 DF 删除防表膨胀);knowledge 删掉 summaries,
改 index/unindex/retrain 三件套,覆盖写先 RemoveDoc 旧文本。
3. 多模态稠密路(此前知识库端到端纯文本)
- 新增 SetDenseSpace/SetMediaGetter/ReindexDense/DenseStats 与
AddWithMedia/AttachMedia,媒体成为一等节点参与跨模态召回。
- 维度与指纹双守卫:维度不符的向量会被 FuseVectors 按最大维度拼成错维度
结果且被当成"已对齐"永久错下去(docStore 踩过);模态不支持(音频)时
静默跳过该媒体、退化为纯文本向量,绝不拿别的模型的向量顶替。
- 未注入多模态空间时行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
4. 派生数据落盘 + 分层参与召回
- 媒体引用是**作者数据**(丢失即丢信息)→ 条目目录内 .media.json;
稠密向量是**派生数据**(可重算)→ 全局 .dense.json,tmp+rename 原子。
混存会让派生数据损坏连带作者数据一起丢。
- 新增 SearchIn(query, category, topK):分类前缀匹配子树,让分层真正
参与召回(此前检索全库平铺,分层只是存储布局)。归一化取作用域内
最大值,否则范围外的强命中会把域内分数压没。
- 删除 SearchTree/SearchCategories 死代码(零调用方,且停留在 Search
修复前的单路口径:无词法融合、0.05 阈值)。
顺带修掉 Add 的 O(N)/写:buildTreeLocked 逐条重算向量(s.vec 里已有)
改为一次建表复用;.index.json 改为标脏 + Flush/Stop 收口。实测单条 Add
2.1ms@50 → 13.7ms@400 压平到 ~400µs(34×)。
存量目录改名迁移落在 migrate_names.go,只报告不改名(os.Rename 不可逆),
冲突整批拒绝以免半迁移。
194 lines
5.2 KiB
Go
194 lines
5.2 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// makeLegacy 造出"修复前 Add 留下的脏目录布局"。
|
||
func makeLegacy(t *testing.T, dirs ...string) string {
|
||
t.Helper()
|
||
root := t.TempDir()
|
||
for _, d := range dirs {
|
||
p := filepath.Join(root, filepath.FromSlash(d), "content.md")
|
||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(p, []byte("遗留正文 "+d), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return root
|
||
}
|
||
|
||
// PlanMigration 只读,不得改动任何文件。
|
||
func TestPlanMigrationIsReadOnly(t *testing.T) {
|
||
root := makeLegacy(t, "Tech/Upper", "a/b with space", "good", "tech/_go_/note")
|
||
before := snapshotTree(t, root)
|
||
|
||
items, err := PlanMigration(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(items) != 4 {
|
||
t.Fatalf("应发现 4 条,实为 %+v", items)
|
||
}
|
||
if after := snapshotTree(t, root); after != before {
|
||
t.Errorf("PlanMigration 改动了盘上状态:\n前 %s\n后 %s", before, after)
|
||
}
|
||
// 清单内容正确
|
||
byOld := map[string]MigrationItem{}
|
||
for _, it := range items {
|
||
byOld[it.OldName] = it
|
||
}
|
||
for old, want := range map[string]string{
|
||
"Tech/Upper": "tech/upper",
|
||
"a/b with space": "a/b_with_space",
|
||
// 段内无空格/大写 ⇒ 已是规范名,NewName 为空(无需改动)
|
||
"tech/_go_/note": "",
|
||
"good": "",
|
||
} {
|
||
it, ok := byOld[old]
|
||
if !ok {
|
||
t.Fatalf("清单缺 %q", old)
|
||
}
|
||
if it.NewName != want {
|
||
t.Errorf("%q 的 NewName 应为 %q,实为 %q", old, want, it.NewName)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 迁移后:Store 能载入全部条目,且能按规范名删除。
|
||
func TestApplyMigrationThenUsable(t *testing.T) {
|
||
root := makeLegacy(t, "Tech/Upper", "a/b with space", "good")
|
||
items, err := PlanMigration(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
applied, failed := ApplyMigration(root, items, 0)
|
||
if applied != 2 || failed != 0 {
|
||
t.Fatalf("应成功 2 失败 0,实为 %d/%d", applied, failed)
|
||
}
|
||
|
||
s := NewStore(root)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
if got := len(s.List()); got != 3 {
|
||
t.Fatalf("迁移后应载入 3 条,实为 %d 条:%v", got, s.List())
|
||
}
|
||
for _, id := range s.List() {
|
||
if err := s.Remove(id); err != nil {
|
||
t.Errorf("迁移后条目 %q 删不掉: %v", id, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 冲突必须整批拒绝,且盘上零改动(半迁移比不迁移更难收拾)。
|
||
func TestApplyMigrationRefusesOnConflict(t *testing.T) {
|
||
root := makeLegacy(t, "A/b", "a/B", "keep/me")
|
||
before := snapshotTree(t, root)
|
||
|
||
items, err := PlanMigration(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
applied, failed := ApplyMigration(root, items, 0)
|
||
if applied != 0 {
|
||
t.Errorf("有冲突时不应改名任何一条,实为成功 %d", applied)
|
||
}
|
||
if failed == 0 {
|
||
t.Error("冲突应被报告为失败")
|
||
}
|
||
if after := snapshotTree(t, root); after != before {
|
||
t.Errorf("冲突路径下盘上被改动:\n前 %s\n后 %s", before, after)
|
||
}
|
||
}
|
||
|
||
// 目标名已存在于盘上(与既有条目撞名)也算冲突。
|
||
func TestApplyMigrationRefusesWhenTargetExists(t *testing.T) {
|
||
root := makeLegacy(t, "Tech/Upper", "tech/upper")
|
||
before := snapshotTree(t, root)
|
||
|
||
items, err := PlanMigration(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
applied, _ := ApplyMigration(root, items, 0)
|
||
if applied != 0 {
|
||
t.Errorf("目标已存在时应拒绝,实为成功 %d", applied)
|
||
}
|
||
if after := snapshotTree(t, root); after != before {
|
||
t.Error("盘上被改动")
|
||
}
|
||
}
|
||
|
||
// limit 必须真的限住条数。
|
||
func TestApplyMigrationRespectsLimit(t *testing.T) {
|
||
root := makeLegacy(t, "A/one", "B/two", "C/three", "D/four")
|
||
items, err := PlanMigration(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
applied, _ := ApplyMigration(root, items, 2)
|
||
if applied != 2 {
|
||
t.Errorf("limit=2 应只改 2 条,实为 %d", applied)
|
||
}
|
||
// 剩下两条仍可被再次迁移(幂等续跑)
|
||
items2, _ := PlanMigration(root)
|
||
rest := 0
|
||
for _, it := range items2 {
|
||
if it.NewName != "" {
|
||
rest++
|
||
}
|
||
}
|
||
if rest != 2 {
|
||
t.Errorf("剩余待迁移应为 2 条,实为 %d", rest)
|
||
}
|
||
applied2, _ := ApplyMigration(root, items2, 0)
|
||
if applied2 != 2 {
|
||
t.Errorf("续跑应再改 2 条,实为 %d", applied2)
|
||
}
|
||
}
|
||
|
||
// 迁移不得越出知识根(回归:sanitize 不过滤 .. 时的老问题)。
|
||
func TestApplyMigrationStaysInRoot(t *testing.T) {
|
||
base := t.TempDir()
|
||
root := filepath.Join(base, "data", "knowledge")
|
||
if err := os.MkdirAll(root, 0755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 人为构造一条越界计划
|
||
items := []MigrationItem{{OldName: "x", NewName: "../escaped"}}
|
||
applied, failed := ApplyMigration(root, items, 0)
|
||
if applied != 0 || failed != 1 {
|
||
t.Errorf("越界目标必须被拒(applied=%d failed=%d)", applied, failed)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(base, "data", "escaped")); err == nil {
|
||
t.Error("发生了根外写入")
|
||
}
|
||
}
|
||
|
||
func snapshotTree(t *testing.T, root string) string {
|
||
t.Helper()
|
||
var sb []byte
|
||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
rel, _ := filepath.Rel(root, p)
|
||
sb = append(sb, rel...)
|
||
if !info.IsDir() {
|
||
sb = append(sb, ' ')
|
||
}
|
||
sb = append(sb, '\n')
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return string(sb)
|
||
}
|