diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go index 376a634..11c6f69 100644 --- a/cmd/memgc/main.go +++ b/cmd/memgc/main.go @@ -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 } diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 5cb69da..03c54b0 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -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() } diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index 3bd25b7..5d88f35 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -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) } diff --git a/internal/agent/core/lightprofile_test.go b/internal/agent/core/lightprofile_test.go index d6d387c..8adac9a 100644 --- a/internal/agent/core/lightprofile_test.go +++ b/internal/agent/core/lightprofile_test.go @@ -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) } diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index 209caeb..8da442b 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -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 +} diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go index ad1001c..2d30e2e 100644 --- a/internal/agent/core/scene_test.go +++ b/internal/agent/core/scene_test.go @@ -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) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index a2c717d..c6e3e95 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -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 } } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 86a147c..5098a1f 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -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 { diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index dfb8cb6..4e64659 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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) } diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 5c08321..35e1e81 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -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 同时参与唯一约束 diff --git a/internal/memory/scene.go b/internal/memory/scene.go index 58c10a5..bcc7b02 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -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"` } diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go new file mode 100644 index 0000000..c12be9c --- /dev/null +++ b/internal/memory/scene_emerge.go @@ -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() +} diff --git a/internal/memory/scene_emerge_test.go b/internal/memory/scene_emerge_test.go new file mode 100644 index 0000000..ca51c7d --- /dev/null +++ b/internal/memory/scene_emerge_test.go @@ -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) + } +}