后端: 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 模块 前端:无 仓库:无
387 lines
14 KiB
Go
387 lines
14 KiB
Go
package worker
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
|
||
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"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// DecisionFlowOutcome 是一轮决策流程的汇总结果。
|
||
//
|
||
// 说明:
|
||
// 1. AddCount/UpdateCount/DeleteCount/NoneCount 分别统计四种动作的执行次数;
|
||
// 2. ItemsToSync 收集所有需要向量同步的 item(ADD 和 UPDATE 产出的);
|
||
// 3. VectorDeletes 收集所有需要从向量库删除的 memory_id(DELETE 动作产出的)。
|
||
type DecisionFlowOutcome struct {
|
||
AddCount int
|
||
UpdateCount int
|
||
DeleteCount int
|
||
NoneCount int
|
||
ItemsToSync []model.MemoryItem // 需要向量同步的新增/更新 item
|
||
VectorDeletes []int64 // 需要从向量库删除的 memory_id 列表
|
||
}
|
||
|
||
// factDecisionResult 是单条 fact 的决策执行结果,支持一对多动作。
|
||
// 原因:conflict 场景下会产生 DELETE + ADD 两个动作,需要打包返回。
|
||
type factDecisionResult struct {
|
||
Outcomes []*ApplyActionOutcome
|
||
}
|
||
|
||
type candidateRecallResult struct {
|
||
Items []memorymodel.CandidateSnapshot
|
||
FallbackMode string
|
||
}
|
||
|
||
// executeDecisionFlow 在 worker 内编排"召回→逐对比对→汇总→执行"全流程。
|
||
//
|
||
// 职责边界:
|
||
// 1. 对每条 fact 独立执行完整决策流程,fact 之间互不影响;
|
||
// 2. 所有数据库写操作在同一个事务内完成,保证原子性;
|
||
// 3. 向量同步在事务外异步执行,不影响事务提交。
|
||
//
|
||
// 降级策略:
|
||
// 1. Milvus 不可用时,回退到 MySQL 按类型查最近 N 条活跃记忆;
|
||
// 2. 单条 LLM 比对失败不影响其他候选,视为 unrelated;
|
||
// 3. 整体流程报错时,由上层根据 FallbackMode 决定是否退回旧路径。
|
||
func (r *Runner) executeDecisionFlow(
|
||
ctx context.Context,
|
||
job *model.MemoryJob,
|
||
payload memorymodel.ExtractJobPayload,
|
||
facts []memorymodel.NormalizedFact,
|
||
) (*DecisionFlowOutcome, error) {
|
||
outcome := &DecisionFlowOutcome{
|
||
ItemsToSync: make([]model.MemoryItem, 0, len(facts)),
|
||
VectorDeletes: make([]int64, 0),
|
||
}
|
||
|
||
// 1. 所有数据库写操作在同一个事务内完成。
|
||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
itemRepo := r.itemRepo.WithTx(tx)
|
||
auditRepo := r.auditRepo.WithTx(tx)
|
||
jobRepo := r.jobRepo.WithTx(tx)
|
||
|
||
for _, fact := range facts {
|
||
// 2. 对每条 fact 执行完整决策流程。
|
||
result, err := r.executeDecisionForFact(ctx, itemRepo, auditRepo, fact, job, payload)
|
||
if err != nil {
|
||
// 单条 fact 决策失败不影响其他 fact,记录日志后继续。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] 单条 fact 决策失败,跳过继续: job_id=%d user_id=%d memory_type=%s hash=%s err=%v", job.ID, payload.UserID, fact.MemoryType, fact.ContentHash, err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 3. 汇总结果到全局 outcome。
|
||
for _, actionOutcome := range result.Outcomes {
|
||
r.collectActionOutcome(outcome, actionOutcome)
|
||
}
|
||
}
|
||
|
||
// 4. 事务内最后确认 job 成功。
|
||
return jobRepo.MarkSuccess(ctx, job.ID)
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return outcome, nil
|
||
}
|
||
|
||
// executeDecisionForFact 对单条 fact 执行完整决策流程。
|
||
//
|
||
// 步骤:
|
||
// 1. Hash 精确命中检查 — 已有完全相同内容则直接跳过;
|
||
// 2. Milvus 语义召回 — 从旧记忆中筛出 TopK 候选(含降级);
|
||
// 3. 逐对 LLM 比对 — 每次拿一条新 fact 和一条旧候选比对;
|
||
// 4. 确定性汇总 — 根据 LLM 比对结果确定 ADD/UPDATE/DELETE/NONE;
|
||
// 5. 校验 + 执行 — 落为数据库动作 + 审计日志。
|
||
func (r *Runner) executeDecisionForFact(
|
||
ctx context.Context,
|
||
itemRepo *memoryrepo.ItemRepo,
|
||
auditRepo *memoryrepo.AuditRepo,
|
||
fact memorymodel.NormalizedFact,
|
||
job *model.MemoryJob,
|
||
payload memorymodel.ExtractJobPayload,
|
||
) (*factDecisionResult, error) {
|
||
result := &factDecisionResult{}
|
||
|
||
// Step 1: Hash 精确命中检查。
|
||
// 原因:如果已有完全相同内容的记忆,直接跳过,无需调 LLM。
|
||
existing, err := itemRepo.FindActiveByHash(ctx, payload.UserID, fact.ContentHash)
|
||
if err != nil {
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] Hash 精确匹配查询失败: user_id=%d memory_type=%s hash=%s err=%v", payload.UserID, fact.MemoryType, fact.ContentHash, err)
|
||
}
|
||
}
|
||
if len(existing) > 0 {
|
||
r.recordDecisionObservation(ctx, job, payload, fact, 0, memorymodel.DecisionActionNone, "hash_exact", true, nil)
|
||
result.Outcomes = append(result.Outcomes, &ApplyActionOutcome{
|
||
Action: memorymodel.DecisionActionNone,
|
||
NeedsSync: false,
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
// Step 2: Milvus 语义召回(含降级)。
|
||
recallResult := r.recallCandidates(ctx, payload, fact)
|
||
candidates := recallResult.Items
|
||
|
||
// 打印召回候选详情,便于排查向量召回和阈值过滤效果。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[DEBUG][去重] 语义召回候选: job_id=%d user_id=%d memory_type=%s candidate_count=%d",
|
||
job.ID, payload.UserID, fact.MemoryType, len(candidates))
|
||
for _, c := range candidates {
|
||
r.logger.Printf("[DEBUG][去重] 候选详情: memory_id=%d score=%.4f content=\"%s\"",
|
||
c.MemoryID, c.Score, truncateRunes(c.Content, 50))
|
||
}
|
||
}
|
||
|
||
// Step 3: 逐对 LLM 比对。
|
||
comparisons := r.compareWithCandidates(ctx, fact, candidates)
|
||
|
||
// Step 4: 确定性汇总。
|
||
decision := memoryutils.AggregateComparisons(fact, comparisons, candidates)
|
||
|
||
// 打印汇总决策结果,便于排查去重终态。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[DEBUG][去重] 汇总决策: job_id=%d action=%s target_id=%d reason=\"%s\"",
|
||
job.ID, decision.Action, decision.TargetID, decision.Reason)
|
||
}
|
||
|
||
// Step 5: 校验 + 执行。
|
||
actionOutcome, err := ApplyFinalDecision(ctx, itemRepo, auditRepo, *decision, fact, job, payload)
|
||
if err != nil {
|
||
r.recordDecisionObservation(ctx, job, payload, fact, len(candidates), decision.Action, recallResult.FallbackMode, false, err)
|
||
return nil, fmt.Errorf("执行决策动作失败: %w", err)
|
||
}
|
||
result.Outcomes = append(result.Outcomes, actionOutcome)
|
||
r.recordDecisionObservation(ctx, job, payload, fact, len(candidates), decision.Action, recallResult.FallbackMode, true, nil)
|
||
|
||
// Step 6: conflict (DELETE) 后需要补一个 ADD 写入新 fact。
|
||
// 原因:旧记忆矛盾需删除,但新事实本身仍然有效,必须写入。
|
||
if decision.Action == memorymodel.DecisionActionDelete {
|
||
addDecision := memorymodel.FinalDecision{
|
||
Action: memorymodel.DecisionActionAdd,
|
||
Reason: "冲突旧记忆已删除,写入新事实",
|
||
}
|
||
addOutcome, addErr := ApplyFinalDecision(ctx, itemRepo, auditRepo, addDecision, fact, job, payload)
|
||
if addErr != nil {
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN] 冲突后补增失败: memory_type=%s err=%v", fact.MemoryType, addErr)
|
||
}
|
||
} else if addOutcome != nil {
|
||
result.Outcomes = append(result.Outcomes, addOutcome)
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// recallCandidates 从旧记忆中召回候选,先尝试 Milvus,降级时用 MySQL。
|
||
func (r *Runner) recallCandidates(
|
||
ctx context.Context,
|
||
payload memorymodel.ExtractJobPayload,
|
||
fact memorymodel.NormalizedFact,
|
||
) candidateRecallResult {
|
||
// 1. 优先使用 Milvus 向量语义召回。
|
||
if r.ragRuntime != nil {
|
||
retrieveResult, err := r.ragRuntime.RetrieveMemory(ctx, infrarag.MemoryRetrieveRequest{
|
||
Query: fact.Content,
|
||
TopK: r.cfg.DecisionCandidateTopK,
|
||
Threshold: r.cfg.DecisionCandidateMinScore,
|
||
UserID: payload.UserID,
|
||
MemoryTypes: []string{fact.MemoryType},
|
||
Action: "search",
|
||
})
|
||
if err == nil && len(retrieveResult.Items) > 0 {
|
||
candidates := r.buildCandidatesFromRAG(retrieveResult.Items)
|
||
if len(candidates) > 0 {
|
||
return candidateRecallResult{
|
||
Items: candidates,
|
||
FallbackMode: "rag",
|
||
}
|
||
}
|
||
// RAG 返回了结果但 DocumentID 全部解析失败,降级到 MySQL。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] Milvus 返回 %d 条结果但 DocumentID 全部解析失败,降级到 MySQL: user_id=%d memory_type=%s", len(retrieveResult.Items), payload.UserID, fact.MemoryType)
|
||
}
|
||
}
|
||
if err != nil && r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] Milvus 语义召回失败,降级到 MySQL: user_id=%d memory_type=%s topk=%d err=%v", payload.UserID, fact.MemoryType, r.cfg.DecisionCandidateTopK, err)
|
||
}
|
||
return candidateRecallResult{
|
||
Items: r.recallCandidatesFromMySQL(ctx, payload, fact),
|
||
FallbackMode: "rag_to_mysql",
|
||
}
|
||
}
|
||
|
||
// 2. 降级:按 user_id + memory_type + status=active 查最近 N 条。
|
||
return candidateRecallResult{
|
||
Items: r.recallCandidatesFromMySQL(ctx, payload, fact),
|
||
FallbackMode: "mysql_only",
|
||
}
|
||
}
|
||
|
||
// buildCandidatesFromRAG 从 RAG 检索结果构建候选快照列表。
|
||
//
|
||
// 步骤:
|
||
// 1. 从 DocumentID(格式 memory:{id})解析出 mysql_id;
|
||
// 2. 从 metadata 提取 title 和 memory_type;
|
||
// 3. 跳过无法解析 DocumentID 的结果。
|
||
func (r *Runner) buildCandidatesFromRAG(hits []infrarag.RetrieveHit) []memorymodel.CandidateSnapshot {
|
||
candidates := make([]memorymodel.CandidateSnapshot, 0, len(hits))
|
||
for _, hit := range hits {
|
||
memoryID := parseMemoryID(hit.DocumentID)
|
||
if memoryID <= 0 {
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] DocumentID 解析失败,跳过候选: document_id=%q", hit.DocumentID)
|
||
}
|
||
continue
|
||
}
|
||
|
||
candidates = append(candidates, memorymodel.CandidateSnapshot{
|
||
MemoryID: memoryID,
|
||
Title: asStringFromMap(hit.Metadata, "title"),
|
||
Content: hit.Text,
|
||
MemoryType: asStringFromMap(hit.Metadata, "memory_type"),
|
||
Score: hit.Score,
|
||
})
|
||
}
|
||
return candidates
|
||
}
|
||
|
||
// recallCandidatesFromMySQL 从 MySQL 查最近 N 条活跃记忆作为候选。
|
||
// 这是 Milvus 不可用时的降级方案。
|
||
func (r *Runner) recallCandidatesFromMySQL(
|
||
ctx context.Context,
|
||
payload memorymodel.ExtractJobPayload,
|
||
fact memorymodel.NormalizedFact,
|
||
) []memorymodel.CandidateSnapshot {
|
||
items, err := r.itemRepo.FindByQuery(ctx, memorymodel.ItemQuery{
|
||
UserID: payload.UserID,
|
||
MemoryTypes: []string{fact.MemoryType},
|
||
Statuses: []string{model.MemoryItemStatusActive},
|
||
Limit: r.cfg.DecisionCandidateTopK,
|
||
})
|
||
if err != nil {
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN] MySQL 降级召回失败: err=%v", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
candidates := make([]memorymodel.CandidateSnapshot, 0, len(items))
|
||
for _, item := range items {
|
||
candidates = append(candidates, memorymodel.CandidateSnapshot{
|
||
MemoryID: item.ID,
|
||
Title: item.Title,
|
||
Content: item.Content,
|
||
MemoryType: item.MemoryType,
|
||
Score: 0, // MySQL 降级无向量分数
|
||
})
|
||
}
|
||
return candidates
|
||
}
|
||
|
||
// compareWithCandidates 对每个候选逐一调 LLM 做关系判断。
|
||
//
|
||
// 说明:
|
||
// 1. LLM 调用失败时视为 unrelated,不影响其他候选的比对;
|
||
// 2. 对比对结果做校验,不合法的也视为 unrelated;
|
||
// 3. 无候选或决策编排器为空时返回空切片,上层直接走 ADD 路径。
|
||
func (r *Runner) compareWithCandidates(
|
||
ctx context.Context,
|
||
fact memorymodel.NormalizedFact,
|
||
candidates []memorymodel.CandidateSnapshot,
|
||
) []memorymodel.ComparisonResult {
|
||
if r.decisionOrchestrator == nil || len(candidates) == 0 {
|
||
return nil
|
||
}
|
||
|
||
comparisons := make([]memorymodel.ComparisonResult, 0, len(candidates))
|
||
for _, candidate := range candidates {
|
||
compResult, err := r.decisionOrchestrator.Compare(ctx, fact, candidate)
|
||
if err != nil {
|
||
// LLM 调用失败 → 视为 unrelated,不影响其他候选。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] LLM 逐对比较调用失败,视为 unrelated: candidate_id=%d memory_type=%s err=%v", candidate.MemoryID, fact.MemoryType, err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 校验 LLM 输出合法性,不合法也跳过。
|
||
if validateErr := memoryutils.ValidateComparisonResult(compResult); validateErr != nil {
|
||
if r.logger != nil {
|
||
r.logger.Printf("[WARN][去重] LLM 比对结果校验不通过,视为 unrelated: candidate_id=%d memory_type=%s relation=%s err=%v", candidate.MemoryID, fact.MemoryType, compResult.Relation, validateErr)
|
||
}
|
||
continue
|
||
}
|
||
|
||
comparisons = append(comparisons, *compResult)
|
||
|
||
// 打印 LLM 比对结果,便于排查误判。
|
||
if r.logger != nil {
|
||
r.logger.Printf("[DEBUG][去重] LLM 比对结果: candidate_id=%d score=%.4f relation=%s reason=\"%s\" candidate_content=\"%s\"",
|
||
candidate.MemoryID, candidate.Score, compResult.Relation, compResult.Reason, truncateRunes(candidate.Content, 50))
|
||
}
|
||
}
|
||
return comparisons
|
||
}
|
||
|
||
// collectActionOutcome 汇总单个动作结果到全局 outcome。
|
||
func (r *Runner) collectActionOutcome(outcome *DecisionFlowOutcome, actionOutcome *ApplyActionOutcome) {
|
||
if actionOutcome == nil {
|
||
return
|
||
}
|
||
|
||
switch actionOutcome.Action {
|
||
case memorymodel.DecisionActionAdd:
|
||
outcome.AddCount++
|
||
if actionOutcome.NeedsSync && actionOutcome.NewItem != nil {
|
||
outcome.ItemsToSync = append(outcome.ItemsToSync, *actionOutcome.NewItem)
|
||
}
|
||
case memorymodel.DecisionActionUpdate:
|
||
outcome.UpdateCount++
|
||
if actionOutcome.NeedsSync && actionOutcome.NewItem != nil {
|
||
outcome.ItemsToSync = append(outcome.ItemsToSync, *actionOutcome.NewItem)
|
||
}
|
||
case memorymodel.DecisionActionDelete:
|
||
outcome.DeleteCount++
|
||
outcome.VectorDeletes = append(outcome.VectorDeletes, actionOutcome.MemoryID)
|
||
case memorymodel.DecisionActionNone:
|
||
outcome.NoneCount++
|
||
}
|
||
}
|
||
|
||
// asStringFromMap 从 metadata map 中安全提取字符串值。
|
||
func asStringFromMap(m map[string]any, key string) string {
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
v, ok := m[key]
|
||
if !ok || v == nil {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
// truncateRunes 截取字符串前 n 个 rune,超出则追加 "..."。
|
||
// 用途:日志内容预览,避免超长内容撑爆单行日志。
|
||
func truncateRunes(s string, n int) string {
|
||
runes := []rune(s)
|
||
if len(runes) <= n {
|
||
return s
|
||
}
|
||
if n <= 0 {
|
||
return ""
|
||
}
|
||
return string(runes[:n]) + "..."
|
||
}
|