mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 问题(生产实证)
星图的「跟随 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
233 lines
7.2 KiB
Go
233 lines
7.2 KiB
Go
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
|
||
}
|