Files
HomeAgent/internal/memory/scene_emerge_test.go
JianFeeeee acc94723fd fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
2026-09-15 10:20:40 +08:00

349 lines
13 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"
"strings"
"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.SceneStats()
if err != nil {
t.Fatalf("SceneStats: %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 钉住「用进废退」:久未重现的关联会淡出并被清掉。
//
// 半衰期的语义是「每个引用至多每 halfLife 衰减一次」:衰减计时起点在
// scene_refs.decayed_at 上,同一半衰期内重复跑心跳不会再砍。
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)
}
// 把时间推旧:模拟长期未重现(上次衰减也在同一时刻)。用 SQLite 的
// datetime('now') 与 CURRENT_TIMESTAMP 同一时间基准UTC
if _, err := g.db.Exec(`UPDATE scene_refs
SET created_at = datetime('now','-48 hours'), decayed_at = datetime('now','-48 hours')`); err != nil {
t.Fatal(err)
}
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = datetime('now','-48 hours')`); 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)
}
// 同一半衰期内再跑decayed_at 已刷新,不该再砍一次
// 否则心跳频率就成了实际半衰期30 天的关联几小时就被清空)
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
t.Fatalf("DecaySceneRefs: %v", err)
}
var same float64
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&same); err != nil {
t.Fatal(err)
}
if same != after {
t.Errorf("同一半衰期内不该重复衰减: %v → %v", after, same)
}
// 进入第二个半衰期:再推旧 decayed_at权重低于 floor清掉
if _, err := g.db.Exec(`UPDATE scene_refs SET decayed_at = datetime('now','-48 hours')`); 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)
}
}
// 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"); 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)
}
}
// TestPeerGroupWeight 钉住群身份是**强**同一性信号peer_group 与 peer 同为
// wFeatPeer。漏掉 peer_group 会让它在 Weight 里落到 default话题级 0.4
// 群聊场面被降级成软信号。
func TestPeerGroupWeight(t *testing.T) {
if got := (SituationFeature{Kind: "peer_group", Value: "group_1"}).Weight(); got != wFeatPeer {
t.Errorf("peer_group 权重应为 %v得到 %v", wFeatPeer, got)
}
if got := (SituationFeature{Kind: "peer", Value: "user_1"}).Weight(); got != wFeatPeer {
t.Errorf("peer 权重应为 %v得到 %v", wFeatPeer, got)
}
}
// 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")
}
}