Files
HomeAgent/internal/memory/scene_test.go
JianFeeeee d98bf512e1 feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回
背景(实测):带条件的记忆召不回来。生产库里明明有
「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」,
但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去;
输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。

根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做)
约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形
命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object),
即使命中名字也拿不到「纯文本不用Markdown」这句正文。

改动:把「触发条件」升成一等索引维度。

- schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight),
  kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理,
  悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。
- 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、
  tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层),
  空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。
- 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」
  挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。
- 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底
  防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。
- 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在
  最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字;
  场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。
- 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message),
  并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本),
  真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。
- 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认);
  docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。
  不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。
- 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE——
  LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆
  (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。
- 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供
  PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、
前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、
清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、
agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。

生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写):
把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比:
- 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式
  --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。
- 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。
- 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
2026-09-15 08:13:52 +08:00

274 lines
9.0 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 (
"os"
"strings"
"testing"
)
func TestNormalizeSceneKey(t *testing.T) {
cases := []struct{ in, want string }{
{"chan:qq", "chan:qq"},
{"chan:QQ", "chan:qq"},
{" chan:qq ", "chan:qq"},
{"chan:qq/peer:group_1027993713", "chan:qq/peer:group_1027993713"},
{"chan:qq / peer:1", "chan:qq/peer:1"},
{"chan:qq/", "chan:qq"},
{"chan:qq///peer:1", "chan:qq/peer:1"},
{"老大2026-09-04 12:27 QQ私聊图片", "老大2026-09-04_12:27_qq私聊图片"},
{"", ""},
{" ", ""},
{"///", ""},
}
for _, c := range cases {
if got := NormalizeSceneKey(c.in); got != c.want {
t.Errorf("NormalizeSceneKey(%q) = %q, want %q", c.in, got, c.want)
}
}
// 超长键要截到上限且不留尾部分隔符
long := strings.Repeat("a", maxSceneKeyLen+40)
if got := NormalizeSceneKey(long); len(got) > maxSceneKeyLen {
t.Errorf("超长键未截断: %d", len(got))
}
if strings.HasSuffix(NormalizeSceneKey(long+"/x"), "/") {
t.Error("截断后不应留尾部分隔符")
}
}
func TestChannelAndToolScene(t *testing.T) {
if got := ChannelScene("qq"); got != "chan:qq" {
t.Errorf("ChannelScene(qq) = %q", got)
}
if got := ChannelScene(""); got != "" {
t.Errorf("空 source 应为空场景,得到 %q", got)
}
if got := ToolScene("qq_get_message"); got != "tool:qq_get_message" {
t.Errorf("ToolScene = %q", got)
}
}
// TestSceneRefsFromCommit 钉住「写入即挂场景」:三元组带 Scene 时,
// 关系与两端实体都进场景,且同一场景的其它记忆不受影响。
func TestSceneRefsFromCommit(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "老大", Relation: "偏好", Object: "QQ回复禁用Markdown格式", Confidence: 1.0, Scene: "chan:qq", SentenceText: "回QQ消息别用markdown"},
{Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
r, err := g.RecallByScene([]string{"chan:qq"}, 8)
if err != nil {
t.Fatalf("RecallByScene: %v", err)
}
if len(r.Relations) != 1 || r.Relations[0].RelationType != "偏好" {
t.Fatalf("场景关系不对: %+v", r.Relations)
}
if r.Relations[0].SentenceText != "回QQ消息别用markdown" {
t.Errorf("场景召回必须带原句,得到 %q", r.Relations[0].SentenceText)
}
names := map[string]bool{}
for _, e := range r.Entities {
names[e.Name] = true
}
if !names["老大"] || !names["QQ回复禁用Markdown格式"] {
t.Errorf("两端实体都应进场景: %v", names)
}
if names["CodeGraph"] {
t.Error("未标场景的三元组实体不该被卷进场景")
}
// 无关场景不命中
if r2, _ := g.RecallByScene([]string{"chan:webui"}, 8); len(r2.Relations) != 0 {
t.Errorf("chan:webui 不该命中: %+v", r2.Relations)
}
}
// TestRecallByScenePrefix 钉住前缀语义:宽场景召回包含更窄的场景,
// 但不会把同前缀不同层的场景chan:qq2吞进来。
func TestRecallByScenePrefix(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "群规", Relation: "禁止", Object: "刷屏", Confidence: 1.0, Scene: "chan:qq/peer:group_1027993713"},
{Subject: "别的", Relation: "是", Object: "无关", Confidence: 1.0, Scene: "chan:qq2"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
r, err := g.RecallByScene([]string{"chan:qq"}, 8)
if err != nil {
t.Fatalf("RecallByScene: %v", err)
}
if len(r.Relations) != 1 || r.Relations[0].SourceName != "群规" {
t.Fatalf("宽场景应取回窄场景的记忆: %+v", r.Relations)
}
}
// TestRecallBySceneOrderAndLimit 钉住排序weight 降序)与上限。
func TestRecallBySceneOrderAndLimit(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "低值", Relation: "置信", Object: "甲组", Confidence: 0.2, Scene: "chan:qq"},
{Subject: "高值", Relation: "置信", Object: "乙组", Confidence: 0.9, Scene: "chan:qq"},
{Subject: "中值", Relation: "置信", Object: "丙组", Confidence: 0.5, Scene: "chan:qq"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
r, err := g.RecallByScene([]string{"chan:qq"}, 2)
if err != nil {
t.Fatalf("RecallByScene: %v", err)
}
if len(r.Relations) != 2 {
t.Fatalf("limit 未生效: %d", len(r.Relations))
}
if r.Relations[0].SourceName != "高值" || r.Relations[1].SourceName != "中值" {
t.Errorf("应按 weight 降序: %s, %s", r.Relations[0].SourceName, r.Relations[1].SourceName)
}
}
// TestTagSceneByEntityGlob 覆盖存量引导dry-run 不写库、apply 后才建引用)。
func TestTagSceneByEntityGlob(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "老大", Relation: "偏好", Object: "QQ回复禁用Markdown格式", Confidence: 1.0},
{Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
n, err := g.TagSceneByEntityGlob("chan:qq", "*QQ*", true)
if err != nil {
t.Fatalf("dry-run: %v", err)
}
if n != 1 {
t.Errorf("dry-run 命中 %dwant 1", n)
}
if r, _ := g.RecallByScene([]string{"chan:qq"}, 8); len(r.Relations) != 0 {
t.Error("dry-run 不应写库")
}
n, err = g.TagSceneByEntityGlob("chan:qq", "*QQ*", false)
if err != nil {
t.Fatalf("apply: %v", err)
}
if n != 1 {
t.Errorf("apply 标注 %dwant 1", n)
}
r, _ := g.RecallByScene([]string{"chan:qq"}, 8)
if len(r.Relations) != 1 || r.Relations[0].TargetName != "QQ回复禁用Markdown格式" {
t.Errorf("标注后应能按场景取回: %+v", r.Relations)
}
}
// TestPurgeNoiseClearsSceneRefs 钉住清理后不留悬空场景引用。
func TestPurgeNoiseClearsSceneRefs(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0, Scene: "chan:qq"},
{Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0, Scene: "chan:qq"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
if _, _, err := g.PurgeNoise(false); err != nil {
t.Fatalf("purge: %v", err)
}
var refs int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr
WHERE (sr.kind='relation' AND sr.ref_id NOT IN (SELECT id FROM relations))
OR (sr.kind='entity' AND sr.ref_id NOT IN (SELECT id FROM entities))`).Scan(&refs); err != nil {
t.Fatalf("count: %v", err)
}
if refs != 0 {
t.Errorf("清理后仍有 %d 条悬空场景引用", refs)
}
// 干净的那条仍在场景里
r, _ := g.RecallByScene([]string{"chan:qq"}, 8)
if len(r.Relations) != 1 || r.Relations[0].TargetName != "CodeGraph" {
t.Errorf("清理误伤场景记忆: %+v", r.Relations)
}
}
func TestSceneStats(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "甲组", Relation: "是", Object: "乙组", Confidence: 1.0, Scene: "chan:qq"},
{Subject: "丙组", Relation: "是", Object: "丁组", Confidence: 1.0, Scene: "chan:webui"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
stats, err := g.SceneStats()
if err != nil {
t.Fatalf("SceneStats: %v", err)
}
if len(stats) != 2 {
t.Fatalf("场景数 %dwant 2: %+v", len(stats), stats)
}
for _, st := range stats {
// 1 条关系 + 2 个实体
if st.Refs != 3 || st.Relations != 1 || st.Entities != 2 {
t.Errorf("场景 %s 统计不对: %+v", st.Key, st)
}
}
}
// TestBuildContextInScene 覆盖注入面:场景块要给到关系全文与原句,
// 且场景实体不在【记忆索引】里重复占位。
func TestBuildContextInScene(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "老大", Relation: "偏好", Object: "QQ回复禁用Markdown格式", Confidence: 1.0,
Scene: "chan:qq", SentenceText: "以后回QQ消息不要用markdown"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
idx := NewIndexer(g)
if err := idx.Sync(); err != nil {
t.Fatalf("sync: %v", err)
}
// 措辞与记忆零字面重合,词法/向量路召不回;场景路必须兜住。
ctx := idx.BuildContextInScene("在吗", []string{"chan:qq"})
text := idx.FormatContext(ctx)
if !strings.Contains(text, "【场景记忆 chan:qq】") {
t.Fatalf("没有场景块: %q", text)
}
if !strings.Contains(text, "老大 --偏好--> QQ回复禁用Markdown格式") {
t.Errorf("场景块里没有关系全文: %q", text)
}
if !strings.Contains(text, "以后回QQ消息不要用markdown") {
t.Errorf("场景块里没有原句: %q", text)
}
if strings.Contains(text, "【记忆索引】") && strings.Contains(text, "索引: 老大") {
t.Errorf("场景实体不该在索引里重复占位: %q", text)
}
// 无场景时行为与原来一致:不出现场景块
plain := idx.FormatContext(idx.BuildContextInScene("在吗", nil))
if strings.Contains(plain, "【场景记忆") {
t.Errorf("无场景却出现场景块: %q", plain)
}
}