fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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
This commit is contained in:
AskaEth committed 2026-10-04 14:20:22 +08:00
1 parent 983bc49218
commit fbad02899a
10 files changed
+1020 -81

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
package memory
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件是「持久语义索引的写入侧」:把记忆条目真正喂进向量索引。
//
// 背景(审计 P0-3):vec_items 表与 Index.Add 早已实现,但生产代码
// 从未把任何条目写进索引 —— 索引恒空,而检索一见 vindex 非 nil 就
// 只查它,语义检索因此在线上恒为 0 分,还遮住了可用的内存余弦回退。
// 这直接触碰 design/03 §3「选增强则向量检索路径真实生效」、design/04
// §7「配置后必须真实走语义路径(非空壳)」与 AGENTS §9 的红线。
//
// 接线方式:装配方(cmd/cyrene/wire_runtime.go)把索引交给检索器
// (memory.WithVectorIndex),NewRetriever 随即把它接到同一个 store 的
// 写入路径(attachVectorIndex),因此「写记忆」与「写向量」不再可能
// 只发生一半,也无需装配方多记一步。
// attachVectorIndex 把持久语义索引接到该 store 的写入路径:此后每条
// 经 Add 落库的条目都会被向量化并 upsert 进索引。nil 索引是空操作
// (保持「未配置索引」的语义)。它可被并发调用,last-writer-wins。
func (s *Store) attachVectorIndex(idx vector.Index) {
if s == nil || idx == nil {
return
}
s.vecMu.Lock()
defer s.vecMu.Unlock()
s.vec = idx
}
// vectorIndex 返回当前接入的持久语义索引,未接入时为 nil。
func (s *Store) vectorIndex() vector.Index {
if s == nil {
return nil
}
s.vecMu.RLock()
defer s.vecMu.RUnlock()
return s.vec
}
// indexItem 把已落库的 item 向量化并写入持久索引(写入侧闭环)。
// 未接入索引、索引报错或向量数不符时都只记日志:条目本身已经
// 在关系表里,语义增强的失败绝不使写入失败,检索侧另有内存余弦
// 回退。索引文本取 item.Content —— 与内存语义回退所用的文本一致,
// 使「索引作答」与「回退作答」在同一份输入上可比。
func (s *Store) indexItem(ctx context.Context, item MemoryItem) {
idx := s.vectorIndex()
if idx == nil {
return
}
vecs, err := idx.Embed(ctx, []string{item.Content})
if err != nil {
s.log.Warn("memory: vector embed for index failed, item left unindexed",
"id", item.ID, "error", err)
return
}
if len(vecs) == 0 {
s.log.Warn("memory: vector embed returned no vector, item left unindexed", "id", item.ID)
return
}
if err := idx.Add(ctx, item.ID, vecs[0]); err != nil {
s.log.Warn("memory: vector index add failed, item left unindexed",
"id", item.ID, "error", err)
}
}