mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-25 03:18:08 +00:00
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。 那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事, 后续类似场面自动唤起对应记忆」的要求重做。 机制(全部取自运行时可观察量,无需模型配合、无需人工标注): - **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx / tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0), 工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。 - **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。 加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让 一次偶然的话题重合把两个不同场面并成一个。 - **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景 (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」, 给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。 - **强化**:场景每次重现 strength+1、并入新特征。 - **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低 ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。 - **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删; 已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。 三个必须讲清的边界: 1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度, 「工具调得多」会被误读成「这个场面更常出现」。 2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚), 涌现是「这轮看起来像哪个场面」。两者都进召回。 3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景, 模型不需要知道场景这回事。 验证:go build/vet 干净,go test -count=1 ./... 全绿。 新增用例(核心证据): - TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 → 同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→ 强度随重现增长、特征多条。全程没有任何人声明过场景键。 - TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被 自动唤起(含原句),无关场面不唤起。 - TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉, 仍在重现的场景不受影响。 - TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
package core
|
||
|
||
import (
|
||
"log"
|
||
"strconv"
|
||
"time"
|
||
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
)
|
||
|
||
// sceneKeysFor 推导本轮输入的**当前场景**。
|
||
//
|
||
// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定)
|
||
// 取回来。优先级:
|
||
// 1. 注入点显式声明(payload.scene)——插件最清楚自己在什么场面里
|
||
// 2. 通道(evt.Source → chan:qq)
|
||
// 3. 工具(tool:qq_get_message)——工具输出触发的召回只知道这一步
|
||
//
|
||
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
|
||
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
|
||
func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||
var keys []string
|
||
seen := make(map[string]bool)
|
||
add := func(k string) {
|
||
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
|
||
// 否则「chan:QQ」与「chan:qq」会变成两个场景,各自只召回一半记忆。
|
||
k = memory.NormalizeSceneKey(k)
|
||
if k == "" || seen[k] {
|
||
return
|
||
}
|
||
seen[k] = true
|
||
keys = append(keys, k)
|
||
}
|
||
|
||
if evt != nil && evt.Payload != nil {
|
||
switch v := evt.Payload["scene"].(type) {
|
||
case string:
|
||
add(v)
|
||
case []string:
|
||
for _, s := range v {
|
||
add(s)
|
||
}
|
||
case []interface{}:
|
||
for _, item := range v {
|
||
if s, ok := item.(string); ok {
|
||
add(s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if evt != nil {
|
||
add(memory.ChannelScene(evt.Source))
|
||
}
|
||
if toolName != "" {
|
||
add(memory.ToolScene(toolName))
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
|
||
type memoryPassOut struct {
|
||
// Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。
|
||
Archived int
|
||
// RecallText 是可注入 prompt 的记忆索引文本(recall 的输出,空串表示无)。
|
||
RecallText string
|
||
}
|
||
|
||
// memoryPass 是「取进来(召回)」与「踢出去(裁剪)」的**唯一入口**。
|
||
//
|
||
// prune 与 recall 是两根正交的声明轴(默认值刻意相反:裁剪是破坏性的、
|
||
// 默认关;召回是只读增量、默认开),但两者都建立在**同一份清洗后的 query**
|
||
// 之上。调用点只负责解析声明,这里统一做三件各写一遍就会写歪的事:
|
||
//
|
||
// 1. 同一 query:prune 与 recall 用同一个查询向量来源,避免「裁错事件、
|
||
// 召回错记忆」——原始内容里的 ANSI/base64/JSON 噪声会把相关性打分带偏。
|
||
// 2. 同一次预算:召回文本按 token 预算截断只做一次(见 recallText)。
|
||
// 3. 同一条审计:谁(trigger)据什么触发了哪种操作都落一条日志,
|
||
// 否则又是一个「幕后发生、查不出是谁」的机制(对齐 prune 的设计初衷)。
|
||
//
|
||
// 边界:prune 与 recall 目前仍走**各自的相关性空间**(prune 用 L0 事件的
|
||
// 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的
|
||
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
|
||
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
|
||
func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut {
|
||
var out memoryPassOut
|
||
if a == nil || (!prune && !recall) {
|
||
return out
|
||
}
|
||
if prune {
|
||
out.Archived = a.pruneByQuery(query)
|
||
}
|
||
if recall && query != "" {
|
||
out.RecallText = a.recallTextFor(query, trigger, scenes)
|
||
}
|
||
if out.Archived > 0 || out.RecallText != "" {
|
||
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
|
||
trigger, out.Archived, len(out.RecallText))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pruneByQuery 按相关性把低相关 L0 事件归档进文档记忆,返回归档数。
|
||
//
|
||
// **不做声明判定**——声明已由调用方(pruneOnInput / stepToolAfter)解析,
|
||
// 这里只负责执行。放在 memoryPass 内部是为了让裁剪与召回共享入口。
|
||
func (a *Agent) pruneByQuery(query string) int {
|
||
if a == nil || a.context == nil {
|
||
return 0
|
||
}
|
||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||
if a.isLightKernel() {
|
||
return 0
|
||
}
|
||
topK := a.maxContextSize - 1
|
||
if topK < 1 {
|
||
topK = 1
|
||
}
|
||
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
|
||
}
|