mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
上一版场景是声明/派生的:调用方写 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 安全。
204 lines
6.8 KiB
Go
204 lines
6.8 KiB
Go
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)
|
||
}
|
||
}
|