feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
This commit is contained in:
1 parent
9b903a89bc
commit
509dc412eb
12 files changed
+660
-55
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user