feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:47:39 +08:00
1 parent 9b903a89bc
commit 509dc412eb
12 files changed
+660 -55

No files matched your search

@@ -55,6 +55,31 @@ func (s *Store) PutProject(ctx context.Context, p Project) error {
return nil
}
// EnsureProject 在 id 尚无项目条目时插入一条最小索引记录,已存在时
// **原样保留**(绝不覆盖既有 title/desc)。title 为空时取 id。
//
// 它是「项目记忆链接项目索引」(design §3)的落地路径:项目作用域记忆
// 携带 project_id 指向项目索引层级,只有索引条目存在时该链接才可解析
// (审计 2026-10-05 §4:project 域记忆的 project_id 大量悬空、表为空)。
// 与 PutProject 的区别是**不覆盖**:写入来自记忆时只有 id 可信,不能拿它
// 去改写由 RAG 导入建立的真实 title/desc。空 id 返回 ErrMissingProject。
func (s *Store) EnsureProject(ctx context.Context, id, title string) error {
if strings.TrimSpace(id) == "" {
return ErrMissingProject
}
if strings.TrimSpace(title) == "" {
title = id
}
now := formatTime(time.Now().UTC())
_, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
VALUES (?, ?, '', ?, ?)
ON CONFLICT(id) DO NOTHING`, id, title, now, now)
if err != nil {
return fmt.Errorf("projectindex: ensure project: %w", err)
}
return nil
}
// GetProject 返回给定 id 的项目。id 为空或
// 不存在时返回 ErrProjectNotFound。
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
+21
View File
@@ -2,6 +2,7 @@ package memory
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
@@ -211,6 +212,10 @@ func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
}
if store != nil {
store.attachVectorIndex(r.vindex)
// 项目索引同时接到写入路径:项目作用域记忆的 ProjectID 落的索引
// 条目因此始终存在(见 store_project.go),Retriever.projectFor 才
// 有东西可解析。
store.attachProjectIndex(r.projectStore)
}
return r
}
@@ -293,6 +298,9 @@ func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK
if len(results) > topK {
results = results[:topK]
}
// 命中即使用:把返回命中的衰减物化回写 decay_weight(design §2,
// 裁决 Q3)。失败只记日志,绝不影响本次检索结果。
r.persistDecay(ctx, results, relevance, now)
return results, nil
}
@@ -432,12 +440,25 @@ func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, q
// 没有项目索引存储、条目不是
// 项目作用域、或条目不带 ProjectID 时返回 nil。加载失败按「无
// 项目」处理,而不是让整次检索失败(design §3 项目索引各角色可查)。
//
// 加载失败**必须留下带来源的日志**(审计 2026-10-05 §7 静默吞点):
// 「项目行不存在」(ErrProjectNotFound,项目记忆的 project_id 悬空)是
// 数据状态,按 Debug 记;其余错误(存储故障等)按 Warn 记,使真实的
// 项目索引故障不会被当成「这条记忆本来就没项目」而无限静默。两条都不
// 影响检索结果,条目照常返回,只是不带 Project。
func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex.Project {
if r.projectStore == nil || it.Scope != ScopeProject || strings.TrimSpace(it.ProjectID) == "" {
return nil
}
p, err := r.projectStore.GetProject(ctx, it.ProjectID)
if err != nil {
if errors.Is(err, projectindex.ErrProjectNotFound) {
r.log.Debug("memory: project link not found, result returned without project",
"project_id", it.ProjectID)
} else {
r.log.Warn("memory: project link lookup failed, result returned without project",
"project_id", it.ProjectID, "error", err)
}
return nil
}
return p
+86
View File
@@ -0,0 +1,86 @@
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
}
+199
View File
@@ -0,0 +1,199 @@
package memory
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
)
// newProjectStoreForTest 打开一个隔离的项目索引库并返回其 store。
func newProjectStoreForTest(t *testing.T) *projectindex.Store {
t.Helper()
ctx := context.Background()
projDB, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open project db: %v", err)
}
t.Cleanup(func() { _ = projDB.Close() })
ps, err := projectindex.New(ctx, projDB, testLogger())
if err != nil {
t.Fatalf("projectindex.New: %v", err)
}
return ps
}
// projectForLogged 报告缓冲里是否存在一条 source=memory/retriever 且 msg 含 want
// 的 JSON 记录。用结构化字段断言,避免依赖文本格式。
func projectForLogged(t *testing.T, buf *bytes.Buffer, want string) bool {
t.Helper()
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
rec := map[string]any{}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue
}
if rec["source"] != "memory/retriever" {
continue
}
if msg, _ := rec["msg"].(string); strings.Contains(msg, want) {
return true
}
}
return false
}
// TestRetriever_ProjectFor_LogsLookupFailure 钉住审计 2026-10-05 §7 的静默吞点:
// 项目链接加载失败(项目行不存在 / 存储故障)必须留下带来源的日志,绝不静默按
// 「无项目」返回。这里构造一条 ProjectID 悬空的项目作用域记忆,断言检索器记下一条
// source=memory/retriever 的记录,且命中仍照常返回(加载失败不影响检索)。
func TestRetriever_ProjectFor_LogsLookupFailure(t *testing.T) {
t.Parallel()
ctx := context.Background()
ps := newProjectStoreForTest(t)
ms := newTestStore(t)
addItem(t, ms, MemoryItem{
PersonaID: "persona.a", Content: "Alpha 项目事实",
Scope: ScopeProject, ProjectID: "project.missing", Kind: KindWorldEvent,
})
buf := &bytes.Buffer{}
log := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
r := NewRetriever(ms, WithProjectIndex(ps), WithRetrieverLogger(log))
results, err := r.Retrieve(ctx, "persona.a", "Alpha", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) == 0 {
t.Fatal("expected a result (a lookup failure must not drop the item)")
}
if results[0].Project != nil {
t.Fatalf("Result.Project = %+v, want nil for a dangling project_id", results[0].Project)
}
if !projectForLogged(t, buf, "project link not found") {
t.Fatalf("缺带来源(source=memory/retriever)的「项目链接加载失败」日志:%q", buf.String())
}
}
// TestRetriever_ProjectWriteback_MaterializesIndexEntry 钉住审计 2026-10-05 §4 的
// 「project 域记忆 project_id 悬空 / project 表数据为零」:接入项目索引后,写入一条
// 带 ProjectID 的项目作用域记忆会确保项目索引条目存在,使 projectFor 能解析链接
// (模型因此够得着「项目索引 → 项目描述」层级),且只补不覆盖既有 title/desc。
func TestRetriever_ProjectWriteback_MaterializesIndexEntry(t *testing.T) {
t.Parallel()
ctx := context.Background()
ps := newProjectStoreForTest(t)
if err := ps.PutProject(ctx, projectindex.Project{ID: "project.beta", Title: "真实标题", Desc: "真实描述"}); err != nil {
t.Fatalf("PutProject: %v", err)
}
ms := newTestStore(t)
_ = NewRetriever(ms, WithProjectIndex(ps)) // 把项目索引接到写入路径
addItem(t, ms, MemoryItem{
PersonaID: "persona.a", Content: "Alpha 项目事实",
Scope: ScopeProject, ProjectID: "project.alpha", Kind: KindWorldEvent,
})
addItem(t, ms, MemoryItem{
PersonaID: "persona.a", Content: "Beta 项目事实",
Scope: ScopeProject, ProjectID: "project.beta", Kind: KindWorldEvent,
})
p, err := ps.GetProject(ctx, "project.alpha")
if err != nil {
t.Fatalf("project index entry not materialized from memory write: %v", err)
}
if p.ID != "project.alpha" {
t.Fatalf("materialized project id = %q, want project.alpha", p.ID)
}
// 只补不覆盖:既有项目的中文标题必须原样保留。
kept, err := ps.GetProject(ctx, "project.beta")
if err != nil {
t.Fatalf("GetProject(beta): %v", err)
}
if kept.Title != "真实标题" || kept.Desc != "真实描述" {
t.Fatalf("EnsureProject clobbered an existing entry: %+v", kept)
}
// 端到端:检索命中带出链接的项目。
r := NewRetriever(ms, WithProjectIndex(ps))
results, err := r.Retrieve(ctx, "persona.a", "Alpha", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) == 0 || results[0].Project == nil || results[0].Project.ID != "project.alpha" {
t.Fatalf("Result.Project = %+v, want the materialized project.alpha", results[0].Project)
}
}
// TestRetriever_Retrieve_MaterializesDecayWeight 钉住审计 2026-10-05 §7 / design/03
// §2 裁决 Q3:检索命中时把衰减值物化回写 decay_weight(实库曾 388/388 全 1.0)。
func TestRetriever_Retrieve_MaterializesDecayWeight(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
old := time.Now().UTC().Add(-60 * 24 * time.Hour)
stored := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "coffee preference", UpdatedAt: old,
Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
if _, err := r.Retrieve(ctx, "persona.a", "coffee", 10); err != nil {
t.Fatalf("Retrieve: %v", err)
}
var dw float64
var updated string
if err := s.db.QueryRowContext(ctx,
"SELECT decay_weight, updated_at FROM memory_items WHERE id = ?", stored.ID).Scan(&dw, &updated); err != nil {
t.Fatalf("read decay_weight: %v", err)
}
if dw >= defaultDecayWeight {
t.Fatalf("decay_weight = %v, want < %v(命中物化的衰减值)", dw, defaultDecayWeight)
}
// 命中即使用:updated_at 被推进到本轮,避免下次读把已物化的值再乘一遍。
if updated == formatTime(old) {
t.Fatalf("updated_at 未随命中推进:%q", updated)
}
}
// TestRetriever_Retrieve_ReadOnlyContext_NoDecayWrite 钉住只读隔离(design/03 §5
// 「能查不能写」):受限(群聊 / 陌生人)检索经 WithoutDecayWriteback 调用时,
// 记忆库零写入。
func TestRetriever_Retrieve_ReadOnlyContext_NoDecayWrite(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
old := time.Now().UTC().Add(-60 * 24 * time.Hour)
stored := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "coffee preference", UpdatedAt: old,
Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
if _, err := r.Retrieve(WithoutDecayWriteback(ctx), "persona.a", "coffee", 10); err != nil {
t.Fatalf("Retrieve: %v", err)
}
var dw float64
if err := s.db.QueryRowContext(ctx,
"SELECT decay_weight FROM memory_items WHERE id = ?", stored.ID).Scan(&dw); err != nil {
t.Fatalf("read decay_weight: %v", err)
}
if dw != defaultDecayWeight {
t.Fatalf("只读检索写入了 decay_weight = %v, want 保持不变 %v", dw, defaultDecayWeight)
}
}
+9 -35
View File
@@ -42,6 +42,7 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
@@ -153,6 +154,13 @@ type Store struct {
// nil 表示没有配置持久索引,检索据此回退到内存余弦。
vecMu sync.RWMutex
vec vector.Index
// projMu 保护 proj,使项目索引存储能在装配后并发接入,而写入路径
// 每写一条项目作用域记忆就确保它链接的项目索引条目存在(见
// ensureProjectIndex)。proj 为 nil 表示未接入项目索引,此时写入
// 不触碰项目索引(design §3 项目记忆链接项目索引)。
projMu sync.RWMutex
proj *projectindex.Store
}
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
@@ -230,6 +238,7 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
}
s.indexItem(ctx, item)
s.ensureProjectIndex(ctx, item)
return item, nil
}
@@ -459,39 +468,4 @@ func discardLogger(log *slog.Logger) *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// schema DDL。internal/db 中的迁移框架是领域表的权威
// 机制,但这里刻意不用它:db 包的迁移测试断言精确的
// 迁移数量,而记忆存储必须自包含、
// 可在任意 *sql.DB 上使用。表与索引因此
// 由 NewStore 幂等创建。
const createMemoryItemsTable = `
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
role_id TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 1.0,
source_conversation TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
decay_weight REAL NOT NULL DEFAULT 1.0,
status TEXT NOT NULL DEFAULT 'active',
merged_into TEXT NOT NULL DEFAULT '',
merged_from TEXT NOT NULL DEFAULT '[]',
merge_count INTEGER NOT NULL DEFAULT 1,
occurred_at TEXT NOT NULL DEFAULT '',
valid_to TEXT NOT NULL DEFAULT '',
expires_at TEXT NOT NULL DEFAULT ''
)`
const createMemoryItemsPersonaIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id
ON memory_items (persona_id)`
const createMemoryItemsPersonaCreatedIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
ON memory_items (persona_id, created_at)`
+62
View File
@@ -0,0 +1,62 @@
package memory
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
)
// 本文件是「项目记忆 → 项目索引」链接的写入侧:把项目作用域记忆携带的
// ProjectID 落成一条真实的项目索引条目,使 design/03 §3 的
// 「项目索引 → 项目描述 → 章节内容」三层在第一层就有数据可解析。
//
// 背景(审计 2026-10-05 §4/§7):project / project_chapter 两表数据为零,
// 而实库里 project 域记忆的 project_id 大量悬空 —— Retriever.projectFor
// 对这些条目恒返回 nil,模型够不着项目/章节。项目索引的权威写入源是
// 知识库导入(admin RAG,PutProject + AddChapter);但一条**先写下来**的
// 项目记忆(LLM 提取或手动添加)若引用一个尚未导入的项目 id,链接即断。
// 这里在记忆写入时确保该索引条目存在(只补不覆盖),把悬空链接就地接通,
// 而不新建知识内容(记忆 ≠ 资料,design §3)。
// attachProjectIndex 把项目索引存储接到该 store 的写入路径:此后每条经
// Add 落库的项目作用域记忆都会确保其 ProjectID 对应的索引条目存在。nil
// 索引是空操作。可并发调用,last-writer-wins。
func (s *Store) attachProjectIndex(idx *projectindex.Store) {
if s == nil || idx == nil {
return
}
s.projMu.Lock()
defer s.projMu.Unlock()
s.proj = idx
}
// projectIndex 返回当前接入的项目索引存储,未接入时为 nil。
func (s *Store) projectIndex() *projectindex.Store {
if s == nil {
return nil
}
s.projMu.RLock()
defer s.projMu.RUnlock()
return s.proj
}
// ensureProjectIndex 为项目作用域且带 ProjectID 的条目确保项目索引条目
// 存在。其它作用域、空 ProjectID、未接入项目索引时都是空操作。
//
// 失败只记日志:项目链接是元数据,接通失败绝不使记忆写入失败(与
// indexItem 的语义一致);条目的关系行已经在库里,Retriever.projectFor
// 仍有日志可循(见 retrieve.go 的静默吞点修复)。
func (s *Store) ensureProjectIndex(ctx context.Context, item MemoryItem) {
if item.Scope != ScopeProject || strings.TrimSpace(item.ProjectID) == "" {
return
}
idx := s.projectIndex()
if idx == nil {
return
}
if err := idx.EnsureProject(ctx, item.ProjectID, item.ProjectID); err != nil {
s.log.Warn("memory: ensure project index entry failed, link left dangling",
"project_id", item.ProjectID, "id", item.ID, "error", err)
}
}
+38
View File
@@ -0,0 +1,38 @@
package memory
// schema DDL。internal/db 中的迁移框架是领域表的权威
// 机制,但这里刻意不用它:db 包的迁移测试断言精确的
// 迁移数量,而记忆存储必须自包含、
// 可在任意 *sql.DB 上使用。表与索引因此
// 由 NewStore 幂等创建。
const createMemoryItemsTable = `
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
role_id TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 1.0,
source_conversation TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
decay_weight REAL NOT NULL DEFAULT 1.0,
status TEXT NOT NULL DEFAULT 'active',
merged_into TEXT NOT NULL DEFAULT '',
merged_from TEXT NOT NULL DEFAULT '[]',
merge_count INTEGER NOT NULL DEFAULT 1,
occurred_at TEXT NOT NULL DEFAULT '',
valid_to TEXT NOT NULL DEFAULT '',
expires_at TEXT NOT NULL DEFAULT ''
)`
const createMemoryItemsPersonaIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id
ON memory_items (persona_id)`
const createMemoryItemsPersonaCreatedIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
ON memory_items (persona_id, created_at)`