Files
HomeAgent/internal/memory/scene_emerge.go
JianFeeeee acc94723fd fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
2026-09-15 10:20:40 +08:00

629 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"database/sql"
"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", "peer_group":
// 群与私聊都是「对话对象」这一维:都是最强的同一性信号。
// 分开 kind 是为了让 `peer:group_1` 与 `peer:user_1` 不互相命中,
// 不是让群身份降级成软信号(漏掉这里它就只剩 topic 权重 0.4)。
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 读入参与**被动聚类**的场景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
WHERE COALESCE(origin, 'emergent') = 'emergent'`)
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, origin) VALUES (?, 1, 'emergent')`, 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 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
//
// 命中多个场景时取并集,跨场景去重(同一关系只出现一次);命中的场景先按
// 相似度排序再交给 RecallByScene。最终顺序以**场景内引用权重**(写入时的
// 置信度)为准,相似度只决定哪些场景参与、不参与每条关系的排序。
// 这正是「类似的场景自动唤起对应的记忆」那一下。
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 的引用直接删除(关联已无信息量)。
//
// 关键在「按半衰期」:每个引用**至多每 halfLife 衰减一次**,计时起点记在
// scene_refs.decayed_at 上。只按 created_at 判龄会在每次心跳都把老引用对半
// 砍——archive 心跳默认 60 分钟、halfLife 传 30 天,于是 30 天前的关联会在
// 几小时内被砍到 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()
// 时间基准必须与 CURRENT_TIMESTAMP 一致SQLite 用 UTC如果在 Go 侧用
// 本地时间拼字符串比较,东八区会凭空多出 8 小时的“年龄”,刚刷新的
// decayed_at 会被判定为还没到点。这里交给 SQLite 的 datetime('now', …)。
mod := fmt.Sprintf("-%d seconds", int(halfLife.Seconds()))
if _, err := g.db.Exec(
`UPDATE scene_refs SET weight = weight * 0.5, decayed_at = CURRENT_TIMESTAMP
WHERE decayed_at < datetime('now', ?) AND id NOT IN (
SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
WHERE s.updated_at >= datetime('now', ?))`, mod, mod); 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
}
// TurnScene 是一轮交互解析出来的场景集合。
type TurnScene struct {
// Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
// 与措辞无关),没有(首次出现、场景还没长出来)时退到第一个声明场景。
Primary string
// Keys 是声明场景 + 涌现场景的全集(去重保序),供召回并集使用。
Keys []string
// Emergent 标记 Primary 是否来自涌现。
Emergent bool
// DeclaredCreated 是本次**新建**的声明场景(此前不存在,因声明而成立)。
DeclaredCreated []string
}
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
//
// 主动路declaredKeys 非空):确保这些场景存在(不存在即建,不等第二次涌现
// ——人明确说了"这是哪个场面",就不该再等它自己涌现),强度 +1并给它记下
// 从**键自身**解析出的特征。声明路刻意**不吸收本轮整场指纹**:一旦吸收,它
// 会在相似度上压过一切,被动聚类再也长不出更细的场面(见 EnsureScene
//
// 被动路始终执行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)
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]
}
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
//
// EnterScene 只在真的命中/新建时返回非空键;返回空键时 created 必为
// false首次只登记足迹所以这里无需再判 created。
if !sig.Empty() {
key, _, 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
}
}
return out, nil
}
// EnsureScene 让一个**声明出来的**场景存在(不存在则建),并给它记一次强度。
//
// 特征只从**键自身**解析(`chan:qq/peer:group_1` → {chan:qq, peer:group_1}
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
func (g *GraphDB) EnsureScene(key string) (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,
}