Version: 0.9.22.dev.260416
后端: 1. 品牌文案与聊天定位统一切到 SmartMate,并放宽非排程问答能力 - 系统人设、路由、排程、查询、交付提示统一从 SmartFlow 改为 SmartMate - 明确普通问答/生活建议/开放讨论可正常回答,deep_answer 不再输出“让我想想”等占位话术 - thinkingMode=auto 时,deep_answer 默认开启 thinking,execute 继续跟随路由决策,其余路由默认关闭 2. Memory 读取链路升级为“结构化强约束 + 语义候选”hybrid 模式,并补齐注入渲染 / Execute 消费 - 新增 read.mode、四类记忆预算、inject.renderMode 等配置及默认值 - 落地 HybridRetrieve,统一 MySQL/RAG 读侧作用域、三级去重(ID/hash/text)、统一重排与按类型预算裁剪 - 新增 FindPinnedByUser、content_hash DTO/兜底补算、legacy/RAG 共用读侧查询口径与 fallback 逻辑 - 记忆注入支持 flat/typed_v2 两种渲染,execute msg3 正式消费 memory_context,主链路注入 MemoryReader 时同步透传 memory 配置 3. Memory 第二步/第三步 handoff 与治理文档补齐 - HANDOFF_Memory向Mem0靠拢三步冲刺计划.md 从 newAgent 迁到 memory 目录,并补充“我的记忆”增删改查与最小留痕口径 - 新增 backend/memory/记忆模块第二步计划.md、backend/memory/第三步治理与观测落地计划.md,分别拆解 hybrid 读取注入闭环与治理/观测/清理路线 - 同步更新 backend/memory/Log.txt 调试日志 前端: 1. 助手输入区新增“智能编排”任务类选择器,并把 task_class_ids 作为请求 extra 透传 - 新建 frontend/src/components/assistant/TaskClassPlanningPicker.vue,支持拉取任务类列表、临时勾选、已选标签回显与清空 - 更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:Chat extra 正式建模 task_class_ids / retry 字段;当本轮带编排任务类时强制新起会话,避免把现有会话历史误混入新编排 2. 会话上下文窗口统计接入前端展示 - 更新 frontend/src/api/agent.ts、新建 frontend/src/components/assistant/ContextWindowMeter.vue、更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:接入 /agent/context-stats,兼容 object/string/null 三种返回;在输入工具栏展示 msg0~msg3 占比与预算使用率 3. 助手面板交互细节优化 - 更新 frontend/src/components/dashboard/AssistantPanel.vue:thinking 开关改为 auto/true/false 三态选择;切会话与重试后同步刷新 context stats;历史列表首屏不足时自动继续分页直到形成滚动区 仓库:无
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ func toItemDTO(item model.MemoryItem) memorymodel.ItemDTO {
|
||||
MemoryType: item.MemoryType,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
ContentHash: fallbackContentHash(item.MemoryType, item.Content, strValue(item.ContentHash)),
|
||||
Confidence: item.Confidence,
|
||||
Importance: item.Importance,
|
||||
SensitivityLevel: item.SensitivityLevel,
|
||||
@@ -117,3 +119,31 @@ func strValue(v *string) string {
|
||||
}
|
||||
return strings.TrimSpace(*v)
|
||||
}
|
||||
|
||||
// fallbackContentHash 返回条目可用于服务级去重的内容哈希。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 优先复用库内已落表的 content_hash,避免同一条数据多套算法口径不一致;
|
||||
// 2. 若历史数据或 RAG metadata 没带 hash,则按“类型 + 规范化内容”补算;
|
||||
// 3. 若类型非法或正文为空,则返回空字符串,让上游继续走文本兜底去重。
|
||||
func fallbackContentHash(memoryType, content, currentHash string) string {
|
||||
currentHash = strings.TrimSpace(currentHash)
|
||||
if currentHash != "" {
|
||||
return currentHash
|
||||
}
|
||||
|
||||
normalizedType := memorymodel.NormalizeMemoryType(memoryType)
|
||||
normalizedContent := normalizeContentForHash(content)
|
||||
if normalizedType == "" || normalizedContent == "" {
|
||||
return ""
|
||||
}
|
||||
return memoryutils.HashContent(normalizedType, normalizedContent)
|
||||
}
|
||||
|
||||
func normalizeContentForHash(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(content), " "))
|
||||
}
|
||||
|
||||
@@ -15,17 +15,23 @@ import (
|
||||
// 3. 轮询与重试参数给出保守默认值,避免对主链路造成压力。
|
||||
func LoadConfigFromViper() memorymodel.Config {
|
||||
cfg := memorymodel.Config{
|
||||
Enabled: viper.GetBool("memory.enabled"),
|
||||
RAGEnabled: viper.GetBool("memory.rag.enabled"),
|
||||
ExtractPrompt: viper.GetString("memory.prompt.extract"),
|
||||
DecisionPrompt: viper.GetString("memory.prompt.decision"),
|
||||
Threshold: viper.GetFloat64("memory.threshold"),
|
||||
EnableReranker: viper.GetBool("memory.enableReranker"),
|
||||
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
|
||||
LLMTopP: viper.GetFloat64("memory.llm.topP"),
|
||||
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
|
||||
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
|
||||
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
|
||||
Enabled: viper.GetBool("memory.enabled"),
|
||||
RAGEnabled: viper.GetBool("memory.rag.enabled"),
|
||||
ReadMode: memorymodel.NormalizeReadMode(viper.GetString("memory.read.mode")),
|
||||
InjectRenderMode: memorymodel.NormalizeInjectRenderMode(viper.GetString("memory.inject.renderMode")),
|
||||
ExtractPrompt: viper.GetString("memory.prompt.extract"),
|
||||
DecisionPrompt: viper.GetString("memory.prompt.decision"),
|
||||
Threshold: viper.GetFloat64("memory.threshold"),
|
||||
EnableReranker: viper.GetBool("memory.enableReranker"),
|
||||
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
|
||||
LLMTopP: viper.GetFloat64("memory.llm.topP"),
|
||||
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
|
||||
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
|
||||
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
|
||||
ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"),
|
||||
ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"),
|
||||
ReadFactLimit: viper.GetInt("memory.read.factLimit"),
|
||||
ReadTodoHintLimit: viper.GetInt("memory.read.todoHintLimit"),
|
||||
|
||||
// 决策层配置:默认关闭,灰度开启后才会生效。
|
||||
DecisionEnabled: viper.GetBool("memory.decision.enabled"),
|
||||
@@ -53,6 +59,12 @@ func LoadConfigFromViper() memorymodel.Config {
|
||||
if cfg.WorkerClaimBatch <= 0 {
|
||||
cfg.WorkerClaimBatch = 1
|
||||
}
|
||||
cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit()
|
||||
cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit()
|
||||
cfg.ReadFactLimit = cfg.EffectiveReadFactLimit()
|
||||
cfg.ReadTodoHintLimit = cfg.EffectiveReadTodoHintLimit()
|
||||
cfg.ReadMode = cfg.EffectiveReadMode()
|
||||
cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode()
|
||||
|
||||
// 决策层配置默认值兜底。
|
||||
// 说明:
|
||||
|
||||
83
backend/memory/service/read_scope.go
Normal file
83
backend/memory/service/read_scope.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
// buildReadScopedItemQuery 构造读侧统一使用的 MySQL 查询条件。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 RetrieveRequest 映射成“读侧作用域”查询参数;
|
||||
// 2. 不负责真正查库,也不负责排序、裁剪或注入;
|
||||
// 3. conversation_id 字段在这里刻意不参与过滤,仅保留在记忆记录元数据里供审计与溯源使用。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 读侧始终按 user_id 作为硬隔离边界,避免跨用户串记忆。
|
||||
// 2. assistant_id / run_id 仍允许参与过滤,因为它们表达的是助手实例与执行轮次边界,而不是“是否跨对话召回”的问题。
|
||||
// 3. conversation_id 明确置空,原因是聊天上下文窗口已经覆盖同对话信息;记忆读侧的价值主要在跨对话补充。
|
||||
func buildReadScopedItemQuery(
|
||||
req memorymodel.RetrieveRequest,
|
||||
now time.Time,
|
||||
statuses []string,
|
||||
limit int,
|
||||
) memorymodel.ItemQuery {
|
||||
return memorymodel.ItemQuery{
|
||||
UserID: req.UserID,
|
||||
ConversationID: "",
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
Statuses: statuses,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
IncludeGlobal: true,
|
||||
OnlyUnexpired: true,
|
||||
Limit: limit,
|
||||
Now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// buildReadScopedRAGRequest 构造读侧统一使用的 RAG 检索请求。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责生成 memory 检索请求,不负责执行向量检索;
|
||||
// 2. 不负责阈值外的重排、fallback 或去重;
|
||||
// 3. conversation_id 字段同样只保留在文档 metadata 中,不再作为聊天读侧的硬过滤条件。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. user_id 仍是唯一必须保留的硬过滤条件,确保召回范围限定在当前用户。
|
||||
// 2. conversation_id 明确置空,避免旧对话记忆在进入相似度计算前就被 metadata filter 提前挡掉。
|
||||
// 3. assistant_id / run_id 保持透传,方便后续若存在多助手场景时继续做更细粒度隔离。
|
||||
func buildReadScopedRAGRequest(
|
||||
req memorymodel.RetrieveRequest,
|
||||
topK int,
|
||||
threshold float64,
|
||||
) infrarag.MemoryRetrieveRequest {
|
||||
return infrarag.MemoryRetrieveRequest{
|
||||
Query: req.Query,
|
||||
TopK: topK,
|
||||
Threshold: threshold,
|
||||
Action: "search",
|
||||
UserID: req.UserID,
|
||||
ConversationID: "",
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldReturnSemanticRAGResult 判断当前是否可以直接采用 RAG 结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责表达“RAG 是否足以短路后续 MySQL fallback”这一条业务规则;
|
||||
// 2. 不负责执行任何检索,也不负责日志记录;
|
||||
// 3. 返回 false 不代表错误,只代表调用方应继续尝试数据库兜底。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. RAG 报错时,一定不能短路,必须继续走 MySQL fallback。
|
||||
// 2. RAG 0 命中时,同样不能短路;否则会把“成功执行但没有候选”误当成最终结果。
|
||||
// 3. 只有“无报错且结果非空”时,才允许直接返回 RAG 结果。
|
||||
func shouldReturnSemanticRAGResult(items []memorymodel.ItemDTO, err error) bool {
|
||||
return err == nil && len(items) > 0
|
||||
}
|
||||
@@ -71,6 +71,9 @@ func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequ
|
||||
}
|
||||
|
||||
limit := normalizeLimit(req.Limit, defaultRetrieveLimit, maxRetrieveLimit)
|
||||
if s.cfg.EffectiveReadMode() == memorymodel.MemoryReadModeHybrid {
|
||||
return s.HybridRetrieve(ctx, req, effectiveSetting, limit, now)
|
||||
}
|
||||
if s.cfg.RAGEnabled && s.ragRuntime != nil && strings.TrimSpace(req.Query) != "" {
|
||||
items, ragErr := s.retrieveByRAG(ctx, req, effectiveSetting, limit, now)
|
||||
if ragErr == nil && len(items) > 0 {
|
||||
@@ -91,18 +94,12 @@ func (s *ReadService) retrieveByLegacy(
|
||||
if !effectiveSetting.MemoryEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
query := memorymodel.ItemQuery{
|
||||
UserID: req.UserID,
|
||||
ConversationID: req.ConversationID,
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
Statuses: []string{model.MemoryItemStatusActive},
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
IncludeGlobal: true,
|
||||
OnlyUnexpired: true,
|
||||
Limit: normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
|
||||
Now: now,
|
||||
}
|
||||
query := buildReadScopedItemQuery(
|
||||
req,
|
||||
now,
|
||||
[]string{model.MemoryItemStatusActive},
|
||||
normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
|
||||
)
|
||||
|
||||
items, err := s.itemRepo.FindByQuery(ctx, query)
|
||||
if err != nil {
|
||||
@@ -114,8 +111,8 @@ func (s *ReadService) retrieveByLegacy(
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left := scoreRetrievedItem(items[i], now, req.ConversationID)
|
||||
right := scoreRetrievedItem(items[j], now, req.ConversationID)
|
||||
left := scoreRetrievedItem(items[i], now)
|
||||
right := scoreRetrievedItem(items[j], now)
|
||||
if left == right {
|
||||
return items[i].ID > items[j].ID
|
||||
}
|
||||
@@ -140,17 +137,7 @@ func (s *ReadService) retrieveByRAG(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, infrarag.MemoryRetrieveRequest{
|
||||
Query: req.Query,
|
||||
TopK: limit,
|
||||
Threshold: s.cfg.Threshold,
|
||||
Action: "search",
|
||||
UserID: req.UserID,
|
||||
ConversationID: req.ConversationID,
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
})
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, limit, s.cfg.Threshold))
|
||||
if err != nil || result == nil || len(result.Items) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,14 +180,17 @@ func normalizeRetrieveMemoryTypes(raw []string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func scoreRetrievedItem(item model.MemoryItem, now time.Time, conversationID string) float64 {
|
||||
// scoreRetrievedItem 计算 legacy 读链路的确定性排序分数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只保留 importance / confidence / recency / explicit / type 这些稳定特征;
|
||||
// 2. conversation_id 已不再参与读侧打分,因为同对话信息本就已经在上下文窗口内;
|
||||
// 3. 若后续需要引入语义分或 reranker,应在 DTO 层补齐对应字段后再统一并入。
|
||||
func scoreRetrievedItem(item model.MemoryItem, now time.Time) float64 {
|
||||
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScore(item, now)
|
||||
if item.IsExplicit {
|
||||
score += 0.1
|
||||
}
|
||||
if strValue(item.ConversationID) != "" && strValue(item.ConversationID) == conversationID {
|
||||
score += 0.08
|
||||
}
|
||||
switch item.MemoryType {
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
score += 0.12
|
||||
@@ -262,15 +252,18 @@ func collectMemoryIDs(items []model.MemoryItem) []int64 {
|
||||
func buildMemoryDTOFromRetrieveHit(hit infrarag.RetrieveHit) (memorymodel.ItemDTO, int64) {
|
||||
memoryID := parseMemoryIDFromDocumentID(hit.DocumentID)
|
||||
metadata := hit.Metadata
|
||||
content := strings.TrimSpace(hit.Text)
|
||||
memoryType := readString(metadata["memory_type"])
|
||||
dto := memorymodel.ItemDTO{
|
||||
ID: memoryID,
|
||||
UserID: int(readFloatLike(metadata["user_id"])),
|
||||
ConversationID: readString(metadata["conversation_id"]),
|
||||
AssistantID: readString(metadata["assistant_id"]),
|
||||
RunID: readString(metadata["run_id"]),
|
||||
MemoryType: readString(metadata["memory_type"]),
|
||||
MemoryType: memoryType,
|
||||
Title: readString(metadata["title"]),
|
||||
Content: strings.TrimSpace(hit.Text),
|
||||
Content: content,
|
||||
ContentHash: fallbackContentHash(memoryType, content, readString(metadata["content_hash"])),
|
||||
Confidence: readFloatLike(metadata["confidence"]),
|
||||
Importance: readFloatLike(metadata["importance"]),
|
||||
SensitivityLevel: int(readFloatLike(metadata["sensitivity_level"])),
|
||||
|
||||
333
backend/memory/service/retrieve_merge.go
Normal file
333
backend/memory/service/retrieve_merge.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// HybridRetrieve 统一承接读取侧混合召回链路。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 结构化路由先取 constraint / 高置信 preference,给模型一份稳定“硬约束底座”;
|
||||
// 2. 再补语义候选,优先走 RAG;RAG 报错或 0 命中时都回退 MySQL,保证链路韧性;
|
||||
// 3. 两路结果统一做三级去重、排序与类型预算裁剪,只对最终真正注入的条目刷新 last_access_at;
|
||||
// 4. 旧 legacy 链路完全保留,方便通过配置快速回滚。
|
||||
func (s *ReadService) HybridRetrieve(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
if s == nil || s.itemRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !effectiveSetting.MemoryEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pinnedItems, err := s.retrievePinnedCandidates(ctx, req, effectiveSetting, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
semanticItems, err := s.retrieveSemanticCandidates(ctx, req, effectiveSetting, limit, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := make([]memorymodel.ItemDTO, 0, len(pinnedItems)+len(semanticItems))
|
||||
merged = append(merged, pinnedItems...)
|
||||
merged = append(merged, semanticItems...)
|
||||
if len(merged) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
merged = dedupByID(merged)
|
||||
merged = dedupByHash(merged)
|
||||
merged = dedupByText(merged)
|
||||
merged = RankItems(merged, now)
|
||||
merged = applyTypeBudget(merged, s.cfg)
|
||||
if len(merged) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_ = s.itemRepo.TouchLastAccessAt(ctx, collectItemDTOIDs(merged), now)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrievePinnedCandidates(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
query := buildReadScopedItemQuery(req, now, nil, 0)
|
||||
items, err := s.itemRepo.FindPinnedByUser(ctx, query, s.cfg.EffectiveReadPreferenceLimit())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
|
||||
return toItemDTOs(items), nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidates(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
queryText := strings.TrimSpace(req.Query)
|
||||
if queryText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidateLimit := hybridSemanticTopK(s.cfg, limit)
|
||||
if s.cfg.RAGEnabled && s.ragRuntime != nil {
|
||||
items, err := s.retrieveSemanticCandidatesByRAG(ctx, req, effectiveSetting, candidateLimit, now)
|
||||
if shouldReturnSemanticRAGResult(items, err) {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
return s.retrieveSemanticCandidatesByMySQL(ctx, req, effectiveSetting, candidateLimit, now)
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidatesByRAG(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
candidateLimit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, candidateLimit, s.cfg.Threshold))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || len(result.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := make([]memorymodel.ItemDTO, 0, len(result.Items))
|
||||
for _, hit := range result.Items {
|
||||
dto, memoryID := buildMemoryDTOFromRetrieveHit(hit)
|
||||
if !effectiveSetting.ImplicitMemoryEnabled && !dto.IsExplicit {
|
||||
continue
|
||||
}
|
||||
if !effectiveSetting.SensitiveMemoryEnabled && dto.SensitivityLevel > 0 {
|
||||
continue
|
||||
}
|
||||
if dto.ID <= 0 && memoryID > 0 {
|
||||
dto.ID = memoryID
|
||||
}
|
||||
items = append(items, dto)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidatesByMySQL(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
candidateLimit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
query := buildReadScopedItemQuery(
|
||||
req,
|
||||
now,
|
||||
[]string{model.MemoryItemStatusActive},
|
||||
normalizeLimit(candidateLimit*3, candidateLimit*3, maxRetrieveLimit*3),
|
||||
)
|
||||
|
||||
items, err := s.itemRepo.FindByQuery(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
|
||||
return toItemDTOs(items), nil
|
||||
}
|
||||
|
||||
// dedupByID 按 memory_id 去重,后出现的结果覆盖先出现的结果。
|
||||
func dedupByID(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
result := make([]memorymodel.ItemDTO, 0, len(items))
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
item := items[i]
|
||||
if item.ID <= 0 {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
reverseItemDTOs(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupByHash 按 content_hash 去重;缺失 hash 时跳过,保留 importance 更高的条目。
|
||||
func dedupByHash(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
|
||||
return fallbackContentHash(item.MemoryType, item.Content, item.ContentHash)
|
||||
})
|
||||
}
|
||||
|
||||
// dedupByText 按“类型标签 + 文本”兜底去重,用于覆盖历史数据未带 hash 的场景。
|
||||
func dedupByText(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
|
||||
text := strings.TrimSpace(item.Content)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return renderMemoryTypeLabelForDedup(item.MemoryType) + "::" + normalizeContentForHash(text)
|
||||
})
|
||||
}
|
||||
|
||||
func dedupByKey(items []memorymodel.ItemDTO, keyBuilder func(item memorymodel.ItemDTO) string) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedIndex := make(map[string]int, len(items))
|
||||
for index, item := range items {
|
||||
key := strings.TrimSpace(keyBuilder(item))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := selectedIndex[key]; exists {
|
||||
if preferCurrentItem(items[previous], item) {
|
||||
selectedIndex[key] = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
selectedIndex[key] = index
|
||||
}
|
||||
|
||||
result := make([]memorymodel.ItemDTO, 0, len(items))
|
||||
for index, item := range items {
|
||||
key := strings.TrimSpace(keyBuilder(item))
|
||||
if key == "" {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
if selectedIndex[key] == index {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func preferCurrentItem(previous memorymodel.ItemDTO, current memorymodel.ItemDTO) bool {
|
||||
if current.Importance != previous.Importance {
|
||||
return current.Importance > previous.Importance
|
||||
}
|
||||
if current.Confidence != previous.Confidence {
|
||||
return current.Confidence > previous.Confidence
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyTypeBudget 在排序结果上应用四类记忆预算。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 每种类型先保底自己的预算上限,避免 fact 抢掉 constraint 的位置;
|
||||
// 2. 裁剪时保持当前排序顺序,不在这里重新打分;
|
||||
// 3. 最终总量由四类预算之和共同决定,默认 18 条。
|
||||
func applyTypeBudget(items []memorymodel.ItemDTO, cfg memorymodel.Config) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
budgetByType := map[string]int{
|
||||
memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(),
|
||||
memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(),
|
||||
memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(),
|
||||
memorymodel.MemoryTypeTodoHint: cfg.EffectiveReadTodoHintLimit(),
|
||||
}
|
||||
usedByType := make(map[string]int, len(budgetByType))
|
||||
result := make([]memorymodel.ItemDTO, 0, minInt(len(items), cfg.TotalReadBudget()))
|
||||
for _, item := range items {
|
||||
if len(result) >= cfg.TotalReadBudget() {
|
||||
break
|
||||
}
|
||||
|
||||
memoryType := resolveBudgetMemoryType(item.MemoryType)
|
||||
if usedByType[memoryType] >= budgetByType[memoryType] {
|
||||
continue
|
||||
}
|
||||
usedByType[memoryType]++
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hybridSemanticTopK(cfg memorymodel.Config, limit int) int {
|
||||
if cfg.TotalReadBudget() > limit {
|
||||
return cfg.TotalReadBudget()
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func resolveBudgetMemoryType(memoryType string) string {
|
||||
normalized := memorymodel.NormalizeMemoryType(memoryType)
|
||||
if normalized == "" {
|
||||
return memorymodel.MemoryTypeFact
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func renderMemoryTypeLabelForDedup(memoryType string) string {
|
||||
switch memorymodel.NormalizeMemoryType(memoryType) {
|
||||
case memorymodel.MemoryTypePreference:
|
||||
return "偏好"
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
return "约束"
|
||||
case memorymodel.MemoryTypeTodoHint:
|
||||
return "待办线索"
|
||||
case memorymodel.MemoryTypeFact:
|
||||
return "事实"
|
||||
default:
|
||||
return "记忆"
|
||||
}
|
||||
}
|
||||
|
||||
func collectItemDTOIDs(items []memorymodel.ItemDTO) []int64 {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func reverseItemDTOs(items []memorymodel.ItemDTO) {
|
||||
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
|
||||
items[left], items[right] = items[right], items[left]
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
78
backend/memory/service/retrieve_rank.go
Normal file
78
backend/memory/service/retrieve_rank.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
// RankItems 对读取结果做统一重排。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先基于 importance / confidence / recency 构造基础分,保持和旧链路相近的排序直觉;
|
||||
// 2. 再叠加“显式记忆 / 类型优先级”奖励,让 constraint 与 preference 更稳定地排在前面;
|
||||
// 3. 同分按 ID 降序,保证排序在日志与测试里具备稳定性。
|
||||
func RankItems(items []memorymodel.ItemDTO, now time.Time) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranked := make([]memorymodel.ItemDTO, len(items))
|
||||
copy(ranked, items)
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
left := scoreRankedItem(ranked[i], now)
|
||||
right := scoreRankedItem(ranked[j], now)
|
||||
if left == right {
|
||||
return ranked[i].ID > ranked[j].ID
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
return ranked
|
||||
}
|
||||
|
||||
// scoreRankedItem 计算 hybrid 读链路的统一重排分数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里仍然只依赖条目自身属性,不引入 conversation_id 加分;
|
||||
// 2. 原因是同对话内容本就已经存在于上下文窗口,记忆读侧应专注跨对话补充;
|
||||
// 3. 类型加权仍然保留,用于确保 constraint / preference 的业务优先级稳定生效。
|
||||
func scoreRankedItem(item memorymodel.ItemDTO, now time.Time) float64 {
|
||||
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScoreDTO(item, now)
|
||||
if item.IsExplicit {
|
||||
score += 0.1
|
||||
}
|
||||
switch memorymodel.NormalizeMemoryType(item.MemoryType) {
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
score += 0.15
|
||||
case memorymodel.MemoryTypePreference:
|
||||
score += 0.10
|
||||
case memorymodel.MemoryTypeTodoHint:
|
||||
score += 0.05
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func recencyScoreDTO(item memorymodel.ItemDTO, now time.Time) float64 {
|
||||
base := item.UpdatedAt
|
||||
if base == nil {
|
||||
base = item.CreatedAt
|
||||
}
|
||||
if base == nil || now.Before(*base) {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
age := now.Sub(*base)
|
||||
switch {
|
||||
case age <= 24*time.Hour:
|
||||
return 1
|
||||
case age <= 7*24*time.Hour:
|
||||
return 0.85
|
||||
case age <= 30*24*time.Hour:
|
||||
return 0.65
|
||||
case age <= 90*24*time.Hour:
|
||||
return 0.45
|
||||
default:
|
||||
return 0.25
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user