Files
HomeAgent/internal/agent/core/memorypass_test.go
JianFeeeee 3a780384d0 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 插件模块测试通过。
2026-09-14 23:32:41 +08:00

108 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}