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 ./... 全绿。
This commit is contained in:
JianFeeeee
2026-09-15 09:37:29 +08:00
parent 0bb3191284
commit 246306a989
12 changed files with 429 additions and 74 deletions

View File

@ -2,6 +2,7 @@ package memory
import (
"os"
"strings"
"testing"
"time"
)
@ -201,3 +202,121 @@ func TestSceneRefDecay(t *testing.T) {
t.Errorf("刚用过的场景不该被衰减掉: %+v", left.Relations)
}
}
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
// - 主动:声明即建场景(不等第二次涌现),并把指纹喂给它
// - 被动:指纹聚类自己长出场景;声明场景学会特征后**即使没人再声明**
// 也能被相似度命中
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 第 1 轮:声明了 chan:qq。此刻还没有涌现场景 → Primary 必须兜底到声明场景
turn, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if turn.Primary != "chan:qq" {
t.Fatalf("首次交互应兜底到声明场景,得到 %q", turn.Primary)
}
if turn.Emergent {
t.Error("首次交互不该有涌现场景")
}
if len(turn.DeclaredCreated) != 1 || turn.DeclaredCreated[0] != "chan:qq" {
t.Errorf("声明即建场景(不等第二次涌现): %+v", turn.DeclaredCreated)
}
// 第 2 轮同类场面:涌现场景长出来,且它比声明场景**更优先**用于写入
turn2, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班表"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if !turn2.Emergent || !strings.HasPrefix(turn2.Primary, "auto:") {
t.Fatalf("第 2 轮应涌现出细粒度场景并优先: %+v", turn2)
}
if len(turn2.Keys) < 2 {
t.Fatalf("两条路都要进召回集合: %+v", turn2.Keys)
}
// 在声明场景里写上一条(模拟首次交互时写的记忆)
if _, _, err := g.Commit([]Triple{{
Subject: "群规", Relation: "禁止", Object: "Markdown排版", Confidence: 1.0,
Scenes: []string{"chan:qq"},
}}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// 在涌现场景里写上一条
if _, _, err := g.Commit([]Triple{{
Subject: "排班表", Relation: "格式", Object: "纯文本", Confidence: 1.0,
Scenes: []string{turn2.Primary},
}}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// **没人声明**的同场面提问:被动路按相似度命中涌现场景;
// 声明场景不在相似度空间里(否则它会吃掉被动路),靠声明/前缀路命中。
sig := mkSig("qq", "group_1027", "qq_get_message", "统计")
r, err := g.RecallBySituation(sig, 8)
if err != nil {
t.Fatalf("RecallBySituation: %v", err)
}
got := map[string]bool{}
for _, rel := range r.Relations {
got[rel.TargetName] = true
}
if !got["纯文本"] {
t.Errorf("涌现场景应被被动命中: %+v", r.Relations)
}
if got["Markdown排版"] {
t.Errorf("声明场景不该进相似度空间(会压死被动路): %+v", r.Relations)
}
// 声明场景走声明键,照样取回
byKey, err := g.RecallByScene([]string{"chan:qq"}, 8)
if err != nil {
t.Fatalf("RecallByScene: %v", err)
}
if len(byKey.Relations) != 1 || byKey.Relations[0].TargetName != "Markdown排版" {
t.Errorf("声明路应取回声明场景的记忆: %+v", byKey.Relations)
}
// 而完整的一轮(声明+涌现)两条路都进召回集合
full, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "统计"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if len(full.Keys) < 2 {
t.Errorf("两条路都应进召回集合: %+v", full.Keys)
}
// 声明键从自身解析特征(不含整轮指纹)
feats := FeaturesFromSceneKey("chan:qq/peer:group_1027")
if len(feats) != 2 || feats[0].Key() != "chan:qq" || feats[1].Key() != "peer:group_1027" {
t.Errorf("声明键特征解析失败: %+v", feats)
}
if len(FeaturesFromSceneKey("老大2026-09-04_12:27_qq私聊图片")) != 0 {
t.Error("无 kind:value 结构的键不该硬猜特征")
}
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
if _, err := g.EnsureScene("chan:qq", Situation{}); err != nil {
t.Fatalf("EnsureScene: %v", err)
}
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = 'chan:qq'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("声明场景应保持独立存在,得到 %d", n)
}
}
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
func TestEffectiveScenes(t *testing.T) {
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})
if len(got) != 2 || got[0] != "auto:a" || got[1] != "chan:qq" {
t.Errorf("合并去重保序失败: %v", got)
}
if effectiveScenes(Triple{}) != nil {
t.Error("无场景应返回 nil")
}
}