Files
HomeAgent/internal/memory/scene_migrate_test.go
JianFeeeee 9036db9321 feat(memory): 图数据库自报真实读写事件,取代「按工具名猜节点」
## 问题(生产实证)

星图的「跟随 agent 活动」此前靠**工具名词元猜节点**:把 `memory_recall`
拆成 [memory, recall] 去匹配实体名。而图谱里的实体名是**中文概念**
(小宅/对话/待命),工具名是英文 —— 两套命名空间永不交集。

实测(生产库)每个工具名命中 **0** 个节点:

    hint=memory_recall  → memory/recall  → 0
    hint=files_read     → files/read     → 0
    hint=knowledge_search → knowledge/search → 0

匹配失败后走「补齐」分支,按 mention_count 取前 8 个 ⇒ **无论 agent 在干什么,
点亮的都是同一批无关节点**(小宅/对话/待命/投递/图片/文档/插件/老大)。
这就是「星图没有读写反馈」的真身。

## 改法:让图数据库在真正读写处上报

新增 `events.EventMemoryAccess` + `GraphDB.AccessHook`,在读写入口埋点上报
**真实块 ID**(不再猜):

| 操作 | 埋点 | 上报 |
|---|---|---|
| 读 | RecallSorted | blocks |
| 写 | commit | blocks + **created**(真正新建的) |
| 删 | DeleteEntity | removed |
| 合 | MergeBlocks | merged(from→to) + removed |

三处关键设计:

1. **钩子用独立锁** `hookMu`。用 `g.mu` 会**自死锁** —— 埋点方法已持该锁,
   而 Go 的 RWMutex 不可重入。
2. **上报在锁外**。拆 `commit`→`commitLocked` 两层解决 `defer Unlock` 的时序:
   钩子内部可能要回调内存系统(如 SSE 订阅者补数据),持锁调用必死锁。
3. **区分「新建」与「更新」**(`putBlockTx` 返回 isNew)。用 SELECT 判存在性
   而非 `RETURNING (xmax = 0)` —— 后者在**事务 + ON CONFLICT** 下报
   `no such column: xmax`(实测:autocommit 可用、包进 tx 就不可用),
   而本函数只在事务里被调用。重复提交同一事实不会重复播「生长」动画。

接线:`bootstrap.newMainAgent` 里 `memDB.SetAccessHook(...)` → `events.Bus`。
只在 `evBus != nil` 时接,把「内核进程」与「独立 CLI 工具」
(homed-graph-migrate / memgc 也用 NewGraphDB)区分开 —— 后者不该为一次
批量迁移向不存在的订阅者发几万条事件。

## 验证

- `access_hook_test.go`:钉住 commit/recall/delete 三类埋点真的触发、
  且重复提交不误报新建
- `access_hook_race_test.go`:4 写 + 4 读 + 钩子热替换并发,`-race` 下无竞态、
  无死锁;**断言事件数 > 0**(否则「钩子从未触发」也会假通过)
- 该测试起初是 flaky 的(读者可能在写者落库前查询 ⇒ 空结果不报事件 ⇒
  断言随机失败)。修法是先播种确定数据再并发读,已连跑 3 次稳定
- 生产实例端到端:CLI 注入一句话 → SSE 收到 6 条 memory_access,
  带真实块 ID 与 op/created
2026-10-08 10:41:07 +08:00

