Version: 0.9.76.dev.260505

后端:
1.阶段 6 agent / memory 服务化收口
- 新增 cmd/agent 独立进程入口,承载 agent zrpc server、agent outbox relay / consumer 和运行时依赖初始化
- 补齐 services/agent/rpc 的 Chat stream 与 conversation meta/list/timeline、schedule-preview、context-stats、schedule-state unary RPC
- 新增 gateway/client/agent 与 shared/contracts/agent,将 /api/v1/agent chat 和非 chat 门面切到 agent zrpc
- 收缩 gateway 本地 AgentService 装配,双 RPC 开关开启时不再初始化本地 agent 编排、LLM、RAG 和 memory reader fallback
- 将 backend/memory 物理迁入 services/memory,私有实现收入 internal,保留 module/model/observe 作为 memory 服务门面
- 调整 memory outbox、memory reader 和 agent 记忆渲染链路的 import 与服务边界,cmd/memory 独占 memory worker / consumer
- 关闭 gateway 侧 agent outbox worker 所有权,agent relay / consumer 由 cmd/agent 独占,gateway 仅保留 HTTP/SSE 门面与迁移期开关回退
- 更新阶段 6 文档,记录 agent / memory 当前切流点、smoke 结果,以及 backend/client 与 gateway/shared 的目录收口口径
This commit is contained in:
Losita
2026-05-05 19:31:39 +08:00
parent d7184b776b
commit 2a96f4c6f9
72 changed files with 2775 additions and 291 deletions

View File

@@ -0,0 +1,42 @@
syntax = "proto3";
package smartflow.agent;
option go_package = "github.com/LoveLosita/smartflow/backend/services/agent/rpc/pb";
service Agent {
rpc Ping(StatusResponse) returns (StatusResponse);
rpc Chat(ChatRequest) returns (stream ChatChunk);
rpc GetConversationMeta(JSONRequest) returns (JSONResponse);
rpc GetConversationList(JSONRequest) returns (JSONResponse);
rpc GetConversationTimeline(JSONRequest) returns (JSONResponse);
rpc GetSchedulePlanPreview(JSONRequest) returns (JSONResponse);
rpc GetContextStats(JSONRequest) returns (JSONResponse);
rpc SaveScheduleState(JSONRequest) returns (JSONResponse);
}
message ChatRequest {
string message = 1;
string thinking = 2;
string model = 3;
int32 user_id = 4;
string conversation_id = 5;
bytes extra_json = 6;
}
message ChatChunk {
string payload = 1;
bool done = 2;
bytes error_json = 3;
}
message StatusResponse {
}
message JSONRequest {
bytes payload_json = 1;
}
message JSONResponse {
bytes data_json = 1;
}

View File

@@ -0,0 +1,76 @@
package rpc
import (
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/respond"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
)
var errAgentServiceNotReady = errors.New("agent service dependency not initialized")
const agentErrorDomain = "smartflow.agent"
// grpcErrorFromServiceError 负责把 agent 内部错误转换为 gRPC status。
//
// 职责边界:
// 1. respond.Response 保留项目内部 status/info供 gateway 反解;
// 2. 未分类错误只暴露通用内部错误,详细信息留在服务日志;
// 3. 不在 RPC 层重判业务规则,业务语义仍由 agent/sv 决定。
func grpcErrorFromServiceError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return grpcErrorFromResponse(respond.ConversationNotFound)
}
var resp respond.Response
if errors.As(err, &resp) {
return grpcErrorFromResponse(resp)
}
log.Printf("agent rpc internal error: %v", err)
return status.Error(codes.Internal, "agent service internal error")
}
func grpcErrorFromResponse(resp respond.Response) error {
code := grpcCodeFromRespondStatus(resp.Status)
message := strings.TrimSpace(resp.Info)
if message == "" {
message = strings.TrimSpace(resp.Status)
}
st := status.New(code, message)
detail := &errdetails.ErrorInfo{
Domain: agentErrorDomain,
Reason: resp.Status,
Metadata: map[string]string{
"info": resp.Info,
},
}
withDetails, err := st.WithDetails(detail)
if err != nil {
return st.Err()
}
return withDetails.Err()
}
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
switch strings.TrimSpace(statusValue) {
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status,
respond.ErrUnauthorized.Status, respond.WrongTokenType.Status, respond.UserLoggedOut.Status:
return codes.Unauthenticated
case respond.ConversationNotFound.Status:
return codes.NotFound
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status,
respond.WrongUserID.Status, respond.MissingConversationID.Status:
return codes.InvalidArgument
}
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
return codes.Internal
}
return codes.InvalidArgument
}

View File

@@ -0,0 +1,256 @@
package rpc
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/services/agent/rpc/pb"
agentsv "github.com/LoveLosita/smartflow/backend/services/agent/sv"
agentcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/agent"
)
type Handler struct {
pb.UnimplementedAgentServer
svc *agentsv.AgentService
}
func NewHandler(svc *agentsv.AgentService) *Handler {
return &Handler{svc: svc}
}
// Ping 供调用方在启动期确认 agent zrpc 已可用。
func (h *Handler) Ping(ctx context.Context, req *pb.StatusResponse) (*pb.StatusResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
return &pb.StatusResponse{}, nil
}
// Chat 把 agent 内部 channel 输出适配为 gRPC server-stream。
//
// 职责边界:
// 1. RPC 层只负责协议转换,不改写 agent/sv 的图编排、工具调用和持久化语义;
// 2. AgentService 内部仍使用 channel 解耦节点输出,跨进程边界统一转换为 stream.Send
// 3. 业务错误通过 error_json chunk 传给 Gateway由 Gateway 保持原 SSE 错误体输出。
func (h *Handler) Chat(req *pb.ChatRequest, stream pb.Agent_ChatServer) error {
if err := h.ensureReady(req); err != nil {
return err
}
extra, err := decodeExtra(req.ExtraJson)
if err != nil {
return grpcErrorFromServiceError(respond.WrongParamType)
}
outChan, errChan := h.svc.AgentChat(
stream.Context(),
req.Message,
req.Thinking,
req.Model,
int(req.UserId),
req.ConversationId,
extra,
)
for outChan != nil || errChan != nil {
select {
case err, ok := <-errChan:
if !ok {
// 1. errChan 关闭表示当前没有更多异步错误;置 nil 后让 select 不再命中该分支。
// 2. 若继续读取已关闭 channel会形成忙等并拖慢长连接 stream。
errChan = nil
continue
}
if err == nil {
continue
}
errorJSON := buildStreamErrorJSON(err)
return stream.Send(&pb.ChatChunk{Done: true, ErrorJson: errorJSON})
case payload, ok := <-outChan:
if !ok {
outChan = nil
return stream.Send(&pb.ChatChunk{Done: true})
}
if err := stream.Send(&pb.ChatChunk{Payload: payload}); err != nil {
return err
}
if strings.TrimSpace(payload) == "[DONE]" {
// 1. AgentService 旧链路已经把 OpenAI 兼容的 [DONE] 当作普通 payload 推给前端。
// 2. RPC 层只负责跨进程透传;这里直接结束 stream避免 Gateway 再补一帧重复 [DONE]。
return nil
}
case <-stream.Context().Done():
return stream.Context().Err()
}
}
return stream.Send(&pb.ChatChunk{Done: true})
}
// GetConversationMeta 透传查询单个会话元信息。
//
// 职责边界:
// 1. RPC 层只负责 JSON 契约反序列化和响应序列化;
// 2. 会话归属、404 语义和 DTO 组装继续由 AgentService 决定;
// 3. Gateway 仍负责 HTTP query 绑定和最终响应包装。
func (h *Handler) GetConversationMeta(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.ConversationQueryRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
resp, err := h.svc.GetConversationMeta(ctx, payload.UserID, payload.ConversationID)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return jsonResponseFromPayload(resp)
}
// GetConversationList 透传查询当前用户会话列表。
func (h *Handler) GetConversationList(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.ConversationListRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
resp, err := h.svc.GetConversationList(ctx, payload.UserID, payload.Page, payload.PageSize, payload.Status)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return jsonResponseFromPayload(resp)
}
// GetConversationTimeline 透传查询会话时间线。
func (h *Handler) GetConversationTimeline(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.ConversationQueryRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
resp, err := h.svc.GetConversationTimeline(ctx, payload.UserID, payload.ConversationID)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return jsonResponseFromPayload(resp)
}
// GetSchedulePlanPreview 透传查询会话内排程预览。
func (h *Handler) GetSchedulePlanPreview(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.ConversationQueryRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
resp, err := h.svc.GetSchedulePlanPreview(ctx, payload.UserID, payload.ConversationID)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return jsonResponseFromPayload(resp)
}
// GetContextStats 透传查询会话上下文 token 统计 JSON。
func (h *Handler) GetContextStats(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.ConversationQueryRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
statsJSON, err := h.svc.GetContextStats(ctx, payload.UserID, payload.ConversationID)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.JSONResponse{DataJson: []byte(strings.TrimSpace(statsJSON))}, nil
}
// SaveScheduleState 透传保存会话内排程拖拽状态。
//
// 职责边界:
// 1. RPC 层只把跨进程契约转换为 AgentService 既有模型;
// 2. 快照读取、归属校验、坐标转换和 Redis 回写仍由 AgentService 完成;
// 3. 成功时返回空 JSON 响应Gateway 继续保持 data=null 的 HTTP 语义。
func (h *Handler) SaveScheduleState(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
var payload agentcontracts.SaveScheduleStateRequest
if err := h.decodeJSONRequest(req, &payload); err != nil {
return nil, err
}
if err := h.svc.SaveScheduleState(ctx, payload.UserID, payload.ConversationID, toModelScheduleStateItems(payload.Items)); err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.JSONResponse{}, nil
}
func (h *Handler) ensureReady(req any) error {
if h == nil || h.svc == nil {
return grpcErrorFromServiceError(errAgentServiceNotReady)
}
if req == nil {
return grpcErrorFromServiceError(respond.MissingParam)
}
return nil
}
func (h *Handler) decodeJSONRequest(req *pb.JSONRequest, out any) error {
if err := h.ensureReady(req); err != nil {
return err
}
if len(req.PayloadJson) == 0 {
return grpcErrorFromServiceError(respond.MissingParam)
}
if err := json.Unmarshal(req.PayloadJson, out); err != nil {
return grpcErrorFromServiceError(respond.WrongParamType)
}
return nil
}
func jsonResponseFromPayload(payload any) (*pb.JSONResponse, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.JSONResponse{DataJson: raw}, nil
}
func toModelScheduleStateItems(items []agentcontracts.SaveScheduleStatePlacedItem) []model.SaveScheduleStatePlacedItem {
if len(items) == 0 {
return nil
}
result := make([]model.SaveScheduleStatePlacedItem, 0, len(items))
for _, item := range items {
result = append(result, model.SaveScheduleStatePlacedItem{
TaskItemID: item.TaskItemID,
Week: item.Week,
DayOfWeek: item.DayOfWeek,
StartSection: item.StartSection,
EndSection: item.EndSection,
EmbedCourseEventID: item.EmbedCourseEventID,
})
}
return result
}
func decodeExtra(raw []byte) (map[string]any, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var extra map[string]any
if err := json.Unmarshal(raw, &extra); err != nil {
return nil, err
}
return extra, nil
}
func buildStreamErrorJSON(err error) []byte {
errorBody := map[string]any{
"message": err.Error(),
"type": "server_error",
}
var respErr respond.Response
if errors.As(err, &respErr) {
errorBody["code"] = respErr.Status
if respErr.Info != "" {
errorBody["message"] = respErr.Info
}
}
raw, marshalErr := json.Marshal(map[string]any{"error": errorBody})
if marshalErr != nil {
return []byte(`{"error":{"message":"agent stream error","type":"server_error"}}`)
}
return raw
}

View File

