392 lines
12 KiB
Go
392 lines
12 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"
|
||
"ai_scheduler/internal/data/mongo_model"
|
||
"ai_scheduler/internal/entitys"
|
||
"ai_scheduler/internal/pkg"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"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"
|
||
)
|
||
|
||
// AdviceIterateBiz 对话完成自我迭代
|
||
// 销售与客户的对话结束(最后一条消息超过 dialog_idle_minutes,默认 30 分钟)后,
|
||
// 将本轮聊天记录交给 LLM 再次分析,提取新信息并新建/完善客户画像。
|
||
type AdviceIterateBiz struct {
|
||
cfg *config.Config
|
||
openai *third_party.OpenAi
|
||
mongo *pkg.Mongo
|
||
clientBiz *AdviceClientBiz
|
||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo
|
||
projectImpl *impl.AdviceProjectImpl
|
||
modelSupImpl *impl.AiAdviceModelSupImpl
|
||
}
|
||
|
||
func NewAdviceIterateBiz(
|
||
cfg *config.Config,
|
||
openai *third_party.OpenAi,
|
||
mongo *pkg.Mongo,
|
||
clientBiz *AdviceClientBiz,
|
||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo,
|
||
projectImpl *impl.AdviceProjectImpl,
|
||
modelSupImpl *impl.AiAdviceModelSupImpl,
|
||
) *AdviceIterateBiz {
|
||
return &AdviceIterateBiz{
|
||
cfg: cfg,
|
||
openai: openai,
|
||
mongo: mongo,
|
||
clientBiz: clientBiz,
|
||
wxMsgMongo: wxMsgMongo,
|
||
projectImpl: projectImpl,
|
||
modelSupImpl: modelSupImpl,
|
||
}
|
||
}
|
||
|
||
// Run 执行画像迭代:扫描未分析消息 → 按客户分组 → 对话完成判定 → LLM 提取增量 → 合并画像 → 标记已分析。
|
||
// Force=true 忽略对话完成(闲置时长)等待,立即迭代(手动触发用)。
|
||
func (a *AdviceIterateBiz) Run(ctx context.Context, param *entitys.AdvicerIterateRunReq) (res *entitys.AdvicerIterateRunRes, err error) {
|
||
res = &entitys.AdvicerIterateRunRes{Details: []string{}}
|
||
if param == nil {
|
||
param = &entitys.AdvicerIterateRunReq{}
|
||
}
|
||
|
||
// 1. 扫描未分析消息(时间正序,单轮上限 500 条)
|
||
filter := bson.M{"analyzed": false}
|
||
if len(param.ClientId) != 0 {
|
||
client, e := a.clientBiz.Info(ctx, &entitys.AdvicerClientInfoReq{Id: param.ClientId})
|
||
if e != nil {
|
||
return res, fmt.Errorf("客户不存在: %w", e)
|
||
}
|
||
if len(client.Wxid) == 0 {
|
||
return res, errors.New("该客户未绑定微信 wxid,无聊天记录可迭代")
|
||
}
|
||
filter["wxid"] = client.Wxid
|
||
}
|
||
cursor, e := a.mongo.Co(a.wxMsgMongo).Find(ctx, filter,
|
||
options.Find().SetSort(bson.D{{Key: "createAt", Value: 1}}).SetLimit(500))
|
||
if e != nil {
|
||
return res, e
|
||
}
|
||
var msgs []mongo_model.AdvicerWxMsgItem
|
||
for cursor.Next(ctx) {
|
||
var item mongo_model.AdvicerWxMsgItem
|
||
if e = cursor.Decode(&item); e != nil {
|
||
return res, e
|
||
}
|
||
msgs = append(msgs, item)
|
||
}
|
||
if e = cursor.Err(); e != nil {
|
||
return res, e
|
||
}
|
||
res.Scanned = len(msgs)
|
||
if len(msgs) == 0 {
|
||
return res, nil
|
||
}
|
||
|
||
// 2. 按客户 wxid 分组(保持时间顺序)
|
||
group := map[string][]mongo_model.AdvicerWxMsgItem{}
|
||
var order []string
|
||
for _, m := range msgs {
|
||
if len(m.Wxid) == 0 {
|
||
continue
|
||
}
|
||
if _, ok := group[m.Wxid]; !ok {
|
||
order = append(order, m.Wxid)
|
||
}
|
||
group[m.Wxid] = append(group[m.Wxid], m)
|
||
}
|
||
|
||
idle := time.Duration(a.idleMinutes()) * time.Minute
|
||
for _, wxid := range order {
|
||
items := group[wxid]
|
||
// 2.1 客户绑定校验(未绑定客户不标记,待绑定后再迭代)
|
||
client, found, e := a.clientBiz.FindByWxid(ctx, wxid)
|
||
if e != nil || !found {
|
||
continue
|
||
}
|
||
// 2.2 对话完成判定:最新一条消息(任意方向)距现在超过闲置时长
|
||
if !param.Force {
|
||
latest := a.latestMsgTime(ctx, wxid)
|
||
if latest.IsZero() || time.Since(latest) < idle {
|
||
res.Idle++
|
||
continue
|
||
}
|
||
}
|
||
// 2.3 LLM 分析并合并画像
|
||
summary, updated, e := a.iterateClient(ctx, &client, items)
|
||
// 2.4 标记本轮消息已分析(无论画像是否有增量,避免重复分析)
|
||
ids := make([]primitive.ObjectID, 0, len(items))
|
||
for _, m := range items {
|
||
ids = append(ids, m.Id)
|
||
}
|
||
_, _ = a.mongo.Co(a.wxMsgMongo).UpdateMany(ctx,
|
||
bson.M{"_id": bson.M{"$in": ids}},
|
||
bson.M{"$set": bson.M{"analyzed": true}})
|
||
|
||
if e != nil {
|
||
res.Details = append(res.Details, fmt.Sprintf("%s:迭代失败 %v", clientName(&client), e))
|
||
continue
|
||
}
|
||
if updated {
|
||
res.Updated++
|
||
text := clientName(&client) + ":画像已更新"
|
||
if strings.TrimSpace(summary) != "" {
|
||
text += "(" + strings.TrimSpace(summary) + ")"
|
||
}
|
||
res.Details = append(res.Details, text)
|
||
} else {
|
||
res.Details = append(res.Details, clientName(&client)+":本轮无新增画像信息")
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// iterateClient 对单客户执行画像迭代:LLM 提取增量 → 合并画像 → 更新 lastIterateAt
|
||
func (a *AdviceIterateBiz) iterateClient(ctx context.Context, c *mongo_model.AdvicerClientItem, newMsgs []mongo_model.AdvicerWxMsgItem) (summary string, updated bool, err error) {
|
||
_, model := projectModelOf(ctx, a.projectImpl, a.modelSupImpl, c.ProjectId)
|
||
if model == nil {
|
||
return "", false, errors.New("项目未配置可用模型(请检查 modelSupId)")
|
||
}
|
||
history := a.recentMsgs(ctx, c.Wxid, 30)
|
||
content := buildIterateUserContent(c, history, newMsgs)
|
||
messages := []openai.ResponseInputMessage{
|
||
{Role: openai.ChatMessageRoleSystem, Content: constants.ClientIteratePrompt},
|
||
{Role: openai.ChatMessageRoleUser, Content: content},
|
||
}
|
||
resp, err := a.openai.CreateResponseMessages(ctx, model.Key, model.URL, model.ChatModel, messages, "")
|
||
if err != nil {
|
||
return "", false, fmt.Errorf("分析聊天记录失败: %w", err)
|
||
}
|
||
var patch iteratePatch
|
||
if err = json.Unmarshal([]byte(extractJsonObject(resp.GetOutputText())), &patch); err != nil {
|
||
return "", false, fmt.Errorf("解析迭代结果失败: %w", err)
|
||
}
|
||
|
||
// 合并增量(仅覆盖模型明确输出的"新发现/修正"字段)
|
||
set := bson.M{}
|
||
if p := patch.PersonalInfo; p != nil {
|
||
pi := c.PersonalInfo
|
||
if v := strings.TrimSpace(p.Name); v != "" && v != pi.Name {
|
||
pi.Name, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.Gender); v != "" && v != pi.Gender {
|
||
pi.Gender, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.Location); v != "" && v != pi.Location {
|
||
pi.Location, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.FamilyOrganize); v != "" && v != pi.FamilyOrganize {
|
||
pi.FamilyOrganize, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.Birthday); v != "" && v != pi.Birthday {
|
||
pi.Birthday, updated = v, true
|
||
}
|
||
if p.IsFirstHome != nil && *p.IsFirstHome != pi.IsFirstHome {
|
||
pi.IsFirstHome, updated = *p.IsFirstHome, true
|
||
}
|
||
set["personalInfo"] = pi
|
||
}
|
||
if p := patch.PurchasePurpose; p != nil {
|
||
pp := c.PurchasePurpose
|
||
if v := strings.TrimSpace(p.PrimaryPurpose); v != "" && v != pp.PrimaryPurpose {
|
||
pp.PrimaryPurpose, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.SecondaryPurpose); v != "" && v != pp.SecondaryPurpose {
|
||
pp.SecondaryPurpose, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.DecisionMakers); v != "" && v != pp.DecisionMakers {
|
||
pp.DecisionMakers, updated = v, true
|
||
}
|
||
set["purchasePurpose"] = pp
|
||
}
|
||
if p := patch.CoreDemands; p != nil {
|
||
cd := c.CoreDemands
|
||
if v := strings.TrimSpace(p.TotalBudget); v != "" && v != cd.TotalBudget {
|
||
cd.TotalBudget, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.PreferredLayout); v != "" && v != cd.PreferredLayout {
|
||
cd.PreferredLayout, updated = v, true
|
||
}
|
||
if v := strings.TrimSpace(p.CoreAppeal); v != "" && v != cd.CoreAppeal {
|
||
cd.CoreAppeal, updated = v, true
|
||
}
|
||
set["coreDemands"] = cd
|
||
}
|
||
if len(patch.Concerns) > 0 {
|
||
merged := mergeUnique(c.Concerns, patch.Concerns)
|
||
if len(merged) != len(c.Concerns) {
|
||
updated = true
|
||
}
|
||
set["concerns"] = merged
|
||
}
|
||
if len(patch.DecisionProfile) > 0 {
|
||
merged := mergeUnique(c.DecisionProfile, patch.DecisionProfile)
|
||
if len(merged) != len(c.DecisionProfile) {
|
||
updated = true
|
||
}
|
||
set["decisionProfile"] = merged
|
||
}
|
||
|
||
now := time.Now()
|
||
set["lastIterateAt"] = now
|
||
if updated {
|
||
set["lastUpdateTime"] = now
|
||
}
|
||
_, err = a.mongo.Co(a.clientBiz.AdvicerClientMongo).UpdateOne(ctx, bson.M{"_id": c.Id}, bson.M{"$set": set})
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
return strings.TrimSpace(patch.Summary), updated, nil
|
||
}
|
||
|
||
// idleMinutes 对话完成闲置时长(分钟,默认 30)
|
||
func (a *AdviceIterateBiz) idleMinutes() int {
|
||
d := a.cfg.Advicer.DialogIdleMinutes
|
||
if d <= 0 {
|
||
d = 30
|
||
}
|
||
return d
|
||
}
|
||
|
||
// latestMsgTime 客户最新一条消息时间(任意方向)
|
||
func (a *AdviceIterateBiz) latestMsgTime(ctx context.Context, wxid string) time.Time {
|
||
res := a.mongo.Co(a.wxMsgMongo).FindOne(ctx, bson.M{"wxid": wxid},
|
||
options.FindOne().SetSort(bson.D{{Key: "createAt", Value: -1}}))
|
||
if res.Err() != nil {
|
||
return time.Time{}
|
||
}
|
||
var m mongo_model.AdvicerWxMsgMongo
|
||
if err := res.Decode(&m); err != nil {
|
||
return time.Time{}
|
||
}
|
||
return m.CreateAt
|
||
}
|
||
|
||
// recentMsgs 拉取客户最近聊天记录(时间正序返回)
|
||
func (a *AdviceIterateBiz) 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)
|
||
}
|
||
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
|
||
}
|
||
|
||
// ==================== 迭代辅助类型与函数 ====================
|
||
|
||
// iteratePatch 画像迭代增量(指针结构区分"未提及"与"空值")
|
||
type iteratePatch struct {
|
||
PersonalInfo *iteratePersonal `json:"personalInfo"`
|
||
PurchasePurpose *iteratePurchase `json:"purchasePurpose"`
|
||
CoreDemands *iterateDemands `json:"coreDemands"`
|
||
Concerns []string `json:"concerns"`
|
||
DecisionProfile []string `json:"decisionProfile"`
|
||
Summary string `json:"summary"`
|
||
}
|
||
|
||
type iteratePersonal struct {
|
||
Name string `json:"name"`
|
||
Gender string `json:"gender"`
|
||
Location string `json:"location"`
|
||
IsFirstHome *bool `json:"isFirstHome"`
|
||
FamilyOrganize string `json:"familyOrganize"`
|
||
Birthday string `json:"birthday"`
|
||
}
|
||
|
||
type iteratePurchase struct {
|
||
PrimaryPurpose string `json:"primaryPurpose"`
|
||
SecondaryPurpose string `json:"secondaryPurpose"`
|
||
DecisionMakers string `json:"decisionMakers"`
|
||
}
|
||
|
||
type iterateDemands struct {
|
||
TotalBudget string `json:"totalBudget"`
|
||
PreferredLayout string `json:"preferredLayout"`
|
||
CoreAppeal string `json:"coreAppeal"`
|
||
}
|
||
|
||
// buildIterateUserContent 组装迭代输入(现有画像 + 本轮新增对话 + 更早上下文)
|
||
func buildIterateUserContent(c *mongo_model.AdvicerClientItem, history []mongo_model.AdvicerWxMsgMongo, newMsgs []mongo_model.AdvicerWxMsgItem) string {
|
||
var b strings.Builder
|
||
b.WriteString("[客户现有画像]\n")
|
||
b.WriteString(pkg.JsonStringIgonErr(c.Entity()))
|
||
b.WriteString("\n\n[本轮需要分析的聊天记录(按时间先后)]\n")
|
||
if len(newMsgs) == 0 {
|
||
b.WriteString("(无)\n")
|
||
}
|
||
for i := range newMsgs {
|
||
b.WriteString(formatIterateMsg(&newMsgs[i].AdvicerWxMsgMongo))
|
||
}
|
||
// 更早的上下文(仅帮助理解语境)
|
||
if len(newMsgs) > 0 {
|
||
earliest := newMsgs[0].CreateAt
|
||
var ctxBuilder strings.Builder
|
||
for i := range history {
|
||
if history[i].CreateAt.Before(earliest) {
|
||
ctxBuilder.WriteString(formatIterateMsg(&history[i]))
|
||
}
|
||
}
|
||
if ctxBuilder.Len() > 0 {
|
||
b.WriteString("\n[更早的聊天记录(仅供理解上下文,无需重复提取)]\n")
|
||
b.WriteString(ctxBuilder.String())
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// formatIterateMsg 单条消息格式化(带时间)
|
||
func formatIterateMsg(m *mongo_model.AdvicerWxMsgMongo) string {
|
||
who := "客户"
|
||
if m.Direction != mongo_model.WxMsgDirectionCustomer {
|
||
who = "我"
|
||
}
|
||
content := m.Content
|
||
if m.MsgType != mongo_model.WxMsgTypeText {
|
||
content = "[" + m.MsgType + "消息] " + content
|
||
}
|
||
return fmt.Sprintf("[%s] %s:%s\n", m.CreateAt.Format("01-02 15:04"), who, content)
|
||
}
|
||
|
||
// mergeUnique 追加去重(保留原有顺序)
|
||
func mergeUnique(old []string, add []string) []string {
|
||
seen := map[string]bool{}
|
||
for _, s := range old {
|
||
seen[strings.TrimSpace(s)] = true
|
||
}
|
||
merged := append([]string{}, old...)
|
||
for _, s := range add {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || seen[s] {
|
||
continue
|
||
}
|
||
seen[s] = true
|
||
merged = append(merged, s)
|
||
}
|
||
return merged
|
||
}
|