233 lines
7.2 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 memory
import (
"fmt"
"testing"
"time"
)
// ★★★ scene_refs 完整迁移到块体系(kind='edge'/'block')
//
// 现状(生产库实测):
//
// scene_refs kind='entity' 450 条 → ref_id = entities.id(自增行号)
// kind='relation' 268 条 → ref_id = relations.id
// kind='block' 90 条 → **ref_text = 块 ID**(ref_id=0)
// kind='document' 2 条
//
// ⇒ 块引用**已经用 ref_text 存块 ID** 了。这给了迁移的现成契约:
//
// kind='entity' → 'block' ref_id 清空,ref_text = blk_ent_<entityID>_<hash>
// kind='relation' → 'edge' ref_id 换成 memory_block_edges.id,ref_text 置空
//
// 映射可行性(生产快照实测,两个方向都是 100%):
//
// 450 条 entity 引用 → 迁移块 450/450
// 268 条 relation 引用 → 边 268/268
// 无法反解的引用 0 条
func TestSceneRef_迁移映射完整(t *testing.T) {
g := newTestGraph(t)
defer func() { _ = g.Close() }()
// 造旧形态数据
seedSceneRefs(t, g)
before := sceneRefCounts(t, g)
fmt.Printf(" 迁移前: %v\n", before)
n, err := g.MigrateSceneRefsToBlocks()
if err != nil {
t.Fatal(err)
}
fmt.Printf(" 迁移 %d 条引用\n", n)
after := sceneRefCounts(t, g)
fmt.Printf(" 迁移后: %v\n", after)
// ★ 旧 kind 必须归零(完整迁移,不是「大部分」)
if after["entity"] != 0 {
t.Errorf("★ kind='entity' 应全部迁走,剩 %d 条", after["entity"])
}
if after["relation"] != 0 {
t.Errorf("★ kind='relation' 应全部迁走,剩 %d 条", after["relation"])
}
// ★ 计数要考虑去重:两条 entity 引用可能映射到同一块
// (scene_refs 有 UNIQUE(scene_id,kind,ref_id,ref_text)),
// 迁移会删除重复那条。判据数据刻意造了这种情况。
//
// 契约是「旧 kind 全清零 + 新 kind 都有 + 无悬空」,
// 而非「条数必须逐条对应」—— 因为去重是**正确**行为。
//
// 本例:entity 2 条 + 原 block 1 条 = 3 条引用,
// 但两条 entity 指向同一块 ⇒ 实际 2 条(去重 1 条)。
if after["block"] < before["block"] {
t.Errorf("★ kind='block' 不该少于原有的 %d 条,实际 %d",
before["block"], after["block"])
}
// 逐项核对(本例:entity 2 条 → 去重后 1 条 + 原 block 1 条 = block 2;
// relation 1 条 → edge 1 条)
if after["block"] != 2 || after["edge"] != 1 {
t.Errorf("★ 期望 block=2(去重后 1 + 原有 1)、edge=1,实际 block=%d edge=%d",
after["block"], after["edge"])
}
fmt.Printf(" 去重:entity 2 条 → 1 条(两条指向同一块)\n")
if after["edge"] != before["relation"] {
t.Errorf("★ kind='edge' 应为 %d 条(relation 全迁),实际 %d",
before["relation"], after["edge"])
}
// ★ 每条迁走的引用都要能定位到真实对象(不能指向虚无)
if n := countDanglingBlockRefs(t, g); n != 0 {
t.Errorf("★ 迁移后有 %d 条 block 引用指向不存在的块", n)
}
if n := countDanglingEdgeRefs(t, g); n != 0 {
t.Errorf("★ 迁移后有 %d 条 edge 引用指向不存在的边", n)
}
// ★ 幂等:跑第二遍不应改变任何东西
if _, err := g.MigrateSceneRefsToBlocks(); err != nil {
t.Fatal(err)
}
third := sceneRefCounts(t, g)
if third["edge"] != after["edge"] || third["block"] != after["block"] {
t.Errorf("★ 幂等被破坏: %v → %v", after, third)
}
}
// seedSceneRefs 造旧形态的 scene_refs(entity/relation/block/document 四种)。
func seedSceneRefs(t *testing.T, g *GraphDB) {
t.Helper()
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
// 两个实体 + 一条关系 + 各自的块与边(块化后的形态,作为迁移来源)
ents := []struct {
id int64
name string
}{}
for i, name := range []string{"值班室分机号", "4324"} {
if _, err := tx.Exec(
`INSERT INTO entities (id, name, mention_count, created_at)
VALUES (?, ?, 1, '2026-01-01 10:00:00')`, int64(i+1), name); err != nil {
t.Fatal(err)
}
ents = append(ents, struct {
id int64
name string
}{int64(i + 1), name})
}
if _, err := tx.Exec(
`INSERT INTO relations (id, source_id, target_id, relation_type, confidence, status)
VALUES (1, 1, 2, '是', 0.9, 'active')`); err != nil {
t.Fatal(err)
}
// 迁移块(ID 含 entityID,可反解)
blkA := legacyEntityBlockID(ents[0].id, ents[0].name)
blkB := legacyEntityBlockID(ents[1].id, ents[1].name)
for _, b := range []MemoryBlock{
{ID: blkA, Modality: BlockText, Text: ents[0].name, Source: "legacy-entity",
CreatedAt: mustT("2026-01-01 10:00:00")},
{ID: blkB, Modality: BlockText, Text: ents[1].name, Source: "legacy-entity",
CreatedAt: mustT("2026-01-01 10:00:00")},
{ID: "blk_src_manual", Modality: BlockText, Text: "手工建的原句块",
Source: SentenceBlockSource},
} {
if _, err := putBlockTx(tx, b); err != nil {
t.Fatal(err)
}
}
// 场景与四种引用
if _, err := tx.Exec(
`INSERT INTO scenes (key, strength) VALUES ('值班场景', 1)`); err != nil {
t.Fatal(err)
}
refs := []struct {
kind, refID, refText string
}{
{"entity", "1", ""},
{"entity", "2", ""},
{"relation", "1", ""},
{"block", "0", blkA},
{"document", "0", "doc-1"},
}
for _, r := range refs {
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
VALUES (1, ?, ?, ?, 1.0)`, r.kind, r.refID, r.refText); err != nil {
t.Fatal(err)
}
}
// ★ 关系边与上面所有数据在**同一事务**内写入。
// 不能调 AddRelationBlockEdge:它内部 g.mu.Lock(),
// 而本函数开头已 g.mu.Lock() 且 defer 解锁 ⇒ 自死锁。
// 首次实现踩了这个:测试直接挂住不动,栈指向 seedSceneRefs。
if _, err := tx.Exec(`INSERT INTO memory_block_edges
(source_kind, source_id, target_kind, target_id, edge_type,
confidence, session_id, status)
VALUES ('block', ?, 'block', ?, '是', 0.9, 's1', ?)`,
blkA, blkB, EdgeActive); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func sceneRefCounts(t *testing.T, g *GraphDB) map[string]int {
t.Helper()
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT kind, COUNT(*) FROM scene_refs GROUP BY kind`)
if err != nil {
t.Fatal(err)
}
defer func() { _ = rows.Close() }()
out := map[string]int{}
for rows.Next() {
var k string
var n int
if err := rows.Scan(&k, &n); err != nil {
t.Fatal(err)
}
out[k] = n
}
return out
}
func countDanglingBlockRefs(t *testing.T, g *GraphDB) int {
t.Helper()
g.mu.RLock()
defer g.mu.RUnlock()
var n int
_ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
WHERE sr.kind='block' AND NOT EXISTS
(SELECT 1 FROM memory_blocks b WHERE b.id = sr.ref_text)`).Scan(&n)
return n
}
func countDanglingEdgeRefs(t *testing.T, g *GraphDB) int {
t.Helper()
g.mu.RLock()
defer g.mu.RUnlock()
var n int
_ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
WHERE sr.kind='edge' AND NOT EXISTS
(SELECT 1 FROM memory_block_edges e WHERE CAST(e.id AS TEXT) = sr.ref_id)`).Scan(&n)
return n
}
func mustT(s string) time.Time {
t, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
panic(err)
}
return t
}