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通道语音输入 等仍在其后。
142 lines
5.6 KiB
Go
142 lines
5.6 KiB
Go
package core
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// 这一组测试锁死「默认召回、可显式关闭」这条语义。
|
||
//
|
||
// 与 prune 刻意相反:裁剪是破坏性的、默认关;召回是只读增量、默认开。
|
||
// 两者正交,一根 ContextPolicy 表达不了 2×2 的组合(只召回不裁剪 / 只裁不召回)。
|
||
func TestRecallDeclared_DefaultsToRecall(t *testing.T) {
|
||
m := agentIO.NewIOManager()
|
||
a := &Agent{io: m}
|
||
|
||
// 没有任何声明 → 默认召回(保持既有"每条输入都召回"的行为)。
|
||
if !a.recallDeclared(&agentIO.InputEvent{Source: "unknown", Payload: map[string]interface{}{}}) {
|
||
t.Fatal("未声明的输入默认必须召回")
|
||
}
|
||
// 通道注册了但没设 RecallPolicy → 仍默认召回。
|
||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
|
||
if !a.recallDeclared(&agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}) {
|
||
t.Fatal("ChannelDef 未设 RecallPolicy 应默认召回")
|
||
}
|
||
// nil 事件不能 panic,且按默认召回。
|
||
if !a.recallDeclared(nil) {
|
||
t.Fatal("nil 事件应默认召回")
|
||
}
|
||
}
|
||
|
||
func TestRecallDeclared_ChannelOptOut(t *testing.T) {
|
||
m := agentIO.NewIOManager()
|
||
m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||
m.RegisterInputChannel("talk", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto})
|
||
a := &Agent{io: m}
|
||
|
||
if a.recallDeclared(&agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}) {
|
||
t.Fatal("通道声明 none 不应召回")
|
||
}
|
||
if !a.recallDeclared(&agentIO.InputEvent{Source: "talk", Payload: map[string]interface{}{}}) {
|
||
t.Fatal("通道声明 auto 应召回")
|
||
}
|
||
}
|
||
|
||
// 注入点声明优先于通道定义:同一通道下的不同注入可以有不同意图。
|
||
func TestRecallDeclared_InjectionOverridesChannel(t *testing.T) {
|
||
m := agentIO.NewIOManager()
|
||
a := &Agent{io: m}
|
||
m.RegisterInputChannel("qq", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||
|
||
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{
|
||
"recall_policy": pubsdk.RecallPolicyAuto,
|
||
}}
|
||
if !a.recallDeclared(evt) {
|
||
t.Fatal("注入点声明 auto 应覆盖通道的 none")
|
||
}
|
||
|
||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyAuto})
|
||
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
|
||
"recall_policy": pubsdk.RecallPolicyNone,
|
||
}}
|
||
if a.recallDeclared(evt) {
|
||
t.Fatal("注入点声明 none 应覆盖通道的 auto")
|
||
}
|
||
}
|
||
|
||
// buildTaskMemoryContext 在声明 none 时必须返回空串(不注入记忆索引)。
|
||
func TestBuildTaskMemoryContext_RespectsPolicy(t *testing.T) {
|
||
m := agentIO.NewIOManager()
|
||
m.RegisterInputChannel("meta", pubsdk.ChannelDef{RecallPolicy: pubsdk.RecallPolicyNone})
|
||
a := &Agent{io: m, indexer: newTestIndexer(t, "咖啡", "张三")}
|
||
|
||
f := &TaskFrame{Evt: &agentIO.InputEvent{Source: "meta", Payload: map[string]interface{}{}}}
|
||
if got := a.buildTaskMemoryContext(f, "咖啡", 0); got != "" {
|
||
t.Fatalf("声明 none 时不应注入记忆,实际 %q", got)
|
||
}
|
||
|
||
f2 := &TaskFrame{Evt: &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{}}}
|
||
if got := a.buildTaskMemoryContext(f2, "咖啡", 0); !strings.Contains(got, "【记忆索引】") {
|
||
t.Fatalf("默认应注入记忆索引,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// 工具触发的召回:以(清洗后的)工具输出为 query,产出可注入的记忆文本。
|
||
func TestRecallTextFor_UsesQuery(t *testing.T) {
|
||
a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")}
|
||
got := a.recallTextFor("咖啡", "tool:test", nil)
|
||
if !strings.Contains(got, "【记忆索引】") {
|
||
t.Fatalf("应产出记忆索引文本,实际 %q", got)
|
||
}
|
||
// 空 query 或无 indexer 时不产出、不 panic。
|
||
if got := a.recallTextFor("", "tool:test", nil); got != "" {
|
||
t.Fatalf("空 query 应返回空串,实际 %q", got)
|
||
}
|
||
if got := (&Agent{}).recallTextFor("咖啡", "tool:test", nil); got != "" {
|
||
t.Fatalf("无 indexer 应返回空串,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// 召回文本以 system 消息挂在末尾;同一任务内多次触发是**替换**而非累加。
|
||
func TestAppendOrReplaceRecall(t *testing.T) {
|
||
msgs := []agentAPI.Message{{Role: "user", Content: "hi"}}
|
||
msgs = appendOrReplaceRecall(msgs, "第一段")
|
||
if len(msgs) != 2 || msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "第一段") {
|
||
t.Fatalf("首次应追加一条 system 召回消息,实际 %+v", msgs)
|
||
}
|
||
msgs = appendOrReplaceRecall(msgs, "第二段")
|
||
if len(msgs) != 2 {
|
||
t.Fatalf("再次触发应替换而非累加,实际 %d 条", len(msgs))
|
||
}
|
||
if !strings.Contains(msgs[1].Content, "第二段") || strings.Contains(msgs[1].Content, "第一段") {
|
||
t.Fatalf("替换后应只含最新召回,实际 %q", msgs[1].Content)
|
||
}
|
||
if msgs = appendOrReplaceRecall(msgs, ""); len(msgs) != 2 {
|
||
t.Fatalf("空召回不应改变消息,实际 %d 条", len(msgs))
|
||
}
|
||
}
|
||
|
||
// newTestIndexer 造一个只含给定实体的图记忆 + 已同步的索引器。
|
||
func newTestIndexer(t *testing.T, subject, object string) *memory.Indexer {
|
||
t.Helper()
|
||
db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db"))
|
||
if err != nil {
|
||
t.Fatalf("NewGraphDB: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
if _, _, err := db.Commit([]memory.Triple{{Subject: subject, Relation: "喜欢", Object: object}}, "s", 0); err != nil {
|
||
t.Fatalf("Commit: %v", err)
|
||
}
|
||
idx := memory.NewIndexer(db)
|
||
if err := idx.Sync(); err != nil {
|
||
t.Fatalf("Sync: %v", err)
|
||
}
|
||
return idx
|
||
}
|