feat(memory): M4 记忆完善(三类作用域访问规则+项目索引分层+LLM异步提取+衰减溯源+向量接入,兼容既有57用例)
This commit is contained in:
1 parent
5b37f5a551
commit
d947ec41a9
10 files changed
+1349
-53
No files matched your search
@@ -0,0 +1,23 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decayFactor computes the decay multiplier for a single item: the stored
|
||||
// decay_weight scaled by a time-decay that halves every recencyHalfLife days
|
||||
// (design §2 衰减: 久未使用的记忆降权). A freshly touched item keeps its full
|
||||
// decay_weight; an item unused for N half-lives is scaled to
|
||||
// decay_weight * 0.5^N, so it ranks progressively lower unless it stays relevant.
|
||||
//
|
||||
// age is measured from UpdatedAt; a future timestamp (clock skew) is clamped to
|
||||
// zero, so a just-recorded item is never scored above its base weight.
|
||||
func (r *Retriever) decayFactor(it MemoryItem, now time.Time) float64 {
|
||||
age := now.Sub(it.UpdatedAt)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
halfLives := float64(age) / float64(r.recencyHalfLife)
|
||||
return it.DecayWeight * math.Pow(0.5, halfLives)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetriever_DecayFactor_久未使用降权(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRetriever(newTestStore(t))
|
||||
now := time.Now().UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updatedAt time.Time
|
||||
decayWeight float64
|
||||
want float64
|
||||
}{
|
||||
{name: "fresh keeps full weight", updatedAt: now, decayWeight: 1.0, want: 1.0},
|
||||
// Default half-life is 30 days: 60 days ago = 2 half-lives = 2^-2.
|
||||
{name: "two half-lives halves twice", updatedAt: now.Add(-60 * 24 * time.Hour), decayWeight: 1.0, want: 0.25},
|
||||
// Lower base decay_weight scales the whole curve down.
|
||||
{name: "low decay_weight scales down", updatedAt: now, decayWeight: 0.5, want: 0.5},
|
||||
{name: "low weight plus age compounds", updatedAt: now.Add(-60 * 24 * time.Hour), decayWeight: 0.5, want: 0.125},
|
||||
// A future timestamp must not boost above the base weight.
|
||||
{name: "future timestamp clamps to zero", updatedAt: now.Add(time.Hour), decayWeight: 1.0, want: 1.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := r.decayFactor(MemoryItem{
|
||||
DecayWeight: tt.decayWeight,
|
||||
UpdatedAt: tt.updatedAt,
|
||||
}, now)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Fatalf("decayFactor = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_DecayWeight_降权(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
// A fresh item but with a near-zero decay_weight (design: 用户可削弱某记忆).
|
||||
addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "低权重但新鲜", UpdatedAt: now,
|
||||
DecayWeight: 0.1, Scope: ScopePersona, Kind: KindWorldEvent,
|
||||
})
|
||||
// A slightly older item with a full decay_weight.
|
||||
addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "权重高稍旧", UpdatedAt: now.Add(-time.Hour),
|
||||
DecayWeight: 1.0, Scope: ScopePersona, Kind: KindWorldEvent,
|
||||
})
|
||||
|
||||
r := NewRetriever(s)
|
||||
// A blank query matches nothing, so ordering is driven by the decay factor
|
||||
// (decay_weight × time decay) alone: the low-weight fresh item must fall
|
||||
// below the full-weight slightly-older item.
|
||||
results, err := r.Retrieve(ctx, "persona.a", "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("got %d results, want 2", len(results))
|
||||
}
|
||||
if got := results[0].Item.Content; got != "权重高稍旧" {
|
||||
t.Fatalf("top result = %q, want the full-decay-weight item", got)
|
||||
}
|
||||
if got := results[1].Item.Content; got != "低权重但新鲜" {
|
||||
t.Fatalf("second result = %q, want the low-decay-weight item", got)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,27 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNilAdapter is returned when an LLMExtractor is built or used without a
|
||||
// model.Adapter to drive extraction.
|
||||
ErrNilAdapter = errors.New("memory: nil model adapter")
|
||||
)
|
||||
|
||||
// Extractor derives candidate memory items from a conversation (design §1).
|
||||
//
|
||||
// The production path is an LLM-driven asynchronous extractor that denoises and
|
||||
// deduplicates each turn; that is deferred to milestone M4, and its integration
|
||||
// point is this interface. The M2 implementation is the rule-based
|
||||
// ManualExtractor, which only records what the user explicitly asks to remember.
|
||||
// Two implementations exist:
|
||||
//
|
||||
// - LLMExtractor: asks a model.Adapter to denoise and deduplicate each turn,
|
||||
// running synchronously (Extract) or on a background goroutine
|
||||
// (ExtractAsync).
|
||||
// - ManualExtractor: deterministic, rule-based — it only records what the user
|
||||
// explicitly asks to remember ("记住 X").
|
||||
type Extractor interface {
|
||||
// Extract returns memory items worth persisting from conversation. ctx
|
||||
// controls cancellation; a canceled context returns an error.
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// extractSystemPrompt tells the model.Adapter how to produce candidate memories:
|
||||
// a JSON array of objects, one per memory worth persisting. Format is locked so
|
||||
// parseCandidates can unmarshal it deterministically.
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。
|
||||
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id","source_conversation":"溯源"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
|
||||
|
||||
// llmCandidate is the wire shape the extractor asks the LLM to emit: one JSON
|
||||
// object per candidate memory. Scope/Kind default to the extractor's configured
|
||||
// values when the model does not provide a valid one.
|
||||
type llmCandidate struct {
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Source string `json:"source_conversation"`
|
||||
}
|
||||
|
||||
// LLMExtractor implements Extractor by asking a model.Adapter to derive candidate
|
||||
// memory items from a conversation (design §1 LLM 异步自动提取). It runs the LLM
|
||||
// call synchronously in Extract and on a background goroutine in ExtractAsync.
|
||||
//
|
||||
// It is idempotent and safe for concurrent use: candidate content is normalized
|
||||
// and deduplicated against a mutex-guarded seen set, so re-extracting the same
|
||||
// memory never yields a second copy, and the same content appearing twice inside
|
||||
// one conversation collapses to a single item.
|
||||
type LLMExtractor struct {
|
||||
adapter model.Adapter
|
||||
personaID string
|
||||
roleID string
|
||||
scope Scope
|
||||
kind Kind
|
||||
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// NewLLMExtractor builds an LLMExtractor whose extracted items are authored by
|
||||
// personaID/roleID and default to ScopePersona + KindRelationship when the model
|
||||
// does not specify otherwise. A nil adapter is rejected at use time via
|
||||
// ErrNilAdapter, matching the lazy construction pattern of the rest of the
|
||||
// runtime; callers may pass nil to build the struct and wire the adapter later.
|
||||
func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtractor {
|
||||
return &LLMExtractor{
|
||||
adapter: adapter,
|
||||
personaID: personaID,
|
||||
roleID: roleID,
|
||||
scope: ScopePersona,
|
||||
kind: KindRelationship,
|
||||
seen: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Extract returns the candidate memory items the adapter derives from
|
||||
// conversation. A nil adapter returns ErrNilAdapter; a canceled context aborts
|
||||
// before the LLM call. The response is parsed as a JSON array and then
|
||||
// deduplicated, so items already extracted by this extractor are dropped.
|
||||
func (e *LLMExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
|
||||
if e.adapter == nil {
|
||||
return nil, ErrNilAdapter
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := e.adapter.Chat(ctx, model.Request{
|
||||
Messages: buildExtractionPrompt(conversation),
|
||||
Purpose: "memory_extract",
|
||||
}, model.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: llm extract: %w", err)
|
||||
}
|
||||
cands, err := parseCandidates(resp.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.dedupeAndBuild(cands), nil
|
||||
}
|
||||
|
||||
// AsyncResult is the outcome of an ExtractAsync call: either candidate items or
|
||||
// the error that aborted extraction.
|
||||
type AsyncResult struct {
|
||||
Items []MemoryItem
|
||||
Err error
|
||||
}
|
||||
|
||||
// ExtractAsync submits the conversation to a background goroutine and returns a
|
||||
// channel that delivers exactly one AsyncResult when extraction finishes, closed
|
||||
// by the goroutine. A nil adapter returns ErrNilAdapter immediately. If ctx is
|
||||
// canceled before the goroutine runs, the result carries the cancellation error.
|
||||
// Because Extract is idempotent, repeated async extraction of the same memory is
|
||||
// safe and never double-records.
|
||||
func (e *LLMExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan AsyncResult, error) {
|
||||
if e.adapter == nil {
|
||||
return nil, ErrNilAdapter
|
||||
}
|
||||
out := make(chan AsyncResult, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
if err := ctx.Err(); err != nil {
|
||||
out <- AsyncResult{Err: err}
|
||||
return
|
||||
}
|
||||
items, err := e.Extract(ctx, conversation)
|
||||
out <- AsyncResult{Items: items, Err: err}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildExtractionPrompt turns the conversation into the model request messages:
|
||||
// the system instruction plus one user message carrying the transcript.
|
||||
func buildExtractionPrompt(conversation []model.Message) []model.Message {
|
||||
var b strings.Builder
|
||||
for _, m := range conversation {
|
||||
b.WriteString(string(m.Role))
|
||||
b.WriteString(": ")
|
||||
b.WriteString(messageText(m))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return []model.Message{
|
||||
model.TextMessage(model.RoleSystem, extractSystemPrompt),
|
||||
model.TextMessage(model.RoleUser, b.String()),
|
||||
}
|
||||
}
|
||||
|
||||
// parseCandidates decodes the adapter's text response into candidates. A fenced
|
||||
// ```json block is unwrapped so the model may wrap its array in markdown. Blank
|
||||
// or non-array content yields no candidates; malformed JSON is an error so the
|
||||
// caller can decide whether to retry or fall back.
|
||||
func parseCandidates(s string) ([]llmCandidate, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "json")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(s, "{") {
|
||||
return nil, fmt.Errorf("memory: llm candidates: expected a JSON array")
|
||||
}
|
||||
var cands []llmCandidate
|
||||
if err := json.Unmarshal([]byte(s), &cands); err != nil {
|
||||
return nil, fmt.Errorf("memory: decode llm candidates: %w", err)
|
||||
}
|
||||
if cands == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return cands, nil
|
||||
}
|
||||
|
||||
// dedupeAndBuild converts valid candidates to MemoryItems, dropping blank content
|
||||
// and any content already emitted by this extractor (idempotent dedup). It
|
||||
// requires e.mu; only valid items mark their dedup key so a malformed candidate
|
||||
// does not suppress a later valid one with the same text.
|
||||
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
out := make([]MemoryItem, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
content := strings.TrimSpace(c.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
key := dedupeKey(content)
|
||||
if _, ok := e.seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
it := MemoryItem{
|
||||
PersonaID: e.personaID,
|
||||
RoleID: e.roleID,
|
||||
ProjectID: strings.TrimSpace(c.ProjectID),
|
||||
Scope: Scope(normalizeScope(c.Scope, string(e.scope))),
|
||||
Kind: Kind(normalizeKind(c.Kind, string(e.kind))),
|
||||
Content: content,
|
||||
Tags: c.Tags,
|
||||
Confidence: c.Confidence,
|
||||
SourceConversation: strings.TrimSpace(c.Source),
|
||||
}
|
||||
if err := it.validate(); err != nil {
|
||||
continue
|
||||
}
|
||||
e.seen[key] = struct{}{}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeKey normalizes content for idempotent dedup: case-folded and whitespace-
|
||||
// collapsed, so "记住 A" and "记住 a" collide into one memory.
|
||||
func dedupeKey(content string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(content)), " ")
|
||||
}
|
||||
|
||||
// normalizeScope returns s when it names a defined scope, otherwise fallback.
|
||||
func normalizeScope(s, fallback string) string {
|
||||
if Scope(s).valid() {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// normalizeKind returns s when it names a defined kind, otherwise fallback.
|
||||
func normalizeKind(s, fallback string) string {
|
||||
if Kind(s).valid() {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// mockAdapter is a scriptable model.Adapter for tests. With no fn it returns an
|
||||
// empty candidate array, so a test that only needs a successful call can use it
|
||||
// directly.
|
||||
type mockAdapter struct {
|
||||
fn func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error)
|
||||
}
|
||||
|
||||
func (m *mockAdapter) Name() string { return "mock" }
|
||||
|
||||
func (m *mockAdapter) Chat(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
|
||||
if m.fn != nil {
|
||||
return m.fn(ctx, req, opts)
|
||||
}
|
||||
return model.Response{Content: "[]"}, nil
|
||||
}
|
||||
|
||||
func (m *mockAdapter) ChatStream(ctx context.Context, req model.Request, onChunk func(delta string)) (model.Response, error) {
|
||||
return model.Response{}, model.ErrStream
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Parse_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"用户喜欢冰美式","scope":"user","kind":"relationship","tags":["咖啡"],"confidence":0.9,"source_conversation":"conv.1"},
|
||||
{"content":"项目Alpha周三开会","scope":"project","project_id":"project.alpha","source_conversation":"conv.1"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "你好"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items, want 2", len(items))
|
||||
}
|
||||
if items[0].Content != "用户喜欢冰美式" || items[0].Scope != ScopeUser || items[0].Confidence != 0.9 {
|
||||
t.Errorf("items[0] = %+v, want parsed user item", items[0])
|
||||
}
|
||||
if items[0].SourceConversation != "conv.1" {
|
||||
t.Errorf("items[0].SourceConversation = %q, want conv.1", items[0].SourceConversation)
|
||||
}
|
||||
if items[1].ProjectID != "project.alpha" || items[1].Scope != ScopeProject {
|
||||
t.Errorf("items[1] = %+v, want project-scoped item", items[1])
|
||||
}
|
||||
if items[0].PersonaID != "persona.a" || items[0].RoleID != "role.1" {
|
||||
t.Errorf("items[0] author = %q/%q, want persona.a/role.1", items[0].PersonaID, items[0].RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_InvalidScopeKind_Fallback_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[{"content":"一个事实","scope":"bogus","kind":"bogus"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("got %d items, want 1", len(items))
|
||||
}
|
||||
// Bogus scope/kind fall back to the extractor's configured defaults.
|
||||
if items[0].Scope != ScopePersona || items[0].Kind != KindRelationship {
|
||||
t.Errorf("fallback scope/kind = %q/%q, want persona/relationship", items[0].Scope, items[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Dedup_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The model emits the same memory three times, differing only in case and
|
||||
// surrounding whitespace; they must collapse into one item.
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"记得喝咖啡"},
|
||||
{"content":" 记得喝咖啡 "},
|
||||
{"content":"记得喝咖啡"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("got %d items, want 1 after dedup", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Empty_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(&mockAdapter{}, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("got %d items, want 0 for empty candidate response", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_InvalidJSON_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "{not-json}"}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); err == nil {
|
||||
t.Fatal("Extract on malformed JSON: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_NilAdapter_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(nil, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, ErrNilAdapter) {
|
||||
t.Fatalf("Extract with nil adapter err = %v, want ErrNilAdapter", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Canceled_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(ctx context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
<-ctx.Done()
|
||||
return model.Response{}, ctx.Err()
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := ex.Extract(ctx, []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Extract with canceled ctx err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_异步_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return model.Response{Content: `[{"content":"异步提取的记忆"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
ch, err := ex.ExtractAsync(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync: %v", err)
|
||||
}
|
||||
// ExtractAsync returned while the adapter is still blocked on release,
|
||||
// proving the extraction runs on a background goroutine.
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("extraction goroutine did not start in time")
|
||||
}
|
||||
close(release)
|
||||
|
||||
res := <-ch
|
||||
if res.Err != nil {
|
||||
t.Fatalf("async result err = %v", res.Err)
|
||||
}
|
||||
if len(res.Items) != 1 || res.Items[0].Content != "异步提取的记忆" {
|
||||
t.Fatalf("async items = %+v, want one extracted item", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_Idempotent_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[{"content":"习惯:早起"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
conv := []model.Message{model.TextMessage(model.RoleUser, "hi")}
|
||||
|
||||
first, err := ex.ExtractAsync(context.Background(), conv)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync first: %v", err)
|
||||
}
|
||||
if res := <-first; res.Err != nil || len(res.Items) != 1 {
|
||||
t.Fatalf("first async result = %+v, want 1 item", res)
|
||||
}
|
||||
|
||||
// A second async extraction of the same memory deduplicates to zero.
|
||||
second, err := ex.ExtractAsync(context.Background(), conv)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync second: %v", err)
|
||||
}
|
||||
if res := <-second; res.Err != nil || len(res.Items) != 0 {
|
||||
t.Fatalf("second async result = %+v, want 0 items (idempotent)", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_NilAdapter_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(nil, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.ExtractAsync(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, ErrNilAdapter) {
|
||||
t.Fatalf("ExtractAsync with nil adapter err = %v, want ErrNilAdapter", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
)
|
||||
|
||||
// Retriever defaults and tuning constants.
|
||||
@@ -35,20 +37,31 @@ const (
|
||||
)
|
||||
|
||||
// Result is a single retrieval hit: the memory item plus its combined
|
||||
// relevance × recency score explaining its ranking. Higher is more relevant.
|
||||
// relevance × decay score explaining its ranking. Higher is more relevant.
|
||||
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.
|
||||
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
|
||||
// project-scoped memory carrying a ProjectID. It lets callers surface the
|
||||
// 项目索引 → 项目描述 → 章节内容 chain for a retrieved item.
|
||||
Project *projectindex.Project
|
||||
}
|
||||
|
||||
// Retriever ranks a persona's long-term memories by relevance (keyword and,
|
||||
// when an embedder is configured, semantic vectors) weighted by recency, 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.
|
||||
// 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
|
||||
vindex vector.Index
|
||||
projectStore *projectindex.Store
|
||||
keywordWeight float64
|
||||
semanticWeight float64
|
||||
recencyHalfLife time.Duration
|
||||
@@ -57,14 +70,38 @@ type Retriever struct {
|
||||
// RetrieverOption customizes a Retriever built by NewRetriever.
|
||||
type RetrieverOption func(*Retriever)
|
||||
|
||||
// WithEmbedder sets the semantic embedder used for vector similarity. When nil
|
||||
// (the default), retrieval falls back to keyword matching plus recency only.
|
||||
// WithEmbedder sets the semantic embedder used for vector similarity computed
|
||||
// in-memory. When nil (the default), retrieval falls back to keyword matching
|
||||
// plus decay only. When a vector index is also wired via WithVectorIndex, the
|
||||
// index takes precedence: it is the persistent semantic path (design §3
|
||||
// 可选增强), and WithEmbedder computes similarity on the fly instead.
|
||||
func WithEmbedder(e embedding.Embedder) RetrieverOption {
|
||||
return func(r *Retriever) {
|
||||
r.embedder = e
|
||||
}
|
||||
}
|
||||
|
||||
// WithVectorIndex wires a persistent vector.Index into retrieval. When set,
|
||||
// semantic relevance is answered by idx.Search over the stored vectors rather
|
||||
// than by in-memory embedding, so a configured pgvector/SQLite vector store is
|
||||
// genuinely used (design §3 "真正启用向量检索"). When nil (the default), the
|
||||
// semantic path is skipped unless WithEmbedder is set. The caller is responsible
|
||||
// for keeping the index in sync with stored items (embed + add on write).
|
||||
func WithVectorIndex(idx vector.Index) RetrieverOption {
|
||||
return func(r *Retriever) {
|
||||
r.vindex = idx
|
||||
}
|
||||
}
|
||||
|
||||
// WithProjectIndex wires a projectindex.Store so retrieval can attach the linked
|
||||
// project-index entry (设计 §3 项目分层) to each project-scoped hit. When nil
|
||||
// (the default), Result.Project is left unset.
|
||||
func WithProjectIndex(s *projectindex.Store) RetrieverOption {
|
||||
return func(r *Retriever) {
|
||||
r.projectStore = s
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeywordWeight sets the relative weight of keyword relevance. Non-positive
|
||||
// values are ignored and the default kept.
|
||||
func WithKeywordWeight(w float64) RetrieverOption {
|
||||
@@ -111,9 +148,10 @@ 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 recency. Query and
|
||||
// candidate contents are both read under ctx; a canceled context aborts the
|
||||
// work. A blank query returns the most recently used items (recency only).
|
||||
// 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).
|
||||
func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]Result, error) {
|
||||
if topK <= 0 {
|
||||
topK = defaultTopK
|
||||
@@ -146,12 +184,18 @@ func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK
|
||||
|
||||
results := make([]Result, 0, len(items))
|
||||
for _, it := range items {
|
||||
recency := r.recencyFactor(it, now)
|
||||
score := recency
|
||||
decay := r.decayFactor(it, now)
|
||||
score := decay
|
||||
if matched {
|
||||
score = relevance[it.ID] * recency
|
||||
score = relevance[it.ID] * decay
|
||||
}
|
||||
results = append(results, Result{Item: it, Score: score})
|
||||
res := Result{
|
||||
Item: it,
|
||||
Score: score,
|
||||
SourceConversation: it.SourceConversation,
|
||||
Project: r.projectFor(ctx, it),
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
@@ -194,11 +238,17 @@ func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]f
|
||||
return scores
|
||||
}
|
||||
|
||||
// semanticScores embeds the query and each candidate content with the configured
|
||||
// embedder and returns the clamped cosine similarity per item. With no embedder
|
||||
// it returns an empty map, so semantic relevance contributes nothing.
|
||||
// semanticScores computes per-item semantic relevance for query. When a vector
|
||||
// index is wired (WithVectorIndex) it answers from the persistent index via
|
||||
// idx.Search — the real vector path (design §3). Otherwise, with an embedder
|
||||
// configured, it embeds query and candidates in-memory and uses clamped cosine
|
||||
// similarity. With neither it returns an empty map, so semantic relevance
|
||||
// contributes nothing.
|
||||
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
if r.vindex != nil {
|
||||
return r.vectorIndexScores(ctx, items, query)
|
||||
}
|
||||
if r.embedder == nil {
|
||||
return scores
|
||||
}
|
||||
@@ -228,15 +278,48 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
|
||||
return scores
|
||||
}
|
||||
|
||||
// recencyFactor combines the stored decay_weight with the time since the item
|
||||
// was last touched, halving every recencyHalfLife days.
|
||||
func (r *Retriever) recencyFactor(it MemoryItem, now time.Time) float64 {
|
||||
age := now.Sub(it.UpdatedAt)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
// vectorIndexScores answers semantic relevance from the persistent vector index:
|
||||
// it embeds the query with the index, searches for the top similar ids, and
|
||||
// returns their cosine similarity, normalized to [0,1] by the top hit. Because
|
||||
// idx.Search ranks over all stored ids, an item absent from the index carries no
|
||||
// semantic score. A blank query or an index error degrades to an empty map.
|
||||
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return scores
|
||||
}
|
||||
halfLives := float64(age) / float64(r.recencyHalfLife)
|
||||
return it.DecayWeight * math.Pow(0.5, halfLives)
|
||||
qvecs, err := r.vindex.Embed(ctx, []string{query})
|
||||
if err != nil || len(qvecs) == 0 {
|
||||
return scores
|
||||
}
|
||||
hits, err := r.vindex.Search(ctx, qvecs[0], len(items))
|
||||
if err != nil || len(hits) == 0 {
|
||||
return scores
|
||||
}
|
||||
max := hits[0].Score
|
||||
for _, h := range hits {
|
||||
if max > 0 {
|
||||
scores[h.ID] = h.Score / max
|
||||
} else {
|
||||
scores[h.ID] = h.Score
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// projectFor returns the project-index entry linked to a project-scoped item, or
|
||||
// nil when the retriever has no project-index store, the item is not
|
||||
// project-scoped, or it carries no ProjectID. A load failure is treated as "no
|
||||
// project" rather than failing the whole retrieval (design §3 项目索引各角色可查).
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// keywordText is the text indexed for keyword matching: content plus tags.
|
||||
@@ -279,7 +362,7 @@ func (r *Retriever) Inject(ctx context.Context, personaID, query string, topK in
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("相关记忆(按相关度 × 最近度排序):")
|
||||
b.WriteString("相关记忆(按相关度 × 衰减排序):")
|
||||
for _, res := range results {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("- [")
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
)
|
||||
|
||||
// mockVectorIndex is a scriptable vector.Index for tests. With no fns it emits
|
||||
// one-hot vectors and returns no search hits.
|
||||
type mockVectorIndex struct {
|
||||
dim int
|
||||
embedFn func(ctx context.Context, texts []string) ([]vector.Vector, error)
|
||||
searchFn func(ctx context.Context, q vector.Vector, topK int) ([]vector.Hit, error)
|
||||
}
|
||||
|
||||
func (m *mockVectorIndex) Dim() int {
|
||||
if m.dim > 0 {
|
||||
return m.dim
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
func (m *mockVectorIndex) Add(_ context.Context, _ string, _ vector.Vector) error { return nil }
|
||||
|
||||
func (m *mockVectorIndex) Close() error { return nil }
|
||||
|
||||
func (m *mockVectorIndex) Embed(ctx context.Context, texts []string) ([]vector.Vector, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.embedFn != nil {
|
||||
return m.embedFn(ctx, texts)
|
||||
}
|
||||
out := make([]vector.Vector, len(texts))
|
||||
for i := range texts {
|
||||
v := make(vector.Vector, m.Dim())
|
||||
v[i%m.Dim()] = 1
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mockVectorIndex) Search(ctx context.Context, _ vector.Vector, topK int) ([]vector.Hit, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.searchFn != nil {
|
||||
return m.searchFn(ctx, nil, topK)
|
||||
}
|
||||
return []vector.Hit{}, nil
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_WithVectorIndex_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
// Both items are in English while the query is Chinese, so the keyword path
|
||||
// shares no token and contributes nothing. The ranking is therefore driven
|
||||
// entirely by the vector index, proving the persistent vector path is used.
|
||||
item1 := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "she is a morning person",
|
||||
Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
item2 := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "she likes jazz",
|
||||
Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
|
||||
idx := &mockVectorIndex{
|
||||
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
|
||||
return []vector.Hit{
|
||||
{ID: item1.ID, Score: 0.9},
|
||||
{ID: item2.ID, Score: 0.2},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
r := NewRetriever(s, WithVectorIndex(idx))
|
||||
|
||||
results, err := r.Retrieve(ctx, "persona.a", "咖啡", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results with a vector index wired")
|
||||
}
|
||||
if got := results[0].Item.Content; got != "she is a morning person" {
|
||||
t.Fatalf("top result = %q, want the item the vector index ranked highest", got)
|
||||
}
|
||||
if results[0].Score <= 0 {
|
||||
t.Fatalf("top result score = %v, want > 0", results[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_WithVectorIndex_PartialHits_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
item1 := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "she is a morning person",
|
||||
Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
item2 := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "she likes jazz",
|
||||
Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
|
||||
// The vector index only knows about item1; item2 is absent from it, so it
|
||||
// carries no semantic score. With a Chinese query that the keyword path
|
||||
// cannot match, only item1 is relevant: it must rank at the top with a
|
||||
// positive score while item2 scores 0, proving the semantic contribution
|
||||
// comes strictly from the vector index.
|
||||
idx := &mockVectorIndex{
|
||||
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
|
||||
return []vector.Hit{{ID: item1.ID, Score: 0.9}}, nil
|
||||
},
|
||||
}
|
||||
r := NewRetriever(s, WithVectorIndex(idx))
|
||||
|
||||
results, err := r.Retrieve(ctx, "persona.a", "咖啡", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("got %d results, want 2", len(results))
|
||||
}
|
||||
if got := results[0].Item.Content; got != "she is a morning person" {
|
||||
t.Fatalf("top result = %q, want the vector-indexed item", got)
|
||||
}
|
||||
if results[0].Score <= 0 {
|
||||
t.Fatalf("top result score = %v, want > 0", results[0].Score)
|
||||
}
|
||||
// item2 has no vector hit and no keyword overlap, so it scores 0 (matched
|
||||
// mode) and sorts below.
|
||||
if results[1].Item.ID != item2.ID {
|
||||
t.Fatalf("second result id = %q, want item2", results[1].Item.ID)
|
||||
}
|
||||
if results[1].Score != 0 {
|
||||
t.Fatalf("second result score = %v, want 0 (no vector hit)", results[1].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_Source_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a",
|
||||
Content: "她喜欢冰美式",
|
||||
Tags: []string{"coffee"},
|
||||
SourceConversation: "conv.42",
|
||||
Scope: ScopePersona,
|
||||
Kind: KindRelationship,
|
||||
})
|
||||
|
||||
r := NewRetriever(s)
|
||||
results, err := r.Retrieve(ctx, "persona.a", "冰美式", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected a result")
|
||||
}
|
||||
if results[0].SourceConversation != "conv.42" {
|
||||
t.Fatalf("Result.SourceConversation = %q, want conv.42 (溯源)", results[0].SourceConversation)
|
||||
}
|
||||
if results[0].Item.SourceConversation != "conv.42" {
|
||||
t.Fatalf("Item.SourceConversation = %q, want conv.42", results[0].Item.SourceConversation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_ProjectLink_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
// Separate project-index DB, so it shares no connection with the memory store.
|
||||
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)
|
||||
}
|
||||
if err := ps.PutProject(ctx, projectindex.Project{
|
||||
ID: "project.alpha", Title: "Alpha 项目", Desc: "Alpha 项目描述",
|
||||
}); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
|
||||
ms := newTestStore(t)
|
||||
addItem(t, ms, MemoryItem{
|
||||
PersonaID: "persona.a",
|
||||
Content: "Alpha 项目的一个事实",
|
||||
Scope: ScopeProject,
|
||||
ProjectID: "project.alpha",
|
||||
Kind: KindWorldEvent,
|
||||
})
|
||||
|
||||
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 {
|
||||
t.Fatal("expected a result")
|
||||
}
|
||||
if results[0].Item.Scope != ScopeProject {
|
||||
t.Fatalf("result scope = %q, want project", results[0].Item.Scope)
|
||||
}
|
||||
if results[0].Project == nil {
|
||||
t.Fatal("Result.Project is nil, want the linked project-index entry")
|
||||
}
|
||||
if results[0].Project.ID != "project.alpha" {
|
||||
t.Fatalf("Result.Project.ID = %q, want project.alpha", results[0].Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetriever_Retrieve_ProjectLink_NoStore_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ms := newTestStore(t)
|
||||
|
||||
addItem(t, ms, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "Alpha 项目的一个事实",
|
||||
Scope: ScopeProject, ProjectID: "project.alpha", Kind: KindWorldEvent,
|
||||
})
|
||||
|
||||
// Without a project-index store wired, the hit still returns and Project is
|
||||
// simply left unset (no error).
|
||||
r := NewRetriever(ms)
|
||||
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")
|
||||
}
|
||||
if results[0].Project != nil {
|
||||
t.Fatalf("Result.Project = %+v, want nil without a project-index store", results[0].Project)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Scope access control sentinels. These surface when a persona attempts a write
|
||||
// it is not permitted to make under the design §3 rules.
|
||||
var (
|
||||
// ErrReadOnly is returned when a persona tries to write or delete a
|
||||
// persona-scope memory item it does not own. Under design §3 other personas
|
||||
// are read-only for 角色日常记忆.
|
||||
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
|
||||
|
||||
// ErrMissingProjectID is returned when a project-scoped query has a blank id.
|
||||
ErrMissingProjectID = errors.New("memory: project_id is required")
|
||||
)
|
||||
|
||||
// canWrite reports whether actorID may write item under the design §3 scope rules:
|
||||
//
|
||||
// - ScopeUser: any persona may write (user habits are globally shared).
|
||||
// - ScopePersona: only the owning persona may write (other personas read-only).
|
||||
// - ScopeProject: any persona may write (project memory is 人人可写).
|
||||
//
|
||||
// The caller must have already validated item.Scope; an invalid scope is not
|
||||
// writable by anyone.
|
||||
func canWrite(actorID string, it MemoryItem) bool {
|
||||
switch it.Scope {
|
||||
case ScopeUser, ScopeProject:
|
||||
return true
|
||||
case ScopePersona:
|
||||
return it.PersonaID == actorID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AddScoped persists item under actorID's authority, enforcing the design §3
|
||||
// write rules by persona_id + scope. A blank actorID returns ErrMissingPersona.
|
||||
// When item.PersonaID is blank it is filled with actorID, so a persona always
|
||||
// records its own provenance; a persona-scope item owned by a different persona
|
||||
// returns ErrReadOnly. User- and project-scope items are writable by any persona.
|
||||
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
|
||||
if strings.TrimSpace(actorID) == "" {
|
||||
return MemoryItem{}, ErrMissingPersona
|
||||
}
|
||||
if item.PersonaID == "" {
|
||||
item.PersonaID = actorID
|
||||
}
|
||||
if !item.Scope.valid() {
|
||||
return MemoryItem{}, ErrInvalidScope
|
||||
}
|
||||
if !canWrite(actorID, item) {
|
||||
return MemoryItem{}, ErrReadOnly
|
||||
}
|
||||
return s.Add(ctx, item)
|
||||
}
|
||||
|
||||
// ListScope returns every item of scope visible to personaID, newest first,
|
||||
// applying the design §3 read rules:
|
||||
//
|
||||
// - ScopeUser: every user-scope item (globally shared across personas).
|
||||
// - ScopePersona: every persona-scope item. Other personas are read-only but
|
||||
// still readable, so a persona can read another persona's 角色日常记忆 here.
|
||||
// - ScopeProject: every project-scope item (人人可读).
|
||||
//
|
||||
// personaID names the reading persona and is required so callers always declare
|
||||
// the identity boundary even for a shared scope. The higher policy layer still
|
||||
// decides whether a restricted context (group chat / stranger, design §5) may
|
||||
// receive these; this method only enforces the base read rule.
|
||||
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
if !scope.valid() {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE scope = ?
|
||||
ORDER BY created_at DESC`, string(scope))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list scope items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "scope item")
|
||||
}
|
||||
|
||||
// ListProject returns every memory item linked to projectID, newest first. A
|
||||
// project-scoped memory carries a ProjectID that links to the projectindex
|
||||
// hierarchy (design §3); this is the deep-search entry point — callers combine
|
||||
// it with the projectindex store to walk 项目索引 → 项目描述 → 章节内容. Project
|
||||
// memory is readable by all personas, so no persona filter is applied. A blank
|
||||
// projectID returns ErrMissingProjectID.
|
||||
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProjectID
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE project_id = ?
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list project items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "project item")
|
||||
}
|
||||
|
||||
// DeleteScoped removes the item with the given id, but only if actorID may write
|
||||
// it under the design §3 rules. This enforces the persona read-only boundary on
|
||||
// deletion: a persona may delete its own persona-scope items and any user- or
|
||||
// project-scope item, but not another persona's 角色日常记忆. A blank id returns
|
||||
// ErrNotFound; writing a foreign persona-scope item returns ErrReadOnly.
|
||||
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
|
||||
if strings.TrimSpace(actorID) == "" {
|
||||
return ErrMissingPersona
|
||||
}
|
||||
item, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory: delete scoped load: %w", err)
|
||||
}
|
||||
if !canWrite(actorID, item) {
|
||||
return ErrReadOnly
|
||||
}
|
||||
return s.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ensureProjectIDColumn rolls the memory_items table forward when it was created
|
||||
// before the M4 project index (design §3) existed: it adds the project_id column
|
||||
// if it is missing, and ignores the duplicate-column result when present. It is
|
||||
// SQLite-specific (the memory store is SQLite-backed) and idempotent, so NewStore
|
||||
// may be called on a fresh schema or a pre-existing one.
|
||||
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
|
||||
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// SQLite reports "duplicate column name: project_id" when the column already
|
||||
// exists (e.g. created by the CREATE TABLE in NewStore, or by a previous run).
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: roll forward project_id column: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStore_ListScope_User_全局共享(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
for _, p := range []string{"persona.a", "persona.b"} {
|
||||
if _, err := s.AddScoped(ctx, p, MemoryItem{
|
||||
PersonaID: p, Scope: ScopeUser, Kind: KindRelationship,
|
||||
Content: "用户习惯-" + p,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddScoped user for %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// persona.a sees both personas' user-scope items (globally shared).
|
||||
items, err := s.ListScope(ctx, ScopeUser, "persona.a")
|
||||
if err != nil {
|
||||
t.Fatalf("ListScope user: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("ListScope(user) = %d items, want 2 (globally shared)", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ListScope_Persona_其他角色只读(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Scope: ScopePersona, Kind: KindWorldEvent, Content: "a的日常",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddScoped persona.a: %v", err)
|
||||
}
|
||||
|
||||
// persona.b may READ persona.a's persona-scope item (read-only rule).
|
||||
items, err := s.ListScope(ctx, ScopePersona, "persona.b")
|
||||
if err != nil {
|
||||
t.Fatalf("ListScope persona for b: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].PersonaID != "persona.a" {
|
||||
t.Fatalf("ListScope(persona) for persona.b = %+v, want persona.a's item", items)
|
||||
}
|
||||
// AddScoped auto-filled PersonaID from the acting actor.
|
||||
if items[0].PersonaID != "persona.a" {
|
||||
t.Errorf("owner persona = %q, want persona.a", items[0].PersonaID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddScoped_Persona_跨角色写_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Scope: ScopePersona, Kind: KindRelationship, Content: "a的日常",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddScoped persona.a: %v", err)
|
||||
}
|
||||
|
||||
// persona.b cannot WRITE persona.a's persona-scope item.
|
||||
_, err := s.AddScoped(ctx, "persona.b", MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: "b想改a的日常",
|
||||
})
|
||||
if !errors.Is(err, ErrReadOnly) {
|
||||
t.Fatalf("AddScoped cross-persona err = %v, want wrap of ErrReadOnly", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddScoped_UserProject_人人写(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
// Any persona may write a user- or project-scope item.
|
||||
for _, sc := range []Scope{ScopeUser, ScopeProject} {
|
||||
item, err := s.AddScoped(ctx, "persona.b", MemoryItem{
|
||||
Scope: sc, Kind: KindWorldEvent, Content: "共享-" + string(sc),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddScoped %s: %v", sc, err)
|
||||
}
|
||||
// PersonaID was auto-filled with the acting actor.
|
||||
if item.PersonaID != "persona.b" {
|
||||
t.Errorf("AddScoped %s owner = %q, want persona.b", sc, item.PersonaID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddScoped_MissingActor_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddScoped(ctx, "", MemoryItem{
|
||||
Content: "x", Scope: ScopeUser,
|
||||
}); !errors.Is(err, ErrMissingPersona) {
|
||||
t.Fatalf("AddScoped(empty actor) err = %v, want ErrMissingPersona", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddScoped_InvalidScope_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Content: "x", Scope: "bogus",
|
||||
}); !errors.Is(err, ErrInvalidScope) {
|
||||
t.Fatalf("AddScoped(bogus scope) err = %v, want ErrInvalidScope", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ListScope_前置校验_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.ListScope(ctx, ScopeUser, ""); !errors.Is(err, ErrMissingPersona) {
|
||||
t.Fatalf("ListScope(empty persona) err = %v, want ErrMissingPersona", err)
|
||||
}
|
||||
if _, err := s.ListScope(ctx, "bogus", "persona.a"); !errors.Is(err, ErrInvalidScope) {
|
||||
t.Fatalf("ListScope(bogus scope) err = %v, want ErrInvalidScope", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteScoped_跨角色_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
stored, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Scope: ScopePersona, Kind: KindRelationship, Content: "a的日常",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddScoped persona.a: %v", err)
|
||||
}
|
||||
|
||||
// persona.a deletes its own item.
|
||||
if err := s.DeleteScoped(ctx, "persona.a", stored.ID); err != nil {
|
||||
t.Fatalf("DeleteScoped owner: %v", err)
|
||||
}
|
||||
|
||||
// Re-add, then persona.b cannot delete persona.a's item.
|
||||
stored2, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Scope: ScopePersona, Kind: KindRelationship, Content: "a的日常2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddScoped persona.a: %v", err)
|
||||
}
|
||||
if err := s.DeleteScoped(ctx, "persona.b", stored2.ID); !errors.Is(err, ErrReadOnly) {
|
||||
t.Fatalf("DeleteScoped cross-persona err = %v, want ErrReadOnly", err)
|
||||
}
|
||||
// The foreign item survives.
|
||||
if _, err := s.Get(ctx, stored2.ID); err != nil {
|
||||
t.Fatalf("foreign item removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteScoped_BlankActor_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.DeleteScoped(ctx, "", "some-id"); !errors.Is(err, ErrMissingPersona) {
|
||||
t.Fatalf("DeleteScoped(empty actor) err = %v, want ErrMissingPersona", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ListProject_项目记忆(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddScoped(ctx, "persona.a", MemoryItem{
|
||||
Scope: ScopeProject, Kind: KindWorldEvent, Content: "项目Alpha事实",
|
||||
ProjectID: "project.alpha",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddScoped project: %v", err)
|
||||
}
|
||||
if _, err := s.AddScoped(ctx, "persona.b", MemoryItem{
|
||||
Scope: ScopeProject, Kind: KindWorldEvent, Content: "项目Beta事实",
|
||||
ProjectID: "project.beta",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddScoped project: %v", err)
|
||||
}
|
||||
|
||||
items, err := s.ListProject(ctx, "project.alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProject: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].ProjectID != "project.alpha" {
|
||||
t.Fatalf("ListProject(alpha) = %+v, want only alpha item", items)
|
||||
}
|
||||
if items[0].Content != "项目Alpha事实" {
|
||||
t.Errorf("item content = %q, want the alpha item", items[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ListProject_BlankID_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.ListProject(ctx, ""); !errors.Is(err, ErrMissingProjectID) {
|
||||
t.Fatalf("ListProject(blank) err = %v, want ErrMissingProjectID", err)
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,27 @@
|
||||
// It exposes three layers:
|
||||
//
|
||||
// - Store: a SQLite-backed long-term memory store. It persists memory items
|
||||
// with persona/role isolation (design §0 layer ②, §4) and offers
|
||||
// Add/Get/ListByPersona/Delete plus a manual "记住" path (design §1).
|
||||
// with persona/role isolation (design §0 layer ②, §4), applies the
|
||||
// three-scope access rules (design §3) via AddScoped/ListScope/ListProject/
|
||||
// DeleteScoped, and offers Add/Get/ListByPersona/Delete plus a manual
|
||||
// "记住" path (design §1). Each item may carry a project_id linking the
|
||||
// project-index hierarchy.
|
||||
// - WorkingMemory: a per-session sliding window of messages plus a summary
|
||||
// (design §0 layer ①).
|
||||
// - Retriever: relevance + recency retrieval and context injection
|
||||
// (design §2).
|
||||
// - Retriever: relevance (keyword + optional semantic) × decay retrieval with
|
||||
// source provenance and context injection (design §2). It accepts an optional
|
||||
// vector.Index for the persistent semantic path and a projectindex.Store so
|
||||
// project-scoped hits surface their linked project entry.
|
||||
//
|
||||
// Extraction (design §1) is exposed through the Extractor interface; the
|
||||
// concrete M2 implementation is the rule-based ManualExtractor, which records
|
||||
// what the user explicitly asks to remember. LLM-driven automatic extraction is
|
||||
// deferred to milestone M4 and is not implemented here.
|
||||
// Extraction (design §1) is exposed through the Extractor interface with two
|
||||
// implementations: ManualExtractor (rule-based, records an explicit "记住 X"
|
||||
// directive) and LLMExtractor (drives a model.Adapter to denoise and deduplicate
|
||||
// a turn, running synchronously via Extract or asynchronously via ExtractAsync).
|
||||
//
|
||||
// Milestone M4 additions that this package deliberately leaves out but keeps
|
||||
// extension points for: persistent vector storage, decay scheduling, and the
|
||||
// project-index hierarchy (design §3). The schema already carries decay_weight
|
||||
// and the retriever already plugs an optional embedding.Embedder, so those
|
||||
// features land without a schema migration.
|
||||
// Milestone M4 is implemented here: the schema carries decay_weight and
|
||||
// project_id, the retriever plugs an optional embedding.Embedder / vector.Index
|
||||
// and a project-index store, and the store enforces the user/persona/project
|
||||
// scope rules. No external dependency is needed beyond the existing packages.
|
||||
package memory
|
||||
|
||||
import (
|
||||
@@ -35,6 +39,8 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Scope classifies who a long-term memory belongs to. It is orthogonal to Kind
|
||||
@@ -89,6 +95,7 @@ type MemoryItem struct {
|
||||
ID string
|
||||
PersonaID string
|
||||
RoleID string
|
||||
ProjectID string
|
||||
Scope Scope
|
||||
Kind Kind
|
||||
Content string
|
||||
@@ -156,7 +163,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
log = discardLogger(log)
|
||||
log = logger.WithSource(discardLogger(log), "memory")
|
||||
for _, ddl := range []string{
|
||||
createMemoryItemsTable,
|
||||
createMemoryItemsPersonaIndex,
|
||||
@@ -166,6 +173,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
return nil, fmt.Errorf("memory: apply schema: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ensureProjectIDColumn(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
@@ -186,10 +196,10 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, scope, kind, content, tags, confidence,
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, string(item.Scope), string(item.Kind),
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight)
|
||||
if err != nil {
|
||||
@@ -204,7 +214,7 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return MemoryItem{}, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, scope, kind,
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE id = ?`, id)
|
||||
item, err := scanItem(row)
|
||||
@@ -219,30 +229,39 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
|
||||
// ListByPersona returns every item for personaID, newest first. It is the
|
||||
// persona-isolation boundary: items belonging to other personas are never
|
||||
// returned, so personas never share memory (design §4).
|
||||
// returned, so personas never share memory (design §4). Use ListScope when a
|
||||
// caller needs to see shared user/project scopes or another persona's read-only
|
||||
// items.
|
||||
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, scope, kind,
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE persona_id = ?
|
||||
ORDER BY created_at DESC`, personaID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list persona items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "persona item")
|
||||
}
|
||||
|
||||
// scanItems reads every row into a MemoryItem slice, closing rows and wrapping
|
||||
// scan/iterate failures with the operation name. A non-nil, possibly empty slice
|
||||
// is always returned on success.
|
||||
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]MemoryItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: scan persona item: %w", err)
|
||||
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory: iterate persona items: %w", err)
|
||||
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -352,7 +371,7 @@ func scanItem(sc rowScanner) (MemoryItem, error) {
|
||||
tagsJSON string
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &scope, &kind,
|
||||
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
|
||||
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
|
||||
&createdAt, &updatedAt, &it.DecayWeight); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -423,6 +442,7 @@ 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,
|
||||
|
||||
Reference in new issue
Block a user