Version: 0.9.23.dev.260416

后端:
1. Memory 管理面 API 落地(“我的记忆”增删改查 + 恢复)
   - 补齐 List/Get/Create/Update/Delete/Restore 的 handler、请求模型与返回视图
   - 注册 `/api/v1/memory/items*` 路由并接入 MemoryHandler
   - 新增 memory item not found / invalid memory type / invalid memory content 三类管理面错误码
2. Memory Module / Service / Repo 扩展为“可管理 + 可治理”门面
   - 新增 NewModuleWithObserve / ObserveDeps,导出 GetItem / CreateItem / UpdateItem / DeleteItem / RestoreItem / RunDedupCleanup / MemoryObserver / MemoryMetrics
   - 新增手动新增、修改、恢复能力;删除链路切到 SoftDeleteByID;所有管理动作统一事务内写 audit,并桥接向量同步与管理面观测
   - 补齐 CreateItemFields / UpdateItemFields、单条 Create、管理侧字段更新、软删/恢复,以及 dedup 扫描/归档所需 repo 能力
   - 审计操作补齐 archive / restore
3. Memory 读侧与注入侧观测补齐
   - HybridRetrieve 返回 telemetry,统一记录 pinned hit / semantic hit / dedup drop / degraded / RAG fallback,并上报读取命中、去重丢弃、RAG 降级指标
   - AgentService 持有 memory observer / metrics;injectMemoryContext 对读取失败、空注入、成功注入补齐结构化日志与注入计数
4. Worker / 决策 / 向量同步链路治理增强
   - 召回结果显式携带 fallbackMode;hash 精确命中、rag→mysql 降级、最终动作统一写入决策观测
   - 接入 vectorSyncer / observer / metrics;为 job 重试、任务成功/失败、决策分布与 fallback 补齐打点;向量 upsert/delete 统一改走公共 Syncer,并收敛 parseMemoryID 解析逻辑
5. 启动层接入 Memory 观测依赖
   - 启动时创建 LoggerObserver + MetricsRegistry,并通过 NewModuleWithObserve 注入 memory 模块
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-16 19:34:32 +08:00
parent a1b2ffedb8
commit fad3aed30a
23 changed files with 2527 additions and 121 deletions

View File

