package biz import ( "ai_scheduler/internal/biz/llm_service/third_party" "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" "ai_scheduler/internal/data/impl" dbmodel "ai_scheduler/internal/data/model" "ai_scheduler/internal/data/mongo_model" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg" "context" "encoding/json" "fmt" "strings" "time" "github.com/sashabaranov/go-openai" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo/options" "xorm.io/builder" ) // AdviceEvaluateBiz 客户等级评估(熟客/意向/沉睡/非客户) // LLM 为主、规则兜底:有聊天记录的客户由模型综合画像与对话语义评估,其余按互动规则评估。 // 供每日 0 点定时任务与后台手动触发使用。 type AdviceEvaluateBiz struct { cfg *config.Config openai *third_party.OpenAi mongo *pkg.Mongo clientMongo *mongo_model.AdvicerClientMongo wxMsgMongo *mongo_model.AdvicerWxMsgMongo adviceProjectImpl *impl.AdviceProjectImpl aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl } func NewAdviceEvaluateBiz( cfg *config.Config, openai *third_party.OpenAi, mongo *pkg.Mongo, clientMongo *mongo_model.AdvicerClientMongo, wxMsgMongo *mongo_model.AdvicerWxMsgMongo, adviceProjectImpl *impl.AdviceProjectImpl, aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl, ) *AdviceEvaluateBiz { return &AdviceEvaluateBiz{ cfg: cfg, openai: openai, mongo: mongo, clientMongo: clientMongo, wxMsgMongo: wxMsgMongo, adviceProjectImpl: adviceProjectImpl, aiAdviceModelSupImpl: aiAdviceModelSupImpl, } } // EvaluateAll 评估客户等级并写回(clientId 为空时评估全部客户) func (a *AdviceEvaluateBiz) EvaluateAll(ctx context.Context, param *entitys.AdvicerClientEvaluateReq) (res *entitys.AdvicerClientEvaluateRes, err error) { res = &entitys.AdvicerClientEvaluateRes{Details: []entitys.AdvicerClientLevelDetail{}} // 1. 拉取待评估客户 filter := bson.M{} if param != nil && len(param.ClientId) != 0 { objectID, e := primitive.ObjectIDFromHex(param.ClientId) if e != nil { return nil, fmt.Errorf("客户ID转换失败: %w", e) } filter["_id"] = objectID } cursor, err := a.mongo.Co(a.clientMongo).Find(ctx, filter) if err != nil { return nil, err } var clients []mongo_model.AdvicerClientItem for cursor.Next(ctx) { var item mongo_model.AdvicerClientItem if err = cursor.Decode(&item); err != nil { return nil, err } clients = append(clients, item) } if err = cursor.Err(); err != nil { return nil, err } res.Total = len(clients) // 2. 项目/模型配置缓存,避免逐个客户重复查库 projectCache := map[int32]*dbmodel.AiAdviceProject{} modelCache := map[int32]*dbmodel.AiAdviceModelSup{} // 3. 逐个评估并写回 now := time.Now() for i := range clients { item := clients[i] level, reason := a.evaluateClient(ctx, &item, projectCache, modelCache) _, e := a.mongo.Co(a.clientMongo).UpdateOne(ctx, bson.M{"_id": item.Id}, bson.M{"$set": bson.M{ "clientLevel": level, "levelReason": reason, "levelUpdateAt": now, }}) if e != nil { // 单个客户写回失败不阻断整体评估 continue } res.Evaluated++ res.Details = append(res.Details, entitys.AdvicerClientLevelDetail{ ClientId: item.Id.Hex(), ClientName: item.SectionString(mongo_model.SectionKeyClientName), Level: level, LevelDesc: mongo_model.ClientLevelDesc[level], Reason: reason, }) } return res, nil } // evaluateClient 评估单个客户:有聊天记录且模型可用时走 LLM,否则规则兜底 func (a *AdviceEvaluateBiz) evaluateClient(ctx context.Context, item *mongo_model.AdvicerClientItem, projectCache map[int32]*dbmodel.AiAdviceProject, modelCache map[int32]*dbmodel.AiAdviceModelSup) (level, reason string) { msgs := a.recentMsgs(ctx, item.Wxid, 30) model := a.modelOf(ctx, item.ProjectId, projectCache, modelCache) if len(msgs) == 0 || model == nil { return ruleEvaluate(item, time.Now()) } messages := []openai.ResponseInputMessage{ {Role: openai.ChatMessageRoleSystem, Content: constants.ClientEvaluatePrompt}, {Role: openai.ChatMessageRoleUser, Content: buildEvaluateUserContent(item, msgs)}, } resp, err := a.openai.CreateResponseMessages(ctx, model.Key, model.URL, model.ChatModel, messages, "") if err != nil { return ruleEvaluate(item, time.Now()) } var out struct { Level string `json:"level"` Reason string `json:"reason"` } if err = json.Unmarshal([]byte(extractJsonObject(resp.GetOutputText())), &out); err != nil { return ruleEvaluate(item, time.Now()) } // 校验等级合法性,非法输出按规则兜底 switch out.Level { case mongo_model.ClientLevelRegular, mongo_model.ClientLevelIntent, mongo_model.ClientLevelSleeping, mongo_model.ClientLevelNon: default: return ruleEvaluate(item, time.Now()) } if len(strings.TrimSpace(out.Reason)) == 0 { out.Reason = "AI 综合评估" } return out.Level, strings.TrimSpace(out.Reason) } // modelOf 获取客户所属项目对应的模型配置(带缓存;查不到返回 nil 表示用规则兜底) func (a *AdviceEvaluateBiz) modelOf(ctx context.Context, projectId int32, projectCache map[int32]*dbmodel.AiAdviceProject, modelCache map[int32]*dbmodel.AiAdviceModelSup) *dbmodel.AiAdviceModelSup { if projectId == 0 { return nil } proj, ok := projectCache[projectId] if !ok { var p dbmodel.AiAdviceProject cond := builder.NewCond().And(builder.Eq{"project_id": projectId}) if err := a.adviceProjectImpl.GetOneBySearchToStrut(&cond, &p); err != nil || p.ProjectID == 0 { projectCache[projectId] = nil return nil } proj = &p projectCache[projectId] = proj } if proj == nil || proj.ModelSupID == 0 { return nil } if m, ok := modelCache[proj.ModelSupID]; ok { return m } var m dbmodel.AiAdviceModelSup cond := builder.NewCond().And(builder.Eq{"sup_id": proj.ModelSupID}) if err := a.aiAdviceModelSupImpl.GetOneBySearchToStrut(&cond, &m); err != nil || m.SupID == 0 { modelCache[proj.ModelSupID] = nil return nil } modelCache[proj.ModelSupID] = &m return &m } // recentMsgs 拉取该客户最近的聊天记录(时间正序返回) func (a *AdviceEvaluateBiz) recentMsgs(ctx context.Context, wxid string, limit int64) (list []mongo_model.AdvicerWxMsgMongo) { if len(wxid) == 0 { return nil } cursor, err := a.mongo.Co(a.wxMsgMongo).Find(ctx, bson.M{"wxid": wxid}, options.Find().SetSort(bson.D{{Key: "createAt", Value: -1}}).SetLimit(limit)) if err != nil { return nil } for cursor.Next(ctx) { var m mongo_model.AdvicerWxMsgMongo if err := cursor.Decode(&m); err != nil { return nil } list = append(list, m) } // Mongo 按时间倒序查询,反转回时间正序 for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { list[i], list[j] = list[j], list[i] } return list } // buildEvaluateUserContent 组装评估输入(客户画像 + 互动统计 + 最近聊天记录) func buildEvaluateUserContent(item *mongo_model.AdvicerClientItem, msgs []mongo_model.AdvicerWxMsgMongo) string { var b strings.Builder b.WriteString("[客户画像]\n") b.WriteString(pkg.JsonStringIgonErr(item.Entity())) b.WriteString("\n\n[互动统计]\n") b.WriteString(fmt.Sprintf("累计消息数:%d\n", item.MsgCount)) if !item.LastContactAt.IsZero() { b.WriteString("最后互动时间:" + item.LastContactAt.Format("2006-01-02 15:04:05") + "\n") } if !item.LastClientMsgAt.IsZero() { b.WriteString("客户最后发言时间:" + item.LastClientMsgAt.Format("2006-01-02 15:04:05") + "\n") } b.WriteString(fmt.Sprintf("当前时间:%s\n", time.Now().Format("2006-01-02 15:04:05"))) if len(item.ClientLevel) != 0 { b.WriteString("上次评估等级:" + mongo_model.ClientLevelDesc[item.ClientLevel] + "\n") } b.WriteString("\n[最近聊天记录(按时间先后)]\n") if len(msgs) == 0 { b.WriteString("(暂无聊天记录)\n") } for _, m := range msgs { who := "客户" if m.Direction != mongo_model.WxMsgDirectionCustomer { who = "销售" } content := m.Content if m.MsgType != mongo_model.WxMsgTypeText { content = "[" + m.MsgType + "消息] " + content } b.WriteString(fmt.Sprintf("[%s] %s:%s\n", m.CreateAt.Format("01-02 15:04"), who, content)) } return b.String() } // ruleEvaluate 规则兜底评估(LLM 不可用或聊天数据不足时) func ruleEvaluate(item *mongo_model.AdvicerClientItem, now time.Time) (level, reason string) { last := item.LastClientMsgAt if last.IsZero() { last = item.LastContactAt } if last.IsZero() || item.MsgCount == 0 { return mongo_model.ClientLevelUnknown, "暂无互动记录,待积累" } silentDays := int(now.Sub(last).Hours() / 24) switch { case silentDays > 30: return mongo_model.ClientLevelSleeping, fmt.Sprintf("已 %d 天无互动", silentDays) case item.MsgCount >= 20 && silentDays <= 7: return mongo_model.ClientLevelRegular, "近期高频互动" case item.MsgCount >= 3 && silentDays <= 14: return mongo_model.ClientLevelIntent, "近期活跃且有持续互动" default: return mongo_model.ClientLevelSleeping, "互动频率较低" } } // extractJsonObject 从 LLM 输出中提取 JSON 对象(去除 markdown 代码围栏等包裹) func extractJsonObject(s string) string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "```json") s = strings.TrimPrefix(s, "```") s = strings.TrimSuffix(s, "```") s = strings.TrimSpace(s) start := strings.Index(s, "{") end := strings.LastIndex(s, "}") if start >= 0 && end > start { return s[start : end+1] } return s }