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

@@ -7,4 +7,5 @@ type ApiHandlers struct {
TaskClassHandler *TaskClassHandler TaskClassHandler *TaskClassHandler
ScheduleHandler *ScheduleAPI ScheduleHandler *ScheduleAPI
AgentHandler *AgentHandler AgentHandler *AgentHandler
MemoryHandler *MemoryHandler
} }

290
backend/api/memory.go Normal file
View File

@@ -0,0 +1,290 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
memorypkg "github.com/LoveLosita/smartflow/backend/memory"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/gin-gonic/gin"
)
type MemoryHandler struct {
module *memorypkg.Module
}
var errMemoryHandlerNotReady = errors.New("memory handler is not initialized")
func NewMemoryHandler(module *memorypkg.Module) *MemoryHandler {
return &MemoryHandler{module: module}
}
func (h *MemoryHandler) ListItems(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
limit, ok := parseOptionalInt(c.Query("limit"))
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
statusesRaw := c.Query("statuses")
if strings.TrimSpace(statusesRaw) == "" {
statusesRaw = c.Query("status")
}
memoryTypesRaw := c.Query("memory_types")
if strings.TrimSpace(memoryTypesRaw) == "" {
memoryTypesRaw = c.Query("memory_type")
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
items, err := h.module.ListItems(ctx, memorymodel.ListItemsRequest{
UserID: c.GetInt("user_id"),
ConversationID: strings.TrimSpace(c.Query("conversation_id")),
Statuses: splitCSV(statusesRaw),
MemoryTypes: splitCSV(memoryTypesRaw),
Limit: limit,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemViews(items)))
}
func (h *MemoryHandler) GetItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.GetItem(ctx, model.MemoryGetItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) CreateItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
var req model.MemoryCreateItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.UserID = c.GetInt("user_id")
req.OperatorType = "user"
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.CreateItem(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) UpdateItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var req model.MemoryUpdateItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.UserID = c.GetInt("user_id")
req.MemoryID = memoryID
req.OperatorType = "user"
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.UpdateItem(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) DeleteItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.DeleteItem(ctx, model.MemoryDeleteItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
Reason: strings.TrimSpace(body.Reason),
OperatorType: "user",
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func (h *MemoryHandler) RestoreItem(c *gin.Context) {
if h == nil || h.module == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errMemoryHandlerNotReady))
return
}
memoryID, ok := parseMemoryIDParam(c)
if !ok {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
item, err := h.module.RestoreItem(ctx, model.MemoryRestoreItemRequest{
UserID: c.GetInt("user_id"),
MemoryID: memoryID,
Reason: strings.TrimSpace(body.Reason),
OperatorType: "user",
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, toMemoryItemView(item)))
}
func parseMemoryIDParam(c *gin.Context) (int64, bool) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
return 0, false
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value <= 0 {
return 0, false
}
return value, true
}
func parseOptionalInt(raw string) (int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, true
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
return value, true
}
func splitCSV(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
result = append(result, part)
}
return result
}
func toMemoryItemViews(items []memorymodel.ItemDTO) []model.MemoryItemView {
if len(items) == 0 {
return nil
}
result := make([]model.MemoryItemView, 0, len(items))
for _, item := range items {
result = append(result, toMemoryItemView(&item))
}
return result
}
func toMemoryItemView(item *memorymodel.ItemDTO) model.MemoryItemView {
if item == nil {
return model.MemoryItemView{}
}
return model.MemoryItemView{
ID: item.ID,
UserID: item.UserID,
ConversationID: item.ConversationID,
AssistantID: item.AssistantID,
RunID: item.RunID,
MemoryType: item.MemoryType,
Title: item.Title,
Content: item.Content,
ContentHash: item.ContentHash,
Confidence: item.Confidence,
Importance: item.Importance,
SensitivityLevel: item.SensitivityLevel,
IsExplicit: item.IsExplicit,
Status: item.Status,
TTLAt: item.TTLAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}

View File

@@ -14,6 +14,7 @@ import (
ragconfig "github.com/LoveLosita/smartflow/backend/infra/rag/config" ragconfig "github.com/LoveLosita/smartflow/backend/infra/rag/config"
"github.com/LoveLosita/smartflow/backend/inits" "github.com/LoveLosita/smartflow/backend/inits"
"github.com/LoveLosita/smartflow/backend/memory" "github.com/LoveLosita/smartflow/backend/memory"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
"github.com/LoveLosita/smartflow/backend/middleware" "github.com/LoveLosita/smartflow/backend/middleware"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv" newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
@@ -78,7 +79,18 @@ func Start() {
// 1. memory 模块对启动层只暴露一个门面。 // 1. memory 模块对启动层只暴露一个门面。
// 2. 后续若接入统一 DI 容器,也优先注入这个门面,而不是继续暴露内部 repo/service。 // 2. 后续若接入统一 DI 容器,也优先注入这个门面,而不是继续暴露内部 repo/service。
memoryCfg := memory.LoadConfigFromViper() memoryCfg := memory.LoadConfigFromViper()
memoryModule := memory.NewModule(db, infrallm.WrapArkClient(aiHub.Worker), ragRuntime, memoryCfg) memoryObserver := memoryobserve.NewLoggerObserver(log.Default())
memoryMetrics := memoryobserve.NewMetricsRegistry()
memoryModule := memory.NewModuleWithObserve(
db,
infrallm.WrapArkClient(aiHub.Worker),
ragRuntime,
memoryCfg,
memory.ObserveDeps{
Observer: memoryObserver,
Metrics: memoryMetrics,
},
)
// DAO 层初始化。 // DAO 层初始化。
cacheRepo := dao.NewCacheDAO(rdb) cacheRepo := dao.NewCacheDAO(rdb)
@@ -180,6 +192,7 @@ func Start() {
taskClassApi := api.NewTaskClassHandler(taskClassService) taskClassApi := api.NewTaskClassHandler(taskClassService)
scheduleApi := api.NewScheduleAPI(scheduleService) scheduleApi := api.NewScheduleAPI(scheduleService)
agentApi := api.NewAgentHandler(agentService) agentApi := api.NewAgentHandler(agentService)
memoryApi := api.NewMemoryHandler(memoryModule)
handlers := &api.ApiHandlers{ handlers := &api.ApiHandlers{
UserHandler: userApi, UserHandler: userApi,
TaskHandler: taskApi, TaskHandler: taskApi,
@@ -187,6 +200,7 @@ func Start() {
CourseHandler: courseApi, CourseHandler: courseApi,
ScheduleHandler: scheduleApi, ScheduleHandler: scheduleApi,
AgentHandler: agentApi, AgentHandler: agentApi,
MemoryHandler: memoryApi,
} }
r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter) r := routers.RegisterRouters(handlers, cacheRepo, userRepo, limiter)

View File

@@ -0,0 +1,73 @@
package cleanup
import (
"sort"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const dedupRecentTieWindow = 24 * time.Hour
// DedupDecision 描述单个重复组的治理结论。
type DedupDecision struct {
Keep model.MemoryItem
Archive []model.MemoryItem
}
// DecideDedupGroup 决定一组重复 active 记忆中“保留谁、归档谁”。
//
// 步骤化说明:
// 1. 先按“最近更新时间”判断谁更值得保留,符合治理计划里的“优先保留最近更新”;
// 2. 若更新时间非常接近,再比较 confidence/importance避免刚好相差几秒就误保留低质量版本
// 3. 最后用主键逆序兜底,保证同组治理结果稳定可复现。
func DecideDedupGroup(items []model.MemoryItem) DedupDecision {
if len(items) == 0 {
return DedupDecision{}
}
ordered := make([]model.MemoryItem, len(items))
copy(ordered, items)
sort.SliceStable(ordered, func(i, j int) bool {
return preferDedupKeep(ordered[i], ordered[j])
})
return DedupDecision{
Keep: ordered[0],
Archive: ordered[1:],
}
}
func preferDedupKeep(left model.MemoryItem, right model.MemoryItem) bool {
leftTime := dedupBaseTime(left)
rightTime := dedupBaseTime(right)
diff := leftTime.Sub(rightTime)
if diff < 0 {
diff = -diff
}
if diff > dedupRecentTieWindow {
return leftTime.After(rightTime)
}
if left.Confidence != right.Confidence {
return left.Confidence > right.Confidence
}
if left.Importance != right.Importance {
return left.Importance > right.Importance
}
if !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
return left.ID > right.ID
}
func dedupBaseTime(item model.MemoryItem) time.Time {
if item.UpdatedAt != nil {
return *item.UpdatedAt
}
if item.CreatedAt != nil {
return *item.CreatedAt
}
return time.Time{}
}

View File

@@ -0,0 +1,257 @@
package cleanup
import (
"context"
"errors"
"strconv"
"strings"
"time"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/memory/vectorsync"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
// DedupRunner 负责执行一次离线记忆去重治理。
//
// 职责边界:
// 1. 只处理“active + content_hash 非空”的重复组;
// 2. 只负责 archive + audit + 向量删除桥接,不负责自动定时调度;
// 3. 支持 dry-run便于上线初期先观察治理结果再正式落库。
type DedupRunner struct {
db *gorm.DB
itemRepo *memoryrepo.ItemRepo
auditRepo *memoryrepo.AuditRepo
vectorSyncer *memoryvectorsync.Syncer
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
}
func NewDedupRunner(
db *gorm.DB,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
vectorSyncer *memoryvectorsync.Syncer,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *DedupRunner {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &DedupRunner{
db: db,
itemRepo: itemRepo,
auditRepo: auditRepo,
vectorSyncer: vectorSyncer,
observer: observer,
metrics: metrics,
}
}
// Run 执行一次离线去重治理。
func (r *DedupRunner) Run(ctx context.Context, req model.MemoryDedupCleanupRequest) (model.MemoryDedupCleanupResult, error) {
result := model.MemoryDedupCleanupResult{
DryRun: req.DryRun,
}
if r == nil || r.db == nil || r.itemRepo == nil || r.auditRepo == nil {
return result, errors.New("memory dedup runner is not initialized")
}
items, err := r.itemRepo.ListActiveItemsForDedup(ctx, req.UserID, req.Limit)
if err != nil {
r.recordDedupObserve(ctx, req, result, false, err)
return result, err
}
groups := groupDuplicateItems(items)
result.ScannedGroupCount = len(groups)
if len(groups) == 0 {
r.recordDedupObserve(ctx, req, result, true, nil)
return result, nil
}
for _, group := range groups {
decision := DecideDedupGroup(group)
if decision.Keep.ID > 0 {
result.KeptCount++
}
if len(decision.Archive) == 0 {
continue
}
result.DedupedGroupCount++
archiveIDs := collectDedupIDs(decision.Archive)
result.ArchivedCount += len(archiveIDs)
result.ArchivedIDs = append(result.ArchivedIDs, archiveIDs...)
if req.DryRun {
continue
}
now := time.Now()
txErr := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemRepo := r.itemRepo.WithTx(tx)
auditRepo := r.auditRepo.WithTx(tx)
if archiveErr := itemRepo.ArchiveByIDsAt(ctx, archiveIDs, now); archiveErr != nil {
return archiveErr
}
for _, item := range decision.Archive {
after := item
after.Status = model.MemoryItemStatusArchived
after.UpdatedAt = &now
after.VectorStatus = "pending"
audit := memoryutils.BuildItemAuditLog(
item.ID,
item.UserID,
memoryutils.AuditOperationArchive,
normalizeCleanupOperator(req.OperatorType),
normalizeCleanupReason(req.Reason),
&item,
&after,
)
if createErr := auditRepo.Create(ctx, audit); createErr != nil {
return createErr
}
}
return nil
})
if txErr != nil {
r.recordDedupObserve(ctx, req, result, false, txErr)
return result, txErr
}
r.vectorSyncer.Delete(ctx, "", archiveIDs)
r.metrics.AddCounter(memoryobserve.MetricCleanupArchivedTotal, int64(len(archiveIDs)), map[string]string{
"dry_run": "false",
})
}
r.recordDedupObserve(ctx, req, result, true, nil)
return result, nil
}
func groupDuplicateItems(items []model.MemoryItem) [][]model.MemoryItem {
if len(items) == 0 {
return nil
}
result := make([][]model.MemoryItem, 0)
currentGroup := make([]model.MemoryItem, 0, 2)
currentKey := ""
for _, item := range items {
key := dedupGroupKey(item)
if key == "" {
continue
}
if currentKey == "" || currentKey != key {
if len(currentGroup) > 1 {
copied := make([]model.MemoryItem, len(currentGroup))
copy(copied, currentGroup)
result = append(result, copied)
}
currentKey = key
currentGroup = currentGroup[:0]
}
currentGroup = append(currentGroup, item)
}
if len(currentGroup) > 1 {
copied := make([]model.MemoryItem, len(currentGroup))
copy(copied, currentGroup)
result = append(result, copied)
}
return result
}
func dedupGroupKey(item model.MemoryItem) string {
contentHash := strings.TrimSpace(derefString(item.ContentHash))
if item.UserID <= 0 || strings.TrimSpace(item.MemoryType) == "" || contentHash == "" {
return ""
}
return strings.Join([]string{
strconv.Itoa(item.UserID),
item.MemoryType,
contentHash,
}, "::")
}
func collectDedupIDs(items []model.MemoryItem) []int64 {
ids := make([]int64, 0, len(items))
for _, item := range items {
if item.ID <= 0 {
continue
}
ids = append(ids, item.ID)
}
return ids
}
func normalizeCleanupOperator(operatorType string) string {
operatorType = strings.TrimSpace(operatorType)
if operatorType == "" {
return "system"
}
return memoryutils.NormalizeOperatorType(operatorType)
}
func normalizeCleanupReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return "离线 dedup 治理归档重复记忆"
}
return reason
}
func derefString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func (r *DedupRunner) recordDedupObserve(
ctx context.Context,
req model.MemoryDedupCleanupRequest,
result model.MemoryDedupCleanupResult,
success bool,
err error,
) {
if r == nil {
return
}
status := "success"
level := memoryobserve.LevelInfo
if !success || err != nil {
status = "error"
level = memoryobserve.LevelWarn
}
r.observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentCleanup,
Operation: memoryobserve.OperationDedup,
Fields: map[string]any{
"user_id": req.UserID,
"limit": req.Limit,
"dry_run": req.DryRun,
"scanned_group_count": result.ScannedGroupCount,
"deduped_group_count": result.DedupedGroupCount,
"archived_count": result.ArchivedCount,
"success": success && err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
r.metrics.AddCounter(memoryobserve.MetricCleanupRunTotal, 1, map[string]string{
"operation": "dedup",
"status": status,
})
}

View File

@@ -67,10 +67,39 @@ type ListItemsRequest struct {
Limit int Limit int
} }
// DeleteItemRequest 描述软删除一条记忆时所需的最小参数 // CreateItemFields 是 repo 层落库时真正需要的字段集合
type DeleteItemRequest struct { type CreateItemFields struct {
UserID int UserID int
MemoryID int64 ConversationID string
Reason string AssistantID string
OperatorType string RunID string
MemoryType string
Title string
Content string
NormalizedContent string
ContentHash string
Confidence float64
Importance float64
SensitivityLevel int
IsExplicit bool
Status string
TTLAt *time.Time
VectorStatus string
SourceMessageID *int64
SourceEventID *string
LastAccessAt *time.Time
}
// UpdateItemFields 是“用户管理侧修改记忆”时 repo 层允许更新的字段集合。
type UpdateItemFields struct {
MemoryType string
Title string
Content string
NormalizedContent string
ContentHash string
Confidence float64
Importance float64
SensitivityLevel int
IsExplicit bool
TTLAt *time.Time
} }

View File

@@ -7,11 +7,15 @@ import (
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm" infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag" infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
memorycleanup "github.com/LoveLosita/smartflow/backend/memory/cleanup"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryorchestrator "github.com/LoveLosita/smartflow/backend/memory/orchestrator" memoryorchestrator "github.com/LoveLosita/smartflow/backend/memory/orchestrator"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo" memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryservice "github.com/LoveLosita/smartflow/backend/memory/service" memoryservice "github.com/LoveLosita/smartflow/backend/memory/service"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/memory/vectorsync"
memoryworker "github.com/LoveLosita/smartflow/backend/memory/worker" memoryworker "github.com/LoveLosita/smartflow/backend/memory/worker"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -26,6 +30,8 @@ type Module struct {
cfg memorymodel.Config cfg memorymodel.Config
llmClient *infrallm.Client llmClient *infrallm.Client
ragRuntime infrarag.Runtime ragRuntime infrarag.Runtime
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
jobRepo *memoryrepo.JobRepo jobRepo *memoryrepo.JobRepo
itemRepo *memoryrepo.ItemRepo itemRepo *memoryrepo.ItemRepo
@@ -35,9 +41,17 @@ type Module struct {
enqueueService *memoryservice.EnqueueService enqueueService *memoryservice.EnqueueService
readService *memoryservice.ReadService readService *memoryservice.ReadService
manageService *memoryservice.ManageService manageService *memoryservice.ManageService
vectorSyncer *memoryvectorsync.Syncer
dedupRunner *memorycleanup.DedupRunner
runner *memoryworker.Runner runner *memoryworker.Runner
} }
// ObserveDeps 描述 memory 模块可选的观测依赖。
type ObserveDeps struct {
Observer memoryobserve.Observer
Metrics memoryobserve.MetricsRecorder
}
// LoadConfigFromViper 复用 memory 子包里的配置加载逻辑,对外收口一个统一入口。 // LoadConfigFromViper 复用 memory 子包里的配置加载逻辑,对外收口一个统一入口。
func LoadConfigFromViper() memorymodel.Config { func LoadConfigFromViper() memorymodel.Config {
return memoryservice.LoadConfigFromViper() return memoryservice.LoadConfigFromViper()
@@ -51,7 +65,18 @@ func LoadConfigFromViper() memorymodel.Config {
// 3. ragRuntime 允许为 nil此时读取/向量同步自动回退旧逻辑; // 3. ragRuntime 允许为 nil此时读取/向量同步自动回退旧逻辑;
// 4. 若后续接入统一 DI 容器,也应优先注册这个 Module而不是把内部 repo/service 继续向外泄漏。 // 4. 若后续接入统一 DI 容器,也应优先注册这个 Module而不是把内部 repo/service 继续向外泄漏。
func NewModule(db *gorm.DB, llmClient *infrallm.Client, ragRuntime infrarag.Runtime, cfg memorymodel.Config) *Module { func NewModule(db *gorm.DB, llmClient *infrallm.Client, ragRuntime infrarag.Runtime, cfg memorymodel.Config) *Module {
return wireModule(db, llmClient, ragRuntime, cfg) return NewModuleWithObserve(db, llmClient, ragRuntime, cfg, ObserveDeps{})
}
// NewModuleWithObserve 创建带观测依赖的 memory 模块门面。
func NewModuleWithObserve(
db *gorm.DB,
llmClient *infrallm.Client,
ragRuntime infrarag.Runtime,
cfg memorymodel.Config,
deps ObserveDeps,
) *Module {
return wireModule(db, llmClient, ragRuntime, cfg, deps)
} }
// WithTx 返回绑定到指定事务连接的同构门面。 // WithTx 返回绑定到指定事务连接的同构门面。
@@ -67,7 +92,10 @@ func (m *Module) WithTx(tx *gorm.DB) *Module {
if tx == nil { if tx == nil {
return m return m
} }
return wireModule(tx, m.llmClient, m.ragRuntime, m.cfg) return wireModule(tx, m.llmClient, m.ragRuntime, m.cfg, ObserveDeps{
Observer: m.observer,
Metrics: m.metrics,
})
} }
// EnqueueExtract 把一次记忆抽取请求入队到 memory_jobs。 // EnqueueExtract 把一次记忆抽取请求入队到 memory_jobs。
@@ -98,14 +126,46 @@ func (m *Module) ListItems(ctx context.Context, req memorymodel.ListItemsRequest
return m.manageService.ListItems(ctx, req) return m.manageService.ListItems(ctx, req)
} }
// GetItem 返回当前用户自己的单条记忆详情。
func (m *Module) GetItem(ctx context.Context, req model.MemoryGetItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
return m.manageService.GetItem(ctx, req)
}
// CreateItem 手动新增一条用户记忆。
func (m *Module) CreateItem(ctx context.Context, req model.MemoryCreateItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
return m.manageService.CreateItem(ctx, req)
}
// UpdateItem 手动修改一条用户记忆。
func (m *Module) UpdateItem(ctx context.Context, req model.MemoryUpdateItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
return m.manageService.UpdateItem(ctx, req)
}
// DeleteItem 软删除一条记忆,并补写审计日志。 // DeleteItem 软删除一条记忆,并补写审计日志。
func (m *Module) DeleteItem(ctx context.Context, req memorymodel.DeleteItemRequest) (*memorymodel.ItemDTO, error) { func (m *Module) DeleteItem(ctx context.Context, req model.MemoryDeleteItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil { if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil") return nil, errors.New("memory module manage service is nil")
} }
return m.manageService.DeleteItem(ctx, req) return m.manageService.DeleteItem(ctx, req)
} }
// RestoreItem 恢复一条 deleted/archived 记忆。
func (m *Module) RestoreItem(ctx context.Context, req model.MemoryRestoreItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
return m.manageService.RestoreItem(ctx, req)
}
// GetUserSetting 读取用户当前生效的记忆开关。 // GetUserSetting 读取用户当前生效的记忆开关。
func (m *Module) GetUserSetting(ctx context.Context, userID int) (memorymodel.UserSettingDTO, error) { func (m *Module) GetUserSetting(ctx context.Context, userID int) (memorymodel.UserSettingDTO, error) {
if m == nil || m.manageService == nil { if m == nil || m.manageService == nil {
@@ -122,6 +182,30 @@ func (m *Module) UpsertUserSetting(ctx context.Context, req memorymodel.UpdateUs
return m.manageService.UpsertUserSetting(ctx, req) return m.manageService.UpsertUserSetting(ctx, req)
} }
// RunDedupCleanup 执行一次离线 dedup 治理。
func (m *Module) RunDedupCleanup(ctx context.Context, req model.MemoryDedupCleanupRequest) (model.MemoryDedupCleanupResult, error) {
if m == nil || m.dedupRunner == nil {
return model.MemoryDedupCleanupResult{}, errors.New("memory module dedup runner is nil")
}
return m.dedupRunner.Run(ctx, req)
}
// MemoryObserver 暴露 memory 模块当前使用的 observer供注入桥接等外围能力复用。
func (m *Module) MemoryObserver() memoryobserve.Observer {
if m == nil || m.observer == nil {
return memoryobserve.NewNopObserver()
}
return m.observer
}
// MemoryMetrics 暴露 memory 模块当前使用的轻量计数器。
func (m *Module) MemoryMetrics() memoryobserve.MetricsRecorder {
if m == nil || m.metrics == nil {
return memoryobserve.NewNopMetrics()
}
return m.metrics
}
// StartWorker 启动 memory 后台 worker。 // StartWorker 启动 memory 后台 worker。
// //
// 说明: // 说明:
@@ -142,15 +226,30 @@ func (m *Module) StartWorker(ctx context.Context) {
log.Println("Memory worker started") log.Println("Memory worker started")
} }
func wireModule(db *gorm.DB, llmClient *infrallm.Client, ragRuntime infrarag.Runtime, cfg memorymodel.Config) *Module { func wireModule(
db *gorm.DB,
llmClient *infrallm.Client,
ragRuntime infrarag.Runtime,
cfg memorymodel.Config,
deps ObserveDeps,
) *Module {
jobRepo := memoryrepo.NewJobRepo(db) jobRepo := memoryrepo.NewJobRepo(db)
itemRepo := memoryrepo.NewItemRepo(db) itemRepo := memoryrepo.NewItemRepo(db)
auditRepo := memoryrepo.NewAuditRepo(db) auditRepo := memoryrepo.NewAuditRepo(db)
settingsRepo := memoryrepo.NewSettingsRepo(db) settingsRepo := memoryrepo.NewSettingsRepo(db)
observer := deps.Observer
if observer == nil {
observer = memoryobserve.NewLoggerObserver(log.Default())
}
metrics := deps.Metrics
if metrics == nil {
metrics = memoryobserve.NewMetricsRegistry()
}
vectorSyncer := memoryvectorsync.NewSyncer(ragRuntime, itemRepo, observer, metrics)
enqueueService := memoryservice.NewEnqueueService(jobRepo) enqueueService := memoryservice.NewEnqueueService(jobRepo)
readService := memoryservice.NewReadService(itemRepo, settingsRepo, ragRuntime, cfg) readService := memoryservice.NewReadService(itemRepo, settingsRepo, ragRuntime, cfg, observer, metrics)
manageService := memoryservice.NewManageService(db, itemRepo, auditRepo, settingsRepo) manageService := memoryservice.NewManageService(db, itemRepo, auditRepo, settingsRepo, vectorSyncer, observer, metrics)
extractor := memoryorchestrator.NewLLMWriteOrchestrator(llmClient, cfg) extractor := memoryorchestrator.NewLLMWriteOrchestrator(llmClient, cfg)
// 决策编排器:仅在 DecisionEnabled 时才创建有效实例。 // 决策编排器:仅在 DecisionEnabled 时才创建有效实例。
@@ -161,13 +260,16 @@ func wireModule(db *gorm.DB, llmClient *infrallm.Client, ragRuntime infrarag.Run
decisionOrchestrator = memoryorchestrator.NewLLMDecisionOrchestrator(llmClient, cfg) decisionOrchestrator = memoryorchestrator.NewLLMDecisionOrchestrator(llmClient, cfg)
} }
runner := memoryworker.NewRunner(db, jobRepo, itemRepo, auditRepo, settingsRepo, extractor, ragRuntime, cfg, decisionOrchestrator) runner := memoryworker.NewRunner(db, jobRepo, itemRepo, auditRepo, settingsRepo, extractor, ragRuntime, cfg, decisionOrchestrator, vectorSyncer, observer, metrics)
dedupRunner := memorycleanup.NewDedupRunner(db, itemRepo, auditRepo, vectorSyncer, observer, metrics)
return &Module{ return &Module{
db: db, db: db,
cfg: cfg, cfg: cfg,
llmClient: llmClient, llmClient: llmClient,
ragRuntime: ragRuntime, ragRuntime: ragRuntime,
observer: observer,
metrics: metrics,
jobRepo: jobRepo, jobRepo: jobRepo,
itemRepo: itemRepo, itemRepo: itemRepo,
auditRepo: auditRepo, auditRepo: auditRepo,
@@ -175,6 +277,8 @@ func wireModule(db *gorm.DB, llmClient *infrallm.Client, ragRuntime infrarag.Run
enqueueService: enqueueService, enqueueService: enqueueService,
readService: readService, readService: readService,
manageService: manageService, manageService: manageService,
vectorSyncer: vectorSyncer,
dedupRunner: dedupRunner,
runner: runner, runner: runner,
} }
} }

View File

@@ -0,0 +1,119 @@
package observe
import (
"context"
"errors"
"strings"
)
const (
ComponentRead = "read"
ComponentWrite = "write"
ComponentInject = "inject"
ComponentManage = "manage"
ComponentCleanup = "cleanup"
OperationRetrieve = "retrieve"
OperationDecision = "decision"
OperationInject = "inject"
OperationManage = "manage"
OperationDedup = "dedup"
MetricJobTotal = "memory_job_total"
MetricJobRetryTotal = "memory_job_retry_total"
MetricDecisionTotal = "memory_decision_total"
MetricDecisionFallbackTotal = "memory_decision_fallback_total"
MetricRetrieveHitTotal = "memory_retrieve_hit_total"
MetricRetrieveDedupDropTotal = "memory_retrieve_dedup_drop_total"
MetricInjectItemTotal = "memory_inject_item_total"
MetricRAGFallbackTotal = "memory_rag_fallback_total"
MetricManageTotal = "memory_manage_total"
MetricCleanupRunTotal = "memory_cleanup_run_total"
MetricCleanupArchivedTotal = "memory_cleanup_archived_total"
)
type fieldsContextKey struct{}
// WithFields 把 memory 链路公共字段挂进上下文,供下游日志复用。
//
// 职责边界:
// 1. 只负责字段透传与覆盖,不负责真正打印日志;
// 2. 只保留有意义的字段,避免结构化日志长期堆积空值;
// 3. 若上游已写入同名字段,则以后写值为准,方便链路逐层补齐上下文。
func WithFields(ctx context.Context, fields map[string]any) context.Context {
if len(fields) == 0 {
return ctx
}
if ctx == nil {
ctx = context.Background()
}
merged := FieldsFromContext(ctx)
for key, value := range fields {
key = strings.TrimSpace(key)
if key == "" || !shouldKeepField(value) {
continue
}
merged[key] = value
}
if len(merged) == 0 {
return ctx
}
return context.WithValue(ctx, fieldsContextKey{}, merged)
}
// FieldsFromContext 读取当前上下文中已经累积的观测字段。
func FieldsFromContext(ctx context.Context) map[string]any {
if ctx == nil {
return map[string]any{}
}
raw, ok := ctx.Value(fieldsContextKey{}).(map[string]any)
if !ok || len(raw) == 0 {
return map[string]any{}
}
result := make(map[string]any, len(raw))
for key, value := range raw {
result[key] = value
}
return result
}
// MergeFields 合并多份结构化字段,后写同名字段覆盖先写字段。
func MergeFields(parts ...map[string]any) map[string]any {
result := make(map[string]any)
for _, part := range parts {
for key, value := range part {
key = strings.TrimSpace(key)
if key == "" || !shouldKeepField(value) {
continue
}
result[key] = value
}
}
return result
}
// ClassifyError 把常见错误压成稳定错误码,便于日志与指标统一聚合。
func ClassifyError(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, context.DeadlineExceeded):
return "deadline_exceeded"
case errors.Is(err, context.Canceled):
return "canceled"
default:
return "memory_error"
}
}
func shouldKeepField(value any) bool {
if value == nil {
return false
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text) != ""
}
return true
}

View File

@@ -0,0 +1,158 @@
package observe
import (
"sort"
"strings"
"sync"
)
// CounterSnapshot 是轻量计数器的快照视图,供后续排障或接平台时读取。
type CounterSnapshot struct {
Name string
Labels map[string]string
Value int64
}
// MetricsRecorder 描述 memory 模块对计数器的最小依赖。
type MetricsRecorder interface {
AddCounter(name string, delta int64, labels map[string]string)
Snapshot() []CounterSnapshot
}
// NewNopMetrics 返回空实现,保证无观测平台时仍可安全运行。
func NewNopMetrics() MetricsRecorder {
return nopMetrics{}
}
type nopMetrics struct{}
func (nopMetrics) AddCounter(string, int64, map[string]string) {}
func (nopMetrics) Snapshot() []CounterSnapshot {
return nil
}
// MetricsRegistry 是 memory 模块当前阶段的轻量内存计数器实现。
//
// 职责边界:
// 1. 只做线程安全计数,不负责导出协议;
// 2. 标签做低基数归一化,避免治理期临时字段把指标打爆;
// 3. 后续若项目统一接 Prometheus可直接保留调用口径并替换实现。
type MetricsRegistry struct {
mu sync.RWMutex
counters map[string]*counterRecord
}
type counterRecord struct {
name string
labels map[string]string
value int64
}
func NewMetricsRegistry() *MetricsRegistry {
return &MetricsRegistry{
counters: make(map[string]*counterRecord),
}
}
// AddCounter 追加计数值delta<=0 时直接忽略,避免脏数据污染快照。
func (r *MetricsRegistry) AddCounter(name string, delta int64, labels map[string]string) {
if r == nil || delta <= 0 {
return
}
name = strings.TrimSpace(name)
if name == "" {
return
}
normalizedLabels := normalizeLabels(labels)
key := buildCounterKey(name, normalizedLabels)
r.mu.Lock()
defer r.mu.Unlock()
if existing, ok := r.counters[key]; ok {
existing.value += delta
return
}
r.counters[key] = &counterRecord{
name: name,
labels: normalizedLabels,
value: delta,
}
}
// Snapshot 返回当前全部计数器快照,便于后续排障或测试读取。
func (r *MetricsRegistry) Snapshot() []CounterSnapshot {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.counters) == 0 {
return nil
}
keys := make([]string, 0, len(r.counters))
for key := range r.counters {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]CounterSnapshot, 0, len(keys))
for _, key := range keys {
record := r.counters[key]
labels := make(map[string]string, len(record.labels))
for labelKey, labelValue := range record.labels {
labels[labelKey] = labelValue
}
result = append(result, CounterSnapshot{
Name: record.name,
Labels: labels,
Value: record.value,
})
}
return result
}
func normalizeLabels(labels map[string]string) map[string]string {
if len(labels) == 0 {
return nil
}
result := make(map[string]string, len(labels))
for key, value := range labels {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
result[key] = value
}
if len(result) == 0 {
return nil
}
return result
}
func buildCounterKey(name string, labels map[string]string) string {
if len(labels) == 0 {
return name
}
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
var sb strings.Builder
sb.WriteString(name)
for _, key := range keys {
sb.WriteString("|")
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(labels[key])
}
return sb.String()
}

View File

@@ -0,0 +1,109 @@
package observe
import (
"context"
"fmt"
"log"
"sort"
"strings"
)
// Level 表示 memory 结构化观测事件等级。
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
)
// Event 描述一次 memory 模块内部结构化观测事件。
//
// 职责边界:
// 1. 只承载稳定字段,不绑定具体日志平台;
// 2. 组件与操作名尽量保持低基数,避免后续指标聚合失控;
// 3. 字段内容应偏“排障与治理”,不承载大段原始文本。
type Event struct {
Level Level
Component string
Operation string
Fields map[string]any
}
// Observer 是 memory 模块的最小观测接口。
type Observer interface {
Observe(ctx context.Context, event Event)
}
// ObserverFunc 允许用函数快速适配 Observer。
type ObserverFunc func(ctx context.Context, event Event)
func (f ObserverFunc) Observe(ctx context.Context, event Event) {
if f == nil {
return
}
f(ctx, event)
}
// NewNopObserver 返回空实现,保证观测能力不会反向阻塞主链路。
func NewNopObserver() Observer {
return ObserverFunc(func(context.Context, Event) {})
}
// NewLoggerObserver 返回标准日志实现,当前阶段默认打到后端进程日志。
func NewLoggerObserver(logger *log.Logger) Observer {
if logger == nil {
logger = log.Default()
}
return &loggerObserver{logger: logger}
}
type loggerObserver struct {
logger *log.Logger
}
func (o *loggerObserver) Observe(ctx context.Context, event Event) {
if o == nil || o.logger == nil {
return
}
level := strings.TrimSpace(string(event.Level))
if level == "" {
level = string(LevelInfo)
}
component := strings.TrimSpace(event.Component)
if component == "" {
component = "unknown"
}
operation := strings.TrimSpace(event.Operation)
if operation == "" {
operation = "unknown"
}
fields := FieldsFromContext(ctx)
for key, value := range event.Fields {
key = strings.TrimSpace(key)
if key == "" || !shouldKeepField(value) {
continue
}
fields[key] = value
}
parts := []string{
"memory",
fmt.Sprintf("level=%s", level),
fmt.Sprintf("component=%s", component),
fmt.Sprintf("operation=%s", operation),
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%v", key, fields[key]))
}
o.logger.Print(strings.Join(parts, " "))
}

