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 59607a7ef5
commit b9f638a297
13 changed files with 956 additions and 18 deletions

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
}