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:
42
backend/services/agent/rpc/agent.proto
Normal file
42
backend/services/agent/rpc/agent.proto
Normal 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;
|
||||
}
|
||||
76
backend/services/agent/rpc/errors.go
Normal file
76
backend/services/agent/rpc/errors.go
Normal 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
|
||||
}
|
||||
256
backend/services/agent/rpc/handler.go
Normal file
256
backend/services/agent/rpc/handler.go
Normal 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
|
||||
}
|
||||
68
backend/services/agent/rpc/pb/agent.pb.go
Normal file
68
backend/services/agent/rpc/pb/agent.pb.go
Normal 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() {}
|
||||
313
backend/services/agent/rpc/pb/agent_grpc.pb.go
Normal file
313
backend/services/agent/rpc/pb/agent_grpc.pb.go
Normal 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",
|
||||
}
|
||||
60
backend/services/agent/rpc/server.go
Normal file
60
backend/services/agent/rpc/server.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 根据配置选择记忆渲染方式。
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
76
backend/services/memory/README.md
Normal file
76
backend/services/memory/README.md
Normal 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` 决策,解决“同义记忆去重”和“旧记忆更新”。
|
||||
@@ -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` 服务的记忆系统。
|
||||
78
backend/services/memory/docs/legacy/Log.txt
Normal file
78
backend/services/memory/docs/legacy/Log.txt
Normal 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
|
||||
521
backend/services/memory/docs/legacy/第三步治理与观测落地计划.md
Normal file
521
backend/services/memory/docs/legacy/第三步治理与观测落地计划.md
Normal 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 和内部清理能力
|
||||
|
||||
一句人话总结:
|
||||
|
||||
先让系统“看得见”,再让系统“能管理”,最后再让系统“敢清理”。
|
||||
558
backend/services/memory/docs/legacy/记忆模块实施计划.md
Normal file
558
backend/services/memory/docs/legacy/记忆模块实施计划.md
Normal 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. 日程助手是强约束场景,结构化事实主库优先级高于图谱表达能力。
|
||||
|
||||
---
|
||||
|
||||
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。
|
||||
363
backend/services/memory/docs/legacy/记忆模块第二步计划.md
Normal file
363
backend/services/memory/docs/legacy/记忆模块第二步计划.md
Normal 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 能支撑后续链路。
|
||||
|
||||
**改动 1:Config 新增读侧配置字段**
|
||||
- 文件:`backend/memory/model/config.go`
|
||||
- 新增 6 个字段:`ReadMode` / `ReadConstraintLimit` / `ReadPreferenceLimit` / `ReadFactLimit` / `ReadTodoHintLimit` / `InjectRenderMode`
|
||||
|
||||
**改动 2:ConfigLoader 读取 + 默认值**
|
||||
- 文件:`backend/memory/service/config_loader.go`
|
||||
- 读取上述 6 个 viper key,默认值:ReadMode="legacy", ConstraintLimit=5, PreferenceLimit=5, FactLimit=5, TodoHintLimit=3, RenderMode="flat"
|
||||
|
||||
**改动 3:ItemDTO 补齐 ContentHash**
|
||||
- 文件:`backend/memory/model/item.go` — ItemDTO 新增 `ContentHash string`
|
||||
- 文件:`backend/memory/service/common.go` — `toItemDTO` 补映射 `ContentHash: strValue(item.ContentHash)`
|
||||
- 原因:去重阶段需要 content_hash,当前 ItemDTO 没有这个字段
|
||||
|
||||
### 阶段 1:第一路 — 结构化强约束召回
|
||||
|
||||
**改动 4:ItemRepo 新增 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 截断,类型内部保持排序结果
|
||||
|
||||
### 阶段 6:ReadService 接入
|
||||
|
||||
**改动 5:ReadService.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 渲染逻辑
|
||||
|
||||
### 阶段 8:Execute 记忆消费补齐
|
||||
|
||||
**新增文件:`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:注入入口切换
|
||||
|
||||
**改动 7:agent_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
|
||||
↓
|
||||
阶段 1(Repo): item_repo.go (FindPinnedByUser)
|
||||
↓
|
||||
阶段 3+4(去重+排序): retrieve_merge.go(去重函数)+ retrieve_rank.go(可并行)
|
||||
↓
|
||||
阶段 5(预算): retrieve_merge.go(HybridRetrieve 入口 + applyTypeBudget)
|
||||
↓ ↑ 合并阶段 1~5 为完整 HybridRetrieve 函数
|
||||
阶段 6(接入): read_service.go(hybrid 分支)
|
||||
↓
|
||||
阶段 7(渲染): agent_memory_render.go(可和阶段 6 并行)
|
||||
↓
|
||||
阶段 8(Execute 消费): 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 降级**:关 Milvus,hybrid 模式仍通过 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`
|
||||
73
backend/services/memory/internal/cleanup/dedup_policy.go
Normal file
73
backend/services/memory/internal/cleanup/dedup_policy.go
Normal 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{}
|
||||
}
|
||||
257
backend/services/memory/internal/cleanup/dedup_runner.go
Normal file
257
backend/services/memory/internal/cleanup/dedup_runner.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 输出到 ComparisonResult,MemoryID 由代码填充而非 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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
29
backend/services/memory/internal/repo/audit_repo.go
Normal file
29
backend/services/memory/internal/repo/audit_repo.go
Normal 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
|
||||
}
|
||||
568
backend/services/memory/internal/repo/item_repo.go
Normal file
568
backend/services/memory/internal/repo/item_repo.go
Normal 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
|
||||
}
|
||||
222
backend/services/memory/internal/repo/job_repo.go
Normal file
222
backend/services/memory/internal/repo/job_repo.go
Normal 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
|
||||
}
|
||||
64
backend/services/memory/internal/repo/settings_repo.go
Normal file
64
backend/services/memory/internal/repo/settings_repo.go
Normal 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
|
||||
}
|
||||
149
backend/services/memory/internal/service/common.go
Normal file
149
backend/services/memory/internal/service/common.go
Normal 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), " "))
|
||||
}
|
||||
91
backend/services/memory/internal/service/config_loader.go
Normal file
91
backend/services/memory/internal/service/config_loader.go
Normal 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
|
||||
}
|
||||
33
backend/services/memory/internal/service/enqueue_service.go
Normal file
33
backend/services/memory/internal/service/enqueue_service.go
Normal 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)
|
||||
}
|
||||
659
backend/services/memory/internal/service/manage_service.go
Normal file
659
backend/services/memory/internal/service/manage_service.go
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
83
backend/services/memory/internal/service/read_scope.go
Normal file
83
backend/services/memory/internal/service/read_scope.go
Normal 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
|
||||
}
|
||||
438
backend/services/memory/internal/service/read_service.go
Normal file
438
backend/services/memory/internal/service/read_service.go
Normal 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
|
||||
}
|
||||
341
backend/services/memory/internal/service/retrieve_merge.go
Normal file
341
backend/services/memory/internal/service/retrieve_merge.go
Normal 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
|
||||
}
|
||||
76
backend/services/memory/internal/service/retrieve_rank.go
Normal file
76
backend/services/memory/internal/service/retrieve_rank.go
Normal 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
|
||||
}
|
||||
}
|
||||
115
backend/services/memory/internal/utils/aggregate_decision.go
Normal file
115
backend/services/memory/internal/utils/aggregate_decision.go
Normal 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: "无相关旧记忆,直接新增",
|
||||
}
|
||||
}
|
||||
77
backend/services/memory/internal/utils/audit.go
Normal file
77
backend/services/memory/internal/utils/audit.go
Normal 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
|
||||
}
|
||||
49
backend/services/memory/internal/utils/decision_validate.go
Normal file
49
backend/services/memory/internal/utils/decision_validate.go
Normal 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
|
||||
}
|
||||
104
backend/services/memory/internal/utils/extract_json.go
Normal file
104
backend/services/memory/internal/utils/extract_json.go
Normal 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
|
||||
}
|
||||
133
backend/services/memory/internal/utils/normalize_facts.go
Normal file
133
backend/services/memory/internal/utils/normalize_facts.go
Normal 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[:])
|
||||
}
|
||||
81
backend/services/memory/internal/utils/settings.go
Normal file
81
backend/services/memory/internal/utils/settings.go
Normal 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
|
||||
}
|
||||
213
backend/services/memory/internal/vectorsync/syncer.go
Normal file
213
backend/services/memory/internal/vectorsync/syncer.go
Normal 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)
|
||||
}
|
||||
248
backend/services/memory/internal/worker/apply_actions.go
Normal file
248
backend/services/memory/internal/worker/apply_actions.go
Normal 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
|
||||
}
|
||||
386
backend/services/memory/internal/worker/decision_flow.go
Normal file
386
backend/services/memory/internal/worker/decision_flow.go
Normal 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 收集所有需要向量同步的 item(ADD 和 UPDATE 产出的);
|
||||
// 3. VectorDeletes 收集所有需要从向量库删除的 memory_id(DELETE 动作产出的)。
|
||||
type DecisionFlowOutcome struct {
|
||||
AddCount int
|
||||
UpdateCount int
|
||||
DeleteCount int
|
||||
NoneCount int
|
||||
ItemsToSync []model.MemoryItem // 需要向量同步的新增/更新 item
|
||||
VectorDeletes []int64 // 需要从向量库删除的 memory_id 列表
|
||||
}
|
||||
|
||||
// factDecisionResult 是单条 fact 的决策执行结果,支持一对多动作。
|
||||
// 原因:conflict 场景下会产生 DELETE + ADD 两个动作,需要打包返回。
|
||||
type factDecisionResult struct {
|
||||
Outcomes []*ApplyActionOutcome
|
||||
}
|
||||
|
||||
type candidateRecallResult struct {
|
||||
Items []memorymodel.CandidateSnapshot
|
||||
FallbackMode string
|
||||
}
|
||||
|
||||
// executeDecisionFlow 在 worker 内编排"召回→逐对比对→汇总→执行"全流程。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 对每条 fact 独立执行完整决策流程,fact 之间互不影响;
|
||||
// 2. 所有数据库写操作在同一个事务内完成,保证原子性;
|
||||
// 3. 向量同步在事务外异步执行,不影响事务提交。
|
||||
//
|
||||
// 降级策略:
|
||||
// 1. Milvus 不可用时,回退到 MySQL 按类型查最近 N 条活跃记忆;
|
||||
// 2. 单条 LLM 比对失败不影响其他候选,视为 unrelated;
|
||||
// 3. 整体流程报错时,由上层根据 FallbackMode 决定是否退回旧路径。
|
||||
func (r *Runner) executeDecisionFlow(
|
||||
ctx context.Context,
|
||||
job *model.MemoryJob,
|
||||
payload memorymodel.ExtractJobPayload,
|
||||
facts []memorymodel.NormalizedFact,
|
||||
) (*DecisionFlowOutcome, error) {
|
||||
outcome := &DecisionFlowOutcome{
|
||||
ItemsToSync: make([]model.MemoryItem, 0, len(facts)),
|
||||
VectorDeletes: make([]int64, 0),
|
||||
}
|
||||
|
||||
// 1. 所有数据库写操作在同一个事务内完成。
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
itemRepo := r.itemRepo.WithTx(tx)
|
||||
auditRepo := r.auditRepo.WithTx(tx)
|
||||
jobRepo := r.jobRepo.WithTx(tx)
|
||||
|
||||
for _, fact := range facts {
|
||||
// 2. 对每条 fact 执行完整决策流程。
|
||||
result, err := r.executeDecisionForFact(ctx, itemRepo, auditRepo, fact, job, payload)
|
||||
if err != nil {
|
||||
// 单条 fact 决策失败不影响其他 fact,记录日志后继续。
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[WARN][去重] 单条 fact 决策失败,跳过继续: job_id=%d user_id=%d memory_type=%s hash=%s err=%v", job.ID, payload.UserID, fact.MemoryType, fact.ContentHash, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 汇总结果到全局 outcome。
|
||||
for _, actionOutcome := range result.Outcomes {
|
||||
r.collectActionOutcome(outcome, actionOutcome)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 事务内最后确认 job 成功。
|
||||
return jobRepo.MarkSuccess(ctx, job.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// executeDecisionForFact 对单条 fact 执行完整决策流程。
|
||||
//
|
||||
// 步骤:
|
||||
// 1. Hash 精确命中检查 — 已有完全相同内容则直接跳过;
|
||||
// 2. Milvus 语义召回 — 从旧记忆中筛出 TopK 候选(含降级);
|
||||
// 3. 逐对 LLM 比对 — 每次拿一条新 fact 和一条旧候选比对;
|
||||
// 4. 确定性汇总 — 根据 LLM 比对结果确定 ADD/UPDATE/DELETE/NONE;
|
||||
// 5. 校验 + 执行 — 落为数据库动作 + 审计日志。
|
||||
func (r *Runner) executeDecisionForFact(
|
||||
ctx context.Context,
|
||||
itemRepo *memoryrepo.ItemRepo,
|
||||
auditRepo *memoryrepo.AuditRepo,
|
||||
fact memorymodel.NormalizedFact,
|
||||
job *model.MemoryJob,
|
||||
payload memorymodel.ExtractJobPayload,
|
||||
) (*factDecisionResult, error) {
|
||||
result := &factDecisionResult{}
|
||||
|
||||
// Step 1: Hash 精确命中检查。
|
||||
// 原因:如果已有完全相同内容的记忆,直接跳过,无需调 LLM。
|
||||
existing, err := itemRepo.FindActiveByHash(ctx, payload.UserID, fact.ContentHash)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[WARN][去重] Hash 精确匹配查询失败: user_id=%d memory_type=%s hash=%s err=%v", payload.UserID, fact.MemoryType, fact.ContentHash, err)
|
||||
}
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
r.recordDecisionObservation(ctx, job, payload, fact, 0, memorymodel.DecisionActionNone, "hash_exact", true, nil)
|
||||
result.Outcomes = append(result.Outcomes, &ApplyActionOutcome{
|
||||
Action: memorymodel.DecisionActionNone,
|
||||
NeedsSync: false,
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 2: Milvus 语义召回(含降级)。
|
||||
recallResult := r.recallCandidates(ctx, payload, fact)
|
||||
candidates := recallResult.Items
|
||||
|
||||
// 打印召回候选详情,便于排查向量召回和阈值过滤效果。
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[DEBUG][去重] 语义召回候选: job_id=%d user_id=%d memory_type=%s candidate_count=%d",
|
||||
job.ID, payload.UserID, fact.MemoryType, len(candidates))
|
||||
for _, c := range candidates {
|
||||
r.logger.Printf("[DEBUG][去重] 候选详情: memory_id=%d score=%.4f content=\"%s\"",
|
||||
c.MemoryID, c.Score, truncateRunes(c.Content, 50))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 逐对 LLM 比对。
|
||||
comparisons := r.compareWithCandidates(ctx, fact, candidates)
|
||||
|
||||
// Step 4: 确定性汇总。
|
||||
decision := memoryutils.AggregateComparisons(fact, comparisons, candidates)
|
||||
|
||||
// 打印汇总决策结果,便于排查去重终态。
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[DEBUG][去重] 汇总决策: job_id=%d action=%s target_id=%d reason=\"%s\"",
|
||||
job.ID, decision.Action, decision.TargetID, decision.Reason)
|
||||
}
|
||||
|
||||
// Step 5: 校验 + 执行。
|
||||
actionOutcome, err := ApplyFinalDecision(ctx, itemRepo, auditRepo, *decision, fact, job, payload)
|
||||
if err != nil {
|
||||
r.recordDecisionObservation(ctx, job, payload, fact, len(candidates), decision.Action, recallResult.FallbackMode, false, err)
|
||||
return nil, fmt.Errorf("执行决策动作失败: %w", err)
|
||||
}
|
||||
result.Outcomes = append(result.Outcomes, actionOutcome)
|
||||
r.recordDecisionObservation(ctx, job, payload, fact, len(candidates), decision.Action, recallResult.FallbackMode, true, nil)
|
||||
|
||||
// Step 6: conflict (DELETE) 后需要补一个 ADD 写入新 fact。
|
||||
// 原因:旧记忆矛盾需删除,但新事实本身仍然有效,必须写入。
|
||||
if decision.Action == memorymodel.DecisionActionDelete {
|
||||
addDecision := memorymodel.FinalDecision{
|
||||
Action: memorymodel.DecisionActionAdd,
|
||||
Reason: "冲突旧记忆已删除,写入新事实",
|
||||
}
|
||||
addOutcome, addErr := ApplyFinalDecision(ctx, itemRepo, auditRepo, addDecision, fact, job, payload)
|
||||
if addErr != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[WARN] 冲突后补增失败: memory_type=%s err=%v", fact.MemoryType, addErr)
|
||||
}
|
||||
} else if addOutcome != nil {
|
||||
result.Outcomes = append(result.Outcomes, addOutcome)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// recallCandidates 从旧记忆中召回候选,先尝试 Milvus,降级时用 MySQL。
|
||||
func (r *Runner) recallCandidates(
|
||||
ctx context.Context,
|
||||
payload memorymodel.ExtractJobPayload,
|
||||
fact memorymodel.NormalizedFact,
|
||||
) candidateRecallResult {
|
||||
// 1. 优先使用 Milvus 向量语义召回。
|
||||
if r.ragRuntime != nil {
|
||||
retrieveResult, err := r.ragRuntime.RetrieveMemory(ctx, 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]) + "..."
|
||||
}
|
||||
56
backend/services/memory/internal/worker/loop.go
Normal file
56
backend/services/memory/internal/worker/loop.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
backend/services/memory/internal/worker/mock_extractor.go
Normal file
22
backend/services/memory/internal/worker/mock_extractor.go
Normal 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()
|
||||
}
|
||||
483
backend/services/memory/internal/worker/runner.go
Normal file
483
backend/services/memory/internal/worker/runner.go
Normal 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 失败时由编排器做保守 fallback,worker 只关心最终结果。
|
||||
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
|
||||
}
|
||||
16
backend/services/memory/model/audit.go
Normal file
16
backend/services/memory/model/audit.go
Normal 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
|
||||
}
|
||||
134
backend/services/memory/model/config.go
Normal file
134
backend/services/memory/model/config.go
Normal 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
|
||||
}
|
||||
71
backend/services/memory/model/decision.go
Normal file
71
backend/services/memory/model/decision.go
Normal 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_id,LLM 不可见,仅供汇总决策时使用;
|
||||
// 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 时指向旧记忆 ID,ADD/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
|
||||
}
|
||||
105
backend/services/memory/model/item.go
Normal file
105
backend/services/memory/model/item.go
Normal 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
|
||||
}
|
||||
45
backend/services/memory/model/job.go
Normal file
45
backend/services/memory/model/job.go
Normal 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
|
||||
}
|
||||
20
backend/services/memory/model/settings.go
Normal file
20
backend/services/memory/model/settings.go
Normal 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
|
||||
}
|
||||
56
backend/services/memory/model/status.go
Normal file
56
backend/services/memory/model/status.go
Normal 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
|
||||
}
|
||||
284
backend/services/memory/module.go
Normal file
284
backend/services/memory/module.go
Normal 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,
|
||||
}
|
||||
}
|
||||
119
backend/services/memory/observe/log_fields.go
Normal file
119
backend/services/memory/observe/log_fields.go
Normal 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
|
||||
}
|
||||
158
backend/services/memory/observe/metrics.go
Normal file
158
backend/services/memory/observe/metrics.go
Normal 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()
|
||||
}
|
||||
109
backend/services/memory/observe/observer.go
Normal file
109
backend/services/memory/observe/observer.go
Normal 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, " "))
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user