mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 18:38:11 +00:00
背景(实测):带条件的记忆召不回来。生产库里明明有 「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通道语音输入 等仍在其后。
274 lines
9.0 KiB
Go
274 lines
9.0 KiB
Go
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 命中 %d,want 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 标注 %d,want 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("场景数 %d,want 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)
|
||
}
|
||
}
|