718 lines
25 KiB
Go
718 lines
25 KiB
Go
package biz
|
||
|
||
import (
|
||
"ai_scheduler/internal/biz/llm_service/third_party"
|
||
"ai_scheduler/internal/data/constants"
|
||
"ai_scheduler/internal/data/mongo_model"
|
||
|
||
"ai_scheduler/internal/data/impl"
|
||
dbmodel "ai_scheduler/internal/data/model"
|
||
"ai_scheduler/internal/entitys"
|
||
"ai_scheduler/internal/pkg"
|
||
"ai_scheduler/utils"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/gofiber/fiber/v2/log"
|
||
"github.com/google/uuid"
|
||
"github.com/sashabaranov/go-openai"
|
||
"go.mongodb.org/mongo-driver/bson"
|
||
"go.mongodb.org/mongo-driver/mongo/options"
|
||
"xorm.io/builder"
|
||
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type AdviceChatBiz struct {
|
||
openai *third_party.OpenAi
|
||
rdb *utils.Rdb
|
||
aiAdviceSessionImpl *impl.AiAdviceSessionImpl
|
||
aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl
|
||
advicerChatHisMongo *mongo_model.AdvicerChatHisMongo
|
||
mongo *pkg.Mongo
|
||
// followImpl 用于在对话时查询并注入该客户未闭环的成交线索
|
||
followImpl *impl.AdviceFollowImpl
|
||
// followBiz 用于对话回复后把 AI 内联返回的 remind/lead 落库并回写 mission;
|
||
// 因 wire 构造顺序(chatBiz 早于 followBiz)用 setter 注入,避免重排依赖链。
|
||
followBiz *AdviceFollowBiz
|
||
}
|
||
|
||
// BindFollowBiz 注入跟进持久化业务(由 wire_gen 在构造后调用)
|
||
func (a *AdviceChatBiz) BindFollowBiz(f *AdviceFollowBiz) { a.followBiz = f }
|
||
|
||
func NewAdviceChatBiz(
|
||
openai *third_party.OpenAi,
|
||
rdb *utils.Rdb,
|
||
aiAdviceSessionImpl *impl.AiAdviceSessionImpl,
|
||
aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl,
|
||
advicerChatHisMongo *mongo_model.AdvicerChatHisMongo,
|
||
mongo *pkg.Mongo,
|
||
followImpl *impl.AdviceFollowImpl,
|
||
) *AdviceChatBiz {
|
||
return &AdviceChatBiz{
|
||
openai: openai,
|
||
rdb: rdb,
|
||
aiAdviceSessionImpl: aiAdviceSessionImpl,
|
||
aiAdviceModelSupImpl: aiAdviceModelSupImpl,
|
||
advicerChatHisMongo: advicerChatHisMongo,
|
||
mongo: mongo,
|
||
followImpl: followImpl,
|
||
}
|
||
}
|
||
|
||
func (a *AdviceChatBiz) contextCache(ctx context.Context, chatData *entitys.ChatData, req *entitys.AdvicerChatRegistReq, projectInfo *entitys.AdvicerProjectInfoRes) (promptJson string, contextCache string, err error) {
|
||
switch constants.Mode(projectInfo.ModelInfo.Mode) {
|
||
case constants.ModeResponse, constants.ModeContext:
|
||
// 恒定无状态:注册阶段不再调用模型创建续写 response。
|
||
// previous_response_id 跨供应商不可靠且聊天链路已不再引用,省去会话开头的多余一次模型调用。
|
||
prompt, err := a.buildBasePromptResponse(ctx, chatData, req)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
contextCache = ""
|
||
promptJson = pkg.JsonStringIgonErr(prompt)
|
||
default:
|
||
return "", "", fmt.Errorf("未知的mode类型:%d", projectInfo.ModelInfo.Mode)
|
||
}
|
||
return
|
||
}
|
||
|
||
func (a *AdviceChatBiz) Regis(ctx context.Context, chatData *entitys.ChatData, req *entitys.AdvicerChatRegistReq, projectInfo *entitys.AdvicerProjectInfoRes) (string, error) {
|
||
promptJson, contextCache, err := a.contextCache(ctx, chatData, req, projectInfo)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
sessionId := uuid.New().String()
|
||
//创建会话
|
||
err = a.aiAdviceSessionImpl.Add(ctx, &dbmodel.AiAdviceSession{
|
||
SessionID: sessionId,
|
||
ProjectID: projectInfo.Base.ProjectID,
|
||
SupID: projectInfo.Base.ModelSupID,
|
||
AdvicerVersionID: req.AdvicerVersionId,
|
||
AdvicerID: req.AdvicerId,
|
||
ClientID: req.ClientId,
|
||
TalkSkillID: req.TalkSkillId,
|
||
Mission: req.Mission,
|
||
ContextCache: contextCache,
|
||
CreateAt: time.Now(),
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
err = a.rdb.Rdb.SetEx(ctx, sessionId, promptJson, 3600*time.Second).Err()
|
||
// 存储 ChatData 供后续 Chat 全量注入提示词
|
||
if chatDataJson, e := json.Marshal(chatData); e == nil {
|
||
_ = a.rdb.Rdb.SetEx(ctx, "chatdata:"+sessionId, string(chatDataJson), 3600*time.Second).Err()
|
||
}
|
||
return sessionId, err
|
||
}
|
||
|
||
// UpdateSessionMission 仅更新 session 的 mission 字段(用于复用已有会话时刷新人格/项目信息)
|
||
func (a *AdviceChatBiz) UpdateSessionMission(ctx context.Context, sessionId, mission string) error {
|
||
cond := builder.NewCond()
|
||
cond = cond.And(builder.Eq{"session_id": sessionId})
|
||
return a.aiAdviceSessionImpl.UpdateByCond(&cond, map[string]interface{}{"mission": mission})
|
||
}
|
||
|
||
func (a *AdviceChatBiz) Chat(ctx context.Context, chat *entitys.AdvicerChatReq) (assistant mongo_model.Assistant, err error) {
|
||
var session dbmodel.AiAdviceSession
|
||
cond := builder.NewCond()
|
||
cond = cond.And(builder.Eq{"session_id": chat.SessionId})
|
||
err = a.aiAdviceSessionImpl.GetOneBySearchToStrut(&cond, &session)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
if session.SessionID == "" {
|
||
return assistant, errors.New("未找到会话信息")
|
||
}
|
||
if len(chat.Content) == 0 {
|
||
return assistant, nil
|
||
}
|
||
var modelInfo dbmodel.AiAdviceModelSup
|
||
cond = builder.NewCond()
|
||
cond = cond.And(builder.Eq{"sup_id": session.SupID})
|
||
err = a.aiAdviceModelSupImpl.GetOneBySearchToStrut(&cond, &modelInfo)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
if modelInfo.SupID == 0 {
|
||
return assistant, errors.New("未找到模型信息")
|
||
}
|
||
chatHis, err := a.getChatHis(ctx, session.SessionID, 6)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
var chatData entitys.ChatData
|
||
if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil {
|
||
_ = json.Unmarshal([]byte(cdStr), &chatData)
|
||
}
|
||
// chatdata 快照(TTL 1h)过期后用请求携带的客户 wxid 兜底,避免 remind/lead 漏落库与成交线索漏注入
|
||
if chatData.ClientWxid == "" {
|
||
chatData.ClientWxid = chat.ClientWxid
|
||
}
|
||
prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis, &chatData)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
|
||
// 恒定无状态:不依赖 previous_response_id,每轮靠 buildChatPromptResponse 全量注入上下文,跨供应商可移植且不会再出现 "Previous response not found"
|
||
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, "")
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
|
||
result := resContent.GetOutputText()
|
||
log.Infof("[AI回复] sessionId=%s content=%s", session.SessionID, result)
|
||
if err = json.Unmarshal([]byte(result), &assistant); err != nil {
|
||
return assistant, err
|
||
}
|
||
var inToken, outToken int64
|
||
if resContent.Usage != nil {
|
||
inToken = int64(resContent.Usage.InputTokens)
|
||
outToken = int64(resContent.Usage.OutputTokens)
|
||
}
|
||
chatCtx, cancel := context.WithCancel(context.Background())
|
||
go func(session dbmodel.AiAdviceSession) {
|
||
defer cancel()
|
||
_, _ = a.mongo.Co(a.advicerChatHisMongo).InsertOne(chatCtx, &mongo_model.AdvicerChatHisMongo{
|
||
SessionId: chat.SessionId,
|
||
User: chat.Content,
|
||
Assistant: assistant,
|
||
InToken: inToken,
|
||
OutToken: outToken,
|
||
CreatAt: time.Now(),
|
||
})
|
||
if assistant.MissionStatus == "fail" || assistant.MissionStatus == "completed" {
|
||
cond = builder.NewCond()
|
||
cond = cond.And(builder.Eq{"session_id": chat.SessionId})
|
||
session.MissionStatus = assistant.MissionStatus
|
||
session.MissionCompleteDesc = assistant.MissionCompleteDesc
|
||
_ = a.aiAdviceSessionImpl.UpdateByCond(&cond, session)
|
||
}
|
||
// 对话内联抽取:把 AI 返回的 remind/lead 去重落库,并回写简洁 mission 到客户画像
|
||
if a.followBiz != nil {
|
||
if e := a.followBiz.PersistFromAssistant(chatCtx, session.ProjectID, session.AdvicerID, chatData.ClientWxid, assistant.Mission, &assistant); e != nil {
|
||
log.Errorf("[跟进持久化] sessionId=%s 失败: %v", chat.SessionId, e)
|
||
}
|
||
}
|
||
}(session)
|
||
return
|
||
}
|
||
|
||
// ChatWithSession 与 Chat 逻辑相同,但直接接收已查到的 session 和 modelInfo,跳过重复查询。
|
||
// 用于托管流程中 Regis 刚创建完 session 后立即 Chat 的场景。
|
||
func (a *AdviceChatBiz) ChatWithSession(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, modelInfo *dbmodel.AiAdviceModelSup) (assistant mongo_model.Assistant, err error) {
|
||
if session == nil || session.SessionID == "" {
|
||
return assistant, errors.New("会话信息不能为空")
|
||
}
|
||
if modelInfo == nil || modelInfo.SupID == 0 {
|
||
return assistant, errors.New("模型信息不能为空")
|
||
}
|
||
if len(chat.Content) == 0 {
|
||
return assistant, nil
|
||
}
|
||
chatHis, err := a.getChatHis(ctx, session.SessionID, 6)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
var chatData entitys.ChatData
|
||
if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil {
|
||
_ = json.Unmarshal([]byte(cdStr), &chatData)
|
||
}
|
||
// 同 Chat:chatdata 快照过期时用请求携带的客户 wxid 兜底
|
||
if chatData.ClientWxid == "" {
|
||
chatData.ClientWxid = chat.ClientWxid
|
||
}
|
||
prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis, &chatData)
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
|
||
// 恒定无状态:不依赖 previous_response_id,每轮靠 buildChatPromptResponse 全量注入上下文,跨供应商可移植且不会再出现 "Previous response not found"
|
||
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, "")
|
||
if err != nil {
|
||
return assistant, err
|
||
}
|
||
|
||
result := resContent.GetOutputText()
|
||
log.Infof("[AI回复] sessionId=%s content=%s", session.SessionID, result)
|
||
if err = json.Unmarshal([]byte(result), &assistant); err != nil {
|
||
return assistant, err
|
||
}
|
||
var inToken, outToken int64
|
||
if resContent.Usage != nil {
|
||
inToken = int64(resContent.Usage.InputTokens)
|
||
outToken = int64(resContent.Usage.OutputTokens)
|
||
}
|
||
chatCtx, cancel := context.WithCancel(context.Background())
|
||
go func(s dbmodel.AiAdviceSession) {
|
||
defer cancel()
|
||
_, _ = a.mongo.Co(a.advicerChatHisMongo).InsertOne(chatCtx, &mongo_model.AdvicerChatHisMongo{
|
||
SessionId: chat.SessionId,
|
||
User: chat.Content,
|
||
Assistant: assistant,
|
||
InToken: inToken,
|
||
OutToken: outToken,
|
||
CreatAt: time.Now(),
|
||
})
|
||
if assistant.MissionStatus == "fail" || assistant.MissionStatus == "completed" {
|
||
cond := builder.NewCond()
|
||
cond = cond.And(builder.Eq{"session_id": chat.SessionId})
|
||
s.MissionStatus = assistant.MissionStatus
|
||
s.MissionCompleteDesc = assistant.MissionCompleteDesc
|
||
_ = a.aiAdviceSessionImpl.UpdateByCond(&cond, s)
|
||
}
|
||
// 对话内联抽取:把 AI 返回的 remind/lead 去重落库,并回写简洁 mission 到客户画像
|
||
if a.followBiz != nil {
|
||
if e := a.followBiz.PersistFromAssistant(chatCtx, s.ProjectID, s.AdvicerID, chatData.ClientWxid, assistant.Mission, &assistant); e != nil {
|
||
log.Errorf("[跟进持久化] sessionId=%s 失败: %v", chat.SessionId, e)
|
||
}
|
||
}
|
||
}(*session)
|
||
return
|
||
}
|
||
|
||
// stripEmpty 递归去除 map/slice 中的空值(空字符串、nil、空数组、空对象),减少 JSON token
|
||
func stripEmpty(v interface{}) interface{} {
|
||
switch val := v.(type) {
|
||
case map[string]interface{}:
|
||
out := make(map[string]interface{})
|
||
for k, item := range val {
|
||
cleaned := stripEmpty(item)
|
||
if cleaned != nil {
|
||
out[k] = cleaned
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
case []interface{}:
|
||
var out []interface{}
|
||
for _, item := range val {
|
||
cleaned := stripEmpty(item)
|
||
if cleaned != nil {
|
||
out = append(out, cleaned)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
case string:
|
||
if val == "" {
|
||
return nil
|
||
}
|
||
return val
|
||
case nil:
|
||
return nil
|
||
default:
|
||
return v
|
||
}
|
||
}
|
||
|
||
// compactJSON 先去空值再序列化,比直接 JsonStringIgonErr 更省 token
|
||
func compactJSON(v interface{}) string {
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
var raw interface{}
|
||
if err = json.Unmarshal(data, &raw); err != nil {
|
||
return string(data)
|
||
}
|
||
cleaned := stripEmpty(raw)
|
||
if cleaned == nil {
|
||
return "{}"
|
||
}
|
||
out, _ := json.Marshal(cleaned)
|
||
return string(out)
|
||
}
|
||
|
||
func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity, chatData *entitys.ChatData) ([]openai.ResponseInputMessage, error) {
|
||
outputFormatHint := fmt.Sprintf("\n[当前时间]%s\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission\":\"当前任务一句话简述(10~20字)\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"};当且仅当本轮对话中客户与你明确了未来的约定(提醒/回访/到访/成交/付款等)时,才在同一个 json 里追加可选字段 remind(到点提醒类约定)或 lead(成交线索类约定),结构见上方说明;没有明确约定时严禁输出这两个字段,一切以 [当前时间] 为基准推算 due_at。", time.Now().Format("2006-01-02 15:04"))
|
||
var sys strings.Builder
|
||
// 基础规则 + 任务 + 风控红线
|
||
sys.WriteString(constants.BasePrompt)
|
||
if session.Mission != "" {
|
||
sys.WriteString(session.Mission)
|
||
sys.WriteString("\n")
|
||
}
|
||
if chatData != nil && chatData.RuleDimension != "" {
|
||
sys.WriteString(constants.RulePromptPrefix)
|
||
sys.WriteString(chatData.RuleDimension)
|
||
sys.WriteString("\n")
|
||
}
|
||
sys.WriteString(constants.BasePrompt2)
|
||
// 全量结构化数据注入(stripEmpty 去空值省 token)
|
||
if chatData != nil {
|
||
if chatData.ProjectInfo != nil {
|
||
sys.WriteString("\n[项目信息]")
|
||
sys.WriteString(compactJSON(chatData.ProjectInfo))
|
||
}
|
||
if chatData.ProjectData != nil && len(chatData.ProjectData) > 0 {
|
||
sys.WriteString("\n[项目资料]")
|
||
sys.WriteString(compactJSON(chatData.ProjectData))
|
||
}
|
||
if chatData.AdvicerInfo != nil {
|
||
sys.WriteString("\n[销售信息]")
|
||
sys.WriteString(compactJSON(chatData.AdvicerInfo))
|
||
}
|
||
if chatData.AdvicerVersion != nil && len(chatData.AdvicerVersion) > 0 {
|
||
sys.WriteString("\n[销售人设风格]")
|
||
sys.WriteString(compactJSON(chatData.AdvicerVersion))
|
||
}
|
||
if len(chatData.ClientInfo) > 0 {
|
||
sys.WriteString("\n[客户信息]")
|
||
sys.WriteString(compactJSON(chatData.ClientInfo))
|
||
}
|
||
// 未闭环的成交线索(到访/成交/付款约定),供后续对话自然衔接
|
||
if a.followImpl != nil && chatData.ClientWxid != "" {
|
||
if leads, e := a.followImpl.FindActiveLeadsByWxid(ctx, chatData.ClientWxid); e == nil && len(leads) > 0 {
|
||
sys.WriteString("\n[成交线索](以下是该客户此前与你达成、尚未闭环的到访/成交/付款约定,请在对话中自然衔接、适时确认或推进;切勿生硬罗列、逐条盘问,也不要编造未约定的细节)\n")
|
||
for _, ld := range leads {
|
||
line := "- " + dbmodel.LeadTypeLabel(ld.LeadType) + ":" + ld.Title
|
||
if ld.Detail != "" {
|
||
line += "(" + ld.Detail + ")"
|
||
}
|
||
if !ld.ExpectAt.IsZero() {
|
||
line += ",约定时间 " + ld.ExpectAt.Format("2006-01-02 15:04")
|
||
}
|
||
sys.WriteString(line + "\n")
|
||
}
|
||
}
|
||
}
|
||
if chatData.TalkSkill != nil {
|
||
sys.WriteString("\n[销售技巧](以下是你的备用弹药,不是待办提问清单:仅当客户主动聊到相关话题时,才顺势借用其中的说法来回应;严禁照着这些条目逐条向客户提问,也不要主动抛出这些话题)")
|
||
sys.WriteString(compactJSON(chatData.TalkSkill))
|
||
}
|
||
}
|
||
sys.WriteString(outputFormatHint)
|
||
message := []openai.ResponseInputMessage{
|
||
{
|
||
Role: openai.ChatMessageRoleSystem,
|
||
Content: sys.String(),
|
||
},
|
||
{
|
||
Role: openai.ChatMessageRoleSystem,
|
||
Content: formatChatHis(chatList),
|
||
},
|
||
{
|
||
Role: openai.ChatMessageRoleUser,
|
||
Content: chat.Content,
|
||
},
|
||
}
|
||
return message, nil
|
||
}
|
||
|
||
// formatChatHis 将历史记录转写为「客户/我」的自然对话文本,更贴近真人聊天语境
|
||
func formatChatHis(chatList []mongo_model.AdvicerChatHisMongoEntity) string {
|
||
if len(chatList) == 0 {
|
||
return "(暂无历史聊天记录,这是你与客户的第一轮对话)"
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("以下是你(顾问)与客户最近的聊天记录(按时间先后排列):\n")
|
||
for _, h := range chatList {
|
||
b.WriteString("客户:" + h.User + "\n")
|
||
b.WriteString("我:" + h.Assistant + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (a *AdviceChatBiz) getChatHis(ctx context.Context, sessionId string, limit int64) (chatList []mongo_model.AdvicerChatHisMongoEntity, err error) {
|
||
chatList = make([]mongo_model.AdvicerChatHisMongoEntity, 0)
|
||
filter := bson.M{}
|
||
filter["sessionId"] = sessionId
|
||
// 按时间倒序取最近 limit 条(保证获取的是最新对话)
|
||
cursor, err := a.mongo.Co(a.advicerChatHisMongo).Find(ctx, filter,
|
||
options.Find().SetSort(bson.D{{Key: "creatAt", Value: -1}}).SetLimit(limit))
|
||
if err != nil {
|
||
return chatList, err
|
||
}
|
||
for cursor.Next(ctx) {
|
||
var chatHIS mongo_model.AdvicerChatHisMongo
|
||
if err := cursor.Decode(&chatHIS); err != nil {
|
||
return nil, err
|
||
}
|
||
chatList = append(chatList, chatHIS.Entity())
|
||
}
|
||
|
||
if err := cursor.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
// Mongo 按时间倒序查询,反转回时间正序后再拼入 prompt
|
||
for i, j := 0, len(chatList)-1; i < j; i, j = i+1, j-1 {
|
||
chatList[i], chatList[j] = chatList[j], chatList[i]
|
||
}
|
||
return
|
||
}
|
||
|
||
func (a *AdviceChatBiz) taskPrompt(session *dbmodel.AiAdviceSession) string {
|
||
var b strings.Builder
|
||
b.WriteString("[当前时间]" + time.Now().Format("2006-01-02 15:04:05"))
|
||
status := session.MissionStatus
|
||
if status != "completed" && status != "fail" {
|
||
status = "in_progress"
|
||
}
|
||
b.WriteString("\n[当前任务状态]" + status)
|
||
if session.MissionCompleteDesc != "" {
|
||
b.WriteString("\n[任务最新进展]" + session.MissionCompleteDesc)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (a *AdviceChatBiz) buildBasePromptResponse(ctx context.Context, chatData *entitys.ChatData, req *entitys.AdvicerChatRegistReq) ([]openai.ResponseInputMessage, error) {
|
||
message := []openai.ResponseInputMessage{
|
||
{
|
||
Role: openai.ChatMessageRoleSystem,
|
||
Content: a.sysPrompt(chatData, req),
|
||
},
|
||
}
|
||
// 销售人设风格指令(自然语言规则 + 真实对话范例 few-shot),无人设数据时跳过
|
||
if persona := a.personaPrompt(chatData); persona != "" {
|
||
message = append(message, openai.ResponseInputMessage{
|
||
Role: openai.ChatMessageRoleSystem,
|
||
Content: persona,
|
||
})
|
||
}
|
||
message = append(message, openai.ResponseInputMessage{
|
||
Role: openai.ChatMessageRoleSystem,
|
||
Content: "[结构化数据]\n" + pkg.JsonStringIgonErr(chatData),
|
||
})
|
||
return message, nil
|
||
}
|
||
|
||
func (a *AdviceChatBiz) sysPrompt(chatData *entitys.ChatData, req *entitys.AdvicerChatRegistReq) string {
|
||
var prompt strings.Builder
|
||
prompt.WriteString(constants.BasePrompt)
|
||
prompt.WriteString(req.Mission)
|
||
prompt.WriteString("\n")
|
||
// 项目风控红线(来自项目模板 rule_dimension),最高优先级约束
|
||
if rule := strings.TrimSpace(chatData.RuleDimension); rule != "" {
|
||
prompt.WriteString(constants.RulePromptPrefix)
|
||
prompt.WriteString(rule)
|
||
prompt.WriteString("\n")
|
||
}
|
||
prompt.WriteString(constants.BasePrompt2)
|
||
return prompt.String()
|
||
|
||
}
|
||
|
||
// personaPrompt 会话注册时注入的人设风格提示(内部委托 formatPersonaPrompt)
|
||
func (a *AdviceChatBiz) personaPrompt(chatData *entitys.ChatData) string {
|
||
return formatPersonaPrompt(chatData.AdvicerVersion)
|
||
}
|
||
|
||
// formatPersonaPrompt 将销售版本的扁平维度数据编译为自然语言风格指令 + 真实对话范例(few-shot),
|
||
// 让模型模仿「本人」的说话方式,而不是机械读取数据;无人设数据时返回空串。
|
||
// chat 会话与策略引擎(AI 回复/主动触达)共用本函数,保证各链路语气一致。
|
||
func formatPersonaPrompt(v map[string]interface{}) string {
|
||
if v == nil || len(v) == 0 {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
items := 0
|
||
b.WriteString("[真人风格指令]以下是你本人的说话风格,对话时必须严格模仿,让对方认不出你是AI:\n")
|
||
// 方言/口音特征
|
||
if df := asMap(v["方言/口音特征"]); df != nil {
|
||
region, _ := df["region"].(string)
|
||
keyWords := asStringSlice(df["keyWords"])
|
||
if region != "" || len(keyWords) > 0 {
|
||
b.WriteString("- 语言习惯:")
|
||
if region != "" {
|
||
b.WriteString("说话带" + region + "口音")
|
||
if intensity, ok := asFloat64(df["intensity"]); ok && intensity > 0 {
|
||
b.WriteString(fmt.Sprintf(",方言使用强度约%d%%(自然穿插即可,别每句话都用)", int(intensity*100)))
|
||
}
|
||
}
|
||
if len(keyWords) > 0 {
|
||
b.WriteString(";常用口语词:" + strings.Join(keyWords, "、"))
|
||
}
|
||
b.WriteString("\n")
|
||
items++
|
||
}
|
||
}
|
||
// 词汇偏好
|
||
if vp := asMap(v["词汇偏好"]); vp != nil {
|
||
parts := []string{}
|
||
if hf := asStringSlice(vp["highFreqWords"]); len(hf) > 0 {
|
||
parts = append(parts, "高频词:"+strings.Join(hf, "、"))
|
||
}
|
||
if cp := asStringSlice(vp["catchphrases"]); len(cp) > 0 {
|
||
parts = append(parts, "口头禅:"+strings.Join(cp, "、"))
|
||
}
|
||
if len(parts) > 0 {
|
||
b.WriteString("- 词汇偏好:" + strings.Join(parts, ";") + "\n")
|
||
items++
|
||
}
|
||
}
|
||
// 句式模式
|
||
if sp := asMap(v["句式模式"]); sp != nil {
|
||
modes := []struct{ key, label string }{
|
||
{"openingMode", "开场"}, {"explanationMode", "讲解"},
|
||
{"confirmationMode", "确认"}, {"summaryMode", "总结"}, {"transitionMode", "过渡"},
|
||
}
|
||
parts := []string{}
|
||
for _, m := range modes {
|
||
if items := asStringSlice(sp[m.key]); len(items) > 0 {
|
||
parts = append(parts, m.label+"爱用「"+strings.Join(items, "」「")+"」")
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
b.WriteString("- 句式习惯:" + strings.Join(parts, ";") + "\n")
|
||
items++
|
||
}
|
||
}
|
||
// 标点与排版习惯
|
||
if ph := asMap(v["标点与排版习惯"]); ph != nil {
|
||
parts := []string{}
|
||
for _, key := range []string{"periodUsage", "ellipsisUsage", "waveUsage", "spaceUsage", "newlineHabit", "emojiUsage"} {
|
||
if s, _ := ph[key].(string); s != "" {
|
||
parts = append(parts, s)
|
||
}
|
||
}
|
||
if len(parts) > 0 {
|
||
b.WriteString("- 标点排版:" + strings.Join(parts, ";") + "\n")
|
||
items++
|
||
}
|
||
}
|
||
// 语气基调
|
||
if tt := asMap(v["语气标签"]); tt != nil {
|
||
keys := []string{"enthusiasm", "patience", "confidence", "friendliness", "persuasion"}
|
||
labels := []string{"热情度", "耐心度", "自信度", "亲和力", "说服力"}
|
||
total := 0.0
|
||
vals := make([]int, len(keys))
|
||
for i, k := range keys {
|
||
if f, ok := asFloat64(tt[k]); ok {
|
||
vals[i] = int(f * 100)
|
||
total += f
|
||
}
|
||
}
|
||
if total > 0 {
|
||
b.WriteString(fmt.Sprintf("- 语气基调:%s%d%%、%s%d%%、%s%d%%、%s%d%%、%s%d%%\n",
|
||
labels[0], vals[0], labels[1], vals[1], labels[2], vals[2], labels[3], vals[3], labels[4], vals[4]))
|
||
items++
|
||
}
|
||
}
|
||
// 个性标签
|
||
if tags := asStringSlice(v["个性标签"]); len(tags) > 0 {
|
||
b.WriteString("- 性格特征:" + strings.Join(tags, "、") + "\n")
|
||
items++
|
||
}
|
||
// 标志性对话(few-shot,最多 5 条)
|
||
if dialogs, ok := v["标志性对话"].([]interface{}); ok && len(dialogs) > 0 {
|
||
b.WriteString("- 以下是你以前和客户的真实原话,只模仿说话方式(语气/用词/断句),不要照抄内容:\n")
|
||
count := 0
|
||
for _, d := range dialogs {
|
||
if count >= 5 {
|
||
break
|
||
}
|
||
dm := asMap(d)
|
||
if dm == nil {
|
||
continue
|
||
}
|
||
dialogue, _ := dm["dialogue"].(string)
|
||
if dialogue == "" {
|
||
continue
|
||
}
|
||
context, _ := dm["context"].(string)
|
||
if context != "" {
|
||
b.WriteString(fmt.Sprintf(" 客户场景「%s」时,你说过:「%s」\n", context, dialogue))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf(" 你说过:「%s」\n", dialogue))
|
||
}
|
||
count++
|
||
}
|
||
items++
|
||
}
|
||
if items == 0 {
|
||
return ""
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// asMap 将 interface{} 安全转为 map[string]interface{}
|
||
// 使用 JSON 中转,兼容 bson.M / primitive.M / map[string]interface{} 等各种 map 类型,
|
||
// 解决 mongo-driver 返回 primitive.M 时类型断言 v.(bson.M) 匹配不上的问题。
|
||
func asMap(v interface{}) map[string]interface{} {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
if m, ok := v.(map[string]interface{}); ok {
|
||
return m
|
||
}
|
||
// bson.D(有序文档):mongo-driver 解码嵌套文档时可能返回 bson.D 而非 bson.M
|
||
if d, ok := v.(bson.D); ok {
|
||
return d.Map()
|
||
}
|
||
// JSON 中转,兼容 primitive.M 等其他 bson 类型
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var m map[string]interface{}
|
||
if err = json.Unmarshal(data, &m); err != nil {
|
||
return nil
|
||
}
|
||
return m
|
||
}
|
||
|
||
// asStringSlice 将 interface{} 安全转为 []string
|
||
// 使用 JSON 中转,兼容 bson.A / primitive.A / []interface{} 等各种 slice 类型。
|
||
func asStringSlice(v interface{}) []string {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
switch arr := v.(type) {
|
||
case []string:
|
||
return arr
|
||
case []interface{}:
|
||
out := make([]string, 0, len(arr))
|
||
for _, item := range arr {
|
||
if s, ok := item.(string); ok {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
// JSON 中转,兼容 primitive.A 等 bson 切片类型
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var arr []interface{}
|
||
if err = json.Unmarshal(data, &arr); err != nil {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(arr))
|
||
for _, item := range arr {
|
||
if s, ok := item.(string); ok {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// asFloat64 将 interface{} 安全转为 float64
|
||
func asFloat64(v interface{}) (float64, bool) {
|
||
if v == nil {
|
||
return 0, false
|
||
}
|
||
switch f := v.(type) {
|
||
case float64:
|
||
return f, true
|
||
case int32:
|
||
return float64(f), true
|
||
case int64:
|
||
return float64(f), true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func (a *AdviceChatBiz) callLlmResponse(ctx context.Context, request []openai.ResponseInputMessage, key string, url string, modelName string, id string) (*openai.CreateResponseResponse, error) {
|
||
return a.openai.CreateResponseMessages(ctx, key, url, modelName, request, id)
|
||
}
|