Version: 0.9.21.dev.260416

后端:
1. Memory 写入链路新增"召回→比对→汇总"去重决策层
- 新增决策流程:Runner 根据decision.enabled 配置走决策路径(语义召回候选 → Hash 精确命中 → LLM 逐对比对 → 汇总决策 → 执行 ADD/UPDATE/DELETE/NONE),默认关闭,旧路径完全保留
- 新增 LLMDecisionOrchestrator:单对关系判断编排器,输出 duplicate/update/conflict/unrelated 四种关系
- 新增 decision_flow / apply_actions:决策流程主循环与动作落地(新增、更新内容、软删除、跳过)
- 新增 aggregate_decision / decision_validate:汇总规则(按优先级判定动作)与 LLM 输出校验
- 新增 decision model:CandidateSnapshot / ComparisonResult / FinalDecision 等决策层核心类型
- ItemRepo 新增 FindActiveByHash / UpdateContentByID / SoftDeleteByID 三个决策层专用方法
- RAG Runtime / Pipeline / Service 新增 DeleteMemory 向量删除能力,MilvusStore 补充 duplicate collection 错误识别
- Runner 新增 syncVectorDeletes 处理决策层 DELETE 动作的向量清理
- config 新增 decision(enabled/candidateTopK/candidateMinScore/fallbackMode)和 write.mode 配置项,config_loader 增加默认值兜底
- 删除 HANDOFF-RAG复用后续实施计划.md 和旧 log.txt,新增 Log.txt 记录决策流程调试日志
- normalize_facts 导出 HashContent 供决策层复用,audit 新增 update 操作常量

前端:无 仓库:无
This commit is contained in:
Losita
2026-04-16 12:11:58 +08:00
parent 8bde981592
commit 634a9fb926
21 changed files with 1271 additions and 1032 deletions

View File

