diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 2a30924..cc1308c 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -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 } diff --git a/internal/runtime/loop/memory_readonly_decay_test.go b/internal/runtime/loop/memory_readonly_decay_test.go new file mode 100644 index 0000000..b076c61 --- /dev/null +++ b/internal/runtime/loop/memory_readonly_decay_test.go @@ -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) + } +} diff --git a/internal/runtime/loop/memory_scoped_write_test.go b/internal/runtime/loop/memory_scoped_write_test.go new file mode 100644 index 0000000..e920ab9 --- /dev/null +++ b/internal/runtime/loop/memory_scoped_write_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/projectindex/project.go b/internal/runtime/memory/projectindex/project.go index 993ab4a..0bbe284 100644 --- a/internal/runtime/memory/projectindex/project.go +++ b/internal/runtime/memory/projectindex/project.go @@ -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) { diff --git a/internal/runtime/memory/retrieve.go b/internal/runtime/memory/retrieve.go index bda05ce..d815cb0 100644 --- a/internal/runtime/memory/retrieve.go +++ b/internal/runtime/memory/retrieve.go @@ -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 diff --git a/internal/runtime/memory/retrieve_decay.go b/internal/runtime/memory/retrieve_decay.go new file mode 100644 index 0000000..3c51688 --- /dev/null +++ b/internal/runtime/memory/retrieve_decay.go @@ -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 +} diff --git a/internal/runtime/memory/retrieve_p2_test.go b/internal/runtime/memory/retrieve_p2_test.go new file mode 100644 index 0000000..2d52934 --- /dev/null +++ b/internal/runtime/memory/retrieve_p2_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index fa62156..18c0538 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -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)` diff --git a/internal/runtime/memory/store_project.go b/internal/runtime/memory/store_project.go new file mode 100644 index 0000000..94a6ba4 --- /dev/null +++ b/internal/runtime/memory/store_project.go @@ -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) + } +} diff --git a/internal/runtime/memory/store_schema.go b/internal/runtime/memory/store_schema.go new file mode 100644 index 0000000..5a21e50 --- /dev/null +++ b/internal/runtime/memory/store_schema.go @@ -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)` diff --git a/plugins/tools/io/knowledge.go b/plugins/tools/io/knowledge.go index 16016da..65a9e53 100644 --- a/plugins/tools/io/knowledge.go +++ b/plugins/tools/io/knowledge.go @@ -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{ diff --git a/plugins/tools/io/knowledge_test.go b/plugins/tools/io/knowledge_test.go index ae630b7..5f49f71 100644 --- a/plugins/tools/io/knowledge_test.go +++ b/plugins/tools/io/knowledge_test.go @@ -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) {