@@ -0,0 +1,68 @@
package pb
import proto "github.com/golang/protobuf/proto"
var _ = proto.Marshal
const _ = proto.ProtoPackageIsVersion3
type ChatRequest struct {
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
Thinking string `protobuf:"bytes,2,opt,name=thinking,proto3" json:"thinking,omitempty"`
Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"`
UserId int32 `protobuf:"varint,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
ConversationId string `protobuf:"bytes,5,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
ExtraJson []byte `protobuf:"bytes,6,opt,name=extra_json,json=extraJson,proto3" json:"extra_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *ChatRequest) Reset() { *m = ChatRequest{} }
func (m *ChatRequest) String() string { return proto.CompactTextString(m) }
func (*ChatRequest) ProtoMessage() {}
type ChatChunk struct {
Payload string `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
Done bool `protobuf:"varint,2,opt,name=done,proto3" json:"done,omitempty"`
ErrorJson []byte `protobuf:"bytes,3,opt,name=error_json,json=errorJson,proto3" json:"error_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *ChatChunk) Reset() { *m = ChatChunk{} }
func (m *ChatChunk) String() string { return proto.CompactTextString(m) }
func (*ChatChunk) ProtoMessage() {}
type StatusResponse struct {
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatusResponse) Reset() { *m = StatusResponse{} }
func (m *StatusResponse) String() string { return proto.CompactTextString(m) }
func (*StatusResponse) ProtoMessage() {}
type JSONRequest struct {
PayloadJson []byte `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *JSONRequest) Reset() { *m = JSONRequest{} }
func (m *JSONRequest) String() string { return proto.CompactTextString(m) }
func (*JSONRequest) ProtoMessage() {}
type JSONResponse struct {
DataJson []byte `protobuf:"bytes,1,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *JSONResponse) Reset() { *m = JSONResponse{} }
func (m *JSONResponse) String() string { return proto.CompactTextString(m) }
func (*JSONResponse) ProtoMessage() {}

View File

@@ -0,0 +1,313 @@
package pb
import (
context "context"
io "io"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
const (
Agent_Ping_FullMethodName = "/smartflow.agent.Agent/Ping"
Agent_Chat_FullMethodName = "/smartflow.agent.Agent/Chat"
Agent_GetConversationMeta_FullMethodName = "/smartflow.agent.Agent/GetConversationMeta"
Agent_GetConversationList_FullMethodName = "/smartflow.agent.Agent/GetConversationList"
Agent_GetConversationTimeline_FullMethodName = "/smartflow.agent.Agent/GetConversationTimeline"
Agent_GetSchedulePlanPreview_FullMethodName = "/smartflow.agent.Agent/GetSchedulePlanPreview"
Agent_GetContextStats_FullMethodName = "/smartflow.agent.Agent/GetContextStats"
Agent_SaveScheduleState_FullMethodName = "/smartflow.agent.Agent/SaveScheduleState"
)
type AgentClient interface {
Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error)
Chat(ctx context.Context, in *ChatRequest, opts ...grpc.CallOption) (Agent_ChatClient, error)
GetConversationMeta(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetConversationList(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetConversationTimeline(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetSchedulePlanPreview(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetContextStats(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
SaveScheduleState(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
}
type agentClient struct {
cc grpc.ClientConnInterface
}
func NewAgentClient(cc grpc.ClientConnInterface) AgentClient {
return &agentClient{cc}
}
func (c *agentClient) Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error) {
out := new(StatusResponse)
err := c.cc.Invoke(ctx, Agent_Ping_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) Chat(ctx context.Context, in *ChatRequest, opts ...grpc.CallOption) (Agent_ChatClient, error) {
stream, err := c.cc.NewStream(ctx, &Agent_ServiceDesc.Streams[0], Agent_Chat_FullMethodName, opts...)
if err != nil {
return nil, err
}
client := &agentChatClient{stream}
if err := client.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := client.ClientStream.CloseSend(); err != nil {
return nil, err
}
return client, nil
}
func (c *agentClient) GetConversationMeta(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_GetConversationMeta_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) GetConversationList(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_GetConversationList_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) GetConversationTimeline(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_GetConversationTimeline_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) GetSchedulePlanPreview(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_GetSchedulePlanPreview_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) GetContextStats(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_GetContextStats_FullMethodName, in, out, opts...)
return out, err
}
func (c *agentClient) SaveScheduleState(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Agent_SaveScheduleState_FullMethodName, in, out, opts...)
return out, err
}
type Agent_ChatClient interface {
Recv() (*ChatChunk, error)
grpc.ClientStream
}
type agentChatClient struct {
grpc.ClientStream
}
func (x *agentChatClient) Recv() (*ChatChunk, error) {
m := new(ChatChunk)
if err := x.ClientStream.RecvMsg(m); err != nil {
if err == io.EOF {
return nil, err
}
return nil, err
}
return m, nil
}
type AgentServer interface {
Ping(context.Context, *StatusResponse) (*StatusResponse, error)
Chat(*ChatRequest, Agent_ChatServer) error
GetConversationMeta(context.Context, *JSONRequest) (*JSONResponse, error)
GetConversationList(context.Context, *JSONRequest) (*JSONResponse, error)
GetConversationTimeline(context.Context, *JSONRequest) (*JSONResponse, error)
GetSchedulePlanPreview(context.Context, *JSONRequest) (*JSONResponse, error)
GetContextStats(context.Context, *JSONRequest) (*JSONResponse, error)
SaveScheduleState(context.Context, *JSONRequest) (*JSONResponse, error)
}
type UnimplementedAgentServer struct{}
func (UnimplementedAgentServer) Ping(context.Context, *StatusResponse) (*StatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedAgentServer) Chat(*ChatRequest, Agent_ChatServer) error {
return status.Errorf(codes.Unimplemented, "method Chat not implemented")
}
func (UnimplementedAgentServer) GetConversationMeta(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConversationMeta not implemented")
}
func (UnimplementedAgentServer) GetConversationList(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConversationList not implemented")
}
func (UnimplementedAgentServer) GetConversationTimeline(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConversationTimeline not implemented")
}
func (UnimplementedAgentServer) GetSchedulePlanPreview(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetSchedulePlanPreview not implemented")
}
func (UnimplementedAgentServer) GetContextStats(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetContextStats not implemented")
}
func (UnimplementedAgentServer) SaveScheduleState(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SaveScheduleState not implemented")
}
func RegisterAgentServer(s grpc.ServiceRegistrar, srv AgentServer) {
s.RegisterService(&Agent_ServiceDesc, srv)
}
type Agent_ChatServer interface {
Send(*ChatChunk) error
grpc.ServerStream
}
type agentChatServer struct {
grpc.ServerStream
}
func (x *agentChatServer) Send(m *ChatChunk) error {
return x.ServerStream.SendMsg(m)
}
func _Agent_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StatusResponse)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).Ping(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Agent_Ping_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).Ping(ctx, req.(*StatusResponse))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_GetConversationMeta_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).GetConversationMeta(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_GetConversationMeta_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).GetConversationMeta(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_GetConversationList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).GetConversationList(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_GetConversationList_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).GetConversationList(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_GetConversationTimeline_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).GetConversationTimeline(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_GetConversationTimeline_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).GetConversationTimeline(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_GetSchedulePlanPreview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).GetSchedulePlanPreview(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_GetSchedulePlanPreview_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).GetSchedulePlanPreview(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_GetContextStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).GetContextStats(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_GetContextStats_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).GetContextStats(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_SaveScheduleState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServer).SaveScheduleState(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Agent_SaveScheduleState_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServer).SaveScheduleState(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Agent_Chat_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ChatRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(AgentServer).Chat(m, &agentChatServer{stream})
}
var Agent_ServiceDesc = grpc.ServiceDesc{
ServiceName: "smartflow.agent.Agent",
HandlerType: (*AgentServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Ping", Handler: _Agent_Ping_Handler},
{MethodName: "GetConversationMeta", Handler: _Agent_GetConversationMeta_Handler},
{MethodName: "GetConversationList", Handler: _Agent_GetConversationList_Handler},
{MethodName: "GetConversationTimeline", Handler: _Agent_GetConversationTimeline_Handler},
{MethodName: "GetSchedulePlanPreview", Handler: _Agent_GetSchedulePlanPreview_Handler},
{MethodName: "GetContextStats", Handler: _Agent_GetContextStats_Handler},
{MethodName: "SaveScheduleState", Handler: _Agent_SaveScheduleState_Handler},
},
Streams: []grpc.StreamDesc{
{StreamName: "Chat", Handler: _Agent_Chat_Handler, ServerStreams: true},
},
Metadata: "services/agent/rpc/agent.proto",
}

View File

@@ -0,0 +1,60 @@
package rpc
import (
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/agent/rpc/pb"
agentsv "github.com/LoveLosita/smartflow/backend/services/agent/sv"
"github.com/zeromicro/go-zero/core/service"
"github.com/zeromicro/go-zero/zrpc"
"google.golang.org/grpc"
)
const (
defaultListenOn = "0.0.0.0:9089"
defaultTimeout = 0
)
type ServerOptions struct {
ListenOn string
Timeout time.Duration
Service *agentsv.AgentService
}
// NewServer 创建 agent zrpc 服务端。
//
// 职责边界:
// 1. 只负责 zrpc server 配置与 gRPC handler 注册;
// 2. 不创建数据库、Redis、LLM 或业务服务,它们由 cmd/agent 管理;
// 3. Chat 是长连接 server-stream默认不设置 RPC timeout避免截断 SSE 转发。
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
if opts.Service == nil {
return nil, "", errors.New("agent service dependency not initialized")
}
listenOn := strings.TrimSpace(opts.ListenOn)
if listenOn == "" {
listenOn = defaultListenOn
}
timeout := opts.Timeout
if timeout < 0 {
timeout = defaultTimeout
}
server, err := zrpc.NewServer(zrpc.RpcServerConf{
ServiceConf: service.ServiceConf{
Name: "agent.rpc",
Mode: service.DevMode,
},
ListenOn: listenOn,
Timeout: int64(timeout / time.Millisecond),
}, func(grpcServer *grpc.Server) {
pb.RegisterAgentServer(grpcServer, NewHandler(opts.Service))
})
if err != nil {
return nil, "", err
}
return server, listenOn, nil
}

View File

@@ -12,8 +12,6 @@ import (
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
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/pkg"
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
@@ -21,6 +19,8 @@ import (
agentprompt "github.com/LoveLosita/smartflow/backend/services/agent/prompt"
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
)

View File

@@ -6,9 +6,9 @@ import (
"strings"
"time"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
)
const (

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// renderMemoryPinnedContentByMode 根据配置选择记忆渲染方式。

View File

@@ -4,8 +4,8 @@ import (
"context"
"errors"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
memorycontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/memory"
)

View File

@@ -0,0 +1,76 @@
# Memory 模块现状说明
## 当前已打通的链路
1. 用户消息落聊天历史时,会通过 outbox 发布 `memory.extract.requested`
2. 事件消费者只负责把请求幂等写入 `memory_jobs`,不在消费回调里做重 LLM 计算。
3. 启动期会拉起 `memory worker`,后台轮询 `memory_jobs`
4. worker 抢占任务后,调用 `backend/infra/llm` 驱动的记忆抽取编排器。
5. 抽取结果会被标准化后写入 `memory_items`,同时写入 `memory_audit_logs`
6. 全部落库成功后,任务状态推进到 `success`;失败则走可重试状态机。
## 当前目录职责
- `module.go`:对外统一门面,负责组装 repo / service / worker / orchestrator。
- `model/`:记忆模块 DTO、状态常量、配置对象。
- `repo/``memory_jobs / memory_items / memory_audit_logs / memory_user_settings` 访问层。
- `service/`:任务入队、读取重排、管理维护、配置加载。
- `orchestrator/`:记忆抽取编排。
- `write_orchestrator.go` 是纯本地 fallback。
- `llm_write_orchestrator.go` 是当前主用的 LLM 抽取器。
- `worker/`:任务执行器与后台轮询循环。
- `utils/`JSON 提取、候选事实标准化、设置过滤、审计构造等纯函数工具。
## 当前已补齐的内部能力
1. `Module`
- 负责把 repo / service / worker / orchestrator 组装成统一门面。
- 外部现在优先依赖 `memory.Module`,而不是自己手搓内部组件。
- 支持 `WithTx(tx)`,方便接入现有统一事务管理器。
2. `EnqueueService`
- 负责把 `memory.extract.requested` 事件转成 `memory_jobs`,不做重 LLM 计算。
3. `Runner + RunPollingLoop`
- 负责后台轮询任务、调用抽取器、写入 `memory_items`、补写 `memory_audit_logs`
4. `ReadService`
- 负责在 memory 内部做“按用户开关过滤 + 轻量重排 + 访问时间刷新”。
- 当前还没有接到 `newAgent` prompt 注入侧,这是刻意保留的切流点。
5. `ManageService`
- 负责记忆管理面能力:列出记忆、软删除记忆、读取/更新用户记忆开关。
- 删除动作会同步写入审计日志,保证“有变更就有审计”。
## 当前推荐接入姿势
1. 启动阶段统一创建:
- `memoryModule := memory.NewModule(db, llmClient, memory.LoadConfigFromViper())`
2. 后台 worker 启动:
- `memoryModule.StartWorker(ctx)`
3. 事务内写入记忆任务:
- `memoryModule.WithTx(tx).EnqueueExtract(ctx, payload, eventID)`
4. 后续 agent 读取:
- 直接调用 `memoryModule.Retrieve(...)`
## 当前实现边界
1. 已实现异步写入链路,也已补齐 memory 内部读取与管理能力,但还没有接“读取召回 + prompt 注入”。
2. 已实现 MySQL 事实落库,但还没有接 Milvus 向量同步。
3. 已实现 LLM 抽取和基础审计日志,但还没有做 `ADD/UPDATE/DELETE/NONE` 决策型冲突消解。
4. 当前更偏“先把 memory 自己的闭环打通”,后续再继续做 agent 注入、向量检索和冲突更新。
## 当前推荐验证方式
1. 发起一条用户消息,确认 outbox 中生成 `memory.extract.requested`
2. 等待事件消费后,确认 `memory_jobs` 出现 `pending` 或被 worker 抢占为 `processing`
3. 等待后台 worker 执行后,确认:
- `memory_jobs.status = success`
- `memory_items` 出现新记忆
- `memory_audit_logs` 出现对应 `create` 记录
4. 直接调用 `ManageService`
- `ListItems` 能列出 active/archived 记忆
- `DeleteItem` 会把状态改成 `deleted`,并新增一条 `delete` 审计
- `GetUserSetting / UpsertUserSetting` 能返回并更新用户记忆开关
## 下一步建议
1.`ReadService` 接进 `newAgent`,先注入“偏好 / 约束 / 最近 todo_hint”三类高价值记忆。
2. 引入向量召回与 rerank把“当前话题相关的事实类记忆”补进候选集合。
3. 再补 `ADD/UPDATE/DELETE/NONE` 决策,解决“同义记忆去重”和“旧记忆更新”。

View File

@@ -0,0 +1,639 @@
# Memory 向 Mem0 靠拢三步冲刺计划newAgent
## 1. 一句话结论
当前 `memory` 已经具备了“可异步写入、可基础抽取、可基础检索、可注入 newAgent”的骨架但距离真正有 Mem0 味道的记忆系统,还差三块核心能力:
1. 写入侧没有“先召回旧记忆,再做 `ADD/UPDATE/DELETE/NONE` 决策”的治理层。
2. 读侧没有把“硬约束优先、语义召回补充、结果去重、注入预算”做成稳定链路。
3. 系统层没有形成“可灰度、可解释、可清理、可回滚”的治理闭环。
因此建议按三步走推进,并严格遵守一个原则:
1. 每一轮只处理一个能力域。
2. 第一步只动写入决策层。
3. 第二步只动读链路与注入质量。
4. 第三步只动治理、清理、指标与切流收口。
---
## 2. 本文档给谁看
本文档面向三类读者:
1. 需要继续实现 `memory/newAgent` 的 agent。
2. 需要拆任务、排优先级的人。
3. 需要快速判断“本轮该改什么、不该改什么”的维护者。
本文档不是背景介绍文档,而是“可直接拿去拆工单和接力开发”的冲刺说明。
---
## 3. 当前现状与目标差距
### 3.1 当前已完成的部分
当前已经有的能力:
1. 聊天消息可通过 `outbox -> memory.extract.requested -> memory_jobs -> worker` 进入异步记忆链路。
2. Worker 可调用 LLM 做事实抽取,并通过 `NormalizeFacts` 做批内标准化和批内去重。
3. `memory_items / memory_jobs / memory_audit_logs / memory_user_settings` 四张核心表已经建立并接线。
4. `ReadService` 已可做基础查询与轻量排序。
5. `newAgent` 已通过 `injectMemoryContext` 把记忆写入 pinned block。
6. 用户设置、删除、审计已经具备基础治理能力。
### 3.2 当前离 Mem0 还差什么
最关键的差距如下:
| 能力 | 当前状态 | 与 Mem0 的差距 |
| --- | --- | --- |
| 异步入队 | 已完成 | 基本到位 |
| 抽取候选事实 | 已完成 | 缺少更强的抽取后治理 |
| 批内去重 | 已完成 | 仅限单批,不处理历史记忆 |
| 历史去重 | 未完成 | 需要按旧记忆召回后做决策 |
| `ADD/UPDATE/DELETE/NONE` 决策 | 未完成 | 这是最关键差距 |
| 语义召回 | 部分完成 | 接口有了,质量与稳定性未形成闭环 |
| 读侧去重 | 未完成 | 现在更多是展示层弱去重 |
| Prompt 注入 | 基础版已接 | 还没有类型分层与预算控制 |
| 管理治理 | 部分完成 | 还缺更新、恢复、历史清理、指标闭环 |
| 灰度/回滚 | 较弱 | 需要细粒度 feature flag 与分阶段切流 |
### 3.3 本次冲刺的目标定义
本轮不是要把项目做成完整 Mem0也不是做图记忆或多 Provider 平台而是要做到一个“Mem0-lite 可自信上线”的状态。满足以下条件,就可以认为基本靠近目标:
1. 相同或同义记忆不会无脑越写越多。
2. 用户纠正一条旧记忆时,系统更倾向于更新旧值,而不是新增一条冲突值。
3. 读侧能优先拿到“硬约束 + 偏好 + 当前话题相关事实”,而不是仅按最近更新时间胡乱注入。
4. Prompt 注入是稳定、可控、可解释的,而不是纯拼接。
5. 出问题时可以快速关掉某一层能力,而不是整条 memory 链路一起陪葬。
---
## 4. 设计原则与边界
### 4.1 每轮只处理一个能力域
为避免回归问题无法定位,本计划明确规定:
1. 第一步只处理“写入决策层”。
2. 第二步只处理“读取与注入层”。
3. 第三步只处理“治理、清理与切流层”。
禁止在同一轮里同时大改:
1. `memory` 写入逻辑。
2. `newAgent` 图节点结构。
3. WebSearch / 其他 RAG 语料。
4. 多个 prompt 体系。
### 4.2 保留旧实现,走并行迁移
整个冲刺必须遵守并行迁移策略:
1. 旧的“抽取后直接 `Create`”路径先保留。
2. 新的“决策后 ApplyAction”路径并行落地。
3. 用 feature flag 灰度切流。
4. 验证通过后,再决定是否删除旧路径。
### 4.3 不新增“memory 工具化”这条支线
本轮不建议把 `memory` 改成一个显式工具让 `newAgent` 主动调用,原因如下:
1. 当前 `pinned block` 已经接入主链路,切点稳定。
2. 本轮目标是让记忆“更准”,不是让图结构更复杂。
3. 若同时引入工具化调用,会把“写入决策层”和“图编排层”耦到一起。
因此本轮默认继续沿用:
1. `backend/memory/service/read_service.go`
2. `backend/service/agentsvc/agent_memory.go`
3. `pinned block` 注入
---
## 5. 三步走总览
| 步骤 | 只处理的能力域 | 核心目标 | 旧实现是否保留 |
| --- | --- | --- | --- |
| 第一步 | 写入决策层 | 把“抽取即新增”升级为“召回旧记忆 + 决策动作” | 保留 |
| 第二步 | 读链路与注入层 | 把“查到就拼”升级为“硬约束优先 + 语义补充 + 注入预算” | 保留 |
| 第三步 | 治理与切流层 | 把“能跑”升级为“可灰度、可观测、可清理、可回滚” | 收口 |
---
## 6. 第一步:先把写入侧做成 Mem0-lite
### 6.1 这一步解决什么问题
当前写入链路本质上还是:
`抽取 -> 标准化 -> 直接写 memory_items`
这会带来三个直接问题:
1. 历史同义记忆不会合并。
2. 用户纠正旧记忆时,系统更可能新增一条相反记忆。
3. `content_hash` 现在更多只是存了个字段,没有真正承担“历史治理”的职责。
第一步的目标是把写入链路升级为:
`抽取 -> 召回旧记忆候选 -> 临时 ID 映射 -> LLM 决策 -> ApplyAction`
### 6.2 本轮要落的能力
第一步必须落地以下能力:
1. 为每条新候选 fact 召回有限个旧记忆候选。
2. 用临时整数 ID 或候选序号喂给决策模型,避免模型直接编造真实 `memory_id`
3. 让模型只输出结构化 JSON 决策:`ADD/UPDATE/DELETE/NONE`
4. 后端严格校验决策合法性,再执行数据库动作。
5. `UPDATE/DELETE` 也必须补齐审计日志,而不是只有 `create/delete`
### 6.3 推荐的文件落点
建议新增文件:
1. `backend/memory/model/decision.go`
- 定义决策 DTO、候选旧记忆 DTO、ApplyAction DTO。
2. `backend/memory/orchestrator/llm_decision_orchestrator.go`
- 负责“给定新 fact + 旧候选 -> 输出结构化动作决策”。
3. `backend/memory/utils/decision_id_map.go`
- 负责“真实 memory_id <-> 临时决策 ID”的映射。
4. `backend/memory/utils/decision_validate.go`
- 负责校验动作是否合法、目标 ID 是否存在、动作字段是否完整。
5. `backend/memory/worker/decision_flow.go`
- 负责 worker 内的“候选召回 -> 决策 -> 动作执行编排”。
6. `backend/memory/worker/apply_actions.go`
- 负责把 `ADD/UPDATE/DELETE/NONE` 落为数据库动作与审计。
建议修改文件:
1. `backend/memory/model/config.go`
2. `backend/memory/service/config_loader.go`
3. `backend/memory/repo/item_repo.go`
4. `backend/memory/worker/runner.go`
5. `backend/memory/utils/audit.go`
### 6.4 推荐新增配置
建议新增配置项,全部走 `memory` 命名空间:
1. `memory.decision.enabled`
- 是否启用决策层。
2. `memory.decision.candidateTopK`
- 每个新 fact 召回多少个旧记忆候选。
3. `memory.decision.fallbackMode`
- 建议支持 `legacy_add` / `drop` 两种模式。
4. `memory.write.mode`
- 建议支持 `legacy` / `decision` 两种模式。
建议默认值:
1. `memory.decision.enabled=false`
2. `memory.write.mode=legacy`
3. `memory.decision.candidateTopK=5`
4. `memory.decision.fallbackMode=legacy_add`
### 6.5 `ItemRepo` 需要补的能力
当前 `ItemRepo` 只有“查、建、删状态、刷访问时间、刷向量状态”,还不够支撑决策动作。第一步至少要补以下能力:
1. `FindDecisionCandidates(...)`
-`user_id + assistant_id + conversation_id + run_id + memory_type` 查候选。
- 当 RAG 可用时,可优先用向量召回补候选。
2. `UpdateContentByID(...)`
- 用于 `UPDATE`
- 至少要更新:`title/content/normalized_content/content_hash/confidence/importance/sensitivity_level/is_explicit/updated_at`
3. `SoftDeleteByID(...)`
- 用于决策型 `DELETE`
4. `FindActiveByHash(...)`
- 给兜底幂等或低成本重复检测预留接口。
注意:
1. 不要把这些逻辑继续堆进 `UpsertItems`
2. `UpsertItems` 可以暂时保留给 legacy 路径使用。
3. 新路径应尽量使用显式动作函数,而不是一个“万能 Upsert”。
### 6.6 Worker 内推荐的执行顺序
对每个 job建议执行以下顺序
1. 先抽取新事实。
2. 对抽取结果做 `NormalizeFacts`
3. 按用户设置过滤。
4.`memory.decision.enabled=false`,直接走旧路径并返回。
5. 对每条新 fact 召回旧候选:
- 先查强约束域内候选。
-`memory.rag.enabled=true`,再用 RAG 补充语义候选。
6. 对候选做临时 ID 映射。
7.`LLMDecisionOrchestrator` 输出动作。
8. 后端校验动作合法性。
9. 执行动作:
- `ADD`:创建 item + `create` audit
- `UPDATE`:更新旧 item + `update` audit
- `DELETE`:软删除旧 item + `delete` audit
- `NONE`:只记日志,不动表
10. 根据动作决定是否做向量同步:
- `ADD`:新增向量
- `UPDATE`:重写向量
- `DELETE`:删向量或打 pending 删除标记
### 6.7 决策 Prompt 的建议约束
决策 prompt 需要非常收敛,建议只允许模型做一件事:
1. 给定一条新 fact。
2. 给定少量旧候选。
3.`ADD/UPDATE/DELETE/NONE` 中选一个动作。
不建议第一版就让模型:
1. 一次同时处理多条新 fact 与多条旧事实的复杂批量决策。
2. 自己生成复杂的替代文案策略。
3. 自己修改 scope 或元数据。
推荐第一版输出结构大致为:
```json
{
"decisions": [
{
"candidate_index": 0,
"action": "UPDATE",
"target_temp_id": 2,
"title": "更新后的标题",
"content": "更新后的内容",
"reason": "新事实是在纠正旧事实"
}
]
}
```
### 6.8 这一步的验收标准
满足以下条件,可认为第一步完成:
1. 重复表达同一偏好,不会连续生成多条 `active` 记忆。
2. 用户显式纠正旧偏好时,会更倾向触发 `UPDATE`,而不是再新增一条冲突记忆。
3. `memory_audit_logs` 能明确区分 `create/update/delete`
4. 决策层失败时,不会阻断原有 legacy 链路。
5. 关闭 `memory.decision.enabled` 后,系统行为可完全回到当前实现。
### 6.9 这一步的回滚点
第一步必须保留明确回滚点:
1. 关闭 `memory.decision.enabled`
2. `memory.write.mode` 切回 `legacy`
回滚后仍然使用:
1. `LLMWriteOrchestrator.ExtractFacts`
2. `NormalizeFacts`
3. `buildMemoryItems`
4. `ItemRepo.UpsertItems`
### 6.10 这一步明确不做什么
第一步不要顺手做以下事情:
1. 不重构 `newAgent` 图节点。
2. 不引入 memory 工具调用。
3. 不做图记忆。
4. 不做用户侧“编辑记忆内容”的管理 API。
5. 不同时改 WebSearch 的 RAG 链路。
---
## 7. 第二步:把读取与注入做成真正可用的记忆链路
### 7.1 这一步解决什么问题
写入侧即使更聪明,如果读出来的还是“按分数凑五条,再平铺给 prompt”整体体验依然不会像 Mem0。
第二步要解决的问题是:
1. 硬约束和偏好不能被普通事实挤掉。
2. 历史重复项不能继续在读侧污染 TopK。
3. 注入给模型的文本需要可控,而不是简单平铺。
4. RAG 可用时要真正成为加分项,不可用时要稳定降级。
### 7.2 本轮要落的能力
第二步必须落地以下能力:
1. 读侧合并“结构化强约束召回”和“语义候选召回”。
2. 读侧在服务层做真正的去重,而不是只在渲染字符串时弱去重。
3. 注入文本按类型分组,而不是所有内容同一层级平铺。
4. 给每一类记忆设置注入预算,避免事实类把 prompt 撑爆。
### 7.3 推荐的文件落点
建议优先修改文件:
1. `backend/memory/service/read_service.go`
2. `backend/memory/repo/item_repo.go`
3. `backend/service/agentsvc/agent_memory.go`
如需补辅助文件,建议新增:
1. `backend/memory/service/retrieve_merge.go`
- 负责多路召回的结果合并、去重、预算裁剪。
2. `backend/memory/service/retrieve_rank.go`
- 负责重排与门控。
3. `backend/service/agentsvc/agent_memory_render.go`
- 负责把 memory DTO 渲染成稳定的注入 block。
说明:
1. 当前 `agent_memory.go` 已经不算小。
2. 第二步不要继续往单文件里堆“召回策略 + 去重 + 渲染模板”。
3. 这一轮拆开渲染层是合理的职责拆分,不属于跨能力域大重构。
### 7.4 读取侧推荐的新流程
建议读侧升级为以下顺序:
1. 先从 MySQL 拉“必守约束”:
- `constraint`
- 高置信度 `preference`
2. 再按当前 query 做相关召回:
-`memory.rag.enabled=true`,优先走 RAG
- 否则走 legacy DB 排序
3. 合并两路结果。
4. 先按 `memory_id` 去重。
5. 再按 `content_hash` 去重。
6. 最后才按渲染文本兜底去重。
7. 对结果做类型预算:
- `constraint`:优先保留
- `preference`:次优先
- `todo_hint`:控制数量
- `fact`:最容易膨胀,要严格限额
### 7.5 注入层推荐的渲染方式
当前渲染方式更像“扁平清单”。第二步建议升级成“分段注入”,例如:
1. 必守约束
2. 用户偏好
3. 当前话题相关事实
4. 近期线索
推荐生成类似文本:
```text
以下是与当前对话相关的用户记忆,仅在确实有帮助时参考,不要机械复述。
【必守约束】
- 用户点外卖不要香菜。
【用户偏好】
- 用户偏爱黑咖啡。
【当前话题相关事实】
- 用户最近在准备周四的程序设计作业。
```
这样做的好处:
1. 模型更容易区分“必须遵守”和“仅可参考”。
2. 日后更容易按类型做 budget。
3. 若发生错误注入,也更容易解释是哪一层出错。
### 7.6 第二步建议新增配置
建议新增:
1. `memory.read.mode`
- 建议支持 `legacy` / `hybrid`
2. `memory.read.factLimit`
3. `memory.read.preferenceLimit`
4. `memory.read.constraintLimit`
5. `memory.inject.renderMode`
- 建议支持 `flat` / `typed_v2`
建议默认值:
1. `memory.read.mode=legacy`
2. `memory.inject.renderMode=flat`
灰度时再逐步切到:
1. `memory.read.mode=hybrid`
2. `memory.inject.renderMode=typed_v2`
### 7.7 这一步的验收标准
满足以下条件,可认为第二步完成:
1. 同一条重复记忆即使数据库里有多条,最终注入给 prompt 也只保留一条。
2. `constraint` 类记忆不会轻易被 `fact` 类挤出注入集合。
3. RAG 异常时,系统仍能稳定退回 legacy 读取逻辑。
4. 注入文本结构清晰,且总长度稳定,不会一轮长一轮短。
5. newAgent 的 `pinned block` 内容更可读、更可解释。
### 7.8 这一步的回滚点
第二步必须支持快速回滚:
1. `memory.read.mode=legacy`
2. `memory.inject.renderMode=flat`
3. `memory.rag.enabled=false`
回滚后保留:
1. 旧的 `ReadService.retrieveByLegacy`
2. 当前 `agent_memory.go` 扁平渲染逻辑
### 7.9 这一步明确不做什么
第二步不要顺手做以下事情:
1. 不把 memory 改造成工具调用。
2. 不改 `newAgent` 的图路由结构。
3. 不把 WebSearch 一起并进统一召回。
4. 不在这一轮清理历史重复脏数据。
---
## 8. 第三步:做治理、清理、指标与切流收口
### 8.1 这一步解决什么问题
前两步做完后,系统可能“效果已经不错”,但仍缺三个上线必须项:
1. 出问题时怎么知道错在哪一层。
2. 历史已经写进去的重复脏数据怎么治理。
3. 什么时候能把 legacy 路径关掉。
第三步就是收口这一层。
### 8.2 本轮要落的能力
第三步建议至少做以下能力:
1. 为写入决策、读取召回、注入渲染补齐结构化日志和指标。
2. 增加历史重复清理能力。
3. 补齐“我的记忆”增删改查语义,以及必要的最小变更留痕。
4. 明确 feature flag 切流策略与回滚手册。
5. 更新文档,避免后续维护者只看到旧 README。
### 8.3 推荐的文件落点
建议修改文件:
1. `backend/memory/utils/audit.go`
2. `backend/memory/service/manage_service.go`
3. `backend/memory/repo/item_repo.go`
4. `backend/memory/README.md`
5. `backend/memory/记忆模块实施计划.md`
建议新增文件:
1. `backend/memory/cleanup/dedup_runner.go`
- 用于历史重复治理。
2. `backend/memory/cleanup/dedup_policy.go`
- 负责定义“保留哪条、归档哪条”。
3. `backend/memory/observe/log_fields.go`
- 统一日志字段,避免不同文件各写各的。
### 8.4 历史数据清理建议
建议不要直接写危险 SQL 一把梭清表,而是通过可留痕的治理流程清理历史脏数据:
1.`user_id + memory_type + content_hash + status=active` 扫描重复组。
2. 为每组挑一个保留主记录:
- 优先保留最近更新
- 或优先保留置信度更高
3. 其余重复项改为 `archived``deleted`
4. 对每次治理动作写最小变更留痕。
建议第一版优先做“离线治理工具”或“手动触发 job”不要直接绑到主 worker 周期任务里。
### 8.5 建议补的指标
第三步建议至少打这些指标:
1. `memory_job_success_rate`
2. `memory_job_retry_rate`
3. `memory_decision_distribution`
4. `memory_decision_fallback_rate`
5. `memory_retrieve_hit_count`
6. `memory_retrieve_dedup_drop_count`
7. `memory_inject_item_count`
8. `memory_rag_fallback_rate`
9. `memory_wrong_mention_rate`
10. `memory_user_correction_rate`
其中前八项可以本轮先落,后两项可通过后续用户纠正入口接入。
### 8.6 建议的切流顺序
第三步不要“一刀切”。建议按以下顺序灰度:
1. 阶段 A决策层 shadow 模式
- 真正写库仍走 legacy
- 新决策层只做日志,不生效
2. 阶段 B决策层仅对显式记忆生效
3. 阶段 C决策层对全部写入生效
4. 阶段 D读侧切到 hybrid
5. 阶段 E注入切到 typed_v2
6. 阶段 F历史清理跑完再考虑关闭 legacy 默认路径
### 8.7 这一步的验收标准
满足以下条件,可认为第三步完成:
1. 能从日志看清某条记忆为何被 `ADD/UPDATE/DELETE/NONE`
2. 能从指标看清读侧命中、去重、降级、回滚情况。
3. 能对历史重复数据做可留痕清理。
4. 出现异常时可在分钟级通过开关退回 legacy。
5. 文档与代码现状一致,不再依赖口头传递。
### 8.8 这一步的回滚点
第三步的回滚不应影响前两步代码保留,只需回切开关:
1. 决策层回到 `legacy`
2. 读侧回到 `legacy`
3. 注入渲染回到 `flat`
4. 停掉清理任务
### 8.9 这一步明确不做什么
第三步仍然不建议同时做以下事情:
1. 不做图记忆。
2. 不做多 Provider 工厂化。
3. 不拆独立 memory 服务。
4. 不把 WebSearch 与 Memory 强行合并到同一轮上线。
---
## 9. 推荐的三轮交付顺序
如果资源有限,建议严格按下面顺序推进:
1. 先做第一步。
- 原因:写侧如果还是“抽取即新增”,读侧再怎么优化也会越来越脏。
2. 再做第二步。
- 原因:写侧稳定后,读侧才能真正体现效果。
3. 最后做第三步。
- 原因:治理、指标、清理要建立在前两步能力已经基本成形的前提下。
一句话总结:
1. 先让系统“会整理记忆”。
2. 再让系统“会正确读记忆”。
3. 最后让系统“可稳定上线和维护”。
---
## 10. 建议的任务拆分方式
如果后续要多人并行,建议按职责边界拆,而不是按文件随意拆:
### 10.1 第一步可拆为两块
1. 决策模型与编排
- `decision.go`
- `llm_decision_orchestrator.go`
- `decision_validate.go`
2. Repo 与动作执行
- `item_repo.go`
- `apply_actions.go`
- `audit.go`
### 10.2 第二步可拆为两块
1. 读侧召回与合并
- `read_service.go`
- `retrieve_merge.go`
- `retrieve_rank.go`
2. newAgent 注入渲染
- `agent_memory.go`
- `agent_memory_render.go`
### 10.3 第三步可拆为两块
1. 治理与清理
- `dedup_runner.go`
- `manage_service.go`
2. 观测与文档
- 指标日志
- README / 计划文档更新
---
## 11. 如果只看一个结论,请看这里
要让当前 memory 真正靠近 Mem0不是再加一张表也不是再加一个 prompt而是要完成以下收敛
1. 写入侧从“抽到就加”升级为“先回看旧记忆,再决定加改删不做”。
2. 读侧从“查到就拼”升级为“硬约束优先、语义补充、结果去重、预算注入”。
3. 系统侧从“能跑”升级为“有灰度、有指标、有清理、有回滚”。
只要三步按这个顺序推进,最终得到的就不是一个“会不断积灰的记忆表”,而是一套真正能为 `newAgent` 服务的记忆系统。

View File

@@ -0,0 +1,78 @@
GOROOT=C:\Program Files\Go #gosetup
GOPATH=C:\Users\Dev\go #gosetup
"C:\Program Files\Go\bin\go.exe" build -o C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___7go_build_main_go.exe D:\SmartFlow-Agent\backend\main.go #gosetup
C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___7go_build_main_go.exe #gosetup
2026/04/16 16:00:27 Config loaded successfully
2026/04/16 16:00:36 Database connected successfully
2026/04/16 16:00:36 Database auto migration completed
2026/04/16 16:00:36 RAG runtime initialized: store=milvus embed=eino reranker=noop
2026/04/16 16:00:36 outbox engine starting: topic=smartflow.agent.outbox brokers=[localhost:9092] retry_scan=1s batch=100
2026/04/16 16:00:36 Kafka topic is ready: smartflow.agent.outbox
2026/04/16 16:00:36 Outbox event bus started
2026/04/16 16:00:36 Memory worker started
2026/04/16 16:00:36 WebSearch provider: bocha
2026/04/16 16:00:36 Routes setup completed
2026/04/16 16:00:36 Server starting on port 8080...
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /api/v1/health --> github.com/LoveLosita/smartflow/backend/routers.RegisterRouters.func1 (3 handlers)
[GIN-debug] POST /api/v1/user/register --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserRegister-fm (3 handlers)
[GIN-debug] POST /api/v1/user/login --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogin-fm (3 handlers)
[GIN-debug] POST /api/v1/user/refresh-token --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).RefreshTokenHandler-fm (3 handlers)
[GIN-debug] POST /api/v1/user/logout --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogout-fm (5 handlers)
[GIN-debug] POST /api/v1/task/create --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).AddTask-fm (6 handlers)
[GIN-debug] PUT /api/v1/task/complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).CompleteTask-fm (6 handlers)
[GIN-debug] PUT /api/v1/task/undo-complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).UndoCompleteTask-fm (6 handlers)
[GIN-debug] GET /api/v1/task/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).GetUserTasks-fm (5 handlers)
[GIN-debug] POST /api/v1/course/validate --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).CheckUserCourse-fm (5 handlers)
[GIN-debug] POST /api/v1/course/import --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).AddUserCourses-fm (6 handlers)
[GIN-debug] POST /api/v1/task-class/add --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClass-fm (6 handlers)
[GIN-debug] GET /api/v1/task-class/list --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetTaskClassInfos-fm (5 handlers)
[GIN-debug] GET /api/v1/task-class/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetCompleteTaskClass-fm (5 handlers)
[GIN-debug] PUT /api/v1/task-class/update --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserUpdateTaskClass-fm (6 handlers)
[GIN-debug] POST /api/v1/task-class/insert-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClassItemIntoSchedule-fm (6 handlers)
[GIN-debug] DELETE /api/v1/task-class/delete-item --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClassItem-fm (6 handlers)
[GIN-debug] DELETE /api/v1/task-class/delete-class --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClass-fm (6 handlers)
[GIN-debug] PUT /api/v1/task-class/apply-batch-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserInsertBatchTaskClassItemsIntoSchedule-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/today --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserTodaySchedule-fm (5 handlers)
[GIN-debug] GET /api/v1/schedule/week --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserWeeklySchedule-fm (5 handlers)
[GIN-debug] DELETE /api/v1/schedule/delete --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).DeleteScheduleEvent-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/recent-completed --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserRecentCompletedSchedules-fm (5 handlers)
[GIN-debug] GET /api/v1/schedule/current --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserOngoingSchedule-fm (5 handlers)
[GIN-debug] DELETE /api/v1/schedule/undo-task-item --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).UserRevocateTaskItemFromSchedule-fm (6 handlers)
[GIN-debug] GET /api/v1/schedule/smart-planning --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanning-fm (5 handlers)
[GIN-debug] POST /api/v1/schedule/smart-planning-multi --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanningMulti-fm (5 handlers)
[GIN-debug] POST /api/v1/agent/chat --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).ChatAgent-fm (6 handlers)
[GIN-debug] GET /api/v1/agent/conversation-meta --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationMeta-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/conversation-list --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationList-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/conversation-history --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationHistory-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/schedule-preview --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetSchedulePlanPreview-fm (5 handlers)
[GIN-debug] GET /api/v1/agent/context-stats --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetContextStats-fm (5 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080
[GIN] 2026/04/16 - 16:00:38 | 200 | 47.9273ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
[GIN] 2026/04/16 - 16:00:38 | 200 | 12.4182ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=d1dda8e1-b7f0-4721-ad84-529ecad5d637"
[GIN] 2026/04/16 - 16:00:38 | 200 | 88.1335ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=d1dda8e1-b7f0-4721-ad84-529ecad5d637"
2026/04/16 16:01:07 D:/SmartFlow-Agent/backend/dao/agent.go:306 record not found
[42.474ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = '284c4b76-d6cc-40a6-b3de-fa4c8288022b' ORDER BY `agent_chats`.`id` LIMIT 1
2026/04/16 16:01:07 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
2026/04/16 16:01:07 [DEBUG] loadOrCreateRuntimeState chatID=284c4b76-d6cc-40a6-b3de-fa4c8288022b ok=false err=<nil> hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false
2026/04/16 16:01:08 rag level=info component=store operation=ensure_collection action=search collection=smartflow_rag_chunks corpus=memory latency_ms=4 metric_type=COSINE status=already_exists store=milvus vector_dim=1024
2026/04/16 16:01:08 rag level=info component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory filter_count=3 latency_ms=51 result_count=0 status=success store=milvus top_k=18 vector_dim=1024
2026/04/16 16:01:08 rag level=info component=runtime operation=retrieve action=search corpus=memory fallback_used=false hit_count=0 latency_ms=255 query_len=51 raw_count=0 status=success threshold=0.55 top_k=18
2026/04/16 16:01:26 [DEBUG] chat routing chat=284c4b76-d6cc-40a6-b3de-fa4c8288022b route=direct_reply needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw=<SMARTFLOW_ROUTE nonce="9b04f5df-3452-4a15-a39f-0449c1851729" route="direct_reply"/>
[GIN] 2026/04/16 - 16:01:27 | 200 | 19.3318195s | 127.0.0.1 | POST "/api/v1/agent/chat"
[GIN] 2026/04/16 - 16:01:27 | 200 | 84.0901ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
2026/04/16 16:01:27 outbox due messages=3, start dispatch
[GIN] 2026/04/16 - 16:01:27 | 200 | 2.24ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=284c4b76-d6cc-40a6-b3de-fa4c8288022b"
[GIN] 2026/04/16 - 16:01:27 | 200 | 46.4062ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=284c4b76-d6cc-40a6-b3de-fa4c8288022b"
2026/04/16 16:01:28 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
2026/04/16 16:01:29 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
2026/04/16 16:01:30 outbox due messages=1, start dispatch
2026/04/16 16:01:31 异步生成会话标题失败(模型生成失败) chat=284c4b76-d6cc-40a6-b3de-fa4c8288022b err=failed to create chat completion: context deadline exceeded

View File

@@ -0,0 +1,521 @@
# Memory 第三步治理与观测落地计划
## 1. 这份文档解决什么问题
这份文档只回答第三步要做什么,不再重复前两步已经完成的抽取、决策、召回细节。
第三步的目标很简单:
1. 把 memory 从“能跑”升级成“敢灰度、敢排障、敢清理、敢回滚”。
2. 把“日志打在哪里、我怎么看、会不会给接口”说清楚。
3. 把改动范围收敛在治理层,不再继续扩算法和能力边界。
一句人话总结:
前两步解决的是“有没有能力”,第三步解决的是“出了问题怎么查、怎么收、怎么退”。
---
## 2. 先说结论
第三步我会分成两块做:
1. 观测与切流
2. 用户管理与清理
为什么这么拆:
1. 现在第二步最小闭环已经通了,最怕的不是“能力不够多”,而是“出了问题不知道卡在哪一层”。
2. 如果没有统一日志、指标和开关,后面再继续加功能,只会让 memory 变成一个越来越难维护的黑箱。
3. 历史重复脏数据不先治理,后面读链路和注入链路的数据噪音会越来越重。
第三步不追求“更聪明”,追求“更稳、更可控”。
---
## 3. 你最关心的三个问题
## 3.1 日志会打在哪里
第三步不会把所有信息都塞进一个地方,而是分三层:
### A. 运行日志
运行日志打到后端服务本身的标准日志,也就是当前 `backend` 进程控制台 / 容器 stdout。
这层主要看实时链路,适合排查:
1. 这次写入为什么是 `ADD / UPDATE / DELETE / NONE`
2. 这次召回为什么没命中
3. 这次注入为什么降级到 `flat``legacy`
4. 这次 worker 为什么走了 fallback
这层的形态参考当前 RAG 轻量 Observer 的做法,不单独造一套散装日志方案。
参考文件:
1. `backend/cmd/start.go`
2. `backend/infra/rag/core/observer.go`
### B. 变更留痕
变更留痕继续落库,不只打终端。
当前已经有:
1. `memory_audit_logs`
2. `backend/model/memory.go`
3. `backend/memory/repo/audit_repo.go`
这层主要看“已经发生过的变更事实”,适合研发排查和后端自查:
1. 哪条记忆被删了
2. 删之前和删之后内容是什么
3. 这次 dedup 清理保留了哪条,归档了哪条
4. 某次 update / delete / restore 是谁触发的,原因是什么
### C. 汇总指标
第一版不先上完整 Prometheus / Grafana 平台,而是先把关键指标打稳,再视需要接统一观测平台。
这层主要看趋势和健康度,适合回答:
1. 最近写入成功率怎么样
2. hybrid 召回到底有没有提升
3. 去重到底丢了多少垃圾数据
4. 是否频繁回滚到 legacy
---
## 3.2 我会怎么看
开发和联调阶段,推荐分两种看法:
### 看实时问题
直接看后端运行日志。
适合看:
1. 单次请求链路
2. 单次 worker 执行过程
3. fallback / 降级 / 回滚是否发生
### 看历史问题
直接查数据库留痕表和主表。
适合看:
1. 某条 memory 历史上被怎么改过
2. 某次清理动作具体处理了哪些记录
3. 当前 active / archived / deleted 分布
建议排查时优先查这几张表:
1. `memory_jobs`
2. `memory_items`
3. `memory_audit_logs`
第一版就够用了,不强依赖前端页面才能排查。
---
## 3.3 会不会提供接口
会,但原则上只补“面向当前用户管理自己记忆”的接口,不补“原始运行日志接口”,也不把 `memory` 先做成全项目唯一完整的审计后台。
原因很直接:
1. 原始日志噪音很大,不适合直接给前端看。
2. 原始日志字段会迭代,直接对外暴露会把内部实现绑死。
3. 原始日志可能带内部 trace、错误细节不适合直接外露。
所以第三步对外提供的是“用户管理自己记忆”的接口,不是“把 stdout 原样吐给前端”,也不是“先给 memory 单独造一套管理后台接口”。
第三步建议优先补这几类用户接口:
### 第一组:当前用户查看自己的记忆
1. `GET /api/v1/memory/items`
- 分页查看“我自己的记忆”
2. `GET /api/v1/memory/items/:id`
- 查看“我自己的某条记忆”详情
### 第二组:当前用户主动维护自己的记忆
1. `POST /api/v1/memory/items`
- 手动新增一条记忆
2. `PATCH /api/v1/memory/items/:id`
- 修改自己的一条记忆
3. `DELETE /api/v1/memory/items/:id`
- 删除自己的一条记忆
### 第三组:当前用户恢复误删内容
1. `POST /api/v1/memory/items/:id/restore`
- 若底层采用软删或归档,可补恢复动作
这些接口都默认只允许操作“当前登录用户自己的记忆”,不支持跨用户查询和跨用户修改。
原则:
1. 原始日志看后端 stdout
2. 内部变更留痕优先给后端查表和排障使用,不急着做成前端正式能力
3. 对外先开放用户真正会用到的“我的记忆”增删改查
---
## 4. 第三步到底要做什么
## 4.1 观测与切流
这是第三步的第一优先级。
### 要做的事
1. 给写入决策链路补统一结构化日志
2. 给读侧召回链路补统一结构化日志
3. 给注入渲染链路补统一结构化日志
4. 给上述三条链路补关键计数指标
5. 把现有配置字段整理成清晰的切流顺序和回滚手册
### 为什么先做这个
因为第三步如果先做 dedup 清理,但没有日志和切流能力,一旦清错了,排查成本会很高。
---
## 4.2 用户管理与清理
这是第三步的第二优先级。
### 要做的事
1. 给“我的记忆”补完整增删改查语义
2. 给历史重复数据补离线 dedup 工具
3. 给关键变更动作补最小留痕
4. 把 dedup 保持在后端内部治理流程,不急着做成前端接口
### 为什么不一上来绑主 worker
因为第一版 dedup 的目标是“可留痕、可回滚”,不是“全自动”,也不是先给 `memory` 单独造一个很重的治理后台。
离线或手动触发更安全,出问题也更容易止血。
---
## 5. 具体改动计划
## 5.1 第一轮:先把观测底座补起来
### 目标
先让系统“可看见”。
### 预计改动
新增:
1. `backend/memory/observe/log_fields.go`
修改:
1. `backend/memory/worker/decision_flow.go`
2. `backend/memory/worker/apply_actions.go`
3. `backend/memory/service/read_service.go`
4. `backend/memory/service/retrieve_merge.go`
5. `backend/service/agentsvc/agent_memory.go`
6. `backend/service/agentsvc/agent_memory_render.go`
### 这一轮会补什么日志
#### 写入决策日志
至少记录这些字段:
1. `trace_id`
2. `user_id`
3. `conversation_id`
4. `job_id`
5. `fact_type`
6. `candidate_count`
7. `final_action`
8. `fallback_mode`
9. `success`
#### 读侧召回日志
至少记录这些字段:
1. `trace_id`
2. `user_id`
3. `read_mode`
4. `query_len`
5. `legacy_hit_count`
6. `semantic_hit_count`
7. `dedup_drop_count`
8. `final_count`
9. `degraded`
#### 注入渲染日志
至少记录这些字段:
1. `trace_id`
2. `user_id`
3. `inject_mode`
4. `input_count`
5. `rendered_count`
6. `token_budget`
7. `fallback`
---
## 5.2 第二轮:补指标,不急着开 overview 接口
### 目标
先让系统“可量化”。
### 第一版建议补的指标
优先补这 8 个:
1. `memory_job_success_rate`
2. `memory_job_retry_rate`
3. `memory_decision_distribution`
4. `memory_decision_fallback_rate`
5. `memory_retrieve_hit_count`
6. `memory_retrieve_dedup_drop_count`
7. `memory_inject_item_count`
8. `memory_rag_fallback_rate`
暂不强求第一版就补:
1. `memory_wrong_mention_rate`
2. `memory_user_correction_rate`
因为这两个更依赖后续“用户纠错入口”。
### 这一轮先不做什么
这一轮先不单独新增 `GET /api/v1/memory/overview`
原因不是这个接口没价值,而是现在别的模块还没有统一的观测面板和汇总接口规范。`memory` 这一轮先把指标打稳,后续如果全项目一起做观测面板,再统一收口更对称。
也就是说,这一轮优先把“数据先有”做出来,不急着把“看板接口先长出来”。
---
## 5.3 第三轮:补用户管理动作
### 目标
先让用户“能管理自己的记忆”。
### 预计改动
修改:
1. `backend/memory/service/manage_service.go`
2. `backend/memory/repo/item_repo.go`
3. `backend/memory/utils/audit.go`
4. `backend/memory/module.go`
新增:
1. `backend/api/memory.go`
2. 路由注册文件中的 memory 接线
### 要补的动作
1. `list`
2. `detail`
3. `create`
4. `update`
5. `delete`
6. 若底层保留软删语义,再补 `restore`
### 接口建议
新增:
1. `GET /api/v1/memory/items`
2. `GET /api/v1/memory/items/:id`
3. `POST /api/v1/memory/items`
4. `PATCH /api/v1/memory/items/:id`
5. `DELETE /api/v1/memory/items/:id`
6. `POST /api/v1/memory/items/:id/restore`
- 仅在底层采用软删或归档方案时开放
### 设计要求
1. 所有接口默认只作用于“当前登录用户自己的记忆”
2. 后端仍保留最小变更留痕,但不把它包装成用户侧“审计接口”
3. 接口返回给前端的是“人能看懂的记忆内容和操作结果”,不是底层日志
---
## 5.4 第四轮:做离线 dedup 治理
### 目标
先让系统“可清理”。
### 预计新增
1. `backend/memory/cleanup/dedup_runner.go`
2. `backend/memory/cleanup/dedup_policy.go`
### 第一版治理规则
按以下维度扫描重复组:
1. `user_id`
2. `memory_type`
3. `content_hash`
4. `status = active`
每组处理规则:
1. 选一条主记录保留
2. 优先保留最近更新的
3. 若最近更新时间接近,则优先保留置信度更高的
4. 其余记录改为 `archived`
5. 每次治理动作都写最小变更留痕
### 接口建议
这一轮不对外新增 dedup 接口。
dedup 先保留为后端内部治理能力,必要时通过离线任务、后台命令或内部 job 触发,避免 `memory` 先演化成一个比其他模块更重的专用治理后台。
### 明确限制
第一版不做:
1. 直接危险 SQL 清表
2. 自动定时常驻清理
3. 无留痕的批量删除
---
## 6. 日志、留痕、接口分别给谁看
这个地方一定要分清,不然第三步会越做越乱。
### 运行日志
给研发和排障看。
特点:
1. 实时
2. 噪音大
3. 字段多
4. 不直接给前端
### 变更留痕
先给研发和后端排障使用。
特点:
1. 是持久化结果
2. 适合看历史
3. 这一轮不急着做成正式用户接口
### 用户接口
给用户和前端页面看。
特点:
1. 只暴露“我的记忆”内容和操作结果
2. 不暴露内部 raw log
3. 不承载平台级观测职责
---
## 7. 切流顺序
第三步不允许一刀切。
建议严格按下面顺序灰度:
1. 阶段 A决策层 shadow
- 真正写库仍然走 `legacy`
- 新决策层只记日志,不生效
2. 阶段 B决策层仅对显式记忆生效
3. 阶段 C决策层对全部写入生效
4. 阶段 D读侧切到 `hybrid`
5. 阶段 E注入切到 `typed_v2`
6. 阶段 F历史清理跑完再考虑关闭 `legacy` 默认路径
这里的配置基础已经存在,关键是把切流顺序写清、用清、能回退。
参考文件:
1. `backend/memory/model/config.go`
2. `backend/memory/service/config_loader.go`
---
## 8. 回滚方案
第三步的回滚不应影响前两步代码保留,只回切开关。
### 最小回滚动作
1. 写侧回到 `legacy`
2. 读侧回到 `legacy`
3. 注入回到 `flat`
4. 停掉 dedup 清理任务
### 回滚原则
1. 先停治理动作,再回切主路径
2. 不做破坏性 schema 回滚
3. 不依赖人工热修逻辑判断
---
## 9. 第三步明确不做什么
为了防止范围失控,这一轮明确不做:
1. 不做图记忆
2. 不做多 Provider 工厂化
3. 不拆独立 memory 服务
4. 不在这一轮给 `memory` 先单独做完整审计后台
5. 不把 WebSearch 和 Memory 强行合并成一轮上线
6. 不再扩新的召回算法分支
---
## 10. 完成标准
满足以下条件,算第三步完成:
1. 能从日志看清某条记忆为什么被判成 `ADD / UPDATE / DELETE / NONE`
2. 能从指标看清召回命中、去重、降级、回滚情况
3. 用户能通过接口管理自己的记忆
4. 能对历史重复数据做可留痕清理
5. 出异常时能通过开关在分钟级切回 `legacy`
6. 文档和代码现状一致,不再靠口头传递
---
## 11. 如果只看一页,请看这个执行顺序
第三步不要散着做,建议按这个顺序推进:
1. 先补统一日志字段和结构化日志
2. 再补指标,把观测数据打稳
3. 再补“我的记忆”增删改查能力
4. 最后做离线 dedup 和内部清理能力
一句人话总结:
先让系统“看得见”,再让系统“能管理”,最后再让系统“敢清理”。

View File

@@ -0,0 +1,558 @@
# 记忆模块实施计划(面试优先版 -> 产品可用版)
## 1. 文档目标
1. 在 3 天内交付一个“可演示、可讲清楚、可继续演进”的记忆系统 MVP。
2. 兼容当前单体工程,不引入高风险拆分,不破坏现有聊天主链路。
3. 复用现有 Outbox 异步基础设施,避免重复造轮子。
4. 形成可直接用于面试讲述的架构故事线、指标体系与演示脚本。
5. 在不增加过度复杂度的前提下,吸收 Mem0 中已被验证的关键机制(抽取、决策、检索、降级、防幻觉)。
## 2. 背景与约束
1. 当前系统是单体 Go 项目,已有稳定的 `Outbox + Kafka + 消费事务` 通路。
2. 当前项目定位先是日程助手,长期演进为陪伴型助手。
3. 短期目标是快速做出“真的可用”的记忆能力,不追求一次做成完整通用平台。
4. 风险约束:
- 不能让重型 LLM 处理阻塞聊天实时响应。
- 不能在 Outbox 消费主循环里堆重计算,避免拖垮其他事件消费。
- 不能牺牲数据一致性与可审计性。
## 3. 总体方案
### 3.1 核心思路
采用“同步快路径 + 异步慢路径”:
1. 同步快路径:回复前快速读取可用记忆(以 MySQL 结构化事实为主),保证“下一轮能用”。
2. 异步慢路径:通过 Outbox 触发记忆抽取任务,执行去重、冲突消解、打分、向量化等重操作。
3. 读写解耦:写路径确保可靠入队,读路径优先稳定可控,再做语义增强。
### 3.2 存储职责分层
1. MySQL事实主库偏好、约束、任务上下文、TTL、置信度、敏感级别、来源
2. Milvus语义召回同义表达匹配、模糊语义联想
3. Redis可选热数据缓存后续优化不作为 MVP 必选项)。
### 3.3 编排层职责
`Memory Orchestrator` 负责两条链路:
1. 写入链路:候选抽取 -> 去重/冲突 -> 打分 -> 分流落库MySQL/Milvus
2. 读取链路:硬约束优先 -> 语义召回补充 -> 重排 -> 门控 -> 注入上下文。
### 3.4 借鉴 Mem0 的关键机制(已裁剪版)
1. 双阶段去重决策:先向量召回候选旧记忆,再由 LLM 决策 `ADD/UPDATE/DELETE/NONE`,而不是只靠相似度阈值硬判。
2. UUID 映射防幻觉:把真实 `memory_id` 映射成临时整数给 LLM回收结果时再反查防止模型编造不存在 ID。
3. 结构化输出刚性约束:抽取与决策都用 JSON 结构,失败时走 `extract_json -> normalize_facts` 容错链,不让解析失败直接污染主流程。
4. 动作分型嵌入:嵌入接口显式传入 `memory_action``add/search/update`),为后续差异化 embedding 策略预留接口。
5. 检索后处理标准化:`threshold 过滤 -> 可选 reranker -> 统一降级`,当重排器异常时保留向量原始排序并打告警日志。
6. 多维隔离语义:统一采用 `user_id + agent_id + run_id` 三维过滤;在本项目映射为 `user_id + assistant_id + conversation_id`
### 3.5 本项目明确不做(本轮)
1. 不做图记忆Graph Memory落地实现仅预留扩展点避免 3 天范围失控。
2. 不做多 Provider 工厂体系,只保留单 Provider 可替换接口,后续再扩展。
3. 不做独立 server 化记忆服务,先在单体内完成闭环与指标验证。
## 4. 3 天执行计划(可直接照着做)
## Day 1把“可写入”打通可靠入队 + 可追踪)
### 目标
1. 记忆任务能稳定从聊天主链路发出。
2. 能看到任务从 `pending``success/failed` 的状态流转。
3. 保证失败可重试、可追踪、可补偿。
### 任务清单
1. 新增文档与目录占位:
- `backend/memory/README.md`(模块说明)
- `backend/memory/service/`(门面)
- `backend/memory/model/`DTO 与状态)
- `backend/memory/repo/`(数据访问)
- `backend/memory/orchestrator/`(编排)
- `backend/memory/worker/`(异步执行)
2. 新增 MySQL 表(建议先手写 SQL + DAO
- `memory_items`
- `memory_jobs`
- `memory_audit_logs`
- `memory_user_settings`
3. 新增配置对象(`memory config`
- 抽取 prompt、更新决策 prompt、阈值、是否启用 reranker、LLM 温度参数。
- 默认采用低随机参数(`temperature/top_p` 低值)提高可复现性。
4. 新增 Outbox 事件:
- `memory.extract.requested`v1
5. 在聊天后置持久化环节发布事件:
- 仅传轻量字段,避免超大 payload。
6. 新增消费处理器:
- 只做任务入库,不做重型 LLM 调用。
7. 新增解析与标准化工具:
- `extract_json()`:从模型输出中抽取 JSON兼容代码块包裹
- `normalize_facts()`:去重、去空、长度校验、非法项过滤。
8. 新增决策状态机定义:
- `ADD/UPDATE/DELETE/NONE` 的合法状态与动作映射。
9. 启动期接线:
-`backend/cmd/start.go` 注册记忆事件处理器。
### Day 1 验收标准
1. 一次聊天后Outbox 中能看到 `memory.extract.requested` 事件。
2. 事件消费后,`memory_jobs` 生成记录。
3. 人工触发 worker 可完成一次任务状态推进(哪怕先是 mock 抽取)。
## Day 2把“可读取可注入”打通先 MySQL 后向量)
### 目标
1. 记忆可在回复前被检索并注入上下文。
2. 能避免明显的“尬提”与无关提及。
3. 提供最小用户可控能力(查看/删除/关闭)。
### 任务清单
1. 实现 `MemoryReadService`
- 按用户与会话上下文读取记忆。
- 优先结构化硬约束(时间偏好、排程禁忌、显式偏好)。
2. 实现 `MemoryInjector`
- Top-K 记忆选择。
- token 预算截断。
- 注入模板统一化。
3. 实现门控逻辑:
- 相关性阈值。
- 置信度阈值。
- 时间衰减权重。
- 敏感级别检查。
4. 增加“阈值 + 可选重排 + 降级”链路:
- 阈值过滤作为第一道过滤。
- `reranker` 失败时自动降级为原排序并记录原因码。
5. 新增最小管理接口:
- `GET /api/v1/memory/items`
- `DELETE /api/v1/memory/items/:id`
- `POST /api/v1/memory/settings`(开关)
6. 完成首版日志埋点:
- 检索命中数、注入条数、门控丢弃原因。
- 决策分布ADD/UPDATE/DELETE/NONE 占比)。
### Day 2 验收标准
1. 给出偏好后,下一轮排程请求能利用该偏好。
2. 无关话题不会频繁硬提旧记忆。
3. 用户可删除指定记忆,删除后不再注入。
## Day 3把“可讲清楚”与“可评估”补齐面试可答
### 目标
1. 输出完整可讲架构,说明设计取舍。
2. 增加可量化指标,证明记忆“有用”而不是“看起来有”。
3. 可选接入 Milvus若环境未就绪先保留接口 + mock
### 任务清单
1. 实现/预留向量接口:
- `VectorStore.Upsert()`
- `VectorStore.Search()`
- `VectorStore.Delete()`
- `VectorStore.Get()`(为 UPDATE/DELETE 决策回查旧值)
2. 对接 Milvus可选
- collection 初始化。
- 向量 + 元数据过滤检索。
3. 指标体系落地:
- 记忆命中率retrieved/useful
- 错误提及率wrong mention
- 用户纠正率user correction
- 回复延迟影响P50/P95
4. 准备演示脚本与面试问答稿:
- 5 分钟架构说明。
- 3 个典型失败案例及兜底策略。
- 未来迭代路线。
5. 输出“借鉴 Mem0 但本地化裁剪”的对比说明:
- 借鉴了什么。
- 为什么暂时不做图记忆与多 Provider 工厂。
### Day 3 验收标准
1. 能现场演示“记住偏好 -> 下轮生效 -> 删除后失效”。
2. 能答清楚“为什么不是纯同步/纯异步”。
3. 能答清楚“为什么 MySQL + Milvus 双存储”。
## 5. 数据模型设计(首版)
## 5.1 `memory_items`(长期事实记忆)
用途:保存对业务有约束价值的可注入记忆。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint必填
3. `conversation_id` varchar(64)(可空,表示全局用户记忆)
4. `assistant_id` varchar(64)(可空,区分不同助手人格/技能域)
5. `run_id` varchar(64)(可空,会话级隔离)
6. `memory_type` varchar(32)
- `preference`(偏好)
- `constraint`(硬约束)
- `fact`(事实)
- `todo_hint`(近期提醒线索)
7. `title` varchar(128)
8. `content` text
9. `normalized_content` text去噪后
10. `content_hash` varchar(64)(幂等去重)
11. `confidence` decimal(5,4)0~1
12. `importance` decimal(5,4)0~1
13. `sensitivity_level` tinyint
- 0 普通
- 1 中敏
- 2 高敏
14. `source_message_id` bigint
15. `source_event_id` varchar(64)
16. `is_explicit` tinyint(1)(是否用户明确要求记住)
17. `status` varchar(16)
- `active`
- `archived`
- `deleted`
18. `ttl_at` datetime到期时间
19. `last_access_at` datetime
20. `created_at` datetime
21. `updated_at` datetime
22. `vector_status` varchar(16)`pending/synced/failed`
23. `vector_id` varchar(128)(向量库主键映射)
索引建议:
1. `(user_id, status, memory_type, updated_at desc)`
2. `(user_id, conversation_id, status, updated_at desc)`
3. `(source_message_id)`(排查链路)
4. `(ttl_at)`(过期清理)
5. `(user_id, assistant_id, run_id, status, updated_at desc)`
6. `(user_id, memory_type, content_hash)`(幂等去重)
## 5.2 `memory_jobs`(异步任务队列表)
用途:承接 Outbox 消费后的待处理任务,解耦重计算。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint
3. `conversation_id` varchar(64)
4. `source_message_id` bigint
5. `source_event_id` varchar(64)
6. `job_type` varchar(32)
- `extract`
- `embed`
- `reconcile`
7. `idempotency_key` varchar(128)
8. `payload_json` longtext
9. `status` varchar(16)
- `pending`
- `processing`
- `success`
- `failed`
- `dead`
10. `retry_count` int
11. `max_retry` int
12. `next_retry_at` datetime
13. `last_error` varchar(2000)
14. `created_at` datetime
15. `updated_at` datetime
索引建议:
1. `(status, next_retry_at, id)`
2. `(user_id, created_at desc)`
3. `(source_event_id)`(幂等与追踪)
4. `(idempotency_key)`(消费防重)
## 5.3 `memory_audit_logs`(审计日志)
用途:回答“这条记忆是谁在什么条件下写的/改的/删的”。
关键字段建议:
1. `id` bigint PK
2. `memory_id` bigint
3. `user_id` bigint
4. `operation` varchar(32)
- `create`
- `update`
- `archive`
- `delete`
- `restore`
5. `operator_type` varchar(16)
- `system`
- `user`
6. `reason` varchar(255)
7. `before_json` longtext
8. `after_json` longtext
9. `created_at` datetime
## 5.4 `memory_user_settings`(用户记忆开关)
用途:实现用户可控能力。
关键字段建议:
1. `user_id` bigint PK
2. `memory_enabled` tinyint(1)
3. `implicit_memory_enabled` tinyint(1)
4. `sensitive_memory_enabled` tinyint(1)
5. `updated_at` datetime
## 6. 事件与协议设计
## 6.1 事件类型
1. `memory.extract.requested`v1
2. 预留:
- `memory.embed.requested`
- `memory.cleanup.requested`
## 6.2 载荷字段v1
1. `user_id`
2. `conversation_id`
3. `assistant_id`
4. `run_id`
5. `source_message_id`
6. `source_role`
7. `source_text`
8. `occurred_at`
9. `trace_id`
10. `idempotency_key`
设计约束:
1. Payload 只放执行需要的最小字段。
2. 大文本允许截断并保留摘要,防止消息膨胀。
3. 必须包含幂等标识(如 `source_message_id + user_id`)。
4. 过滤维度必须完整(`user_id + assistant_id + run_id`),避免跨会话串记忆。
## 7. 写入流程详细设计
## 7.1 主流程
1. 聊天主链路完成并落历史消息。
2. 发布 `memory.extract.requested` 到 Outbox。
3. Outbox 消费处理器验证 payload。
4. 处理器创建或幂等更新 `memory_jobs`(仅任务入库)。
5. `memory/worker` 扫描 `pending` 任务并抢占为 `processing`
6. Worker 调用 LLM 执行“候选事实抽取”JSON 输出)。
7. 执行 `extract_json -> normalize_facts` 容错标准化链路。
8. 对每条候选事实做向量检索,召回 Top-K 旧记忆候选。
9. 对召回结果执行“临时整数 ID 映射”,再交给 LLM 决策 `ADD/UPDATE/DELETE/NONE`
10. 根据决策执行写入动作:
- `ADD`:新增 `memory_items` + 审计日志。
- `UPDATE`:更新记录并保留历史旧值。
- `DELETE`:软删除并记录删除原因。
- `NONE`:不写入,仅记调试日志。
11. 按决策动作触发向量同步(支持 `vector_pending`)。
12. 成功后任务标记 `success`,失败按重试策略推进。
## 7.2 失败处理策略
1. Payload 非法:直接标记 dead不重试。
2. LLM 短时失败:指数退避重试。
3. DB 写失败:重试,超过上限 dead。
4. 向量写失败:
- MVP 策略:不阻塞事实写入,记录 `vector_pending` 状态。
- 后续策略:补偿任务重建向量索引。
## 7.3 幂等策略
1. 幂等键:`user_id + source_message_id + memory_type + normalized_content_hash`
2. 同幂等键重复写入:更新 `updated_at`、提升访问热度,不新增重复条目。
3. 由 Outbox 重试导致的重复消费必须无副作用。
4. 对 UPDATE/DELETE 必须先校验目标 `memory_id` 是否存在且属于当前过滤域。
## 8. 读取流程详细设计
## 8.1 主流程
1. 接收用户新问题,先做意图分类(排程/闲聊/混合)。
2.`memory_items` 拉取硬约束记忆(高优先级)。
3. 若 Milvus 可用,执行语义召回补充记忆候选。
4. 对候选执行重排:
- 相关性分
- 置信度分
- 时间衰减分
- 显式记忆加权
5. 执行门控:
- 低相关丢弃
- 高敏过滤
- 过期过滤
6. 执行阈值过滤后可选 reranker若 reranker 异常则自动降级使用原排序。
7. 按 token budget 选择最终注入条目。
8. 组装统一注入上下文,传给主模型生成回复。
## 8.2 重排评分(建议公式)
`final_score = 0.45 * relevance + 0.25 * confidence + 0.20 * recency + 0.10 * explicit_bonus`
说明:
1. 排程类场景可增加硬约束权重。
2. 闲聊类场景可提高语义相关权重。
3. 该公式为 MVP 默认值,后续可通过线上数据调参。
## 8.3 门控规则MVP
1. `final_score < 0.55` 不注入。
2. `sensitivity_level >= 2` 且用户未开启敏感记忆时不注入。
3. `ttl_at < now` 不注入。
4. 同主题最多注入 1~2 条,防止重复轰炸。
## 9. 对外接口MVP
## 9.1 用户接口
1. `GET /api/v1/memory/items`
- 支持按类型、时间、状态过滤。
2. `DELETE /api/v1/memory/items/:id`
- 软删除并写审计日志。
3. `POST /api/v1/memory/settings`
- 修改记忆总开关、隐式记忆开关。
## 9.2 内部接口
1. `MemoryService.EnqueueExtractJob(ctx, payload)`
2. `MemoryService.RetrieveForPrompt(ctx, req)`
3. `MemoryService.UpsertMemoryItems(ctx, items)`
4. `MemoryService.DeleteMemory(ctx, userID, memoryID)`
## 10. 可观测性与指标
## 10.1 指标定义
1. `memory_job_success_rate`
2. `memory_job_retry_rate`
3. `memory_retrieval_hit_rate`
4. `memory_injection_count_avg`
5. `memory_wrong_mention_rate`
6. `memory_user_correction_rate`
7. `chat_p95_latency_delta_with_memory`
8. `memory_json_parse_fail_rate`
9. `memory_decision_distribution`ADD/UPDATE/DELETE/NONE
10. `reranker_fallback_rate`
## 10.2 日志与追踪
1. 每个任务写 `trace_id`,贯穿聊天请求 -> outbox -> memory_job -> memory_item。
2. 对门控丢弃记录原因码:
- `LOW_SCORE`
- `EXPIRED`
- `SENSITIVE_BLOCKED`
- `DUP_TOPIC`
3. 保证可以反查“为什么这次没有提某条记忆”。
## 11. 安全与隐私约束
1. 敏感信息默认不做隐式记忆(如健康、财务、证件等)。
2. 用户必须可删除历史记忆,删除后不再用于注入。
3. 记忆开关关闭后,仅保留必要系统数据,不再新增记忆条目。
4. 审计日志保留系统写入行为,便于风控与合规排查。
## 12. 测试策略
## 12.1 单元测试范围(实现阶段)
1. 候选抽取结果解析函数。
2. 冲突消解函数。
3. 重排评分函数。
4. 门控函数。
5. 幂等去重函数。
6. `extract_json` 容错解析函数。
7. `normalize_facts` 标准化函数。
8. UUID 映射与反查函数。
9. `ADD/UPDATE/DELETE/NONE` 决策结果校验函数。
## 12.2 集成测试范围(实现阶段)
1. 聊天后事件成功入 outbox。
2. Outbox 消费后任务成功入 `memory_jobs`
3. Worker 成功写 `memory_items`
4. 读取链路能在回复中注入预期记忆。
## 12.3 注意事项(遵循项目约束)
1. 若编写 Go 测试文件(`*_test.go`)做验证,任务完成后按项目约定移除测试文件。
2. 每次执行本地 `go test` 后清理项目根目录 `.gocache`
## 13. 风险与回滚
## 13.1 主要风险
1. 记忆误提影响体验。
2. LLM 抽取不稳定导致脏记忆。
3. 向量检索误召回导致不相关注入。
4. 任务积压影响时效。
## 13.2 应对策略
1. 先严门控,宁可少提,不要乱提。
2. 保留“用户纠正”入口,纠正后提高冲突更新优先级。
3. 对召回做 metadata 过滤(近 30 天、类型限定)。
4. 监控任务积压长度,超阈值降级(停向量,仅结构化记忆)。
## 13.3 回滚方案
1. 配置开关 `memory.enabled=false` 可一键关闭记忆注入。
2. 保留写入链路但停读取链路,避免历史数据丢失。
3. 极端情况下停 worker仅保留主链路聊天功能。
## 14. 面试表达模板(可直接复述)
1. “我们做的是同步快路径 + 异步慢路径。同步保证下轮可用,异步负责治理和质量。”
2. “结构化事实放 MySQL 保证可控可审计,语义联想放 Milvus 提高召回覆盖。”
3. “Outbox 保证事件可靠入队Worker 解耦重计算,避免阻塞主链路。”
4. “借鉴 Mem0 的双阶段策略:先向量召回旧记忆,再让 LLM 决策 ADD/UPDATE/DELETE/NONE兼顾召回率与准确率。”
5. “我们用 UUID 映射防止模型伪造 ID并且用 JSON 容错链保证抽取稳定性。”
6. “我们用命中率、误提率、纠正率和 reranker 降级率验证记忆是否真的有价值。”
## 15. DoD完成定义
1. 代码层:
- 记忆事件可发布、可消费、可重试。
- 记忆可检索、可注入、可删除、可关闭。
2. 质量层:
- 有基础指标与日志,支持问题排查。
- 有失败兜底与降级路径。
3. 叙事层:
- 3 分钟能讲清架构。
- 5 分钟能演示端到端效果。
- 能回答核心取舍与后续演进。
## 16. 本轮执行顺序建议
1. 先做 Day 1 的表结构与事件接线,不进入复杂抽取细节。
2. 再做 Day 2 的读取注入,优先 MySQL 结构化记忆。
3. 最后补 Day 3 的 Milvus 与指标,确保面试讲述闭环。
## 17. Mem0 借鉴清单与取舍结论(本轮新增)
### 17.1 直接借鉴
1. `ADD/UPDATE/DELETE/NONE` 统一决策状态机。
2. `threshold -> reranker(可选) -> fallback` 的检索后处理套路。
3. 三维过滤隔离(`user_id/agent_id/run_id`)的语义边界设计。
4. 历史追踪思路(本项目落在 `memory_audit_logs`)。
5. 低随机参数 + JSON 输出约束,提升可复现性。
### 17.2 延后借鉴
1. 图记忆(关系三元组与软删除)延后到 V2/V3。
2. 多 Provider 工厂体系延后到“需要跨云/跨模型”时再上。
3. 托管 API 平台化能力延后到单体稳定后再拆。
### 17.3 不照搬的原因
1. 当前目标是 3 天可演示 MVP优先“稳定可讲”而非“能力最全”。
2. 项目已有 Outbox 可靠链路,先最大化复用,避免架构重复。
3. 日程助手是强约束场景,结构化事实主库优先级高于图谱表达能力。
---
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。

View File

@@ -0,0 +1,363 @@
# 第二步执行计划:读取与注入层升级
## Context
第一步(写入决策层)已完成,写侧已有"召回 → 比对 → ADD/UPDATE/DELETE/NONE"能力。
但读侧仍是"查到就拼",存在四个问题:
1. RAG 和 legacy **互斥**,无法做到"MySQL 强约束 + RAG 语义补充"双路合并
2. 去重仅 `seen[line]` 字符串级,无 `memory_id` / `content_hash` 级去重
3. 所有类型平铺、limit=5 一刀切constraint 可被 fact 挤掉
4. `memory_context` 虽已写入 `PinnedBlocks`,但 Execute 阶段走自定义 `msg0~msg3` 骨架,当前并未消费这块内容
---
## 当前数据流legacy
```
用户发消息
agent_newagent.go:114 injectMemoryContext()
│ 调用 MemoryReader.Retrieve()
│ 入参: userID, chatID, query=userMessage, limit=5
ReadService.Retrieve() ← read_service.go:51
│ 门控: 用户设置检查
│ 分支: RAG成功→走RAG / 否则→走legacy
│ 两路互斥,只走一条
├── retrieveByRAG() ← read_service.go:132
│ ragRuntime.RetrieveMemory() → []RetrieveHit
│ 转为 []ItemDTO, 用户设置过滤, 截断到 limit
└── retrieveByLegacy() ← read_service.go:84
itemRepo.FindByQuery(limit*3) → []MemoryItem
用户设置过滤 → scoreRetrievedItem排序 → 截断到 limit
toItemDTOs() 转换, TouchLastAccessAt
▼ 返回 []ItemDTO最多5条无类型预算无服务级去重
renderMemoryPinnedContent() ← agent_memory.go:105
│ 遍历 items, 对每条生成 "[类型] 内容"
│ seen[line] 字符串级弱去重
拼接为一段纯文本 → ConversationContext.UpsertPinnedBlock(key="memory_context")
├── base.go:55 renderPinnedBlocks()
│ 把所有 pinned blocks 拼成 system message
│ Chat / Plan / Deliver / 走通用 buildStageMessages 的节点可自动消费
└── execute_context.go:52 buildExecuteStageMessages()
Execute 走自定义 msg0~msg3 骨架
当前未渲染 memory_context等价于 Execute 看不到这段记忆
```
---
## 目标数据流hybrid
```
用户发消息
agent_newagent.go:114 injectMemoryContext() ← 不改触发点,改内部链路
│ 调用 MemoryReader.Retrieve()
ReadService.Retrieve() ← read_service.go
│ 门控: 用户设置检查(不变)
│ 分支: cfg.ReadMode == "hybrid" → 走新链路
│ 否则 → 走旧链路(完全不变)
▼ ══════════════════════════════════════════════
HybridRetrieve() ← 新文件 retrieve_merge.go
║ ← 整个混合链路收口在一个函数里
║ ┌─────────────────────────────────────────┐
║ │ 第一路:结构化强约束召回 │
║ │ │
║ │ ItemRepo.FindPinnedByUser() │ ← 新方法 item_repo.go
║ │ → constraint: status=active, 全取 │
║ │ → preference: confidence>=0.8, │
║ │ 按 importance 降序取 limit 条 │
║ │ 合并 → []MemoryItem → toItemDTOs() │
║ │ 结果 A │
║ └─────────────────────────────────────────┘
║ ↓
║ ┌─────────────────────────────────────────┐
║ │ 第二路:语义候选召回 │
║ │ │
║ │ RAG 可用? │
║ │ 是 → ragRuntime.RetrieveMemory() │ ← 复用现有 RAG 链路
║ │ → []RetrieveHit │
║ │ → buildMemoryDTOFromRetrieveHit() │ ← 复用 read_service.go 已有函数
║ │ → 用户设置过滤 │
║ │ 否 → itemRepo.FindByQuery() │ ← 复用现有 FindByQuery
║ │ → toItemDTOs() │
║ │ → 用户设置过滤 │
║ │ 结果 B │
║ └─────────────────────────────────────────┘
║ ↓
║ 合并 A + B → []ItemDTO
║ ↓
║ ┌─────────────────────────────────────────┐
║ │ 三级去重 │
║ │ │
║ │ 1. dedupByID — 按 memory_id 去重 │ ← 同 ID 只保留一条
║ │ 后出现的覆盖先出现的 │
║ │ 2. dedupByHash — 按 content_hash 去重 │ ← 复用 HashContent 算法
║ │ hash 为空的跳过 │ (normalize_facts.go)
║ │ 保留 importance 更高的 │
║ │ 3. dedupByText — 按渲染文本兜底去重 │ ← hash 缺失/空值兜底
║ │ 用 localizeMemoryType + │
║ │ Content 生成 key │
║ └─────────────────────────────────────────┘
║ ↓
║ ┌─────────────────────────────────────────┐
║ │ 排序 │
║ │ │
║ │ RankItems() │ ← 新文件 retrieve_rank.go
║ │ 类型优先级权重叠加原加权分: │
║ │ constraint +0.15 │
║ │ preference +0.10 │
║ │ todo_hint +0.05 │
║ │ fact +0 │
║ │ + 原 0.35*importance + 0.3*confidence │
║ │ + 0.2*recency + 0.1*explicit │
║ │ + 0.08*同会话加分 │
║ │ 同分按 ID 降序 │
║ └─────────────────────────────────────────┘
║ ↓
║ ┌─────────────────────────────────────────┐
║ │ 类型预算裁剪 │
║ │ │
║ │ applyTypeBudget() │
║ │ constraint: 最多 ConstraintLimit 条 │ ← 默认 5
║ │ preference: 最多 PreferenceLimit 条 │ ← 默认 5
║ │ todo_hint: 最多 TodoHintLimit 条 │ ← 默认 3
║ │ fact: 最多 FactLimit 条 │ ← 默认 5
║ │ 类型内部保持 RankItems 排序结果 │
║ │ 总计最多 18 条(仍受 Execute 上下文预算约束)│
║ └─────────────────────────────────────────┘
║ ↓
║ 返回 []ItemDTO去重、排序、预算裁剪后的最终结果
══════════════════════════════════════════════
▼ 返回到 injectMemoryContext()
│ cfg.InjectRenderMode == "typed_v2" ?
├── typed_v2 → RenderTypedMemoryContent() ← 新文件 agent_memory_render.go
│ 按类型分组渲染:
│ ┌──────────────────────────────────┐
│ │ 以下是与当前对话相关的用户记忆, │
│ │ 仅在确实有帮助时参考,不要机械复述。 │
│ │ │
│ │ 【必守约束】 │
│ │ - 用户点外卖不要香菜。 │
│ │ │
│ │ 【用户偏好】 │
│ │ - 用户偏爱黑咖啡。 │
│ │ │
│ │ 【当前话题相关事实】 │
│ │ - 用户最近在准备周四的程序设计作业。 │
│ │ │
│ │ 【近期待办】 │
│ │ - 周五前交英语作文。 │
│ └──────────────────────────────────┘
│ 规则: 空段不输出, 段内 "- " 前缀
└── flat → RenderFlatMemoryContent() ← 新文件 agent_memory_render.go
从 agent_memory.go 迁入现有 renderMemoryPinnedContent 逻辑,不变
▼ 拼接为纯文本
ConversationContext.UpsertPinnedBlock(key="memory_context")
├── 通用阶段 → base.go:55 renderPinnedBlocks() ← 不改
│ 把所有 pinned blocks 拼成 system message
│ Chat / Plan / Deliver / 走通用组装的节点自动消费 memory_context
└── Execute 阶段 → buildExecuteMessage3() ← 修改 execute_context.go
renderExecuteMemoryContext(ctx) ← 新文件 execute_pinned.go
→ 只白名单读取 key="memory_context"
→ 以“相关记忆”补充段拼入 msg3
→ 不复用通用 renderPinnedBlocks避免 execution_context/current_step 等块重复注入
```
---
## 每个阶段对应的代码改动
### 阶段 0前置准备配置 + DTO 补齐)
改造开始前,先让配置和 DTO 能支撑后续链路。
**改动 1Config 新增读侧配置字段**
- 文件:`backend/memory/model/config.go`
- 新增 6 个字段:`ReadMode` / `ReadConstraintLimit` / `ReadPreferenceLimit` / `ReadFactLimit` / `ReadTodoHintLimit` / `InjectRenderMode`
**改动 2ConfigLoader 读取 + 默认值**
- 文件:`backend/memory/service/config_loader.go`
- 读取上述 6 个 viper key默认值ReadMode="legacy", ConstraintLimit=5, PreferenceLimit=5, FactLimit=5, TodoHintLimit=3, RenderMode="flat"
**改动 3ItemDTO 补齐 ContentHash**
- 文件:`backend/memory/model/item.go` — ItemDTO 新增 `ContentHash string`
- 文件:`backend/memory/service/common.go``toItemDTO` 补映射 `ContentHash: strValue(item.ContentHash)`
- 原因:去重阶段需要 content_hash当前 ItemDTO 没有这个字段
### 阶段 1第一路 — 结构化强约束召回
**改动 4ItemRepo 新增 FindPinnedByUser**
- 文件:`backend/memory/repo/item_repo.go`
- 两次查询合并:
- 查 1`memory_type=constraint AND status=active AND user_id=? AND (未过期)`
- 查 2`memory_type=preference AND confidence>=0.8 AND status=active AND user_id=? AND (未过期)` 按 importance DESC LIMIT preferenceLimit
- 合并返回,约束在前偏好在后
- 复用已有的 `applyScopedEquality` 模式构建 WHERE
### 阶段 2第二路 — 语义候选召回
**无新文件**。直接在 HybridRetrieve 内部实现:
- RAG 可用:调 `ragRuntime.RetrieveMemory()` → 复用 `buildMemoryDTOFromRetrieveHit()` 转 DTO
- RAG 不可用:调 `itemRepo.FindByQuery()` → 复用 `toItemDTOs()` 转 DTO
- 两路复用现有函数,不重写
### 阶段 3三级去重
**新增文件:`backend/memory/service/retrieve_merge.go`**
三个纯函数,输入 `[]ItemDTO` 输出 `[]ItemDTO`
1. `dedupByID` — map[int64]ItemDTO后出现的覆盖先出现的
2. `dedupByHash` — map[string]ItemDTO保留 importance 更高的hash 为空跳过
3. `dedupByText` — map[string]ItemDTO`localizeMemoryType + Content` 生成 key
复用:`HashContent` 算法(来自 `normalize_facts.go`,已导出)
### 阶段 4排序
**新增文件:`backend/memory/service/retrieve_rank.go`**
- `RankItems(items, now, conversationID)` — 在原 `scoreRetrievedItem` 基础上叠加类型优先级权重
-`scoreRetrievedItem` 保留给 legacy 路径,不删除
### 阶段 5类型预算裁剪
**同文件:`backend/memory/service/retrieve_merge.go`**
- `applyTypeBudget(items, cfg)` — 按 4 个类型 limit 截断,类型内部保持排序结果
### 阶段 6ReadService 接入
**改动 5ReadService.Retrieve 新增 hybrid 分支**
- 文件:`backend/memory/service/read_service.go`
- 改动极小:在现有 Retrieve 方法中门控通过后、limit 计算后,加一个 `if cfg.ReadMode == "hybrid"` 分支调 HybridRetrieve
- 旧路径RAG 优先 → legacy 兜底)完全不动
### 阶段 7渲染
**新增文件:`backend/service/agentsvc/agent_memory_render.go`**
- `RenderTypedMemoryContent(items)` — 按类型分组渲染,空段不输出
- `RenderFlatMemoryContent(items)` — 迁入现有 `renderMemoryPinnedContent` 逻辑
- 产物仍统一收口为 `ConversationContext.PinnedBlock(key="memory_context")`,后续 Execute 只消费这块内容,不再重复维护第二套 memory 渲染逻辑
### 阶段 8Execute 记忆消费补齐
**新增文件:`backend/newAgent/prompt/execute_pinned.go`**
- 新增 `renderExecuteMemoryContext(ctx)`:只白名单读取 `memory_context` 这一个 pinned block
- 输出定位:作为 Execute `msg3` 的补充段,不进入 `msg1/msg2`,避免污染历史归档与 ReAct 窗口
- 设计约束:**不**直接复用通用 `renderPinnedBlocks()`,避免 `execution_context` / `current_step` / `rough_build_done` 等 Execute 自有 pinned block 重复注入
**改动 6`execute_context.go` 接入 memory_context**
- 文件:`backend/newAgent/prompt/execute_context.go`
-`buildExecuteMessage3()` 中拼接 `renderExecuteMemoryContext(ctx)` 的结果
- 空记忆不输出;只追加“相关记忆”段,不改动 `msg0/msg1/msg2` 既有职责
### 阶段 9注入入口切换
**改动 7agent_memory.go 接入 renderMode**
- 文件:`backend/service/agentsvc/agent.go` — AgentService 新增 `memoryCfg memorymodel.Config` 字段
- 文件:`backend/service/agentsvc/agent_memory.go``SetMemoryReader` 签名增加 cfg 参数;`injectMemoryContext` 根据 cfg.InjectRenderMode 选渲染函数
**改动 8启动层传参**
- 文件:`backend/cmd/start.go``SetMemoryReader(memoryModule)``SetMemoryReader(memoryModule, memoryCfg)`
- memoryCfg 在同函数第 78 行已定义,无需额外引入
---
## 文件变更汇总
| 文件 | 操作 | 对应阶段 |
|---|---|---|
| `backend/memory/model/config.go` | 修改 | 阶段 0 |
| `backend/memory/service/config_loader.go` | 修改 | 阶段 0 |
| `backend/memory/model/item.go` | 修改 | 阶段 0 |
| `backend/memory/service/common.go` | 修改 | 阶段 0 |
| `backend/memory/repo/item_repo.go` | 修改 | 阶段 1 |
| `backend/memory/service/retrieve_merge.go` | **新增** | 阶段 3 + 5 |
| `backend/memory/service/retrieve_rank.go` | **新增** | 阶段 4 |
| `backend/memory/service/read_service.go` | 修改 | 阶段 6 |
| `backend/service/agentsvc/agent_memory_render.go` | **新增** | 阶段 7 |
| `backend/newAgent/prompt/execute_pinned.go` | **新增** | 阶段 8 |
| `backend/newAgent/prompt/execute_context.go` | 修改 | 阶段 8 |
| `backend/service/agentsvc/agent.go` | 修改 | 阶段 9 |
| `backend/service/agentsvc/agent_memory.go` | 修改 | 阶段 9 |
| `backend/cmd/start.go` | 修改 | 阶段 9 |
---
## 实施顺序(严格依赖链)
```
阶段 0前置: config.go → config_loader.go → item.go + common.go
阶段 1Repo: item_repo.go (FindPinnedByUser)
阶段 3+4去重+排序): retrieve_merge.go去重函数+ retrieve_rank.go可并行
阶段 5预算: retrieve_merge.goHybridRetrieve 入口 + applyTypeBudget
↓ ↑ 合并阶段 1~5 为完整 HybridRetrieve 函数
阶段 6接入: read_service.gohybrid 分支)
阶段 7渲染: agent_memory_render.go可和阶段 6 并行)
阶段 8Execute 消费): execute_pinned.go + execute_context.go
阶段 9集成: agent.go + agent_memory.go + start.go
```
---
## 回滚策略
全部配置开关回滚,不改代码:
| 配置 | 回滚值 | 效果 |
|---|---|---|
| `memory.read.mode` | `legacy` | 读侧回到当前行为 |
| `memory.inject.renderMode` | `flat` | 注入渲染回到当前行为 |
---
## 验证方式
1. **默认启动不变**:不配置任何新参数,系统行为与当前完全一致
2. **hybrid 双路召回**:设 `memory.read.mode=hybrid`,日志确认两路召回 + 合并 + 去重生效
3. **constraint 优先**:写入 5 条 fact + 2 条 constraint确认 constraint 不被挤出
4. **去重生效**:同一用户多条同义记忆,注入只保留一条
5. **RAG 降级**:关 Milvushybrid 模式仍通过 MySQL fallback 正常工作
6. **typed_v2 渲染**:设 `memory.inject.renderMode=typed_v2`pinned block 按段输出
7. **Execute 可见记忆**:进入 Execute 节点时,送入 LLM 的 `msg3` 含“相关记忆”段,且内容来自 `memory_context`
8. **Execute 无重复注入**`execution_context` / `current_step` 等 Execute 自有 pinned block 不因 memory 接入被重复渲染
9. **单元测试**:对去重/预算/排序/渲染 / Execute 记忆桥接编写测试,跑完删除
---
## 本轮明确不做
1. 不把 memory 改造成工具调用
2. 不改 newAgent 的图路由结构
3. 不把 WebSearch 并进统一召回
4. 不清理历史重复脏数据
5. 不动写入决策层代码
6. 不让 Execute 无差别复用通用 `renderPinnedBlocks()`,避免把全部 pinned block 一股脑塞进 `msg3`

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"
"github.com/LoveLosita/smartflow/backend/model"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/services/memory/internal/vectorsync"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
"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

@@ -0,0 +1,135 @@
package orchestrator
import (
"context"
"fmt"
"log"
"strings"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
const defaultDecisionCompareMaxTokens = 600
// LLMDecisionOrchestrator 负责对"一条新 fact vs 一条旧记忆"做关系判断。
//
// 职责边界:
// 1. 每次只比较一对,是最小粒度的 LLM 调用;
// 2. LLM 只输出 relation关系类型不输出 action不输出 target ID
// 3. LLM 调用失败时返回 error由上层决定是否视为 unrelated。
type LLMDecisionOrchestrator struct {
client *llmservice.Client
cfg memorymodel.Config
logger *log.Logger
}
// NewLLMDecisionOrchestrator 构造决策比对编排器。
func NewLLMDecisionOrchestrator(client *llmservice.Client, cfg memorymodel.Config) *LLMDecisionOrchestrator {
return &LLMDecisionOrchestrator{
client: client,
cfg: cfg,
logger: log.Default(),
}
}
// Compare 对单条新 fact 与单条旧候选做关系判断。
//
// 返回语义:
// 1. 成功时返回比对结果relation 为四种合法值之一;
// 2. LLM 不可用或输出异常时返回 error上层应视为 unrelated
// 3. 不做最终决策,最终动作由确定性汇总逻辑产出。
func (o *LLMDecisionOrchestrator) Compare(
ctx context.Context,
fact memorymodel.NormalizedFact,
candidate memorymodel.CandidateSnapshot,
) (*memorymodel.ComparisonResult, error) {
if o == nil || o.client == nil {
return nil, fmt.Errorf("决策编排器未初始化")
}
// 1. 构建逐对比较 prompt极简二元判断LLM 只输出 relation。
systemPrompt := buildDecisionCompareSystemPrompt()
userPrompt := buildDecisionCompareUserPrompt(fact, candidate)
messages := llmservice.BuildSystemUserMessages(systemPrompt, nil, userPrompt)
// 2. 调用 LLM 做结构化输出,温度用低值保证判断稳定。
resp, _, err := llmservice.GenerateJSON[decisionCompareResponse](
ctx,
o.client,
messages,
llmservice.GenerateOptions{
Temperature: 0.1,
MaxTokens: defaultDecisionCompareMaxTokens,
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
},
)
if err != nil {
if o.logger != nil {
o.logger.Printf("[WARN][去重] 决策比对 LLM 调用失败: memory_type=%s candidate_id=%d err=%v", fact.MemoryType, candidate.MemoryID, err)
}
return nil, err
}
// 3. 映射 LLM 输出到 ComparisonResultMemoryID 由代码填充而非 LLM。
result := &memorymodel.ComparisonResult{
MemoryID: candidate.MemoryID,
Relation: normalizeRelation(resp.Relation),
UpdatedContent: strings.TrimSpace(resp.UpdatedContent),
UpdatedTitle: strings.TrimSpace(resp.UpdatedTitle),
Reason: strings.TrimSpace(resp.Reason),
}
return result, nil
}
// decisionCompareResponse 是 LLM 逐对比较的 JSON 输出结构。
type decisionCompareResponse struct {
Relation string `json:"relation"`
UpdatedContent string `json:"updated_content"`
UpdatedTitle string `json:"updated_title"`
Reason string `json:"reason"`
}
// normalizeRelation 统一 relation 字段为小写标准形式。
func normalizeRelation(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
// buildDecisionCompareSystemPrompt 构建逐对比较的系统 prompt。
func buildDecisionCompareSystemPrompt() string {
return strings.TrimSpace(`你是一个记忆关系判断器。请判断"新事实"和"旧记忆"之间的关系。
关系类型:
- duplicate两者表达相同意思新事实没有新信息
- update新事实是对旧记忆的修正、补充或更精确表述
- conflict新事实与旧记忆在同一话题上存在矛盾如"喜欢X"变为"不喜欢X"、"去了A地"变为"实际去了B地"),旧记忆已过时
- unrelated两者说的是不同的事情或属于同一大类下的不同偏好如"喜欢唱歌"与"喜欢打球"是不同爱好,不矛盾)
输出 JSON
{"relation":"...","updated_content":"...","updated_title":"...","reason":"..."}
规则:
1. relation=update 时updated_content 必须写出合并后的完整内容(不是只写差异部分)
2. 其余 relation 类型updated_content 留空即可
3. reason 写简短判断依据
4. 只输出 JSON不要输出解释或 markdown
5. conflict 仅限同一话题内的矛盾信息;不同话题的偏好、不同领域的兴趣一律判 unrelated`)
}
// buildDecisionCompareUserPrompt 构建逐对比较的用户 prompt。
func buildDecisionCompareUserPrompt(fact memorymodel.NormalizedFact, candidate memorymodel.CandidateSnapshot) string {
return fmt.Sprintf("新事实:【%s】%s\n旧记忆【%s】%s",
fact.MemoryType, fact.Content,
candidate.MemoryType, candidate.Content,
)
}
// resolveMemoryThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
func resolveMemoryThinkingMode(enabled bool) llmservice.ThinkingMode {
if enabled {
return llmservice.ThinkingModeEnabled
}
return llmservice.ThinkingModeDisabled
}

View File

@@ -0,0 +1,331 @@
package orchestrator
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
const (
defaultMemoryExtractMaxTokens = 1200
defaultMemoryExtractMaxFacts = 5
)
// LLMWriteOrchestrator 负责把单条对话消息转成可入库的记忆候选。
//
// 职责边界:
// 1. 负责调用 LLM 做抽取、把输出标准化成 memory_facts
// 2. 不负责落库,不负责任务状态机推进;
// 3. 当 LLM 不可用或输出异常时,回退到保守的本地抽取,保证链路不完全断。
type LLMWriteOrchestrator struct {
client *llmservice.Client
cfg memorymodel.Config
logger *log.Logger
}
// NewLLMWriteOrchestrator 构造 LLM 版记忆写入编排器。
func NewLLMWriteOrchestrator(client *llmservice.Client, cfg memorymodel.Config) *LLMWriteOrchestrator {
return &LLMWriteOrchestrator{
client: client,
cfg: cfg,
logger: log.Default(),
}
}
// ExtractFacts 从单条消息中抽取可入库事实。
//
// 返回语义:
// 1. 成功时返回标准化后的候选事实;
// 2. 即使 LLM 失败,也尽量返回保守的 fallback 结果,避免 worker 空转报错;
// 3. 只有输入本身为空时才返回空结果。
func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
sourceText := strings.TrimSpace(payload.SourceText)
if sourceText == "" {
return nil, nil
}
if o == nil || o.client == nil {
return fallbackNormalizedFacts(payload), nil
}
messages := llmservice.BuildSystemUserMessages(
buildMemoryExtractSystemPrompt(o.cfg.ExtractPrompt),
nil,
buildMemoryExtractUserPrompt(payload),
)
resp, rawResult, err := llmservice.GenerateJSON[memoryExtractResponse](
ctx,
o.client,
messages,
llmservice.GenerateOptions{
Temperature: clampTemperature(o.cfg.LLMTemperature),
MaxTokens: defaultMemoryExtractMaxTokens,
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
Metadata: map[string]any{
"stage": "memory_extract",
"user_id": payload.UserID,
"conversation_id": payload.ConversationID,
},
},
)
if err != nil {
if o.logger != nil {
o.logger.Printf("[WARN] memory extract llm failed user_id=%d conversation_id=%s err=%v raw=%s",
payload.UserID, payload.ConversationID, err, truncateForLog(rawResult))
}
return fallbackNormalizedFacts(payload), nil
}
facts := convertExtractResponse(resp)
normalized := memoryutils.NormalizeFacts(facts)
if len(normalized) == 0 {
return fallbackNormalizedFacts(payload), nil
}
return normalized, nil
}
type memoryExtractResponse struct {
MessageIntent string `json:"message_intent"`
Facts []memoryExtractFact `json:"facts"`
}
type memoryExtractFact struct {
MemoryType string `json:"memory_type"`
Title string `json:"title"`
Content string `json:"content"`
Confidence float64 `json:"confidence"`
Importance float64 `json:"importance"`
SensitivityLevel int `json:"sensitivity_level"`
IsExplicit bool `json:"is_explicit"`
}
type memoryExtractPromptInput struct {
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
AssistantID string `json:"assistant_id,omitempty"`
RunID string `json:"run_id,omitempty"`
SourceMessageID int64 `json:"source_message_id,omitempty"`
SourceRole string `json:"source_role"`
SourceText string `json:"source_text"`
OccurredAt string `json:"occurred_at"`
TraceID string `json:"trace_id,omitempty"`
}
func buildMemoryExtractSystemPrompt(override string) string {
override = strings.TrimSpace(override)
if override != "" {
return override
}
return strings.TrimSpace(`你是一个”记忆守门员”。
你的任务是判断用户消息是否包含值得长期记住的信息,如有则提取。
请只输出 JSON 对象,不要输出解释、不要输出 markdown。
输出格式:
{
“message_intent”: “chitchat|task_request|knowledge_qa|preference|personal_fact|standing_instruction”,
“facts”: [
{
“memory_type”: “preference|constraint|fact”,
“title”: “短标题”,
“content”: “完整事实内容”,
“confidence”: 0.0,
“importance”: 0.0,
“sensitivity_level”: 0,
“is_explicit”: false
}
]
}
意图分类规则:
- chitchat闲聊、寒暄、情绪表达”你好””谢谢””我今天好累””嗯嗯”
- task_request一次性任务请求”帮我查天气””定个闹钟””帮我写个邮件”
- knowledge_qa知识问答、信息查询”什么是量子力学””北京明天多少度”
- preference用户偏好、习惯、口味”我喜欢吃辣””别用简称””我习惯用微信”
- personal_fact个人事实”我有两个孩子””我在上海工作””我老婆对花生过敏”
- standing_instruction持久指令”以后都用英文回复我””记住我的生日是3月5号”
规则:
1. 先判断 message_intent。chitchat / task_request / knowledge_qa 三类facts 输出空数组。
2. 只有 preference / personal_fact / standing_instruction 才提取 facts最多 3 条。
3. 一条消息可能同时包含任务和偏好(如”帮我查天气,记住我喜欢晴天”),此时 intent 取偏好类型facts 只保留偏好部分。
4. confidence 表示这条事实是否真的值得长期记,取 0 到 1。低于 0.5 的不要输出。
5. importance 表示对后续陪伴的价值,取 0 到 1。
6. sensitivity_level 取 0 到 2数字越大越敏感。
7. 用户明确说”记住”或”以后提醒我”时is_explicit 设为 true。
8. 宁可漏记也不要滥记。大多数消息不应该产生任何 facts。`)
}
func buildMemoryExtractUserPrompt(payload memorymodel.ExtractJobPayload) string {
request := memoryExtractPromptInput{
UserID: payload.UserID,
ConversationID: payload.ConversationID,
AssistantID: payload.AssistantID,
RunID: payload.RunID,
SourceMessageID: payload.SourceMessageID,
SourceRole: payload.SourceRole,
SourceText: payload.SourceText,
OccurredAt: payload.OccurredAt.Format("2006-01-02 15:04:05"),
TraceID: payload.TraceID,
}
raw, err := json.MarshalIndent(request, "", " ")
if err != nil {
return fmt.Sprintf("请分析这条用户消息,判断是否需要写入长期记忆:%s", payload.SourceText)
}
return fmt.Sprintf("请分析下面这条用户消息,判断 message_intent如包含值得长期记住的信息则提取 facts。\n输入\n%s",
string(raw))
}
func convertExtractResponse(resp *memoryExtractResponse) []memorymodel.FactCandidate {
if resp == nil {
return nil
}
// 意图过滤:跳过不需要记忆的消息类型。
// 兼容自定义 prompt不返回 message_intent 时跳过此检查,保持向后兼容)。
if intent := strings.TrimSpace(resp.MessageIntent); intent != "" {
if isSkipIntent(intent) {
return nil
}
}
if len(resp.Facts) == 0 {
return nil
}
result := make([]memorymodel.FactCandidate, 0, len(resp.Facts))
for _, fact := range resp.Facts {
memoryType := memorymodel.NormalizeMemoryType(fact.MemoryType)
if memoryType == "" {
continue
}
content := strings.TrimSpace(fact.Content)
if content == "" {
continue
}
confidence := clamp01(fact.Confidence)
if confidence == 0 {
confidence = 0.6
}
importance := clamp01(fact.Importance)
if importance == 0 {
importance = defaultImportanceByType(memoryType)
}
result = append(result, memorymodel.FactCandidate{
MemoryType: memoryType,
Title: strings.TrimSpace(fact.Title),
Content: content,
Confidence: confidence,
Importance: importance,
SensitivityLevel: clampInt(fact.SensitivityLevel, 0, 2),
IsExplicit: fact.IsExplicit,
})
}
return result
}
func fallbackNormalizedFacts(payload memorymodel.ExtractJobPayload) []memorymodel.NormalizedFact {
sourceText := strings.TrimSpace(payload.SourceText)
if sourceText == "" {
return nil
}
return memoryutils.NormalizeFacts([]memorymodel.FactCandidate{
{
MemoryType: memorymodel.MemoryTypeFact,
Title: buildFallbackTitle(sourceText),
Content: sourceText,
Confidence: 0.45,
Importance: defaultImportanceByType(memorymodel.MemoryTypeFact),
SensitivityLevel: 0,
IsExplicit: false,
},
})
}
func buildFallbackTitle(sourceText string) string {
runes := []rune(strings.TrimSpace(sourceText))
if len(runes) == 0 {
return "用户提到"
}
if len(runes) > 24 {
runes = runes[:24]
}
return "用户提到:" + string(runes)
}
func clampTemperature(v float64) float64 {
if v <= 0 {
return 0.1
}
if v > 1 {
return 1
}
return v
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func clampInt(v, minValue, maxValue int) int {
if v < minValue {
return minValue
}
if v > maxValue {
return maxValue
}
return v
}
func defaultImportanceByType(memoryType string) float64 {
switch memoryType {
case memorymodel.MemoryTypePreference:
return 0.85
case memorymodel.MemoryTypeConstraint:
return 0.95
default:
return 0.6
}
}
// isSkipIntent 判断意图是否属于"不需要记忆"的类别。
// chitchat / task_request / knowledge_qa 三类直接跳过,不产出任何候选事实。
func isSkipIntent(intent string) bool {
switch strings.ToLower(strings.TrimSpace(intent)) {
case "chitchat", "task_request", "knowledge_qa":
return true
default:
return false
}
}
func truncateForLog(raw *llmservice.TextResult) string {
if raw == nil {
return ""
}
text := strings.TrimSpace(raw.Text)
if len(text) <= 200 {
return text
}
return text[:200] + "..."
}

View File

@@ -0,0 +1,40 @@
package orchestrator
import (
"context"
"strings"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// WriteOrchestrator 是 Day1 的本地回退版本。
//
// 职责边界:
// 1. 只做最保守的“从 source_text 直接生成一条候选事实”;
// 2. 不依赖 LLM便于在模型不可用时保底
// 3. 后续会逐步被 LLM 版编排器取代,但不会直接删掉,方便回退。
type WriteOrchestrator struct{}
func NewWriteOrchestrator() *WriteOrchestrator {
return &WriteOrchestrator{}
}
// ExtractFacts 执行最小回退链路。
func (o *WriteOrchestrator) ExtractFacts(_ context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
sourceText := strings.TrimSpace(payload.SourceText)
if sourceText == "" {
return nil, nil
}
candidates := []memorymodel.FactCandidate{{
MemoryType: memorymodel.MemoryTypeFact,
Title: "用户提到",
Content: sourceText,
Confidence: 0.6,
Importance: 0.6,
SensitivityLevel: 0,
IsExplicit: false,
}}
return memoryutils.NormalizeFacts(candidates), nil
}

View File

@@ -0,0 +1,29 @@
package repo
import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
// AuditRepo 封装 memory_audit_logs 的数据访问。
type AuditRepo struct {
db *gorm.DB
}
func NewAuditRepo(db *gorm.DB) *AuditRepo {
return &AuditRepo{db: db}
}
func (r *AuditRepo) WithTx(tx *gorm.DB) *AuditRepo {
return &AuditRepo{db: tx}
}
func (r *AuditRepo) Create(ctx context.Context, log model.MemoryAuditLog) error {
if r == nil || r.db == nil {
return errors.New("memory audit repo is nil")
}
return r.db.WithContext(ctx).Create(&log).Error
}

View File

@@ -0,0 +1,568 @@
package repo
import (
"context"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
"gorm.io/gorm"
)
// ItemRepo 封装 memory_items 的数据访问。
//
// 职责边界:
// 1. 只负责表级读写,不承载注入、重排、审计决策;
// 2. 查询条件统一由 ItemQuery 表达,避免 service 层拼装 SQL
// 3. 软删除、访问时间刷新等状态变更也收敛到这里。
type ItemRepo struct {
db *gorm.DB
}
func NewItemRepo(db *gorm.DB) *ItemRepo {
return &ItemRepo{db: db}
}
func (r *ItemRepo) WithTx(tx *gorm.DB) *ItemRepo {
return &ItemRepo{db: tx}
}
// UpsertItems 批量写入记忆条目。
func (r *ItemRepo) UpsertItems(ctx context.Context, items []model.MemoryItem) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if len(items) == 0 {
return nil
}
for i := range items {
if err := r.db.WithContext(ctx).Create(&items[i]).Error; err != nil {
return err
}
}
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 按统一过滤条件读取记忆条目。
//
// 步骤化说明:
// 1. 先强制 user_id 过滤,避免跨用户串记忆;
// 2. 再按会话/助手/run 维度补充过滤IncludeGlobal=true 时允许读取对应全局条目;
// 3. 最后补状态、类型、过期时间和 limit返回稳定排序结果。
func (r *ItemRepo) FindByQuery(ctx context.Context, query memorymodel.ItemQuery) ([]model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
if query.UserID <= 0 {
return nil, errors.New("memory item query user_id is invalid")
}
db := r.db.WithContext(ctx).Model(&model.MemoryItem{}).Where("user_id = ?", query.UserID)
db = applyScopedEquality(db, "conversation_id", query.ConversationID, query.IncludeGlobal)
db = applyScopedEquality(db, "assistant_id", query.AssistantID, query.IncludeGlobal)
db = applyScopedEquality(db, "run_id", query.RunID, query.IncludeGlobal)
if len(query.Statuses) > 0 {
db = db.Where("status IN ?", query.Statuses)
}
if len(query.MemoryTypes) > 0 {
db = db.Where("memory_type IN ?", query.MemoryTypes)
}
if query.OnlyUnexpired {
now := query.Now
if now.IsZero() {
now = time.Now()
}
db = db.Where("(ttl_at IS NULL OR ttl_at > ?)", now)
}
if query.Limit > 0 {
db = db.Limit(query.Limit)
}
var items []model.MemoryItem
err := db.
Order("is_explicit DESC").
Order("importance DESC").
Order("updated_at DESC").
Find(&items).Error
return items, err
}
// FindPinnedByUser 读取“应优先注入”的结构化记忆。
//
// 步骤化说明:
// 1. 先在同一组 user/conversation/assistant/run 作用域下查 constraint保证硬约束不会因语义召回波动丢失
// 2. 再查高置信 preference并按 importance 降序裁到预算,避免偏好噪声过多;
// 3. 两路结果按“constraint 在前、preference 在后”拼接,后续由 service 层统一去重、排序和预算裁剪;
// 4. 这里不直接做最终预算,是因为读取侧还要和语义候选合并后统一重排。
func (r *ItemRepo) FindPinnedByUser(
ctx context.Context,
query memorymodel.ItemQuery,
preferenceLimit int,
) ([]model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
if query.UserID <= 0 {
return nil, errors.New("memory item query user_id is invalid")
}
includeConstraint := allowPinnedMemoryType(query.MemoryTypes, memorymodel.MemoryTypeConstraint)
includePreference := allowPinnedMemoryType(query.MemoryTypes, memorymodel.MemoryTypePreference)
if !includeConstraint && !includePreference {
return nil, nil
}
base := r.db.WithContext(ctx).Model(&model.MemoryItem{}).Where("user_id = ?", query.UserID)
base = applyScopedEquality(base, "conversation_id", query.ConversationID, query.IncludeGlobal)
base = applyScopedEquality(base, "assistant_id", query.AssistantID, query.IncludeGlobal)
base = applyScopedEquality(base, "run_id", query.RunID, query.IncludeGlobal)
base = applyPinnedUnexpiredScope(base, query)
result := make([]model.MemoryItem, 0, preferenceLimit+4)
if includeConstraint {
var constraints []model.MemoryItem
err := base.Session(&gorm.Session{}).
Where("memory_type = ? AND status = ?", memorymodel.MemoryTypeConstraint, model.MemoryItemStatusActive).
Order("importance DESC").
Order("updated_at DESC").
Find(&constraints).Error
if err != nil {
return nil, err
}
result = append(result, constraints...)
}
if includePreference {
if preferenceLimit <= 0 {
preferenceLimit = memorymodel.DefaultReadPreferenceLimit
}
var preferences []model.MemoryItem
err := base.Session(&gorm.Session{}).
Where("memory_type = ? AND confidence >= ? AND status = ?", memorymodel.MemoryTypePreference, 0.8, model.MemoryItemStatusActive).
Order("importance DESC").
Order("updated_at DESC").
Limit(preferenceLimit).
Find(&preferences).Error
if err != nil {
return nil, err
}
result = append(result, preferences...)
}
return result, nil
}
// GetByIDForUser 读取某个用户的一条记忆条目。
func (r *ItemRepo) GetByIDForUser(ctx context.Context, userID int, memoryID int64) (*model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
if userID <= 0 || memoryID <= 0 {
return nil, errors.New("memory item query params is invalid")
}
var item model.MemoryItem
err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", memoryID, userID).
First(&item).Error
if err != nil {
return nil, err
}
return &item, nil
}
// UpdateStatusByID 更新某条记忆的状态。
func (r *ItemRepo) UpdateStatusByID(ctx context.Context, userID int, memoryID int64, status string) error {
return r.UpdateStatusByIDAt(ctx, userID, memoryID, status, time.Now())
}
// UpdateStatusByIDAt 更新某条记忆的状态,并允许上层显式指定更新时间。
//
// 这样做的原因:
// 1. 管理侧删除时,需要让“库内更新时间”和“审计 after 快照时间”保持一致;
// 2. 读取侧若只是刷新 last_access_at不应该误改 updated_at
// 3. 因此把“更新时间来源”收口到 repo避免 service 层自己拼 SQL。
func (r *ItemRepo) UpdateStatusByIDAt(
ctx context.Context,
userID int,
memoryID int64,
status string,
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")
}
status = strings.TrimSpace(status)
if status == "" {
return errors.New("memory item status is empty")
}
if updatedAt.IsZero() {
updatedAt = time.Now()
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{UserID: userID}).
Where("id = ? AND user_id = ?", memoryID, userID).
Updates(map[string]any{
"status": status,
"updated_at": updatedAt,
}).Error
}
// TouchLastAccessAt 批量刷新记忆访问时间。
//
// 说明:
// 1. 这里只更新 last_access_at不更新 updated_at
// 2. 因为 updated_at 代表“内容被修改”的时间,不能被一次普通读取污染;
// 3. 否则后续读取重排会把“最近被读过的旧记忆”误判成“最近被更新的记忆”。
func (r *ItemRepo) TouchLastAccessAt(ctx context.Context, ids []int64, accessedAt time.Time) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if len(ids) == 0 {
return nil
}
if accessedAt.IsZero() {
accessedAt = time.Now()
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id IN ?", ids).
Updates(map[string]any{
"last_access_at": accessedAt,
}).Error
}
// UpdateVectorStateByID 更新单条记忆的向量同步桥接状态。
//
// 说明:
// 1. 这里只更新 vector_status/vector_id不更新 updated_at
// 2. 因为向量同步属于索引层状态,不代表记忆内容本身被修改;
// 3. 若误改 updated_at会污染读取侧的时间排序语义。
func (r *ItemRepo) UpdateVectorStateByID(
ctx context.Context,
memoryID int64,
vectorStatus string,
vectorID *string,
) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if memoryID <= 0 {
return errors.New("memory item vector update id is invalid")
}
vectorStatus = strings.TrimSpace(vectorStatus)
if vectorStatus == "" {
return errors.New("memory item vector status is empty")
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id = ?", memoryID).
UpdateColumns(map[string]any{
"vector_status": vectorStatus,
"vector_id": vectorID,
}).Error
}
// FindActiveByHash 按用户和内容哈希精确查找活跃记忆。
//
// 用途:
// 1. 决策层 Step 1 的 Hash 精确命中检查;
// 2. 利用 idx_memory_items_user_type_hash 联合索引,避免全表扫描;
// 3. 只返回 status=active 的记录,软删除记录不参与去重。
func (r *ItemRepo) FindActiveByHash(ctx context.Context, userID int, contentHash string) ([]model.MemoryItem, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory item repo is nil")
}
if userID <= 0 || strings.TrimSpace(contentHash) == "" {
return nil, errors.New("memory item find by hash params is invalid")
}
var items []model.MemoryItem
err := r.db.WithContext(ctx).
Where("user_id = ? AND content_hash = ? AND status = ?", userID, contentHash, model.MemoryItemStatusActive).
Find(&items).Error
return items, err
}
// UpdateContentByID 更新指定记忆的内容相关字段。
//
// 步骤化说明:
// 1. 只改 title/content/normalized_content/content_hash/confidence/importance 六个字段;
// 2. 不改 status/user_id/memory_type 等身份字段,保证更新操作不改变记忆归属;
// 3. updated_at 由 GORM AutoUpdateTime 自动维护。
func (r *ItemRepo) UpdateContentByID(ctx context.Context, memoryID int64, fields memorymodel.UpdateContentFields) error {
if r == nil || r.db == nil {
return errors.New("memory item repo is nil")
}
if memoryID <= 0 {
return errors.New("memory item update content id is invalid")
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{}).
Where("id = ?", memoryID).
Updates(map[string]any{
"title": fields.Title,
"content": fields.Content,
"normalized_content": fields.NormalizedContent,
"content_hash": fields.ContentHash,
"confidence": fields.Confidence,
"importance": fields.Importance,
}).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{UserID: userID}).
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 软删除指定用户的某条记忆。
//
// 说明:
// 1. 复用 UpdateStatusByIDAt 的逻辑模式,把 status 改为 deleted
// 2. 同时把 vector_status 重置为 pending确保向量侧也能感知删除
// 3. 必须带 user_id 条件,避免跨用户误删。
func (r *ItemRepo) SoftDeleteByID(ctx context.Context, userID int, memoryID int64) 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 soft delete params is invalid")
}
return r.db.WithContext(ctx).
Model(&model.MemoryItem{UserID: userID}).
Where("id = ? AND user_id = ?", memoryID, userID).
Updates(map[string]any{
"status": model.MemoryItemStatusDeleted,
"vector_status": "pending",
"updated_at": time.Now(),
}).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{UserID: userID}).
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 {
value = strings.TrimSpace(value)
if value == "" {
return db
}
if includeGlobal {
return db.Where("("+column+" = ? OR "+column+" IS NULL)", value)
}
return db.Where(column+" = ?", value)
}
func applyPinnedUnexpiredScope(db *gorm.DB, query memorymodel.ItemQuery) *gorm.DB {
if db == nil || !query.OnlyUnexpired {
return db
}
now := query.Now
if now.IsZero() {
now = time.Now()
}
return db.Where("(ttl_at IS NULL OR ttl_at > ?)", now)
}
func allowPinnedMemoryType(memoryTypes []string, target string) bool {
if len(memoryTypes) == 0 {
return true
}
target = memorymodel.NormalizeMemoryType(target)
for _, item := range memoryTypes {
if memorymodel.NormalizeMemoryType(item) == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,222 @@
package repo
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/LoveLosita/smartflow/backend/model"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// JobRepo 封装 memory_jobs 的数据访问。
type JobRepo struct {
db *gorm.DB
}
func NewJobRepo(db *gorm.DB) *JobRepo {
return &JobRepo{db: db}
}
func (r *JobRepo) WithTx(tx *gorm.DB) *JobRepo {
return &JobRepo{db: tx}
}
// CreatePendingExtractJob 创建“待抽取”任务(幂等写入)。
//
// 失败语义:
// 1. 参数非法直接返回 error由上游决定 dead 或重试;
// 2. 同幂等键重复写入采用 DoNothing保证无副作用。
func (r *JobRepo) CreatePendingExtractJob(
ctx context.Context,
payload memorymodel.ExtractJobPayload,
sourceEventID string,
) error {
if r == nil || r.db == nil {
return errors.New("memory job repo is nil")
}
if payload.UserID <= 0 {
return errors.New("invalid user_id")
}
if payload.IdempotencyKey == "" {
return errors.New("idempotency_key is empty")
}
rawPayload, err := json.Marshal(payload)
if err != nil {
return err
}
now := time.Now()
job := model.MemoryJob{
UserID: payload.UserID,
ConversationID: strPtrOrNil(payload.ConversationID),
SourceMessageID: int64PtrOrNil(payload.SourceMessageID),
SourceEventID: strPtrOrNil(sourceEventID),
JobType: model.MemoryJobTypeExtract,
IdempotencyKey: payload.IdempotencyKey,
PayloadJSON: string(rawPayload),
Status: model.MemoryJobStatusPending,
RetryCount: 0,
MaxRetry: 6,
NextRetryAt: &now,
}
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "idempotency_key"}},
DoNothing: true,
}).
Create(&job).Error
}
// ClaimNextRunnableExtractJob 抢占一个可执行的 extract 任务。
//
// 抢占规则:
// 1. 只从 pending/failed 中挑 next_retry_at 已到期任务;
// 2. 用行锁避免多个 worker 抢到同一条任务;
// 3. 抢占成功后立即置为 processing防止重复执行。
func (r *JobRepo) ClaimNextRunnableExtractJob(ctx context.Context, now time.Time) (*model.MemoryJob, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory job repo is nil")
}
var claimed *model.MemoryJob
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job model.MemoryJob
query := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("job_type = ?", model.MemoryJobTypeExtract).
Where("status IN ?", []string{model.MemoryJobStatusPending, model.MemoryJobStatusFailed}).
Where("(next_retry_at IS NULL OR next_retry_at <= ?)", now).
Order("id ASC").
Limit(1).
Find(&job)
if query.Error != nil {
return query.Error
}
if query.RowsAffected == 0 {
return nil
}
updates := map[string]any{
"status": model.MemoryJobStatusProcessing,
"updated_at": now,
"last_error": nil,
}
if updateErr := tx.Model(&model.MemoryJob{}).Where("id = ?", job.ID).Updates(updates).Error; updateErr != nil {
return updateErr
}
job.Status = model.MemoryJobStatusProcessing
job.UpdatedAt = &now
claimed = &job
return nil
})
if err != nil {
return nil, err
}
return claimed, nil
}
// MarkSuccess 把任务推进为 success 最终态。
func (r *JobRepo) MarkSuccess(ctx context.Context, jobID int64) error {
if r == nil || r.db == nil {
return errors.New("memory job repo is nil")
}
now := time.Now()
updates := map[string]any{
"status": model.MemoryJobStatusSuccess,
"last_error": nil,
"next_retry_at": nil,
"updated_at": now,
}
return r.db.WithContext(ctx).Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
}
// MarkFailed 按重试策略推进任务到 failed/dead。
//
// 规则:
// 1. retry_count +1 后若超上限,直接 dead
// 2. 未超上限则写 failed 并设置 next_retry_at。
func (r *JobRepo) MarkFailed(ctx context.Context, jobID int64, reason string) error {
if r == nil || r.db == nil {
return errors.New("memory job repo is nil")
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job model.MemoryJob
queryErr := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", jobID).
First(&job).Error
if queryErr != nil {
return queryErr
}
if job.Status == model.MemoryJobStatusSuccess || job.Status == model.MemoryJobStatusDead {
return nil
}
maxRetry := job.MaxRetry
if maxRetry <= 0 {
maxRetry = 6
}
nextRetryCount := job.RetryCount + 1
now := time.Now()
status := model.MemoryJobStatusFailed
var nextRetryAt *time.Time
if nextRetryCount >= maxRetry {
status = model.MemoryJobStatusDead
nextRetryAt = nil
} else {
t := now.Add(calcRetryBackoff(nextRetryCount))
nextRetryAt = &t
}
lastErr := truncateError(reason)
updates := map[string]any{
"status": status,
"retry_count": nextRetryCount,
"last_error": &lastErr,
"next_retry_at": nextRetryAt,
"updated_at": now,
}
return tx.Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
})
}
func calcRetryBackoff(retryCount int) time.Duration {
if retryCount <= 0 {
return time.Second
}
if retryCount > 6 {
retryCount = 6
}
return time.Second * time.Duration(1<<(retryCount-1))
}
func truncateError(reason string) string {
if len(reason) <= 2000 {
return reason
}
return reason[:2000]
}
func strPtrOrNil(v string) *string {
if v == "" {
return nil
}
value := v
return &value
}
func int64PtrOrNil(v int64) *int64 {
if v <= 0 {
return nil
}
value := v
return &value
}

View File

@@ -0,0 +1,64 @@
package repo
import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SettingsRepo 封装 memory_user_settings 的读写。
type SettingsRepo struct {
db *gorm.DB
}
func NewSettingsRepo(db *gorm.DB) *SettingsRepo {
return &SettingsRepo{db: db}
}
func (r *SettingsRepo) WithTx(tx *gorm.DB) *SettingsRepo {
return &SettingsRepo{db: tx}
}
// GetByUserID 读取用户记忆设置。
//
// 返回语义:
// 1. 命中时返回真实记录;
// 2. 未命中时返回 nil,nil由上层决定是否走默认开关
// 3. 不在仓储层偷偷补默认值,避免写路径和读路径语义不一致。
func (r *SettingsRepo) GetByUserID(ctx context.Context, userID int) (*model.MemoryUserSetting, error) {
if r == nil || r.db == nil {
return nil, errors.New("memory settings repo is nil")
}
if userID <= 0 {
return nil, errors.New("memory settings user_id is invalid")
}
var setting model.MemoryUserSetting
query := r.db.WithContext(ctx).Where("user_id = ?", userID).Limit(1).Find(&setting)
if query.Error != nil {
return nil, query.Error
}
if query.RowsAffected == 0 {
return nil, nil
}
return &setting, nil
}
// Upsert 写入用户记忆设置。
func (r *SettingsRepo) Upsert(ctx context.Context, setting model.MemoryUserSetting) error {
if r == nil || r.db == nil {
return errors.New("memory settings repo is nil")
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"memory_enabled",
"implicit_memory_enabled",
"sensitive_memory_enabled",
"updated_at",
}),
}).Create(&setting).Error
}

View File

@@ -0,0 +1,149 @@
package service
import (
"strings"
"github.com/LoveLosita/smartflow/backend/model"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
func toItemDTO(item model.MemoryItem) memorymodel.ItemDTO {
return memorymodel.ItemDTO{
ID: 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,
ContentHash: fallbackContentHash(item.MemoryType, item.Content, strValue(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,
}
}
func toItemDTOs(items []model.MemoryItem) []memorymodel.ItemDTO {
if len(items) == 0 {
return nil
}
result := make([]memorymodel.ItemDTO, 0, len(items))
for _, item := range items {
result = append(result, toItemDTO(item))
}
return result
}
func toUserSettingDTO(setting model.MemoryUserSetting) memorymodel.UserSettingDTO {
return memorymodel.UserSettingDTO{
UserID: setting.UserID,
MemoryEnabled: setting.MemoryEnabled,
ImplicitMemoryEnabled: setting.ImplicitMemoryEnabled,
SensitiveMemoryEnabled: setting.SensitiveMemoryEnabled,
UpdatedAt: setting.UpdatedAt,
}
}
func normalizeMemoryTypes(raw []string) []string {
if len(raw) == 0 {
return nil
}
result := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
normalized := memorymodel.NormalizeMemoryType(item)
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
func normalizeManageStatuses(raw []string) []string {
if len(raw) == 0 {
return []string{
model.MemoryItemStatusActive,
model.MemoryItemStatusArchived,
}
}
result := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
status := strings.ToLower(strings.TrimSpace(item))
if status != model.MemoryItemStatusActive &&
status != model.MemoryItemStatusArchived &&
status != model.MemoryItemStatusDeleted {
continue
}
if _, exists := seen[status]; exists {
continue
}
seen[status] = struct{}{}
result = append(result, status)
}
if len(result) == 0 {
return []string{
model.MemoryItemStatusActive,
model.MemoryItemStatusArchived,
}
}
return result
}
func normalizeLimit(limit, defaultValue, maxValue int) int {
if limit <= 0 {
limit = defaultValue
}
if maxValue > 0 && limit > maxValue {
return maxValue
}
return limit
}
func strValue(v *string) string {
if v == nil {
return ""
}
return strings.TrimSpace(*v)
}
// fallbackContentHash 返回条目可用于服务级去重的内容哈希。
//
// 说明:
// 1. 优先复用库内已落表的 content_hash避免同一条数据多套算法口径不一致
// 2. 若历史数据或 RAG metadata 没带 hash则按“类型 + 规范化内容”补算;
// 3. 若类型非法或正文为空,则返回空字符串,让上游继续走文本兜底去重。
func fallbackContentHash(memoryType, content, currentHash string) string {
currentHash = strings.TrimSpace(currentHash)
if currentHash != "" {
return currentHash
}
normalizedType := memorymodel.NormalizeMemoryType(memoryType)
normalizedContent := normalizeContentForHash(content)
if normalizedType == "" || normalizedContent == "" {
return ""
}
return memoryutils.HashContent(normalizedType, normalizedContent)
}
func normalizeContentForHash(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
return strings.ToLower(strings.Join(strings.Fields(content), " "))
}

View File

@@ -0,0 +1,91 @@
package service
import (
"time"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
"github.com/spf13/viper"
)
// LoadConfigFromViper 读取记忆模块配置并做默认值兜底。
//
// 默认策略:
// 1. temperature/top_p 使用低随机参数,提升可复现性;
// 2. Day1 先提供参数位,不强制所有参数立即生效;
// 3. 轮询与重试参数给出保守默认值,避免对主链路造成压力。
func LoadConfigFromViper() memorymodel.Config {
cfg := memorymodel.Config{
Enabled: viper.GetBool("memory.enabled"),
RAGEnabled: viper.GetBool("memory.rag.enabled"),
ReadMode: memorymodel.NormalizeReadMode(viper.GetString("memory.read.mode")),
InjectRenderMode: memorymodel.NormalizeInjectRenderMode(viper.GetString("memory.inject.renderMode")),
ExtractPrompt: viper.GetString("memory.prompt.extract"),
DecisionPrompt: viper.GetString("memory.prompt.decision"),
Threshold: viper.GetFloat64("memory.threshold"),
EnableReranker: viper.GetBool("memory.enableReranker"),
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
LLMTopP: viper.GetFloat64("memory.llm.topP"),
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"),
ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"),
ReadFactLimit: viper.GetInt("memory.read.factLimit"),
// 决策层配置:默认关闭,灰度开启后才会生效。
DecisionEnabled: viper.GetBool("memory.decision.enabled"),
DecisionCandidateTopK: viper.GetInt("memory.decision.candidateTopK"),
DecisionCandidateMinScore: viper.GetFloat64("memory.decision.candidateMinScore"),
DecisionFallbackMode: viper.GetString("memory.decision.fallbackMode"),
WriteMode: viper.GetString("memory.write.mode"),
WriteMinConfidence: viper.GetFloat64("memory.write.minConfidence"),
LLMThinking: viper.GetBool("agent.thinking.memory"),
}
if cfg.Threshold <= 0 {
cfg.Threshold = 0.55
}
if cfg.LLMTemperature <= 0 {
cfg.LLMTemperature = 0.1
}
if cfg.LLMTopP <= 0 {
cfg.LLMTopP = 0.2
}
if cfg.JobMaxRetry <= 0 {
cfg.JobMaxRetry = 6
}
if cfg.WorkerPollEvery <= 0 {
cfg.WorkerPollEvery = 2 * time.Second
}
if cfg.WorkerClaimBatch <= 0 {
cfg.WorkerClaimBatch = 1
}
cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit()
cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit()
cfg.ReadFactLimit = cfg.EffectiveReadFactLimit()
cfg.ReadMode = cfg.EffectiveReadMode()
cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode()
// 决策层配置默认值兜底。
// 说明:
// 1. TopK 和 MinScore 是 Milvus 召回参数,需要保守默认值避免召回过多噪声候选;
// 2. FallbackMode 默认退回旧路径新增,保证决策流程异常时不丢数据;
// 3. WriteMode 由 DecisionEnabled 隐式决定,这里不做强制联动。
if cfg.DecisionCandidateTopK <= 0 {
cfg.DecisionCandidateTopK = 5
}
if cfg.DecisionCandidateMinScore <= 0 {
cfg.DecisionCandidateMinScore = 0.6
}
if cfg.DecisionFallbackMode == "" {
cfg.DecisionFallbackMode = "legacy_add"
}
if cfg.WriteMode == "" {
cfg.WriteMode = "legacy"
}
if cfg.WriteMinConfidence <= 0 {
cfg.WriteMinConfidence = 0.5
}
return cfg
}

View File

@@ -0,0 +1,33 @@
package service
import (
"context"
"errors"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// EnqueueService 是 Day1 的“任务入队门面”。
//
// 职责边界:
// 1. 只负责把抽取请求入 memory_jobs
// 2. 不负责执行抽取、不负责写 memory_items。
type EnqueueService struct {
jobRepo *memoryrepo.JobRepo
}
func NewEnqueueService(jobRepo *memoryrepo.JobRepo) *EnqueueService {
return &EnqueueService{jobRepo: jobRepo}
}
func (s *EnqueueService) EnqueueExtractJob(
ctx context.Context,
payload memorymodel.ExtractJobPayload,
sourceEventID string,
) error {
if s == nil || s.jobRepo == nil {
return errors.New("memory enqueue service is nil")
}
return s.jobRepo.CreatePendingExtractJob(ctx, payload, sourceEventID)
}

View File

@@ -0,0 +1,659 @@
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/services/memory/internal/vectorsync"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
"gorm.io/gorm"
)
const (
defaultManageListLimit = 20
maxManageListLimit = 100
defaultManualConfidence = 0.95
defaultManualImportance = 0.90
)
// ManageService 负责 memory 模块内部的管理面能力。
//
// 职责边界:
// 1. 负责“列出记忆 / 删除记忆 / 读取与更新用户开关”这类维护动作;
// 2. 负责把用户主动管理行为补充进 memory_audit_logs
// 3. 不负责 prompt 注入、不负责向量召回,也不负责后台抽取任务执行。
type ManageService struct {
db *gorm.DB
itemRepo *memoryrepo.ItemRepo
auditRepo *memoryrepo.AuditRepo
settingsRepo *memoryrepo.SettingsRepo
vectorSyncer *memoryvectorsync.Syncer
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
}
func NewManageService(
db *gorm.DB,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
settingsRepo *memoryrepo.SettingsRepo,
vectorSyncer *memoryvectorsync.Syncer,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *ManageService {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &ManageService{
db: db,
itemRepo: itemRepo,
auditRepo: auditRepo,
settingsRepo: settingsRepo,
vectorSyncer: vectorSyncer,
observer: observer,
metrics: metrics,
}
}
// ListItems 列出某个用户当前可管理的记忆条目。
//
// 说明:
// 1. 这里面向“管理视角”,不会按用户开关再做二次过滤;
// 2. 即便用户暂时关闭 memory总览页仍需要看见已有记忆便于手动删除或核对
// 3. 默认只返回 active/archived除非显式传入 deleted。
func (s *ManageService) ListItems(ctx context.Context, req memorymodel.ListItemsRequest) ([]memorymodel.ItemDTO, error) {
if s == nil || s.itemRepo == nil {
return nil, errors.New("memory manage service is nil")
}
if req.UserID <= 0 {
return nil, nil
}
conversationID := strings.TrimSpace(req.ConversationID)
query := memorymodel.ItemQuery{
UserID: req.UserID,
ConversationID: conversationID,
Statuses: normalizeManageStatuses(req.Statuses),
MemoryTypes: normalizeMemoryTypes(req.MemoryTypes),
IncludeGlobal: conversationID != "",
OnlyUnexpired: false,
Limit: normalizeLimit(req.Limit, defaultManageListLimit, maxManageListLimit),
}
items, err := s.itemRepo.FindByQuery(ctx, query)
if err != nil {
return nil, err
}
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 软删除一条记忆,并补写审计日志。
//
// 步骤化说明:
// 1. 先在事务里读取当前条目快照,确保审计前镜像和实际删除对象一致;
// 2. 若该条目已是 deleted则直接按幂等语义返回避免重复写多条删除审计
// 3. 状态更新成功后再写 audit log保证“有删除就有审计”失败时整笔事务回滚。
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 {
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
}
now := time.Now()
operatorType := memoryutils.NormalizeOperatorType(req.OperatorType)
reason := normalizeDeleteReason(req.Reason)
var deletedItem model.MemoryItem
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemRepo := s.itemRepo.WithTx(tx)
auditRepo := s.auditRepo.WithTx(tx)
current, err := itemRepo.GetByIDForUser(ctx, req.UserID, req.MemoryID)
if err != nil {
return err
}
if current.Status == model.MemoryItemStatusDeleted {
deletedItem = *current
return nil
}
before := *current
after := before
after.Status = model.MemoryItemStatusDeleted
after.UpdatedAt = &now
after.VectorStatus = "pending"
if err = itemRepo.SoftDeleteByID(ctx, req.UserID, req.MemoryID); err != nil {
return err
}
audit := memoryutils.BuildItemAuditLog(
req.MemoryID,
req.UserID,
memoryutils.AuditOperationDelete,
operatorType,
reason,
&before,
&after,
)
if err = auditRepo.Create(ctx, audit); err != nil {
return err
}
deletedItem = after
return nil
})
if err != nil {
err = translateManageError(err)
s.recordManageAction(ctx, "delete", req.UserID, req.MemoryID, "", false, err)
return nil, err
}
if deletedItem.ID <= 0 {
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)
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 返回用户当前生效的记忆开关。
//
// 返回语义:
// 1. 若数据库中还没有记录,返回系统默认开关,而不是 nil
// 2. 这样前端/上层调用方始终拿到完整结构,避免再做一层判空补默认值;
// 3. 这里只读 settings不附带修改动作。
func (s *ManageService) GetUserSetting(ctx context.Context, userID int) (memorymodel.UserSettingDTO, error) {
if s == nil || s.settingsRepo == nil {
return memorymodel.UserSettingDTO{}, errors.New("memory manage service is nil")
}
if userID <= 0 {
return memorymodel.UserSettingDTO{}, nil
}
setting, err := s.settingsRepo.GetByUserID(ctx, userID)
if err != nil {
return memorymodel.UserSettingDTO{}, err
}
return toUserSettingDTO(memoryutils.EffectiveUserSetting(setting, userID)), nil
}
// UpsertUserSetting 写入用户记忆开关。
//
// 说明:
// 1. 当前阶段先直接覆盖三类开关,不做 patch 语义;
// 2. 这样便于前端把整块设置表单一次性提交,接口语义更稳定;
// 3. 若后续需要记录设置变更审计,再单独扩展 setting audit而不是复用 item audit。
func (s *ManageService) UpsertUserSetting(ctx context.Context, req memorymodel.UpdateUserSettingRequest) (memorymodel.UserSettingDTO, error) {
if s == nil || s.settingsRepo == nil {
return memorymodel.UserSettingDTO{}, errors.New("memory manage service is nil")
}
if req.UserID <= 0 {
return memorymodel.UserSettingDTO{}, nil
}
now := time.Now()
setting := model.MemoryUserSetting{
UserID: req.UserID,
MemoryEnabled: req.MemoryEnabled,
ImplicitMemoryEnabled: req.ImplicitMemoryEnabled,
SensitiveMemoryEnabled: req.SensitiveMemoryEnabled,
UpdatedAt: &now,
}
if err := s.settingsRepo.Upsert(ctx, setting); err != nil {
return memorymodel.UserSettingDTO{}, err
}
return toUserSettingDTO(setting), nil
}
func normalizeDeleteReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return "用户删除记忆"
}
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

@@ -0,0 +1,83 @@
package service
import (
"time"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
)
// buildReadScopedItemQuery 构造读侧统一使用的 MySQL 查询条件。
//
// 职责边界:
// 1. 只负责把 RetrieveRequest 映射成“读侧作用域”查询参数;
// 2. 不负责真正查库,也不负责排序、裁剪或注入;
// 3. conversation_id 字段在这里刻意不参与过滤,仅保留在记忆记录元数据里供审计与溯源使用。
//
// 步骤化说明:
// 1. 读侧始终按 user_id 作为硬隔离边界,避免跨用户串记忆。
// 2. assistant_id / run_id 仍允许参与过滤,因为它们表达的是助手实例与执行轮次边界,而不是“是否跨对话召回”的问题。
// 3. conversation_id 明确置空,原因是聊天上下文窗口已经覆盖同对话信息;记忆读侧的价值主要在跨对话补充。
func buildReadScopedItemQuery(
req memorymodel.RetrieveRequest,
now time.Time,
statuses []string,
limit int,
) memorymodel.ItemQuery {
return memorymodel.ItemQuery{
UserID: req.UserID,
ConversationID: "",
AssistantID: req.AssistantID,
RunID: req.RunID,
Statuses: statuses,
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
IncludeGlobal: true,
OnlyUnexpired: true,
Limit: limit,
Now: now,
}
}
// buildReadScopedRAGRequest 构造读侧统一使用的 RAG 检索请求。
//
// 职责边界:
// 1. 只负责生成 memory 检索请求,不负责执行向量检索;
// 2. 不负责阈值外的重排、fallback 或去重;
// 3. conversation_id 字段同样只保留在文档 metadata 中,不再作为聊天读侧的硬过滤条件。
//
// 步骤化说明:
// 1. user_id 仍是唯一必须保留的硬过滤条件,确保召回范围限定在当前用户。
// 2. conversation_id 明确置空,避免旧对话记忆在进入相似度计算前就被 metadata filter 提前挡掉。
// 3. assistant_id / run_id 保持透传,方便后续若存在多助手场景时继续做更细粒度隔离。
func buildReadScopedRAGRequest(
req memorymodel.RetrieveRequest,
topK int,
threshold float64,
) ragservice.MemoryRetrieveRequest {
return ragservice.MemoryRetrieveRequest{
Query: req.Query,
TopK: topK,
Threshold: threshold,
Action: "search",
UserID: req.UserID,
ConversationID: "",
AssistantID: req.AssistantID,
RunID: req.RunID,
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
}
}
// shouldReturnSemanticRAGResult 判断当前是否可以直接采用 RAG 结果。
//
// 职责边界:
// 1. 只负责表达“RAG 是否足以短路后续 MySQL fallback”这一条业务规则
// 2. 不负责执行任何检索,也不负责日志记录;
// 3. 返回 false 不代表错误,只代表调用方应继续尝试数据库兜底。
//
// 步骤化说明:
// 1. RAG 报错时,一定不能短路,必须继续走 MySQL fallback。
// 2. RAG 0 命中时,同样不能短路;否则会把“成功执行但没有候选”误当成最终结果。
// 3. 只有“无报错且结果非空”时,才允许直接返回 RAG 结果。
func shouldReturnSemanticRAGResult(items []memorymodel.ItemDTO, err error) bool {
return err == nil && len(items) > 0
}

View File

@@ -0,0 +1,438 @@
package service
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
)
const (
defaultRetrieveLimit = 5
maxRetrieveLimit = 20
)
// ReadService 负责 memory 模块内部的读取、门控与轻量重排。
//
// 职责边界:
// 1. 负责把 memory_items 读出来并做用户设置过滤;
// 2. 负责最小可用的排序与截断,为后续 prompt 注入提供稳定入口;
// 3. 不直接依赖 agent不负责真正把记忆拼进 prompt。
type ReadService struct {
itemRepo *memoryrepo.ItemRepo
settingsRepo *memoryrepo.SettingsRepo
ragRuntime ragservice.Runtime
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(
itemRepo *memoryrepo.ItemRepo,
settingsRepo *memoryrepo.SettingsRepo,
ragRuntime ragservice.Runtime,
cfg memorymodel.Config,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *ReadService {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &ReadService{
itemRepo: itemRepo,
settingsRepo: settingsRepo,
ragRuntime: ragRuntime,
cfg: cfg,
observer: observer,
metrics: metrics,
}
}
// Retrieve 读取可供后续注入使用的候选记忆。
func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error) {
if s == nil || s.itemRepo == nil || s.settingsRepo == nil {
return nil, nil
}
if req.UserID <= 0 {
return nil, nil
}
now := req.Now
if now.IsZero() {
now = time.Now()
}
telemetry := retrieveTelemetry{
ReadMode: s.cfg.EffectiveReadMode(),
QueryLen: len(strings.TrimSpace(req.Query)),
}
setting, err := s.settingsRepo.GetByUserID(ctx, req.UserID)
if err != nil {
s.recordRetrieve(ctx, req, telemetry, err)
return nil, err
}
effectiveSetting := memoryutils.EffectiveUserSetting(setting, req.UserID)
if !effectiveSetting.MemoryEnabled {
return nil, nil
}
limit := normalizeLimit(req.Limit, defaultRetrieveLimit, maxRetrieveLimit)
if s.cfg.EffectiveReadMode() == memorymodel.MemoryReadModeHybrid {
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) != "" {
items, ragErr := s.retrieveByRAG(ctx, req, effectiveSetting, limit, now)
if ragErr == nil && len(items) > 0 {
telemetry.SemanticHitCount = len(items)
telemetry.FinalCount = len(items)
s.recordRetrieve(ctx, req, telemetry, nil)
return items, nil
}
telemetry.Degraded = true
telemetry.RAGFallbackUsed = true
}
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(
ctx context.Context,
req memorymodel.RetrieveRequest,
limit int,
now time.Time,
effectiveSetting model.MemoryUserSetting,
) ([]memorymodel.ItemDTO, error) {
if !effectiveSetting.MemoryEnabled {
return nil, nil
}
query := buildReadScopedItemQuery(
req,
now,
[]string{model.MemoryItemStatusActive},
normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
)
items, err := s.itemRepo.FindByQuery(ctx, query)
if err != nil {
return nil, err
}
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
if len(items) == 0 {
return nil, nil
}
sort.SliceStable(items, func(i, j int) bool {
left := scoreRetrievedItem(items[i], now)
right := scoreRetrievedItem(items[j], now)
if left == right {
return items[i].ID > items[j].ID
}
return left > right
})
if len(items) > limit {
items = items[:limit]
}
_ = s.itemRepo.TouchLastAccessAt(ctx, collectMemoryIDs(items), now)
return toItemDTOs(items), nil
}
func (s *ReadService) retrieveByRAG(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
limit int,
now time.Time,
) ([]memorymodel.ItemDTO, error) {
if !effectiveSetting.MemoryEnabled {
return nil, nil
}
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, limit, s.cfg.Threshold))
if err != nil || result == nil || len(result.Items) == 0 {
return nil, err
}
items := make([]memorymodel.ItemDTO, 0, len(result.Items))
ids := make([]int64, 0, len(result.Items))
for _, hit := range result.Items {
dto, memoryID := buildMemoryDTOFromRetrieveHit(hit)
if !effectiveSetting.ImplicitMemoryEnabled && !dto.IsExplicit {
continue
}
if !effectiveSetting.SensitiveMemoryEnabled && dto.SensitivityLevel > 0 {
continue
}
if dto.ID <= 0 && memoryID > 0 {
dto.ID = memoryID
}
items = append(items, dto)
if dto.ID > 0 {
ids = append(ids, dto.ID)
}
}
if len(items) > limit {
items = items[:limit]
}
_ = s.itemRepo.TouchLastAccessAt(ctx, ids, now)
return items, nil
}
func normalizeRetrieveMemoryTypes(raw []string) []string {
normalized := normalizeMemoryTypes(raw)
if len(normalized) > 0 {
return normalized
}
return []string{
memorymodel.MemoryTypeConstraint,
memorymodel.MemoryTypePreference,
memorymodel.MemoryTypeFact,
}
}
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 读链路的确定性排序分数。
//
// 说明:
// 1. 这里只保留 importance / confidence / recency / explicit / type 这些稳定特征;
// 2. conversation_id 已不再参与读侧打分,因为同对话信息本就已经在上下文窗口内;
// 3. 若后续需要引入语义分或 reranker应在 DTO 层补齐对应字段后再统一并入。
func scoreRetrievedItem(item model.MemoryItem, now time.Time) float64 {
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScore(item, now)
if item.IsExplicit {
score += 0.1
}
switch item.MemoryType {
case memorymodel.MemoryTypeConstraint:
score += 0.12
case memorymodel.MemoryTypePreference:
score += 0.08
}
return score
}
func recencyScore(item model.MemoryItem, now time.Time) float64 {
base := item.UpdatedAt
if base == nil {
base = item.CreatedAt
}
if base == nil || now.Before(*base) {
return 0.5
}
age := now.Sub(*base)
switch {
case age <= 24*time.Hour:
return 1
case age <= 7*24*time.Hour:
return 0.85
case age <= 30*24*time.Hour:
return 0.65
case age <= 90*24*time.Hour:
return 0.45
default:
return 0.25
}
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func collectMemoryIDs(items []model.MemoryItem) []int64 {
if len(items) == 0 {
return nil
}
ids := make([]int64, 0, len(items))
for _, item := range items {
if item.ID <= 0 {
continue
}
ids = append(ids, item.ID)
}
return ids
}
func buildMemoryDTOFromRetrieveHit(hit ragservice.RetrieveHit) (memorymodel.ItemDTO, int64) {
memoryID := parseMemoryIDFromDocumentID(hit.DocumentID)
metadata := hit.Metadata
content := strings.TrimSpace(hit.Text)
memoryType := readString(metadata["memory_type"])
dto := memorymodel.ItemDTO{
ID: memoryID,
UserID: int(readFloatLike(metadata["user_id"])),
ConversationID: readString(metadata["conversation_id"]),
AssistantID: readString(metadata["assistant_id"]),
RunID: readString(metadata["run_id"]),
MemoryType: memoryType,
Title: readString(metadata["title"]),
Content: content,
ContentHash: fallbackContentHash(memoryType, content, readString(metadata["content_hash"])),
Confidence: readFloatLike(metadata["confidence"]),
Importance: readFloatLike(metadata["importance"]),
SensitivityLevel: int(readFloatLike(metadata["sensitivity_level"])),
IsExplicit: readBoolLike(metadata["is_explicit"]),
Status: readString(metadata["status"]),
TTLAt: readTimeLike(metadata["ttl_at"]),
}
return dto, memoryID
}
func parseMemoryIDFromDocumentID(documentID string) int64 {
documentID = strings.TrimSpace(documentID)
if !strings.HasPrefix(documentID, "memory:") {
return 0
}
raw := strings.TrimPrefix(documentID, "memory:")
if strings.HasPrefix(raw, "uid:") {
return 0
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return parsed
}
func readString(v any) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
func readFloatLike(v any) float64 {
switch value := v.(type) {
case float64:
return value
case float32:
return float64(value)
case int:
return float64(value)
case int64:
return float64(value)
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err == nil {
return parsed
}
}
return 0
}
func readBoolLike(v any) bool {
switch value := v.(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true")
default:
return false
}
}
func readTimeLike(v any) *time.Time {
text := readString(v)
if text == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, text)
if err != nil {
return nil
}
return &parsed
}

View File

@@ -0,0 +1,341 @@
package service
import (
"context"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// HybridRetrieve 统一承接读取侧 RAG-first 召回链路。
//
// 步骤化说明:
// 1. 优先走 RAG 语义搜索,按 query 相关性召回候选记忆;
// 2. RAG 报错或 0 命中时回退 MySQL保证链路韧性
// 3. 召回结果做三级去重、排序与类型预算裁剪(总量不超过调用方 limit
// 4. 旧 legacy 链路完全保留,方便通过配置快速回滚。
func (s *ReadService) HybridRetrieve(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
limit int,
now time.Time,
) ([]memorymodel.ItemDTO, retrieveTelemetry, error) {
telemetry := retrieveTelemetry{}
if s == nil || s.itemRepo == nil {
return nil, telemetry, nil
}
if !effectiveSetting.MemoryEnabled {
return nil, telemetry, nil
}
// RAG-first只走语义召回不再全量拉 MySQL pinned。
items, semanticTelemetry, err := s.retrieveSemanticCandidates(ctx, req, effectiveSetting, limit, now)
if err != nil {
return nil, telemetry, err
}
telemetry.SemanticHitCount = semanticTelemetry.HitCount
telemetry.Degraded = semanticTelemetry.Degraded
telemetry.RAGFallbackUsed = semanticTelemetry.RAGFallbackUsed
if len(items) == 0 {
return nil, telemetry, nil
}
beforeDedupCount := len(items)
items = dedupByID(items)
items = dedupByHash(items)
items = dedupByText(items)
telemetry.DedupDropCount = beforeDedupCount - len(items)
items = RankItems(items, now)
items = applyTypeBudget(items, s.cfg, limit)
if len(items) == 0 {
return nil, telemetry, nil
}
telemetry.FinalCount = len(items)
_ = s.itemRepo.TouchLastAccessAt(ctx, collectItemDTOIDs(items), now)
return items, telemetry, nil
}
func (s *ReadService) retrievePinnedCandidates(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
now time.Time,
) ([]memorymodel.ItemDTO, error) {
query := buildReadScopedItemQuery(req, now, nil, 0)
items, err := s.itemRepo.FindPinnedByUser(ctx, query, s.cfg.EffectiveReadPreferenceLimit())
if err != nil {
return nil, err
}
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
return toItemDTOs(items), nil
}
func (s *ReadService) retrieveSemanticCandidates(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
limit int,
now time.Time,
) ([]memorymodel.ItemDTO, semanticRetrieveTelemetry, error) {
telemetry := semanticRetrieveTelemetry{}
queryText := strings.TrimSpace(req.Query)
if queryText == "" {
return nil, telemetry, nil
}
candidateLimit := hybridSemanticTopK(s.cfg, limit)
if s.cfg.RAGEnabled && s.ragRuntime != nil {
items, err := s.retrieveSemanticCandidatesByRAG(ctx, req, effectiveSetting, candidateLimit, now)
if shouldReturnSemanticRAGResult(items, err) {
telemetry.HitCount = len(items)
return items, telemetry, nil
}
telemetry.Degraded = true
telemetry.RAGFallbackUsed = true
}
items, err := s.retrieveSemanticCandidatesByMySQL(ctx, req, effectiveSetting, candidateLimit, now)
telemetry.HitCount = len(items)
return items, telemetry, err
}
func (s *ReadService) retrieveSemanticCandidatesByRAG(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
candidateLimit int,
now time.Time,
) ([]memorymodel.ItemDTO, error) {
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, candidateLimit, s.cfg.Threshold))
if err != nil {
return nil, err
}
if result == nil || len(result.Items) == 0 {
return nil, nil
}
items := make([]memorymodel.ItemDTO, 0, len(result.Items))
for _, hit := range result.Items {
dto, memoryID := buildMemoryDTOFromRetrieveHit(hit)
if !effectiveSetting.ImplicitMemoryEnabled && !dto.IsExplicit {
continue
}
if !effectiveSetting.SensitiveMemoryEnabled && dto.SensitivityLevel > 0 {
continue
}
if dto.ID <= 0 && memoryID > 0 {
dto.ID = memoryID
}
items = append(items, dto)
}
return items, nil
}
func (s *ReadService) retrieveSemanticCandidatesByMySQL(
ctx context.Context,
req memorymodel.RetrieveRequest,
effectiveSetting model.MemoryUserSetting,
candidateLimit int,
now time.Time,
) ([]memorymodel.ItemDTO, error) {
query := buildReadScopedItemQuery(
req,
now,
[]string{model.MemoryItemStatusActive},
normalizeLimit(candidateLimit, candidateLimit, maxRetrieveLimit),
)
items, err := s.itemRepo.FindByQuery(ctx, query)
if err != nil {
return nil, err
}
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
return toItemDTOs(items), nil
}
// dedupByID 按 memory_id 去重,后出现的结果覆盖先出现的结果。
func dedupByID(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
if len(items) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(items))
result := make([]memorymodel.ItemDTO, 0, len(items))
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
if item.ID <= 0 {
result = append(result, item)
continue
}
if _, exists := seen[item.ID]; exists {
continue
}
seen[item.ID] = struct{}{}
result = append(result, item)
}
reverseItemDTOs(result)
return result
}
// dedupByHash 按 content_hash 去重;缺失 hash 时跳过,保留 importance 更高的条目。
func dedupByHash(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
return fallbackContentHash(item.MemoryType, item.Content, item.ContentHash)
})
}
// dedupByText 按“类型标签 + 文本”兜底去重,用于覆盖历史数据未带 hash 的场景。
func dedupByText(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
text := strings.TrimSpace(item.Content)
if text == "" {
text = strings.TrimSpace(item.Title)
}
if text == "" {
return ""
}
return renderMemoryTypeLabelForDedup(item.MemoryType) + "::" + normalizeContentForHash(text)
})
}
func dedupByKey(items []memorymodel.ItemDTO, keyBuilder func(item memorymodel.ItemDTO) string) []memorymodel.ItemDTO {
if len(items) == 0 {
return nil
}
selectedIndex := make(map[string]int, len(items))
for index, item := range items {
key := strings.TrimSpace(keyBuilder(item))
if key == "" {
continue
}
if previous, exists := selectedIndex[key]; exists {
if preferCurrentItem(items[previous], item) {
selectedIndex[key] = index
}
continue
}
selectedIndex[key] = index
}
result := make([]memorymodel.ItemDTO, 0, len(items))
for index, item := range items {
key := strings.TrimSpace(keyBuilder(item))
if key == "" {
result = append(result, item)
continue
}
if selectedIndex[key] == index {
result = append(result, item)
}
}
return result
}
func preferCurrentItem(previous memorymodel.ItemDTO, current memorymodel.ItemDTO) bool {
if current.Importance != previous.Importance {
return current.Importance > previous.Importance
}
if current.Confidence != previous.Confidence {
return current.Confidence > previous.Confidence
}
return true
}
// applyTypeBudget 在排序结果上应用四类记忆预算,并以 callerLimit 作为总量硬上限。
//
// 说明:
// 1. 每种类型先保底自己的预算上限,避免 fact 抢掉 constraint 的位置;
// 2. 裁剪时保持当前排序顺序,不在这里重新打分;
// 3. 最终总量不超过 min(callerLimit, cfg.TotalReadBudget())。
func applyTypeBudget(items []memorymodel.ItemDTO, cfg memorymodel.Config, callerLimit int) []memorymodel.ItemDTO {
if len(items) == 0 {
return nil
}
hardCap := cfg.TotalReadBudget()
if callerLimit > 0 && callerLimit < hardCap {
hardCap = callerLimit
}
budgetByType := map[string]int{
memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(),
memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(),
memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(),
}
usedByType := make(map[string]int, len(budgetByType))
result := make([]memorymodel.ItemDTO, 0, minInt(len(items), hardCap))
for _, item := range items {
if len(result) >= hardCap {
break
}
memoryType := resolveBudgetMemoryType(item.MemoryType)
if usedByType[memoryType] >= budgetByType[memoryType] {
continue
}
usedByType[memoryType]++
result = append(result, item)
}
return result
}
// hybridSemanticTopK 计算语义召回的候选集大小。
// 使用 callerLimit 的 2 倍作为 TopK保证去重后仍有足够结果填充预算。
func hybridSemanticTopK(cfg memorymodel.Config, limit int) int {
return limit * 2
}
func resolveBudgetMemoryType(memoryType string) string {
normalized := memorymodel.NormalizeMemoryType(memoryType)
if normalized == "" {
return memorymodel.MemoryTypeFact
}
return normalized
}
func renderMemoryTypeLabelForDedup(memoryType string) string {
switch memorymodel.NormalizeMemoryType(memoryType) {
case memorymodel.MemoryTypePreference:
return "偏好"
case memorymodel.MemoryTypeConstraint:
return "约束"
case memorymodel.MemoryTypeFact:
return "事实"
default:
return "记忆"
}
}
func collectItemDTOIDs(items []memorymodel.ItemDTO) []int64 {
if len(items) == 0 {
return nil
}
ids := make([]int64, 0, len(items))
for _, item := range items {
if item.ID <= 0 {
continue
}
ids = append(ids, item.ID)
}
return ids
}
func reverseItemDTOs(items []memorymodel.ItemDTO) {
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}

View File

@@ -0,0 +1,76 @@
package service
import (
"sort"
"time"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// RankItems 对读取结果做统一重排。
//
// 步骤化说明:
// 1. 先基于 importance / confidence / recency 构造基础分,保持和旧链路相近的排序直觉;
// 2. 再叠加“显式记忆 / 类型优先级”奖励,让 constraint 与 preference 更稳定地排在前面;
// 3. 同分按 ID 降序,保证排序在日志与测试里具备稳定性。
func RankItems(items []memorymodel.ItemDTO, now time.Time) []memorymodel.ItemDTO {
if len(items) == 0 {
return nil
}
ranked := make([]memorymodel.ItemDTO, len(items))
copy(ranked, items)
sort.SliceStable(ranked, func(i, j int) bool {
left := scoreRankedItem(ranked[i], now)
right := scoreRankedItem(ranked[j], now)
if left == right {
return ranked[i].ID > ranked[j].ID
}
return left > right
})
return ranked
}
// scoreRankedItem 计算 hybrid 读链路的统一重排分数。
//
// 说明:
// 1. 这里仍然只依赖条目自身属性,不引入 conversation_id 加分;
// 2. 原因是同对话内容本就已经存在于上下文窗口,记忆读侧应专注跨对话补充;
// 3. 类型加权仍然保留,用于确保 constraint / preference 的业务优先级稳定生效。
func scoreRankedItem(item memorymodel.ItemDTO, now time.Time) float64 {
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScoreDTO(item, now)
if item.IsExplicit {
score += 0.1
}
switch memorymodel.NormalizeMemoryType(item.MemoryType) {
case memorymodel.MemoryTypeConstraint:
score += 0.15
case memorymodel.MemoryTypePreference:
score += 0.10
}
return score
}
func recencyScoreDTO(item memorymodel.ItemDTO, now time.Time) float64 {
base := item.UpdatedAt
if base == nil {
base = item.CreatedAt
}
if base == nil || now.Before(*base) {
return 0.5
}
age := now.Sub(*base)
switch {
case age <= 24*time.Hour:
return 1
case age <= 7*24*time.Hour:
return 0.85
case age <= 30*24*time.Hour:
return 0.65
case age <= 90*24*time.Hour:
return 0.45
default:
return 0.25
}
}

View File

@@ -0,0 +1,115 @@
package utils
import (
"fmt"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// AggregateComparisons 把一轮 LLM 比对结果汇总为最终动作。
//
// 职责边界:
// 1. 纯确定性逻辑,不调 LLM不调外部服务
// 2. 按优先级从高到低判定duplicate > update > conflict > unrelated
// 3. 多条 update 时选 Score 最高的候选执行 UPDATE。
//
// 汇总规则:
// 1. 出现 duplicate → 最终动作 NONE新 fact 完全重复,不需要写入);
// 2. 出现 update → 最终动作 UPDATE更新 Score 最高的那条旧记忆);
// 3. 出现 conflict → 最终动作 DELETE + 后续按 ADD 处理(旧记忆过时,先删旧的再写新的);
// 4. 全部 unrelated → 最终动作 ADD没有相关旧记忆直接新增
func AggregateComparisons(
fact memorymodel.NormalizedFact,
comparisons []memorymodel.ComparisonResult,
candidates []memorymodel.CandidateSnapshot,
) *memorymodel.FinalDecision {
// 1. 无候选时直接 ADD无需走任何判断。
if len(comparisons) == 0 {
return &memorymodel.FinalDecision{
Action: memorymodel.DecisionActionAdd,
Reason: "无相关旧记忆,直接新增",
}
}
// 2. 建立 memoryID → CandidateSnapshot 映射,用于查找 Score。
snapshotMap := make(map[int64]memorymodel.CandidateSnapshot, len(candidates))
for _, c := range candidates {
snapshotMap[c.MemoryID] = c
}
hasDuplicate := false
var bestUpdate *memorymodel.ComparisonResult
bestUpdateScore := -1.0
var conflictResult *memorymodel.ComparisonResult
for i := range comparisons {
comp := &comparisons[i]
switch comp.Relation {
case memorymodel.RelationDuplicate:
// 3. 出现一条 duplicate 即可确定最终动作为 NONE。
hasDuplicate = true
case memorymodel.RelationUpdate:
// 4. 多条 update 时,选 Score 最高的那条执行 UPDATE。
snapshot, ok := snapshotMap[comp.MemoryID]
score := 0.0
if ok {
score = snapshot.Score
}
if score > bestUpdateScore {
bestUpdateScore = score
bestUpdate = comp
}
case memorymodel.RelationConflict:
// 5. 记录第一条 conflict用于后续 DELETE + ADD 处理。
if conflictResult == nil {
conflictResult = comp
}
}
}
// 6. 按优先级判定最终动作。
if hasDuplicate {
return &memorymodel.FinalDecision{
Action: memorymodel.DecisionActionNone,
Reason: "存在完全重复的旧记忆,跳过写入",
}
}
if bestUpdate != nil {
// 7. UPDATE 动作:使用 LLM 提供的合并后内容。
title := bestUpdate.UpdatedTitle
if title == "" {
title = fact.Title
}
content := bestUpdate.UpdatedContent
reason := bestUpdate.Reason
if reason == "" {
reason = "新事实是对旧记忆的修正或补充"
}
return &memorymodel.FinalDecision{
Action: memorymodel.DecisionActionUpdate,
TargetID: bestUpdate.MemoryID,
Title: title,
Content: content,
Reason: fmt.Sprintf("更新旧记忆(id=%d): %s", bestUpdate.MemoryID, reason),
}
}
if conflictResult != nil {
// 8. conflict → 先 DELETE 旧记忆,后续由上层按 ADD 写入新 fact。
return &memorymodel.FinalDecision{
Action: memorymodel.DecisionActionDelete,
TargetID: conflictResult.MemoryID,
Reason: fmt.Sprintf("旧记忆(id=%d)与新事实冲突,删除后新增: %s", conflictResult.MemoryID, conflictResult.Reason),
}
}
// 9. 全部 unrelated → 直接 ADD。
return &memorymodel.FinalDecision{
Action: memorymodel.DecisionActionAdd,
Reason: "无相关旧记忆,直接新增",
}
}

View File

@@ -0,0 +1,77 @@
package utils
import (
"encoding/json"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
// AuditOperationCreate 表示系统新建一条记忆。
AuditOperationCreate = "create"
// AuditOperationUpdate 表示决策层更新已有记忆的内容。
AuditOperationUpdate = "update"
// AuditOperationArchive 表示治理层把重复记忆归档。
AuditOperationArchive = "archive"
// AuditOperationDelete 表示对已有记忆做软删除。
AuditOperationDelete = "delete"
// AuditOperationRestore 表示把已删除/归档记忆恢复为 active。
AuditOperationRestore = "restore"
)
// BuildItemAuditLog 构造记忆变更审计日志。
//
// 职责边界:
// 1. 负责把 before/after 快照统一序列化为审计日志结构;
// 2. 不负责决定“是否应该写审计”,该决策由上层 service/worker 控制;
// 3. 不负责落库,调用方仍需显式调用 AuditRepo。
func BuildItemAuditLog(
memoryID int64,
userID int,
operation string,
operatorType string,
reason string,
before *model.MemoryItem,
after *model.MemoryItem,
) model.MemoryAuditLog {
return model.MemoryAuditLog{
MemoryID: memoryID,
UserID: userID,
Operation: strings.TrimSpace(operation),
OperatorType: NormalizeOperatorType(operatorType),
Reason: strings.TrimSpace(reason),
BeforeJSON: marshalMemoryItemSnapshot(before),
AfterJSON: marshalMemoryItemSnapshot(after),
}
}
// NormalizeOperatorType 统一规整审计操作者类型。
//
// 规则说明:
// 1. 目前只接受 user/system 两类固定值;
// 2. 空值或未知值统一回退为 user避免把脏值直接写进审计表
// 3. 若后续扩展 admin/tool 等类型,再在这里集中放开即可。
func NormalizeOperatorType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "system":
return "system"
default:
return "user"
}
}
func marshalMemoryItemSnapshot(item *model.MemoryItem) *string {
if item == nil {
return nil
}
raw, err := json.Marshal(item)
if err != nil {
empty := "{}"
return &empty
}
value := string(raw)
return &value
}

View File

@@ -0,0 +1,49 @@
package utils
import (
"fmt"
"strings"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// 合法关系类型集合,用于校验 LLM 输出的 relation 字段。
var validRelations = map[string]struct{}{
memorymodel.RelationDuplicate: {},
memorymodel.RelationUpdate: {},
memorymodel.RelationConflict: {},
memorymodel.RelationUnrelated: {},
}
// ValidateComparisonResult 校验单次比对结果的基本合法性。
//
// 职责边界:
// 1. 只校验 LLM 输出的结构合法性,不校验业务语义;
// 2. relation 必须是四种合法值之一update 时必须有 UpdatedContent
// 3. 校验失败直接返回 error调用方决定丢弃或重试。
func ValidateComparisonResult(result *memorymodel.ComparisonResult) error {
if result == nil {
return fmt.Errorf("比对结果不能为空")
}
// 1. MemoryID 必须大于 0确保能定位到旧记忆。
if result.MemoryID <= 0 {
return fmt.Errorf("比对结果 memory_id 无效: %d", result.MemoryID)
}
// 2. relation 必须是四种合法值之一,防止 LLM 输出非法值。
relation := strings.TrimSpace(strings.ToLower(result.Relation))
if _, ok := validRelations[relation]; !ok {
return fmt.Errorf("比对结果 relation 非法: %s", result.Relation)
}
// 3. relation=update 时UpdatedContent 不能为空。
// 原因update 需要合并后的完整内容,不能只写差异部分。
if relation == memorymodel.RelationUpdate {
if strings.TrimSpace(result.UpdatedContent) == "" {
return fmt.Errorf("relation=update 时 updated_content 不能为空")
}
}
return nil
}

View File

@@ -0,0 +1,104 @@
package utils
import (
"encoding/json"
"errors"
"regexp"
"strings"
)
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*([\\[{].*[\\]}])\\s*```")
// ExtractJSON 从模型输出中提取 JSON 文本(兼容代码块包裹)。
//
// 步骤:
// 1. 先判断整段文本是否本身就是合法 JSON
// 2. 再尝试匹配 ```json ... ``` 代码块;
// 3. 最后做一次“首个 JSON 对象/数组”扫描提取。
func ExtractJSON(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("empty model output")
}
// 1. 直接 JSON 命中时,避免做额外启发式扫描。
if json.Valid([]byte(trimmed)) {
return trimmed, nil
}
// 2. 兼容 markdown 代码块包裹 JSON。
matches := fencedJSONPattern.FindStringSubmatch(trimmed)
if len(matches) > 1 {
candidate := strings.TrimSpace(matches[1])
if json.Valid([]byte(candidate)) {
return candidate, nil
}
}
// 3. 兜底扫描首个完整 JSON 片段,尽量提升容错能力。
if candidate, ok := findFirstJSONSegment(trimmed); ok {
return candidate, nil
}
return "", errors.New("json not found in model output")
}
func findFirstJSONSegment(raw string) (string, bool) {
start := -1
var open, close rune
for i, ch := range raw {
if ch == '{' {
start = i
open = '{'
close = '}'
break
}
if ch == '[' {
start = i
open = '['
close = ']'
break
}
}
if start < 0 {
return "", false
}
depth := 0
inString := false
escaped := false
for i, ch := range raw[start:] {
if inString {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
continue
}
if ch == open {
depth++
continue
}
if ch == close {
depth--
if depth == 0 {
candidate := strings.TrimSpace(raw[start : start+i+1])
if json.Valid([]byte(candidate)) {
return candidate, true
}
return "", false
}
}
}
return "", false
}

