mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
refactor(webui): 图谱判据迁到 test/ —— 导出 GraphDataForVisual 一层转换
graph_source_test.go → test/
handler_graph_pulse_test.go → test/ (7 条判据)
webui/ 174 → 167
webui/test/ 28 → 35
合计 202 不变
## 这一条必须动生产代码,理由要写清
判据想测的是「GraphData 的 raw map 被裁成可视化视图时,稠密向量必须被剥掉」。
两条路都不通:
走 HTTP 端点 /api/v1/memory/graph
→ 端点输入来自 h.memory.GraphData(),h.memory 是私有字段,
包外无法注入合成数据。测不了。
包内测试直接调 graphDataForVisual
→ 这正是要摆脱的白盒写法。
所以导出这一层转换 `graphDataForVisual → GraphDataForVisual`。它不是
为测试造出来的形状:函数的输入输出本来就是本包唯一的「raw map →
visual map」语义,端点在 handler_memory.go:199 调它。导出的是真实边界,
不是内部细节。(判据 §5.3「不要为满足测试导出内部」指的是后者。)
顺带把 `graphBlockView`(私有 JSON 结构)在测试侧改成匿名结构体 ——
它只用于反序列化断言,没有必要引用包内类型。
## 改走真实路由暴露的两个事实
### ① `newGraphHandler` 直接设 `&Handler{memory: m}`
改成 `NewHandler(sdk.New("webui", sdk.SDKConfig{Memory: m, ...}))` ——
`Memory` 确实能经 SDKConfig 注入(plugin.go:457 `base.SetMemoryAPI(cfg.Memory)`)。
### ② 503 暴露了鉴权要求
改走路由后立刻:
status = 503, want 200
`requireAPI` 需要 api_key,而最小装配没配。补 `seedWebUIConfig` 后 200。
之前直接调 handler 的写法**从来不知道有这个要求** —— 路由层挡掉的东西,
绕过路由就看不见。
## 判据有效性(变异验证)
在 `GraphDataForVisual` 里把裁剪短路掉(`if blocks != nil { return out }`),
实测红,且输出直接暴露泄漏内容:
--- FAIL: TestHandleMemoryGraph_StripsVector
response still contains vector field:
{"data":{"memory_blocks":[{"id":"b_old",...,"vector":[0.1,0.2,0.3],...
"success":true}
稠密向量确实会被下发到前端。恢复后全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 67 包全过,0 FAIL
This commit is contained in:
54
internal/plugins/webui/test/graph_source_test.go
Normal file
54
internal/plugins/webui/test/graph_source_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
|
||||
)
|
||||
|
||||
// 星图数据源必须来自块体系(memory_blocks),而不是停写的旧表 entities。
|
||||
//
|
||||
// 判据:真实图谱里 memory_blocks 远多于 entities,且块侧仍在增长。
|
||||
// 这里用合成数据钉住「有块时节点数 == 块数」,防未来又切回 nodes。
|
||||
func TestGraphDataExposesBlocksForStarmap(t *testing.T) {
|
||||
// 模拟后端 GraphData 的返回:旧表 1 个节点,块表 3 个块 + 2 条边
|
||||
data := map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{{"id": int64(1), "name": "旧表实体", "type": "Concept"}},
|
||||
"edges": []map[string]interface{}{},
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "blk_src_a", Modality: memory.BlockText, Text: "张三喜欢咖啡", Source: "sentence"},
|
||||
{ID: "blk_ent_b", Modality: memory.BlockText, Text: "张三", Source: "triple", SemanticType: "Person"},
|
||||
{ID: "blk_ent_c", Modality: memory.BlockText, Text: "咖啡", Source: "triple"},
|
||||
},
|
||||
"memory_block_edges": []memory.MemoryBlockEdge{
|
||||
{ID: 1, SourceKind: "block", SourceID: "blk_src_a", TargetKind: "block", TargetID: "blk_ent_b", Type: "contains"},
|
||||
{ID: 2, SourceKind: "block", SourceID: "blk_src_a", TargetKind: "block", TargetID: "blk_ent_c", Type: "contains"},
|
||||
},
|
||||
}
|
||||
out := webui.GraphDataForVisual(data)
|
||||
// 前端读的是 memory_blocks 字段
|
||||
blocks, ok := out["memory_blocks"]
|
||||
if !ok {
|
||||
t.Fatal("GraphData 必须下发 memory_blocks(前端星图的真实数据源)")
|
||||
}
|
||||
raw, _ := json.Marshal(blocks)
|
||||
// 只用 JSON 结构断言下发内容,不引用包内私有类型。
|
||||
var got []struct {
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text"`
|
||||
HasData bool `json:"has_data"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("memory_blocks 应下发 3 个,实际 %d", len(got))
|
||||
}
|
||||
if _, hasNodes := out["nodes"]; !hasNodes {
|
||||
t.Fatal("旧 nodes 字段应保留(向后兼容),只是前端不再依赖它")
|
||||
}
|
||||
}
|
||||
289
internal/plugins/webui/test/handler_graph_pulse_test.go
Normal file
289
internal/plugins/webui/test/handler_graph_pulse_test.go
Normal file
@ -0,0 +1,289 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// fakeGraphMemory 是一个最小 MemoryAPI 桩,只为把 GraphData 喂给 handler。
|
||||
type fakeGraphMemory struct {
|
||||
data map[string]interface{}
|
||||
}
|
||||
|
||||
func (f *fakeGraphMemory) Recall([]string, int) ([]pubsdk.Entity, []pubsdk.Relation, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) Commit([]pubsdk.Triple) error { return nil }
|
||||
func (f *fakeGraphMemory) Introspect() (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) MergeEntities(string, string) (int, error) { return 0, nil }
|
||||
func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil }
|
||||
|
||||
// MediaBytes 是 CAS 淘汰后插件取媒体的入口。本桩只服务星图端点,
|
||||
// 不做媒体断言,返回“无此块”即可(与真实实现在无引用时的行为一致)。
|
||||
func (f *fakeGraphMemory) MediaBytes(string) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
// BlocksChangedSince 是 pulse 端点现在的数据源(不再走 GraphData 全量 + 过滤)。
|
||||
//
|
||||
// 桩要**忠实反映过滤语义**:只回窗口内的块。若这里偷懒返回全部,
|
||||
// 就把「窗口过滤」这条判据架空了——测的是桩,不是产品。
|
||||
func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk.MemoryBlockView, error) {
|
||||
blocks, _ := f.data["memory_blocks"].([]memory.MemoryBlock)
|
||||
var out []sdk.MemoryBlockView
|
||||
for _, b := range blocks {
|
||||
if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
out = append(out, sdk.MemoryBlockView{
|
||||
ID: b.ID, Modality: string(b.Modality), Text: b.Text,
|
||||
HasData: len(b.Data) > 0, MIME: b.MIME,
|
||||
Source: b.Source, Tool: b.Tool, Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// graphFixture 造一份与生产实例同构的图谱快照:
|
||||
// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。
|
||||
func graphFixture() map[string]interface{} {
|
||||
now := time.Now()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
return map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{"id": 1, "name": "小宅", "type": "Concept", "mention_count": 228,
|
||||
"created_at": old, "updated_at": now.Add(-8 * time.Hour)},
|
||||
{"id": 2, "name": "刚刚学到的东西", "type": "Concept", "mention_count": 1,
|
||||
"created_at": now, "updated_at": now},
|
||||
},
|
||||
"edges": []map[string]interface{}{},
|
||||
// ★ memory_blocks 才是 pulse 的过滤源(2026-10-05 修)。
|
||||
// 刻意放两个:一个 8h 前(默认 900s 窗口外)、一个刚更新(窗口内)。
|
||||
// 旧节点(nodes/小宅)的 updated_at 也刻意设成 8h 前 ——
|
||||
// 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块",
|
||||
Vector: []float64{0.1, 0.2, 0.3},
|
||||
CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)},
|
||||
{ID: "b_new", Modality: memory.BlockModality("image"), Text: "刚学到的东西",
|
||||
Data: []byte("png-bytes"), MIME: "image/png",
|
||||
Vector: []float64{0.1, 0.2, 0.3},
|
||||
CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newGraphHandler 通过**导出入口**装配 Handler:Memory 经 SDKConfig 注入,
|
||||
// 不再直接设 h.memory 私有字段。
|
||||
func newGraphHandler(t *testing.T, m *fakeGraphMemory) *webui.Handler {
|
||||
t.Helper()
|
||||
reg := internalConfig.NewConfigRegistry("")
|
||||
seedWebUIConfig(reg)
|
||||
return webui.NewHandler(sdk.New("webui", sdk.SDKConfig{
|
||||
Memory: m,
|
||||
Settings: sdk.NewSettings("webui", reg),
|
||||
EventBus: events.NewBus(),
|
||||
}))
|
||||
}
|
||||
|
||||
// serveGraph 经导出的 RegisterRoutes 分发请求。
|
||||
func serveGraph(t *testing.T, h *webui.Handler, w http.ResponseWriter, r *http.Request) {
|
||||
t.Helper()
|
||||
r.Header.Set("X-API-Key", testAPIKey)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。
|
||||
//
|
||||
// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图
|
||||
// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。
|
||||
func TestHandleMemoryGraph_StripsVector(t *testing.T) {
|
||||
h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
serveGraph(t, h, rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
|
||||
t.Fatalf("response still contains vector field:\n%s", rr.Body.String())
|
||||
}
|
||||
// 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。
|
||||
for _, want := range []string{`"has_data":true`, `"id":"b_new"`, `"nodes"`, `"edges"`} {
|
||||
if !bytes.Contains(rr.Body.Bytes(), []byte(want)) {
|
||||
t.Fatalf("response missing %s:\n%s", want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_OnlyRecent 钉住 pulse 端点只回窗口内变动的节点。
|
||||
//
|
||||
// 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。
|
||||
func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) {
|
||||
h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)
|
||||
serveGraph(t, h, rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false")
|
||||
}
|
||||
// 默认窗口 900s:8h 前的「小宅」不该在里面,刚更新的应该在。
|
||||
if len(resp.Data.Nodes) != 1 {
|
||||
t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes)
|
||||
}
|
||||
if resp.Data.Nodes[0].ID != "b_new" {
|
||||
t.Fatalf("expected block id=b_new, got %q", resp.Data.Nodes[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。
|
||||
func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) {
|
||||
h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil)
|
||||
serveGraph(t, h, rr, req)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Nodes) != 2 {
|
||||
t.Fatalf("since=86400 should include both nodes, got %d", len(resp.Data.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。
|
||||
func TestHandleMemoryGraphPulse_NoVector(t *testing.T) {
|
||||
h := newGraphHandler(t, &fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
serveGraph(t, h, rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
|
||||
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
|
||||
t.Fatalf("pulse response contains vector:\n%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphDataForVisual_PassthroughWhenTypeMismatch 钉住断言失败时不吞数据。
|
||||
//
|
||||
// graphDataForVisual 用类型断言识别 memory_blocks。若 GraphData 换了
|
||||
// 返回类型(比如改成 []*MemoryBlock),断言不中时必须原样透传而不是
|
||||
// 把字段弄丢 —— 宁可多发 vector,也不能让整个图谱接口变空。
|
||||
func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{{"id": 1}},
|
||||
"memory_blocks": []*memory.MemoryBlock{{ID: "x"}},
|
||||
}
|
||||
out := webui.GraphDataForVisual(in)
|
||||
if out["memory_blocks"] == nil {
|
||||
t.Fatalf("memory_blocks dropped on type mismatch")
|
||||
}
|
||||
if _, ok := out["nodes"]; !ok {
|
||||
t.Fatalf("nodes dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphPulse_IgnoresFrozenLegacyNodes 钉住 pulse 的**过滤源**是块表而非旧表。
|
||||
//
|
||||
// ★ 这条是 2026-10-05 清理时补的,针对一个真实的生产静默失效:
|
||||
//
|
||||
// /memory/graph/pulse 恒返回 {"nodes":[]},
|
||||
// 而 /memory/graph 同一时刻有 1294 个节点。
|
||||
//
|
||||
// 根因:pulse 原实现按 `out["nodes"]` 的 updated_at 过滤,而 nodes 来自
|
||||
// **旧表 entities**(graph.go:1445)——旧表停双写后不再增长,
|
||||
// 生产最后更新停在 2026-10-03,而 pulse 默认窗口只有 900s
|
||||
// ⇒ 1294 个节点**永远**落在窗口外 ⇒ 恒空。
|
||||
// 症状形态:HTTP 200 + success:true,无任何报错。
|
||||
//
|
||||
// ★ 为什么上面两条判据抓不住:它们的 fixture 里 nodes 与 memory_blocks
|
||||
//
|
||||
// 「恰好都有一条落在窗口内」,所以读哪个源都能过。
|
||||
// 这条刻意让**旧表节点全部落在窗口外、块有一个在窗口内**,
|
||||
// 于是「读错源」与「读对源」的结果**必然不同**。
|
||||
func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) {
|
||||
now := time.Now()
|
||||
frozen := now.Add(-72 * time.Hour) // 旧表:三天前,之后再没更新过
|
||||
|
||||
fixture := map[string]interface{}{
|
||||
// 旧表节点:全部在窗口外(模拟停双写后的冻结状态)
|
||||
"nodes": []map[string]interface{}{
|
||||
{"id": 1, "name": "旧实体", "type": "Concept", "mention_count": 228,
|
||||
"created_at": frozen, "updated_at": frozen},
|
||||
},
|
||||
"edges": []map[string]interface{}{},
|
||||
// 块表:有一条刚更新(活图谱的真实形态)
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块",
|
||||
CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
}
|
||||
|
||||
h := newGraphHandler(t, &fakeGraphMemory{data: fixture})
|
||||
rr := httptest.NewRecorder()
|
||||
serveGraph(t, h, rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, rr.Body.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false")
|
||||
}
|
||||
if len(resp.Data.Nodes) != 1 {
|
||||
t.Fatalf("旧表节点已冻结三天、块表有一条刚更新 ⇒ 应回 1 个(来自块表),"+
|
||||
"实际 %d 个:%+v\n★ 若为 0,说明 pulse 又退回读旧表了 —— "+
|
||||
"生产上这表现为「星图不跟随 agent 动」且无任何报错",
|
||||
len(resp.Data.Nodes), resp.Data.Nodes)
|
||||
}
|
||||
if resp.Data.Nodes[0].ID != "b_live" {
|
||||
t.Fatalf("应回块表的 b_live,实际 %q", resp.Data.Nodes[0].ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user