后端: 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;历史列表首屏不足时自动继续分页直到形成滚动区 仓库:无
341 lines
8.5 KiB
Go
341 lines
8.5 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
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"
|
||
)
|
||
|
||
const (
|
||
defaultRetrieveLimit = 5
|
||
maxRetrieveLimit = 20
|
||
)
|
||
|
||
// ReadService 负责 memory 模块内部的读取、门控与轻量重排。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责把 memory_items 读出来并做用户设置过滤;
|
||
// 2. 负责最小可用的排序与截断,为后续 prompt 注入提供稳定入口;
|
||
// 3. 不直接依赖 newAgent,不负责真正把记忆拼进 prompt。
|
||
type ReadService struct {
|
||
itemRepo *memoryrepo.ItemRepo
|
||
settingsRepo *memoryrepo.SettingsRepo
|
||
ragRuntime infrarag.Runtime
|
||
cfg memorymodel.Config
|
||
}
|
||
|
||
func NewReadService(
|
||
itemRepo *memoryrepo.ItemRepo,
|
||
settingsRepo *memoryrepo.SettingsRepo,
|
||
ragRuntime infrarag.Runtime,
|
||
cfg memorymodel.Config,
|
||
) *ReadService {
|
||
return &ReadService{
|
||
itemRepo: itemRepo,
|
||
settingsRepo: settingsRepo,
|
||
ragRuntime: ragRuntime,
|
||
cfg: cfg,
|
||
}
|
||
}
|
||
|
||
// Retrieve 读取可供后续注入使用的候选记忆。
|
||
func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error) {
|
||
if s == nil || s.itemRepo == nil || s.settingsRepo == nil {
|
||
return nil, nil
|
||
}
|
||
if req.UserID <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
now := req.Now
|
||
if now.IsZero() {
|
||
now = time.Now()
|
||
}
|
||
|
||
setting, err := s.settingsRepo.GetByUserID(ctx, req.UserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
effectiveSetting := memoryutils.EffectiveUserSetting(setting, req.UserID)
|
||
if !effectiveSetting.MemoryEnabled {
|
||
return nil, nil
|
||
}
|
||
|
||
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 {
|
||
return items, nil
|
||
}
|
||
}
|
||
|
||
return s.retrieveByLegacy(ctx, req, limit, now, effectiveSetting)
|
||
}
|
||
|
||
func (s *ReadService) retrieveByLegacy(
|
||
ctx context.Context,
|
||
req memorymodel.RetrieveRequest,
|
||
limit int,
|
||
now time.Time,
|
||
effectiveSetting model.MemoryUserSetting,
|
||
) ([]memorymodel.ItemDTO, error) {
|
||
if !effectiveSetting.MemoryEnabled {
|
||
return nil, nil
|
||
}
|
||
query := buildReadScopedItemQuery(
|
||
req,
|
||
now,
|
||
[]string{model.MemoryItemStatusActive},
|
||
normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
|
||
)
|
||
|
||
items, err := s.itemRepo.FindByQuery(ctx, query)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
|
||
if len(items) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
left := scoreRetrievedItem(items[i], now)
|
||
right := scoreRetrievedItem(items[j], now)
|
||
if left == right {
|
||
return items[i].ID > items[j].ID
|
||
}
|
||
return left > right
|
||
})
|
||
|
||
if len(items) > limit {
|
||
items = items[:limit]
|
||
}
|
||
_ = s.itemRepo.TouchLastAccessAt(ctx, collectMemoryIDs(items), now)
|
||
return toItemDTOs(items), nil
|
||
}
|
||
|
||
func (s *ReadService) retrieveByRAG(
|
||
ctx context.Context,
|
||
req memorymodel.RetrieveRequest,
|
||
effectiveSetting model.MemoryUserSetting,
|
||
limit int,
|
||
now time.Time,
|
||
) ([]memorymodel.ItemDTO, error) {
|
||
if !effectiveSetting.MemoryEnabled {
|
||
return nil, nil
|
||
}
|
||
|
||
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, limit, s.cfg.Threshold))
|
||
if err != nil || result == nil || len(result.Items) == 0 {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]memorymodel.ItemDTO, 0, len(result.Items))
|
||
ids := make([]int64, 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)
|
||
if dto.ID > 0 {
|
||
ids = append(ids, dto.ID)
|
||
}
|
||
}
|
||
if len(items) > limit {
|
||
items = items[:limit]
|
||
}
|
||
_ = s.itemRepo.TouchLastAccessAt(ctx, ids, now)
|
||
return items, nil
|
||
}
|
||
|
||
func normalizeRetrieveMemoryTypes(raw []string) []string {
|
||
normalized := normalizeMemoryTypes(raw)
|
||
if len(normalized) > 0 {
|
||
return normalized
|
||
}
|
||
return []string{
|
||
memorymodel.MemoryTypeConstraint,
|
||
memorymodel.MemoryTypePreference,
|
||
memorymodel.MemoryTypeTodoHint,
|
||
memorymodel.MemoryTypeFact,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
switch item.MemoryType {
|
||
case memorymodel.MemoryTypeConstraint:
|
||
score += 0.12
|
||
case memorymodel.MemoryTypePreference:
|
||
score += 0.08
|
||
case memorymodel.MemoryTypeTodoHint:
|
||
score += 0.05
|
||
}
|
||
return score
|
||
}
|
||
|
||
func recencyScore(item model.MemoryItem, 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
|
||
}
|
||
}
|
||
|
||
func clamp01(v float64) float64 {
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
func collectMemoryIDs(items []model.MemoryItem) []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 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: memoryType,
|
||
Title: readString(metadata["title"]),
|
||
Content: content,
|
||
ContentHash: fallbackContentHash(memoryType, content, readString(metadata["content_hash"])),
|
||
Confidence: readFloatLike(metadata["confidence"]),
|
||
Importance: readFloatLike(metadata["importance"]),
|
||
SensitivityLevel: int(readFloatLike(metadata["sensitivity_level"])),
|
||
IsExplicit: readBoolLike(metadata["is_explicit"]),
|
||
Status: readString(metadata["status"]),
|
||
TTLAt: readTimeLike(metadata["ttl_at"]),
|
||
}
|
||
return dto, memoryID
|
||
}
|
||
|
||
func parseMemoryIDFromDocumentID(documentID string) int64 {
|
||
documentID = strings.TrimSpace(documentID)
|
||
if !strings.HasPrefix(documentID, "memory:") {
|
||
return 0
|
||
}
|
||
raw := strings.TrimPrefix(documentID, "memory:")
|
||
if strings.HasPrefix(raw, "uid:") {
|
||
return 0
|
||
}
|
||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
func readString(v any) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||
}
|
||
|
||
func readFloatLike(v any) float64 {
|
||
switch value := v.(type) {
|
||
case float64:
|
||
return value
|
||
case float32:
|
||
return float64(value)
|
||
case int:
|
||
return float64(value)
|
||
case int64:
|
||
return float64(value)
|
||
case string:
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func readBoolLike(v any) bool {
|
||
switch value := v.(type) {
|
||
case bool:
|
||
return value
|
||
case string:
|
||
return strings.EqualFold(strings.TrimSpace(value), "true")
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func readTimeLike(v any) *time.Time {
|
||
text := readString(v)
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
parsed, err := time.Parse(time.RFC3339, text)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return &parsed
|
||
}
|