View File

@@ -0,0 +1,133 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
const (
maxTitleLength = 64
maxContentLength = 1000
)
// NormalizeFacts 对候选事实做标准化与过滤。
//
// 步骤:
// 1. 标准化 memory_type 与文本字段,丢弃空值和非法类型;
// 2. 对超长内容截断,避免脏数据污染后续链路;
// 3. 基于“类型+标准化内容”做去重,避免同一轮重复写入。
func NormalizeFacts(candidates []memorymodel.FactCandidate) []memorymodel.NormalizedFact {
if len(candidates) == 0 {
return nil
}
result := make([]memorymodel.NormalizedFact, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
memoryType := memorymodel.NormalizeMemoryType(candidate.MemoryType)
if memoryType == "" {
continue
}
content := normalizeWhitespace(candidate.Content)
if content == "" {
continue
}
content = truncateByRune(content, maxContentLength)
title := normalizeWhitespace(candidate.Title)
if title == "" {
title = truncateByRune(content, maxTitleLength)
}
title = truncateByRune(title, maxTitleLength)
confidence := clamp01(candidate.Confidence)
if confidence == 0 {
confidence = 0.6
}
importance := clamp01(candidate.Importance)
if importance == 0 {
importance = defaultImportanceByType(memoryType)
}
sensitivityLevel := clampInt(candidate.SensitivityLevel, 0, 2)
normalizedContent := strings.ToLower(content)
contentHash := HashContent(memoryType, normalizedContent)
dedupKey := fmt.Sprintf("%s:%s", memoryType, contentHash)
if _, exists := seen[dedupKey]; exists {
continue
}
seen[dedupKey] = struct{}{}
result = append(result, memorymodel.NormalizedFact{
MemoryType: memoryType,
Title: title,
Content: content,
NormalizedContent: normalizedContent,
ContentHash: contentHash,
Confidence: confidence,
Importance: importance,
SensitivityLevel: sensitivityLevel,
IsExplicit: candidate.IsExplicit,
})
}
return result
}
func normalizeWhitespace(raw string) string {
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
}
func truncateByRune(raw string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(raw)
if len(runes) <= max {
return raw
}
return string(runes[:max])
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func clampInt(v, minValue, maxValue int) int {
if v < minValue {
return minValue
}
if v > maxValue {
return maxValue
}
return v
}
func defaultImportanceByType(memoryType string) float64 {
switch memoryType {
case memorymodel.MemoryTypePreference:
return 0.85
case memorymodel.MemoryTypeConstraint:
return 0.95
default:
return 0.6
}
}
// HashContent 计算记忆内容的去重哈希。
// 算法sha256(memoryType + "::" + normalizedContent)
// 说明:导出此函数是为了让决策层 apply_actions 也能复用同一算法,避免哈希不一致导致去重失效。
func HashContent(memoryType, normalizedContent string) string {
sum := sha256.Sum256([]byte(memoryType + "::" + normalizedContent))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,81 @@
package utils
import (
"github.com/LoveLosita/smartflow/backend/model"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// EffectiveUserSetting 返回用户记忆设置的生效值。
//
// 规则说明:
// 1. 用户未显式配置时,走系统默认值;
// 2. 默认允许普通记忆和隐式记忆,但默认关闭敏感记忆;
// 3. 返回值始终是完整对象,方便调用方直接使用,不再分支判空。
func EffectiveUserSetting(setting *model.MemoryUserSetting, userID int) model.MemoryUserSetting {
if setting == nil {
return model.MemoryUserSetting{
UserID: userID,
MemoryEnabled: true,
ImplicitMemoryEnabled: true,
SensitiveMemoryEnabled: false,
}
}
return *setting
}
// FilterFactsBySetting 按用户记忆开关过滤候选事实。
func FilterFactsBySetting(facts []memorymodel.NormalizedFact, setting model.MemoryUserSetting) []memorymodel.NormalizedFact {
if !setting.MemoryEnabled || len(facts) == 0 {
return nil
}
result := make([]memorymodel.NormalizedFact, 0, len(facts))
for _, fact := range facts {
if !setting.ImplicitMemoryEnabled && !fact.IsExplicit {
continue
}
if !setting.SensitiveMemoryEnabled && fact.SensitivityLevel > 0 {
continue
}
result = append(result, fact)
}
return result
}
// FilterItemsBySetting 按用户记忆开关过滤已入库记忆。
func FilterItemsBySetting(items []model.MemoryItem, setting model.MemoryUserSetting) []model.MemoryItem {
if !setting.MemoryEnabled || len(items) == 0 {
return nil
}
result := make([]model.MemoryItem, 0, len(items))
for _, item := range items {
if !setting.ImplicitMemoryEnabled && !item.IsExplicit {
continue
}
if !setting.SensitiveMemoryEnabled && item.SensitivityLevel > 0 {
continue
}
result = append(result, item)
}
return result
}
// FilterFactsByConfidence 按置信度阈值过滤候选事实。
//
// 说明:
// 1. minConfidence <= 0 时不做过滤,保持向后兼容;
// 2. 过滤在 FilterFactsBySetting 之后执行,是写入链路的第二道程序化门槛;
// 3. 阈值由 memory.write.minConfidence 配置控制,默认 0.5。
func FilterFactsByConfidence(facts []memorymodel.NormalizedFact, minConfidence float64) []memorymodel.NormalizedFact {
if minConfidence <= 0 || len(facts) == 0 {
return facts
}
result := make([]memorymodel.NormalizedFact, 0, len(facts))
for _, fact := range facts {
if fact.Confidence >= minConfidence {
result = append(result, fact)
}
}
return result
}

View File

@@ -0,0 +1,213 @@
package vectorsync
import (
"context"
"fmt"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/model"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
)
// Syncer 负责 memory_items 与向量库之间的最小桥接。
//
// 职责边界:
// 1. 只负责“把已经落库的记忆同步到 RAG / 从 RAG 删除”;
// 2. 不负责决定哪些记忆该写、该删、该恢复,这些决策仍由上游 service/worker/cleanup 控制;
// 3. 同步失败时只回写 vector_status 并打观测,不反向回滚业务事务,避免把在线链路拖成强依赖。
type Syncer struct {
ragRuntime ragservice.Runtime
itemRepo *memoryrepo.ItemRepo
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
logger *log.Logger
}
func NewSyncer(
ragRuntime ragservice.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([]ragservice.MemoryIngestItem, 0, len(items))
for _, item := range items {
requestItems = append(requestItems, ragservice.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,
}), ragservice.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

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

View File

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

View File

@@ -0,0 +1,56 @@
package worker
import (
"context"
"log"
"time"
)
// RunPollingLoop 持续轮询 memory_jobs把异步 worker 真正跑起来。
//
// 职责边界:
// 1. 这里只负责“循环 + 轮询频率 + 批量触发”;
// 2. 不负责抽取逻辑,也不负责落库逻辑;
// 3. 任意一次 RunOnce 报错时只打日志并继续下一轮,避免整个后台循环退出。
func RunPollingLoop(ctx context.Context, runner *Runner, pollEvery time.Duration, claimBatch int) {
if runner == nil {
return
}
if runner.logger == nil {
runner.logger = log.Default()
}
if pollEvery <= 0 {
pollEvery = 2 * time.Second
}
if claimBatch <= 0 {
claimBatch = 1
}
runBatch := func() {
for i := 0; i < claimBatch; i++ {
result, err := runner.RunOnce(ctx)
if err != nil {
runner.logger.Printf("memory worker loop run once failed: %v", err)
return
}
if result == nil || !result.Claimed {
return
}
}
}
runBatch()
ticker := time.NewTicker(pollEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
runner.logger.Printf("memory worker loop stopped: %v", ctx.Err())
return
case <-ticker.C:
runBatch()
}
}
}

View File

@@ -0,0 +1,22 @@
package worker
import (
"context"
memoryorchestrator "github.com/LoveLosita/smartflow/backend/services/memory/internal/orchestrator"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
)
// Extractor 是 worker 抽取依赖接口。
//
// 设计目的:
// 1. Day1 先接 mock 编排器跑通状态机;
// 2. Day2/Day3 可无缝替换为真实 LLM 抽取实现。
type Extractor interface {
ExtractFacts(ctx context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error)
}
// NewMockExtractor 返回 Day1 默认 mock 抽取器。
func NewMockExtractor() Extractor {
return memoryorchestrator.NewWriteOrchestrator()
}

View File

@@ -0,0 +1,483 @@
package worker
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
memoryorchestrator "github.com/LoveLosita/smartflow/backend/services/memory/internal/orchestrator"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/services/memory/internal/vectorsync"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
"gorm.io/gorm"
)
// RunOnceResult 描述单次手工触发执行的结果。
type RunOnceResult struct {
Claimed bool
JobID int64
Status string
Facts int
}
// Runner 负责把 memory_jobs 推进成 memory_items 和审计日志。
//
// 职责边界:
// 1. 负责任务抢占、抽取、落库和状态推进;
// 2. 不负责 outbox 消费,也不负责 LLM prompt 组装;
// 3. 失败时只做可恢复的状态回写,避免把业务错误直接抛到启动层。
type Runner struct {
db *gorm.DB
jobRepo *memoryrepo.JobRepo
itemRepo *memoryrepo.ItemRepo
auditRepo *memoryrepo.AuditRepo
settingsRepo *memoryrepo.SettingsRepo
extractor Extractor
ragRuntime ragservice.Runtime
logger *log.Logger
vectorSyncer *memoryvectorsync.Syncer
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
// 决策层依赖。
// 说明:
// 1. cfg 提供决策层配置是否启用、TopK、MinScore、FallbackMode
// 2. decisionOrchestrator 在决策启用时负责 LLM 逐对比较,为 nil 时走旧路径。
cfg memorymodel.Config
decisionOrchestrator *memoryorchestrator.LLMDecisionOrchestrator
}
// NewRunner 构造记忆 worker 执行器。
func NewRunner(
db *gorm.DB,
jobRepo *memoryrepo.JobRepo,
itemRepo *memoryrepo.ItemRepo,
auditRepo *memoryrepo.AuditRepo,
settingsRepo *memoryrepo.SettingsRepo,
extractor Extractor,
ragRuntime ragservice.Runtime,
cfg memorymodel.Config,
decisionOrchestrator *memoryorchestrator.LLMDecisionOrchestrator,
vectorSyncer *memoryvectorsync.Syncer,
observer memoryobserve.Observer,
metrics memoryobserve.MetricsRecorder,
) *Runner {
if observer == nil {
observer = memoryobserve.NewNopObserver()
}
if metrics == nil {
metrics = memoryobserve.NewNopMetrics()
}
return &Runner{
db: db,
jobRepo: jobRepo,
itemRepo: itemRepo,
auditRepo: auditRepo,
settingsRepo: settingsRepo,
extractor: extractor,
ragRuntime: ragRuntime,
logger: log.Default(),
vectorSyncer: vectorSyncer,
observer: observer,
metrics: metrics,
cfg: cfg,
decisionOrchestrator: decisionOrchestrator,
}
}
// RunOnce 手工执行一轮任务处理。
//
// 返回语义:
// 1. Claimed=false 表示当前没有可执行任务;
// 2. Claimed=true 且 Status=success/failed/dead 表示本轮已经推进过一个任务;
// 3. 只有初始化缺失或数据库级错误才返回 error。
func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
if r == nil || r.db == nil || r.jobRepo == nil || r.itemRepo == nil || r.auditRepo == nil || r.settingsRepo == nil || r.extractor == nil {
return nil, errors.New("memory worker runner is not initialized")
}
// 1. 先抢占一条可执行任务,避免多个 worker 重复处理同一条记录。
job, err := r.jobRepo.ClaimNextRunnableExtractJob(ctx, time.Now())
if err != nil {
return nil, err
}
if job == 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{
Claimed: true,
JobID: job.ID,
Status: model.MemoryJobStatusProcessing,
Facts: 0,
}
// 2. 解析任务载荷。这里属于数据质量问题,解析失败就直接标记为可重试失败。
var payload memorymodel.ExtractJobPayload
if err = json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
failReason := fmt.Sprintf("解析任务载荷失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, nil, result.Status, false, err)
return result, nil
}
// 3. 先读取用户记忆设置。总开关关闭时,任务直接成功结束,不再继续抽取和落库。
setting, err := r.settingsRepo.GetByUserID(ctx, payload.UserID)
if err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err
}
effectiveSetting := memoryutils.EffectiveUserSetting(setting, payload.UserID)
if !effectiveSetting.MemoryEnabled {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err
}
result.Status = model.MemoryJobStatusSuccess
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
}
// 4. 调用抽取器。LLM 失败时由编排器做保守 fallbackworker 只关心最终结果。
facts, extractErr := r.extractor.ExtractFacts(ctx, payload)
if extractErr != nil {
failReason := fmt.Sprintf("抽取执行失败: %v", extractErr)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, extractErr)
return result, nil
}
facts = memoryutils.FilterFactsBySetting(facts, effectiveSetting)
facts = memoryutils.FilterFactsByConfidence(facts, r.cfg.WriteMinConfidence)
if len(facts) == 0 {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err
}
result.Status = model.MemoryJobStatusSuccess
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
}
items := buildMemoryItems(job, payload, facts)
if len(items) == 0 {
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
r.recordJobOutcome(ctx, job, &payload, model.MemoryJobStatusFailed, false, err)
return nil, err
}
result.Status = model.MemoryJobStatusSuccess
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
}
// 5. 根据配置选择写入路径:决策层 or 旧路径。
if r.cfg.DecisionEnabled && r.decisionOrchestrator != nil {
// 5a. 决策路径:召回→比对→汇总→执行。
outcome, decisionErr := r.executeDecisionFlow(ctx, job, payload, facts)
if decisionErr != nil {
// 决策流程整体失败,根据 FallbackMode 决定是否退回旧路径。
r.logger.Printf("[WARN][去重] 决策流程整体失败: job_id=%d user_id=%d facts_count=%d fallback=%s err=%v", job.ID, payload.UserID, len(facts), r.cfg.DecisionFallbackMode, decisionErr)
if r.cfg.DecisionFallbackMode == "legacy_add" {
if err = r.persistMemoryWrite(ctx, job.ID, items); err != nil {
failReason := fmt.Sprintf("决策降级后记忆落库失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, err)
return result, nil
}
result.Status = model.MemoryJobStatusSuccess
result.Facts = len(items)
r.syncMemoryVectors(ctx, items)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil
}
// FallbackMode=drop丢弃本轮抽取结果直接标记 job 成功。
_ = r.jobRepo.MarkSuccess(ctx, job.ID)
result.Status = model.MemoryJobStatusSuccess
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil
}
// 5b. 决策成功:同步向量(新增/更新)和删除过期向量。
result.Status = model.MemoryJobStatusSuccess
result.Facts = outcome.AddCount + outcome.UpdateCount + outcome.DeleteCount
r.syncMemoryVectors(ctx, outcome.ItemsToSync)
r.syncVectorDeletes(ctx, outcome.VectorDeletes)
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)
r.recordJobOutcome(ctx, job, &payload, result.Status, true, nil)
return result, nil
}
// 5c. 旧路径:和现在完全一样 — 先在事务里写入记忆条目和审计日志,再统一确认 job 成功。
if err = r.persistMemoryWrite(ctx, job.ID, items); err != nil {
failReason := fmt.Sprintf("记忆落库失败: %v", err)
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
result.Status = model.MemoryJobStatusFailed
r.recordJobOutcome(ctx, job, &payload, result.Status, false, err)
return result, nil
}
result.Status = model.MemoryJobStatusSuccess
result.Facts = len(items)
r.syncMemoryVectors(ctx, 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
}
func (r *Runner) persistMemoryWrite(ctx context.Context, jobID int64, items []model.MemoryItem) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
jobRepo := r.jobRepo.WithTx(tx)
itemRepo := r.itemRepo.WithTx(tx)
auditRepo := r.auditRepo.WithTx(tx)
if err := itemRepo.UpsertItems(ctx, items); err != nil {
return err
}
for i := range items {
audit := memoryutils.BuildItemAuditLog(
items[i].ID,
items[i].UserID,
memoryutils.AuditOperationCreate,
"system",
"LLM 提取入库",
nil,
&items[i],
)
if err := auditRepo.Create(ctx, audit); err != nil {
return err
}
}
return jobRepo.MarkSuccess(ctx, jobID)
})
}
func buildMemoryItems(job *model.MemoryJob, payload memorymodel.ExtractJobPayload, facts []memorymodel.NormalizedFact) []model.MemoryItem {
if job == nil || len(facts) == 0 {
return nil
}
items := make([]model.MemoryItem, 0, len(facts))
for _, fact := range facts {
items = append(items, model.MemoryItem{
UserID: payload.UserID,
ConversationID: strPtrOrNil(payload.ConversationID),
AssistantID: strPtrOrNil(payload.AssistantID),
RunID: strPtrOrNil(payload.RunID),
MemoryType: fact.MemoryType,
Title: fact.Title,
Content: fact.Content,
NormalizedContent: strPtrFromValue(fact.NormalizedContent),
ContentHash: strPtrFromValue(fact.ContentHash),
Confidence: fact.Confidence,
Importance: fact.Importance,
SensitivityLevel: fact.SensitivityLevel,
SourceMessageID: int64PtrOrNil(payload.SourceMessageID),
SourceEventID: job.SourceEventID,
IsExplicit: fact.IsExplicit,
Status: model.MemoryItemStatusActive,
TTLAt: resolveMemoryTTLAt(payload.OccurredAt, fact.MemoryType),
VectorStatus: "pending",
})
}
return items
}
func (r *Runner) syncMemoryVectors(ctx context.Context, items []model.MemoryItem) {
if r == nil || r.vectorSyncer == nil || len(items) == 0 {
return
}
r.vectorSyncer.Upsert(ctx, "", items)
}
// syncVectorDeletes 处理决策层 DELETE 动作产出的向量清理需求。
//
// 步骤:
// 1. 将 memoryID 转为 Milvus documentID"memory:{id}" 格式);
// 2. 调 Runtime.DeleteMemory 真正从 Milvus 删除对应向量;
// 3. 更新 MySQL vector_status 标记删除结果。
func (r *Runner) syncVectorDeletes(ctx context.Context, memoryIDs []int64) {
if r == nil || r.vectorSyncer == nil || len(memoryIDs) == 0 {
return
}
r.vectorSyncer.Delete(ctx, "", memoryIDs)
}
func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time {
switch memoryType {
case memorymodel.MemoryTypeFact:
t := base.Add(180 * 24 * time.Hour)
return &t
default:
return nil
}
}
func strPtrFromValue(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
value := v
return &value
}
func strPtrOrNil(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
value := v
return &value
}
func int64PtrOrNil(v int64) *int64 {
if v <= 0 {
return nil
}
value := v
return &value
}
func (r *Runner) recordJobOutcome(
ctx context.Context,
job *model.MemoryJob,
payload *memorymodel.ExtractJobPayload,
status string,
success bool,
err error,
) {
if r == nil {
return
}
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 {
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
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
// AuditLogDTO 是审计日志领域对象。
type AuditLogDTO struct {
ID int64
MemoryID int64
UserID int
Operation string
OperatorType string
Reason string
BeforeJSON string
AfterJSON string
CreatedAt *time.Time
}

View File

@@ -0,0 +1,134 @@
package model
import (
"strings"
"time"
)
const (
// MemoryReadModeLegacy 表示读取侧沿用“RAG 优先,失败再走 legacy”旧链路。
MemoryReadModeLegacy = "legacy"
// MemoryReadModeHybrid 表示读取侧走“结构化强约束 + 语义候选”混合链路。
MemoryReadModeHybrid = "hybrid"
// MemoryInjectRenderModeFlat 表示沿用扁平列表渲染。
MemoryInjectRenderModeFlat = "flat"
// MemoryInjectRenderModeTypedV2 表示按记忆类型分段渲染。
MemoryInjectRenderModeTypedV2 = "typed_v2"
// DefaultReadConstraintLimit 是 constraint 默认预算上限。
DefaultReadConstraintLimit = 5
// DefaultReadPreferenceLimit 是 preference 默认预算上限。
DefaultReadPreferenceLimit = 5
// DefaultReadFactLimit 是 fact 默认预算上限。
DefaultReadFactLimit = 5
)
// Config 是记忆模块配置对象Day1 首版)。
//
// 职责边界:
// 1. 只承载模块运行参数,不承载业务状态;
// 2. 允许启动期统一注入,避免业务层直接依赖配置中心。
type Config struct {
Enabled bool
RAGEnabled bool
ReadMode string
ReadConstraintLimit int
ReadPreferenceLimit int
ReadFactLimit int
InjectRenderMode string
ExtractPrompt string
DecisionPrompt string
Threshold float64
EnableReranker bool
LLMTemperature float64
LLMTopP float64
JobMaxRetry int
WorkerPollEvery time.Duration
WorkerClaimBatch int
// 决策层配置。
// 说明:
// 1. DecisionEnabled 控制是否启用"召回→比对→汇总"决策流程;
// 2. 默认关闭,旧路径完全保留,回滚无风险;
// 3. DecisionFallbackMode 仅在决策流程整体报错时生效,不影响单条 LLM 比对失败(单条失败视为 unrelated
DecisionEnabled bool
DecisionCandidateTopK int // Milvus 语义召回候选数上限
DecisionCandidateMinScore float64 // Milvus 语义召回最低相似度
DecisionFallbackMode string // "legacy_add"(退回旧路径直接新增)/ "drop"(丢弃)
WriteMode string // "legacy"(旧路径)/ "decision"(决策流程),仅 DecisionEnabled=true 时生效
// 写入置信度阈值。
// 说明:
// 1. 抽取结果 confidence 低于此值直接丢弃,不做入库;
// 2. 默认 0.5,与"守门员"prompt 的 confidence>=0.5 输出规则配合;
// 3. fallback 路径 confidence 设为 0.45低于默认阈值LLM 不可用时不写入。
WriteMinConfidence float64
// 记忆模块 LLM 调用是否开启 thinking由 config.yaml 的 agent.thinking.memory 注入。
LLMThinking bool
}
// NormalizeReadMode 统一读取模式字符串。
func NormalizeReadMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case MemoryReadModeHybrid:
return MemoryReadModeHybrid
default:
return MemoryReadModeLegacy
}
}
// NormalizeInjectRenderMode 统一注入渲染模式字符串。
func NormalizeInjectRenderMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case MemoryInjectRenderModeTypedV2:
return MemoryInjectRenderModeTypedV2
default:
return MemoryInjectRenderModeFlat
}
}
// EffectiveReadConstraintLimit 返回 constraint 生效预算。
func (c Config) EffectiveReadConstraintLimit() int {
return normalizePositiveLimit(c.ReadConstraintLimit, DefaultReadConstraintLimit)
}
// EffectiveReadPreferenceLimit 返回 preference 生效预算。
func (c Config) EffectiveReadPreferenceLimit() int {
return normalizePositiveLimit(c.ReadPreferenceLimit, DefaultReadPreferenceLimit)
}
// EffectiveReadFactLimit 返回 fact 生效预算。
func (c Config) EffectiveReadFactLimit() int {
return normalizePositiveLimit(c.ReadFactLimit, DefaultReadFactLimit)
}
// EffectiveReadMode 返回生效读取模式。
func (c Config) EffectiveReadMode() string {
return NormalizeReadMode(c.ReadMode)
}
// EffectiveInjectRenderMode 返回生效渲染模式。
func (c Config) EffectiveInjectRenderMode() string {
return NormalizeInjectRenderMode(c.InjectRenderMode)
}
// TotalReadBudget 返回三类记忆的总预算上限。
func (c Config) TotalReadBudget() int {
return c.EffectiveReadConstraintLimit() +
c.EffectiveReadPreferenceLimit() +
c.EffectiveReadFactLimit()
}
func normalizePositiveLimit(value int, defaultValue int) int {
if value <= 0 {
return defaultValue
}
return value
}

