mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-25 03:18:08 +00:00
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/... 全绿。
This commit is contained in:
@ -12,6 +12,15 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// maxKeywordEntities 是单个关键词能取回的实体上限。
|
||||
//
|
||||
// 无上限时,一个宽关键词("QQ")会命中上百个实体并逐个参与深度扩展,
|
||||
// 把一次召回变成一次全表扫描。
|
||||
const maxKeywordEntities = 50
|
||||
|
||||
// maxAdjacentRelations 是深度扩展里**每层**读取的关系上限。
|
||||
const maxAdjacentRelations = 200
|
||||
|
||||
// maxFullRecallEntities 是「无关键词全量读取」路径的实体上限。
|
||||
// 该路径只服务于内部整备(Indexer.Sync / 实体合并检测),并非用户检索;
|
||||
// 无上限时一张大图会被整表 read 进内存。超限时 GraphDB.Recall 会记日志。
|
||||
@ -236,6 +245,9 @@ func (g *GraphDB) initSchema() error {
|
||||
-- 数值型节点(relation/entity)为空串。
|
||||
ref_text TEXT NOT NULL DEFAULT '',
|
||||
weight REAL DEFAULT 1.0,
|
||||
-- decayed_at 是半衰期衰减的计时起点:每个引用至多每 halfLife
|
||||
-- 衰减一次(见 DecaySceneRefs)。重复写入/强化会把它刷成当前时刻。
|
||||
decayed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(scene_id, kind, ref_id, ref_text)
|
||||
)`,
|
||||
@ -245,14 +257,12 @@ func (g *GraphDB) initSchema() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sentences_text ON sentences(text)`,
|
||||
}
|
||||
|
||||
for _, s := range schemas {
|
||||
@ -307,6 +317,17 @@ func (g *GraphDB) initSchema() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 迁移8:scene_refs 加 decayed_at(半衰期衰减的计时起点)。
|
||||
// ALTER 不接受非常量默认值,先加可空列再用 created_at 回填,
|
||||
// 于是既有引用的「上一次衰减」就定在它被写入的时刻,不会被立即清掉。
|
||||
if !columnExists(tx, "scene_refs", "decayed_at") {
|
||||
tx.Exec(`ALTER TABLE scene_refs ADD COLUMN decayed_at TIMESTAMP`)
|
||||
tx.Exec(`UPDATE scene_refs SET decayed_at = created_at WHERE decayed_at IS NULL`)
|
||||
}
|
||||
// 冗余索引清理:entities.name 与 sentences.text 上的 UNIQUE 已隐含等价索引
|
||||
// (sqlite_autoindex_*),再建一个同列索引只增加写放大,查询不会用到。
|
||||
tx.Exec(`DROP INDEX IF EXISTS idx_entity_name`)
|
||||
tx.Exec(`DROP INDEX IF EXISTS idx_sentences_text`)
|
||||
// 迁移3:将现有 sentence_ref 数据迁移到 sentences 表
|
||||
tx.Exec(`INSERT OR IGNORE INTO sentences (text) SELECT DISTINCT sentence_ref FROM relations WHERE sentence_ref != ''`)
|
||||
tx.Exec(`UPDATE relations SET sentence_id = (SELECT id FROM sentences WHERE text = relations.sentence_ref) WHERE sentence_ref != ''`)
|
||||
@ -639,10 +660,25 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
||||
entityIDs := make(map[int64]bool)
|
||||
|
||||
for _, kw := range keywords {
|
||||
// 相关度排序 + 限额。
|
||||
//
|
||||
// 此前这里既没有 ORDER BY 也没有 LIMIT:拿回来的顺序就是建表顺序
|
||||
// (rowid 升序),于是注入进 prompt 的"前 5 个实体"是**最早创建的**,
|
||||
// 越新越准的记忆越排后面被截掉(实测:输入「QQ回复格式」命中 148 个,
|
||||
// 规则实体排第 32,前 5 里根本没有它)。
|
||||
//
|
||||
// 相关度分三层:完全相等 > 前缀命中 > 包含命中;同层按提及次数、
|
||||
// 再按名字长度(短名更可能是实体本身而不是长描述)。
|
||||
rows, err := g.db.Query(
|
||||
`SELECT id, name, type, mention_count, created_at, updated_at
|
||||
FROM entities WHERE LOWER(name) LIKE ?`,
|
||||
"%"+kw+"%",
|
||||
FROM entities WHERE LOWER(name) LIKE ?
|
||||
ORDER BY CASE
|
||||
WHEN LOWER(name) = LOWER(?) THEN 0
|
||||
WHEN LOWER(name) LIKE LOWER(?) || '%' THEN 1
|
||||
ELSE 2 END,
|
||||
mention_count DESC, LENGTH(name) ASC
|
||||
LIMIT ?`,
|
||||
"%"+kw+"%", kw, kw, maxKeywordEntities,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -717,6 +753,11 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
||||
query += " AND r.session_id = ?"
|
||||
allIDs = append(allIDs, sessionFilter)
|
||||
}
|
||||
// 每层限额:热实体("文档"这类)的邻接可能是上千条,无上限时每层都
|
||||
// 整片读进内存,而调用方(memory_recall 注入 10 条、自动注入只要实体名)
|
||||
// 根本用不到。按置信度取最相关的一批。
|
||||
query += " ORDER BY r.confidence DESC, r.updated_at DESC LIMIT ?"
|
||||
allIDs = append(allIDs, maxAdjacentRelations)
|
||||
|
||||
relRows, err := g.db.Query(query, allIDs...)
|
||||
if err != nil {
|
||||
@ -901,10 +942,13 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
|
||||
g.db.Exec(`DELETE FROM entities WHERE id NOT IN (
|
||||
SELECT DISTINCT source_id FROM relations
|
||||
UNION SELECT DISTINCT target_id FROM relations)`)
|
||||
|
||||
// 这里**不再**顺手全局删孤儿实体。
|
||||
//
|
||||
// 原来那句 `DELETE FROM entities WHERE id NOT IN (relations 两端)` 是与
|
||||
// 调用方意图无关的全局副作用:memory_edit 只想去掉一条关系,却可能把
|
||||
// 图里其它孤零零的实体一并清掉。孤儿清理交给 PurgeOrphans
|
||||
// (显式、可 dry-run、有计数与审计),一次改动只做一件事。
|
||||
//
|
||||
// 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、
|
||||
// 召回却是空的(SceneStats 也跟着说谎)。
|
||||
g.purgeStaleSceneRefsLocked()
|
||||
|
||||
@ -3,8 +3,10 @@ package memory
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
@ -19,8 +21,21 @@ type Indexer struct {
|
||||
|
||||
// recalledOrder 记录 recalled 的插入顺序,用于超限时按 FIFO 淘汰。
|
||||
recalledOrder []string
|
||||
|
||||
// 增量同步状态:运行中新增的实体此前要等下一个归档心跳(Indexer.Sync)
|
||||
// 才进向量索引,在那之前只能靠关键词路命中——"刚记住的东西过一会儿才想得
|
||||
// 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。
|
||||
lastSyncCount int
|
||||
lastSyncAt time.Time
|
||||
}
|
||||
|
||||
// retrainInterval 是增量重训的最小间隔。
|
||||
//
|
||||
// 为什么不每次都重训:TF-IDF 向量器是**全局**重训(词表与 idf 都变),
|
||||
// 一次是 O(实体数)。没有下限时,密集写入的场景下每轮召回都触发一次全量重训,
|
||||
// 把一个读操作变成写放大的热点。心跳(30min)+ 这个下限,够快也够稳。
|
||||
const retrainInterval = 30 * time.Second
|
||||
|
||||
// maxRecalledEntities 是「已召回实体」去重集的上限。
|
||||
//
|
||||
// 无上限时它只增不减:进程活得越久,被永久跳过的实体越多,自动注入
|
||||
@ -76,6 +91,10 @@ func (idx *Indexer) Sync() error {
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
// 没有实体也是一次成功的同步:记下基线,否则 syncIfStale 会在
|
||||
// 「基线还停在旧值」与「本轮无实体」之间反复误判、每 30s 重跑一次。
|
||||
idx.lastSyncCount = 0
|
||||
idx.lastSyncAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -93,10 +112,49 @@ func (idx *Indexer) Sync() error {
|
||||
}
|
||||
|
||||
idx.trained = true
|
||||
idx.lastSyncCount = len(names)
|
||||
idx.lastSyncAt = time.Now()
|
||||
log.Printf("[indexer] synced %d entities to vector index", len(names))
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncIfStale 在实体数发生变化时补一次重建(调用方必须**不持锁**)。
|
||||
//
|
||||
// 返回是否发生了重建。计数查询走 idx_count 索引,成本可忽略;
|
||||
// 只有真的变了、且离上次同步超过 retrainInterval 才重训。
|
||||
func (idx *Indexer) syncIfStale() bool {
|
||||
if idx.db == nil {
|
||||
return false
|
||||
}
|
||||
var count int
|
||||
if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil {
|
||||
return false
|
||||
}
|
||||
// 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被
|
||||
// maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上
|
||||
// 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。
|
||||
expected := count
|
||||
if expected > maxFullRecallEntities {
|
||||
expected = maxFullRecallEntities
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
known, last := idx.lastSyncCount, idx.lastSyncAt
|
||||
idx.mu.RUnlock()
|
||||
if expected == known {
|
||||
return false
|
||||
}
|
||||
if !last.IsZero() && time.Since(last) < retrainInterval {
|
||||
return false
|
||||
}
|
||||
if err := idx.Sync(); err != nil {
|
||||
log.Printf("[indexer] 增量同步失败(沿用旧索引): %v", err)
|
||||
return false
|
||||
}
|
||||
log.Printf("[indexer] 实体数 %d→%d,已增量重建实体名向量索引", known, count)
|
||||
return true
|
||||
}
|
||||
|
||||
type InjectedContext struct {
|
||||
Entities []Entity `json:"entities"`
|
||||
Relations []Relation `json:"relations"`
|
||||
@ -130,6 +188,9 @@ func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *Inje
|
||||
|
||||
input := CleanText(userInput)
|
||||
|
||||
// 0. 索引保鲜:运行中新增的实体不该等到下一个心跳才可被召回
|
||||
idx.syncIfStale()
|
||||
|
||||
// 1. 向量搜索:从实体名向量索引中找到相关实体
|
||||
vectorEntities := idx.vectorSearchEntities(input)
|
||||
|
||||
@ -444,16 +505,30 @@ func buildIndexSummary(entities []Entity) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities)))
|
||||
|
||||
// 「高频」必须真的按提及次数排。
|
||||
//
|
||||
// 此前取的是 entities[0..2],也就是 Recall 的返回顺序(旧实现下等于建表
|
||||
// 顺序),却写着"高频"两个字:给模型的暗示是"这几条最重要",实际是
|
||||
// "这几条建得最早"。名不副实的标签比没有标签更坏——它会稳定地误导。
|
||||
byMentions := make([]Entity, len(entities))
|
||||
copy(byMentions, entities)
|
||||
sort.SliceStable(byMentions, func(i, j int) bool {
|
||||
if byMentions[i].MentionCount != byMentions[j].MentionCount {
|
||||
return byMentions[i].MentionCount > byMentions[j].MentionCount
|
||||
}
|
||||
return byMentions[i].Name < byMentions[j].Name
|
||||
})
|
||||
|
||||
topN := 3
|
||||
if len(entities) < topN {
|
||||
topN = len(entities)
|
||||
if len(byMentions) < topN {
|
||||
topN = len(byMentions)
|
||||
}
|
||||
b.WriteString(",高频:")
|
||||
for i := 0; i < topN; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString("、")
|
||||
}
|
||||
b.WriteString(entities[i].Name)
|
||||
b.WriteString(byMentions[i].Name)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
|
||||
@ -3,6 +3,7 @@ package memory
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewIndexer(t *testing.T) {
|
||||
@ -48,6 +49,52 @@ func TestIndexerSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncIfStaleBaseline 钉住增量同步的基线口径:
|
||||
// - 实体数未变 → 不重训
|
||||
// - 实体数变了但不足 retrainInterval → 先不重训(避免密集写入时写放大)
|
||||
// - 实体数变了且间隔已过 → 重训,并把基线追到新值
|
||||
func TestSyncIfStaleBaseline(t *testing.T) {
|
||||
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, _, err := db.Commit([]Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
|
||||
}, "test", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := NewIndexer(db)
|
||||
if !idx.syncIfStale() {
|
||||
t.Fatal("首次应建立索引")
|
||||
}
|
||||
if idx.syncIfStale() {
|
||||
t.Error("实体数未变不该重训")
|
||||
}
|
||||
|
||||
if _, _, err := db.Commit([]Triple{
|
||||
{Subject: "李四", Relation: "喜欢", Object: "足球"},
|
||||
}, "test", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx.syncIfStale() {
|
||||
t.Error("retrainInterval 内不该重训(避免写放大)")
|
||||
}
|
||||
|
||||
// 把上次同步时刻推老,计数变化才该触发重训
|
||||
idx.mu.Lock()
|
||||
idx.lastSyncAt = time.Now().Add(-2 * retrainInterval)
|
||||
idx.mu.Unlock()
|
||||
if !idx.syncIfStale() {
|
||||
t.Error("计数变化且间隔已过应重训")
|
||||
}
|
||||
if idx.syncIfStale() {
|
||||
t.Error("重训后基线应追上,不该再重训")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexerBuildContext(t *testing.T) {
|
||||
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
|
||||
@ -198,7 +198,7 @@ func tagSceneRefTx(tx *sql.Tx, sceneKey, kind string, id int64, textID string, w
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scene_id, kind, ref_id, ref_text)
|
||||
DO UPDATE SET weight = MAX(weight, excluded.weight)`,
|
||||
DO UPDATE SET weight = MAX(weight, excluded.weight), decayed_at = CURRENT_TIMESTAMP`,
|
||||
sceneID, kind, id, textID, weight); err != nil {
|
||||
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
|
||||
}
|
||||
|
||||
@ -80,7 +80,10 @@ func (f SituationFeature) Weight() float64 {
|
||||
switch NormalizeSceneKey(f.Kind) {
|
||||
case "chan":
|
||||
return wFeatChan
|
||||
case "peer":
|
||||
case "peer", "peer_group":
|
||||
// 群与私聊都是「对话对象」这一维:都是最强的同一性信号。
|
||||
// 分开 kind 是为了让 `peer:group_1` 与 `peer:user_1` 不互相命中,
|
||||
// 不是让群身份降级成软信号(漏掉这里它就只剩 topic 权重 0.4)。
|
||||
return wFeatPeer
|
||||
case "tool":
|
||||
return wFeatTool
|
||||
@ -387,7 +390,9 @@ func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
|
||||
|
||||
// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
|
||||
//
|
||||
// 命中多个场景时按相似度 × 权重合并,跨场景去重(同一关系只出现一次)。
|
||||
// 命中多个场景时取并集,跨场景去重(同一关系只出现一次);命中的场景先按
|
||||
// 相似度排序再交给 RecallByScene。最终顺序以**场景内引用权重**(写入时的
|
||||
// 置信度)为准,相似度只决定哪些场景参与、不参与每条关系的排序。
|
||||
// 这正是「类似的场景自动唤起对应的记忆」那一下。
|
||||
func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) {
|
||||
if sig.Empty() {
|
||||
@ -427,7 +432,7 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
|
||||
for _, h := range hits {
|
||||
keys = append(keys, h.key)
|
||||
}
|
||||
// 复用按场景键的取回逻辑(前缀语义 + weight 排序),再按相似度加权重排
|
||||
// 复用按场景键的取回逻辑(前缀语义 + weight 排序)
|
||||
out, err := g.RecallByScene(keys, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -440,6 +445,11 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
|
||||
// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会
|
||||
// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。
|
||||
// 权重按半衰期折半;低于 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
|
||||
@ -449,12 +459,15 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
cut := time.Now().Add(-halfLife).Format("2006-01-02 15:04:05")
|
||||
// 时间基准必须与 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
|
||||
WHERE created_at < ? AND id NOT IN (
|
||||
`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 >= ?)`, cut, cut); err != nil {
|
||||
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)
|
||||
@ -465,31 +478,6 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// EmergentScenes 列出当前长出来的场景(按强度降序),供观察「涌现」是否在发生。
|
||||
func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(
|
||||
`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),
|
||||
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
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SceneStat
|
||||
for rows.Next() {
|
||||
var st SceneStat
|
||||
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 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
|
||||
@ -505,10 +493,10 @@ type TurnScene struct {
|
||||
|
||||
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
|
||||
//
|
||||
// 主动路(declaredKeys 非空):确保这些场景存在,并把本轮的场面指纹喂给它,
|
||||
// 强度 +1。这样声明出来的场景会**慢慢学会自己认自己**——同一个场面以后
|
||||
// 即使没人声明,也能被 RecallBySituation 按相似度命中。
|
||||
// 声明即建场景,不等第二次:人明确说了"这是哪个场面",就不该再等它自己涌现。
|
||||
// 主动路(declaredKeys 非空):确保这些场景存在(不存在即建,不等第二次涌现
|
||||
// ——人明确说了"这是哪个场面",就不该再等它自己涌现),强度 +1,并给它记下
|
||||
// 从**键自身**解析出的特征。声明路刻意**不吸收本轮整场指纹**:一旦吸收,它
|
||||
// 会在相似度上压过一切,被动聚类再也长不出更细的场面(见 EnsureScene)。
|
||||
//
|
||||
// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时
|
||||
// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底——
|
||||
@ -524,7 +512,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
learned, err := g.EnsureScene(key, sig)
|
||||
learned, err := g.EnsureScene(key)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@ -538,8 +526,11 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
||||
}
|
||||
|
||||
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
|
||||
//
|
||||
// EnterScene 只在真的命中/新建时返回非空键;返回空键时 created 必为
|
||||
// false(首次只登记足迹),所以这里无需再判 created。
|
||||
if !sig.Empty() {
|
||||
key, created, err := g.EnterScene(sig)
|
||||
key, _, err := g.EnterScene(sig)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@ -549,8 +540,6 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
||||
}
|
||||
out.Primary = key
|
||||
out.Emergent = true
|
||||
} else if created {
|
||||
out.Emergent = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@ -562,7 +551,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
||||
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
|
||||
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。
|
||||
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
|
||||
func (g *GraphDB) EnsureScene(key string, _ Situation) (bool, error) {
|
||||
func (g *GraphDB) EnsureScene(key string) (bool, error) {
|
||||
key = NormalizeSceneKey(key)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
|
||||
@ -65,9 +65,9 @@ func TestSceneEmergesFromRepetition(t *testing.T) {
|
||||
t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2)
|
||||
}
|
||||
|
||||
scenes, err := g.EmergentScenes()
|
||||
scenes, err := g.SceneStats()
|
||||
if err != nil {
|
||||
t.Fatalf("EmergentScenes: %v", err)
|
||||
t.Fatalf("SceneStats: %v", err)
|
||||
}
|
||||
if len(scenes) != 2 {
|
||||
t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes)
|
||||
@ -132,6 +132,9 @@ func TestSceneRecallsBySituationNotWording(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。
|
||||
//
|
||||
// 半衰期的语义是「每个引用至多每 halfLife 衰减一次」:衰减计时起点在
|
||||
// scene_refs.decayed_at 上,同一半衰期内重复跑心跳不会再砍。
|
||||
func TestSceneRefDecay(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
@ -147,13 +150,13 @@ func TestSceneRefDecay(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 把时间推旧:模拟长期未重现
|
||||
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
|
||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
||||
// 把时间推旧:模拟长期未重现(上次衰减也在同一时刻)。用 SQLite 的
|
||||
// datetime('now') 与 CURRENT_TIMESTAMP 同一时间基准(UTC)。
|
||||
if _, err := g.db.Exec(`UPDATE scene_refs
|
||||
SET created_at = datetime('now','-48 hours'), decayed_at = datetime('now','-48 hours')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = ?`,
|
||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
||||
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = datetime('now','-48 hours')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -169,9 +172,21 @@ func TestSceneRefDecay(t *testing.T) {
|
||||
t.Errorf("一个半衰期后权重应减半: %v → %v", before, after)
|
||||
}
|
||||
|
||||
// 再推旧一次:第二个半衰期后低于 floor,关联已无信息量,清掉
|
||||
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
|
||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
||||
// 同一半衰期内再跑:decayed_at 已刷新,不该再砍一次
|
||||
// (否则心跳频率就成了实际半衰期,30 天的关联几小时就被清空)
|
||||
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
|
||||
t.Fatalf("DecaySceneRefs: %v", err)
|
||||
}
|
||||
var same float64
|
||||
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&same); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if same != after {
|
||||
t.Errorf("同一半衰期内不该重复衰减: %v → %v", after, same)
|
||||
}
|
||||
|
||||
// 进入第二个半衰期:再推旧 decayed_at,权重低于 floor,清掉
|
||||
if _, err := g.db.Exec(`UPDATE scene_refs SET decayed_at = datetime('now','-48 hours')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := g.DecaySceneRefs(time.Hour, 0.4)
|
||||
@ -204,9 +219,8 @@ func TestSceneRefDecay(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
|
||||
// - 主动:声明即建场景(不等第二次涌现),并把指纹喂给它
|
||||
// - 被动:指纹聚类自己长出场景;声明场景学会特征后**即使没人再声明**
|
||||
// 也能被相似度命中
|
||||
// - 主动:声明即建场景(不等第二次涌现),特征只从键自身解析
|
||||
// - 被动:指纹聚类自己长出场景;声明场景**不**进相似度空间,靠声明/前缀键取回
|
||||
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
|
||||
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
@ -298,7 +312,7 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
||||
}
|
||||
|
||||
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
|
||||
if _, err := g.EnsureScene("chan:qq", Situation{}); err != nil {
|
||||
if _, err := g.EnsureScene("chan:qq"); err != nil {
|
||||
t.Fatalf("EnsureScene: %v", err)
|
||||
}
|
||||
var n int
|
||||
@ -310,6 +324,18 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeerGroupWeight 钉住群身份是**强**同一性信号:peer_group 与 peer 同为
|
||||
// wFeatPeer。漏掉 peer_group 会让它在 Weight 里落到 default(话题级 0.4),
|
||||
// 群聊场面被降级成软信号。
|
||||
func TestPeerGroupWeight(t *testing.T) {
|
||||
if got := (SituationFeature{Kind: "peer_group", Value: "group_1"}).Weight(); got != wFeatPeer {
|
||||
t.Errorf("peer_group 权重应为 %v,得到 %v", wFeatPeer, got)
|
||||
}
|
||||
if got := (SituationFeature{Kind: "peer", Value: "user_1"}).Weight(); got != wFeatPeer {
|
||||
t.Errorf("peer 权重应为 %v,得到 %v", wFeatPeer, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
|
||||
func TestEffectiveScenes(t *testing.T) {
|
||||
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})
|
||||
|
||||
Reference in New Issue
Block a user