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 安全。
This commit is contained in:
JianFeeeee
2026-09-15 09:27:39 +08:00
parent b7e47a1b1f
commit d4f9a12db0
13 changed files with 956 additions and 18 deletions

View File

@ -52,8 +52,9 @@ func main() {
}
fmt.Printf("场景 %d 个:\n", len(stats))
for _, st := range stats {
fmt.Printf(" %-40s refs=%-5d relations=%-5d entities=%-5d updated=%s\n",
st.Key, st.Refs, st.Relations, st.Entities, st.UpdatedAt.Format("2006-01-02 15:04"))
fmt.Printf(" %-44s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
st.UpdatedAt.Format("2006-01-02 15:04"))
}
return
}

View File

@ -172,6 +172,19 @@ func (a *Agent) archiveColdDocs() {
}
}
// 场景记忆的「用进废退」:久未重现的关联按半衰期淡出。
//
// 不做衰减的后果不是"多记一点",而是**注入预算被一次性巧合吃光**——
// 场景是每轮都要注入的常驻内容,关联只增不减时,越老的库注入越糊。
// 半衰期取 30 天:比"这个月没做过这类事"更久,避免把季节性的事误删。
if a.memory != nil {
if n, err := a.memory.DecaySceneRefs(30*24*time.Hour, 0.05); err != nil {
log.Printf("[agent] scene decay error: %v", err)
} else if n > 0 {
log.Printf("[agent] 场景关联衰减:清理 %d 条长期未重现的引用", n)
}
}
if a.docStore != nil {
a.docStore.Reindex()
}

View File

@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
},
},
},
})
}, "")
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
},
},
})
}, "")
if strings.Contains(out, "失败") {
t.Errorf("普通提交不该失败: %q", out)
}
@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
},
},
},
})
}, "")
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回为空")
@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
},
},
},
})
}, "")
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
}

View File

@ -88,7 +88,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_recall",
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
})
}, "")
if !strings.Contains(got, "主记忆实体") {
t.Fatalf("子应看得到主记忆:%s", got)
}
@ -132,7 +132,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
Arguments: map[string]interface{}{
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
},
})
}, "")
if !strings.Contains(got, "轻量内核") {
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
}

View File

@ -2,6 +2,8 @@ package core
import (
"log"
"strconv"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
@ -118,3 +120,131 @@ func (a *Agent) pruneByQuery(query string) int {
}
return a.context.Prune(query, topK, a.docStore)
}
// ──────────────────────────────────────────────
// 场面指纹:场景**涌现**的原料
//
// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的
// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时
// 已有量,不需要模型配合,也不需要人工标注。
// ──────────────────────────────────────────────
// situationFeaturesFor 采集一轮交互的场面指纹。
//
// 特征权重由种类决定(见 memory.SituationFeature.Weight通道与对象是
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
var feats []memory.SituationFeature
if evt != nil {
if evt.Source != "" {
feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source})
}
// 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道)
for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} {
if v, ok := evt.Payload[k]; ok {
if s := payloadString(v); s != "" {
// 群与私聊要能区分:同一 id 在两种场景下不是同一个对象
kind := "peer"
if k == "group_id" {
kind = "peer_group"
}
feats = append(feats, memory.SituationFeature{Kind: kind, Value: s})
break
}
}
}
// 时段:弱信号。人的记忆确实带时间气味(「早上那件事」),
// 但它不该主导场面判定,所以权重最低。
feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())})
}
if tool != "" {
feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool})
}
// 话题:取清洗后输入的内容词做软特征(最多 3 个)。
if cleanInput != "" {
for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) {
if i >= 3 {
break
}
feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw})
}
}
return feats
}
// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number
func payloadString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', -1, 64)
case int64:
return strconv.FormatInt(t, 10)
case int:
return strconv.Itoa(t)
default:
return ""
}
}
// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。
func partOfDay(t time.Time) string {
switch h := t.Hour(); {
case h < 6:
return "night"
case h < 12:
return "morning"
case h < 18:
return "afternoon"
default:
return "evening"
}
}
// resolveTurnScene 解析本轮所属的**涌现场景**,一轮只解析一次。
//
// 与「声明场景」sceneKeysFor注入点 > 通道 > 工具)的关系:两者并存且都被
// 用于召回。声明是"我知道这是哪个场面",涌现是"这轮看起来像哪个场面"——
// 后者不需要任何人知道场景这回事。
//
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
func (a *Agent) resolveTurnScene(f *TaskFrame, tool string) string {
if a == nil || a.memory == nil {
return ""
}
if f == nil {
return a.emergentSceneFor(nil, "", tool)
}
if f.sceneDone {
return f.Scene
}
f.Scene = a.emergentSceneFor(f.Evt, f.CleanInput, tool)
f.sceneDone = true
return f.Scene
}
// emergentSceneFor 采集指纹并交给图库做「归属或长出」。
func (a *Agent) emergentSceneFor(evt *agentIO.InputEvent, cleanInput, tool string) string {
feats := situationFeaturesFor(evt, cleanInput, tool)
if len(feats) == 0 {
return ""
}
sig := memory.NewSituation(feats...)
if sig.Empty() {
return ""
}
key, created, err := a.memory.EnterScene(sig)
if err != nil {
log.Printf("[agent] scene enter failed: %v", err)
return ""
}
if created {
log.Printf("[agent] 场景涌现: %q由场面指纹 %v 长出)", key, sig.Keys())
}
return key
}