View File

@@ -0,0 +1,71 @@
package model
// RelationType 常量描述一条新 fact 与一条旧记忆之间的关系。
//
// 四种关系:
// 1. duplicate — 完全重复,新 fact 没有新信息,旧记忆无需变动;
// 2. update — 新 fact 是对旧记忆的修正、补充或更精确表述,需要合并更新;
// 3. conflict — 新 fact 与旧记忆矛盾,旧记忆已过时,应删旧增新;
// 4. unrelated — 两者说的是不同的事情,互不影响。
const (
RelationDuplicate = "duplicate"
RelationUpdate = "update"
RelationConflict = "conflict"
RelationUnrelated = "unrelated"
)
// CandidateSnapshot 是喂给 LLM 的旧记忆候选快照。
//
// 职责边界:
// 1. 只承载 LLM 做关系判断所需的最小信息;
// 2. MemoryID 是真实 memory_idLLM 不可见,仅供汇总决策时使用;
// 3. Score 是向量召回的相似度分数,用于多条 update 时选最优候选。
type CandidateSnapshot struct {
MemoryID int64
Title string
Content string
MemoryType string
Score float64 // Milvus 相似度分数0 表示来自 Hash 查询)
}
// ComparisonResult 是单次"新 fact vs 一条旧记忆"的 LLM 输出。
//
// 职责边界:
// 1. 只描述 LLM 对一对比较的结果,不包含最终决策动作;
// 2. UpdatedContent/UpdatedTitle 仅在 relation=update 时有意义;
// 3. Reason 写审计日志用,便于后续复盘 LLM 判断依据。
type ComparisonResult struct {
MemoryID int64 // 被比较的旧记忆 ID
Relation string // duplicate / update / conflict / unrelated
UpdatedContent string // 仅 relation=update 时有意义:合并后的新内容
UpdatedTitle string // 仅 relation=update 时有意义:合并后的新标题
Reason string // 判断理由(写审计日志用)
}
// FinalDecision 是汇总后的最终动作。
//
// 说明:
// 1. 由确定性代码产出,不是 LLM 产出;
// 2. Action 取值复用 status.go 中已定义的 DecisionActionAdd/Update/Delete/None 常量;
// 3. TargetID 在 UPDATE/DELETE 时指向旧记忆 IDADD/NONE 时为 0。
type FinalDecision struct {
Action string // ADD / UPDATE / DELETE / NONE
TargetID int64 // UPDATE/DELETE 时指向旧记忆 ID
Title string // UPDATE 时的新标题
Content string // UPDATE 时的新内容
Reason string // 汇总理由
}
// UpdateContentFields 是 UPDATE 动作需要更新的字段集合。
//
// 说明:
// 1. 只包含 UPDATE 动作实际需要修改的字段,避免全量覆盖;
// 2. NormalizedContent/ContentHash 由调用方重新计算,保证一致性。
type UpdateContentFields struct {
Title string
Content string
NormalizedContent string
ContentHash string
Confidence float64
Importance float64
}

