refactor(memory): 裁剪与召回收敛到唯一入口 memoryPass

把「踢出去(prune)」与「取进来(recall)」从两处各写一遍,收敛为
memoryPass(query, trigger, prune, recall) 单一入口,统一:

- 同一份清洗后的 query(避免噪声带偏相关性打分);
- 同一次 token 预算与召回截断;
- 同一条带 trigger 的审计日志(谁、据什么触发了哪种操作)。

落地:
- 新增 memorypass.go:memoryPass + pruneByQuery(原 pruneOnInput 的执行体);
- pruneOnInput 只解析声明,执行委托 memoryPass;
- stepToolAfter 的裁剪/召回改为一次 memoryPass 调用(去掉重复的 topK 逻辑);
- 抽出 recallText,输入侧 buildTaskMemoryContext 与工具侧 recallTextFor 共用;
- 输入侧召回 query 改用 CleanInput(清洗文本),与裁剪侧同一语义;
- QQ qq_get_history 补齐声明 ContextPolicy=prune + RecallPolicy=auto
  (内容类工具:真实聊天正文既当轮用完即裁,又据正文召回)。

测试:新增 memorypass_test.go,锁死 no-op / 两轴同时生效 / 正交不互相触发 /
输入侧用清洗 query。go build/vet 干净,internal/... 全绿,qq 插件模块测试通过。
This commit is contained in:
JianFeeeee
2026-09-14 23:32:41 +08:00
parent 6547506704
commit fd03e8b905
6 changed files with 217 additions and 34 deletions

View File

@ -0,0 +1,107 @@
package core
import (
"path/filepath"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
// memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。
//
// 这一组测试锁死三件事:
// 1. 两个策略都不声明时是 no-op(不裁剪、不召回);
// 2. 同时声明时一次调用同时产出「归档数」与「召回文本」;
// 3. 单一策略只产出对应的那一个输出(正交,不互相触发)。
func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
a := &Agent{
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
}
out := a.memoryPass("咖啡", "test", false, false)
if out.Archived != 0 || out.RecallText != "" {
t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out)
}
}
func TestMemoryPass_PruneAndRecallTogether(t *testing.T) {
a := newMemoryPassAgent(t)
before := a.context.Len()
out := a.memoryPass("咖啡", "tool:test", true, true)
if out.Archived == 0 {
t.Fatal("声明 prune 应归档低相关事件")
}
if a.context.Len() >= before {
t.Fatalf("裁剪后上下文应变短:%d → %d", before, a.context.Len())
}
if !strings.Contains(out.RecallText, "【记忆索引】") {
t.Fatalf("声明 recall 应产出记忆索引文本,实际 %q", out.RecallText)
}
}
func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
// 只裁不召回:输出只有归档数。
onlyPrune := &Agent{
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
}
if out := onlyPrune.memoryPass("咖啡", "test", true, false); out.RecallText != "" {
t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText)
}
// 只召回不裁剪:输出只有召回文本,上下文条数不变。
onlyRecall := &Agent{
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
}
before := onlyRecall.context.Len()
out := onlyRecall.memoryPass("咖啡", "test", false, true)
if out.Archived != 0 {
t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived)
}
if onlyRecall.context.Len() != before {
t.Fatalf("只声明 recall 不应改变上下文条数:%d → %d", before, onlyRecall.context.Len())
}
}
// 输入侧召回必须用**清洗后**的 query(通道 Cleaner 的输出),与裁剪侧一致。
// 用无关原文 + 命中清洗文本做区分,锁死「用的是 CleanInput 而不是 Input」。
func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) {
a := &Agent{indexer: newTestIndexer(t, "咖啡", "张三")}
// CleanInput 命中实体、原文完全不相关 → 应召回(证明用了清洗文本)。
f := &TaskFrame{Evt: nil, CleanInput: "咖啡"}
if got := a.buildTaskMemoryContext(f, "zzz", 0); !strings.Contains(got, "【记忆索引】") {
t.Fatalf("应据清洗后的 query 召回,实际 %q", got)
}
// 清洗为空 → 回退原文;原文无关则不召回。
f2 := &TaskFrame{CleanInput: ""}
if got := a.buildTaskMemoryContext(f2, "zzz", 0); got != "" {
t.Fatalf("清洗为空且原文无关时不应召回,实际 %q", got)
}
}
// newMemoryPassAgent 造一个同时能做裁剪与召回的 agent(含 doc 记忆落点)。
func newMemoryPassAgent(t *testing.T) *Agent {
t.Helper()
return &Agent{
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
}
}
// newPruneableContext 造 n 条可被裁剪的上下文(最近 10 条受保护)。
func newPruneableContext(n int) *RelevanceContext {
ctx := NewRelevanceContext("", memory.NewStaticEmbedder(""))
for i := 0; i < n; i++ {
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "事件内容"})
}
return ctx
}