From 5d58cbc0f6a8acc92cfb942fb86ab4047bf6356d Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Fri, 23 Jan 2026 18:21:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=201.=E5=A2=9E=E5=8A=A0=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E5=B7=A5=E5=85=B7=E6=96=B9=E6=B3=95=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=A5=E8=AF=86=E5=BA=93=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=96=B9=E6=B3=95=202.=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=AF=B9=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E8=B0=83=E7=94=A8=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E9=85=8D=E7=BD=AE=E3=80=81=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=203.=E5=A2=9E=E5=8A=A0=E6=96=B0&=E6=97=A7SDK=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=9C=BA=E6=99=AF=E7=BE=A4=E6=96=B9=E6=B3=95=204.?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=97=AE=E9=A2=98=E5=A4=84=E7=90=86=E7=BE=A4?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=AF=B9=E8=AF=9D=E5=94=A4=E8=B5=B7?= =?UTF-8?q?QA=E6=95=B0=E6=8D=AE=E6=94=B6=E9=9B=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 10 + internal/biz/ding_talk_bot.go | 2 +- internal/biz/group_config.go | 285 ++++++++---------- internal/config/config.go | 17 ++ internal/data/constants/dingtalk.go | 15 + internal/data/constants/knowledge.go | 21 ++ internal/data/impl/bot_config.go | 23 ++ .../tools/common/knowledge_base/client.go | 78 +++++ .../common/knowledge_base/client_test.go | 63 ++++ .../tools/common/knowledge_base/parse.go | 48 +++ .../tools/common/knowledge_base/type.go | 10 + internal/domain/tools/registry.go | 3 + internal/pkg/dingtalk/im_client.go | 25 ++ internal/pkg/dingtalk/old_client.go | 83 ++++- internal/pkg/dingtalk/robot_client.go | 15 +- internal/pkg/provider_set.go | 2 +- internal/services/callback.go | 219 +++++++++++++- internal/services/dtalk_bot.go | 248 +++++++++------ 18 files changed, 884 insertions(+), 283 deletions(-) create mode 100644 internal/domain/tools/common/knowledge_base/client.go create mode 100644 internal/domain/tools/common/knowledge_base/client_test.go create mode 100644 internal/domain/tools/common/knowledge_base/parse.go create mode 100644 internal/domain/tools/common/knowledge_base/type.go diff --git a/config/config_env.yaml b/config/config_env.yaml index e480b7a..764939e 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -169,6 +169,16 @@ default_prompt: 若图片为文档类(如合同、发票、收据),请结构化输出关键字段(如客户名称、金额、开票日期等)。 ' user_prompt: '识别图片内容' + # 权限配置 permissionConfig: permission_url: "http://api.test.user.1688sup.cn:8001/v1/menu/myCodes?systemId=" + +# 知识库配置 +knowledge_config: + base_url: "http://127.0.0.1:9600" + tenant_id: "default" + mode: "naive" + stream: true + think: false + only_rag: true diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index d2fe01a..cf5d6fb 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -171,7 +171,7 @@ func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entit return } - return d.groupConfigBiz.handleMatch(ctx, rec, groupConfig) + return d.groupConfigBiz.handleMatch(ctx, rec, groupConfig, requireData.Req) } func (d *DingTalkBotBiz) initGroup(ctx context.Context, conversationId string, conversationTitle string, robotCode string) (group *model.AiBotGroup, err error) { diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index 0d4739a..bacd2e9 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -6,6 +6,7 @@ import ( "ai_scheduler/internal/data/constants" "ai_scheduler/internal/data/impl" "ai_scheduler/internal/data/model" + "ai_scheduler/internal/domain/tools/common/knowledge_base" "ai_scheduler/internal/domain/workflow/recharge" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" @@ -28,6 +29,7 @@ import ( "strings" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" "github.com/alibabacloud-go/dingtalk/card_1_0" "github.com/alibabacloud-go/tea/tea" "github.com/coze-dev/coze-go" @@ -39,6 +41,7 @@ import ( type GroupConfigBiz struct { botGroupConfigImpl *impl.BotGroupConfigImpl reportDailyCacheImpl *impl.ReportDailyCacheImpl + botConfigImpl *impl.BotConfigImpl ossClient *utils_oss.Client workflowManager *runtime.Registry botTools []model.AiBotTool @@ -53,6 +56,7 @@ func NewGroupConfigBiz( tools *tools_regis.ToolRegis, ossClient *utils_oss.Client, botGroupConfigImpl *impl.BotGroupConfigImpl, + botConfigImpl *impl.BotConfigImpl, workflowManager *runtime.Registry, conf *config.Config, reportDailyCacheImpl *impl.ReportDailyCacheImpl, @@ -64,6 +68,7 @@ func NewGroupConfigBiz( botTools: tools.BootTools, ossClient: ossClient, botGroupConfigImpl: botGroupConfigImpl, + botConfigImpl: botConfigImpl, workflowManager: workflowManager, conf: conf, reportDailyCacheImpl: reportDailyCacheImpl, @@ -236,7 +241,7 @@ func (g *GroupConfigBiz) handleReport(ctx context.Context, rec *entitys.Recogniz return nil } -func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig) (err error) { +func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (err error) { if !rec.Match.IsMatch { if len(rec.Match.Chat) != 0 { @@ -265,8 +270,7 @@ func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize case constants.TaskTypeCozeWorkflow: return g.handleCozeWorkflow(ctx, rec, pointTask) case constants.TaskTypeKnowle: // 知识库V2版本 - return g.handleKnowledgeV2(ctx, rec, groupConfig) - // return g.handleKnowledgeV3(ctx, rec, pointTask) + return g.handleKnowledgeV2(ctx, rec, groupConfig, callback) default: return g.otherTask(ctx, rec) } @@ -473,173 +477,88 @@ func (g *GroupConfigBiz) GetReportCache(ctx context.Context, day time.Time, tota } // handleKnowledgeV2 处理知识库V2版本 -func (g *GroupConfigBiz) handleKnowledgeV2(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig) (err error) { - req := l_request.Request{ - Method: "POST", - Url: "http://127.0.0.1:9600/query", - Headers: map[string]string{ - "Content-Type": "application/json", - "X-Tenant-ID": "default", - }, - Json: map[string]interface{}{ - "query": rec.UserContent.Text, - "mode": "naive", - "stream": true, - "think": false, - }, - } - resp, err := req.SendNoParseResponse() +func (g *GroupConfigBiz) handleKnowledgeV2(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (err error) { + // 请求知识库工具 + knowledgeBase := knowledge_base.New(g.conf.KnowledgeConfig) + knowledgeResp, err := knowledgeBase.Call(&knowledge_base.ChatRequest{ + TenantID: constants.KnowledgeTenantIdDefault, // 后续动态接参 + Query: rec.UserContent.Text, + Mode: constants.KnowledgeModeMix, + Stream: true, + Think: false, + }) if err != nil { - return fmt.Errorf("请求失败,err: %v", err) + return fmt.Errorf("请求知识库工具失败,err: %v", err) } - defer resp.Body.Close() - isRetrieved, err := g.connectAndReadSSE(resp, rec.Ch, true) + // 读取知识库SSE数据 + isRetrieved, err := g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) if err != nil { return } // 未检索到匹配信息,询问是否拉群 if !isRetrieved { - // 获取群问题处理人 - type issueOwnerType struct { - UserId string `json:"userid"` - Name string `json:"name"` - } - var issueOwner []issueOwnerType - if err = json.Unmarshal([]byte(groupConfig.IssueOwner), &issueOwner); err != nil { - return fmt.Errorf("解析群问题处理人失败,err: %v", err) - } - // 合并所有name - var userNames []string - for _, owner := range issueOwner { - userNames = append(userNames, "@"+owner.Name) - } - issueOwnerStr := strings.Join(userNames, "、") - - // 构建卡片 OutTrackId - outTrackId := constants.BuildCardOutTrackId("cidwP24PLZhLVOS2dVIkEawLw==", "ding5wwvnf9hxeyjau7t") - - // 发送钉钉卡片 - _, err = g.dingtalkCardClient.CreateAndDeliver(dingtalk.AppKey{ - AppKey: "ding5wwvnf9hxeyjau7t", - AppSecret: "FxXVlTzxrKXvJ8h-9uK0s5TjaBfOJSXumpmrHal-NmQAtku9wOPxcss0Af6WHoAK", - }, &card_1_0.CreateAndDeliverRequest{ - CardTemplateId: tea.String("faad6d5d-726d-467f-a6ba-28c1930aa5f3.schema"), - OutTrackId: tea.String(outTrackId), - CallbackType: tea.String("STREAM"), - CardData: &card_1_0.CreateAndDeliverRequestCardData{ - CardParamMap: map[string]*string{ - "title": tea.String("群创建提醒"), - "content": tea.String(fmt.Sprintf("**确认创建群聊?**\n\n将邀请以下成员加入群聊:\n\n%s", issueOwnerStr)), - "remark": tea.String("注:如若无需,忽略即可"), - "button_left": tea.String("点击进群"), - "button_left_link": tea.String(""), - "button_right": tea.String("忽略"), - "button_right_link": tea.String(""), - "action_id": tea.String("create_group"), - "button_display": tea.String("true"), - "_CARD_DEBUG_TOOL_ENTRY": tea.String("show"), - }, - }, - ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ - SupportForward: tea.Bool(false), - }, - OpenSpaceId: tea.String("dtv1.card//im_group.cidwP24PLZhLVOS2dVIkEawLw=="), - ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ - RobotCode: tea.String("ding5wwvnf9hxeyjau7t"), - Recipients: []*string{ - tea.String("17415698414368678"), - }, - }, - }) - if err != nil { - return fmt.Errorf("发送钉钉卡片失败,err: %v", err) - } - - return + g.shouldCreateIssueHandlingGroup(ctx, rec, groupConfig, callback) + return nil } return } -// 连接 SSE 并读取数据 -// event: thinking -// data: {"text": "1. 上下文检索中...\n"} -// event: answer -// data: {"text": "根据"} -func (g *GroupConfigBiz) connectAndReadSSE(resp *http.Response, channel chan entitys.Response, useParagraphMode bool) (isRetrieved bool, err error) { - scanner := bufio.NewScanner(resp.Body) +// 读取知识库 SSE 数据 +func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entitys.Response, useParagraphMode bool) (isRetrieved bool, err error) { + scanner := bufio.NewScanner(resp) var buffer strings.Builder for scanner.Scan() { line := scanner.Text() - // 解析event行 - if strings.HasPrefix(line, "event:") { - eventStr := strings.TrimSpace(strings.TrimPrefix(line, "event:")) - if eventStr == "" { - continue - } - - // thinking不输出 - if eventStr == "thinking" { - continue - } - // system 事件输出 - if eventStr == "system" { - // 未检索到,直接返回 - dataStr := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if dataStr != "retrieved" { - entitys.ResStream(channel, "", fmt.Sprintf("知识库未检测到匹配信息,即将为您创建群聊解决问题?")) - return false, nil - } - continue - } - + delta, done, err := knowledge_base.ParseOpenAIStreamData(line) + if err != nil { + return false, fmt.Errorf("解析SSE数据失败: %w", err) + } + if done { + break + } + if delta == nil { continue } - // 解析 data 行 - if strings.HasPrefix(line, "data:") { - dataStr := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if dataStr == "" { - continue - } + // 知识库未命中 输出提示后中断 + if delta.XRagStatus == constants.KnowledgeRagStatusMiss { + var missContent string = "知识库未检测到匹配信息,即将为您创建群聊解决问题。" + entitys.ResStream(channel, "", missContent) + return false, nil + } + // 推理内容 + if delta.ReasoningContent != "" { + entitys.ResStream(channel, "", delta.ReasoningContent) + continue + } + // 输出内容 - 段落 + if delta.Content != "" && useParagraphMode { + // 存入缓冲区 + buffer.WriteString(delta.Content) + content := buffer.String() - var data struct { - Text string `json:"text"` - } - if err := json.Unmarshal([]byte(dataStr), &data); err != nil { - log.Errorf("SSE数据解析失败: %v body: %s", err, dataStr) - continue - } + // 检查是否有换行符,按段落输出 + if idx := strings.LastIndex(content, "\n"); idx != -1 { + // 发送直到最后一个换行符的内容 + toSend := content[:idx+1] + entitys.ResStream(channel, "", toSend) - if data.Text != "" { - if useParagraphMode { - // 存入缓冲区 - buffer.WriteString(data.Text) - content := buffer.String() - - // 检查是否有换行符,按段落输出 - if idx := strings.LastIndex(content, "\n"); idx != -1 { - // 发送直到最后一个换行符的内容 - toSend := content[:idx+1] - entitys.ResStream(channel, "", toSend) - - // 重置缓冲区,保留剩余部分 - remaining := content[idx+1:] - buffer.Reset() - buffer.WriteString(remaining) - } - } else { - // 逐字输出模式:直接发送 - entitys.ResStream(channel, "", data.Text) - } + // 重置缓冲区,保留剩余部分 + remaining := content[idx+1:] + buffer.Reset() + buffer.WriteString(remaining) } } + // 输出内容 - 逐字 + if delta.Content != "" && !useParagraphMode { + entitys.ResStream(channel, "", delta.Content) + } } - if err := scanner.Err(); err != nil { return true, fmt.Errorf("读取SSE流中断: %w", err) } @@ -652,33 +571,67 @@ func (g *GroupConfigBiz) connectAndReadSSE(resp *http.Response, channel chan ent return true, nil } -// handleKnowledgeV3 处理知识库V3同步版本 -func (g *GroupConfigBiz) handleKnowledgeV3(ctx context.Context, rec *entitys.Recognize, pointTask *model.AiBotTool) (err error) { - req := l_request.Request{ - Method: "POST", - Url: "http://127.0.0.1:9600/query", - Headers: map[string]string{ - "Content-Type": "application/json", - "X-Tenant-ID": "default", - }, - Json: map[string]interface{}{ - "query": rec.UserContent.Text, - "mode": "naive", - "stream": false, - "think": false, - }, +// 询问是否创建群聊处理问题 +func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) error { + // 获取群问题处理人 + type issueOwnerType struct { + UserId string `json:"userid"` + Name string `json:"name"` } - resp, err := req.Send() + var issueOwner []issueOwnerType + if err := json.Unmarshal([]byte(groupConfig.IssueOwner), &issueOwner); err != nil { + return fmt.Errorf("解析群问题处理人失败,err: %v", err) + } + // 合并所有name + var userNames []string + for _, owner := range issueOwner { + userNames = append(userNames, "@"+owner.Name) + } + issueOwnerStr := strings.Join(userNames, "、") + + botConfig, err := g.botConfigImpl.GetRobotConfig(callback.RobotCode) if err != nil { - return fmt.Errorf("请求失败,err: %v", err) + return fmt.Errorf("获取机器人配置失败,err: %v", err) } - obj := make(map[string]string) - if err := json.Unmarshal([]byte(resp.Text), &obj); err != nil { - return fmt.Errorf("解析响应失败,err: %v", err) + // 构建卡片 OutTrackId + outTrackId := constants.BuildCardOutTrackId(callback.ConversationId, botConfig.ClientId) + + // 发送钉钉卡片 + _, err = g.dingtalkCardClient.CreateAndDeliver(dingtalk.AppKey{ + AppKey: botConfig.ClientId, + AppSecret: botConfig.ClientSecret, + }, &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(constants.DingtalkCardTplCreateGroupApprove), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("STREAM"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("创建群聊提醒"), + "content": tea.String(fmt.Sprintf("**确认创建群聊?**\n\n将邀请以下成员加入群聊:\n\n%s", issueOwnerStr)), + "remark": tea.String("注:如若无需,忽略即可"), + "button_left": tea.String("创建群聊"), + "button_right": tea.String("忽略"), + "action_id": tea.String("create_group"), + "button_display": tea.String("true"), + "group_scope": tea.String(strings.TrimSpace(rec.UserContent.Text)), + // "_CARD_DEBUG_TOOL_ENTRY": tea.String("show"), // debug字段 + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + callback.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(callback.RobotCode), + Recipients: []*string{ + tea.String(callback.SenderStaffId), + }, + }, + }) + if err != nil { + return fmt.Errorf("发送钉钉卡片失败,err: %v", err) } - entitys.ResText(rec.Ch, "", obj["response"]) - - return + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 64857a9..b62ef98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { Oss Oss `mapstructure:"oss"` DefaultPrompt SysPrompt `mapstructure:"default_prompt"` PermissionConfig PermissionConfig `mapstructure:"permissionConfig"` + KnowledgeConfig KnowledgeConfig `mapstructure:"knowledge_config"` LLM LLM `mapstructure:"llm"` Dingtalk DingtalkConfig `mapstructure:"dingtalk"` Qywx QywxConfig `mapstructure:"qywx"` @@ -253,6 +254,22 @@ type PermissionConfig struct { PermissionURL string `mapstructure:"permission_url"` } +// KnowledgeConfig 知识库配置 +type KnowledgeConfig struct { + // 知识库地址 + BaseURL string `mapstructure:"base_url"` + // 默认租户ID + TenantID string `mapstructure:"tenant_id"` + // 模式 + Mode string `mapstructure:"mode"` + // 是否流式 + Stream bool `mapstructure:"stream"` + // 是否思考 + Think bool `mapstructure:"think"` + // 是否仅RAG + OnlyRAG bool `mapstructure:"only_rag"` +} + // LoadConfig 加载配置 func LoadConfig(configPath string) (*Config, error) { viper.SetConfigFile(configPath) diff --git a/internal/data/constants/dingtalk.go b/internal/data/constants/dingtalk.go index 88d02d0..b25becb 100644 --- a/internal/data/constants/dingtalk.go +++ b/internal/data/constants/dingtalk.go @@ -116,3 +116,18 @@ func ParseCardOutTrackId(outTrackId string) (spaceId string, botId string) { return } + +// dingtalk 卡片模板 +const ( + DingtalkCardTplBaseMsg string = "291468f8-a048-4132-a37e-a14365e855e9.schema" // 基础消息卡片(title + content) + DingtalkCardTplCreateGroupApprove string = "faad6d5d-726d-467f-a6ba-28c1930aa5f3.schema" // 创建群聊申请 +) + +// dingtalk 模板群相关 +const ( + // 群模板id + GroupTemplateIdIssueHandling string = "420089e3-b0fb-40f5-89d2-ec47223bff3b" // 问题处理群模板id + + // 模板群机器人ID + GroupTemplateRobotIdIssueHandling string = "VqgJYpB91j3RnB217690607273471011" // 问题处理群模板机器人ID +) diff --git a/internal/data/constants/knowledge.go b/internal/data/constants/knowledge.go index b8b7861..c7f9088 100644 --- a/internal/data/constants/knowledge.go +++ b/internal/data/constants/knowledge.go @@ -20,3 +20,24 @@ func GetKnowledgeId(caller Caller) KnowledgeId { } return CallerKnowledgeIdMap[caller] } + +// 知识库 +const ( + KnowledgeTenantIdDefault = "default" +) + +// 知识库模式 +const ( + KnowledgeModeBypass = "bypass" // 绕过知识库,直接返回用户输入 + KnowledgeModeNaive = "naive" // 简单模式,直接返回知识库答案 + KnowledgeModeLocal = "local" // 本地模式,仅使用本地知识库 + KnowledgeModeGlobal = "global" // 全局模式,使用全局知识库 + KnowledgeModeHybrid = "hybrid" // 混合模式,结合本地和全局知识库 + KnowledgeModeMix = "mix" // 混合模式,结合本地、全局和知识库 +) + +// 知识库命中状态 +const ( + KnowledgeRagStatusHit = "hit" // 知识库命中 + KnowledgeRagStatusMiss = "miss" // 知识库未命中 +) diff --git a/internal/data/impl/bot_config.go b/internal/data/impl/bot_config.go index 2c98ffb..4fcbbe6 100644 --- a/internal/data/impl/bot_config.go +++ b/internal/data/impl/bot_config.go @@ -2,8 +2,12 @@ package impl import ( "ai_scheduler/internal/data/model" + "ai_scheduler/internal/entitys" "ai_scheduler/tmpl/dataTemp" "ai_scheduler/utils" + "encoding/json" + + "xorm.io/builder" ) type BotConfigImpl struct { @@ -15,3 +19,22 @@ func NewBotConfigImpl(db *utils.Db) *BotConfigImpl { DataTemp: *dataTemp.NewDataTemp(db, new(model.AiBotConfig)), } } + +// GetRobotConfig 获取机器人配置 +func (b *BotConfigImpl) GetRobotConfig(robotCode string) (entitys.DingTalkBot, error) { + // 获取机器人配置 + var botConfig model.AiBotConfig + cond := builder.NewCond().And(builder.Eq{"robot_code": robotCode}) + err := b.GetOneBySearchToStrut(&cond, &botConfig) + if err != nil { + return entitys.DingTalkBot{}, err + } + // 解出 config + var config entitys.DingTalkBot + err = json.Unmarshal([]byte(botConfig.BotConfig), &config) + if err != nil { + return entitys.DingTalkBot{}, err + } + + return config, nil +} diff --git a/internal/domain/tools/common/knowledge_base/client.go b/internal/domain/tools/common/knowledge_base/client.go new file mode 100644 index 0000000..e85cbf4 --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/client.go @@ -0,0 +1,78 @@ +package knowledge_base + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "fmt" + "io" + "net/http" + "strings" +) + +type Client struct { + cfg config.KnowledgeConfig +} + +func New(cfg config.KnowledgeConfig) *Client { + return &Client{cfg: cfg} +} + +func (c *Client) Call(req *ChatRequest) (io.ReadCloser, error) { + if req == nil { + return nil, fmt.Errorf("req is nil") + } + if req.TenantID == "" { + return nil, fmt.Errorf("tenantID is empty") + } + if req.Query == "" { + return nil, fmt.Errorf("query is empty") + } + if req.Mode == "" { + req.Mode = c.cfg.Mode + } + if !req.Stream { + req.Stream = c.cfg.Stream // 仅支持流式输出 + } + if !req.Think { + req.Think = c.cfg.Think + } + if !req.OnlyRAG { + req.OnlyRAG = c.cfg.OnlyRAG + } + + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + + rsp, err := (&l_request.Request{ + Method: "POST", + Url: baseURL + "/query", + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Tenant-ID": req.TenantID, + "Accept": "text/event-stream", + }, + Json: map[string]interface{}{ + "query": req.Query, + "mode": req.Mode, + "stream": req.Stream, + "think": req.Think, + "only_rag": req.OnlyRAG, + }, + }).SendNoParseResponse() + if err != nil { + return nil, err + } + if rsp == nil || rsp.Body == nil { + return nil, fmt.Errorf("empty response") + } + + if rsp.StatusCode != http.StatusOK { + defer rsp.Body.Close() + bodyPreview, _ := io.ReadAll(io.LimitReader(rsp.Body, 4096)) + if len(bodyPreview) > 0 { + return nil, fmt.Errorf("knowledge base returned status %d: %s", rsp.StatusCode, string(bodyPreview)) + } + return nil, fmt.Errorf("knowledge base returned status %d", rsp.StatusCode) + } + + return rsp.Body, nil +} diff --git a/internal/domain/tools/common/knowledge_base/client_test.go b/internal/domain/tools/common/knowledge_base/client_test.go new file mode 100644 index 0000000..84acfdb --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/client_test.go @@ -0,0 +1,63 @@ +package knowledge_base + +import ( + "ai_scheduler/internal/config" + "bufio" + "strings" + "testing" +) + +func TestCall(t *testing.T) { + req := &ChatRequest{ + TenantID: "admin_test_qa", + Query: "lightRAG 的优势?", + Mode: "naive", + Stream: true, + Think: false, + OnlyRAG: true, + } + + client := New(config.KnowledgeConfig{BaseURL: "http://127.0.0.1:9600"}) + resp, err := client.Call(req) + if err != nil { + t.Errorf("Call failed: %v", err) + } + if resp == nil { + t.Error("Response is nil") + } + defer resp.Close() + + scanner := bufio.NewScanner(resp) + var outThinking strings.Builder + var outContent strings.Builder + for scanner.Scan() { + line := scanner.Text() + delta, done, err := ParseOpenAIStreamData(line) + if err != nil { + t.Fatalf("parse openai stream failed: %v", err) + } + if delta == nil { + continue + } + if done { + break + } + + if delta.XRagStatus != "" { + t.Logf("XRagStatus: %s", delta.XRagStatus) + } + if delta.Content != "" { + outContent.WriteString(delta.Content) + } + if delta.ReasoningContent != "" { + outThinking.WriteString(delta.ReasoningContent) + } + + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan failed: %v", err) + } + + t.Logf("Thinking: %s", outThinking.String()) + t.Logf("Content: %s", outContent.String()) +} diff --git a/internal/domain/tools/common/knowledge_base/parse.go b/internal/domain/tools/common/knowledge_base/parse.go new file mode 100644 index 0000000..595c444 --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/parse.go @@ -0,0 +1,48 @@ +package knowledge_base + +import ( + "encoding/json" + "fmt" + "strings" +) + +type openAIChunk struct { + Choices []struct { + Delta *Delta `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` +} + +type Delta struct { + ReasoningContent string `json:"reasoning_content"` // 推理内容 + Content string `json:"content"` // 内容 + XRagStatus string `json:"x_rag_status"` // rag命中状态 hit|miss +} + +func ParseOpenAIStreamData(dataLine string) (delta *Delta, done bool, err error) { + data := strings.TrimSpace(strings.TrimPrefix(dataLine, "data:")) + if data == "" { + return nil, false, nil + } + + data = strings.TrimSpace(data) + if data == "" { + return nil, false, nil + } + if data == "[DONE]" { + return nil, true, nil + } + + var chunk openAIChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + return nil, false, fmt.Errorf("unmarshal openai stream chunk failed: %w", err) + } + + for _, c := range chunk.Choices { + if c.Delta != nil { + return c.Delta, false, nil // 只输出第一个delta + } + } + + return nil, false, nil +} diff --git a/internal/domain/tools/common/knowledge_base/type.go b/internal/domain/tools/common/knowledge_base/type.go new file mode 100644 index 0000000..1fd0123 --- /dev/null +++ b/internal/domain/tools/common/knowledge_base/type.go @@ -0,0 +1,10 @@ +package knowledge_base + +type ChatRequest struct { + TenantID string // 租户 ID + Query string // 查询内容 + Mode string // 模式,默认 naive 可选:[bypass|naive|local|global|hybrid|mix] + Stream bool // 仅支持流式输出 + Think bool // 是否开启思考模式 + OnlyRAG bool // 是否仅开启 RAG 模式 +} diff --git a/internal/domain/tools/registry.go b/internal/domain/tools/registry.go index f6b2193..d1851a4 100644 --- a/internal/domain/tools/registry.go +++ b/internal/domain/tools/registry.go @@ -4,6 +4,7 @@ import ( "ai_scheduler/internal/config" "ai_scheduler/internal/domain/tools/common/excel_generator" "ai_scheduler/internal/domain/tools/common/image_converter" + "ai_scheduler/internal/domain/tools/common/knowledge_base" "ai_scheduler/internal/domain/tools/hyt/goods_add" "ai_scheduler/internal/domain/tools/hyt/goods_brand_search" "ai_scheduler/internal/domain/tools/hyt/goods_category_add" @@ -25,6 +26,7 @@ type Manager struct { type CommonTools struct { ExcelGenerator *excel_generator.Client ImageConverter *image_converter.Client + KnowledgeBase *knowledge_base.Client } type HytTools struct { @@ -60,6 +62,7 @@ func NewManager(cfg *config.Config) *Manager { Common: &CommonTools{ ExcelGenerator: excel_generator.New(), ImageConverter: image_converter.New(cfg.EinoTools.Excel2Pic), + KnowledgeBase: knowledge_base.New(cfg.KnowledgeConfig), }, } } diff --git a/internal/pkg/dingtalk/im_client.go b/internal/pkg/dingtalk/im_client.go index b25e812..72857aa 100644 --- a/internal/pkg/dingtalk/im_client.go +++ b/internal/pkg/dingtalk/im_client.go @@ -51,3 +51,28 @@ func (c *ImClient) AddRobotToConversation(appKey AppKey, imData *im.AddRobotToCo return *resp.Body.ChatBotUserId, nil } + +// 创建场景群 不返回chatid,如果没有获取群聊分享链接的诉求,可以使用该接口 +func (c *ImClient) CreateSceneGroup(appKey AppKey, req *im.CreateSceneGroupConversationRequest) (openConversationId string, err error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + + // 调用API + resp, err := c.cli.CreateSceneGroupConversationWithOptions( + req, + &im.CreateSceneGroupConversationHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, + &util.RuntimeOptions{}, + ) + if err != nil { + return "", err + } + + if resp.Body == nil { + return "", errorcode.ParamErrf("empty response body") + } + + return *resp.Body.OpenConversationId, nil +} diff --git a/internal/pkg/dingtalk/old_client.go b/internal/pkg/dingtalk/old_client.go index 8d1877c..def0604 100644 --- a/internal/pkg/dingtalk/old_client.go +++ b/internal/pkg/dingtalk/old_client.go @@ -4,6 +4,7 @@ package dingtalk import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" "bytes" "context" "encoding/json" @@ -12,6 +13,7 @@ import ( "net/http" "net/url" "os" + "strings" "github.com/faabiosr/cachego/file" "github.com/fastwego/dingding" @@ -113,7 +115,7 @@ func (c *OldClient) GetAccessToken() (string, error) { } // CreateInternalGroupConversation 创建企业内部群聊 -func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, groupName string, userIds []string) (chatId, openConversationId string, err error) { +func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, accessToken, groupName string, userIds []string) (chatId, openConversationId string, err error) { body := struct { Name string `json:"name"` Owner string `json:"owner"` @@ -130,11 +132,20 @@ func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, groupNa UserIds: userIds, } b, _ := json.Marshal(body) - var res []byte - res, err = c.do(ctx, http.MethodPost, "/chat/create", b) + + req := l_request.Request{ + Method: "POST", + JsonByte: b, + Url: "https://oapi.dingtalk.com/chat/create?access_token=" + accessToken, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + res, err := req.Send() if err != nil { return } + var resp struct { Code int `json:"errcode"` Msg string `json:"errmsg"` @@ -142,7 +153,7 @@ func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, groupNa OpenConversationId string `json:"openConversationId"` ConversationTag int `json:"conversationTag"` } - if err = json.Unmarshal(res, &resp); err != nil { + if err = json.Unmarshal(res.Content, &resp); err != nil { return } if resp.Code != 0 { @@ -152,6 +163,70 @@ func (c *OldClient) CreateInternalGroupConversation(ctx context.Context, groupNa return resp.ChatId, resp.OpenConversationId, nil } +// CreateSceneGroupConversation 创建场景群-基于群模板 +func (c *OldClient) CreateSceneGroupConversation(ctx context.Context, accessToken, groupName string, userIds []string, templateId string) (chatId, openConversationId string, err error) { + body := struct { + Title string `json:"title"` // 群名称 + TemplateId string `json:"template_id"` // 群模板ID + OwnerUserID string `json:"owner_user_id"` // 群主的userid。 + UserIds string `json:"user_ids"` // 群成员userid列表。 + SubAdminIds string `json:"subadmin_ids"` // 群管理员userid列表。 + UUID string `json:"uuid"` // 建群去重的业务ID,由接口调用方指定。 + Icon string `json:"icon"` // 群头像,格式为mediaId。需要调用上传媒体文件接口上传群头像,获取mediaId。 + MentionAllAuthority int `json:"mention_all_authority"` // @all 权限:0(默认):所有人都可以@all 1:仅群主可@all + ShowHistoryType int `json:"show_history_type"` // 新成员是否可查看聊天历史消息:0(默认):不可以查看历史记录 1:可以查看历史记录 + ValidationType int `json:"validation_type"` // 入群是否需要验证:0(默认):不验证入群 1:入群验证 + Searchable int `json:"searchable"` // 群是否可搜索:0(默认):不可搜索 1:可搜索 + ChatBannedType int `json:"chat_banned_type"` // 是否开启群禁言:0(默认):不禁言 1:全员禁言 + ManagementType int `json:"management_type"` // 管理类型:0(默认):所有人可管理 1:仅群主可管理 + OnlyAdminCanDing int `json:"only_admin_can_ding"` // 群内发DING权限:0(默认):所有人可发DING 1:仅群主和管理员可发DING + AllMembersCanCreateMcsConf int `json:"all_members_can_create_mcs_conf"` // 群会议权限:0:仅群主和管理员可发起视频和语音会议 1(默认):所有人可发起视频和语音会议 + AllMembersCanCreateCalendar int `json:"all_members_can_create_calendar"` // 群日历权限:0:仅群主和管理员可创建群日历 1(默认):所有人可创建群日历 + GroupEmailDisabled int `json:"group_email_disabled"` // 群邮件权限:0(默认):群内成员可以对本群发送群邮件 1:群内成员不可对本群发送群邮件 + OnlyAdminCanSetMsgTop int `json:"only_admin_can_set_msg_top"` // 置顶群消息权限:0(默认):所有人可置顶群消息 1:仅群主和管理员可置顶群消息 + AddFriendForbidden int `json:"add_friend_forbidden"` // 群成员私聊权限:0(默认):所有人可私聊 1:普通群成员之间不能够加好友、单聊,且部分功能使用受限(管理员与非管理员之间不受影响) + GroupLiveSwitch int `json:"group_live_switch"` // 群直播权限:0:仅群主与管理员可发起直播 1(默认):群内任意成员可发起群直播 + MembersToAdminChat int `json:"members_to_admin_chat"` // 是否禁止非管理员向管理员发起单聊:0(默认):非管理员可以向管理员发起单聊 1:禁止非管理员向管理员发起单聊 + }{ + Title: groupName, + TemplateId: templateId, + OwnerUserID: userIds[0], + UserIds: strings.Join(userIds, ","), + SubAdminIds: strings.Join(userIds, ","), + } + + b, _ := json.Marshal(body) + + req := l_request.Request{ + Method: "POST", + JsonByte: b, + Url: "https://oapi.dingtalk.com/topapi/im/chat/scenegroup/create?access_token=" + accessToken, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + res, err := req.Send() + if err != nil { + return + } + + var resp struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` + Result struct { + ChatId string `json:"chat_id"` + OpenConversationId string `json:"open_conversation_id"` + } `json:"result"` + } + if err = json.Unmarshal(res.Content, &resp); err != nil { + return + } + if resp.Code != 0 { + return "", "", errors.New(resp.Msg) + } + return resp.Result.ChatId, resp.Result.OpenConversationId, nil +} + // 获取入群二维码链接 func (c *OldClient) GetJoinGroupQrcode(ctx context.Context, chatId, userId string) (string, error) { body := struct { diff --git a/internal/pkg/dingtalk/robot_client.go b/internal/pkg/dingtalk/robot_client.go index 886b389..ccd868a 100644 --- a/internal/pkg/dingtalk/robot_client.go +++ b/internal/pkg/dingtalk/robot_client.go @@ -11,10 +11,11 @@ import ( ) type RobotClient struct { - cli *robot.Client + cli *robot.Client + oauth2Client *Oauth2Client } -func NewRobotClient() (*RobotClient, error) { +func NewRobotClient(oauth2Client *Oauth2Client) (*RobotClient, error) { cfg := &openapi.Config{ Protocol: tea.String("https"), RegionId: tea.String("central"), @@ -23,7 +24,7 @@ func NewRobotClient() (*RobotClient, error) { if err != nil { return nil, err } - return &RobotClient{cli: c}, nil + return &RobotClient{cli: c, oauth2Client: oauth2Client}, nil } type SendGroupMessagesReq struct { @@ -34,6 +35,12 @@ type SendGroupMessagesReq struct { } func (c *RobotClient) SendGroupMessages(appKey AppKey, req *SendGroupMessagesReq) (string, error) { + // 获取token + accessToken, err := c.oauth2Client.GetAccessToken(appKey) + if err != nil { + return "", err + } + msgParamBytes, _ := json.Marshal(req.MsgParam) msgParamJson := string(msgParamBytes) resp, err := c.cli.OrgGroupSendWithOptions( @@ -43,7 +50,7 @@ func (c *RobotClient) SendGroupMessages(appKey AppKey, req *SendGroupMessagesReq OpenConversationId: tea.String(req.OpenConversationId), RobotCode: tea.String(req.RobotCode), }, - &robot.OrgGroupSendHeaders{XAcsDingtalkAccessToken: tea.String(appKey.AccessToken)}, + &robot.OrgGroupSendHeaders{XAcsDingtalkAccessToken: tea.String(accessToken)}, &util.RuntimeOptions{}, ) if err != nil { diff --git a/internal/pkg/provider_set.go b/internal/pkg/provider_set.go index a49c8bf..25f0c15 100644 --- a/internal/pkg/provider_set.go +++ b/internal/pkg/provider_set.go @@ -21,7 +21,7 @@ var ProviderSetClient = wire.NewSet( dingtalk.NewOldClient, dingtalk.NewContactClient, dingtalk.NewNotableClient, - // dingtalk.NewRobotClient, + dingtalk.NewRobotClient, dingtalk.NewOauth2Client, dingtalk.NewCardClient, dingtalk.NewImClient, diff --git a/internal/services/callback.go b/internal/services/callback.go index 2a58bd7..e97f816 100644 --- a/internal/services/callback.go +++ b/internal/services/callback.go @@ -1,6 +1,7 @@ package services import ( + "ai_scheduler/internal/biz" "ai_scheduler/internal/biz/handle/qywx/json_callback/wxbizjsonmsgcrypt" "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" @@ -11,6 +12,7 @@ import ( "ai_scheduler/internal/pkg" "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/util" + "ai_scheduler/internal/pkg/utils_ollama" "ai_scheduler/internal/tool_callback" "context" "encoding/json" @@ -19,8 +21,12 @@ import ( "strings" "time" + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" + "github.com/alibabacloud-go/dingtalk/card_1_0" + "github.com/alibabacloud-go/tea/tea" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/log" + "github.com/ollama/ollama/api" ) // CallbackService 统一回调入口 @@ -30,17 +36,33 @@ type CallbackService struct { dingtalkOldClient *dingtalk.OldClient dingtalkContactClient *dingtalk.ContactClient dingtalkNotableClient *dingtalk.NotableClient + dingtalkCardClient *dingtalk.CardClient callbackManager callback.Manager + dingTalkBotBiz *biz.DingTalkBotBiz + ollamaClient *utils_ollama.Client } -func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, callbackManager callback.Manager) *CallbackService { +func NewCallbackService( + cfg *config.Config, + gateway *gateway.Gateway, + dingtalkOldClient *dingtalk.OldClient, + dingtalkContactClient *dingtalk.ContactClient, + dingtalkNotableClient *dingtalk.NotableClient, + dingtalkCardClient *dingtalk.CardClient, + callbackManager callback.Manager, + dingTalkBotBiz *biz.DingTalkBotBiz, + ollamaClient *utils_ollama.Client, +) *CallbackService { return &CallbackService{ cfg: cfg, gateway: gateway, dingtalkOldClient: dingtalkOldClient, dingtalkContactClient: dingtalkContactClient, dingtalkNotableClient: dingtalkNotableClient, + dingtalkCardClient: dingtalkCardClient, callbackManager: callbackManager, + dingTalkBotBiz: dingTalkBotBiz, + ollamaClient: ollamaClient, } } @@ -367,22 +389,189 @@ func getString(str, endstr string, start int, msg *string) int { // CallbackDingtalkRobot 钉钉机器人回调 func (s *CallbackService) CallbackDingtalkRobot(c *fiber.Ctx) (err error) { - // 获取query中的参数 - query := c.Request().URI().QueryString() - str, _ := url.QueryUnescape(string(query)) - // 转map - params := make(map[string]string) - for _, param := range strings.Split(str, "&") { - kv := strings.Split(param, "=") - if len(kv) == 2 { - params[kv[0]] = kv[1] - } + // 获取body中的参数 + body := c.Request().Body() + var data chatbot.BotCallbackDataModel + if err := json.Unmarshal(body, &data); err != nil { + return fmt.Errorf("invalid body: %v", err) } - // token 校验 - token := params["token"] - if token != "aB3dE7fG9hI2jK4L5M6N7O8P9Q0R1S2T" { - return fmt.Errorf("token not match") + + fmt.Println(string(body)) + + // token 校验 ? token 好像没带? + + // 通过机器人ID路由到不同能力 + switch data.RobotCode { + case constants.GroupTemplateRobotIdIssueHandling: + // 问题处理群机器人 + err := s.issueHandling(c, data) + if err != nil { + return fmt.Errorf("issueHandling failed: %v", err) + } + default: + // 其他机器人 + return nil + } + + // ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + // defer cancel() + + // 统一初始化请求参数 + // requireData, err := s.dingTalkBotBiz.InitRequire(ctx, &data) + // if err != nil { + // return fmt.Errorf("初始化请求参数失败: %v", err) + // } + + // 这里需要再实现一套HTTP形式的回调,用于处理钉钉群模板机器人的回调 + // 主程等待处理结果 + // resChan := make(chan string, 10) + // defer close(resChan) + + return nil +} + +// issueHandling 问题处理群机器人回调 +// 能力1: 通过[内容提取] 宏,分析用户QA问题,调出QA表单卡片 +// 能力2: 通过[QA收集] 宏,收集用户反馈,写入知识库 +func (s *CallbackService) issueHandling(c *fiber.Ctx, data chatbot.BotCallbackDataModel) error { + // 宏解析 + if strings.Contains(data.Text.Content, "[内容提取]") { + s.issueHandlingExtractContent(data) + } + if strings.Contains(data.Text.Content, "[QA收集]") { + s.issueHandlingCollectQA() } return nil } + +// 问题处理群机器人内容提取 +func (s *CallbackService) issueHandlingExtractContent(data chatbot.BotCallbackDataModel) { + systemPrompt := `你是一个【问题与答案生成助手】。 + + 你的职责是: + - 分析用户输入的内容 + - 识别其中隐含或明确的问题 + - 基于输入内容本身,生成对应的问题与答案 + + 当用户输入为【多条群聊聊天记录】时: + - 结合问题主题,判断聊天记录中正在讨论或试图解决的问题 + - 一个群聊中可能包含多个相互独立的问题,但它们都围绕着一个主题,尽可能总结为一个问题 + - 若确实问题很独立,需要分别识别,对每个问题,整理出清晰、可复用的“问题描述”和“对应答案” + + 生成答案时的原则: + - 答案必须来源于聊天内容中已经给出的信息或共识 + - 不要引入外部知识,不要使用聊天记录中真实人名或敏感信息,适当总结 + - 若聊天中未形成明确答案,应明确标记为“暂无明确结论” + - 若存在多种不同观点,应分别列出,不要擅自合并或裁决 + + 【JSON 输出原则】: + - 你的最终输出必须是**合法的 JSON** + - 不得输出任何额外解释性文字 + - JSON 结构必须严格符合以下约定 + + JSON 结构约定: + { + "items": [ + { + "question": "清晰、独立、可复用的问题描述", + "answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”", + "confidence": "high | medium | low" + } + ] + } + + 字段说明: + - items:问题与答案列表;若未识别到有效问题,则返回空数组 [] + - question:抽象后的标准问题表述,不包含具体聊天语句 + - answer:整理后的答案,不得引入聊天之外的信息 + - confidence:根据聊天中信息的一致性和明确程度给出判断 + + 如果无法从输入中识别出任何有效问题,返回: + { "items": [] } + + 问题主题: + %s + + 用户输入: + %s + ` + + prompt := fmt.Sprintf(systemPrompt, "紧急加款,提示当前账户为离线账户,请输入银行流水号", data.Text.Content) + + fmt.Println("prompt:", prompt) + + generateResp, err := s.ollamaClient.Generation(context.Background(), &api.GenerateRequest{ + Model: s.cfg.Ollama.GenerateModel, + Prompt: prompt, + Stream: util.AnyToPoint(false), + }) + if err != nil { + log.Errorf("问题提取失败: %v", err) + return + } + + // 解析 JSON 响应 + var resp struct { + Items []struct { + Question string `json:"question"` + Answer string `json:"answer"` + Confidence string `json:"confidence"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(generateResp.Response), &resp); err != nil { + log.Errorf("解析 JSON 响应失败: %v", err) + return + } + + cardContentTpl := "问题:%s \n答案:%s" + var cardContentList []string + for _, item := range resp.Items { + cardContentList = append(cardContentList, fmt.Sprintf(cardContentTpl, item.Question, item.Answer)) + } + cardContent := strings.Join(cardContentList, "\n\n") + + // 调用卡片 + // 构建卡片 OutTrackId + outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) + + _, err = s.dingtalkCardClient.CreateAndDeliver( + dingtalk.AppKey{ + AppKey: "ding5wwvnf9hxeyjau7t", + AppSecret: "FxXVlTzxrKXvJ8h-9uK0s5TjaBfOJSXumpmrHal-NmQAtku9wOPxcss0Af6WHoAK", + }, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String("3a447814-6a3e-4a02-b48a-92c57b349d77.schema"), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CallbackRouteKey: tea.String("gateway.dev.cdlsxd.cn-dingtalk-robot"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("QA知识收集"), + "button_display": tea.String("true"), + "QA_details_now": tea.String(cardContent), + "textarea_display": tea.String("normal"), + "action_id": tea.String("collect_qa"), + "tenant_id": tea.String(constants.KnowledgeTenantIdDefault), + "_CARD_DEBUG_TOOL_ENTRY": tea.String("show"), // debug字段 + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + data.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(constants.GroupTemplateRobotIdIssueHandling), + Recipients: []*string{ + tea.String(data.SenderStaffId), + }, + }, + }, + ) + +} + +// 问题处理群机器人 QA 收集 +func (s *CallbackService) issueHandlingCollectQA() { + +} diff --git a/internal/services/dtalk_bot.go b/internal/services/dtalk_bot.go index 9e46bf5..9d9bfc3 100644 --- a/internal/services/dtalk_bot.go +++ b/internal/services/dtalk_bot.go @@ -10,7 +10,6 @@ import ( "ai_scheduler/internal/pkg/dingtalk" "context" "encoding/json" - "fmt" "log" "sync" "time" @@ -18,21 +17,21 @@ import ( "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/card" "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" "github.com/alibabacloud-go/dingtalk/card_1_0" - "github.com/alibabacloud-go/dingtalk/im_1_0" "github.com/alibabacloud-go/tea/tea" "golang.org/x/sync/errgroup" "xorm.io/builder" ) type DingBotService struct { - config *config.Config - dingTalkBotBiz *biz.DingTalkBotBiz - dingTalkOld *dingtalk.OldClient - dingtalkCardClient *dingtalk.CardClient - dingtalkImClient *dingtalk.ImClient - botGroupConfigImpl *impl.BotGroupConfigImpl - botGroupImpl *impl.BotGroupImpl - botConfigImpl *impl.BotConfigImpl + config *config.Config + dingTalkBotBiz *biz.DingTalkBotBiz + dingTalkOld *dingtalk.OldClient + dingtalkCardClient *dingtalk.CardClient + dingtalkImClient *dingtalk.ImClient + dingtalkOauth2Client *dingtalk.Oauth2Client + botGroupConfigImpl *impl.BotGroupConfigImpl + botGroupImpl *impl.BotGroupImpl + botConfigImpl *impl.BotConfigImpl } func NewDingBotService( @@ -41,19 +40,21 @@ func NewDingBotService( dingTalkOld *dingtalk.OldClient, dingtalkCardClient *dingtalk.CardClient, dingtalkImClient *dingtalk.ImClient, + dingtalkOauth2Client *dingtalk.Oauth2Client, botGroupConfigImpl *impl.BotGroupConfigImpl, botGroupImpl *impl.BotGroupImpl, botConfigImpl *impl.BotConfigImpl, ) *DingBotService { return &DingBotService{ - config: config, - dingTalkBotBiz: dingTalkBotBiz, - dingTalkOld: dingTalkOld, - dingtalkCardClient: dingtalkCardClient, - dingtalkImClient: dingtalkImClient, - botGroupConfigImpl: botGroupConfigImpl, - botGroupImpl: botGroupImpl, - botConfigImpl: botConfigImpl, + config: config, + dingTalkBotBiz: dingTalkBotBiz, + dingTalkOld: dingTalkOld, + dingtalkCardClient: dingtalkCardClient, + dingtalkImClient: dingtalkImClient, + dingtalkOauth2Client: dingtalkOauth2Client, + botGroupConfigImpl: botGroupConfigImpl, + botGroupImpl: botGroupImpl, + botConfigImpl: botConfigImpl, } } @@ -180,9 +181,6 @@ func (d *DingBotService) OnCardMessageReceived(ctx context.Context, data *card.C return nil, nil } - // 卡片同步回调超时时间为2s,2s内同步返回,2s后异步更新卡片 - startTime := time.Now() - // action 处理 - 这里先只处理第一个匹配的actionId for _, actionId := range data.CardActionData.CardPrivateData.ActionIdList { switch actionId { @@ -197,78 +195,18 @@ func (d *DingBotService) OnCardMessageReceived(ctx context.Context, data *card.C return nil, err } - // 新群分享链接 - newGroupShareLink := "" - timeOutLimit := 1500 * time.Millisecond - - // 钉钉appKey - appKey := dingtalk.AppKey{} - + // 创建群聊及群初始化(ws中,直接协程) if data.CardActionData.CardPrivateData.Params["status"] == "confirm" { - // 创建群聊 - 这里用的是“统一登录平台”这个应用的接口 - // 不是很关心成功失败,ws中,后续考虑协程去创建 - chatId, openConversationId, err := d.dingTalkOld.CreateInternalGroupConversation(ctx, "问题处理群", userIds) - if err != nil { - fmt.Printf("创建群聊失败: %v", err) - } - - _ = openConversationId - // 获取机器人配置 - var botConfig model.AiBotConfig - cond := builder.NewCond().And(builder.Eq{"robot_code": botId}) - err = d.botConfigImpl.GetOneBySearchToStrut(&cond, &botConfig) - if err != nil { - return nil, err - } - // 解出 config - var config entitys.DingTalkBot - err = json.Unmarshal([]byte(botConfig.BotConfig), &config) - if err != nil { - log.Printf("配置解析失败 %v", err.Error()) - } - appKey = dingtalk.AppKey{ - AppKey: config.ClientId, - AppSecret: config.ClientSecret, - } - - // 添加当前机器人到新群 - _, err = d.dingtalkImClient.AddRobotToConversation( - appKey, - &im_1_0.AddRobotToConversationRequest{ - OpenConversationId: tea.String(openConversationId), - RobotCode: tea.String(botId), - }) - if err != nil { - fmt.Printf("添加机器人到会话失败: %v", err) - } - - // 返回新群分享链接,直接进群 - newGroupShareLink, err = d.dingTalkOld.GetJoinGroupQrcode(ctx, chatId, data.UserId) - if err != nil { - fmt.Printf("获取入群二维码失败: %v", err) - } - } - - endTime := time.Now() - if endTime.Sub(startTime) > timeOutLimit { - // 异步更新卡片 - d.dingtalkCardClient.UpdateCard(appKey, &card_1_0.UpdateCardRequest{ - OutTrackId: tea.String(data.OutTrackId), - CardData: &card_1_0.UpdateCardRequestCardData{ - CardParamMap: map[string]*string{ - "button_display": tea.String("false"), - "new_group_share_link": tea.String(newGroupShareLink), - }, - }, - CardUpdateOptions: &card_1_0.UpdateCardRequestCardUpdateOptions{ - UpdateCardDataByKey: tea.Bool(true), - }, - }) - return + go func() { + err := d.createIssueHandlingGroupAndInit(ctx, data.CardActionData.CardPrivateData.Params, spaceId, botId, userIds) + if err != nil { + log.Printf("创建群聊及群初始化失败: %v", err) + } + }() } // 构建关闭创建群组卡片按钮的响应 - resp = d.buildCreateGroupCardResp(newGroupShareLink) + resp = d.buildCreateGroupCardResp() return } } @@ -312,13 +250,139 @@ func (d *DingBotService) buildNewGroupUserIds(ctx context.Context, spaceId, botI return userIds, nil } +// createIssueHandlingGroupAndInit 创建问题处理群聊及群初始化 +func (d *DingBotService) createIssueHandlingGroupAndInit(ctx context.Context, callbackParams map[string]any, spaceId, botId string, userIds []string) error { + // 获取机器人配置 + botConfig, err := d.botConfigImpl.GetRobotConfig(botId) + if err != nil { + return err + } + + appKey := dingtalk.AppKey{ + AppKey: botConfig.ClientId, + AppSecret: botConfig.ClientSecret, + } + + // 获取 access_token + accessToken, err := d.dingtalkOauth2Client.GetAccessToken(appKey) + if err != nil { + return err + } + appKey.AccessToken = accessToken + + // 创建群聊 + _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, spaceId, botId, userIds) + if err != nil { + return err + } + + // 添加当前机器人到新群 - SDK 有问题,后续再考虑使用 + // _, err = d.dingtalkImClient.AddRobotToConversation( + // appKey, + // &im_1_0.AddRobotToConversationRequest{ + // OpenConversationId: tea.String(openConversationId), + // RobotCode: tea.String(botId), + // }) + // if err != nil { + // fmt.Printf("添加机器人到会话失败: %v", err) + // } + + // 返回新群分享链接,直接进群 - SDK 有问题,后续再考虑使用 + // newGroupShareLink, err = d.dingTalkOld.GetJoinGroupQrcode(ctx, chatId, data.UserId) + // if err != nil { + // fmt.Printf("获取入群二维码失败: %v", err) + // } + + // 初始化群聊 + // 1.开场白 + + // 构建卡片 OutTrackId + outTrackId := constants.BuildCardOutTrackId(openConversationId, constants.GroupTemplateRobotIdIssueHandling) + + // 群主题 + groupScope := callbackParams["group_scope"].(string) + + _, err = d.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(constants.DingtalkCardTplBaseMsg), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("当前会话主题"), + "markdown": tea.String("问题:" + groupScope), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + openConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(constants.GroupTemplateRobotIdIssueHandling), + AtUserIds: map[string]*string{ + "@ALL": tea.String("@ALL"), + }, + }, + }, + ) + + // 2. 机器人能力 + // 构建卡片 OutTrackId + outTrackId = constants.BuildCardOutTrackId(openConversationId, constants.GroupTemplateRobotIdIssueHandling) + _, err = d.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(constants.DingtalkCardTplBaseMsg), + OutTrackId: tea.String(outTrackId), + CallbackType: tea.String("HTTP"), + CardData: &card_1_0.CreateAndDeliverRequestCardData{ + CardParamMap: map[string]*string{ + "title": tea.String("当前机器人能力"), + "markdown": tea.String("- 聊天内容提取(@机器人 [内容提取]) \n - QA知识收集 (@机器人 [QA收集])"), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + openConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(constants.GroupTemplateRobotIdIssueHandling), + AtUserIds: map[string]*string{ + "@ALL": tea.String("@ALL"), + }, + }, + }, + ) + + return nil +} + +// createGroupV1 创建普通内部群会话 +// 这里用的是“统一登录平台”这个应用的接口加入群聊 - 这里用的是“统一登录平台”这个应用的接口 +func (d *DingBotService) createIssueHandlingGroup(ctx context.Context, accessToken, spaceId, botId string, userIds []string) (chatId, openConversationId string, err error) { + // 是否使用模板群开关 + var useTemplateGroup bool = true + + // 创建内部群会话 + if !useTemplateGroup { + return d.dingTalkOld.CreateInternalGroupConversation(ctx, accessToken, "问题处理群", userIds) + } + + // 根据群模板ID创建群 + if useTemplateGroup { + return d.dingTalkOld.CreateSceneGroupConversation(ctx, accessToken, "问题处理群", userIds, constants.GroupTemplateIdIssueHandling) + } + + return +} + // buildCreateGroupCardResp 构建关闭创建群组卡片按钮 -func (d *DingBotService) buildCreateGroupCardResp(newGroupShareLink string) *card.CardResponse { +func (d *DingBotService) buildCreateGroupCardResp() *card.CardResponse { return &card.CardResponse{ CardData: &card.CardDataDto{ CardParamMap: map[string]string{ - "button_display": "false", - "new_group_share_link": newGroupShareLink, + "button_display": "false", }, }, CardUpdateOptions: &card.CardUpdateOptions{