View File

@@ -0,0 +1,105 @@
package model
import "time"
// ItemDTO 是记忆条目对外读写 DTO。
//
// 职责边界:
// 1. 面向 memory 模块内部服务层使用;
// 2. 不直接绑定 GORM 标签,避免传输结构与存储结构强耦合。
type ItemDTO struct {
ID int64
UserID int
ConversationID string
AssistantID string
RunID string
MemoryType string
Title string
Content string
ContentHash string
Confidence float64
Importance float64
SensitivityLevel int
IsExplicit bool
Status string
TTLAt *time.Time
CreatedAt *time.Time
UpdatedAt *time.Time
}
// ItemQuery 描述 memory_items 的通用查询条件。
//
// 职责边界:
// 1. 只表达 memory 仓储层需要的过滤条件;
// 2. 不直接承载注入策略、重排策略等上层业务语义;
// 3. IncludeGlobal 用于“会话级 + 全局级”混合读取场景。
type ItemQuery struct {
UserID int
ConversationID string
AssistantID string
RunID string
Statuses []string
MemoryTypes []string
IncludeGlobal bool
OnlyUnexpired bool
Limit int
Now time.Time
}
// RetrieveRequest 描述“供提示词注入前读取”所需的最小参数。
type RetrieveRequest struct {
Query string
UserID int
ConversationID string
AssistantID string
RunID string
MemoryTypes []string
Limit int
Now time.Time
}
// ListItemsRequest 描述记忆管理页列表查询参数。
type ListItemsRequest struct {
UserID int
ConversationID string
Statuses []string
MemoryTypes []string
Limit int
}
// CreateItemFields 是 repo 层落库时真正需要的字段集合。
type CreateItemFields struct {
UserID int
ConversationID string
AssistantID 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

@@ -0,0 +1,45 @@
package model
import "time"
// ExtractJobPayload 是 memory_jobs.payload_json 的领域视图。
//
// 职责边界:
// 1. 只描述抽取任务执行所需字段;
// 2. 与数据库模型解耦,避免后续表结构调整污染 worker 逻辑。
type ExtractJobPayload struct {
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
AssistantID string `json:"assistant_id,omitempty"`
RunID string `json:"run_id,omitempty"`
SourceMessageID int64 `json:"source_message_id,omitempty"`
SourceRole string `json:"source_role"`
SourceText string `json:"source_text"`
OccurredAt time.Time `json:"occurred_at"`
TraceID string `json:"trace_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
}
// FactCandidate 表示抽取阶段得到的候选事实。
type FactCandidate struct {
MemoryType string
Title string
Content string
Confidence float64
Importance float64
SensitivityLevel int
IsExplicit bool
}
// NormalizedFact 表示通过标准化后的可入库事实。
type NormalizedFact struct {
MemoryType string
Title string
Content string
NormalizedContent string
ContentHash string
Confidence float64
Importance float64
SensitivityLevel int
IsExplicit bool
}

View File

@@ -0,0 +1,20 @@
package model
import "time"
// UserSettingDTO 是用户记忆开关领域对象。
type UserSettingDTO struct {
UserID int
MemoryEnabled bool
ImplicitMemoryEnabled bool
SensitiveMemoryEnabled bool
UpdatedAt *time.Time
}
// UpdateUserSettingRequest 描述记忆开关写入请求。
type UpdateUserSettingRequest struct {
UserID int
MemoryEnabled bool
ImplicitMemoryEnabled bool
SensitiveMemoryEnabled bool
}

View File

@@ -0,0 +1,56 @@
package model
import "strings"
const (
// MemoryTypePreference 表示用户偏好类记忆。
MemoryTypePreference = "preference"
// MemoryTypeConstraint 表示硬约束类记忆。
MemoryTypeConstraint = "constraint"
// MemoryTypeFact 表示一般事实类记忆。
MemoryTypeFact = "fact"
)
const (
// DecisionActionAdd 表示新增记忆。
DecisionActionAdd = "ADD"
// DecisionActionUpdate 表示更新记忆。
DecisionActionUpdate = "UPDATE"
// DecisionActionDelete 表示删除记忆。
DecisionActionDelete = "DELETE"
// DecisionActionNone 表示不做写入动作。
DecisionActionNone = "NONE"
)
var validMemoryTypes = map[string]struct{}{
MemoryTypePreference: {},
MemoryTypeConstraint: {},
MemoryTypeFact: {},
}
var validDecisionActions = map[string]struct{}{
DecisionActionAdd: {},
DecisionActionUpdate: {},
DecisionActionDelete: {},
DecisionActionNone: {},
}
// NormalizeMemoryType 统一记忆类型字符串。
//
// 职责边界:
// 1. 只做字符串标准化,不做业务兜底;
// 2. 若调用方传入非法类型,返回空字符串供上游决定丢弃或降级。
func NormalizeMemoryType(memoryType string) string {
normalized := strings.ToLower(strings.TrimSpace(memoryType))
if _, ok := validMemoryTypes[normalized]; !ok {
return ""
}
return normalized
}
// IsValidDecisionAction 校验决策动作是否合法。
func IsValidDecisionAction(action string) bool {
normalized := strings.ToUpper(strings.TrimSpace(action))
_, ok := validDecisionActions[normalized]
return ok
}

View File

@@ -0,0 +1,284 @@
package memory
import (
"context"
"errors"
"log"
"github.com/LoveLosita/smartflow/backend/model"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memorycleanup "github.com/LoveLosita/smartflow/backend/services/memory/internal/cleanup"
memoryorchestrator "github.com/LoveLosita/smartflow/backend/services/memory/internal/orchestrator"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryservice "github.com/LoveLosita/smartflow/backend/services/memory/internal/service"
memoryvectorsync "github.com/LoveLosita/smartflow/backend/services/memory/internal/vectorsync"
memoryworker "github.com/LoveLosita/smartflow/backend/services/memory/internal/worker"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memoryobserve "github.com/LoveLosita/smartflow/backend/services/memory/observe"
ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
"gorm.io/gorm"
)
// Module 是 memory 模块对外暴露的统一门面。
//
// 职责边界:
// 1. 负责把 repo、service、worker、orchestrator 组装成一个稳定入口;
// 2. 负责对外暴露“写入 / 读取 / 管理 / 启动 worker”这些高层意图
// 3. 不负责替代应用层 DI也不负责替代上层事务管理器事务边界仍由调用方掌控。
type Module struct {
db *gorm.DB
cfg memorymodel.Config
llmClient *llmservice.Client
ragRuntime ragservice.Runtime
observer memoryobserve.Observer
metrics memoryobserve.MetricsRecorder
jobRepo *memoryrepo.JobRepo
itemRepo *memoryrepo.ItemRepo
auditRepo *memoryrepo.AuditRepo
settingsRepo *memoryrepo.SettingsRepo
enqueueService *memoryservice.EnqueueService
readService *memoryservice.ReadService
manageService *memoryservice.ManageService
vectorSyncer *memoryvectorsync.Syncer
dedupRunner *memorycleanup.DedupRunner
runner *memoryworker.Runner
}
// ObserveDeps 描述 memory 模块可选的观测依赖。
type ObserveDeps struct {
Observer memoryobserve.Observer
Metrics memoryobserve.MetricsRecorder
}
// LoadConfigFromViper 复用 memory 子包里的配置加载逻辑,对外收口一个统一入口。
func LoadConfigFromViper() memorymodel.Config {
return memoryservice.LoadConfigFromViper()
}
// NewModule 创建 memory 模块门面。
//
// 设计说明:
// 1. 这里做的是“轻组装”,不引入额外容器概念,方便先接进现有项目;
// 2. llmClient 允许为 nil此时写入链路会自动回退到本地 fallback 抽取;
// 3. ragRuntime 允许为 nil此时读取/向量同步自动回退旧逻辑;
// 4. 若后续接入统一 DI 容器,也应优先注册这个 Module而不是把内部 repo/service 继续向外泄漏。
func NewModule(db *gorm.DB, llmClient *llmservice.Client, ragRuntime ragservice.Runtime, cfg memorymodel.Config) *Module {
return NewModuleWithObserve(db, llmClient, ragRuntime, cfg, ObserveDeps{})
}
// NewModuleWithObserve 创建带观测依赖的 memory 模块门面。
func NewModuleWithObserve(
db *gorm.DB,
llmClient *llmservice.Client,
ragRuntime ragservice.Runtime,
cfg memorymodel.Config,
deps ObserveDeps,
) *Module {
return wireModule(db, llmClient, ragRuntime, cfg, deps)
}
// WithTx 返回绑定到指定事务连接的同构门面。
//
// 步骤化说明:
// 1. 上层事务管理器先创建 tx
// 2. 再通过 WithTx(tx) 把 memory 内部所有 repo/service 一次性切到同一个事务连接;
// 3. 这样外部无需重新 new 一堆 repo也不会破坏既有跨表事务边界。
func (m *Module) WithTx(tx *gorm.DB) *Module {
if m == nil {
return nil
}
if tx == nil {
return m
}
return wireModule(tx, m.llmClient, m.ragRuntime, m.cfg, ObserveDeps{
Observer: m.observer,
Metrics: m.metrics,
})
}
// EnqueueExtract 把一次记忆抽取请求入队到 memory_jobs。
func (m *Module) EnqueueExtract(
ctx context.Context,
payload memorymodel.ExtractJobPayload,
sourceEventID string,
) error {
if m == nil || m.enqueueService == nil {
return errors.New("memory module enqueue service is nil")
}
return m.enqueueService.EnqueueExtractJob(ctx, payload, sourceEventID)
}
// Retrieve 读取后续可供 prompt 注入使用的候选记忆。
func (m *Module) Retrieve(ctx context.Context, req memorymodel.RetrieveRequest) ([]memorymodel.ItemDTO, error) {
if m == nil || m.readService == nil {
return nil, errors.New("memory module read service is nil")
}
return m.readService.Retrieve(ctx, req)
}
// ListItems 列出用户当前可管理的记忆条目。
func (m *Module) ListItems(ctx context.Context, req memorymodel.ListItemsRequest) ([]memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
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 软删除一条记忆,并补写审计日志。
func (m *Module) DeleteItem(ctx context.Context, req model.MemoryDeleteItemRequest) (*memorymodel.ItemDTO, error) {
if m == nil || m.manageService == nil {
return nil, errors.New("memory module manage service is nil")
}
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 读取用户当前生效的记忆开关。
func (m *Module) GetUserSetting(ctx context.Context, userID int) (memorymodel.UserSettingDTO, error) {
if m == nil || m.manageService == nil {
return memorymodel.UserSettingDTO{}, errors.New("memory module manage service is nil")
}
return m.manageService.GetUserSetting(ctx, userID)
}
// UpsertUserSetting 写入用户记忆开关。
func (m *Module) UpsertUserSetting(ctx context.Context, req memorymodel.UpdateUserSettingRequest) (memorymodel.UserSettingDTO, error) {
if m == nil || m.manageService == nil {
return memorymodel.UserSettingDTO{}, errors.New("memory module manage service is nil")
}
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。
//
// 说明:
// 1. 这里只负责按当前配置拉起轮询循环;
// 2. 若 memory.enabled=false则直接记录日志并返回
// 3. 当前不做重复启动保护,生命周期仍假设由应用启动层统一掌控。
func (m *Module) StartWorker(ctx context.Context) {
if m == nil || m.runner == nil {
log.Println("Memory worker is not initialized")
return
}
if !m.cfg.Enabled {
log.Println("Memory worker is disabled")
return
}
go memoryworker.RunPollingLoop(ctx, m.runner, m.cfg.WorkerPollEvery, m.cfg.WorkerClaimBatch)
log.Println("Memory worker started")
}
func wireModule(
db *gorm.DB,
llmClient *llmservice.Client,
ragRuntime ragservice.Runtime,
cfg memorymodel.Config,
deps ObserveDeps,
) *Module {
jobRepo := memoryrepo.NewJobRepo(db)
itemRepo := memoryrepo.NewItemRepo(db)
auditRepo := memoryrepo.NewAuditRepo(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)
readService := memoryservice.NewReadService(itemRepo, settingsRepo, ragRuntime, cfg, observer, metrics)
manageService := memoryservice.NewManageService(db, itemRepo, auditRepo, settingsRepo, vectorSyncer, observer, metrics)
extractor := memoryorchestrator.NewLLMWriteOrchestrator(llmClient, cfg)
// 决策编排器:仅在 DecisionEnabled 时才创建有效实例。
// 原因cfg.DecisionEnabled=false 时Runner 不走决策路径,编排器不会使用,
// 但仍然创建以保持构造签名统一,避免上层调用方感知条件逻辑。
var decisionOrchestrator *memoryorchestrator.LLMDecisionOrchestrator
if cfg.DecisionEnabled && llmClient != nil {
decisionOrchestrator = memoryorchestrator.NewLLMDecisionOrchestrator(llmClient, cfg)
}
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{
db: db,
cfg: cfg,
llmClient: llmClient,
ragRuntime: ragRuntime,
observer: observer,
metrics: metrics,
jobRepo: jobRepo,
itemRepo: itemRepo,
auditRepo: auditRepo,
settingsRepo: settingsRepo,
enqueueService: enqueueService,
readService: readService,
manageService: manageService,
vectorSyncer: vectorSyncer,
dedupRunner: dedupRunner,
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

@@ -7,10 +7,10 @@ import (
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
memorymodule "github.com/LoveLosita/smartflow/backend/memory"
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
coremodel "github.com/LoveLosita/smartflow/backend/model"
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
memorymodule "github.com/LoveLosita/smartflow/backend/services/memory"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
memorycontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/memory"
)