Files
HomeAgent/internal/agent/core/scene_test.go
JianFeeeee 49695c38f3 feat(memory): 场景双通道——主动声明与被动涌现并存,且互不吞噬
按「声明式的也要支持,相当于主动被动两条路」落实。此前两者只是恰好并存,
没有边界,实测会互相吃掉(下面的坑就是)。

- Triple.Scenes []string(多值):一轮写下的记忆**两条路都挂**。
  只挂一条会丢东西——只挂声明则细粒度唤起丢失,只挂涌现则首次交互
  (场景还没长出来)没有兜底。单值 Scene 保留兼容。
- TurnScene:Primary 用于写(优先涌现场景,首次退到声明场景兜底),
  Keys 是两条路的并集,用于召回(声明+涌动的场景一起进 RecallByScene)。
- EnterSceneWithHint:主动路 EnsureScene(声明即建场景,不等第二次),
  被动路 EnterScene(指纹聚类)。写侧由 executeToolCall 把本轮场景集合
  传给 memory_commit,模型不需要知道"场景"这回事。

踩到并修掉的坑(两条路互相吞噬):
  最初让声明场景也吸收**整轮指纹**,于是 chan:qq 的相似度永远是 1.0,
  把后续所有同类轮次全部吃掉 → 被动路再也长不出更细的场面,
  实测 turn2.Emergent=true 但 Primary 仍是 chan:qq、没有 auto: 场景。
  修法:给场景加 origin(declared/emergent):
  - 被动聚类只认 origin='emergent' 的场景(声明场景不进相似度空间);
  - 声明场景的特征**只从键自身解析**(chan:qq/peer:group_1 → {chan:qq, peer:group_1}),
    白名单 kind(chan/peer/peer_group/tool/topic/part),不猜——
    「老大2026-09-04_12:27_qq私聊图片」里的 12:27 也是 kind:value 形态,
    放进特征空间就是往相似度里灌垃圾(有测试钉住)。
  - 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),机制各归各。
- memgc -scene-stats 增加 [declared|emergent] 与 strength/features 两栏,
  可直接观察两条路各自在长什么。

新增/改写用例:
- TestDeclaredAndEmergentBothLearn:首次交互兜底到声明场景 → 第 2 轮长出
  细粒度涌现场景且**优先用于写入** → 声明场景不进相似度空间(防止压死被动路)
  但仍走声明键取回 → 两条路都进召回集合 → 声明键特征解析与白名单。
- TestEffectiveScenes:多值+单值合并去重保序。

go build/vet 干净,go test -count=1 ./... 全绿。
2026-09-15 09:37:29 +08:00

122 lines
3.9 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 (
"strings"
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// TestSceneKeysFor 钉住当前场景的推导优先级:
// 注入点显式声明 > 通道 > 工具;并列命中且去重。
func TestSceneKeysFor(t *testing.T) {
// 通道 + 工具:两个都能独立成立的触发条件,都要带上
got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message")
want := []string{"chan:qq", "tool:qq_get_message"}
if len(got) != len(want) {
t.Fatalf("sceneKeysFor = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("sceneKeysFor[%d] = %q, want %q", i, got[i], want[i])
}
}
// 注入点显式声明排最前;归一化生效;重复声明去重
evt := &agentIO.InputEvent{
Source: "QQ",
Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "},
}
got = sceneKeysFor(evt, "")
if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" {
t.Errorf("显式声明应排最前且通道场景归一: %v", got)
}
// 数组形式声明
evt = &agentIO.InputEvent{
Source: "webui",
Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}},
}
got = sceneKeysFor(evt, "")
if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" {
t.Errorf("数组声明未生效: %v", got)
}
// nil 事件不 panic
if got := sceneKeysFor(nil, ""); len(got) != 0 {
t.Errorf("nil 事件应无场景: %v", got)
}
// 未声明的 payload 键不影响
evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}}
if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" {
t.Errorf("无 scene 声明时应只有通道场景: %v", got)
}
}
// TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量,
// 不需要模型配合也不需要人工标注。
func TestSituationFeaturesFor(t *testing.T) {
evt := &agentIO.InputEvent{
Source: "QQ",
Payload: map[string]interface{}{"group_id": float64(1027993713)},
}
feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
kinds := map[string]int{}
for _, f := range feats {
kinds[f.Kind]++
}
if kinds["chan"] != 1 || kinds["peer_group"] != 1 || kinds["tool"] != 1 || kinds["part"] != 1 {
t.Fatalf("必备维度缺失: %+v", feats)
}
if kinds["topic"] == 0 {
t.Errorf("话题软特征缺失: %+v", feats)
}
if kinds["topic"] > 3 {
t.Errorf("话题最多 3 个,得到 %d", kinds["topic"])
}
sig := memory.NewSituation(feats...)
keys := sig.Keys()
// 归一化 + 数值 id 的转换
found := false
for _, k := range keys {
if k == "chan:qq" {
found = true
}
}
if !found {
t.Errorf("通道特征未归一化: %v", keys)
}
for _, k := range keys {
if strings.Contains(k, "peer_group:1027993713") {
found = true
}
}
if !found {
t.Errorf("数值型 group_id 未转成特征: %v", keys)
}
// 无事件时不 panic且只有工具特征时也成立
if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
}
}
// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」:
// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。
func TestTurnSceneKeysBothPaths(t *testing.T) {
// 声明的通道场景与工具场景都在,涌现键(若有)追加在后
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}}
got := sceneKeysFor(evt, "qq_get_message")
want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"}
if len(got) != len(want) {
t.Fatalf("声明侧场景数不对: %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("声明侧[%d] = %q want %q", i, got[i], want[i])
}
}
}