diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index e3a40c9..3611f9b 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -197,6 +197,28 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) } +// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。 +// +// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出 +// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」, +// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型 +// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。 +func formatRecallRelations(relations []memory.Relation, max int) []string { + var out []string + for i, r := range relations { + if max > 0 && i >= max { + out = append(out, "...更多关系被截断") + break + } + line := fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName) + if s := strings.TrimSpace(r.SentenceText); s != "" { + line += " 原句: \"" + truncateStr(s, 60) + "\"" + } + out = append(out, line) + } + return out +} + // mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 // // Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token), diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index c7252ea..a52939e 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -759,3 +759,39 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) { t.Fatal("删除后内容应已移除") } } + +// TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」: +// memory_recall 的关系行必须带上 sentence_text(截断),否则模型按工具 +// schema 填了原始句子也永远取不回,该字段形同虚设。 +func TestFormatRecallRelations_SurfacesSentence(t *testing.T) { + rels := []memory.Relation{ + {SourceName: "张三", RelationType: "喜欢", TargetName: "咖啡", SentenceText: "张三说他每天早上一定要喝一杯手冲咖啡。"}, + {SourceName: "张三", RelationType: "住在", TargetName: "北京"}, // 无原句:不应出现空的原句字段 + } + lines := formatRecallRelations(rels, 10) + if len(lines) != 2 { + t.Fatalf("应渲染 2 行,实际 %d: %v", len(lines), lines) + } + if !strings.Contains(lines[0], "张三 →(喜欢)→ 咖啡") || !strings.Contains(lines[0], "原句:") { + t.Errorf("第一条应带原句,实际 %q", lines[0]) + } + if strings.Contains(lines[1], "原句") { + t.Errorf("无 sentence_text 的关系不应出现原句字段,实际 %q", lines[1]) + } +} + +// TestFormatRecallRelations_Truncates 锁死关系条数上限: +// 超过 max 时截断并明确告知,避免刷屏。 +func TestFormatRecallRelations_Truncates(t *testing.T) { + var rels []memory.Relation + for i := 0; i < 15; i++ { + rels = append(rels, memory.Relation{SourceName: "A", RelationType: "连", TargetName: "B"}) + } + lines := formatRecallRelations(rels, 10) + if len(lines) != 11 { + t.Fatalf("10 条关系 + 1 条截断提示,实际 %d: %v", len(lines), lines) + } + if !strings.Contains(lines[10], "截断") { + t.Errorf("最后一行应为截断提示,实际 %q", lines[10]) + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 7bf4a1e..485af18 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -174,13 +174,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type)) } parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations))) - for i, r := range result.Relations { - if i >= 10 { - parts = append(parts, "...更多关系被截断") - break - } - parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) - } + parts = append(parts, formatRecallRelations(result.Relations, 10)...) // 命中的关系若挂着媒体块,把媒体说明附在结果末尾。 // // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 40664fc..8b8ac7f 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -554,6 +554,14 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se return result, nil } + // seenRel 跨层去重。 + // + // 每层都用**已累积的** entityIDs 查邻接关系,因此上一层刚产出、以及 + // 两个已访问实体之间的关系会在下一层被重复查回并再次 append。 + // 深度 2、稠密图上重复会淹没 memory_recall 的 10 条关系预算—— + // 模型看到的是同一句话刷屏,真正的新关系被截断。 + seenRel := make(map[int64]bool) + for depthLevel := 0; depthLevel < depth; depthLevel++ { ids := make([]interface{}, 0, len(entityIDs)) for id := range entityIDs { @@ -600,7 +608,10 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se relRows.Close() return nil, err } - result.Relations = append(result.Relations, rel) + if !seenRel[rel.ID] { + seenRel[rel.ID] = true + result.Relations = append(result.Relations, rel) + } if !entityIDs[rel.SourceID] { newIDs[rel.SourceID] = true diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go index ee32ed5..117843e 100644 --- a/internal/memory/graph_test.go +++ b/internal/memory/graph_test.go @@ -260,6 +260,22 @@ func TestRecallWithDepth(t *testing.T) { if len(result.Relations) == 0 { t.Error("expected relations with depth search") } + + // 逐层查邻接会把已访问实体之间的关系反复查回。不跨层去重时, + // 小明→小红 会在 depth=2 出现两次,memory_recall 的 10 条关系预算 + // 被同一句话刷屏、真正的新关系(小红→小刚)被截断。 + seen := map[int64]int{} + for _, r := range result.Relations { + seen[r.ID]++ + } + for id, n := range seen { + if n > 1 { + t.Errorf("关系 id=%d 在深度遍历中重复 %d 次", id, n) + } + } + if len(result.Relations) != 2 { + t.Errorf("depth=2 应得 2 条关系(小明→小红、小红→小刚),实际 %d", len(result.Relations)) + } } func TestPurgeHard(t *testing.T) {