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

@ -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 同时参与唯一约束