View File

@@ -46,6 +46,54 @@ func (r *ItemRepo) UpsertItems(ctx context.Context, items []model.MemoryItem) er
return nil 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 按统一过滤条件读取记忆条目。 // FindByQuery 按统一过滤条件读取记忆条目。
// //
// 步骤化说明: // 步骤化说明:
@@ -324,6 +372,53 @@ func (r *ItemRepo) UpdateContentByID(ctx context.Context, memoryID int64, fields
}).Error }).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 软删除指定用户的某条记忆。 // SoftDeleteByID 软删除指定用户的某条记忆。
// //
// 说明: // 说明:
@@ -348,6 +443,95 @@ func (r *ItemRepo) SoftDeleteByID(ctx context.Context, userID int, memoryID int6
}).Error }).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 { func applyScopedEquality(db *gorm.DB, column, value string, includeGlobal bool) *gorm.DB {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {

View File

@@ -7,15 +7,20 @@ import (
"time" "time"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo" memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils" memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/memory/vectorsync"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"gorm.io/gorm" "gorm.io/gorm"
) )
const ( const (
defaultManageListLimit = 20 defaultManageListLimit = 20
maxManageListLimit = 100 maxManageListLimit = 100
defaultManualConfidence = 0.95
defaultManualImportance = 0.90
) )
// ManageService 负责 memory 模块内部的管理面能力。 // ManageService 负责 memory 模块内部的管理面能力。
@@ -29,6 +34,9 @@ type ManageService struct {
itemRepo *memoryrepo.ItemRepo itemRepo *memoryrepo.ItemRepo
auditRepo *memoryrepo.AuditRepo auditRepo *memoryrepo.AuditRepo
settingsRepo *memoryrepo.SettingsRepo settingsRepo *memoryrepo.SettingsRepo
vectorSyncer *memoryvectorsync.Syncer
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
} }
func NewManageService( func NewManageService(
@@ -36,12 +44,24 @@ func NewManageService(
itemRepo *memoryrepo.ItemRepo, itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo, auditRepo *memoryrepo.AuditRepo,
settingsRepo *memoryrepo.SettingsRepo, settingsRepo *memoryrepo.SettingsRepo,
vectorSyncer *memoryvectorsync.Syncer,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *ManageService { ) *ManageService {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &ManageService{ return &ManageService{
db: db, db: db,
itemRepo: itemRepo, itemRepo: itemRepo,
auditRepo: auditRepo, auditRepo: auditRepo,
settingsRepo: settingsRepo, settingsRepo: settingsRepo,
vectorSyncer: vectorSyncer,
observer: observer,
metrics: metrics,
} }
} }
@@ -77,18 +97,152 @@ func (s *ManageService) ListItems(ctx context.Context, req memorymodel.ListItems
return toItemDTOs(items), nil return toItemDTOs(items), nil
} }
// GetItem 返回“当前用户自己的某条记忆”详情。
func (s *ManageService) GetItem(ctx context.Context, req model.MemoryGetItemRequest) (*memorymodel.ItemDTO, error) {
if s == nil || s.itemRepo == nil {
return nil, errors.New("memory manage service is nil")
}
if req.UserID <= 0 {
return nil, respond.WrongUserID
}
if req.MemoryID <= 0 {
return nil, respond.WrongParamType
}
item, err := s.itemRepo.GetByIDForUser(ctx, req.UserID, req.MemoryID)
if err != nil {
return nil, translateManageError(err)
}
dto := toItemDTO(*item)
return &dto, nil
}
// CreateItem 手动新增一条用户记忆,并补审计与向量同步桥接。
func (s *ManageService) CreateItem(ctx context.Context, req model.MemoryCreateItemRequest) (*memorymodel.ItemDTO, error) {
if s == nil || s.db == nil || s.itemRepo == nil || s.auditRepo == nil {
return nil, errors.New("memory manage service is not initialized")
}
if req.UserID <= 0 {
return nil, respond.WrongUserID
}
fields, err := buildCreateItemFields(req)
if err != nil {
s.recordManageAction(ctx, "create", req.UserID, 0, fields.MemoryType, false, err)
return nil, err
}
var createdItem model.MemoryItem
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemRepo := s.itemRepo.WithTx(tx)
auditRepo := s.auditRepo.WithTx(tx)
created, createErr := itemRepo.Create(ctx, fields)
if createErr != nil {
return createErr
}
createdItem = *created
audit := memoryutils.BuildItemAuditLog(
createdItem.ID,
createdItem.UserID,
memoryutils.AuditOperationCreate,
memoryutils.NormalizeOperatorType(req.OperatorType),
normalizeManageReason(req.Reason, "用户手动新增记忆"),
nil,
&createdItem,
)
return auditRepo.Create(ctx, audit)
})
if err != nil {
err = translateManageError(err)
s.recordManageAction(ctx, "create", req.UserID, 0, fields.MemoryType, false, err)
return nil, err
}
s.vectorSyncer.Upsert(ctx, "", []model.MemoryItem{createdItem})
s.recordManageAction(ctx, "create", req.UserID, createdItem.ID, createdItem.MemoryType, true, nil)
dto := toItemDTO(createdItem)
return &dto, nil
}
// UpdateItem 手动修改一条用户记忆,并补审计与向量重同步桥接。
func (s *ManageService) UpdateItem(ctx context.Context, req model.MemoryUpdateItemRequest) (*memorymodel.ItemDTO, error) {
if s == nil || s.db == nil || s.itemRepo == nil || s.auditRepo == nil {
return nil, errors.New("memory manage service is not initialized")
}
if req.UserID <= 0 {
return nil, respond.WrongUserID
}
if req.MemoryID <= 0 {
return nil, respond.WrongParamType
}
var updatedItem model.MemoryItem
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemRepo := s.itemRepo.WithTx(tx)
auditRepo := s.auditRepo.WithTx(tx)
current, getErr := itemRepo.GetByIDForUser(ctx, req.UserID, req.MemoryID)
if getErr != nil {
return getErr
}
fields, afterItem, buildErr := buildUpdateItemFields(req, *current)
if buildErr != nil {
return buildErr
}
now := time.Now()
afterItem.UpdatedAt = &now
afterItem.VectorStatus = "pending"
if updateErr := itemRepo.UpdateManagedFieldsByIDAt(ctx, req.UserID, req.MemoryID, fields, now); updateErr != nil {
return updateErr
}
audit := memoryutils.BuildItemAuditLog(
current.ID,
current.UserID,
memoryutils.AuditOperationUpdate,
memoryutils.NormalizeOperatorType(req.OperatorType),
normalizeManageReason(req.Reason, "用户手动修改记忆"),
current,
&afterItem,
)
if auditErr := auditRepo.Create(ctx, audit); auditErr != nil {
return auditErr
}
updatedItem = afterItem
return nil
})
if err != nil {
err = translateManageError(err)
s.recordManageAction(ctx, "update", req.UserID, req.MemoryID, resolveUpdateMemoryType(req), false, err)
return nil, err
}
s.vectorSyncer.Upsert(ctx, "", []model.MemoryItem{updatedItem})
s.recordManageAction(ctx, "update", req.UserID, updatedItem.ID, updatedItem.MemoryType, true, nil)
dto := toItemDTO(updatedItem)
return &dto, nil
}
// DeleteItem 软删除一条记忆,并补写审计日志。 // DeleteItem 软删除一条记忆,并补写审计日志。
// //
// 步骤化说明: // 步骤化说明:
// 1. 先在事务里读取当前条目快照,确保审计前镜像和实际删除对象一致; // 1. 先在事务里读取当前条目快照,确保审计前镜像和实际删除对象一致;
// 2. 若该条目已是 deleted则直接按幂等语义返回避免重复写多条删除审计 // 2. 若该条目已是 deleted则直接按幂等语义返回避免重复写多条删除审计
// 3. 状态更新成功后再写 audit log保证“有删除就有审计”失败时整笔事务回滚。 // 3. 状态更新成功后再写 audit log保证“有删除就有审计”失败时整笔事务回滚。
func (s *ManageService) DeleteItem(ctx context.Context, req memorymodel.DeleteItemRequest) (*memorymodel.ItemDTO, error) { func (s *ManageService) DeleteItem(ctx context.Context, req model.MemoryDeleteItemRequest) (*memorymodel.ItemDTO, error) {
if s == nil || s.db == nil || s.itemRepo == nil || s.auditRepo == nil { if s == nil || s.db == nil || s.itemRepo == nil || s.auditRepo == nil {
return nil, errors.New("memory manage service is not initialized") return nil, errors.New("memory manage service is not initialized")
} }
if req.UserID <= 0 || req.MemoryID <= 0 { if req.UserID <= 0 {
return nil, nil return nil, respond.WrongUserID
}
if req.MemoryID <= 0 {
return nil, respond.WrongParamType
} }
now := time.Now() now := time.Now()
@@ -113,8 +267,9 @@ func (s *ManageService) DeleteItem(ctx context.Context, req memorymodel.DeleteIt
after := before after := before
after.Status = model.MemoryItemStatusDeleted after.Status = model.MemoryItemStatusDeleted
after.UpdatedAt = &now after.UpdatedAt = &now
after.VectorStatus = "pending"
if err = itemRepo.UpdateStatusByIDAt(ctx, req.UserID, req.MemoryID, model.MemoryItemStatusDeleted, now); err != nil { if err = itemRepo.SoftDeleteByID(ctx, req.UserID, req.MemoryID); err != nil {
return err return err
} }
@@ -135,16 +290,87 @@ func (s *ManageService) DeleteItem(ctx context.Context, req memorymodel.DeleteIt
return nil return nil
}) })
if err != nil { if err != nil {
err = translateManageError(err)
s.recordManageAction(ctx, "delete", req.UserID, req.MemoryID, "", false, err)
return nil, err return nil, err
} }
if deletedItem.ID <= 0 { if deletedItem.ID <= 0 {
return nil, nil return nil, nil
} }
if deletedItem.Status == model.MemoryItemStatusDeleted {
s.vectorSyncer.Delete(ctx, "", []int64{deletedItem.ID})
}
s.recordManageAction(ctx, "delete", req.UserID, deletedItem.ID, deletedItem.MemoryType, true, nil)
result := toItemDTO(deletedItem) result := toItemDTO(deletedItem)
return &result, nil return &result, nil
} }
// RestoreItem 把 archived/deleted 记忆恢复为 active并补审计与向量同步桥接。
func (s *ManageService) RestoreItem(ctx context.Context, req model.MemoryRestoreItemRequest) (*memorymodel.ItemDTO, error) {
if s == nil || s.db == nil || s.itemRepo == nil || s.auditRepo == nil {
return nil, errors.New("memory manage service is not initialized")
}
if req.UserID <= 0 {
return nil, respond.WrongUserID
}
if req.MemoryID <= 0 {
return nil, respond.WrongParamType
}
var restoredItem model.MemoryItem
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemRepo := s.itemRepo.WithTx(tx)
auditRepo := s.auditRepo.WithTx(tx)
current, getErr := itemRepo.GetByIDForUser(ctx, req.UserID, req.MemoryID)
if getErr != nil {
return getErr
}
if current.Status == model.MemoryItemStatusActive {
restoredItem = *current
return nil
}
now := time.Now()
before := *current
after := before
after.Status = model.MemoryItemStatusActive
after.UpdatedAt = &now
after.VectorStatus = "pending"
if restoreErr := itemRepo.RestoreByIDAt(ctx, req.UserID, req.MemoryID, now); restoreErr != nil {
return restoreErr
}
audit := memoryutils.BuildItemAuditLog(
before.ID,
before.UserID,
memoryutils.AuditOperationRestore,
memoryutils.NormalizeOperatorType(req.OperatorType),
normalizeManageReason(req.Reason, "用户恢复记忆"),
&before,
&after,
)
if auditErr := auditRepo.Create(ctx, audit); auditErr != nil {
return auditErr
}
restoredItem = after
return nil
})
if err != nil {
err = translateManageError(err)
s.recordManageAction(ctx, "restore", req.UserID, req.MemoryID, "", false, err)
return nil, err
}
s.vectorSyncer.Upsert(ctx, "", []model.MemoryItem{restoredItem})
s.recordManageAction(ctx, "restore", req.UserID, restoredItem.ID, restoredItem.MemoryType, true, nil)
dto := toItemDTO(restoredItem)
return &dto, nil
}
// GetUserSetting 返回用户当前生效的记忆开关。 // GetUserSetting 返回用户当前生效的记忆开关。
// //
// 返回语义: // 返回语义:
@@ -201,3 +427,233 @@ func normalizeDeleteReason(reason string) string {
} }
return reason return reason
} }
func normalizeManageReason(reason string, fallback string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return fallback
}
return reason
}
func translateManageError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, gorm.ErrRecordNotFound):
return respond.MemoryItemNotFound
default:
return err
}
}
func buildCreateItemFields(req model.MemoryCreateItemRequest) (memorymodel.CreateItemFields, error) {
memoryType, err := normalizeManagedMemoryType(req.MemoryType)
if err != nil {
return memorymodel.CreateItemFields{}, err
}
content, normalizedContent, err := normalizeManagedContent(req.Content)
if err != nil {
return memorymodel.CreateItemFields{}, err
}
title := normalizeManagedTitle(req.Title, content)
return memorymodel.CreateItemFields{
UserID: req.UserID,
ConversationID: strings.TrimSpace(req.ConversationID),
AssistantID: strings.TrimSpace(req.AssistantID),
RunID: strings.TrimSpace(req.RunID),
MemoryType: memoryType,
Title: title,
Content: content,
NormalizedContent: normalizedContent,
ContentHash: memoryutils.HashContent(memoryType, normalizedContent),
Confidence: normalizeManageScore(req.Confidence, defaultManualConfidence),
Importance: normalizeManageScore(req.Importance, defaultManualImportance),
SensitivityLevel: normalizeManageSensitivity(req.SensitivityLevel, 0),
IsExplicit: normalizeManageBool(req.IsExplicit, true),
Status: model.MemoryItemStatusActive,
TTLAt: req.TTLAt,
VectorStatus: "pending",
}, nil
}
func buildUpdateItemFields(
req model.MemoryUpdateItemRequest,
current model.MemoryItem,
) (memorymodel.UpdateItemFields, model.MemoryItem, error) {
memoryType := current.MemoryType
if req.MemoryType != nil {
normalizedType, err := normalizeManagedMemoryType(*req.MemoryType)
if err != nil {
return memorymodel.UpdateItemFields{}, model.MemoryItem{}, err
}
memoryType = normalizedType
}
content := current.Content
if req.Content != nil {
normalizedContentValue, _, err := normalizeManagedContent(*req.Content)
if err != nil {
return memorymodel.UpdateItemFields{}, model.MemoryItem{}, err
}
content = normalizedContentValue
}
normalizedContent := normalizeContentForHash(content)
if normalizedContent == "" {
return memorymodel.UpdateItemFields{}, model.MemoryItem{}, respond.MemoryInvalidContent
}
title := current.Title
if req.Title != nil {
title = normalizeManagedTitle(*req.Title, content)
}
ttlAt := current.TTLAt
if req.ClearTTL {
ttlAt = nil
} else if req.TTLAt != nil {
ttlAt = req.TTLAt
}
fields := memorymodel.UpdateItemFields{
MemoryType: memoryType,
Title: title,
Content: content,
NormalizedContent: normalizedContent,
ContentHash: memoryutils.HashContent(memoryType, normalizedContent),
Confidence: normalizeManageScore(req.Confidence, current.Confidence),
Importance: normalizeManageScore(req.Importance, current.Importance),
SensitivityLevel: normalizeManageSensitivity(req.SensitivityLevel, current.SensitivityLevel),
IsExplicit: normalizeManageBool(req.IsExplicit, current.IsExplicit),
TTLAt: ttlAt,
}
after := current
after.MemoryType = fields.MemoryType
after.Title = fields.Title
after.Content = fields.Content
after.NormalizedContent = strPtr(fields.NormalizedContent)
after.ContentHash = strPtr(fields.ContentHash)
after.Confidence = fields.Confidence
after.Importance = fields.Importance
after.SensitivityLevel = fields.SensitivityLevel
after.IsExplicit = fields.IsExplicit
after.TTLAt = fields.TTLAt
return fields, after, nil
}
func normalizeManagedMemoryType(raw string) (string, error) {
normalized := memorymodel.NormalizeMemoryType(raw)
if normalized == "" {
return "", respond.MemoryInvalidType
}
return normalized, nil
}
func normalizeManagedContent(raw string) (string, string, error) {
content := strings.TrimSpace(raw)
if content == "" {
return "", "", respond.MemoryInvalidContent
}
normalized := normalizeContentForHash(content)
if normalized == "" {
return "", "", respond.MemoryInvalidContent
}
return content, normalized, nil
}
func normalizeManagedTitle(raw string, content string) string {
title := strings.TrimSpace(raw)
if title != "" {
return title
}
content = strings.TrimSpace(content)
if content == "" {
return "未命名记忆"
}
runes := []rune(content)
if len(runes) > 24 {
return string(runes[:24])
}
return content
}
func normalizeManageScore(value *float64, defaultValue float64) float64 {
if value == nil {
return clamp01(defaultValue)
}
return clamp01(*value)
}
func normalizeManageSensitivity(value *int, defaultValue int) int {
if value == nil {
return defaultValue
}
if *value < 0 {
return defaultValue
}
return *value
}
func normalizeManageBool(value *bool, defaultValue bool) bool {
if value == nil {
return defaultValue
}
return *value
}
func resolveUpdateMemoryType(req model.MemoryUpdateItemRequest) string {
if req.MemoryType == nil {
return ""
}
return strings.TrimSpace(*req.MemoryType)
}
func strPtr(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
result := value
return &result
}
func (s *ManageService) recordManageAction(
ctx context.Context,
operation string,
userID int,
memoryID int64,
memoryType string,
success bool,
err error,
) {
if s == nil {
return
}
status := "success"
level := memoryobserve.LevelInfo
if !success || err != nil {
status = "error"
level = memoryobserve.LevelWarn
}
s.metrics.AddCounter(memoryobserve.MetricManageTotal, 1, map[string]string{
"operation": strings.TrimSpace(operation),
"status": status,
})
s.observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentManage,
Operation: memoryobserve.OperationManage,
Fields: map[string]any{
"user_id": userID,
"memory_id": memoryID,
"action": strings.TrimSpace(operation),
"memory_type": strings.TrimSpace(memoryType),
"success": success && err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
}

View File

@@ -10,6 +10,7 @@ import (
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag" infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo" memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils" memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
@@ -31,6 +32,26 @@ type ReadService struct {
settingsRepo *memoryrepo.SettingsRepo settingsRepo *memoryrepo.SettingsRepo
ragRuntime infrarag.Runtime ragRuntime infrarag.Runtime
cfg memorymodel.Config cfg memorymodel.Config
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
}
type retrieveTelemetry struct {
ReadMode string
QueryLen int
LegacyHitCount int
PinnedHitCount int
SemanticHitCount int
DedupDropCount int
FinalCount int
Degraded bool
RAGFallbackUsed bool
}
type semanticRetrieveTelemetry struct {
HitCount int
Degraded bool
RAGFallbackUsed bool
} }
func NewReadService( func NewReadService(
@@ -38,12 +59,22 @@ func NewReadService(
settingsRepo *memoryrepo.SettingsRepo, settingsRepo *memoryrepo.SettingsRepo,
ragRuntime infrarag.Runtime, ragRuntime infrarag.Runtime,
cfg memorymodel.Config, cfg memorymodel.Config,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *ReadService { ) *ReadService {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &ReadService{ return &ReadService{
itemRepo: itemRepo, itemRepo: itemRepo,
settingsRepo: settingsRepo, settingsRepo: settingsRepo,
ragRuntime: ragRuntime, ragRuntime: ragRuntime,
cfg: cfg, cfg: cfg,
observer: observer,
metrics: metrics,
} }
} }
@@ -60,9 +91,14 @@ func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequ
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
telemetry := retrieveTelemetry{
ReadMode: s.cfg.EffectiveReadMode(),
QueryLen: len(strings.TrimSpace(req.Query)),
}
setting, err := s.settingsRepo.GetByUserID(ctx, req.UserID) setting, err := s.settingsRepo.GetByUserID(ctx, req.UserID)
if err != nil { if err != nil {
s.recordRetrieve(ctx, req, telemetry, err)
return nil, err return nil, err
} }
effectiveSetting := memoryutils.EffectiveUserSetting(setting, req.UserID) effectiveSetting := memoryutils.EffectiveUserSetting(setting, req.UserID)
@@ -72,16 +108,29 @@ func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequ
limit := normalizeLimit(req.Limit, defaultRetrieveLimit, maxRetrieveLimit) limit := normalizeLimit(req.Limit, defaultRetrieveLimit, maxRetrieveLimit)
if s.cfg.EffectiveReadMode() == memorymodel.MemoryReadModeHybrid { if s.cfg.EffectiveReadMode() == memorymodel.MemoryReadModeHybrid {
return s.HybridRetrieve(ctx, req, effectiveSetting, limit, now) items, hybridTelemetry, hybridErr := s.HybridRetrieve(ctx, req, effectiveSetting, limit, now)
hybridTelemetry.ReadMode = memorymodel.MemoryReadModeHybrid
hybridTelemetry.QueryLen = telemetry.QueryLen
s.recordRetrieve(ctx, req, hybridTelemetry, hybridErr)
return items, hybridErr
} }
if s.cfg.RAGEnabled && s.ragRuntime != nil && strings.TrimSpace(req.Query) != "" { if s.cfg.RAGEnabled && s.ragRuntime != nil && strings.TrimSpace(req.Query) != "" {
items, ragErr := s.retrieveByRAG(ctx, req, effectiveSetting, limit, now) items, ragErr := s.retrieveByRAG(ctx, req, effectiveSetting, limit, now)
if ragErr == nil && len(items) > 0 { if ragErr == nil && len(items) > 0 {
telemetry.SemanticHitCount = len(items)
telemetry.FinalCount = len(items)
s.recordRetrieve(ctx, req, telemetry, nil)
return items, nil return items, nil
} }
telemetry.Degraded = true
telemetry.RAGFallbackUsed = true
} }
return s.retrieveByLegacy(ctx, req, limit, now, effectiveSetting) items, legacyErr := s.retrieveByLegacy(ctx, req, limit, now, effectiveSetting)
telemetry.LegacyHitCount = len(items)
telemetry.FinalCount = len(items)
s.recordRetrieve(ctx, req, telemetry, legacyErr)
return items, legacyErr
} }
func (s *ReadService) retrieveByLegacy( func (s *ReadService) retrieveByLegacy(
@@ -180,6 +229,58 @@ func normalizeRetrieveMemoryTypes(raw []string) []string {
} }
} }
func (s *ReadService) recordRetrieve(
ctx context.Context,
req memorymodel.RetrieveRequest,
telemetry retrieveTelemetry,
err error,
) {
if s == nil {
return
}
level := memoryobserve.LevelInfo
if err != nil {
level = memoryobserve.LevelWarn
}
s.observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentRead,
Operation: memoryobserve.OperationRetrieve,
Fields: map[string]any{
"user_id": req.UserID,
"read_mode": telemetry.ReadMode,
"query_len": telemetry.QueryLen,
"legacy_hit_count": telemetry.LegacyHitCount,
"pinned_hit_count": telemetry.PinnedHitCount,
"semantic_hit_count": telemetry.SemanticHitCount,
"dedup_drop_count": telemetry.DedupDropCount,
"final_count": telemetry.FinalCount,
"degraded": telemetry.Degraded,
"rag_fallback_used": telemetry.RAGFallbackUsed,
"success": err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
if telemetry.FinalCount > 0 {
s.metrics.AddCounter(memoryobserve.MetricRetrieveHitTotal, int64(telemetry.FinalCount), map[string]string{
"read_mode": strings.TrimSpace(telemetry.ReadMode),
})
}
if telemetry.DedupDropCount > 0 {
s.metrics.AddCounter(memoryobserve.MetricRetrieveDedupDropTotal, int64(telemetry.DedupDropCount), map[string]string{
"read_mode": strings.TrimSpace(telemetry.ReadMode),
})
}
if telemetry.RAGFallbackUsed {
s.metrics.AddCounter(memoryobserve.MetricRAGFallbackTotal, 1, map[string]string{
"read_mode": strings.TrimSpace(telemetry.ReadMode),
})
}
}
// scoreRetrievedItem 计算 legacy 读链路的确定性排序分数。 // scoreRetrievedItem 计算 legacy 读链路的确定性排序分数。
// //
// 说明: // 说明:

View File

@@ -23,41 +23,50 @@ func (s *ReadService) HybridRetrieve(
effectiveSetting model.MemoryUserSetting, effectiveSetting model.MemoryUserSetting,
limit int, limit int,
now time.Time, now time.Time,
) ([]memorymodel.ItemDTO, error) { ) ([]memorymodel.ItemDTO, retrieveTelemetry, error) {
telemetry := retrieveTelemetry{}
if s == nil || s.itemRepo == nil { if s == nil || s.itemRepo == nil {
return nil, nil return nil, telemetry, nil
} }
if !effectiveSetting.MemoryEnabled { if !effectiveSetting.MemoryEnabled {
return nil, nil return nil, telemetry, nil
} }
pinnedItems, err := s.retrievePinnedCandidates(ctx, req, effectiveSetting, now) pinnedItems, err := s.retrievePinnedCandidates(ctx, req, effectiveSetting, now)
if err != nil { if err != nil {
return nil, err return nil, telemetry, err
} }
semanticItems, err := s.retrieveSemanticCandidates(ctx, req, effectiveSetting, limit, now) telemetry.PinnedHitCount = len(pinnedItems)
semanticItems, semanticTelemetry, err := s.retrieveSemanticCandidates(ctx, req, effectiveSetting, limit, now)
if err != nil { if err != nil {
return nil, err return nil, telemetry, err
} }
telemetry.SemanticHitCount = len(semanticItems)
telemetry.Degraded = semanticTelemetry.Degraded
telemetry.RAGFallbackUsed = semanticTelemetry.RAGFallbackUsed
merged := make([]memorymodel.ItemDTO, 0, len(pinnedItems)+len(semanticItems)) merged := make([]memorymodel.ItemDTO, 0, len(pinnedItems)+len(semanticItems))
merged = append(merged, pinnedItems...) merged = append(merged, pinnedItems...)
merged = append(merged, semanticItems...) merged = append(merged, semanticItems...)
if len(merged) == 0 { if len(merged) == 0 {
return nil, nil return nil, telemetry, nil
} }
beforeDedupCount := len(merged)
merged = dedupByID(merged) merged = dedupByID(merged)
merged = dedupByHash(merged) merged = dedupByHash(merged)
merged = dedupByText(merged) merged = dedupByText(merged)
telemetry.DedupDropCount = beforeDedupCount - len(merged)
merged = RankItems(merged, now) merged = RankItems(merged, now)
merged = applyTypeBudget(merged, s.cfg) merged = applyTypeBudget(merged, s.cfg)
if len(merged) == 0 { if len(merged) == 0 {
return nil, nil return nil, telemetry, nil
} }
telemetry.FinalCount = len(merged)
_ = s.itemRepo.TouchLastAccessAt(ctx, collectItemDTOIDs(merged), now) _ = s.itemRepo.TouchLastAccessAt(ctx, collectItemDTOIDs(merged), now)
return merged, nil return merged, telemetry, nil
} }
func (s *ReadService) retrievePinnedCandidates( func (s *ReadService) retrievePinnedCandidates(
@@ -81,20 +90,26 @@ func (s *ReadService) retrieveSemanticCandidates(
effectiveSetting model.MemoryUserSetting, effectiveSetting model.MemoryUserSetting,
limit int, limit int,
now time.Time, now time.Time,
) ([]memorymodel.ItemDTO, error) { ) ([]memorymodel.ItemDTO, semanticRetrieveTelemetry, error) {
telemetry := semanticRetrieveTelemetry{}
queryText := strings.TrimSpace(req.Query) queryText := strings.TrimSpace(req.Query)
if queryText == "" { if queryText == "" {
return nil, nil return nil, telemetry, nil
} }
candidateLimit := hybridSemanticTopK(s.cfg, limit) candidateLimit := hybridSemanticTopK(s.cfg, limit)
if s.cfg.RAGEnabled && s.ragRuntime != nil { if s.cfg.RAGEnabled && s.ragRuntime != nil {
items, err := s.retrieveSemanticCandidatesByRAG(ctx, req, effectiveSetting, candidateLimit, now) items, err := s.retrieveSemanticCandidatesByRAG(ctx, req, effectiveSetting, candidateLimit, now)
if shouldReturnSemanticRAGResult(items, err) { if shouldReturnSemanticRAGResult(items, err) {
return items, nil telemetry.HitCount = len(items)
return items, telemetry, nil
} }
telemetry.Degraded = true
telemetry.RAGFallbackUsed = true
} }
return s.retrieveSemanticCandidatesByMySQL(ctx, req, effectiveSetting, candidateLimit, now) items, err := s.retrieveSemanticCandidatesByMySQL(ctx, req, effectiveSetting, candidateLimit, now)
telemetry.HitCount = len(items)
return items, telemetry, err
} }
func (s *ReadService) retrieveSemanticCandidatesByRAG( func (s *ReadService) retrieveSemanticCandidatesByRAG(

View File

@@ -12,8 +12,12 @@ const (
AuditOperationCreate = "create" AuditOperationCreate = "create"
// AuditOperationUpdate 表示决策层更新已有记忆的内容。 // AuditOperationUpdate 表示决策层更新已有记忆的内容。
AuditOperationUpdate = "update" AuditOperationUpdate = "update"
// AuditOperationArchive 表示治理层把重复记忆归档。
AuditOperationArchive = "archive"
// AuditOperationDelete 表示对已有记忆做软删除。 // AuditOperationDelete 表示对已有记忆做软删除。
AuditOperationDelete = "delete" AuditOperationDelete = "delete"
// AuditOperationRestore 表示把已删除/归档记忆恢复为 active。
AuditOperationRestore = "restore"
) )
// BuildItemAuditLog 构造记忆变更审计日志。 // BuildItemAuditLog 构造记忆变更审计日志。

View File

@@ -0,0 +1,213 @@
package vectorsync
import (
"context"
"fmt"
"log"
"strings"
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
"github.com/LoveLosita/smartflow/backend/model"
)
// Syncer 负责 memory_items 与向量库之间的最小桥接。
//
// 职责边界:
// 1. 只负责“把已经落库的记忆同步到 RAG / 从 RAG 删除”;
// 2. 不负责决定哪些记忆该写、该删、该恢复,这些决策仍由上游 service/worker/cleanup 控制;
// 3. 同步失败时只回写 vector_status 并打观测,不反向回滚业务事务,避免把在线链路拖成强依赖。
type Syncer struct {
ragRuntime infrarag.Runtime
itemRepo *memoryrepo.ItemRepo
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
logger *log.Logger
}
func NewSyncer(
ragRuntime infrarag.Runtime,
itemRepo *memoryrepo.ItemRepo,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *Syncer {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &Syncer{
ragRuntime: ragRuntime,
itemRepo: itemRepo,
observer: observer,
metrics: metrics,
logger: log.Default(),
}
}
// Upsert 把新增/修改/恢复后的记忆同步到向量库。
func (s *Syncer) Upsert(ctx context.Context, traceID string, items []model.MemoryItem) {
if s == nil || s.ragRuntime == nil || s.itemRepo == nil || len(items) == 0 {
return
}
requestItems := make([]infrarag.MemoryIngestItem, 0, len(items))
for _, item := range items {
requestItems = append(requestItems, infrarag.MemoryIngestItem{
MemoryID: item.ID,
UserID: item.UserID,
ConversationID: strValue(item.ConversationID),
AssistantID: strValue(item.AssistantID),
RunID: strValue(item.RunID),
MemoryType: item.MemoryType,
Title: item.Title,
Content: item.Content,
Confidence: item.Confidence,
Importance: item.Importance,
SensitivityLevel: item.SensitivityLevel,
IsExplicit: item.IsExplicit,
Status: item.Status,
TTLAt: item.TTLAt,
CreatedAt: item.CreatedAt,
})
}
result, err := s.ragRuntime.IngestMemory(memoryobserve.WithFields(ctx, map[string]any{
"trace_id": traceID,
}), infrarag.MemoryIngestRequest{
TraceID: traceID,
Action: "add",
Items: requestItems,
})
if err != nil {
s.observer.Observe(ctx, memoryobserve.Event{
Level: memoryobserve.LevelWarn,
Component: memoryobserve.ComponentWrite,
Operation: "vector_upsert",
Fields: map[string]any{
"trace_id": traceID,
"item_count": len(items),
"success": false,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
for _, item := range items {
_ = s.itemRepo.UpdateVectorStateByID(ctx, item.ID, "failed", nil)
}
return
}
vectorIDMap := make(map[int64]string, len(result.DocumentIDs))
for _, documentID := range result.DocumentIDs {
memoryID := parseMemoryID(documentID)
if memoryID <= 0 {
continue
}
vectorIDMap[memoryID] = documentID
}
for _, item := range items {
vectorID := strPtrOrNil(vectorIDMap[item.ID])
_ = s.itemRepo.UpdateVectorStateByID(ctx, item.ID, "synced", vectorID)
}
s.observer.Observe(ctx, memoryobserve.Event{
Level: memoryobserve.LevelInfo,
Component: memoryobserve.ComponentWrite,
Operation: "vector_upsert",
Fields: map[string]any{
"trace_id": traceID,
"item_count": len(items),
"document_count": len(result.DocumentIDs),
"success": true,
},
})
}
// Delete 把一批记忆对应的向量从向量库中删除。
func (s *Syncer) Delete(ctx context.Context, traceID string, memoryIDs []int64) {
if s == nil || len(memoryIDs) == 0 {
return
}
if s.ragRuntime == nil || s.itemRepo == nil {
return
}
documentIDs := make([]string, 0, len(memoryIDs))
for _, id := range memoryIDs {
documentIDs = append(documentIDs, fmt.Sprintf("memory:%d", id))
}
err := s.ragRuntime.DeleteMemory(memoryobserve.WithFields(ctx, map[string]any{
"trace_id": traceID,
}), documentIDs)
if err != nil {
s.observer.Observe(ctx, memoryobserve.Event{
Level: memoryobserve.LevelWarn,
Component: memoryobserve.ComponentWrite,
Operation: "vector_delete",
Fields: map[string]any{
"trace_id": traceID,
"item_count": len(memoryIDs),
"success": false,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
for _, memoryID := range memoryIDs {
_ = s.itemRepo.UpdateVectorStateByID(ctx, memoryID, "failed", nil)
}
return
}
for _, memoryID := range memoryIDs {
_ = s.itemRepo.UpdateVectorStateByID(ctx, memoryID, "deleted", nil)
}
s.observer.Observe(ctx, memoryobserve.Event{
Level: memoryobserve.LevelInfo,
Component: memoryobserve.ComponentWrite,
Operation: "vector_delete",
Fields: map[string]any{
"trace_id": traceID,
"item_count": len(memoryIDs),
"success": true,
},
})
}
func parseMemoryID(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
}
var value int64
for _, ch := range raw {
if ch < '0' || ch > '9' {
return 0
}
value = value*10 + int64(ch-'0')
}
return value
}
func strPtrOrNil(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
value := v
return &value
}
func strValue(v *string) string {
if v == nil {
return ""
}
return strings.TrimSpace(*v)
}

View File

@@ -33,6 +33,11 @@ type factDecisionResult struct {
Outcomes []*ApplyActionOutcome Outcomes []*ApplyActionOutcome
} }
type candidateRecallResult struct {
Items []memorymodel.CandidateSnapshot
FallbackMode string
}
// executeDecisionFlow 在 worker 内编排"召回→逐对比对→汇总→执行"全流程。 // executeDecisionFlow 在 worker 内编排"召回→逐对比对→汇总→执行"全流程。
// //
// 职责边界: // 职责边界:
@@ -116,6 +121,7 @@ func (r *Runner) executeDecisionForFact(
} }
} }
if len(existing) > 0 { if len(existing) > 0 {
r.recordDecisionObservation(ctx, job, payload, fact, 0, memorymodel.DecisionActionNone, "hash_exact", true, nil)
result.Outcomes = append(result.Outcomes, &ApplyActionOutcome{ result.Outcomes = append(result.Outcomes, &ApplyActionOutcome{
Action: memorymodel.DecisionActionNone, Action: memorymodel.DecisionActionNone,
NeedsSync: false, NeedsSync: false,
@@ -124,7 +130,8 @@ func (r *Runner) executeDecisionForFact(
} }
// Step 2: Milvus 语义召回(含降级)。 // Step 2: Milvus 语义召回(含降级)。
candidates := r.recallCandidates(ctx, payload, fact) recallResult := r.recallCandidates(ctx, payload, fact)
candidates := recallResult.Items
// 打印召回候选详情,便于排查向量召回和阈值过滤效果。 // 打印召回候选详情,便于排查向量召回和阈值过滤效果。
if r.logger != nil { if r.logger != nil {
@@ -151,9 +158,11 @@ func (r *Runner) executeDecisionForFact(
// Step 5: 校验 + 执行。 // Step 5: 校验 + 执行。
actionOutcome, err := ApplyFinalDecision(ctx, itemRepo, auditRepo, *decision, fact, job, payload) actionOutcome, err := ApplyFinalDecision(ctx, itemRepo, auditRepo, *decision, fact, job, payload)
if err != nil { if err != nil {
r.recordDecisionObservation(ctx, job, payload, fact, len(candidates), decision.Action, recallResult.FallbackMode, false, err)
return nil, fmt.Errorf("执行决策动作失败: %w", err) return nil, fmt.Errorf("执行决策动作失败: %w", err)
} }
result.Outcomes = append(result.Outcomes, actionOutcome) 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。 // Step 6: conflict (DELETE) 后需要补一个 ADD 写入新 fact。
// 原因:旧记忆矛盾需删除,但新事实本身仍然有效,必须写入。 // 原因:旧记忆矛盾需删除,但新事实本身仍然有效,必须写入。
@@ -180,7 +189,7 @@ func (r *Runner) recallCandidates(
ctx context.Context, ctx context.Context,
payload memorymodel.ExtractJobPayload, payload memorymodel.ExtractJobPayload,
fact memorymodel.NormalizedFact, fact memorymodel.NormalizedFact,
) []memorymodel.CandidateSnapshot { ) candidateRecallResult {
// 1. 优先使用 Milvus 向量语义召回。 // 1. 优先使用 Milvus 向量语义召回。
if r.ragRuntime != nil { if r.ragRuntime != nil {
retrieveResult, err := r.ragRuntime.RetrieveMemory(ctx, infrarag.MemoryRetrieveRequest{ retrieveResult, err := r.ragRuntime.RetrieveMemory(ctx, infrarag.MemoryRetrieveRequest{
@@ -194,7 +203,10 @@ func (r *Runner) recallCandidates(
if err == nil && len(retrieveResult.Items) > 0 { if err == nil && len(retrieveResult.Items) > 0 {
candidates := r.buildCandidatesFromRAG(retrieveResult.Items) candidates := r.buildCandidatesFromRAG(retrieveResult.Items)
if len(candidates) > 0 { if len(candidates) > 0 {
return candidates return candidateRecallResult{
Items: candidates,
FallbackMode: "rag",
}
} }
// RAG 返回了结果但 DocumentID 全部解析失败,降级到 MySQL。 // RAG 返回了结果但 DocumentID 全部解析失败,降级到 MySQL。
if r.logger != nil { if r.logger != nil {
@@ -204,10 +216,17 @@ func (r *Runner) recallCandidates(
if err != nil && r.logger != nil { 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) 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 条。 // 2. 降级:按 user_id + memory_type + status=active 查最近 N 条。
return r.recallCandidatesFromMySQL(ctx, payload, fact) return candidateRecallResult{
Items: r.recallCandidatesFromMySQL(ctx, payload, fact),
FallbackMode: "mysql_only",
}
} }
// buildCandidatesFromRAG 从 RAG 检索结果构建候选快照列表。 // buildCandidatesFromRAG 从 RAG 检索结果构建候选快照列表。

View File

@@ -6,15 +6,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time" "time"
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag" infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memoryorchestrator "github.com/LoveLosita/smartflow/backend/memory/orchestrator" memoryorchestrator "github.com/LoveLosita/smartflow/backend/memory/orchestrator"
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo" memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils" memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/memory/vectorsync"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -42,6 +43,9 @@ type Runner struct {
extractor Extractor extractor Extractor
ragRuntime infrarag.Runtime ragRuntime infrarag.Runtime
logger *log.Logger logger *log.Logger
vectorSyncer *memoryvectorsync.Syncer
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
// 决策层依赖。 // 决策层依赖。
// 说明: // 说明:
@@ -62,7 +66,16 @@ func NewRunner(
ragRuntime infrarag.Runtime, ragRuntime infrarag.Runtime,
cfg memorymodel.Config, cfg memorymodel.Config,
decisionOrchestrator *memoryorchestrator.LLMDecisionOrchestrator, decisionOrchestrator *memoryorchestrator.LLMDecisionOrchestrator,
vectorSyncer *memoryvectorsync.Syncer,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *Runner { ) *Runner {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &Runner{ return &Runner{
db: db, db: db,
jobRepo: jobRepo, jobRepo: jobRepo,
@@ -72,6 +85,9 @@ func NewRunner(
extractor: extractor, extractor: extractor,
ragRuntime: ragRuntime, ragRuntime: ragRuntime,
logger: log.Default(), logger: log.Default(),
vectorSyncer: vectorSyncer,
observer: observer,
metrics: metrics,
cfg: cfg, cfg: cfg,
decisionOrchestrator: decisionOrchestrator, decisionOrchestrator: decisionOrchestrator,
} }
@@ -96,6 +112,11 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
if job == nil { if job == nil {
return &RunOnceResult{Claimed: false}, nil return &RunOnceResult{Claimed: false}, nil
} }
if job.RetryCount > 0 {
r.metrics.AddCounter(memoryobserve.MetricJobRetryTotal, 1, map[string]string{
"job_type": strings.TrimSpace(job.JobType),
})
}
result := &RunOnceResult{ result := &RunOnceResult{
Claimed: true, Claimed: true,
@@ -110,21 +131,25 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
failReason := fmt.Sprintf("解析任务载荷失败: %v", err) failReason := fmt.Sprintf("解析任务载荷失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason) _ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, nil, result.Status, false, err)
return result, nil return result, nil
} }
// 3. 先读取用户记忆设置。总开关关闭时,任务直接成功结束,不再继续抽取和落库。 // 3. 先读取用户记忆设置。总开关关闭时,任务直接成功结束,不再继续抽取和落库。
setting, err := r.settingsRepo.GetByUserID(ctx, payload.UserID) setting, err := r.settingsRepo.GetByUserID(ctx, payload.UserID)
if err != nil { if err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err return nil, err
} }
effectiveSetting := memoryutils.EffectiveUserSetting(setting, payload.UserID) effectiveSetting := memoryutils.EffectiveUserSetting(setting, payload.UserID)
if !effectiveSetting.MemoryEnabled { if !effectiveSetting.MemoryEnabled {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil { if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err return nil, err
} }
result.Status = model.MemoryJobStatusSuccess result.Status = model.MemoryJobStatusSuccess
r.logger.Printf("memory worker skipped by user setting: job_id=%d user_id=%d", job.ID, payload.UserID) r.logger.Printf("memory worker skipped by user setting: job_id=%d user_id=%d", job.ID, payload.UserID)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
@@ -134,26 +159,31 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
failReason := fmt.Sprintf("抽取执行失败: %v", extractErr) failReason := fmt.Sprintf("抽取执行失败: %v", extractErr)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason) _ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, extractErr)
return result, nil return result, nil
} }
facts = memoryutils.FilterFactsBySetting(facts, effectiveSetting) facts = memoryutils.FilterFactsBySetting(facts, effectiveSetting)
if len(facts) == 0 { if len(facts) == 0 {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil { if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err return nil, err
} }
result.Status = model.MemoryJobStatusSuccess result.Status = model.MemoryJobStatusSuccess
r.logger.Printf("memory worker run once noop: job_id=%d", job.ID) r.logger.Printf("memory worker run once noop: job_id=%d", job.ID)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
items := buildMemoryItems(job, payload, facts) items := buildMemoryItems(job, payload, facts)
if len(items) == 0 { if len(items) == 0 {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil { if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err return nil, err
} }
result.Status = model.MemoryJobStatusSuccess result.Status = model.MemoryJobStatusSuccess
r.logger.Printf("memory worker run once empty-after-normalize: job_id=%d", job.ID) r.logger.Printf("memory worker run once empty-after-normalize: job_id=%d", job.ID)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
@@ -169,16 +199,19 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
failReason := fmt.Sprintf("决策降级后记忆落库失败: %v", err) failReason := fmt.Sprintf("决策降级后记忆落库失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason) _ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, err)
return result, nil return result, nil
} }
result.Status = model.MemoryJobStatusSuccess result.Status = model.MemoryJobStatusSuccess
result.Facts = len(items) result.Facts = len(items)
r.syncMemoryVectors(ctx, items) r.syncMemoryVectors(ctx, items)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
// FallbackMode=drop丢弃本轮抽取结果直接标记 job 成功。 // FallbackMode=drop丢弃本轮抽取结果直接标记 job 成功。
_ = r.jobRepo.MarkSuccess(ctx, job.ID) _ = r.jobRepo.MarkSuccess(ctx, job.ID)
result.Status = model.MemoryJobStatusSuccess result.Status = model.MemoryJobStatusSuccess
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
@@ -189,6 +222,7 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
r.syncVectorDeletes(ctx, outcome.VectorDeletes) r.syncVectorDeletes(ctx, outcome.VectorDeletes)
r.logger.Printf("[去重] 决策流程完成: job_id=%d user_id=%d 新增=%d 更新=%d 删除=%d 跳过=%d", r.logger.Printf("[去重] 决策流程完成: job_id=%d user_id=%d 新增=%d 更新=%d 删除=%d 跳过=%d",
job.ID, payload.UserID, outcome.AddCount, outcome.UpdateCount, outcome.DeleteCount, outcome.NoneCount) job.ID, payload.UserID, outcome.AddCount, outcome.UpdateCount, outcome.DeleteCount, outcome.NoneCount)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
@@ -197,6 +231,7 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
failReason := fmt.Sprintf("记忆落库失败: %v", err) failReason := fmt.Sprintf("记忆落库失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason) _ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, err)
return result, nil return result, nil
} }
@@ -204,6 +239,7 @@ func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
result.Facts = len(items) result.Facts = len(items)
r.syncMemoryVectors(ctx, items) r.syncMemoryVectors(ctx, items)
r.logger.Printf("memory worker run once success: job_id=%d extracted_facts=%d", job.ID, len(items)) r.logger.Printf("memory worker run once success: job_id=%d extracted_facts=%d", job.ID, len(items))
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil return result, nil
} }
@@ -268,56 +304,10 @@ func buildMemoryItems(job *model.MemoryJob, payload memorymodel.ExtractJobPayloa
} }
func (r *Runner) syncMemoryVectors(ctx context.Context, items []model.MemoryItem) { func (r *Runner) syncMemoryVectors(ctx context.Context, items []model.MemoryItem) {
if r == nil || r.ragRuntime == nil || r.itemRepo == nil || len(items) == 0 { if r == nil || r.vectorSyncer == nil || len(items) == 0 {
return return
} }
r.vectorSyncer.Upsert(ctx, "", items)
requestItems := make([]infrarag.MemoryIngestItem, 0, len(items))
for _, item := range items {
requestItems = append(requestItems, infrarag.MemoryIngestItem{
MemoryID: item.ID,
UserID: item.UserID,
ConversationID: strValue(item.ConversationID),
AssistantID: strValue(item.AssistantID),
RunID: strValue(item.RunID),
MemoryType: item.MemoryType,
Title: item.Title,
Content: item.Content,
Confidence: item.Confidence,
Importance: item.Importance,
SensitivityLevel: item.SensitivityLevel,
IsExplicit: item.IsExplicit,
Status: item.Status,
TTLAt: item.TTLAt,
CreatedAt: item.CreatedAt,
})
}
result, err := r.ragRuntime.IngestMemory(ctx, infrarag.MemoryIngestRequest{
Action: "add",
Items: requestItems,
})
if err != nil {
r.logger.Printf("[WARN][去重] 记忆向量同步失败: count=%d err=%v", len(items), err)
for _, item := range items {
_ = r.itemRepo.UpdateVectorStateByID(ctx, item.ID, "failed", nil)
}
return
}
vectorIDMap := make(map[int64]string, len(result.DocumentIDs))
for _, documentID := range result.DocumentIDs {
memoryID := parseMemoryID(documentID)
if memoryID <= 0 {
continue
}
vectorIDMap[memoryID] = documentID
}
for _, item := range items {
vectorID := strPtrOrNil(vectorIDMap[item.ID])
_ = r.itemRepo.UpdateVectorStateByID(ctx, item.ID, "synced", vectorID)
}
} }
// syncVectorDeletes 处理决策层 DELETE 动作产出的向量清理需求。 // syncVectorDeletes 处理决策层 DELETE 动作产出的向量清理需求。
@@ -327,33 +317,10 @@ func (r *Runner) syncMemoryVectors(ctx context.Context, items []model.MemoryItem
// 2. 调 Runtime.DeleteMemory 真正从 Milvus 删除对应向量; // 2. 调 Runtime.DeleteMemory 真正从 Milvus 删除对应向量;
// 3. 更新 MySQL vector_status 标记删除结果。 // 3. 更新 MySQL vector_status 标记删除结果。
func (r *Runner) syncVectorDeletes(ctx context.Context, memoryIDs []int64) { func (r *Runner) syncVectorDeletes(ctx context.Context, memoryIDs []int64) {
if r == nil || len(memoryIDs) == 0 { if r == nil || r.vectorSyncer == nil || len(memoryIDs) == 0 {
return return
} }
r.vectorSyncer.Delete(ctx, "", memoryIDs)
// 1. 构造 documentID 列表。
documentIDs := make([]string, 0, len(memoryIDs))
for _, id := range memoryIDs {
documentIDs = append(documentIDs, fmt.Sprintf("memory:%d", id))
}
// 2. 调 Runtime 删除向量。
if r.ragRuntime != nil {
if err := r.ragRuntime.DeleteMemory(ctx, documentIDs); err != nil {
r.logger.Printf("[WARN][去重] Milvus 向量删除失败,标记为 pending 等待后续清理: count=%d ids=%v err=%v", len(memoryIDs), memoryIDs, err)
} else {
r.logger.Printf("[去重] Milvus 向量删除完成: count=%d ids=%v", len(memoryIDs), memoryIDs)
}
}
// 3. 更新 MySQL vector_status。
for _, memoryID := range memoryIDs {
if updateErr := r.itemRepo.UpdateVectorStateByID(ctx, memoryID, "deleted", nil); updateErr != nil {
if r.logger != nil {
r.logger.Printf("[WARN] 向量状态更新失败: memory_id=%d err=%v", memoryID, updateErr)
}
}
}
} }
func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time { func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time {
@@ -395,11 +362,106 @@ func int64PtrOrNil(v int64) *int64 {
return &value return &value
} }
func strValue(v *string) string { func (r *Runner) recordJobOutcome(
if v == nil { ctx context.Context,
return "" job *model.MemoryJob,
payload *memorymodel.ExtractJobPayload,
status string,
success bool,
err error,
) {
if r == nil {
return
} }
return strings.TrimSpace(*v)
level := memoryobserve.LevelInfo
if !success || err != nil {
level = memoryobserve.LevelWarn
}
fields := map[string]any{
"job_id": jobIDValue(job),
"status": strings.TrimSpace(status),
"success": success && err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
}
if payload != nil {
fields["trace_id"] = strings.TrimSpace(payload.TraceID)
fields["user_id"] = payload.UserID
fields["conversation_id"] = strings.TrimSpace(payload.ConversationID)
}
r.observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentWrite,
Operation: "job",
Fields: fields,
})
r.metrics.AddCounter(memoryobserve.MetricJobTotal, 1, map[string]string{
"status": strings.TrimSpace(status),
})
}
func (r *Runner) recordDecisionObservation(
ctx context.Context,
job *model.MemoryJob,
payload memorymodel.ExtractJobPayload,
fact memorymodel.NormalizedFact,
candidateCount int,
finalAction string,
fallbackMode string,
success bool,
err error,
) {
if r == nil {
return
}
level := memoryobserve.LevelInfo
status := "success"
if !success || err != nil {
level = memoryobserve.LevelWarn
status = "error"
}
fallbackMode = strings.TrimSpace(fallbackMode)
if fallbackMode == "" {
fallbackMode = "none"
}
r.observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentWrite,
Operation: memoryobserve.OperationDecision,
Fields: map[string]any{
"trace_id": strings.TrimSpace(payload.TraceID),
"user_id": payload.UserID,
"conversation_id": strings.TrimSpace(payload.ConversationID),
"job_id": jobIDValue(job),
"fact_type": strings.TrimSpace(fact.MemoryType),
"candidate_count": candidateCount,
"final_action": strings.TrimSpace(finalAction),
"fallback_mode": fallbackMode,
"success": success && err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
r.metrics.AddCounter(memoryobserve.MetricDecisionTotal, 1, map[string]string{
"action": strings.TrimSpace(finalAction),
"status": status,
})
if fallbackMode != "none" && fallbackMode != "hash_exact" && fallbackMode != "rag" {
r.metrics.AddCounter(memoryobserve.MetricDecisionFallbackTotal, 1, map[string]string{
"mode": fallbackMode,
})
}
}
func jobIDValue(job *model.MemoryJob) int64 {
if job == nil {
return 0
}
return job.ID
} }
func parseMemoryID(documentID string) int64 { func parseMemoryID(documentID string) int64 {
@@ -411,9 +473,13 @@ func parseMemoryID(documentID string) int64 {
if strings.HasPrefix(raw, "uid:") { if strings.HasPrefix(raw, "uid:") {
return 0 return 0
} }
memoryID, err := strconv.ParseInt(raw, 10, 64)
if err != nil { var value int64
return 0 for _, ch := range raw {
if ch < '0' || ch > '9' {
return 0
}
value = value*10 + int64(ch-'0')
} }
return memoryID return value
} }

View File

@@ -0,0 +1,105 @@
package model
import "time"
// MemoryGetItemRequest 描述“查看我的某条记忆”所需的最小参数。
type MemoryGetItemRequest struct {
UserID int
MemoryID int64
}
// MemoryCreateItemRequest 描述“手动新增一条记忆”的输入。
type MemoryCreateItemRequest struct {
UserID int `json:"-"`
ConversationID string `json:"conversation_id,omitempty"`
AssistantID string `json:"assistant_id,omitempty"`
RunID string `json:"run_id,omitempty"`
MemoryType string `json:"memory_type"`
Title string `json:"title"`
Content string `json:"content"`
Confidence *float64 `json:"confidence,omitempty"`
Importance *float64 `json:"importance,omitempty"`
SensitivityLevel *int `json:"sensitivity_level,omitempty"`
IsExplicit *bool `json:"is_explicit,omitempty"`
TTLAt *time.Time `json:"ttl_at,omitempty"`
Reason string `json:"reason,omitempty"`
OperatorType string `json:"-"`
}
// MemoryUpdateItemRequest 描述“手动修改一条记忆”的 Patch 输入。
//
// 说明:
// 1. 使用指针区分“未传字段”和“显式传零值”;
// 2. ClearTTL 用于表达“显式清空 ttl_at”
// 3. 当前仍只允许修改内容侧字段,不开放跨用户、跨归属字段改写。
type MemoryUpdateItemRequest struct {
UserID int `json:"-"`
MemoryID int64 `json:"-"`
MemoryType *string `json:"memory_type,omitempty"`
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Importance *float64 `json:"importance,omitempty"`
SensitivityLevel *int `json:"sensitivity_level,omitempty"`
IsExplicit *bool `json:"is_explicit,omitempty"`
TTLAt *time.Time `json:"ttl_at,omitempty"`
ClearTTL bool `json:"clear_ttl,omitempty"`
Reason string `json:"reason,omitempty"`
OperatorType string `json:"-"`
}
// MemoryDeleteItemRequest 描述“删除我的一条记忆”的输入。
type MemoryDeleteItemRequest struct {
UserID int
MemoryID int64
Reason string
OperatorType string
}
// MemoryRestoreItemRequest 描述“恢复我的一条记忆”的输入。
type MemoryRestoreItemRequest struct {
UserID int
MemoryID int64
Reason string
OperatorType string
}
// MemoryDedupCleanupRequest 描述离线去重治理任务的执行参数。
type MemoryDedupCleanupRequest struct {
UserID int
Limit int
DryRun bool
Reason string
OperatorType string
}
// MemoryDedupCleanupResult 描述一次离线去重治理的汇总结果。
type MemoryDedupCleanupResult struct {
ScannedGroupCount int `json:"scanned_group_count"`
DedupedGroupCount int `json:"deduped_group_count"`
KeptCount int `json:"kept_count"`
ArchivedCount int `json:"archived_count"`
ArchivedIDs []int64 `json:"archived_ids,omitempty"`
DryRun bool `json:"dry_run"`
}
// MemoryItemView 是前端可见的记忆条目视图。
type MemoryItemView struct {
ID int64 `json:"id"`
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id,omitempty"`
AssistantID string `json:"assistant_id,omitempty"`
RunID string `json:"run_id,omitempty"`
MemoryType string `json:"memory_type"`
Title string `json:"title"`
Content string `json:"content"`
ContentHash string `json:"content_hash,omitempty"`
Confidence float64 `json:"confidence"`
Importance float64 `json:"importance"`
SensitivityLevel int `json:"sensitivity_level"`
IsExplicit bool `json:"is_explicit"`
Status string `json:"status"`
TTLAt *time.Time `json:"ttl_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

View File

@@ -339,6 +339,21 @@ var ( //请求相关的响应
Info: "conversation_id is required when confirm_action is present", Info: "conversation_id is required when confirm_action is present",
} }
MemoryItemNotFound = Response{ //记忆条目不存在
Status: "40055",
Info: "memory item not found",
}
MemoryInvalidType = Response{ //记忆类型不合法
Status: "40056",
Info: "invalid memory type",
}
MemoryInvalidContent = Response{ //记忆内容为空或不合法
Status: "40057",
Info: "invalid memory content",
}
RouteControlInternalError = Response{ //路由控制码内部错误 RouteControlInternalError = Response{ //路由控制码内部错误
Status: "50001", Status: "50001",
Info: "route control failed", Info: "route control failed",

View File

@@ -97,6 +97,16 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview) agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats) agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats)
} }
memoryGroup := apiGroup.Group("/memory")
{
memoryGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1))
memoryGroup.GET("/items", handlers.MemoryHandler.ListItems)
memoryGroup.GET("/items/:id", handlers.MemoryHandler.GetItem)
memoryGroup.POST("/items", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.CreateItem)
memoryGroup.PATCH("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.UpdateItem)
memoryGroup.DELETE("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.DeleteItem)
memoryGroup.POST("/items/:id/restore", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.RestoreItem)
}
} }
// 初始化Gin引擎 // 初始化Gin引擎
log.Println("Routes setup completed") log.Println("Routes setup completed")

View File

@@ -16,6 +16,7 @@ import (
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/inits" "github.com/LoveLosita/smartflow/backend/inits"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools" newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
@@ -60,6 +61,8 @@ type AgentService struct {
compactionStore newagentmodel.CompactionStore compactionStore newagentmodel.CompactionStore
memoryReader MemoryReader memoryReader MemoryReader
memoryCfg memorymodel.Config memoryCfg memorymodel.Config
memoryObserver memoryobserve.Observer
memoryMetrics memoryobserve.MetricsRecorder
} }
// NewAgentService 构造 AgentService。 // NewAgentService 构造 AgentService。

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model" newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
) )
@@ -27,10 +28,21 @@ type MemoryReader interface {
Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error) Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error)
} }
type memoryObserveProvider interface {
MemoryObserver() memoryobserve.Observer
MemoryMetrics() memoryobserve.MetricsRecorder
}
// SetMemoryReader 注入 newAgent 主链路读取记忆所需的薄接口与渲染配置。 // SetMemoryReader 注入 newAgent 主链路读取记忆所需的薄接口与渲染配置。
func (s *AgentService) SetMemoryReader(reader MemoryReader, cfg memorymodel.Config) { func (s *AgentService) SetMemoryReader(reader MemoryReader, cfg memorymodel.Config) {
s.memoryReader = reader s.memoryReader = reader
s.memoryCfg = cfg s.memoryCfg = cfg
s.memoryObserver = memoryobserve.NewNopObserver()
s.memoryMetrics = memoryobserve.NewNopMetrics()
if provider, ok := reader.(memoryObserveProvider); ok {
s.memoryObserver = provider.MemoryObserver()
s.memoryMetrics = provider.MemoryMetrics()
}
} }
// injectMemoryContext 在 graph 执行前,把本轮相关记忆写入 ConversationContext 的 pinned block。 // injectMemoryContext 在 graph 执行前,把本轮相关记忆写入 ConversationContext 的 pinned block。
@@ -64,6 +76,7 @@ func (s *AgentService) injectMemoryContext(
}) })
if err != nil { if err != nil {
conversationContext.RemovePinnedBlock(newAgentMemoryBlockKey) conversationContext.RemovePinnedBlock(newAgentMemoryBlockKey)
s.recordMemoryInject(ctx, userID, 0, false, err)
log.Printf("读取记忆上下文失败 user=%d chat=%s err=%v", userID, chatID, err) log.Printf("读取记忆上下文失败 user=%d chat=%s err=%v", userID, chatID, err)
return return
} }
@@ -71,6 +84,7 @@ func (s *AgentService) injectMemoryContext(
content := renderMemoryPinnedContentByMode(items, s.memoryCfg.EffectiveInjectRenderMode()) content := renderMemoryPinnedContentByMode(items, s.memoryCfg.EffectiveInjectRenderMode())
if content == "" { if content == "" {
conversationContext.RemovePinnedBlock(newAgentMemoryBlockKey) conversationContext.RemovePinnedBlock(newAgentMemoryBlockKey)
s.recordMemoryInject(ctx, userID, len(items), false, nil)
return return
} }
@@ -79,6 +93,7 @@ func (s *AgentService) injectMemoryContext(
Title: newAgentMemoryBlockTitle, Title: newAgentMemoryBlockTitle,
Content: content, Content: content,
}) })
s.recordMemoryInject(ctx, userID, len(items), true, nil)
} }
// shouldInjectMemoryForInput 判断当前输入是否值得触发一次记忆召回。 // shouldInjectMemoryForInput 判断当前输入是否值得触发一次记忆召回。
@@ -100,3 +115,49 @@ func shouldInjectMemoryForInput(userMessage string) bool {
return true return true
} }
} }
func (s *AgentService) recordMemoryInject(
ctx context.Context,
userID int,
inputCount int,
success bool,
err error,
) {
if s == nil {
return
}
observer := s.memoryObserver
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
metrics := s.memoryMetrics
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
level := memoryobserve.LevelInfo
if err != nil {
level = memoryobserve.LevelWarn
}
observer.Observe(ctx, memoryobserve.Event{
Level: level,
Component: memoryobserve.ComponentInject,
Operation: memoryobserve.OperationInject,
Fields: map[string]any{
"user_id": userID,
"inject_mode": s.memoryCfg.EffectiveInjectRenderMode(),
"input_count": inputCount,
"rendered_count": inputCount,
"token_budget": 0,
"fallback": false,
"success": success && err == nil,
"error": err,
"error_code": memoryobserve.ClassifyError(err),
},
})
if inputCount > 0 {
metrics.AddCounter(memoryobserve.MetricInjectItemTotal, int64(inputCount), map[string]string{
"inject_mode": s.memoryCfg.EffectiveInjectRenderMode(),
})
}
}