package memory import ( "context" "fmt" "time" ) // decayWritebackKey 是「是否允许把检索命中的衰减值物化回写」的 context 键。 // 默认允许;只读上下文(群聊 / 陌生人,design/03 §5「能查不能写」)经 // WithoutDecayWriteback 关掉它,使受限检索对记忆库零写入。 type decayWritebackKey struct{} // WithoutDecayWriteback 返回一个禁用衰减物化回写的 context:受限(只读) // 检索必须用它,避免在「查记忆只读不写」的群聊 / 陌生人上下文里改动记忆行。 func WithoutDecayWriteback(ctx context.Context) context.Context { return context.WithValue(ctx, decayWritebackKey{}, false) } // decayWritebackEnabled 报告 ctx 是否允许衰减物化回写(未显式关闭时为 true)。 func decayWritebackEnabled(ctx context.Context) bool { v, ok := ctx.Value(decayWritebackKey{}).(bool) if !ok { return true } return v } // decayRow 是一条待物化的衰减记录:命中条目 id 与其当前衰减值。 type decayRow struct { id string decay float64 } // persistDecay 把本轮检索**真正命中**(relevance > 0)的条目的衰减值 // 物化回写 decay_weight,并把它们的 updated_at 推进到 now。它是 design/03 // §2 与裁决 Q3(2026-10-04)的落地:「updated_at 即衰减源 —— 读时按它计算 // 衰减;检索命中时把结果物化回写 decay_weight」「decay_weight 列语义= // 上次物化的衰减值」。一次命中即一次「使用」,因此同时刷新 updated_at,使 // 下一次读取从同一基准继续衰减,而不会把已物化的值再乘一遍(避免双重衰减)。 // // query 为空时的纯新近度排序不是命中,故不物化;只读上下文 // (WithoutDecayWriteback)也不物化,以守住「群聊/陌生人不写记忆」。 // // 物化失败绝不使检索失败:decay_weight 只是排序/展示用的派生列(design §2 // 「不作为真值源」),失败只记一条带来源的告警。 func (r *Retriever) persistDecay(ctx context.Context, results []Result, relevance map[string]float64, now time.Time) { if !decayWritebackEnabled(ctx) { return } rows := make([]decayRow, 0, len(results)) for _, res := range results { if relevance[res.Item.ID] <= 0 { continue } rows = append(rows, decayRow{id: res.Item.ID, decay: r.decayFactor(res.Item, now)}) } if len(rows) == 0 { return } if err := r.store.writeDecay(ctx, rows, now); err != nil { r.log.Warn("memory: decay weight writeback failed", "error", err) } } // writeDecay 在单个事务里把每条命中的 decay_weight 更新为其物化值,并把 // updated_at 推进到 at(命中即使用)。批量、参数化的 UPDATE,绝不拼接 SQL; // 任一条失败即整批回滚,保持「要么都物化、要么都不动」。 func (s *Store) writeDecay(ctx context.Context, rows []decayRow, at time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("memory: begin decay writeback tx: %w", err) } defer func() { _ = tx.Rollback() }() for _, row := range rows { if _, err := tx.ExecContext(ctx, `UPDATE memory_items SET decay_weight = ?, updated_at = ? WHERE id = ?`, row.decay, formatTime(at), row.id); err != nil { return fmt.Errorf("memory: update decay weight %q: %w", row.id, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("memory: commit decay writeback tx: %w", err) } return nil }