@@ -0,0 +1,248 @@
package worker
import (
"context"
"fmt"
"strings"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
"github.com/LoveLosita/smartflow/backend/model"
)
// ApplyActionOutcome 是单个决策动作的执行结果。
//
// 说明:
// 1. Action 记录本次执行的动作类型ADD/UPDATE/DELETE/NONE
// 2. OldItem 仅在 UPDATE/DELETE 时有值,用于审计 before 快照;
// 3. NewItem 仅在 ADD/UPDATE 时有值,用于审计 after 快照和向量同步;
// 4. NeedsSync 标记是否需要触发向量同步ADD 和 UPDATE 需要)。
type ApplyActionOutcome struct {
Action string
MemoryID int64
OldItem *model.MemoryItem // UPDATE/DELETE 时的 before 快照
NewItem *model.MemoryItem // ADD/UPDATE 时的 after 快照
NeedsSync bool // 是否需要向量同步
}
// ApplyFinalDecision 把汇总后的最终决策落为数据库动作。
//
// 职责边界:
// 1. 在调用方事务内执行,不做独立事务管理;
// 2. 负责写 memory_items + memory_audit_logs不负责 job 状态推进;
// 3. 所有动作的审计日志都由这里统一产出。
//
// 参数说明:
// - itemRepo/auditRepo 必须是事务绑定的实例WithTx 后的);
// - fact 是当前正在处理的标准化事实;
// - job/payload 提供写入所需的上下文user_id、conversation_id 等)。
func ApplyFinalDecision(
ctx context.Context,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
decision memorymodel.FinalDecision,
fact memorymodel.NormalizedFact,
job *model.MemoryJob,
payload memorymodel.ExtractJobPayload,
) (*ApplyActionOutcome, error) {
switch decision.Action {
case memorymodel.DecisionActionAdd:
return applyAdd(ctx, itemRepo, auditRepo, fact, job, payload, decision.Reason)
case memorymodel.DecisionActionUpdate:
return applyUpdate(ctx, itemRepo, auditRepo, decision, fact, job, payload)
case memorymodel.DecisionActionDelete:
return applyDelete(ctx, itemRepo, auditRepo, decision, payload.UserID)
case memorymodel.DecisionActionNone:
return &ApplyActionOutcome{
Action: memorymodel.DecisionActionNone,
NeedsSync: false,
}, nil
default:
return nil, fmt.Errorf("未知的决策动作: %s", decision.Action)
}
}
// applyAdd 执行新增动作:构建 MemoryItem → 写库 → 写审计。
func applyAdd(
ctx context.Context,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
fact memorymodel.NormalizedFact,
job *model.MemoryJob,
payload memorymodel.ExtractJobPayload,
reason string,
) (*ApplyActionOutcome, error) {
// 1. 复用 runner.go 的 buildMemoryItems 构建单条 MemoryItem。
items := buildMemoryItems(job, payload, []memorymodel.NormalizedFact{fact})
if len(items) == 0 {
return nil, fmt.Errorf("构建记忆条目失败: memory_type=%s", fact.MemoryType)
}
// 2. 写库GORM Create 会自动填充 items[0].ID。
if err := itemRepo.UpsertItems(ctx, items); err != nil {
return nil, fmt.Errorf("新增记忆写入失败: %w", err)
}
// 注意:必须在 UpsertItems 之后取 items[0],因为 GORM Create 回填 ID 到 items[i]
// 之前用 item := items[0] 在 UpsertItems 之前拷贝,导致副本 ID 永远为 0。
item := items[0]
// 3. 写审计日志create 动作只有 after 快照)。
audit := memoryutils.BuildItemAuditLog(
item.ID,
item.UserID,
memoryutils.AuditOperationCreate,
"system",
formatAuditReason("决策层新增", reason),
nil,
&item,
)
if err := auditRepo.Create(ctx, audit); err != nil {
return nil, fmt.Errorf("新增审计写入失败: %w", err)
}
return &ApplyActionOutcome{
Action: memorymodel.DecisionActionAdd,
MemoryID: item.ID,
NewItem: &item,
NeedsSync: true,
}, nil
}
// applyUpdate 执行更新动作:查 before → 更新字段 → 写审计before+after
func applyUpdate(
ctx context.Context,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
decision memorymodel.FinalDecision,
fact memorymodel.NormalizedFact,
job *model.MemoryJob,
payload memorymodel.ExtractJobPayload,
) (*ApplyActionOutcome, error) {
// 1. 查 before 快照,同时确认旧记忆存在且属于该用户。
oldItem, err := itemRepo.GetByIDForUser(ctx, payload.UserID, decision.TargetID)
if err != nil {
return nil, fmt.Errorf("查询旧记忆失败(id=%d): %w", decision.TargetID, err)
}
// 2. 重新计算 NormalizedContent 和 ContentHash保证和 NormalizeFacts 的逻辑一致。
// 原因LLM 输出的 merged content 需要重新走归一化链,避免大小写/空格差异导致后续 Hash 去重失效。
updatedContent := strings.TrimSpace(decision.Content)
if updatedContent == "" {
updatedContent = fact.Content
}
normalizedContent := strings.ToLower(updatedContent)
// 复用 utils.HashContent 的 sha256(memoryType + "::" + normalizedContent) 算法。
contentHash := memoryutils.HashContent(fact.MemoryType, normalizedContent)
title := strings.TrimSpace(decision.Title)
if title == "" {
title = oldItem.Title
}
// 3. 执行内容更新。
fields := memorymodel.UpdateContentFields{
Title: title,
Content: updatedContent,
NormalizedContent: normalizedContent,
ContentHash: contentHash,
Confidence: fact.Confidence,
Importance: fact.Importance,
}
if err := itemRepo.UpdateContentByID(ctx, decision.TargetID, fields); err != nil {
return nil, fmt.Errorf("更新记忆内容失败(id=%d): %w", decision.TargetID, err)
}
// 4. 构造 after 快照用于审计。
afterItem := *oldItem
afterItem.Title = title
afterItem.Content = updatedContent
if afterItem.NormalizedContent != nil {
afterItem.NormalizedContent = &normalizedContent
} else {
afterItem.NormalizedContent = strPtrFromValue(normalizedContent)
}
if afterItem.ContentHash != nil {
afterItem.ContentHash = &contentHash
} else {
afterItem.ContentHash = strPtrFromValue(contentHash)
}
afterItem.Confidence = fact.Confidence
afterItem.Importance = fact.Importance
// 5. 写审计日志update 动作同时有 before 和 after 快照)。
audit := memoryutils.BuildItemAuditLog(
oldItem.ID,
oldItem.UserID,
memoryutils.AuditOperationUpdate,
"system",
formatAuditReason("决策层更新", decision.Reason),
oldItem,
&afterItem,
)
if err := auditRepo.Create(ctx, audit); err != nil {
return nil, fmt.Errorf("更新审计写入失败: %w", err)
}
// 6. 向量状态重置为 pending触发向量重同步。
// 原因:内容变了,旧向量已过期,需要重新 embed。
_ = itemRepo.UpdateVectorStateByID(ctx, oldItem.ID, "pending", nil)
return &ApplyActionOutcome{
Action: memorymodel.DecisionActionUpdate,
MemoryID: oldItem.ID,
OldItem: oldItem,
NewItem: &afterItem,
NeedsSync: true,
}, nil
}
// applyDelete 执行软删除动作:查 before → 软删 → 写审计before only
func applyDelete(
ctx context.Context,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
decision memorymodel.FinalDecision,
userID int,
) (*ApplyActionOutcome, error) {
// 1. 查 before 快照。
oldItem, err := itemRepo.GetByIDForUser(ctx, userID, decision.TargetID)
if err != nil {
return nil, fmt.Errorf("查询旧记忆失败(id=%d): %w", decision.TargetID, err)
}
// 2. 执行软删除。
if err := itemRepo.SoftDeleteByID(ctx, userID, decision.TargetID); err != nil {
return nil, fmt.Errorf("软删除记忆失败(id=%d): %w", decision.TargetID, err)
}
// 3. 写审计日志delete 动作只有 before 快照)。
audit := memoryutils.BuildItemAuditLog(
oldItem.ID,
oldItem.UserID,
memoryutils.AuditOperationDelete,
"system",
formatAuditReason("决策层删除", decision.Reason),
oldItem,
nil,
)
if err := auditRepo.Create(ctx, audit); err != nil {
return nil, fmt.Errorf("删除审计写入失败: %w", err)
}
return &ApplyActionOutcome{
Action: memorymodel.DecisionActionDelete,
MemoryID: oldItem.ID,
OldItem: oldItem,
NeedsSync: false,
}, nil
}
// formatAuditReason 统一审计日志的 reason 格式。
func formatAuditReason(prefix, detail string) string {
detail = strings.TrimSpace(detail)
if detail == "" {
return prefix
}
return prefix + ": " + detail
}