diff --git a/internal/memory/graph.go b/internal/memory/graph.go index be5e581..3470483 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -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() diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index ed46532..f9283e9 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -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() diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go index 5b5b937..564a6f4 100644 --- a/internal/memory/indexer_test.go +++ b/internal/memory/indexer_test.go @@ -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 { diff --git a/internal/memory/scene.go b/internal/memory/scene.go index e36226f..2ab7e6a 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -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) } diff --git a/internal/memory/scene_emerge.go b/internal/memory/scene_emerge.go index 5e230a4..ce14ea3 100644 --- a/internal/memory/scene_emerge.go +++ b/internal/memory/scene_emerge.go @@ -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 diff --git a/internal/memory/scene_emerge_test.go b/internal/memory/scene_emerge_test.go index 9dae5db..2a7888f 100644 --- a/internal/memory/scene_emerge_test.go +++ b/internal/memory/scene_emerge_test.go @@ -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", ""}})