mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user