Files
AskaEth fbad02899a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
2026-10-04 14:20:22 +08:00

111 lines
4.0 KiB
Go

package memory
import (
"context"
)
// 本文件是「持久语义索引的一次性回填」:让既有条目也进入语义路径。
//
// 写入侧(store_vector.go)保证**此后**每条记忆都被写进索引,但进程
// 启动前就已落库的 memory_items 没有对应向量。若不回填,语义路径
// 只覆盖新写入的少数条目,旧条目在索引里查不到 —— 对「配置了增强
// 就必须真生效」(AGENTS §9)而言仍是半个空壳。
//
// 回填触发条件极窄:每进程首次需要语义打分时探测一次索引,**只在索
// 引确实为空**(一条向量都没有)时回填全部条目,之后永不重复。因此
// 它不会在正常运行的每次检索上花代价,也不会重复嵌入已入索引的向量。
// indexProbeText 是探测「索引是否为空」用的固定文本:它的向量内容
// 无关紧要(只想知道索引里有没有任何向量),取常量以保证确定性。
const indexProbeText = "probe"
// indexBackfillBatch 是单次 Embed 调用喂给索引的条数上限,避免把
// 整库文本一次性交给远端 embedding API。
const indexBackfillBatch = 64
// maxBackfillItems 是单次回填处理的最大条目数。它给「一次启动就把
// 十万条历史记忆全量向量化」这种代价封顶;超出部分只记日志,不
// 阻塞检索(Store.List 最新在前,因此被截掉的是最旧的一批)。
const maxBackfillItems = 512
// ensureIndexSeeded 每进程最多执行一次:探测持久索引是否为空,为空
// 则把已落库的条目回填进去。未接入索引时是空操作;探测或嵌入失败
// 只记日志且不再重试(重试只会把同一条故障日志刷满每一次检索)。
func (r *Retriever) ensureIndexSeeded(ctx context.Context) {
if r.vindex == nil {
return
}
r.seedMu.Lock()
defer r.seedMu.Unlock()
if r.seeded {
return
}
r.seeded = true
probe, err := r.vindex.Embed(ctx, []string{indexProbeText})
if err != nil || len(probe) == 0 {
r.log.Warn("memory: vector index probe failed, semantic index not seeded", "error", err)
return
}
hits, err := r.vindex.Search(ctx, probe[0], 1)
if err != nil {
r.log.Warn("memory: vector index probe search failed, semantic index not seeded", "error", err)
return
}
if len(hits) > 0 {
// 索引里已有向量:它由写入侧或上一次运行填过,无需回填。
return
}
r.backfillIndex(ctx)
}
// backfillIndex 把已落库的条目向量化并写入索引。它分批 Embed,逐条
// Add;单条失败只记日志(其余条目继续),因此一次失败的 Add 不会让
// 整个回填白跑。按 maxBackfillItems 截断时记一条日志说明被跳过
// 的量,避免「配置了增强却只覆盖一部分」无人知晓。
func (r *Retriever) backfillIndex(ctx context.Context) {
items, err := r.store.List(ctx)
if err != nil {
r.log.Warn("memory: list items for vector backfill failed", "error", err)
return
}
if len(items) == 0 {
return
}
total := len(items)
if total > maxBackfillItems {
r.log.Warn("memory: vector backfill truncated, oldest items left unindexed",
"total", total, "limit", maxBackfillItems)
items = items[:maxBackfillItems]
}
indexed, failed := 0, 0
for start := 0; start < len(items); start += indexBackfillBatch {
end := min(start+indexBackfillBatch, len(items))
batch := items[start:end]
texts := make([]string, len(batch))
for i, it := range batch {
texts[i] = it.Content
}
vecs, err := r.vindex.Embed(ctx, texts)
if err != nil {
r.log.Warn("memory: vector backfill embed failed, seeding stopped",
"indexed", indexed, "error", err)
return
}
for i, v := range vecs {
if i >= len(batch) {
break
}
if err := r.vindex.Add(ctx, batch[i].ID, v); err != nil {
failed++
r.log.Warn("memory: vector backfill add failed", "id", batch[i].ID, "error", err)
continue
}
indexed++
}
}
r.log.Info("memory: vector index backfilled from persisted items",
"indexed", indexed, "failed", failed, "total", total)
}