Files
HomeAgent/internal/memory/scene_emerge_test.go
JianFeeeee d4f9a12db0 feat(memory): 场景从「声明」改为「涌现」——场面指纹自己长成场景
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。
那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事,
后续类似场面自动唤起对应记忆」的要求重做。

机制(全部取自运行时可观察量,无需模型配合、无需人工标注):

- **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx /
  tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0),
  工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。
- **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。
  加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让
  一次偶然的话题重合把两个不同场面并成一个。
- **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景
  (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」,
  给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。
- **强化**:场景每次重现 strength+1、并入新特征。
- **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低
  ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。
- **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删;
  已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。

三个必须讲清的边界:
1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度,
   「工具调得多」会被误读成「这个场面更常出现」。
2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚),
   涌现是「这轮看起来像哪个场面」。两者都进召回。
3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景,
   模型不需要知道场景这回事。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例(核心证据):
- TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 →
  同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→
  强度随重现增长、特征多条。全程没有任何人声明过场景键。
- TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被
  自动唤起(含原句),无关场面不唤起。
- TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉,
  仍在重现的场景不受影响。
- TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
2026-09-15 09:27:39 +08:00

204 lines
6.8 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 memory
import (
"os"
"testing"
"time"
)
func mkSig(chanName, peer, tool string, topics ...string) Situation {
feats := []SituationFeature{{Kind: "chan", Value: chanName}}
if peer != "" {
feats = append(feats, SituationFeature{Kind: "peer", Value: peer})
}
if tool != "" {
feats = append(feats, SituationFeature{Kind: "tool", Value: tool})
}
feats = append(feats, SituationFeature{Kind: "part", Value: "morning"})
for _, t := range topics {
feats = append(feats, SituationFeature{Kind: "topic", Value: t})
}
return NewSituation(feats...)
}
// TestSceneEmergesFromRepetition 是这套机制的核心证据:
// 场景**没有人声明过**——同类场面重复出现时自己长出来。
func TestSceneEmergesFromRepetition(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 第 1 次:只登记足迹,不建场景(一次性的交互不是「场面」)
key, created, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班"))
if err != nil {
t.Fatalf("EnterScene: %v", err)
}
if key != "" || created {
t.Fatalf("首次出现不该长出场景,得到 key=%q created=%v", key, created)
}
// 第 2 次同类场面:场景长出来了
key, created, err = g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班表"))
if err != nil {
t.Fatalf("EnterScene: %v", err)
}
if key == "" || !created {
t.Fatalf("第 2 次同类场面应长出场景,得到 key=%q created=%v", key, created)
}
emergentKey := key
// 第 3 次:同样的场面、**不同的话题**——仍属于同一个场景(是场景,不是每轮一个键)
key3, created3, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "发版"))
if err != nil {
t.Fatalf("EnterScene: %v", err)
}
if created3 || key3 != emergentKey {
t.Fatalf("同场面不同话题应并入既有场景: key=%q created=%v want=%q", key3, created3, emergentKey)
}
// 另一个场面(换通道)不会被并进去,重复两次后自己长出一个
if k, c, _ := g.EnterScene(mkSig("webui", "", "", "排班")); k != "" && !c {
t.Fatalf("不同通道不应并进 QQ 场景: %q", k)
}
if k2, c2, _ := g.EnterScene(mkSig("webui", "", "", "排班")); k2 == "" || !c2 || k2 == emergentKey {
t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2)
}
scenes, err := g.EmergentScenes()
if err != nil {
t.Fatalf("EmergentScenes: %v", err)
}
if len(scenes) != 2 {
t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes)
}
// 强化QQ 场景被遇到 3 次2 次缔造 + 1 次并入)→ strength > 1
var qq SceneStat
for _, sc := range scenes {
if sc.Key == emergentKey {
qq = sc
}
}
if qq.Strength < 2 {
t.Errorf("场景强度应随重现增加,得到 %d", qq.Strength)
}
if qq.Features < 3 {
t.Errorf("场景应记住多个特征,得到 %d", qq.Features)
}
}
// TestSceneRecallsBySituationNotWording 钉住「类似场面自动唤起记忆」:
// 唤起靠场面相似,而不是措辞命中。
func TestSceneRecallsBySituationNotWording(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 让 QQ 场面长出场景
g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班"))
sceneKey, _, err := g.EnterScene(mkSig("qq", "group_1027", "qq_get_message", "排班表"))
if err != nil || sceneKey == "" {
t.Fatalf("场景未长出: %q %v", sceneKey, err)
}
// 在这个场面里写下的规则(与后面提问的措辞零重合)
if _, _, err := g.Commit([]Triple{
{Subject: "群规", Relation: "禁止", Object: "Markdown排版", Confidence: 1.0,
Scene: sceneKey, SentenceText: "本群只发纯文本"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// 换一种措辞、但同一个场面:应当自动唤起上面那条
r, err := g.RecallBySituation(mkSig("qq", "group_1027", "qq_get_message", "统计"), 8)
if err != nil {
t.Fatalf("RecallBySituation: %v", err)
}
if len(r.Relations) != 1 || r.Relations[0].TargetName != "Markdown排版" {
t.Fatalf("同场面应唤起记忆: %+v", r.Relations)
}
if r.Relations[0].SentenceText != "本群只发纯文本" {
t.Errorf("唤起时要带原句: %+v", r.Relations[0])
}
// 别的场面不该被唤起
other, err := g.RecallBySituation(mkSig("webui", "", "", "统计"), 8)
if err != nil {
t.Fatalf("RecallBySituation: %v", err)
}
if len(other.Relations) != 0 {
t.Errorf("无关场面不该唤起: %+v", other.Relations)
}
}
// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。
func TestSceneRefDecay(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "甲组", Relation: "是", Object: "乙组", Confidence: 1.0, Scene: "chan:qq"},
}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
var before float64
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&before); err != nil {
t.Fatal(err)
}
// 把时间推旧:模拟长期未重现
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
// 一个半衰期权重减半1.0 → 0.5),仍高于 floor保留
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
t.Fatalf("DecaySceneRefs: %v", err)
}
var after float64
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&after); err != nil {
t.Fatal(err)
}
if after >= before {
t.Errorf("一个半衰期后权重应减半: %v → %v", before, after)
}
// 再推旧一次:第二个半衰期后低于 floor关联已无信息量清掉
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
n, err := g.DecaySceneRefs(time.Hour, 0.4)
if err != nil {
t.Fatalf("DecaySceneRefs: %v", err)
}
if n != 3 {
t.Errorf("三个低权重引用应被清掉,得到 %d", n)
}
var left int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("衰减后不该剩下引用,得到 %d", left)
}
// 仍在重现的场景不受影响updated_at 新)
if _, _, err := g.Commit([]Triple{
{Subject: "丙组", Relation: "是", Object: "丁组", Confidence: 1.0, Scene: "chan:webui"},
}, "main", 0); err != nil {
t.Fatal(err)
}
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
t.Fatal(err)
}
if left, _ := g.RecallByScene([]string{"chan:webui"}, 8); len(left.Relations) != 1 {
t.Errorf("刚用过的场景不该被衰减掉: %+v", left.Relations)
}
}