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

+17 -11
View File
@@ -101,7 +101,10 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
return block, nil
}
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
results, err := l.retriever.Retrieve(rctx, personaID, query, 0)
// 受限检索是只读的(design/03 §5「能查不能写」):关掉衰减退化的物化
// 回写,使群聊/陌生人的一次召回不会改动记忆行(哪怕是 decay_weight 这类
// 派生列,也不该在只读上下文里被写)。
results, err := l.retriever.Retrieve(memory.WithoutDecayWriteback(rctx), personaID, query, 0)
if err != nil {
l.finishMemoryRecallSpan(span, "restricted", 0, 0, err)
return "", err
@@ -290,11 +293,15 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message,
}
// persistMemories 把提取出的条目写入记忆 store,为每个条目
// 打上产出该 turn 的角色。该赋值刻意无条件:
// extractor 刻意不感知角色(它不再持有一个在构造期
// 内嵌的人格,cmd/cyrene/extractor.go),否则多角色
// 进程会把每条记忆都归到一个角色下。角色为空(完全未配置
// persona)时条目保持 extractor 产出时的样子。
// 打上产出该 turn 的角色。角色归属经 Store 的作用域写规则
// (memory.Store.AddScoped,design/03 §3)落定,而不是在循环里改写:
// extractor 刻意不感知角色(它不再持有一个在构造期内嵌的人格,
// cmd/cyrene/extractor.go),因此它产出的条目通常不带 PersonaID,
// AddScoped 用本回合角色(actorID)把它补上;若 extractor 送出一条
// **归属别的角色**的角色作用域条目,AddScoped 以 ErrReadOnly 拒绝,
// 绝不把它冒充成本回合角色的日常记忆(Store 层防线,审计 2026-10-05
// §1.3「作用域写规则未接线」)。角色为空(完全未配置 persona)时
// AddScoped 返回 ErrMissingPersona,条目按失败记录日志。
//
// 溯源同样在此盖真实 id(裁决 04.4.3,design/03 §2):
// source_conversation 由本持久化点从运行上下文取 Run 的
@@ -315,11 +322,8 @@ func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, s
wctx, span := l.span(ctx, spanNameMemoryWrite, trace.SpanKindMemory)
written, failed := 0, 0
for _, it := range items {
if personaID != "" {
it.PersonaID = personaID
}
it.SourceConversation = source
if _, err := l.memory.Add(wctx, it); err != nil {
if _, err := l.memory.AddScoped(wctx, personaID, it); err != nil {
failed++
l.log.Warn("memory store", "step", step, "error", err)
continue
@@ -416,7 +420,9 @@ func (l *Loop) recordIntent(ctx context.Context, messages []model.Message, step
Confidence: judgment.Confidence,
SourceConversation: memory.FormatSourceConversation(loopSession(ctx), loopTurn(ctx)),
}
if _, err := l.memory.Add(ctx, item); err != nil {
// 经 Store 的作用域写规则落库(ScopeUser 对任何角色可写,design/03 §3),
// 与 persistMemories 共用同一条受规则约束的写入路径,而不是绕过规则直插。
if _, err := l.memory.AddScoped(ctx, personaID, item); err != nil {
l.log.Warn("memory: L4 intent writeback failed", "step", step, "error", err)
return
}
@@ -0,0 +1,73 @@
package loop
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// seedDecayProbe 在独立 store 上放一条**会被命中**(content 含 query 词)且
// updated_at 很旧(60 天前,衰减明显 < 1.0)的记忆,返回 store 与落库条目。
func seedDecayProbe(t *testing.T, query string) (*memory.Store, memory.MemoryItem) {
t.Helper()
ctx := context.Background()
store := newTestMemoryStore(t)
stored, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona-a", Content: query + " preference",
UpdatedAt: time.Now().UTC().Add(-60 * 24 * time.Hour),
Scope: memory.ScopeUser, Kind: memory.KindRelationship,
})
if err != nil {
t.Fatalf("seed memory: %v", err)
}
return store, stored
}
// TestMemoryBlock_ReadOnlyLane_NoDecayWrite 钉住 AGENTS §9 / design/03 §5 的红线
// 「群聊 / 陌生人只读不写记忆」在 **loop 级**成立:真实走 memoryBlock →
// retrieveMemoryBlock 的受限车道(群聊/陌生人,MemWritable=false)召回时,命中
// 记忆的 decay_weight 与 updated_at 必须逐字不变(零写入)。
//
// 为什么必须落在 loop 级:关掉衰减回写的是**受限车道自己在检索调用点**传的
// memory.WithoutDecayWriteback(memory.go 的 retrieveMemoryBlock)。只在 Retriever
// 上单测「显式传入时只读」钉不住「受限车道真的传了它」——把那一行去掉,那些
// 测试仍全绿(审计实证的测试缺口)。
//
// 正向对照:主人车道(OwnerEnv)走同一路径时两者必须被推进,否则本测试可能
// 因为「根本没命中」而假绿。
func TestMemoryBlock_ReadOnlyLane_NoDecayWrite(t *testing.T) {
t.Parallel()
ctx := context.Background()
msgs := []model.Message{model.TextMessage(model.RoleUser, "coffee")}
// 正向对照:主人车道推进衰减(证明命中条件成立,不是空跑)。
ownerStore, ownerSeed := seedDecayProbe(t, "coffee")
owner := New(Options{Retriever: memory.NewRetriever(ownerStore), PersonaID: "persona-a"})
owner.memoryBlock(WithSessionEnv(ctx, OwnerEnv()), msgs)
ownerAfter, err := ownerStore.Get(ctx, ownerSeed.ID)
if err != nil {
t.Fatalf("owner Get: %v", err)
}
if ownerAfter.DecayWeight >= ownerSeed.DecayWeight || ownerAfter.UpdatedAt.Equal(ownerSeed.UpdatedAt) {
t.Fatalf("主人车道未推进衰减(对照不成立):before=%v/%v after=%v/%v",
ownerSeed.DecayWeight, ownerSeed.UpdatedAt, ownerAfter.DecayWeight, ownerAfter.UpdatedAt)
}
// 红线:受限车道对命中记忆零写入。
groupEnv := SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
groupStore, groupSeed := seedDecayProbe(t, "coffee")
group := New(Options{Retriever: memory.NewRetriever(groupStore), PersonaID: "persona-a"})
group.memoryBlock(WithSessionEnv(ctx, groupEnv), msgs)
groupAfter, err := groupStore.Get(ctx, groupSeed.ID)
if err != nil {
t.Fatalf("group Get: %v", err)
}
if groupAfter.DecayWeight != groupSeed.DecayWeight || !groupAfter.UpdatedAt.Equal(groupSeed.UpdatedAt) {
t.Fatalf("受限车道写入了记忆(红线破坏):before=%v/%v after=%v/%v",
groupSeed.DecayWeight, groupSeed.UpdatedAt, groupAfter.DecayWeight, groupAfter.UpdatedAt)
}
}
@@ -0,0 +1,65 @@
package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fixedExtractor(定义见 flow_spans_test.go)返回一组固定条目,用于驱动持久化
// 路径的边界断言。
// TestPersistMemories_ScopedWriteRule_RejectsForeignPersona 钉住审计 2026-10-05
// §1.3「Store 作用域写规则未接线」:提取持久化经 Store 的作用域写规则
// (AddScoped)落库。一条归属**别的角色**的角色作用域条目必须被拒(ErrReadOnly),
// 绝不被冒充成本回合角色的日常记忆。
func TestPersistMemories_ScopedWriteRule_RejectsForeignPersona(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
l := New(Options{
Memory: store,
Extractor: fixedExtractor{items: []memory.MemoryItem{
{PersonaID: "persona-b", Scope: memory.ScopePersona, Kind: memory.KindRelationship, Content: "b 的角色日常"},
}},
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 b 的日常")}, 1)
items, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 0 {
t.Fatalf("跨角色的角色作用域写被接受:%d 条(作用域写规则未接线)", len(items))
}
}
// TestPersistMemories_PersonaAgnosticExtractor_StampedByStore 证明接线后正常路径
// 不变:与角色无关的提取器(PersonaID 为空)产出的条目仍由 Store 用本回合角色
// (actorID)补齐归属,照常落库。
func TestPersistMemories_PersonaAgnosticExtractor_StampedByStore(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
l := New(Options{
Memory: store,
Extractor: fixedExtractor{items: []memory.MemoryItem{
{Scope: memory.ScopePersona, Kind: memory.KindRelationship, Content: "主人爱喝冰美式"},
}},
PersonaID: "persona-a",
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if len(items) != 1 || items[0].PersonaID != "persona-a" {
t.Fatalf("角色无关提取器未被 Store 盖章:%+v", items)
}
}
@@ -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)`
+28 -9
View File
@@ -91,27 +91,46 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
if v, ok := argInt(m, "top_k"); ok && v > 0 {
topK = v
}
results, err := k.retriever.Retrieve(ctx, persona, query, topK)
// 群聊/陌生人(受限环境)是只读的(design/03 §5「能查不能写」):检索
// 时关掉衰减退化的物化回写(memory.WithoutDecayWriteback),并在返回前
// 复用循环注入路径上的同一隔离判据(loop.FilterSharedVisible),绝不另造
// 第二套 —— 否则一旦管理面板把本工具放行给群聊/陌生人,就会返回角色日常
// (ScopePersona,主人私有)与 L4 意图记录(主人逐句原话),触碰 design/03
// §5「不泄露主人敏感信息」(审计 2026-10-05,P0 红线)。
restricted := loop.SessionEnvFrom(ctx).Restricted()
rctx := ctx
if restricted {
rctx = memory.WithoutDecayWriteback(ctx)
}
results, err := k.retriever.Retrieve(rctx, persona, query, topK)
if err != nil {
return nil, fmt.Errorf("io: knowledge: retrieve: %w", err)
}
// 群聊/陌生人(受限环境)只读非敏感的共享域记忆:复用循环注入路径上的
// 同一隔离判据(loop.FilterSharedVisible),绝不另造第二套 —— 否则一旦
// 管理面板把本工具放行给群聊/陌生人,就会返回角色日常(ScopePersona,
// 主人私有)与 L4 意图记录(主人逐句原话),触碰 design/03 §5
// 「不泄露主人敏感信息」(审计 2026-10-05,P0 红线)。
if loop.SessionEnvFrom(ctx).Restricted() {
if restricted {
results = loop.FilterSharedVisible(results)
}
out := make([]map[string]any, 0, len(results))
for _, r := range results {
out = append(out, map[string]any{
entry := map[string]any{
"id": r.Item.ID,
"kind": string(r.Item.Kind),
"scope": string(r.Item.Scope),
"content": r.Item.Content,
"score": r.Score,
})
}
// 项目作用域记忆链接到项目索引层级(design/03 §3:项目索引 →
// 项目描述 → 章节内容)。此前工具丢掉了 Result.Project,模型因此
// 只看得到一条句子,够不着它所属的项目(审计 2026-10-05 §4「模型
// 不可达章节」)。这里把链接的项目条目带出(id/title/desc),使模型
// 能沿「项目索引 → 项目描述」继续深入,而不是盲取一条悬空记忆。
if r.Project != nil {
entry["project"] = map[string]any{
"id": r.Project.ID,
"title": r.Project.Title,
"desc": r.Project.Desc,
}
}
out = append(out, entry)
}
if len(out) == 0 {
return map[string]any{
+37
View File
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -251,6 +252,42 @@ func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) {
}
}
// TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前
// 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目
// 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。
func TestKnowledge_SurfacesLinkedProject(t *testing.T) {
t.Parallel()
sr := &stubRetriever{results: []memory.Result{
{
Item: memory.MemoryItem{ID: "m1", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject},
Score: 0.7,
Project: &projectindex.Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"},
},
{
Item: memory.MemoryItem{ID: "m2", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser},
Score: 0.4,
},
}}
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr))
ctx := loop.WithSessionEnv(tool.WithTurnPersona(context.Background(), "persona-a"), loop.OwnerEnv())
got, err := kt.Execute(ctx, map[string]any{"query": "Cyrene"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
results := got.(map[string]any)["results"].([]map[string]any)
proj, ok := results[0]["project"].(map[string]any)
if !ok {
t.Fatalf("项目作用域命中未带出链接项目:%+v", results[0])
}
if proj["id"] != "proj.1" || proj["title"] != "Cyrene" || proj["desc"] != "陪伴型 AI 助手" {
t.Fatalf("project = %+v, want proj.1/Cyrene/陪伴型 AI 助手", proj)
}
if _, present := results[1]["project"]; present {
t.Fatalf("非项目命中不该带 project 键:%+v", results[1])
}
}
// TestKnowledge_OwnerEnv_AllScopes 钉住反向边界:owner(全权限)环境不做隔离过滤,
// 四类命中都照常返回 —— 修复只收窄群聊/陌生人,绝不误伤主人自己的完整读取。
func TestKnowledge_OwnerEnv_AllScopes(t *testing.T) {