From d98bf512e1c0d2969d4d50c19a9523aadcaa80d3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 08:13:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E5=9C=BA=E6=99=AF=E5=BC=8F?= =?UTF-8?q?=E5=85=B3=E8=81=94=E5=8F=AC=E5=9B=9E=E2=80=94=E2=80=94=E7=BB=99?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E8=8A=82=E7=82=B9=E8=B5=8B=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=8C=E5=9C=BA=E9=9D=A2=E9=87=8D=E7=8E=B0?= =?UTF-8?q?=E5=8D=B3=E5=8F=96=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(实测):带条件的记忆召不回来。生产库里明明有 「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:(QQ 归档的知识天然属于 QQ 场面)。 不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。 - 存量引导:memgc -tag-scene <键> -entity-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通道语音输入 等仍在其后。 --- cmd/memgc/main.go | 34 ++ internal/agent/core/distill.go | 8 + internal/agent/core/eventloop.go | 2 +- internal/agent/core/graphmedia_test.go | 2 +- internal/agent/core/medialive_test.go | 2 +- internal/agent/core/memorypass.go | 61 ++- internal/agent/core/memorypass_test.go | 8 +- internal/agent/core/recallpolicy_test.go | 6 +- internal/agent/core/scene_test.go | 53 +++ internal/agent/core/task.go | 6 +- internal/agent/core/toolcall.go | 9 + internal/agent/core/tooldefs.go | 27 +- internal/memory/graph.go | 52 ++- internal/memory/indexer.go | 105 +++++- internal/memory/noise.go | 7 + internal/memory/scene.go | 453 +++++++++++++++++++++++ internal/memory/scene_test.go | 273 ++++++++++++++ 17 files changed, 1071 insertions(+), 37 deletions(-) create mode 100644 internal/agent/core/scene_test.go create mode 100644 internal/memory/scene.go create mode 100644 internal/memory/scene_test.go diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go index 10693ec..376a634 100644 --- a/cmd/memgc/main.go +++ b/cmd/memgc/main.go @@ -29,6 +29,9 @@ func main() { path := flag.String("db", "", "graph.db 路径(必填)") apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印") orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)") + tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)") + entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景") + sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要") flag.Parse() if *path == "" { @@ -42,6 +45,37 @@ func main() { } defer g.Close() + if *sceneStats { + stats, err := g.SceneStats() + if err != nil { + log.Fatalf("memgc: scene stats: %v", err) + } + fmt.Printf("场景 %d 个:\n", len(stats)) + for _, st := range stats { + fmt.Printf(" %-40s refs=%-5d relations=%-5d entities=%-5d updated=%s\n", + st.Key, st.Refs, st.Relations, st.Entities, st.UpdatedAt.Format("2006-01-02 15:04")) + } + return + } + + // 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型) + // 没有任何场景引用,不补挂就永远吃不到场景召回。 + if *tagScene != "" { + if *entityGlob == "" { + log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测") + } + n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply) + if err != nil { + log.Fatalf("memgc: tag scene: %v", err) + } + if *apply { + fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene) + } else { + fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene) + } + return + } + junk, err := g.NoiseEntities() if err != nil { log.Fatalf("memgc: scan: %v", err) diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 9ef2c60..3c53b0d 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -423,6 +423,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { return nil } + // 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该 + // 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回, + // 不必指望本轮措辞与它们字面重合。 + docScene := memory.ChannelScene(doc.Source) + isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true" // 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」 @@ -434,6 +439,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { Object: doc.Summary, ObjectType: "Topic", Confidence: 1.0, + Scene: docScene, }) } @@ -451,6 +457,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { for _, nt := range result.Triples { mt := nlp.ToMemoryTriple(nt) if mt.Subject != "" && mt.Relation != "" && mt.Object != "" { + mt.Scene = docScene triples = append(triples, mt) } } @@ -465,6 +472,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { Object: doc.Source, ObjectType: "Source", Confidence: 1.0, + Scene: docScene, }) } diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 467b422..e70a084 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -379,7 +379,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int { if !a.pruneDeclared(evt) { return 0 } - return a.memoryPass(cleanInput, "input:"+evt.Source, true, false).Archived + return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived } // pruneDeclared 判定这次输入是否显式声明了裁剪。 diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index a52939e..dd61973 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -376,7 +376,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { t.Fatalf("indexer sync: %v", err) } - out := a.buildMemoryContext("测试图片", 0) + out := a.buildMemoryContext("测试图片", 0, nil) if out == "" { t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入") } diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go index 67838f5..b362bbc 100644 --- a/internal/agent/core/medialive_test.go +++ b/internal/agent/core/medialive_test.go @@ -440,7 +440,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { if err := a.indexer.Sync(); err != nil { t.Fatalf("indexer sync: %v", err) } - if mc := a.buildMemoryContext("测试图片", 0); mc != "" { + if mc := a.buildMemoryContext("测试图片", 0, nil); mc != "" { t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) } else { t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)") diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index babe4f3..209caeb 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -1,6 +1,61 @@ package core -import "log" +import ( + "log" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// sceneKeysFor 推导本轮输入的**当前场景**。 +// +// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定) +// 取回来。优先级: +// 1. 注入点显式声明(payload.scene)——插件最清楚自己在什么场面里 +// 2. 通道(evt.Source → chan:qq) +// 3. 工具(tool:qq_get_message)——工具输出触发的召回只知道这一步 +// +// 多个场景是**并列命中**(取回任一场景的记忆),不是交集: +// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。 +func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string { + var keys []string + seen := make(map[string]bool) + add := func(k string) { + // 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重, + // 否则「chan:QQ」与「chan:qq」会变成两个场景,各自只召回一半记忆。 + k = memory.NormalizeSceneKey(k) + if k == "" || seen[k] { + return + } + seen[k] = true + keys = append(keys, k) + } + + if evt != nil && evt.Payload != nil { + switch v := evt.Payload["scene"].(type) { + case string: + add(v) + case []string: + for _, s := range v { + add(s) + } + case []interface{}: + for _, item := range v { + if s, ok := item.(string); ok { + add(s) + } + } + } + } + + if evt != nil { + add(memory.ChannelScene(evt.Source)) + } + if toolName != "" { + add(memory.ToolScene(toolName)) + } + return keys +} // memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。 type memoryPassOut struct { @@ -26,7 +81,7 @@ type memoryPassOut struct { // 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的 // 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、 // 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。 -func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPassOut { +func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut { var out memoryPassOut if a == nil || (!prune && !recall) { return out @@ -35,7 +90,7 @@ func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPass out.Archived = a.pruneByQuery(query) } if recall && query != "" { - out.RecallText = a.recallTextFor(query, trigger) + out.RecallText = a.recallTextFor(query, trigger, scenes) } if out.Archived > 0 || out.RecallText != "" { log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars", diff --git a/internal/agent/core/memorypass_test.go b/internal/agent/core/memorypass_test.go index af67350..03e4caf 100644 --- a/internal/agent/core/memorypass_test.go +++ b/internal/agent/core/memorypass_test.go @@ -22,7 +22,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) { maxContextSize: 4, indexer: newTestIndexer(t, "咖啡", "张三"), } - out := a.memoryPass("咖啡", "test", false, false) + out := a.memoryPass("咖啡", "test", false, false, nil) if out.Archived != 0 || out.RecallText != "" { t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out) } @@ -31,7 +31,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) { func TestMemoryPass_PruneAndRecallTogether(t *testing.T) { a := newMemoryPassAgent(t) before := a.context.Len() - out := a.memoryPass("咖啡", "tool:test", true, true) + out := a.memoryPass("咖啡", "tool:test", true, true, nil) if out.Archived == 0 { t.Fatal("声明 prune 应归档低相关事件") } @@ -50,7 +50,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) { maxContextSize: 4, indexer: newTestIndexer(t, "咖啡", "张三"), } - if out := onlyPrune.memoryPass("咖啡", "test", true, false); out.RecallText != "" { + if out := onlyPrune.memoryPass("咖啡", "test", true, false, nil); out.RecallText != "" { t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText) } // 只召回不裁剪:输出只有召回文本,上下文条数不变。 @@ -60,7 +60,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) { indexer: newTestIndexer(t, "咖啡", "张三"), } before := onlyRecall.context.Len() - out := onlyRecall.memoryPass("咖啡", "test", false, true) + out := onlyRecall.memoryPass("咖啡", "test", false, true, nil) if out.Archived != 0 { t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived) } diff --git a/internal/agent/core/recallpolicy_test.go b/internal/agent/core/recallpolicy_test.go index 43a52f0..9f9cf27 100644 --- a/internal/agent/core/recallpolicy_test.go +++ b/internal/agent/core/recallpolicy_test.go @@ -90,15 +90,15 @@ func TestBuildTaskMemoryContext_RespectsPolicy(t *testing.T) { // 工具触发的召回:以(清洗后的)工具输出为 query,产出可注入的记忆文本。 func TestRecallTextFor_UsesQuery(t *testing.T) { a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")} - got := a.recallTextFor("咖啡", "tool:test") + got := a.recallTextFor("咖啡", "tool:test", nil) if !strings.Contains(got, "【记忆索引】") { t.Fatalf("应产出记忆索引文本,实际 %q", got) } // 空 query 或无 indexer 时不产出、不 panic。 - if got := a.recallTextFor("", "tool:test"); got != "" { + if got := a.recallTextFor("", "tool:test", nil); got != "" { t.Fatalf("空 query 应返回空串,实际 %q", got) } - if got := (&Agent{}).recallTextFor("咖啡", "tool:test"); got != "" { + if got := (&Agent{}).recallTextFor("咖啡", "tool:test", nil); got != "" { t.Fatalf("无 indexer 应返回空串,实际 %q", got) } } diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go new file mode 100644 index 0000000..ad1001c --- /dev/null +++ b/internal/agent/core/scene_test.go @@ -0,0 +1,53 @@ +package core + +import ( + "testing" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// TestSceneKeysFor 钉住当前场景的推导优先级: +// 注入点显式声明 > 通道 > 工具;并列命中且去重。 +func TestSceneKeysFor(t *testing.T) { + // 通道 + 工具:两个都能独立成立的触发条件,都要带上 + got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message") + want := []string{"chan:qq", "tool:qq_get_message"} + if len(got) != len(want) { + t.Fatalf("sceneKeysFor = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("sceneKeysFor[%d] = %q, want %q", i, got[i], want[i]) + } + } + + // 注入点显式声明排最前;归一化生效;重复声明去重 + evt := &agentIO.InputEvent{ + Source: "QQ", + Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "}, + } + got = sceneKeysFor(evt, "") + if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" { + t.Errorf("显式声明应排最前且通道场景归一: %v", got) + } + + // 数组形式声明 + evt = &agentIO.InputEvent{ + Source: "webui", + Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}}, + } + got = sceneKeysFor(evt, "") + if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" { + t.Errorf("数组声明未生效: %v", got) + } + + // nil 事件不 panic + if got := sceneKeysFor(nil, ""); len(got) != 0 { + t.Errorf("nil 事件应无场景: %v", got) + } + // 未声明的 payload 键不影响 + evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}} + if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" { + t.Errorf("无 scene 声明时应只有通道场景: %v", got) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index ecf9deb..a2c717d 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -753,7 +753,11 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { needRecall := def.RecallPolicy == sdk.RecallPolicyAuto if needPrune || needRecall { query := a.toolOutputForQuery(tc.Name, result) - recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall).RecallText + // 工具路召回的场景有两个来源:本轮输入的场面(如 chan:qq) + // 与这一步工具本身(如 tool:qq_get_message)。带上工具场景, + // 才能让「凡是要回 QQ 消息」这类规则在该步被取回。 + scenes := sceneKeysFor(f.Evt, tc.Name) + recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText } } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 485af18..47b4d7b 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -209,6 +209,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if !ok { return "参数格式错误,需要 triples 数组" } + // 场景键:模型可以在三元组里逐条给(scene 字段),也可以在工具参数 + // 顶层给一次(scene 参数),后者作为本批次的默认场景。 + // 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把 + // 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。 + batchScene := getString(tc.Arguments, "scene") var triples []memory.Triple for _, td := range triplesData { if m, ok := td.(map[string]interface{}); ok { @@ -217,6 +222,10 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { Relation: getString(m, "relation"), Object: getString(m, "object"), SentenceText: getString(m, "sentence_text"), + Scene: getString(m, "scene"), + } + if t.Scene == "" { + t.Scene = batchScene } // 模型显式关联的媒体:结构化字段随三元组一起提交, // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边—— diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 44d9afd..dfb8cb6 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -10,11 +10,11 @@ import ( sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta" ) -func (a *Agent) buildMemoryContext(input string, maxTokens int) string { +func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) string { if a.indexer == nil { return "" } - injected := a.indexer.BuildContext(input) + injected := a.indexer.BuildContextInScene(input, scenes) s := a.indexer.FormatContext(injected) // 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。 @@ -48,8 +48,9 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { // query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义: // 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。 func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string { + scenes := sceneKeysFor(evtOf(f), "") if f == nil { - return a.recallText(input, "input", maxTokens) + return a.recallText(input, "input", maxTokens, scenes) } if !a.recallDeclared(f.Evt) { return "" @@ -62,7 +63,15 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int if f.Evt != nil && f.Evt.Source != "" { trigger = "input:" + f.Evt.Source } - return a.recallText(query, trigger, maxTokens) + return a.recallText(query, trigger, maxTokens, scenes) +} + +// evtOf 安全取出 TaskFrame 的事件(f 为 nil 时返回 nil)。 +func evtOf(f *TaskFrame) *agentIO.InputEvent { + if f == nil { + return nil + } + return f.Evt } // recallTextFor 以 query 触发一次记忆召回,按当前预算截断,返回可注入的文本。 @@ -70,27 +79,27 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int // 这是“召回”侧的单一入口:与 Prune 共用同一份**清洗后**的 query, // 使“取进来”(召回)与“踢出去”(裁剪)落在同一个相关性过程上。 // trigger 仅用于日志溯源(如 "tool:qq_get_message")。 -func (a *Agent) recallTextFor(query, trigger string) string { +func (a *Agent) recallTextFor(query, trigger string, scenes []string) string { memTokens := 0 // 0 = 不截断 if a != nil && a.provider != nil { memTokens = ComputeTokenBudget(a.provider, a.systemPrompt).MemoryTokens } - return a.recallText(query, trigger, memTokens) + return a.recallText(query, trigger, memTokens, scenes) } // recallText 是召回侧的共同实现:query → 记忆索引文本(空串表示无)。 // // 输入侧的 buildTaskMemoryContext 与工具侧的 recallTextFor 都收敛到这里, // 使“同一份 query、同一次预算、同一条审计日志”只写一遍。 -func (a *Agent) recallText(query, trigger string, maxTokens int) string { +func (a *Agent) recallText(query, trigger string, maxTokens int, scenes []string) string { if a == nil || query == "" || a.indexer == nil { return "" } - text := a.buildMemoryContext(query, maxTokens) + text := a.buildMemoryContext(query, maxTokens, scenes) if text == "" { return "" } - log.Printf("[agent] memory recall (%s): injected %d chars", trigger, len(text)) + log.Printf("[agent] memory recall (%s): injected %d chars (scenes=%v)", trigger, len(text), scenes) return text } diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 4a533bc..9d17333 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -55,6 +55,10 @@ type Triple struct { // 媒体不再靠正文 marker 反解:结构化字段直接给出归属, // 由调用方(core)把它变成 L3 一等块并与句子建立结构边。 MediaDigests []string `json:"media_digests,omitempty"` + // Scene 是这条记忆所属的**场景键**(可空)。写完后该三元组的两个实体 + // 与这条关系都会被挂到这个场景上,场景重现时按场景召回。 + // 约定见 memory.NormalizeSceneKey:`chan:qq`、`chan:qq/peer:group_123`。 + Scene string `json:"scene,omitempty"` } type GraphDB struct { @@ -172,6 +176,32 @@ func (g *GraphDB) initSchema() error { summary TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, + // 场景引用:给「记忆节点」再赋一层**触发条件**。 + // + // 为什么需要它:词法/向量召回都靠「字面或语义相似」,而带条件的规则 + // (「回 QQ 消息不要用 Markdown」「老大消息优先」)在措辞不重合时根本 + // 召不回来。场景是这类记忆的**索引键**:节点记住自己「属于哪个场面」, + // 场面重现(又来一条 QQ 消息)时直接按场景取回,不靠字面命中。 + `CREATE TABLE IF NOT EXISTS scenes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + // ref_id 的解释由 kind 决定(relation / entity)。这里不用外键: + // 节点可能先于引用被清理(PurgeNoise/PurgeOrphans),悬空引用由 + // 读取侧的 JOIN 自然过滤掉,而级联删除会把清理变成一个跨表事务。 + `CREATE TABLE IF NOT EXISTS scene_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id INTEGER NOT NULL, + kind TEXT NOT NULL, + ref_id INTEGER NOT NULL, + weight REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scene_id, kind, ref_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_scene_refs_scene ON scene_refs(scene_id, kind)`, + `CREATE INDEX IF NOT EXISTS idx_scene_refs_ref ON scene_refs(kind, ref_id)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`, @@ -365,24 +395,27 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe } } - var existing int + var existing int64 err = tx.QueryRow( - `SELECT 1 FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, + `SELECT id FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, sourceID, targetID, t.Relation, sessionID, ).Scan(&existing) + var relID int64 if err == sql.ErrNoRows { - _, err = tx.Exec( + res, ierr := tx.Exec( `INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, ) - if err != nil { - return nil, 0, 0, err + if ierr != nil { + return nil, 0, 0, ierr } + relID, _ = res.LastInsertId() relationsCreated++ } else if err != nil { return nil, 0, 0, err } else { + relID = existing // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 _, err = tx.Exec( `UPDATE relations SET confidence = ?, updated_at = CURRENT_TIMESTAMP @@ -393,6 +426,15 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe return nil, 0, 0, err } } + + // 场景引用:写完关系立即把「关系 + 两端实体」挂到这个场景上。 + // 同一事务内完成,避免出现「关系写进去了但场景引用丢了」—— + // 那会让这条记忆在后来的场景里永远召不回来,且无声无息。 + if t.Scene != "" && relID != 0 { + if err := tagSceneTx(tx, t.Scene, relID, []int64{sourceID, targetID}, confidence); err != nil { + return nil, 0, 0, err + } + } } if err := tx.Commit(); err != nil { diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index e865994..bda7381 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -102,9 +102,27 @@ type InjectedContext struct { Relations []Relation `json:"relations"` Summary string `json:"summary"` TokenEstimate int `json:"token_estimate"` + + // Scenes 是本轮识别出的当前场景;SceneRelations 是被钉在这些场景上的 + // 记忆(带 relation_type 与原句)。两者都进注入文本——场景记忆是 + // **带条件的规则**,只给实体名等于没召回。 + Scenes []string `json:"scenes,omitempty"` + SceneRelations []Relation `json:"scene_relations,omitempty"` } +// BuildContext 不带场景的召回(保持既有行为:词法 + 实体名向量)。 func (idx *Indexer) BuildContext(userInput string) *InjectedContext { + return idx.BuildContextInScene(userInput, nil) +} + +// BuildContextInScene 在词法/向量召回之上叠加**场景召回**。 +// +// 两条路正交且都要保留: +// - 词法/向量:话题相关(「上次那个 bug 怎么修的」) +// - 场景:条件相关(「在 QQ 上回消息」→ 不要 Markdown) +// +// 场景路不参与相似度打分、也不受关键词为空的影响:只要场面重现就该取回。 +func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *InjectedContext { if idx.db == nil { return &InjectedContext{Summary: ""} } @@ -129,23 +147,41 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext { result, err := idx.db.Recall(allKeywords, nil, 2, "") if err != nil || result == nil { - return &InjectedContext{Summary: ""} + result = &RecallResult{} } - // 过滤已被工具调用显式召回的实体,避免重复注入 - idx.mu.RLock() - filtered := result.Entities[:0] - for _, e := range result.Entities { - if !idx.recalled[e.Name] { - filtered = append(filtered, e) + // 3. 场景召回:当前场面钉住的记忆 + sceneRecall, err := idx.db.RecallByScene(scenes, maxSceneRecallRelations) + if err != nil { + sceneRecall = nil + } + sceneEntityNames := make(map[string]bool) + if sceneRecall != nil { + for _, e := range sceneRecall.Entities { + sceneEntityNames[e.Name] = true } } + + // 过滤已被工具调用显式召回的实体,避免重复注入;场景实体已在场景块 + // 里给过,也不在索引里再占位。 + idx.mu.RLock() + filtered := make([]Entity, 0, len(result.Entities)) + for _, e := range result.Entities { + if idx.recalled[e.Name] || sceneEntityNames[e.Name] { + continue + } + filtered = append(filtered, e) + } idx.mu.RUnlock() ctx := &InjectedContext{ Entities: filtered, Relations: nil, } + if sceneRecall != nil { + ctx.Scenes = sceneRecall.Scenes + ctx.SceneRelations = sceneRecall.Relations + } if len(filtered) > 0 { summary := buildIndexSummary(filtered) @@ -194,8 +230,13 @@ func (idx *Indexer) BuildToolPrompt() string { 将三元组写入图记忆。 参数: - triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体", - "sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"]}] + "sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"], + "scene": "场景键(可选)"}] 填了 media_digests,日后从这条记忆就能取回当时那张图/那段音频。 + 填了 scene,这条记忆就挂在那个**场面**上:场面重现时(如又来一条 QQ 消息) + 不靠字面命中也会被召回。「在什么场合该怎么做」这类约定/规则都该填, + 例如回 QQ 消息的格式约定 → scene="chan:qq"。 +- scene: 本批次默认场景键(可选,逐条 triples 里的 scene 优先)。 ### memory_introspect 查看记忆统计信息。 @@ -210,11 +251,36 @@ func (idx *Indexer) BuildToolPrompt() string { } func (idx *Indexer) FormatContext(ctx *InjectedContext) string { - if ctx == nil || len(ctx.Entities) == 0 { + if ctx == nil || (len(ctx.Entities) == 0 && len(ctx.SceneRelations) == 0) { return "" } var b strings.Builder + + // 场景记忆排在前面:它们是**带条件的规则**(在什么场面下该怎么做), + // 对行为的约束强于“话题相关的实体名”。也正因为带有触发条件, + // 它们的正确性不依赖本轮措辞是否命中了字面。 + if len(ctx.SceneRelations) > 0 { + b.WriteString(fmt.Sprintf("【场景记忆 %s】\n", strings.Join(ctx.Scenes, ", "))) + for i, rel := range ctx.SceneRelations { + if i >= maxSceneRecallRelations { + break + } + b.WriteString(fmt.Sprintf("- %s --%s--> %s", rel.SourceName, rel.RelationType, rel.TargetName)) + if rel.SentenceText != "" { + b.WriteString("(") + b.WriteString(truncateRunes(rel.SentenceText, sceneSentenceMaxRunes)) + b.WriteString(")") + } + b.WriteString("\n") + } + b.WriteString("(以上是该场景下的既有约定,请照办)\n") + } + + if len(ctx.Entities) == 0 { + return strings.TrimRight(b.String(), "\n") + } + b.WriteString("【记忆索引】") if ctx.Summary != "" { @@ -241,6 +307,23 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string { return b.String() } +// maxSceneRecallRelations 是单次场景召回的关系上限。 +// +// 场景是**每轮都要注入**的常驻内容:不封顶时一个宽场景(如 chan:qq) +// 会把它下面所有关系都推进 prompt,把 token 预算吃光。 +const maxSceneRecallRelations = 8 + +// sceneSentenceMaxRunes 是场景关系后附原句的截断长度。 +const sceneSentenceMaxRunes = 60 + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} + func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { return []map[string]interface{}{ { @@ -291,6 +374,10 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { "description": "可选:这条记忆关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后从这条记忆能取回原图/音频。", "items": map[string]interface{}{"type": "string"}, }, + "scene": map[string]interface{}{ + "type": "string", + "description": "可选:这条记忆所属的场景键(如 chan:qq、chan:qq/peer:group_123、tool:qq_get_message)。「在什么场合该怎么做」这类条件性约定/规则必须填:场面重现时它会被直接召回,与用户措辞无关。", + }, }, "required": []string{"subject", "relation", "object"}, }, diff --git a/internal/memory/noise.go b/internal/memory/noise.go index 529d410..da3eda2 100644 --- a/internal/memory/noise.go +++ b/internal/memory/noise.go @@ -180,6 +180,10 @@ func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) { if _, err := g.cleanupOrphanedSentencesLocked(); err != nil { return len(junk), relCount, err } + // 节点没了,场景引用必须跟着对齐:残留引用会让场景看着大、召回却是空的。 + if _, err := g.purgeStaleSceneRefsLocked(); err != nil { + return len(junk), relCount, err + } return len(junk), relCount, nil } @@ -252,5 +256,8 @@ func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) { `DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil { return 0, err } + if _, err := g.purgeStaleSceneRefsLocked(); err != nil { + return 0, err + } return len(orphans), nil } diff --git a/internal/memory/scene.go b/internal/memory/scene.go new file mode 100644 index 0000000..0df958e --- /dev/null +++ b/internal/memory/scene.go @@ -0,0 +1,453 @@ +package memory + +import ( + "database/sql" + "fmt" + "sort" + "strings" + "time" + "unicode" +) + +// ────────────────────────────────────────────── +// 场景式关联召回 +// +// 词法与向量召回都建立在「字面/语义相似」上,而带**条件**的记忆天生不吃这一套: +// +// QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown +// 老大 --偏好--> QQ回复禁用Markdown格式 +// +// 这些规则约束的是「回 QQ 消息这个场面」,而不是某个话题。用户措辞里没出现 +// 「QQ」「Markdown」时它们召不回来;而用户只说了「QQ」时,词法路又会把上百个 +// 含 QQ 的实体按建表顺序排前面,把规则本体挤出注入预算(实测:输入「QQ回复格式」 +// 命中 148 个实体,规则排第 32,注入只取前 5 —— 规则根本没进去)。 +// +// 场景引用把「触发条件」变成一等索引:节点记住自己属于哪个场面, +// 场面重现时按场景直接取回,与措辞无关。 +// +// 场景键的形态是**分层字符串**,用 `/` 分隔,由宽到窄: +// +// chan:qq 通道级(在 QQ 上收发消息) +// chan:qq/peer:group_1027993713 再窄一层(具体群) +// tool:qq_get_message 工具级(取回消息正文这一步) +// chan:doc/src:qq 文档归档的来源 +// +// 召回按**前缀**匹配:当前场景 `chan:qq` 会取回它自己以及所有更窄的场景 +// (`chan:qq/...`)——越窄的场景越具体,不该被漏掉;反向不成立。 +// ────────────────────────────────────────────── + +// maxSceneKeyLen 是场景键的长度上限。场景键要进索引、要参与前缀比较, +// 过长只说明有人把正文塞进了键里(那就该用句子/实体,而不是场景)。 +const maxSceneKeyLen = 96 + +// NormalizeSceneKey 规范化场景键:按 `/` 分层、每层小写、层内空白与非法字符 +// 归一成 `_`(连续多个只留一个)。 +// +// 为什么要归一:场景键是**索引键**,`chan:QQ` 与 `chan:qq` 必须是同一个场景, +// 否则同一条规则会因为写入时大小写不同而分裂成两个召不齐的场景。 +// 为什么空白不算层级分隔符:来源名里天然带空格(如「老大2026-09-04 12:27 +// QQ私聊图片」),把它当层级会把一个平面名字拆成三层伪层级。 +// 归一结果为空(全是非法字符)时返回空串,调用方应视为「没有场景」。 +func NormalizeSceneKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + key = strings.ToLower(key) + + parts := strings.Split(key, "/") + segs := make([]string, 0, len(parts)) + for _, p := range parts { + seg := normalizeSceneSegment(p) + if seg != "" { + segs = append(segs, seg) + } + } + out := strings.Join(segs, "/") + if len(out) > maxSceneKeyLen { + out = strings.TrimRight(out[:maxSceneKeyLen], "/") + } + return out +} + +// normalizeSceneSegment 归一单层场景:小写、空白与非法字符 → `_`(压缩连续)。 +func normalizeSceneSegment(seg string) string { + var b strings.Builder + lastUnderscore := false + for _, r := range seg { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r >= '\u4e00' && r <= '\u9fff', + r == ':' || r == '-' || r == '.': + b.WriteRune(r) + lastUnderscore = false + case r == '_' || unicode.IsSpace(r): + if !lastUnderscore && b.Len() > 0 { + b.WriteRune('_') + lastUnderscore = true + } + default: + // 其它字符(标点、表情)归一成 `_` 而不是静默丢弃: + // 「A/B」与「A_B」是两个不同的来源,不能塌成一个场景。 + if !lastUnderscore && b.Len() > 0 { + b.WriteRune('_') + lastUnderscore = true + } + } + } + return strings.Trim(b.String(), "_") +} + +// ChannelScene 由输入/输出通道名构造场景键(`qq` → `chan:qq`)。 +func ChannelScene(source string) string { + s := NormalizeSceneKey(source) + if s == "" { + return "" + } + return "chan:" + s +} + +// ToolScene 由工具名构造场景键(`qq_get_message` → `tool:qq_get_message`)。 +func ToolScene(tool string) string { + s := NormalizeSceneKey(tool) + if s == "" { + return "" + } + return "tool:" + s +} + +// SceneStat 是单个场景的规模摘要(供 introspection / 运维观察)。 +type SceneStat struct { + Key string `json:"key"` + Refs int `json:"refs"` + Relations int `json:"relations"` + Entities int `json:"entities"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SceneRecall 是一次场景召回的产物。 +type SceneRecall struct { + Scenes []string `json:"scenes"` + Relations []Relation `json:"relations"` + Entities []Entity `json:"entities"` +} + +// tagSceneTx 在事务内把「关系 + 实体」挂到场景上(幂等 upsert)。 +// +// weight 取关系的置信度:场景内的记忆也要能排序,置信度是目前唯一现成的 +// 质量信号。重复写入同一节点只刷新 weight 与时间,不产生重复引用。 +func tagSceneTx(tx *sql.Tx, sceneKey string, relationID int64, entityIDs []int64, weight float64) error { + key := NormalizeSceneKey(sceneKey) + if key == "" { + return nil + } + if _, err := tx.Exec( + `INSERT INTO scenes (key) VALUES (?) + ON CONFLICT(key) DO UPDATE SET updated_at = CURRENT_TIMESTAMP`, key); err != nil { + return fmt.Errorf("upsert scene %q: %w", key, err) + } + var sceneID int64 + if err := tx.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID); err != nil { + return fmt.Errorf("select scene %q: %w", key, err) + } + if weight <= 0 { + weight = 1.0 + } + + refs := make([]struct { + kind string + id int64 + }, 0, len(entityIDs)+1) + if relationID != 0 { + refs = append(refs, struct { + kind string + id int64 + }{"relation", relationID}) + } + for _, eid := range entityIDs { + if eid != 0 { + refs = append(refs, struct { + kind string + id int64 + }{"entity", eid}) + } + } + + for _, r := range refs { + if _, err := tx.Exec( + `INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?) + ON CONFLICT(scene_id, kind, ref_id) + DO UPDATE SET weight = MAX(weight, excluded.weight)`, + sceneID, r.kind, r.id, weight); err != nil { + return fmt.Errorf("upsert scene ref %s/%d: %w", r.kind, r.id, err) + } + } + return nil +} + +// TagScene 给一批已有的关系补挂场景(存量记忆的场景标注入口)。 +// +// 为什么需要「事后标注」:场景是后引入的维度,此前写下的规则(那批 QQ 规则 +// 就是典型)没有任何场景引用,不补挂就永远吃不到场景召回。 +func (g *GraphDB) TagScene(sceneKey string, relationIDs []int64) (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + key := NormalizeSceneKey(sceneKey) + if key == "" || len(relationIDs) == 0 { + return 0, nil + } + tx, err := g.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + n := 0 + for _, rid := range relationIDs { + var sourceID, targetID int64 + var confidence float64 + if err := tx.QueryRow( + `SELECT source_id, target_id, confidence FROM relations WHERE id = ?`, rid, + ).Scan(&sourceID, &targetID, &confidence); err != nil { + if err == sql.ErrNoRows { + continue + } + return n, err + } + if err := tagSceneTx(tx, key, rid, []int64{sourceID, targetID}, confidence); err != nil { + return n, err + } + n++ + } + if err := tx.Commit(); err != nil { + return 0, err + } + return n, nil +} + +// TagSceneByEntityGlob 把「任一端实体名匹配 GLOB pattern」的活跃关系标进场景, +// 返回标注的关系数。dryRun 时只统计。 +// +// 这是**存量引导**用的窄口子:pattern 由调用方显式给出,不做任何自动猜测 +// ——猜错的代价是把无关记忆钉死在某个场景上,之后每次进入该场景都会被注入, +// 比漏标更难发现。 +// +// 为什么用 GLOB 而不是 LIKE:LIKE 对 ASCII **不区分大小写**,于是 `%QQ%` +// 会把对象里带 `/home/newqqagent` 的路径类记忆(生产数据目录、email-mcp、 +// dify-ops技能路径…实测 7 条)一起卷进「QQ 场景」。GLOB 区分大小写, +// `*QQ*` 只命中真正写作 QQ 的那些名字。 +func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (int, error) { + key := NormalizeSceneKey(sceneKey) + if key == "" || pattern == "" { + return 0, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + rows, err := g.db.Query( + `SELECT r.id, r.source_id, r.target_id, r.confidence + FROM relations r + JOIN entities e1 ON r.source_id = e1.id + JOIN entities e2 ON r.target_id = e2.id + WHERE r.status = 'active' AND (e1.name GLOB ? OR e2.name GLOB ?)`, + pattern, pattern) + if err != nil { + return 0, err + } + type cand struct { + relID int64 + sourceID, target int64 + confidence float64 + } + var cands []cand + for rows.Next() { + var c cand + if err := rows.Scan(&c.relID, &c.sourceID, &c.target, &c.confidence); err != nil { + rows.Close() + return 0, err + } + cands = append(cands, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + if dryRun { + return len(cands), nil + } + + tx, err := g.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + for _, c := range cands { + if err := tagSceneTx(tx, key, c.relID, []int64{c.sourceID, c.target}, c.confidence); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return len(cands), nil +} + +// RecallByScene 按当前场景取回被钉在该场景上的记忆(前缀匹配,越窄越算命中)。 +// +// 排序:weight(=写入时置信度)降序 → 关系时间降序。取回的是**关系全文** +// (含 relation_type 与 JOIN 出的原句),不只是实体名——带条件的规则本体 +// 长在关系上,只给名字等于没召回。 +// +// limit 同时约束关系数与实体数,避免一个场景把注入预算吃光。 +func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error) { + out := &SceneRecall{} + var keys []string + seen := make(map[string]bool) + for _, s := range scenes { + k := NormalizeSceneKey(s) + if k == "" || seen[k] { + continue + } + seen[k] = true + keys = append(keys, k) + } + if len(keys) == 0 { + return out, nil + } + out.Scenes = keys + if limit <= 0 { + limit = 8 + } + + g.mu.RLock() + defer g.mu.RUnlock() + + // 前缀条件:(key = ? OR key LIKE ? || '/%'),用 '/' 兜底防止 + // `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。 + conds := make([]string, 0, len(keys)) + args := make([]interface{}, 0, len(keys)*2) + for _, k := range keys { + conds = append(conds, `(s.key = ? OR s.key LIKE ? || '/%')`) + args = append(args, k, k) + } + where := "(" + strings.Join(conds, " OR ") + ")" + + relQuery := `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, + r.relation_type, r.confidence, r.status, r.session_id, + r.turn_id, r.created_at, COALESCE(r.date_bucket, ''), + COALESCE(r.sentence_id, 0), COALESCE(sn.text, ''), + MAX(sr.weight) AS w + FROM scene_refs sr + JOIN scenes s ON sr.scene_id = s.id + JOIN relations r ON sr.kind = 'relation' AND sr.ref_id = r.id + JOIN entities e1 ON r.source_id = e1.id + JOIN entities e2 ON r.target_id = e2.id + LEFT JOIN sentences sn ON r.sentence_id = sn.id + WHERE ` + where + ` AND r.status = 'active' + GROUP BY r.id + ORDER BY w DESC, r.updated_at DESC, r.id DESC + LIMIT ?` + relArgs := append(append([]interface{}{}, args...), limit) + rows, err := g.db.Query(relQuery, relArgs...) + if err != nil { + return nil, err + } + for rows.Next() { + var rel Relation + var w float64 + if err := rows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, &rel.SourceName, &rel.TargetName, + &rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID, + &rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText, &w); err != nil { + rows.Close() + return nil, err + } + out.Relations = append(out.Relations, rel) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + entQuery := `SELECT e.id, e.name, e.type, e.mention_count, e.created_at, e.updated_at, MAX(sr.weight) AS w + FROM scene_refs sr + JOIN scenes s ON sr.scene_id = s.id + JOIN entities e ON sr.kind = 'entity' AND sr.ref_id = e.id + WHERE ` + where + ` + GROUP BY e.id + ORDER BY w DESC, e.mention_count DESC, e.id DESC + LIMIT ?` + entArgs := append(append([]interface{}{}, args...), limit) + erows, err := g.db.Query(entQuery, entArgs...) + if err != nil { + return nil, err + } + for erows.Next() { + var e Entity + var w float64 + if err := erows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt, &w); err != nil { + erows.Close() + return nil, err + } + out.Entities = append(out.Entities, e) + } + erows.Close() + return out, erows.Err() +} + +// SceneStats 返回各场景的规模,按引用数降序。 +func (g *GraphDB) SceneStats() ([]SceneStat, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + rows, err := g.db.Query( + `SELECT s.key, + COUNT(sr.id), + SUM(CASE WHEN sr.kind = 'relation' THEN 1 ELSE 0 END), + SUM(CASE WHEN sr.kind = 'entity' THEN 1 ELSE 0 END), + s.updated_at + FROM scenes s LEFT JOIN scene_refs sr ON sr.scene_id = s.id + GROUP BY s.id ORDER BY COUNT(sr.id) DESC, s.key`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []SceneStat + for rows.Next() { + var st SceneStat + var rels, ents sql.NullInt64 + if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.UpdatedAt); err != nil { + return nil, err + } + st.Relations = int(rels.Int64) + st.Entities = int(ents.Int64) + out = append(out, st) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Refs > out[j].Refs }) + return out, nil +} + +// PurgeStaleSceneRefs 清理指向已不存在节点的场景引用,返回删除数。 +// +// 节点被清理(PurgeNoise / PurgeOrphans / memory_delete_entity)时不会级联 +// 删 scene_refs(见建表注释),残留引用会让场景看起来很大却召回出空结果, +// 也会让 SceneStats 说谎。这个函数把它们对齐。 +func (g *GraphDB) PurgeStaleSceneRefs() (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + return g.purgeStaleSceneRefsLocked() +} + +func (g *GraphDB) purgeStaleSceneRefsLocked() (int, error) { + res, err := g.db.Exec(`DELETE FROM scene_refs WHERE + (kind = 'relation' AND ref_id NOT IN (SELECT id FROM relations)) + OR (kind = 'entity' AND ref_id NOT IN (SELECT id FROM entities))`) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} diff --git a/internal/memory/scene_test.go b/internal/memory/scene_test.go new file mode 100644 index 0000000..9a22285 --- /dev/null +++ b/internal/memory/scene_test.go @@ -0,0 +1,273 @@ +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) + } +}