diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go index 11c6f69..eb1de42 100644 --- a/cmd/memgc/main.go +++ b/cmd/memgc/main.go @@ -52,8 +52,8 @@ func main() { } fmt.Printf("场景 %d 个:\n", len(stats)) for _, st := range stats { - 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, + fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n", + st.Origin, 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/inputunify_test.go b/internal/agent/core/inputunify_test.go index 5d88f35..49e4079 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) { }, }, }, - }, "") + }, nil) if !strings.Contains(out, "关联") { t.Errorf("返回值应告知模型媒体已关联: %q", out) } @@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) { map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"}, }, }, - }, "") + }, nil) if strings.Contains(out, "失败") { t.Errorf("普通提交不该失败: %q", out) } @@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) { }, }, }, - }, "") + }, nil) res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "") if len(res.Relations) == 0 { t.Fatal("召回为空") @@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) { }, }, }, - }, "") + }, nil) 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 8adac9a..5eed9df 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": "主记忆实体,子独有实体"}, - }, "") + }, nil) 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{}{}, }, - }, "") + }, nil) 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 8da442b..a2da104 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -205,46 +205,56 @@ func partOfDay(t time.Time) string { } } -// resolveTurnScene 解析本轮所属的**涌现场景**,一轮只解析一次。 +// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**: // -// 与「声明场景」(sceneKeysFor:注入点 > 通道 > 工具)的关系:两者并存且都被 -// 用于召回。声明是"我知道这是哪个场面",涌现是"这轮看起来像哪个场面"—— -// 后者不需要任何人知道场景这回事。 +// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入 +// 本轮指纹(声明场景因此慢慢学会自己认自己) +// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景 +// +// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景 +// 兜底),Keys 用于**读**(两条路的并集,去重)。 // // 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记 // 一次强度,"工具调得多"会被误读成"这个场面更常出现"。 -func (a *Agent) resolveTurnScene(f *TaskFrame, tool string) string { +func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene { + var out memory.TurnScene if a == nil || a.memory == nil { - return "" + return out } - if f == nil { - return a.emergentSceneFor(nil, "", tool) + if f != nil && f.sceneDone { + return f.turnScene } - 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 "" - } + declared := sceneKeysFor(evtOf(f), tool) + feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool) sig := memory.NewSituation(feats...) - if sig.Empty() { - return "" - } - key, created, err := a.memory.EnterScene(sig) + + turn, err := a.memory.EnterSceneWithHint(sig, declared) if err != nil { log.Printf("[agent] scene enter failed: %v", err) + // 出错时至少把声明场景交给召回,不让整条召回链一起失效 + turn = memory.TurnScene{Keys: declared} + if len(declared) > 0 { + turn.Primary = declared[0] + } + } + if turn.Emergent { + log.Printf("[agent] 场景涌现/命中: %q(指纹 %v)", turn.Primary, sig.Keys()) + } else if len(turn.DeclaredCreated) > 0 { + log.Printf("[agent] 声明场景成立: %v(指纹 %v)", turn.DeclaredCreated, sig.Keys()) + } + if f != nil { + f.turnScene = turn + f.Scene = turn.Primary + f.sceneDone = true + } + return turn +} + +// cleanInputOf 安全取出清洗后输入(f 为 nil 时为空)。 +func cleanInputOf(f *TaskFrame) string { + if f == nil { return "" } - if created { - log.Printf("[agent] 场景涌现: %q(由场面指纹 %v 长出)", key, sig.Keys()) - } - return key + return f.CleanInput } diff --git a/internal/agent/core/scene_test.go b/internal/agent/core/scene_test.go index 2d30e2e..8170037 100644 --- a/internal/agent/core/scene_test.go +++ b/internal/agent/core/scene_test.go @@ -102,3 +102,20 @@ func TestSituationFeaturesFor(t *testing.T) { t.Errorf("仅工具场景应有 1 个特征: %+v", feats) } } + +// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」: +// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。 +func TestTurnSceneKeysBothPaths(t *testing.T) { + // 声明的通道场景与工具场景都在,涌现键(若有)追加在后 + evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}} + got := sceneKeysFor(evt, "qq_get_message") + want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"} + if len(got) != len(want) { + t.Fatalf("声明侧场景数不对: %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("声明侧[%d] = %q want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index c6e3e95..effeae8 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -30,6 +30,7 @@ import ( agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) @@ -115,6 +116,7 @@ type TaskFrame struct { // sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景 // 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。 Scene string + turnScene memory.TurnScene sceneDone bool // 游标与终态 @@ -722,7 +724,10 @@ 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, f.Scene) + // 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路), + // 而工具步不一定走到下面的召回分支,所以不能等那里再解析。 + turn := a.resolveTurnScenes(f, f.CurTool.Name) + result := a.executeToolCall(f.CurTool, f.OutputChannel, turn.Keys...) 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)) @@ -762,9 +767,11 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { // 工具路召回的场景有两个来源:本轮输入的场面(如 chan:qq) // 与这一步工具本身(如 tool:qq_get_message)。带上工具场景, // 才能让「凡是要回 QQ 消息」这类规则在该步被取回。 + // 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景 scenes := sceneKeysFor(f.Evt, tc.Name) - if s := a.resolveTurnScene(f, tc.Name); s != "" { - scenes = append(scenes, s) + turn := a.resolveTurnScenes(f, tc.Name) + for _, k := range turn.Keys { + scenes = append(scenes, k) } 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 5098a1f..9e7e4c5 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, turnScene ...string) (ret string) { +func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes ...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, turnScene done := make(chan string, 1) go func() { - done <- a.executeToolCallInner(tc, channel, firstOr(turnScene)) + done <- a.executeToolCallInner(tc, channel, turnScenes) }() select { @@ -43,21 +43,12 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene } } -// 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 { +func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) string { switch { case tc.Name == "persona_set": return a.executePersonaTool(tc) case strings.HasPrefix(tc.Name, "memory_"): - return a.executeMemoryTool(tc, scene) + return a.executeMemoryTool(tc, turnScenes) case strings.HasPrefix(tc.Name, "social_"): return a.executeSocialTool(tc) case strings.HasPrefix(tc.Name, "knowledge_"): @@ -132,7 +123,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, scene return fmt.Sprintf("%v", result) } -func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string { +func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string { g := a.graphMem() if g == nil { if tc.Name == "memory_document_query" { @@ -223,10 +214,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string // 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把 // 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。 batchScene := getString(tc.Arguments, "scene") - // 没有显式声明时,落到本轮**涌现**出来的场景上:模型不需要知道场景 - // 这回事,记忆也会因为「是在什么场面里写下的」而自动获得唤起入口。 + // 写侧的场景是**两条路都挂**: + // 显式声明(模型在参数里点名)优先; + // 否则挂本轮解析出的场景集合——主动声明的 + 被动涌现的。 + // 只挂一条会丢东西:只挂声明则细粒度唤起丢失,只挂涌现则首次交互 + // (场景还没长出来)没有兜底。 + var batchScenes []string if batchScene == "" { - batchScene = turnScene + batchScenes = turnScenes } var triples []memory.Triple for _, td := range triplesData { @@ -241,6 +236,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string if t.Scene == "" { t.Scene = batchScene } + if len(t.Scenes) == 0 { + t.Scenes = batchScenes + } // 模型显式关联的媒体:结构化字段随三元组一起提交, // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边—— // 不再把 marker 写进句子文本。 diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 4e64659..2cd1310 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -63,9 +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) + // 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集。 + turn := a.resolveTurnScenes(f, "") + for _, k := range turn.Keys { + scenes = append(scenes, k) } return a.recallText(query, trigger, maxTokens, scenes) } diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 35e1e81..be5e581 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -59,6 +59,14 @@ type Triple struct { // 与这条关系都会被挂到这个场景上,场景重现时按场景召回。 // 约定见 memory.NormalizeSceneKey:`chan:qq`、`chan:qq/peer:group_123`。 Scene string `json:"scene,omitempty"` + // Scenes 是同一批多场景挂载(可空):主动**声明**的场景(如 chan:qq) + // 与被动**涌现**出来的场景(如 auto:chan:qq+peer_group:1027)可以同时挂。 + // + // 为什么要两条都挂:声明路是"我知道这是哪个场面",稳定、可读、可兜底; + // 涌现路是"这轮看起来像哪个场面",细粒度、不需要任何人声明。 + // 只挂一条的代价:只挂声明则细粒度唤起丢失,只挂涌现则首次交互(场景 + // 还没长出来)没有兜底。 + Scenes []string `json:"scenes,omitempty"` } type GraphDB struct { @@ -193,6 +201,11 @@ func (g *GraphDB) initSchema() error { -- strength 是场景被重现的次数:场景不是被声明出来的,是被反复遇到 -- 长出来的(见 EnterScene / minSceneEvidence)。 strength INTEGER DEFAULT 1, + -- origin 区分两条路:'declared' 是主动声明出来的(键由人/插件给, + -- 召走精确+前缀匹配),'emergent' 是被动涌现的(召走相似度)。 + -- 两者刻意**分开**参与匹配:若让声明场景也吸收整轮指纹, + -- 它会在相似度上压过一切,被动路就再也长不出更细的场面了。 + origin TEXT NOT NULL DEFAULT 'emergent', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, @@ -256,6 +269,12 @@ func (g *GraphDB) initSchema() error { 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`) + // 迁移7:旧 scenes 表加 origin。既有行都是声明/存量引导来的(建表时还没有 + // 涌现机制),标成 declared;新建的涌现场景在 createSceneLocked 里写 emergent。 + if !columnExists(tx, "scenes", "origin") { + tx.Exec(`ALTER TABLE scenes ADD COLUMN origin TEXT NOT NULL DEFAULT 'emergent'`) + tx.Exec(`UPDATE scenes SET origin = 'declared'`) + } // 迁移5:场景引用加 ref_text(块/文档的 id 是字符串)。 // // 不能只 `ALTER TABLE ADD COLUMN`:REF_TEXT 同时参与唯一约束 @@ -489,12 +508,14 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe } } - // 场景引用:写完关系立即把「关系 + 两端实体」挂到这个场景上。 - // 同一事务内完成,避免出现「关系写进去了但场景引用丢了」—— - // 那会让这条记忆在后来的场景里永远召不回来,且无声无息。 - if t.Scene != "" && relID != 0 { - if err := tagSceneTx(tx, t.Scene, relID, []int64{sourceID, targetID}, confidence); err != nil { - return nil, 0, 0, err + // 场景引用:写完关系立即把「关系 + 两端实体」挂到**每个**场景上 + // (主动声明的 + 被动涌现的)。同一事务内完成,避免出现「关系写进去了 + // 但场景引用丢了」——那会让这条记忆在后来的场景里永远召不回来,且无声无息。 + if relID != 0 { + for _, sc := range effectiveScenes(t) { + if err := tagSceneTx(tx, sc, relID, []int64{sourceID, targetID}, confidence); err != nil { + return nil, 0, 0, err + } } } } @@ -1317,3 +1338,28 @@ func columnExists(tx *sql.Tx, table, column string) bool { } return false } + +// effectiveScenes 合并一条三元组的场景键(Scenes 多值 + Scene 单值),去重且保序。 +// +// 单值 Scene 保留是为了兼容既有调用方与 memory_commit 的模型参数; +// 多值 Scenes 是"声明 + 涌现"两条路同时挂载的载体。 +func effectiveScenes(t Triple) []string { + if len(t.Scenes) == 0 && t.Scene == "" { + return nil + } + seen := make(map[string]bool, len(t.Scenes)+1) + out := make([]string, 0, len(t.Scenes)+1) + add := func(k string) { + k = NormalizeSceneKey(k) + if k == "" || seen[k] { + return + } + seen[k] = true + out = append(out, k) + } + for _, k := range t.Scenes { + add(k) + } + add(t.Scene) + return out +} diff --git a/internal/memory/scene.go b/internal/memory/scene.go index ab564f6..e36226f 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -123,8 +123,10 @@ type SceneStat struct { Entities int `json:"entities"` // Strength 是该场景被重现(强化)的次数;Features 是它长出的特征数。 // 两者一起说明「这个场景是不是真的在涌现」,而不是被一次性写出来的。 - Strength int `json:"strength"` - Features int `json:"features"` + Strength int `json:"strength"` + Features int `json:"features"` + // Origin 是这条场景来自哪条路:declared(主动声明)或 emergent(被动涌现)。 + Origin string `json:"origin"` UpdatedAt time.Time `json:"updated_at"` } @@ -477,6 +479,7 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) { SUM(CASE WHEN sr.kind = 'entity' THEN 1 ELSE 0 END), COALESCE(s.strength, 1), (SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id), + COALESCE(s.origin, 'emergent'), s.updated_at FROM scenes s LEFT JOIN scene_refs sr ON sr.scene_id = s.id GROUP BY s.id ORDER BY COUNT(sr.id) DESC, s.key`) @@ -489,7 +492,7 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) { for rows.Next() { var st SceneStat var rels, ents sql.NullInt64 - if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.Strength, &st.Features, &st.UpdatedAt); err != nil { + if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.Strength, &st.Features, &st.Origin, &st.UpdatedAt); err != nil { return nil, err } st.Relations = int(rels.Int64) diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go index c12be9c..5e230a4 100644 --- a/internal/memory/scene_emerge.go +++ b/internal/memory/scene_emerge.go @@ -1,6 +1,7 @@ package memory import ( + "database/sql" "fmt" "sort" "strings" @@ -238,9 +239,14 @@ func (g *GraphDB) EnterScene(sig Situation) (string, bool, error) { return key, true, nil } -// loadEmergentScenesLocked 读入全部场景及其特征权重。 +// loadEmergentScenesLocked 读入参与**被动聚类**的场景(origin='emergent')及其特征权重。 +// +// 为什么不带上声明场景:声明场景若也进相似度空间,它一旦吸收了整轮指纹就会 +// 以接近 1.0 的相似度吃掉后续所有同类轮次,被动路再也长不出更细的场面。 +// 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),各有各的机制。 func (g *GraphDB) loadEmergentScenesLocked() ([]emergentScene, error) { - rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes`) + rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes + WHERE COALESCE(origin, 'emergent') = 'emergent'`) if err != nil { return nil, err } @@ -338,7 +344,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) { key = fmt.Sprintf("%s#%d", base, i) } - res, err := tx.Exec(`INSERT INTO scenes (key, strength) VALUES (?, 1)`, key) + res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key) if err != nil { return "", err } @@ -467,7 +473,7 @@ func (g *GraphDB) EmergentScenes() ([]SceneStat, error) { `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 + COALESCE(s.origin, 'emergent'), s.updated_at FROM scenes s ORDER BY COALESCE(s.strength,1) DESC, s.updated_at DESC`) if err != nil { return nil, err @@ -476,10 +482,158 @@ func (g *GraphDB) EmergentScenes() ([]SceneStat, error) { 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 { + if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.Origin, &st.UpdatedAt); err != nil { return nil, err } out = append(out, st) } return out, rows.Err() } + +// TurnScene 是一轮交互解析出来的场景集合。 +type TurnScene struct { + // Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、 + // 与措辞无关),没有(首次出现、场景还没长出来)时退到第一个声明场景。 + Primary string + // Keys 是声明场景 + 涌现场景的全集(去重保序),供召回并集使用。 + Keys []string + // Emergent 标记 Primary 是否来自涌现。 + Emergent bool + // DeclaredCreated 是本次**新建**的声明场景(此前不存在,因声明而成立)。 + DeclaredCreated []string +} + +// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。 +// +// 主动路(declaredKeys 非空):确保这些场景存在,并把本轮的场面指纹喂给它, +// 强度 +1。这样声明出来的场景会**慢慢学会自己认自己**——同一个场面以后 +// 即使没人声明,也能被 RecallBySituation 按相似度命中。 +// 声明即建场景,不等第二次:人明确说了"这是哪个场面",就不该再等它自己涌现。 +// +// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时 +// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底—— +// 这正是"只挂一条会丢东西"的那一半。 +func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (TurnScene, error) { + out := TurnScene{} + + // 主动路:声明场景存在化 + 学特征 + 强化 + seen := make(map[string]bool) + for _, raw := range declaredKeys { + key := NormalizeSceneKey(raw) + if key == "" || seen[key] { + continue + } + seen[key] = true + learned, err := g.EnsureScene(key, sig) + if err != nil { + return out, err + } + if learned { + out.DeclaredCreated = append(out.DeclaredCreated, key) + } + out.Keys = append(out.Keys, key) + } + if len(out.Keys) > 0 { + out.Primary = out.Keys[0] + } + + // 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空) + if !sig.Empty() { + key, created, err := g.EnterScene(sig) + if err != nil { + return out, err + } + if key != "" { + if !seen[key] { + out.Keys = append(out.Keys, key) + } + out.Primary = key + out.Emergent = true + } else if created { + out.Emergent = true + } + } + return out, nil +} + +// EnsureScene 让一个**声明出来的**场景存在(不存在则建),并给它记一次强度。 +// +// 特征只从**键自身**解析(`chan:qq/peer:group_1` → {chan:qq, peer:group_1}), +// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度 +// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。 +// 声明路的泛化靠层级键前缀,不需要靠学指纹。 +func (g *GraphDB) EnsureScene(key string, _ Situation) (bool, error) { + key = NormalizeSceneKey(key) + if key == "" { + return false, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + var sceneID int64 + err := g.db.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID) + created := false + if err == sql.ErrNoRows { + res, ierr := g.db.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'declared')`, key) + if ierr != nil { + return false, ierr + } + sceneID, _ = res.LastInsertId() + created = true + } else if err != nil { + return false, err + } + + if _, err := g.db.Exec( + `UPDATE scenes SET strength = COALESCE(strength,1) + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + sceneID); err != nil { + return created, err + } + for _, f := range FeaturesFromSceneKey(key) { + if _, err := g.db.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 created, err + } + } + return created, nil +} + +// FeaturesFromSceneKey 从场景键解析它自身蕴含的场面特征。 +// +// chan:qq → {chan:qq} +// chan:qq/peer:group_1027 → {chan:qq, peer:group_1027} +// 老大2026-09-04_12:27_qq私聊图片 → {}(无 kind:value 结构,不猜) +// +// 只有 `kind:value` 形态的层才算特征——猜不出结构的键宁可留空, +// 也不要往特征空间里灌进会污染相似度的东西。 +func FeaturesFromSceneKey(key string) []SituationFeature { + parts := strings.Split(NormalizeSceneKey(key), "/") + var out []SituationFeature + for _, p := range parts { + i := strings.Index(p, ":") + if i <= 0 || i == len(p)-1 { + continue + } + kind, val := p[:i], p[i+1:] + // 白名单,不猜:`老大2026-09-04_12:27_qq私聊图片` 里的 `12:27` 也是 + // `kind:value` 形态,放进特征空间就是往相似度里灌垃圾。 + if !knownFeatureKinds[kind] { + continue + } + feat := SituationFeature{Kind: kind, Value: val} + if feat.Key() == "" { + continue + } + out = append(out, feat) + } + return out +} + +// knownFeatureKinds 是允许进入场面指纹的特征种类(新的种类在这里登记, +// 并在 SituationFeature.Weight 里给权重)。 +var knownFeatureKinds = map[string]bool{ + "chan": true, "peer": true, "peer_group": true, + "tool": true, "topic": true, "part": true, +} diff --git a/internal/memory/scene_emerge_test.go b/internal/memory/scene_emerge_test.go index ca51c7d..9dae5db 100644 --- a/internal/memory/scene_emerge_test.go +++ b/internal/memory/scene_emerge_test.go @@ -2,6 +2,7 @@ package memory import ( "os" + "strings" "testing" "time" ) @@ -201,3 +202,121 @@ func TestSceneRefDecay(t *testing.T) { 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", Situation{}); 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) + } +} + +// 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") + } +}