@@ -46,6 +46,54 @@ func (r *ItemRepo) UpsertItems(ctx context.Context, items []model.MemoryItem) er
return nil
}
// Create 写入单条记忆并返回带自增主键的结果。
//
// 职责边界:
// 1. 只负责单条落库,不负责内容归一化与业务校验;
// 2. 默认把 vector_status 视为上游已决策好的桥接状态,不在这里擅自改写;
// 3. 返回值用于上游继续写 audit 或做向量同步。
func (r *ItemRepo) Create(ctx context.Context, fields memorymodel.CreateItemFields) (*model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
if fields.UserID <= 0 {
return nil, errors.New("memory item create user_id is invalid")
}
item := model.MemoryItem{
UserID: fields.UserID,
ConversationID: strPtrOrNil(fields.ConversationID),
AssistantID: strPtrOrNil(fields.AssistantID),
RunID: strPtrOrNil(fields.RunID),
MemoryType: fields.MemoryType,
Title: fields.Title,
Content: fields.Content,
NormalizedContent: strPtrOrNil(fields.NormalizedContent),
ContentHash: strPtrOrNil(fields.ContentHash),
Confidence: fields.Confidence,
Importance: fields.Importance,
SensitivityLevel: fields.SensitivityLevel,
SourceMessageID: fields.SourceMessageID,
SourceEventID: fields.SourceEventID,
IsExplicit: fields.IsExplicit,
Status: fields.Status,
TTLAt: fields.TTLAt,
LastAccessAt: fields.LastAccessAt,
VectorStatus: fields.VectorStatus,
}
if item.Status == "" {
item.Status = model.MemoryItemStatusActive
}
if strings.TrimSpace(item.VectorStatus) == "" {
item.VectorStatus = "pending"
}
if err := r.db.WithContext(ctx).Create(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
// FindByQuery 按统一过滤条件读取记忆条目。
//
// 步骤化说明:
@@ -324,6 +372,53 @@ func (r *ItemRepo) UpdateContentByID(ctx context.Context, memoryID int64, fields
}).Error
}
// UpdateManagedFieldsByID 更新“用户管理侧”允许修改的记忆字段。
func (r *ItemRepo) UpdateManagedFieldsByID(ctx context.Context, userID int, memoryID int64, fields memorymodel.UpdateItemFields) error {
return r.UpdateManagedFieldsByIDAt(ctx, userID, memoryID, fields, time.Now())
}
// UpdateManagedFieldsByIDAt 更新“用户管理侧”允许修改的记忆字段,并允许显式指定更新时间。
//
// 步骤化说明:
// 1. 这里只改内容侧和展示侧字段,不改 user_id/status 等归属语义;
// 2. memory_type/content 变化后,会把 vector_status 置为 pending提示上游需要重新同步向量
// 3. TTLAt 允许被设置为 nil用于显式清空过期时间。
func (r *ItemRepo) UpdateManagedFieldsByIDAt(
ctx context.Context,
userID int,
memoryID int64,
fields memorymodel.UpdateItemFields,
updatedAt time.Time,
) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if userID <= 0 || memoryID <= 0 {
return errors.New("memory item update params is invalid")
}
if updatedAt.IsZero() {
updatedAt = time.Now()
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id = ? AND user_id = ?", memoryID, userID).
Updates(map[string]any{
"memory_type": fields.MemoryType,
"title": fields.Title,
"content": fields.Content,
"normalized_content": fields.NormalizedContent,
"content_hash": fields.ContentHash,
"confidence": fields.Confidence,
"importance": fields.Importance,
"sensitivity_level": fields.SensitivityLevel,
"is_explicit": fields.IsExplicit,
"ttl_at": fields.TTLAt,
"vector_status": "pending",
"updated_at": updatedAt,
}).Error
}
// SoftDeleteByID 软删除指定用户的某条记忆。
//
// 说明:
@@ -348,6 +443,95 @@ func (r *ItemRepo) SoftDeleteByID(ctx context.Context, userID int, memoryID int6
}).Error
}
// RestoreByID 把 deleted/archived 记忆恢复为 active。
func (r *ItemRepo) RestoreByID(ctx context.Context, userID int, memoryID int64) error {
return r.RestoreByIDAt(ctx, userID, memoryID, time.Now())
}
// RestoreByIDAt 把 deleted/archived 记忆恢复为 active并显式刷新 vector_status。
//
// 这样做的原因:
// 1. 恢复后的记忆需要重新参与语义召回,因此向量侧也要重新同步;
// 2. 这里统一把 vector_status 置为 pending避免上游遗漏桥接状态更新
// 3. 若目标记录本身已是 active上游应先读快照决定是否真的调用恢复。
func (r *ItemRepo) RestoreByIDAt(ctx context.Context, userID int, memoryID int64, updatedAt time.Time) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if userID <= 0 || memoryID <= 0 {
return errors.New("memory item restore params is invalid")
}
if updatedAt.IsZero() {
updatedAt = time.Now()
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id = ? AND user_id = ?", memoryID, userID).
Updates(map[string]any{
"status": model.MemoryItemStatusActive,
"vector_status": "pending",
"updated_at": updatedAt,
}).Error
}
// ArchiveByIDsAt 把一批重复记忆改为 archived并等待上游删除向量副本。
func (r *ItemRepo) ArchiveByIDsAt(ctx context.Context, ids []int64, updatedAt time.Time) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if len(ids) == 0 {
return nil
}
if updatedAt.IsZero() {
updatedAt = time.Now()
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id IN ?", ids).
Where("status = ?", model.MemoryItemStatusActive).
Updates(map[string]any{
"status": model.MemoryItemStatusArchived,
"vector_status": "pending",
"updated_at": updatedAt,
}).Error
}
// ListActiveItemsForDedup 读取“当前仍 active 且带 content_hash”的候选记忆供离线 dedup 治理使用。
//
// 步骤化说明:
// 1. 只扫描 status=active 且 hash 非空的记录,因为治理目标是“活跃重复项”;
// 2. 先按 user/type/hash 分组,再按更新时间、置信度、主键逆序排列,方便上游顺序分组;
// 3. Limit 仅用于保守控量,不保证整组完整,因此首次治理建议留空或给足够大值。
func (r *ItemRepo) ListActiveItemsForDedup(ctx context.Context, userID int, limit int) ([]model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
db := r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("status = ?", model.MemoryItemStatusActive).
Where("content_hash IS NOT NULL AND content_hash <> ''")
if userID > 0 {
db = db.Where("user_id = ?", userID)
}
if limit > 0 {
db = db.Limit(limit)
}
var items []model.MemoryItem
err := db.
Order("user_id ASC").
Order("memory_type ASC").
Order("content_hash ASC").
Order("updated_at DESC").
Order("confidence DESC").
Order("id DESC").
Find(&items).Error
return items, err
}
func applyScopedEquality(db *gorm.DB, column, value string, includeGlobal bool) *gorm.DB {
value = strings.TrimSpace(value)
if value == "" {