mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
把「踢出去(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 插件模块测试通过。
108 lines
4.0 KiB
Go
108 lines
4.0 KiB
Go
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
|
||
}
|