fix(memory,loop): 记忆作用域读写成对 + source_conversation 结构化(裁决 1.9/04.4.2/04.4.3)

- 04.4.2/1.9 读侧:Retrieve 候选集并集 = 本人 persona 域 ∪ user 共享域 ∪ project 共享域(他角色 persona 域仍排除),群聊/陌生人取受限收窄
- 写侧:群聊/陌生人整段跳过记忆提取(可查不可写),persona 域仅属主可写
- 04.4.3:source_conversation 不再由 LLM 自由生成(prompt 不索要、候选结构不含该键),落库时无条件盖真实 {session_id,message_id},编解码集中在 memory/source.go
- 新增跨角色种子用例(原用例即使关掉并集也会通过)+ 成对用例「同群聊上下文:读得到共享域、写不进 store」
- 同步 store.go/retrieve.go 文档注释,与 design/03 §3/§4 口径一致
This commit is contained in:
AskaEth committed 2026-10-04 12:07:44 +08:00
1 parent af8591b78a
commit 0ce498754a
11 files changed
+428 -60

No files matched your search

+53 -12
View File
@@ -42,8 +42,10 @@ type Result struct {
Item MemoryItem
Score float64
// SourceConversation is the conversation the item was extracted from, for
// 溯源 (design §2). It mirrors Item.SourceConversation so callers can trace a
// hit without reaching into Item.
// 溯源 (design §2). 裁决 04.4.3 起为结构化 JSON(session_id/message_id,
// 见 ParseSourceConversation),由持久化层盖真实 id。It mirrors
// Item.SourceConversation so callers can trace a hit without reaching into
// Item.
SourceConversation string
// Project is the linked project-index entry (设计 §3 项目分层), populated only
// when the retriever is wired with a project-index store and the item is a
@@ -52,11 +54,13 @@ type Result struct {
Project *projectindex.Project
}
// Retriever ranks a persona's long-term memories by relevance (keyword and,
// when an embedder or vector index is configured, semantic vectors) weighted by
// decay (decay_weight × time decay), then returns the top K. It is stateless and
// safe for concurrent use; the ranking is recomputed from the store on every
// call, so results always reflect persisted state.
// Retriever ranks the long-term memories visible to a persona — its own items
// plus the shared user/project scopes (design §3/§4, see visibleCandidates) —
// by relevance (keyword and, when an embedder or vector index is configured,
// semantic vectors) weighted by decay (decay_weight × time decay), then returns
// the top K. It is stateless and safe for concurrent use; the ranking is
// recomputed from the store on every call, so results always reflect persisted
// state.
type Retriever struct {
store *Store
embedder embedding.Embedder
@@ -149,16 +153,20 @@ func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
// Retrieve returns up to topK items for personaID that best match query, ranked
// by relevance (keyword + optional semantic) weighted by decay (decay_weight ×
// time decay). Query and candidate contents are both read under ctx; a canceled
// context aborts the work. A blank query returns the most recently used items
// (decay order only).
// time decay). 候选集是 design/03 §3/§4 的可见域并集:该角色自己的条目
// (ListByPersona)∪ 全局共享的用户习惯域(ScopeUser)∪ 人人可读的
// 项目域(ScopeProject),按 id 去重。他角色的角色日常记忆(ScopePersona)
// 绝不进入候选集 —— 跨角色读只走「把 B 当子代理去查 B」的派发机制
// (design/22 §9),直接检索不越权。Query and candidate contents are both
// read under ctx; a canceled context aborts the work. A blank query returns the
// most recently used items (decay order only).
func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]Result, error) {
if topK <= 0 {
topK = defaultTopK
}
items, err := r.store.ListByPersona(ctx, personaID)
items, err := r.visibleCandidates(ctx, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list: %w", err)
return nil, err
}
if len(items) == 0 {
return []Result{}, nil
@@ -214,6 +222,39 @@ func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK
return results, nil
}
// visibleCandidates 载入 personaID 可见的候选记忆并集(design/03
// §3/§4):本人条目(ListByPersona)∪ ScopeUser 全局共享域 ∪
// ScopeProject 人人可读域,按 id 去重。本人条目先入集,因此去重
// 保留的是同一行的唯一实例;他角色的 ScopePersona 条目(角色
// 日常)不在其列 —— 跨角色读走子代理派发(design/22 §9),
// 直接检索不得越权。任一载入失败都整体报错,绝不静默丢弃
// 某个共享域。
func (r *Retriever) visibleCandidates(ctx context.Context, personaID string) ([]MemoryItem, error) {
own, err := r.store.ListByPersona(ctx, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list: %w", err)
}
seen := make(map[string]struct{}, len(own))
for _, it := range own {
seen[it.ID] = struct{}{}
}
items := own
for _, scope := range []Scope{ScopeUser, ScopeProject} {
shared, err := r.store.ListScope(ctx, scope, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list %s scope: %w", scope, err)
}
for _, it := range shared {
if _, dup := seen[it.ID]; dup {
continue
}
seen[it.ID] = struct{}{}
items = append(items, it)
}
}
return items, nil
}
// keywordScores scores each item against query with an in-memory TF-IDF inverted
// index (embedding.Index), reusing the embedding package's tokenizer. Scores are
// normalized to [0,1] by the maximum so they are comparable with the semantic