ai_scheduler/internal/biz/advice_evaluate.go

287 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}