287 lines
9.5 KiB
Go
287 lines
9.5 KiB
Go
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.PersonalInfo.Name,
|
||
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
|
||
}
|