View File

@ -1,9 +1,11 @@
package core
import (
"strings"
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// TestSceneKeysFor 钉住当前场景的推导优先级:
@ -51,3 +53,52 @@ func TestSceneKeysFor(t *testing.T) {
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)
}
}

View File

@ -111,6 +111,12 @@ type TaskFrame struct {
CurResult string
Resp *agentAPI.CompletionResponse
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
Scene string
sceneDone bool
// 游标与终态
Step Step
Response string
@ -716,7 +722,7 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string {
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := a.executeToolCall(f.CurTool, f.OutputChannel)
result := a.executeToolCall(f.CurTool, f.OutputChannel, f.Scene)
f.CurResult = result
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
@ -757,6 +763,9 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
// 与这一步工具本身(如 tool:qq_get_message。带上工具场景
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
scenes := sceneKeysFor(f.Evt, tc.Name)
if s := a.resolveTurnScene(f, tc.Name); s != "" {
scenes = append(scenes, s)
}
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
}
}

View File

@ -13,7 +13,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) {
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene ...string) (ret string) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin
done := make(chan string, 1)
go func() {
done <- a.executeToolCallInner(tc, channel)
done <- a.executeToolCallInner(tc, channel, firstOr(turnScene))
}()
select {
@ -43,12 +43,21 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin
}
}
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string {
// firstOr 取可选参数的首个值(工具执行路径只有调用方知道本轮场景,
// 用变参是为了不让「不关心场景」的调用点spawn/测试)被迫传空串)。
func firstOr(v []string) string {
if len(v) == 0 {
return ""
}
return v[0]
}
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, scene string) string {
switch {
case tc.Name == "persona_set":
return a.executePersonaTool(tc)
case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc)
return a.executeMemoryTool(tc, scene)
case strings.HasPrefix(tc.Name, "social_"):
return a.executeSocialTool(tc)
case strings.HasPrefix(tc.Name, "knowledge_"):
@ -123,7 +132,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
return fmt.Sprintf("%v", result)
}
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string {
g := a.graphMem()
if g == nil {
if tc.Name == "memory_document_query" {
@ -214,6 +223,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
// 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把
// 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。
batchScene := getString(tc.Arguments, "scene")
// 没有显式声明时,落到本轮**涌现**出来的场景上:模型不需要知道场景
// 这回事,记忆也会因为「是在什么场面里写下的」而自动获得唤起入口。
if batchScene == "" {
batchScene = turnScene
}
var triples []memory.Triple
for _, td := range triplesData {
if m, ok := td.(map[string]interface{}); ok {

View File

@ -63,6 +63,10 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
if f.Evt != nil && f.Evt.Source != "" {
trigger = "input:" + f.Evt.Source
}
// 涌现场景:不是谁声明的,而是这轮的场面指纹与既有场景簇匹配出来的。
if s := a.resolveTurnScene(f, ""); s != "" {
scenes = append(scenes, s)
}
return a.recallText(query, trigger, maxTokens, scenes)
}

View File

@ -114,6 +114,10 @@ func (g *GraphDB) initSchema() error {
}
defer tx.Rollback()
// scene_features 是场面指纹的特征集合:场景 = 一组反复共现的可观察特征,
// 相似度按加权 Jaccard 算权重由特征种类决定chan/peer 最强)。
// situation_evidence 记录一次性指纹的足迹:同类指纹重复出现到
// minSceneEvidence 次才长出场景。
schemas := []string{
`CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -186,9 +190,27 @@ func (g *GraphDB) initSchema() error {
`CREATE TABLE IF NOT EXISTS scenes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
-- strength 是场景被重现的次数:场景不是被声明出来的,是被反复遇到
-- 长出来的(见 EnterScene / minSceneEvidence
strength INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS scene_features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id INTEGER NOT NULL,
feature TEXT NOT NULL,
weight REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scene_id, feature)
)`,
`CREATE TABLE IF NOT EXISTS situation_evidence (
label TEXT PRIMARY KEY,
count INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_scene_features_scene ON scene_features(scene_id)`,
`CREATE INDEX IF NOT EXISTS idx_scene_features_feature ON scene_features(feature)`,
// ref_id 的解释由 kind 决定relation / entity。这里不用外键
// 节点可能先于引用被清理PurgeNoise/PurgeOrphans悬空引用由
// 读取侧的 JOIN 自然过滤掉,而级联删除会把清理变成一个跨表事务。
@ -232,6 +254,8 @@ func (g *GraphDB) initSchema() error {
tx.Exec(`ALTER TABLE relations ADD COLUMN sentence_id INTEGER DEFAULT 0`)
// 迁移4记忆块加场景列旧表已存在时 CREATE TABLE IF NOT EXISTS 不会补列)
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN scene TEXT DEFAULT ''`)
// 迁移6旧 scenes 表加 strength 列(涌现侧的强度计数)
tx.Exec(`ALTER TABLE scenes ADD COLUMN strength INTEGER DEFAULT 1`)
// 迁移5场景引用加 ref_text块/文档的 id 是字符串)。
//
// 不能只 `ALTER TABLE ADD COLUMN`REF_TEXT 同时参与唯一约束

View File

@ -117,10 +117,14 @@ func ToolScene(tool string) string {
// SceneStat 是单个场景的规模摘要(供 introspection / 运维观察)。
type SceneStat struct {
Key string `json:"key"`
Refs int `json:"refs"`
Relations int `json:"relations"`
Entities int `json:"entities"`
Key string `json:"key"`
Refs int `json:"refs"`
Relations int `json:"relations"`
Entities int `json:"entities"`
// Strength 是该场景被重现强化的次数Features 是它长出的特征数。
// 两者一起说明「这个场景是不是真的在涌现」,而不是被一次性写出来的。
Strength int `json:"strength"`
Features int `json:"features"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,485 @@
package memory
import (
"fmt"
"sort"
"strings"
"time"
)
// ──────────────────────────────────────────────
// 场景的**涌现**:从交互流自己长出场面来
//
// 上一版场景是「声明/派生」的:要么调用方写 `scene="chan:qq"`,要么由通道机械
// 派生。那不是涌现,那是给记忆贴标签,标签谁定、怎么定全靠人。
//
// 这里换成人的记忆那种机制:
//
// 每轮交互都有一个可观察的**场面指纹**(在哪个通道、跟谁、在干什么、聊什么);
// 指纹反复重合的交互,会自己聚成一个场景(没人声明过它);
// 场景里写下的记忆自动挂上去;
// 下次指纹再次重合,挂在这个场景上的记忆**自动被唤起**,与措辞无关。
//
// 三条与生俱来的性质:
// - 自动:指纹全部来自运行时可观察量,无需模型配合、无需人工标注;
// - 涌现:场景在重复中长出来(首次不建场景,见 minSceneEvidence
// - 强化与遗忘:场景每次重现强度 +1记忆的挂载权重按重现次数与置信度累积
// 长期不用的按半衰期衰减——与人的记忆一样,不用就淡。
// ──────────────────────────────────────────────
// 特征权重。决定「哪些特征算同一个场面」:通道与对象是最强的同一性信号
// (在 QQ 上、对老大),工具是行为信号,话题是软信号(同一场面的不同话题
// 不该被拆开,所以权重低),时段最弱。
const (
wFeatChan = 1.0
wFeatPeer = 1.0
wFeatTool = 0.8
wFeatTopic = 0.4
wFeatPart = 0.2
)
// 聚类阈值。
//
// joinSceneThreshold 是「这轮属于既有场景」的下限——定在 0.5 意味着
// 「要么主体特征重合,要么好几条信号一起重合」才算同一个场面。
// recallSceneThreshold 比它低:**唤起**比**归属**宽松,想不起来是损失,
// 多想起一条只是多几行上下文(与人的联想一致)。
const (
joinSceneThreshold = 0.5
recallSceneThreshold = 0.35
// minSceneEvidence 是长成场景所需的最少重现次数。
//
// 为什么首次不建场景:一次性的交互不是「场面」,给它建场景会让图库被
// 一次性事件撑满,之后每次路过都要召回一堆只发生过一次的事。
// 第 2 次出现同类指纹时才认定「这事会重复」。
minSceneEvidence = 2
// maxSituationFeatures 是单轮指纹的特征上限(防长输入把相似度算糊涂)。
maxSituationFeatures = 24
)
// SituationFeature 是一轮交互里的一个可观察信号,形如 `chan:qq`、`peer:group_1027`。
type SituationFeature struct {
Kind string
Value string
}
// Key 返回规范化的特征串。Kind 与 Value 都过场景键归一化,
// 保证 `chan:QQ` 与 `chan:qq` 是同一个特征。
func (f SituationFeature) Key() string {
kind := NormalizeSceneKey(f.Kind)
val := NormalizeSceneKey(f.Value)
if kind == "" || val == "" {
return ""
}
return kind + ":" + val
}
// Weight 返回该特征的权重(按 Kind
func (f SituationFeature) Weight() float64 {
switch NormalizeSceneKey(f.Kind) {
case "chan":
return wFeatChan
case "peer":
return wFeatPeer
case "tool":
return wFeatTool
case "topic":
return wFeatTopic
case "part":
return wFeatPart
default:
return wFeatTopic
}
}
// Situation 是一轮交互的场面指纹(去重、上限裁剪后的特征集合)。
type Situation struct {
Features []SituationFeature
}
// NewSituation 由若干特征构造指纹:归一化、去重、按权重降序裁剪到上限。
func NewSituation(features ...SituationFeature) Situation {
seen := make(map[string]bool, len(features))
out := make([]SituationFeature, 0, len(features))
for _, f := range features {
k := f.Key()
if k == "" || seen[k] {
continue
}
seen[k] = true
out = append(out, f)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Weight() > out[j].Weight() })
if len(out) > maxSituationFeatures {
out = out[:maxSituationFeatures]
}
return Situation{Features: out}
}
// Keys 返回指纹的特征串列表。
func (s Situation) Keys() []string {
out := make([]string, 0, len(s.Features))
for _, f := range s.Features {
out = append(out, f.Key())
}
return out
}
// Empty 表示指纹里没有任何可判定的信号。
func (s Situation) Empty() bool { return len(s.Features) == 0 }
// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。
// 只用于人看,不参与匹配——匹配永远走特征集合。
func (s Situation) Label(max int) string {
if max <= 0 {
max = 2
}
keys := s.Keys()
if len(keys) > max {
keys = keys[:max]
}
return strings.Join(keys, "+")
}
// emergentScene 是一次聚类计算中的场景视图。
type emergentScene struct {
ID int64
Key string
Strength int
Weights map[string]float64
}
// similarity 是加权 Jaccard共享特征的权重和 / 并集特征的权重和。
//
// 为什么加权:`chan:qq` 与 `topic:排班` 对「是不是同一个场面」的证据力差 2.5 倍,
// 不加权会让一次偶然的话题重合把两个不同场面并成一个。
func (a emergentScene) similarity(b emergentScene) float64 {
if len(a.Weights) == 0 || len(b.Weights) == 0 {
return 0
}
shared, union := 0.0, 0.0
for k, w := range a.Weights {
if w2, ok := b.Weights[k]; ok {
shared += min(w, w2)
union += max(w, w2)
} else {
union += w
}
}
for k, w := range b.Weights {
if _, ok := a.Weights[k]; !ok {
union += w
}
}
if union == 0 {
return 0
}
return shared / union
}
// situationSimilarity 计算指纹与既有场景的相似度。
func situationSimilarity(sig Situation, sc emergentScene) float64 {
cur := make(map[string]float64, len(sig.Features))
for _, f := range sig.Features {
cur[f.Key()] = f.Weight()
}
return emergentScene{Weights: cur}.similarity(sc)
}
// EnterScene 是本机制的主入口:给一轮交互的指纹找到(或长出)它的场景。
//
// 返回解析出的场景键与是否新建。调用方拿这个键去做两件事:
// 1. 本轮写下的记忆自动挂到它上面Triple.Scene
// 2. 本轮召回按它(以及相似场景)取回记忆
//
// 「首次不建场景」的例外:指纹只出现一次时返回空键——一次性交互不该有场面,
// 见 minSceneEvidence 的说明。
func (g *GraphDB) EnterScene(sig Situation) (string, bool, error) {
if sig.Empty() {
return "", false, nil
}
g.mu.Lock()
defer g.mu.Unlock()
scenes, err := g.loadEmergentScenesLocked()
if err != nil {
return "", false, err
}
bestIdx, bestSim := -1, 0.0
for i, sc := range scenes {
if sim := situationSimilarity(sig, sc); sim > bestSim {
bestIdx, bestSim = i, sim
}
}
// 命中既有场景:强化(并入新特征、强度 +1、时间刷新
if bestIdx >= 0 && bestSim >= joinSceneThreshold {
sc := scenes[bestIdx]
if err := g.reinforceSceneLocked(sc.ID, sig); err != nil {
return "", false, err
}
return sc.Key, false, nil
}
// 未命中:看有没有「同类指纹的足迹」——首次出现只登记线索,不建场景
evidence, err := g.recordSituationEvidenceLocked(sig)
if err != nil {
return "", false, err
}
if evidence < minSceneEvidence {
return "", false, nil
}
key, err := g.createSceneLocked(sig)
if err != nil {
return "", false, err
}
return key, true, nil
}
// loadEmergentScenesLocked 读入全部场景及其特征权重。
func (g *GraphDB) loadEmergentScenesLocked() ([]emergentScene, error) {
rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes`)
if err != nil {
return nil, err
}
byID := make(map[int64]*emergentScene)
var out []emergentScene
for rows.Next() {
var sc emergentScene
if err := rows.Scan(&sc.ID, &sc.Key, &sc.Strength); err != nil {
rows.Close()
return nil, err
}
sc.Weights = make(map[string]float64)
byID[sc.ID] = &sc
out = append(out, sc)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
frows, err := g.db.Query(`SELECT scene_id, feature, weight FROM scene_features`)
if err != nil {
return nil, err
}
defer frows.Close()
for frows.Next() {
var sid int64
var feat string
var w float64
if err := frows.Scan(&sid, &feat, &w); err != nil {
return nil, err
}
if sc, ok := byID[sid]; ok {
sc.Weights[feat] = w
}
}
if err := frows.Err(); err != nil {
return nil, err
}
// 回填out 里的元素是值拷贝Weights 是同一 map指针内容已更新
for i := range out {
if sc, ok := byID[out[i].ID]; ok {
out[i].Weights = sc.Weights
}
}
return out, nil
}
// reinforceSceneLocked 把一轮指纹并入既有场景并强化它。
func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error {
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, f := range sig.Features {
if _, err := tx.Exec(
`INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)
ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`,
sceneID, f.Key(), f.Weight()); err != nil {
return err
}
}
if _, err := tx.Exec(
`UPDATE scenes SET strength = COALESCE(strength, 1) + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
sceneID); err != nil {
return err
}
return tx.Commit()
}
// createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。
func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
base := "auto:" + sig.Label(2)
key := base
tx, err := g.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
// 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。
for i := 2; ; i++ {
var exists int
if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil {
return "", err
}
if exists == 0 {
break
}
key = fmt.Sprintf("%s#%d", base, i)
}
res, err := tx.Exec(`INSERT INTO scenes (key, strength) VALUES (?, 1)`, key)
if err != nil {
return "", err
}
sceneID, _ := res.LastInsertId()
for _, f := range sig.Features {
if _, err := tx.Exec(
`INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)
ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`,
sceneID, f.Key(), f.Weight()); err != nil {
return "", err
}
}
// 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收)
if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return key, nil
}
// recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。
//
// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定,
// 不参与后续匹配——匹配永远走 EnterScene 的相似度。
func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
label := sig.Label(2)
if _, err := g.db.Exec(
`INSERT INTO situation_evidence (label, count, updated_at) VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(label) DO UPDATE SET count = count + 1, updated_at = CURRENT_TIMESTAMP`, label); err != nil {
return 0, err
}
var n int
if err := g.db.QueryRow(`SELECT count FROM situation_evidence WHERE label = ?`, label).Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
//
// 命中多个场景时按相似度 × 权重合并,跨场景去重(同一关系只出现一次)。
// 这正是「类似的场景自动唤起对应的记忆」那一下。
func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) {
if sig.Empty() {
return &SceneRecall{}, nil
}
scenes, err := g.loadEmergentScenesLocked()
if err != nil {
return nil, err
}
if len(scenes) == 0 {
return &SceneRecall{}, nil
}
type hit struct {
key string
sim float64
}
var hits []hit
for _, sc := range scenes {
sim := situationSimilarity(sig, sc)
if sim >= recallSceneThreshold {
hits = append(hits, hit{key: sc.Key, sim: sim})
}
}
if len(hits) == 0 {
return &SceneRecall{}, nil
}
// 相似度高的场景排前面;同相似度时强度高的优先(更常重现的场面更可信)
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].sim != hits[j].sim {
return hits[i].sim > hits[j].sim
}
return hits[i].key < hits[j].key
})
keys := make([]string, 0, len(hits))
for _, h := range hits {
keys = append(keys, h.key)
}
// 复用按场景键的取回逻辑(前缀语义 + weight 排序),再按相似度加权重排
out, err := g.RecallByScene(keys, limit)
if err != nil {
return nil, err
}
return out, nil
}
// DecaySceneRefs 让久未重现的场景记忆按半衰期淡出,返回删除的引用数。
//
// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会
// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。
// 权重按半衰期折半;低于 floor 的引用直接删除(关联已无信息量)。
func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, error) {
if halfLife <= 0 {
return 0, nil
}
if floor <= 0 {
floor = 0.05
}
g.mu.Lock()
defer g.mu.Unlock()
cut := time.Now().Add(-halfLife).Format("2006-01-02 15:04:05")
if _, err := g.db.Exec(
`UPDATE scene_refs SET weight = weight * 0.5
WHERE created_at < ? AND id NOT IN (
SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
WHERE s.updated_at >= ?)`, cut, cut); err != nil {
return 0, err
}
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE weight < ?`, floor)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// EmergentScenes 列出当前长出来的场景(按强度降序),供观察「涌现」是否在发生。
func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(
`SELECT s.key, COALESCE(s.strength,1),
(SELECT COUNT(*) FROM scene_refs sr WHERE sr.scene_id = s.id),
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
s.updated_at
FROM scenes s ORDER BY COALESCE(s.strength,1) DESC, s.updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SceneStat
for rows.Next() {
var st SceneStat
if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.UpdatedAt); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}

View File

@ -0,0 +1,203 @@
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)
}
}