Files
HomeAgent/internal/plugins/healthcheck/plugin_test.go
JianFeeeee 5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00

563 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 healthcheck
import (
"encoding/json"
"os"
"strings"
"testing"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type toolCapture struct {
handlers map[string]sdk.ToolHandler
defs map[string]sdk.ToolDef
}
func newToolCapture() *toolCapture {
return &toolCapture{
handlers: make(map[string]sdk.ToolHandler),
defs: make(map[string]sdk.ToolDef),
}
}
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
tc.handlers[name] = handler
tc.defs[name] = def
return nil
}
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
func newTestSDK(cfg sdk.SDKConfig) *sdk.PluginSDK {
if cfg.Settings == nil {
cfg.Settings = sdk.NewSettings("healthcheck", nil)
}
if cfg.Tool == nil {
cfg.Tool = sdk.NewTool(agentCore.NewStageHost(), agentIO.NewIOManager())
}
return sdk.New("healthcheck", cfg)
}
func setupPlugin() (*Plugin, *toolCapture, error) {
return setupPluginWith(sdk.SDKConfig{})
}
func setupPluginWith(cfg sdk.SDKConfig) (*Plugin, *toolCapture, error) {
tc := newToolCapture()
cfg.RegTool = tc.RegisterTool
cfg.RegStage = tc.RegisterStage
cfg.RegAPI = tc.RegisterAPI
p := New("healthcheck")
s := newTestSDK(cfg)
if err := p.Start(s); err != nil {
return nil, nil, err
}
return p, tc, nil
}
func TestToolsRegistered(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
expected := []string{
"healthcheck",
"healthcheck_plugins",
"healthcheck_tools",
"healthcheck_memory",
"healthcheck_report",
"healthcheck_perf",
}
for _, name := range expected {
if _, ok := tc.handlers[name]; !ok {
t.Errorf("tool %q not registered", name)
}
}
}
func TestHealthcheckFull(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
if len(checks) == 0 {
t.Fatal("expected at least some checks")
}
for _, c := range checks {
cr := c.(map[string]interface{})
name := cr["name"].(string)
pass := cr["pass"].(bool)
if !pass && cr["status"] != "skip" {
t.Errorf("check %q failed: %v (detail: %v)", name, cr["status"], cr["detail"])
}
}
}
func TestHealthcheckPlugins(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_plugins"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
}
func TestHealthcheckToolsList(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_tools"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
}
func TestHealthcheckMemoryNotAvailable(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_memory"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
// Memory is nil in this setup, so it should skip gracefully
if _, ok := resp["pass"]; ok {
pass := resp["pass"].(bool)
if !pass {
t.Fatalf("expected pass=true when memory is nil, got false: %v", resp)
}
}
}
func TestHealthcheckWithMemory(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
memDB, err := memory.NewGraphDB(tmpDir + "/test.db")
if err != nil {
t.Fatal(err)
}
defer memDB.Close()
// 预置一条生产数据,验证 healthcheck 自检后不触碰它
_, _, err = memDB.Commit([]memory.Triple{{Subject: "用户", Relation: "喜欢", Object: "咖啡"}}, "test", 0)
if err != nil {
t.Fatal(err)
}
memSnapshot := func() (int, int) {
m, _ := memDB.Introspect()
ents, _ := m["entity_count"].(int)
rels, _ := m["relation_count"].(int)
return ents, rels
}
be, br := memSnapshot()
_, tc, err := setupPluginWith(sdk.SDKConfig{Memory: sdk.NewGraphMemory(memDB)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck_memory"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v (detail=%v)", resp["status"], resp["detail"])
}
if pass, ok := resp["pass"].(bool); !ok || !pass {
t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"])
}
// 关键:生产记忆内容必须保持不变(未被 healthcheck 污染)
ae, ar := memSnapshot()
if ae != be || ar != br {
t.Fatalf("production memory polluted by healthcheck self-test: before=(%d,%d) after=(%d,%d)", be, br, ae, ar)
}
// 再次确认:注入实例中不应出现 _hc_ 测试实体
relResult, _ := memDB.Recall([]string{"_hc_"}, nil, 1, "")
for _, e := range relResult.Entities {
if len(e.Name) >= 4 && e.Name[:4] == "_hc_" {
t.Fatalf("healthcheck left _hc_ entity in production memory: %q", e.Name)
}
}
}
func TestHealthcheckWithKnowledge(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_know_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ks := knowledge.NewStore(tmpDir)
if err := ks.Start(); err != nil {
t.Fatal(err)
}
defer ks.Stop()
// 预置一条真实知识,验证 healthcheck 自检后不触碰它
if err := ks.Add("生产知识点", "这是生产知识,不应被健康检查破坏"); err != nil {
t.Fatal(err)
}
before := len(ks.List())
_, tc, err := setupPluginWith(sdk.SDKConfig{Knowledge: sdk.NewKnowledge(ks)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
var knowledgeCheck map[string]interface{}
for _, c := range checks {
cr := c.(map[string]interface{})
if cr["name"] == "knowledge" {
knowledgeCheck = cr
break
}
}
if knowledgeCheck == nil {
t.Fatal("expected knowledge check in results")
}
if knowledgeCheck["status"] != "ok" {
t.Fatalf("expected knowledge check ok, got %v (detail=%v)", knowledgeCheck["status"], knowledgeCheck["detail"])
}
// 生产知识库内容必须保持不变(未被 healthcheck 污染)
after := len(ks.List())
if after != before {
t.Fatalf("production knowledge polluted: before=%d after=%d", before, after)
}
for _, name := range ks.List() {
if strings.HasPrefix(name, "_hc_knowledge_test_") {
t.Fatalf("healthcheck left test knowledge in production: %q", name)
}
}
}
func TestHealthcheckWithDocStore(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hc_doc_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ds := doc.NewStore(tmpDir, memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
// 预置一篇生产文档,验证 healthcheck 自检后不触碰它
if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil {
t.Fatal(err)
}
before := len(ds.Query("生产文档", 10))
_, tc, err := setupPluginWith(sdk.SDKConfig{DocMemory: sdk.NewDocMemory(ds)})
if err != nil {
t.Fatal(err)
}
handler := tc.handlers["healthcheck"]
result, err := handler(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
var resp map[string]interface{}
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
}
checks := resp["checks"].([]interface{})
var docCheck map[string]interface{}
for _, c := range checks {
cr := c.(map[string]interface{})
if cr["name"] == "documents" {
docCheck = cr
break
}
}
if docCheck == nil {
t.Fatal("expected documents check in results")
}
if docCheck["status"] != "ok" {
t.Fatalf("expected documents check ok, got %v (detail=%v)", docCheck["status"], docCheck["detail"])
}
// 生产文档必须保持不变(未被 healthcheck 污染)
after := len(ds.Query("生产文档", 10))
if after != before {
t.Fatalf("production doc store polluted: before=%d after=%d", before, after)
}
}
func TestLLMReportCollection(t *testing.T) {
p := &Plugin{name: "healthcheck"}
if len(p.reports) != 0 {
t.Fatal("expected empty reports")
}
p.mu.Lock()
p.reports = append(p.reports, llmReport{ToolName: "test_tool", Status: "ok", Detail: "test passed"})
count := len(p.reports)
p.mu.Unlock()
if count != 1 {
t.Fatalf("expected 1 report, got %d", count)
}
if p.reports[0].ToolName != "test_tool" {
t.Fatalf("expected tool_name=test_tool, got %s", p.reports[0].ToolName)
}
}
// TestSafeReadonlyTool 验证 LLM 自检工具过滤:写类/副作用工具被拒绝,只读工具被放行。
func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行
readonly := []string{
"memory_recall", "memory_introspect", "doc_query",
"knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list",
}
for _, name := range readonly {
if !isSafeReadonlyTool(name) {
t.Errorf("expected readonly tool %q to be safe, but rejected", name)
}
}
// 写/删/改/副作用:应被拒绝
mutating := []string{
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
"memory_merge", "memory_block_merge",
"knowledge_create", "knowledge_delete",
"doc_commit", "doc_delete",
"person_set_trait", "person_relate",
"output_send__qq", "output_send__cli",
"llm_set_source", "config_set",
"timer_set", "plgreload", "plugin_disable",
"cmd_run", "files_write", "files_delete", "terminal_create", "terminal_write",
"terminal_close", "spawn_child",
}
for _, name := range mutating {
if isSafeReadonlyTool(name) {
t.Errorf("expected mutating tool %q to be rejected, but allowed", name)
}
}
}
// TestCollectToolDefsForLLMNoMutating 验证 collectToolDefsForLLM 不会把写类工具交给 LLM 自检。
func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "knowledge_create", Plugin: "knowledge", Description: "create"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
{Name: "files_list", Plugin: "files", Description: "list"},
}
for _, d := range defs {
d := d
stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil })
}
tc := newToolCapture()
s := newTestSDK(sdk.SDKConfig{
RegTool: tc.RegisterTool,
RegStage: tc.RegisterStage,
RegAPI: tc.RegisterAPI,
Tool: sdk.NewTool(stage, agentIO.NewIOManager()),
})
got := p.collectToolDefsForLLM(s, "")
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
// 只读工具应被包含
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_list"} {
if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
}
}
// 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "doc_commit", "knowledge_create", "cmd_run"} {
if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
}
}
}
// TestCollectToolDefsForLLMPluginFilter 验证指定插件时只收集该插件的只读工具。
func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "files_list", Plugin: "files", Description: "list"},
{Name: "files_write", Plugin: "files", Description: "write"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
}
for _, d := range defs {
d := d
stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil })
}
tc := newToolCapture()
s := newTestSDK(sdk.SDKConfig{
RegTool: tc.RegisterTool,
RegStage: tc.RegisterStage,
RegAPI: tc.RegisterAPI,
Tool: sdk.NewTool(stage, agentIO.NewIOManager()),
})
got := p.collectToolDefsForLLM(s, "files")
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
if len(got) != 1 || !allowed["files_list"] {
t.Fatalf("expected only files_list for files plugin, got %v", allowed)
}
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_write"} {
if allowed[name] {
t.Errorf("tool %q must NOT be in files-filtered set", name)
}
}
gotMemory := p.collectToolDefsForLLM(s, "memory")
if len(gotMemory) != 1 || gotMemory[0].Name != "memory_recall" {
t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory)
}
// 全量时不丢任何只读工具
all := p.collectToolDefsForLLM(s, "")
if len(all) != 4 {
t.Fatalf("expected 4 readonly tools unfiltered, got %d", len(all))
}
}
// TestReportsToChecks 验证 LLM 上报明细转为细粒度检查项ok/skip 通过, fail 失败)。
func TestReportsToChecks(t *testing.T) {
p := &Plugin{}
p.reports = []llmReport{
{ToolName: "qq_get_message", Status: "ok", Detail: "正常"},
{ToolName: "doc_query", Status: "skip", Detail: "无可测数据"},
{ToolName: "knowledge_search", Status: "fail", Detail: "搜索超时"},
}
checks := p.reportsToChecks()
if len(checks) != 3 {
t.Fatalf("expected 3 checks, got %d", len(checks))
}
byName := map[string]checkResult{}
for _, c := range checks {
byName[c.Name] = c
}
if !byName["llm_tool/qq_get_message"].Pass {
t.Error("ok report must pass")
}
if !byName["llm_tool/doc_query"].Pass {
t.Error("skip report must pass")
}
if byName["llm_tool/knowledge_search"].Pass {
t.Error("fail report must NOT pass")
}
}