mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
staticcheck 门接入时把存量设为基线 24 条,本次全部清掉,基线改为 0。
## 生产代码里的真实缺陷(4 个)
### ① memory/fusion.go —— BFS 的 depth 参数形同虚设
for d := 1; d <= depth; d++ {
neighbors, _ := g.NeighbourBlocks(seed.BlockID) // ← 永远是 seed
...
break // ← 只跑一轮
}
三处叠加:循环体末尾无条件 `break`;每层传的还是 `seed.BlockID`;
于是 depth=2 与 depth=3 召回结果**完全一样**。
staticcheck SA4008("变量在循环条件里从不改变")正是从这里报出来的 ——
它看出来 `d` 除了打分没有参与寻址。
改成真正的逐层 BFS(frontier 推进到上一层新发现的节点)。实测:
depth=1 → 2 个 [节点0 节点1]
depth=2 → 3 个 [节点0 节点1 节点2] ← 修复前停在这里
depth=3 → 4 个 [节点0 节点1 节点2 节点3]
补判据 `TestBFS召回_层深真实生效`:必须用**链**(n0-n1-n2-n3),
星型在 depth=1 就能召回全部叶子、断不出有没有走深。
变异(改回单层)⇒ 实测红:`★ depth=2 召回 2 个,期望 3`。
### ② nlp/extractor.go —— subjRels/objRels 只写不读
两个切片被 append 了 4 处,但**从未被读取**。三元组的关系类型来自
`relLabel := result.Tokens[vi]`,置信度来自 `result.DepRels[si]`,
与这两个切片无关。删除。
### ③ plugins/cmd/plugin.go —— 配置解析失败会把上限写成 0
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
} // ← 空分支
直接 Sscanf 到 &p.maxOutput:解析失败时它可能已被写成 0 或部分值,
而空分支无法回滚 ⇒ 配置写错一个字符就把输出上限变成 0
(下游 `maxLen := p.maxOutput` 直接拿它算截断长度)。
改为解析到临时变量、失败时保持默认并记日志。
### ④ cmd/homed/bootstrap.go —— 空的 else 分支
`if err != nil { log } else { }` —— 删掉 else。
## 测试判据里的真实缺陷(3 个)
### ⑤ stop_semantics_test —— 短路求值让第二次调用从不执行
if !a.sched.consumeCancel() || !a.sched.consumeCancel()
第一次返回 true 时第二次**根本不会执行**,于是"两条配额都可消费"
这条断言实际只测了一次。拆成两次独立调用(两处同型)。
### ⑥ seq/store_test —— 永假断言
if maxCallDepth != maxCallDepth { t.Fatal("maxCallDepth 不稳定") }
自己跟自己比,从诞生起就不可能失败。改成显式读两次比较。
### ⑦ pluginhost/catalog_test —— typed-nil 守卫永假
var nb *kstate.SyncBus
var iface kstate.SyncState = nb
if iface == nil { t.Skip(...) } // ← 恒 false
把 typed-nil 装进接口后,接口的 (type, value) 二元组都不为 nil。
这道前置守卫**从未生效** —— 而这正是该判据要演示的陷阱本身。
改用 `reflect.ValueOf(iface).IsNil()`。
## 其余(写法/冗余,无行为变化)
agent.go / context.go / toolresult_store_test / apply_regression_test
空 if 块(注释移到真实分支上方)
codec_chunkfast_golden_test 未使用的三个返回值 + 空 if
scheduler_critical/stack_test `if _, _ = f(); true { }` → 直接调用
overflow_test / upgrade_test 变量被整体覆盖,首次赋值未使用
dense_test m[string(x)] 替代临时变量
lex_rules_test range over string 而非 []rune(string)
## 验证
staticcheck -checks 'SA4*,SA5*,SA6*,SA9*' 0 条(基线 24 → 0)
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
干净检出(worktree) build + memory/nlp/core 全绿
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||
)
|
||
|
||
// fakeMM 是可控的多模态嵌入器:文本与图片各自查表,缺省给同一个兜底向量。
|
||
// Dim/Fingerprint 可变,用来验证维度与指纹守卫。
|
||
type fakeMM struct {
|
||
dim int
|
||
fp string
|
||
text map[string][]float64
|
||
img map[string][]float64
|
||
// imgErr 按 digest 注入错误(验证 ErrModalityUnsupported 的静默跳过)
|
||
imgErr map[string]error
|
||
loaded bool
|
||
}
|
||
|
||
func (f *fakeMM) VectorizeDense(t string) ([]float64, error) {
|
||
if v, ok := f.text[t]; ok {
|
||
return v, nil
|
||
}
|
||
return f.text["__default"], nil
|
||
}
|
||
|
||
func (f *fakeMM) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||
if err, ok := f.imgErr[string(img)]; ok {
|
||
return nil, err
|
||
}
|
||
if v, ok := f.img[string(img)]; ok {
|
||
return v, nil
|
||
}
|
||
return f.img["__default"], nil
|
||
}
|
||
|
||
func (f *fakeMM) Fingerprint() string { return f.fp }
|
||
func (f *fakeMM) Dim() int { return f.dim }
|
||
func (f *fakeMM) Loaded() bool { return f.loaded }
|
||
func (f *fakeMM) Close() {}
|
||
|
||
// fakeMedia 是仅按 digest 查表的 MediaGetter。
|
||
type fakeMedia struct{ byDigest map[string][]byte }
|
||
|
||
func (m fakeMedia) Get(digest string) ([]byte, error) {
|
||
if b, ok := m.byDigest[digest]; ok {
|
||
return b, nil
|
||
}
|
||
return nil, errors.New("media: not found")
|
||
}
|
||
|
||
// 跨模态召回:一条知识的“图”与查询向量相近,就该被召回——即便它的正文
|
||
// 与查询毫无词面重叠。这是稠密路存在的全部理由。
|
||
func TestDenseCrossModalRecall(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
// 两个正交方向:eax 表示“猫的图”,eby 表示“别的”。
|
||
//
|
||
// ★ 桩的 key 必须与 denseFor 实际传入的文本**逐字一致**:
|
||
// denseFor 算的是 `k.Name + " " + k.Content`,不是纯 Content。
|
||
// 写成纯 Content 会静默回落到 __default,两条条目就拿到同一向量。
|
||
//
|
||
// ★ ecy(第三个正交方向)给「别的条目」的正文:若 other 与 cat-photo
|
||
// 的正文同向量,纯稠密路下“猫”查询会让 other(余弦 1.0)排在
|
||
// cat-photo(文本⊕图融合后 0.707)之前——那不是产品缺陷,是
|
||
// **桩把所有未知文本映射到同一向量**造成的假象。
|
||
eax := []float64{1, 0, 0, 0}
|
||
eby := []float64{0, 1, 0, 0}
|
||
ecy := []float64{0, 0, 1, 0}
|
||
mm := &fakeMM{
|
||
dim: 4, fp: "fake-v1", loaded: true,
|
||
text: map[string][]float64{
|
||
"__default": eby,
|
||
"cat-photo 猫 图片 说明": eby,
|
||
"other 完全无关的正文": ecy,
|
||
},
|
||
img: map[string][]float64{"__default": eby, "d-cat": eax},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
|
||
if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{
|
||
// ★ 带 Data:Write 路径会 writeMediaFiles 落盘到条目目录,随后 denseFor
|
||
// 读回它算图向量。字节 "d-cat" 对应 fakeMM.img 里的 eax 方向。
|
||
{File: "cat.png", MIME: "image/png", Kind: "image", Data: []byte("d-cat")},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("other", "完全无关的正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 查询向量直接命中“猫图”方向(模拟以图搜知识)
|
||
qv := eax
|
||
hits := s.denseHits(qv)
|
||
if len(hits) == 0 {
|
||
t.Fatal("稠密路无命中")
|
||
}
|
||
if hits[0].id != "cat-photo" {
|
||
t.Fatalf("稠密路首位应为 cat-photo,实为 %v", hits)
|
||
}
|
||
// 分数应显著高于正交基线(0.707 是文本⊕猫图两个正交方向融合的必然值,
|
||
// 不是噪声——单模态文档在同查询下只会有 ~0)
|
||
if !(hits[0].score > 0.5) {
|
||
t.Errorf("cat-photo 与查询向量应显著同向,实为 %f", hits[0].score)
|
||
}
|
||
// 正文里的“猫”字查询也应召回它。
|
||
// ★ 纯稠密路下这里的分数是 1.0(查询向量与 cat-photo 正文向量同向),
|
||
// 而 other 是正交的 0 —— 前提是桩给 other 一个**真正无关**的方向
|
||
// (见上面 ecy 的说明)。
|
||
if got := s.Search("猫", 3); len(got) == 0 || got[0].Name != "cat-photo" {
|
||
t.Errorf("按正文查询应召回 cat-photo,实为 %v", namesOf(got))
|
||
}
|
||
}
|
||
|
||
// 维度守卫:同指纹但维度不同的向量必须被跳过,否则会算出错维度余弦。
|
||
func TestDenseRejectsWrongDim(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
mm := &fakeMM{dim: 4, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": {1, 0, 0, 0}},
|
||
img: map[string][]float64{"__default": {1, 0, 0, 0}},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
if err := s.Add("k", "正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 手动塞一个维度不符的向量(模拟换模型后的残留)
|
||
s.mu.Lock()
|
||
s.items["k"].Dense = []float64{1, 0, 0, 0, 0, 0, 0, 0}
|
||
s.mu.Unlock()
|
||
|
||
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 0 {
|
||
t.Errorf("维度不符的条目必须被跳过,实为 %v", hits)
|
||
}
|
||
}
|
||
|
||
// 指纹守卫:模型换过后,旧向量在重算前不得参与召回。
|
||
func TestDenseRejectsStaleFingerprint(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
mm := &fakeMM{dim: 4, fp: "fp-new", loaded: true,
|
||
text: map[string][]float64{"__default": {1, 0, 0, 0}},
|
||
img: map[string][]float64{"__default": {1, 0, 0, 0}},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
if err := s.Add("k", "正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.mu.Lock()
|
||
s.items["k"].Dense = []float64{1, 0, 0, 0}
|
||
s.items["k"].DenseFP = "fp-old"
|
||
s.mu.Unlock()
|
||
|
||
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 0 {
|
||
t.Errorf("指纹过期的条目在重算前不得参与召回,实为 %v", hits)
|
||
}
|
||
// ReindexDense 应把它修好
|
||
if built, _ := s.ReindexDense(); built != 1 {
|
||
t.Errorf("ReindexDense 应重算 1 条,实为 %d", built)
|
||
}
|
||
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 1 {
|
||
t.Errorf("重算后应可召回,实为 %v", hits)
|
||
}
|
||
}
|
||
|
||
// ReindexDense 幂等:第二次不该再做任何事。
|
||
func TestReindexDenseIdempotent(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
mm := &fakeMM{dim: 3, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": {1, 1, 0}},
|
||
img: map[string][]float64{"__default": {0, 1, 1}},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
for _, n := range []string{"a", "b", "c"} {
|
||
if err := s.Add(n, "正文"+n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// Add 已当场算过,故首次 Reindex 应为 0 新建
|
||
if built, _ := s.ReindexDense(); built != 0 {
|
||
t.Errorf("Add 已算过稠密向量,Reindex 应为 0,实为 %d", built)
|
||
}
|
||
if built, _ := s.ReindexDense(); built != 0 {
|
||
t.Errorf("重复 Reindex 应幂等,实为 %d", built)
|
||
}
|
||
}
|
||
|
||
// 媒体模态不受支持时必须**静默跳过该媒体**,但整条知识的文本向量仍要成立。
|
||
func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
etext := []float64{1, 0}
|
||
mm := &fakeMM{
|
||
dim: 2, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": etext},
|
||
img: map[string][]float64{"__default": {0, 1}},
|
||
imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
|
||
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{File: "a.mp3", MIME: "audio/mpeg"}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
k := s.items["song"]
|
||
if k == nil {
|
||
t.Fatal("条目未写入")
|
||
}
|
||
if len(k.Dense) != 2 {
|
||
t.Fatalf("音频不支持时应退化为纯文本向量,实为 %v", k.Dense)
|
||
}
|
||
// 纯文本向量应与文本方向同向(没被音频的错向量污染)
|
||
if k.Dense[0] <= 0 {
|
||
t.Errorf("文本向量方向被污染:%v", k.Dense)
|
||
}
|
||
}
|
||
|
||
// 未注入稠密空间时,行为必须与加入稠密路之前逐字一致。
|
||
func TestNoDenseSpaceKeepsLegacyBehavior(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
if s.denseEnabled() {
|
||
t.Fatal("未注入时稠密路应为禁用")
|
||
}
|
||
for _, n := range []string{"coffee", "sleep", "arch"} {
|
||
if err := s.Add(n, "正文 "+n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
// 稀疏两路仍要工作
|
||
if got := s.Search("coffee", 3); len(got) == 0 || got[0].Name != "coffee" {
|
||
t.Errorf("无稠密路时稀疏两路应正常召回,实为 %v", namesOf(got))
|
||
}
|
||
// Add 不应试图算稠密向量
|
||
for _, k := range s.items {
|
||
if len(k.Dense) != 0 {
|
||
t.Errorf("无稠密路时不应产生稠密向量:%s", k.Name)
|
||
}
|
||
}
|
||
if st := s.DenseStats(); st["enabled"] != false {
|
||
t.Errorf("DenseStats 应报告未启用,实为 %v", st)
|
||
}
|
||
}
|
||
|
||
// AttachMedia 挂上媒体后必须当场重算稠密向量(否则要等下次 Reindex)。
|
||
func TestAttachMediaRecomputesDense(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
eax := []float64{1, 0}
|
||
mm := &fakeMM{dim: 2, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": {0, 1}},
|
||
img: map[string][]float64{"__default": {0, 1}, "d-x": eax},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
|
||
if err := s.Add("k", "正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// AttachMedia 是「挂已在盘上的媒体」:字节必须先落到条目目录,
|
||
// 否则 denseFor 读不到。"d-x" 对应 fakeMM.img 里的 eax 方向。
|
||
if err := os.WriteFile(filepath.Join(dir, "k", "x.png"), []byte("d-x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
before := append([]float64{}, s.items["k"].Dense...)
|
||
if err := s.AttachMedia("k", KnowledgeMediaRef{File: "x.png", MIME: "image/png"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after := s.items["k"].Dense
|
||
if len(after) != 2 {
|
||
t.Fatalf("AttachMedia 后应有 2 维稠密向量,实为 %v", after)
|
||
}
|
||
// 融合了 eax 后方向应偏向第一维,与 before 不同
|
||
if before[0] == after[0] && before[1] == after[1] {
|
||
t.Errorf("AttachMedia 未重算稠密向量:%v", after)
|
||
}
|
||
}
|
||
|
||
// DenseStats 的计数应与实际状态一致。
|
||
func TestDenseStatsCounts(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
mm := &fakeMM{dim: 2, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": {1, 0}},
|
||
img: map[string][]float64{"__default": {1, 0}},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
if err := s.Add("a", "A"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("b", "B"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.mu.Lock()
|
||
s.items["b"].DenseFP = "stale"
|
||
s.mu.Unlock()
|
||
|
||
st := s.DenseStats()
|
||
if st["ready"] != 1 || st["stale"] != 1 {
|
||
t.Errorf("DenseStats 应为 ready=1 stale=1,实为 %v", st)
|
||
}
|
||
if st["dim"] != 2 || st["fingerprint"] != "fp" {
|
||
t.Errorf("DenseStats 维度/指纹不符:%v", st)
|
||
}
|
||
}
|
||
|
||
func namesOf(ks []*Knowledge) []string {
|
||
out := make([]string, len(ks))
|
||
for i, k := range ks {
|
||
out[i] = k.Name
|
||
}
|
||
return out
|
||
}
|