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
@@ -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)`
|
||||
Reference in new issue
Block a user