From 9418d95a659053e7e6beb0319de6fd48b09eba33 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Sat, 31 Jan 2026 17:59:08 +0800 Subject: [PATCH 01/16] fix: heartbeat_interval 30 -> 300 --- config/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.yaml b/config/config.yaml index dff34ce..c41dac1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -39,7 +39,7 @@ sys: channel_pool_len: 100 channel_pool_size: 32 llm_pool_len: 5 - heartbeat_interval: 30 + heartbeat_interval: 300 key: report-api pollSize: 5 #连接池大小,不配置,或配置为0表示不启用连接池 minIdleConns: 2 #最小空闲连接数 From 22b7a3d0960a24884d362d733bf3400ae61ab8ed Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Sat, 31 Jan 2026 18:02:38 +0800 Subject: [PATCH 02/16] fix: llm model cloud -> local --- config/config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index c41dac1..bbc416d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,12 +5,12 @@ server: ollama: base_url: "http://172.17.0.1:11434" - # model: "qwen3:8b" - # generate_model: "qwen3:8b" - # mapping_model: "qwen3:8b" - model: "qwen3-coder:480b-cloud" - generate_model: "qwen3-coder:480b-cloud" - mapping_model: "deepseek-v3.2:cloud" + model: "qwen3:8b" + generate_model: "qwen3:8b" + mapping_model: "qwen3:8b" + # model: "qwen3-coder:480b-cloud" + # generate_model: "qwen3-coder:480b-cloud" + # mapping_model: "deepseek-v3.2:cloud" vl_model: "qwen2.5vl:3b" timeout: "120s" level: "info" From 71ed8146f590a34c1173a721031b18db75f615da Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Sat, 31 Jan 2026 18:10:05 +0800 Subject: [PATCH 03/16] fix: HeardBeatX 10s -> 100s --- internal/biz/handle/dingtalk/send_card.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/biz/handle/dingtalk/send_card.go b/internal/biz/handle/dingtalk/send_card.go index d2e5cb7..4660f33 100644 --- a/internal/biz/handle/dingtalk/send_card.go +++ b/internal/biz/handle/dingtalk/send_card.go @@ -20,7 +20,7 @@ import ( ) const DefaultInterval = 100 * time.Millisecond -const HeardBeatX = 100 +const HeardBeatX = 1000 type SendCardClient struct { Auth *Auth From 21585e731f4c917b6d3b0b9062c78bef35e3c2ef Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Sat, 31 Jan 2026 18:14:03 +0800 Subject: [PATCH 04/16] fix: llm model local -> cloud --- config/config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index bbc416d..c41dac1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,12 +5,12 @@ server: ollama: base_url: "http://172.17.0.1:11434" - model: "qwen3:8b" - generate_model: "qwen3:8b" - mapping_model: "qwen3:8b" - # model: "qwen3-coder:480b-cloud" - # generate_model: "qwen3-coder:480b-cloud" - # mapping_model: "deepseek-v3.2:cloud" + # model: "qwen3:8b" + # generate_model: "qwen3:8b" + # mapping_model: "qwen3:8b" + model: "qwen3-coder:480b-cloud" + generate_model: "qwen3-coder:480b-cloud" + mapping_model: "deepseek-v3.2:cloud" vl_model: "qwen2.5vl:3b" timeout: "120s" level: "info" From b3b09f184b5d82102652ab1047cfd7b1bf888c39 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 2 Feb 2026 10:29:08 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=89?= =?UTF-8?q?=E5=BC=A0=E9=97=AE=E9=A2=98=E5=A4=84=E7=90=86=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/model/ai_issue_assign_rule.gen.go | 27 +++++++++++++++++++ .../data/model/ai_issue_assign_user.gen.go | 24 +++++++++++++++++ internal/data/model/ai_issue_type.gen.go | 25 +++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 internal/data/model/ai_issue_assign_rule.gen.go create mode 100644 internal/data/model/ai_issue_assign_user.gen.go create mode 100644 internal/data/model/ai_issue_type.gen.go diff --git a/internal/data/model/ai_issue_assign_rule.gen.go b/internal/data/model/ai_issue_assign_rule.gen.go new file mode 100644 index 0000000..a1dfbbc --- /dev/null +++ b/internal/data/model/ai_issue_assign_rule.gen.go @@ -0,0 +1,27 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameAiIssueAssignRule = "ai_issue_assign_rule" + +// AiIssueAssignRule AI问题分配规则表,指定系统+问题类型对应分配规则 +type AiIssueAssignRule struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + SysID int64 `gorm:"column:sys_id;not null;comment:系统ID,关联 ai_sys.id" json:"sys_id"` // 系统ID,关联 ai_sys.id + IssueTypeID int64 `gorm:"column:issue_type_id;not null;comment:问题类型ID,关联 ai_issue_type.id" json:"issue_type_id"` // 问题类型ID,关联 ai_issue_type.id + Status int32 `gorm:"column:status;not null;default:1;comment:规则状态:1=启用,0=停用" json:"status"` // 规则状态:1=启用,0=停用 + Description string `gorm:"column:description;comment:规则描述,用于说明规则用途" json:"description"` // 规则描述,用于说明规则用途 + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 +} + +// TableName AiIssueAssignRule's table name +func (*AiIssueAssignRule) TableName() string { + return TableNameAiIssueAssignRule +} diff --git a/internal/data/model/ai_issue_assign_user.gen.go b/internal/data/model/ai_issue_assign_user.gen.go new file mode 100644 index 0000000..c05f88d --- /dev/null +++ b/internal/data/model/ai_issue_assign_user.gen.go @@ -0,0 +1,24 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameAiIssueAssignUser = "ai_issue_assign_user" + +// AiIssueAssignUser 规则对应的用户表,命中规则时需要通知的钉钉用户 +type AiIssueAssignUser struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + RuleID int64 `gorm:"column:rule_id;not null;comment:规则ID,关联 ai_issue_assign_rule.id" json:"rule_id"` // 规则ID,关联 ai_issue_assign_rule.id + UserID int64 `gorm:"column:user_id;not null;comment:钉钉用户ID,关联 ai_bot_user.id" json:"user_id"` // 钉钉用户ID,关联 ai_bot_user.id + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 +} + +// TableName AiIssueAssignUser's table name +func (*AiIssueAssignUser) TableName() string { + return TableNameAiIssueAssignUser +} diff --git a/internal/data/model/ai_issue_type.gen.go b/internal/data/model/ai_issue_type.gen.go new file mode 100644 index 0000000..2a91fe5 --- /dev/null +++ b/internal/data/model/ai_issue_type.gen.go @@ -0,0 +1,25 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameAiIssueType = "ai_issue_type" + +// AiIssueType AI问题类型表 +type AiIssueType struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + Code string `gorm:"column:code;not null;comment:问题类型编码,例如: ui, bug, demand" json:"code"` // 问题类型编码,例如: ui, bug, demand + Name string `gorm:"column:name;not null;comment:问题类型名称,例如: UI问题, Bug, 需求" json:"name"` // 问题类型名称,例如: UI问题, Bug, 需求 + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 +} + +// TableName AiIssueType's table name +func (*AiIssueType) TableName() string { + return TableNameAiIssueType +} From 3b6471a196af4a48289ef1f09551c20f255f0ab2 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 2 Feb 2026 15:07:51 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix:=201.=E5=BC=80=E6=94=BE=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E5=8D=95=E8=81=8A=E5=A4=84=E7=90=86=202.=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=97=AE=E9=A2=98->=E8=B4=9F=E8=B4=A3?= =?UTF-8?q?=E4=BA=BA=E8=B7=AF=E7=94=B1=E7=9B=B8=E5=85=B3=E6=96=B9=E6=B3=95?= =?UTF-8?q?=203.=E5=A4=9A=E8=BD=AE=E5=AF=B9=E8=AF=9D=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=AE=9E=E9=99=85=E9=97=AE=E9=A2=98=204.?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E8=B0=83=E7=94=A8=205.=E5=BB=BA?= =?UTF-8?q?=E7=BE=A4=EF=BC=88=E6=97=A0=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 2 + internal/biz/ding_talk_bot.go | 280 +++++++++++++++++- internal/biz/do/handle.go | 220 +++++++++----- internal/biz/group_config.go | 15 +- internal/biz/llm_service/ollama.go | 8 + internal/data/impl/ai_issue_manager.go | 64 ++++ internal/data/impl/base.go | 3 +- internal/data/impl/bot_user.go | 11 + internal/data/impl/provider_set.go | 1 + .../data/model/ai_issue_assign_rule.gen.go | 6 +- .../data/model/ai_issue_assign_user.gen.go | 6 +- internal/data/model/ai_issue_type.gen.go | 2 +- 13 files changed, 514 insertions(+), 105 deletions(-) create mode 100644 internal/data/impl/ai_issue_manager.go diff --git a/go.mod b/go.mod index 095f736..30a9fdb 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/duke-git/lancet/v2 v2.3.8 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eino-contrib/jsonschema v1.0.3 // indirect github.com/eino-contrib/ollama v0.1.0 // indirect diff --git a/go.sum b/go.sum index 7d1f8b1..1c24ee2 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/duke-git/lancet/v2 v2.3.8 h1:dlkqn6Nj2LRWFuObNxttkMHxrFeaV6T26JR8jbEVbPg= +github.com/duke-git/lancet/v2 v2.3.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 0d8dd0f..4820961 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -12,6 +12,7 @@ import ( "ai_scheduler/internal/tools" "ai_scheduler/internal/tools/bbxt" "ai_scheduler/tmpl/dataTemp" + "ai_scheduler/utils" "context" "database/sql" "encoding/json" @@ -27,6 +28,7 @@ import ( "github.com/alibabacloud-go/dingtalk/card_1_0" "github.com/alibabacloud-go/tea/tea" + "github.com/duke-git/lancet/v2/slice" "github.com/gofiber/fiber/v2/log" "xorm.io/builder" ) @@ -44,6 +46,7 @@ type DingTalkBotBiz struct { botGroupQywxImpl *impl.BotGroupQywxImpl toolManager *tools.Manager chatHis *impl.BotChatHisImpl + botUserImpl *impl.BotUserImpl conf *config.Config cardSend *dingtalk.SendCardClient qywxGroupHandle *qywx.Group @@ -53,6 +56,9 @@ type DingTalkBotBiz struct { dingtalkOauth2Client *dingtalkPkg.Oauth2Client dingTalkOld *dingtalkPkg.OldClient dingtalkCardClient *dingtalkPkg.CardClient + rdb *utils.Rdb + issueImpl *impl.IssueImpl + sysImpl *impl.SysImpl } // NewDingTalkBotBiz @@ -64,6 +70,7 @@ func NewDingTalkBotBiz( botGroupConfigImpl *impl.BotGroupConfigImpl, dingTalkUser *dingtalk.User, chatHis *impl.BotChatHisImpl, + botUserImpl *impl.BotUserImpl, reportDailyCacheImpl *impl.ReportDailyCacheImpl, toolManager *tools.Manager, conf *config.Config, @@ -73,6 +80,9 @@ func NewDingTalkBotBiz( dingtalkOauth2Client *dingtalkPkg.Oauth2Client, dingTalkOld *dingtalkPkg.OldClient, dingtalkCardClient *dingtalkPkg.CardClient, + rdb *utils.Rdb, + issueImpl *impl.IssueImpl, + sysImpl *impl.SysImpl, ) *DingTalkBotBiz { return &DingTalkBotBiz{ do: do, @@ -85,6 +95,7 @@ func NewDingTalkBotBiz( botGroupConfigImpl: botGroupConfigImpl, toolManager: toolManager, chatHis: chatHis, + botUserImpl: botUserImpl, conf: conf, cardSend: cardSend, reportDailyCacheImpl: reportDailyCacheImpl, @@ -92,6 +103,9 @@ func NewDingTalkBotBiz( dingtalkOauth2Client: dingtalkOauth2Client, dingTalkOld: dingTalkOld, dingtalkCardClient: dingtalkCardClient, + rdb: rdb, + issueImpl: issueImpl, + sysImpl: sysImpl, } } @@ -141,19 +155,249 @@ func (d *DingTalkBotBiz) Do(ctx context.Context, requireData *entitys.RequireDat return } +// handleSingleChat 单聊处理 +// 先不接意图识别-仅提供问题处理 func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) { - entitys.ResLog(requireData.Ch, "", "个人聊天暂未开启,请期待后续更新") - return - //requireData.UserInfo, err = d.dingTalkUser.GetUserInfoFromBot(ctx, requireData.Req.SenderStaffId, dingtalk.WithId(1)) - //if err != nil { - // return - //} - //requireData.ID=requireData.UserInfo.UserID - ////如果不是管理或者不是老板,则进行权限判断 - //if requireData.UserInfo.IsSenior == constants.IsSeniorFalse && requireData.UserInfo.IsBoss == constants.IsBossFalse { - // - //} - //return + // 1. 获取用户信息 + requireData.UserInfo, err = d.dingTalkUser.GetUserInfoFromBot(ctx, requireData.Req.SenderStaffId, dingtalk.WithId(1)) + if err != nil { + return + } + requireData.ID = int32(requireData.UserInfo.UserId) + + // 2. 检查会话状态 (Redis) + statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) + status, _ := d.rdb.Rdb.Get(ctx, statusKey).Result() + + if status == "WAITING_FOR_SYS_CONFIRM" { + // 用户回复了系统名称 + sysName := requireData.Req.Text.Content + d.rdb.Rdb.Del(ctx, statusKey) + return d.handleWithSpecificSys(ctx, requireData, sysName) + } + + // 3. 获取历史记录 (最近6轮用户输入) + userHist, err := d.getRecentUserHistory(ctx, constants.ConversationTypeSingle, requireData.ID, 6) + if err != nil { + return err + } + + // 4. 改写 Query (Query Rewriting) + rewrittenQuery, err := d.handle.RewriteQuery(ctx, userHist, requireData.Req.Text.Content) + var queryText = requireData.Req.Text.Content + if err == nil && rewrittenQuery != "" { + queryText = rewrittenQuery + } + + // 构造识别对象 + rec := &entitys.Recognize{ + Ch: requireData.Ch, + SystemPrompt: d.defaultPrompt(), + UserContent: &entitys.RecognizeUserContent{ + Text: queryText, + }, + } + + // 5. 调用知识库 + isRetrieved, err := d.groupConfigBiz.handleKnowledge(ctx, rec, nil, requireData.Req) + if err != nil { + return err + } + + if isRetrieved { + return nil + } + + // 6. Fallback: 分类 -> 规则 -> 拉群 + return d.fallbackToGroupCreation(ctx, requireData) +} + +// handleWithSpecificSys 处理用户明确指定的系统 +func (d *DingTalkBotBiz) handleWithSpecificSys(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, sysName string) error { + // 1. 查找系统 + var sys model.AiSy + cond := builder.NewCond().And(builder.Eq{"sys_name": sysName}) + err := d.sysImpl.GetOneBySearchToStrut(&cond, &sys) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + entitys.ResText(requireData.Ch, "", "抱歉,我还是没有找到名为“"+sysName+"”的系统。请联系管理员确认系统名称。") + return nil + } + return err + } + + // 2. 既然已经明确了系统,直接尝试拉群(这里假设问题类型为“其他”或由LLM再次分析) + // 为简化,这里再次调用分类逻辑,但带上已确定的系统 + return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) +} + +// getRecentUserHistory 获取最近的用户输入历史 +func (d *DingTalkBotBiz) getRecentUserHistory(ctx context.Context, conversationType constants.ConversationType, id int32, limit int) ([]model.AiBotChatHi, error) { + var his []model.AiBotChatHi + cond := builder.NewCond(). + And(builder.Eq{"his_type": conversationType}). + And(builder.Eq{"id": id}). + And(builder.Eq{"role": "user"}) + + _, err := d.chatHis.GetListToStruct(&cond, &dataTemp.ReqPageBo{Limit: limit}, &his, "his_id desc") + if err != nil { + return nil, err + } + return his, nil +} + +// fallbackToGroupCreation 分类并拉群 +func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) error { + // 1. 获取所有系统和问题类型用于分类 + allSys, err := d.sysImpl.FindAll() + if err != nil { + return err + } + sysNames := slice.Map(allSys, func(_ int, sys model.AiSy) string { + return sys.SysName + }) + allIssueTypes, err := d.issueImpl.IssueType.FindAll() + if err != nil { + return err + } + issueTypeNames := slice.Map(allIssueTypes, func(_ int, it model.AiIssueType) string { + return it.Name + }) + + // 2. LLM 分类 + classification, err := d.handle.ClassifyIssue(ctx, sysNames, issueTypeNames, requireData.Req.Text.Content) + if err != nil { + // 分类失败,使用兜底 + return d.createDefaultGroup(ctx, requireData, "系统无法识别") + } + + // 3. 匹配系统 + var sys model.AiSy + for _, s := range allSys { + if s.SysName == classification.SysName { + sys = s + break + } + } + + if sys.SysID == 0 { + // 无法明确系统,询问用户 + statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) + d.rdb.Rdb.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) + entitys.ResText(requireData.Ch, "", "抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您安排对应的技术支持。") + return nil + } + + return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) +} + +// fallbackToGroupCreationWithSys 在已知系统的情况下进行分类并拉群 +func (d *DingTalkBotBiz) fallbackToGroupCreationWithSys(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, sys *model.AiSy) error { + // 1. 获取所有问题类型 + allIssueTypes, err := d.issueImpl.IssueType.FindAll() + if err != nil { + return err + } + issueTypeNames := slice.Map(allIssueTypes, func(_ int, it model.AiIssueType) string { + return it.Name + }) + + // 2. LLM 再次分类(确定问题类型和简述) + classification, err := d.handle.ClassifyIssue(ctx, []string{sys.SysName}, issueTypeNames, requireData.Req.Text.Content) + if err != nil { + return d.createDefaultGroup(ctx, requireData, "问题类型识别失败") + } + issueType, found, err := d.issueImpl.IssueType.FindOne(d.issueImpl.WithName(classification.IssueTypeName)) + if !found { + log.Errorf("issue type %s not found; err: %v", classification.IssueTypeName, err) + return fmt.Errorf("问题类型 %s 不存在", classification.IssueTypeName) + } + + // 3. 查找分配规则 + rule, found, err := d.issueImpl.IssueAssignRule.FindOne( + d.issueImpl.WithSysID(sys.SysID), + d.issueImpl.WithIssueTypeID(issueType.ID), + d.issueImpl.WithStatus(1), + ) + if !found { + log.Errorf("assign rule not found for sys %s and issue type %s; err: %v", sys.SysName, issueType.Name, err) + return fmt.Errorf("分配规则 %s-%s 不存在", sys.SysName, issueType.Name) + } + + var staffIds []string + if rule.ID != 0 { + // 获取配置的用户 + assignUsers, err := d.issueImpl.IssueAssignUser.FindAll(d.issueImpl.WithRuleID(rule.ID)) + if len(assignUsers) == 0 { + log.Errorf("assign user not found for rule %d; err: %v", rule.ID, err) + return fmt.Errorf("分配用户 %d 不存在", rule.ID) + } + userIds := slice.Map(assignUsers, func(_ int, au model.AiIssueAssignUser) int32 { + return au.UserID + }) + // 获取有效用户 + botUsers, err := d.botUserImpl.GetByUserIds(userIds) + if err != nil { + return err + } + + // 仅获取有效用户的 staff_id + for _, au := range assignUsers { + botUser, found := slice.Find(botUsers, func(_ int, bu model.AiBotUser) bool { + return bu.UserID == au.UserID + }) + if found && botUser.StaffID != "" { + staffIds = append(staffIds, botUser.StaffID) + } + } + } + + // 兜底处理人 + if len(staffIds) == 0 { + staffIds = []string{"17415698414368678"} + } + + // 合并提问者 + staffIds = append([]string{requireData.Req.SenderStaffId}, staffIds...) + + // 4. 拉群 + groupName := fmt.Sprintf("[%s]-%s", classification.IssueTypeName, classification.Summary) + return d.createIssueHandlingGroupAndInitSingle(ctx, requireData.Req.RobotCode, groupName, staffIds, classification.Summary) +} + +// createDefaultGroup 兜底拉群 +func (d *DingTalkBotBiz) createDefaultGroup(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, reason string) error { + userIds := []string{requireData.Req.SenderStaffId, "17415698414368678"} + groupName := fmt.Sprintf("[未知]-%s", reason) + return d.createIssueHandlingGroupAndInitSingle(ctx, requireData.Req.RobotCode, groupName, userIds, "由于无法识别具体问题类型,已为您拉起默认技术支持群。") +} + +// createIssueHandlingGroupAndInitSingle 创建群聊并初始化(单聊专用) +func (d *DingTalkBotBiz) createIssueHandlingGroupAndInitSingle(ctx context.Context, robotCode string, groupName string, userIds []string, summary string) error { + // 获取机器人配置 + appKey, err := d.botConfigImpl.GetRobotAppKey(robotCode) + if err != nil { + log.Errorf("get robot app key failed; err: %v", err) + return fmt.Errorf("未找到机器人配置") + } + + // 获取 access_token + accessToken, err := d.dingtalkOauth2Client.GetAccessToken(appKey) + if err != nil { + log.Errorf("get access token failed; err: %v", err) + return fmt.Errorf("获取 access token 失败") + } + appKey.AccessToken = accessToken + + // 创建群聊 + _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, groupName, userIds) + if err != nil { + log.Errorf("create issue handling group failed; err: %v", err) + return fmt.Errorf("创建群聊失败") + } + + // 初始化群聊 + return d.initIssueHandlingGroup(appKey, openConversationId, summary) } func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) { @@ -504,7 +748,7 @@ func (d *DingTalkBotBiz) createIssueHandlingGroupAndInit(ctx context.Context, ca appKey.AccessToken = accessToken // 创建群聊 - _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, userIds) + _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, "问题处理群", userIds) if err != nil { return err } @@ -534,18 +778,22 @@ func (d *DingTalkBotBiz) createIssueHandlingGroupAndInit(ctx context.Context, ca } // createIssueHandlingGroup 创建问题处理群聊会话 -func (d *DingTalkBotBiz) createIssueHandlingGroup(ctx context.Context, accessToken string, userIds []string) (chatId, openConversationId string, err error) { +func (d *DingTalkBotBiz) createIssueHandlingGroup(ctx context.Context, accessToken string, groupName string, userIds []string) (chatId, openConversationId string, err error) { // 是否使用模板群开关 var useTemplateGroup bool = true + if groupName == "" { + groupName = "问题处理群" + } + // 创建内部群会话 if !useTemplateGroup { - return d.dingTalkOld.CreateInternalGroupConversation(ctx, accessToken, "问题处理群", userIds) + return d.dingTalkOld.CreateInternalGroupConversation(ctx, accessToken, groupName, userIds) } // 根据群模板ID创建群 if useTemplateGroup { - return d.dingTalkOld.CreateSceneGroupConversation(ctx, accessToken, "问题处理群", userIds, d.conf.Dingtalk.SceneGroup.GroupTemplateIDIssueHandling) + return d.dingTalkOld.CreateSceneGroupConversation(ctx, accessToken, groupName, userIds, d.conf.Dingtalk.SceneGroup.GroupTemplateIDIssueHandling) } return diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index b9432ea..fa6bd57 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -12,14 +12,12 @@ import ( "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" "ai_scheduler/internal/gateway" - "ai_scheduler/internal/pkg" "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/l_request" "ai_scheduler/internal/pkg/mapstructure" "ai_scheduler/internal/pkg/rec_extra" "ai_scheduler/internal/pkg/util" "ai_scheduler/internal/tools" - "ai_scheduler/internal/tools/public" "bufio" errorsSpecial "errors" "io" @@ -33,6 +31,7 @@ import ( "github.com/coze-dev/coze-go" "github.com/gofiber/fiber/v2/log" + "github.com/ollama/ollama/api" "gorm.io/gorm/utils" ) @@ -93,6 +92,79 @@ func (r *Handle) Recognize(ctx context.Context, rec *entitys.Recognize, promptPr return } +// RewriteQuery 改写查询词,支持多轮对话 +func (r *Handle) RewriteQuery(ctx context.Context, history []model.AiBotChatHi, currentQuery string) (string, error) { + if len(history) == 0 { + return currentQuery, nil + } + + var histStr strings.Builder + for _, h := range history { + role := "用户" + if h.Role != "user" { + role = "助手" + } + histStr.WriteString(fmt.Sprintf("%s: %s\n", role, h.Content)) + } + + systemPrompt := `你是一个搜索查询改写专家。请结合用户的历史对话上下文,将用户当前的输入改写为一个独立的、语义完整的、适合知识库检索的中文查询词。 +要求: +1. 保持原意,补全指代(如“它”、“刚才那个问题”)。 +2. 只返回改写后的查询词,不要有任何解释。 +3. 如果当前输入已经很完整,直接返回原句。` + + userPrompt := fmt.Sprintf("### 历史对话:\n%s\n### 当前输入:\n%s\n### 改写后的查询词:", histStr.String(), currentQuery) + + messages := []api.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + } + + return r.Ollama.Chat(ctx, messages) +} + +type IssueClassification struct { + SysName string `json:"sys_name"` + IssueTypeName string `json:"issue_type_name"` + Summary string `json:"summary"` + Reason string `json:"reason"` +} + +// ClassifyIssue 问题分类分析 +func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes []string, userInput string) (*IssueClassification, error) { + systemPrompt := fmt.Sprintf(`你是一个技术支持路由专家。请分析用户的输入,并将其归类到最合适的系统和问题类型中。 +可用系统列表: [%s] +可用问题类型: [%s] + +请仅以 JSON 格式回复,包含以下字段: +- sys_name: 系统名称 +- issue_type_name: 问题类型名称 +- summary: 15字以内的问题简述(用于群命名) +- reason: 分类理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) + + messages := []api.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userInput}, + } + + resp, err := r.Ollama.Chat(ctx, messages) + if err != nil { + return nil, err + } + + // 尝试清理 JSON 内容(有时模型会返回 markdown 块) + resp = strings.TrimPrefix(resp, "```json") + resp = strings.TrimSuffix(resp, "```") + resp = strings.TrimSpace(resp) + + var result IssueClassification + if err := json.Unmarshal([]byte(resp), &result); err != nil { + return nil, fmt.Errorf("解析分类结果失败: %w, 原文: %s", err, resp) + } + + return &result, nil +} + func (r *Handle) handleOtherTask(ctx context.Context, requireData *entitys.RequireData) (err error) { entitys.ResText(requireData.Ch, "", requireData.Match.Reasoning) return @@ -166,87 +238,87 @@ func (r *Handle) handleTask(ctx context.Context, rec *entitys.Recognize, task *m } // 知识库 -func (r *Handle) handleKnowle(ctx context.Context, rec *entitys.Recognize, task *model.AiTask) (err error) { +// func (r *Handle) handleKnowle(ctx context.Context, rec *entitys.Recognize, task *model.AiTask) (err error) { - var ( - configData entitys.ConfigDataTool - sessionIdKnowledge string - query string - host string - ) - err = json.Unmarshal([]byte(task.Config), &configData) - if err != nil { - return - } - ext, err := rec_extra.GetTaskRecExt(rec) - if err != nil { - return - } - // 通过session 找到知识库session - var has bool - if len(ext.Session) == 0 { - return errors.SessionNotFound - } - ext.SessionInfo, has, err = r.sessionImpl.FindOne(r.sessionImpl.WithSessionId(ext.Session)) - if err != nil { - return - } else if !has { - return errors.SessionNotFound - } +// var ( +// configData entitys.ConfigDataTool +// sessionIdKnowledge string +// query string +// host string +// ) +// err = json.Unmarshal([]byte(task.Config), &configData) +// if err != nil { +// return +// } +// ext, err := rec_extra.GetTaskRecExt(rec) +// if err != nil { +// return +// } +// // 通过session 找到知识库session +// var has bool +// if len(ext.Session) == 0 { +// return errors.SessionNotFound +// } +// ext.SessionInfo, has, err = r.sessionImpl.FindOne(r.sessionImpl.WithSessionId(ext.Session)) +// if err != nil { +// return +// } else if !has { +// return errors.SessionNotFound +// } - // 找到知识库的host - { - tool, exists := r.toolManager.GetTool(configData.Tool) - if !exists { - return fmt.Errorf("tool not found: %s", configData.Tool) - } +// // 找到知识库的host +// { +// tool, exists := r.toolManager.GetTool(configData.Tool) +// if !exists { +// return fmt.Errorf("tool not found: %s", configData.Tool) +// } - if knowledgeTool, ok := tool.(*public.KnowledgeBaseTool); !ok { - return fmt.Errorf("未找到知识库Tool: %s", configData.Tool) - } else { - host = knowledgeTool.GetConfig().BaseURL - } +// if knowledgeTool, ok := tool.(*public.KnowledgeBaseTool); !ok { +// return fmt.Errorf("未找到知识库Tool: %s", configData.Tool) +// } else { +// host = knowledgeTool.GetConfig().BaseURL +// } - } +// } - // 知识库的session为空,请求知识库获取, 并绑定 - if ext.SessionInfo.KnowlegeSessionID == "" { - // 请求知识库 - if sessionIdKnowledge, err = public.GetKnowledgeBaseSession(host, ext.Sys.KnowlegeBaseID, ext.Sys.KnowlegeTenantKey); err != nil { - return - } +// // 知识库的session为空,请求知识库获取, 并绑定 +// if ext.SessionInfo.KnowlegeSessionID == "" { +// // 请求知识库 +// if sessionIdKnowledge, err = public.GetKnowledgeBaseSession(host, ext.Sys.KnowlegeBaseID, ext.Sys.KnowlegeTenantKey); err != nil { +// return +// } - // 绑定知识库session,下次可以使用 - ext.SessionInfo.KnowlegeSessionID = sessionIdKnowledge - if err = r.sessionImpl.Update(&ext.SessionInfo, r.sessionImpl.WithSessionId(ext.SessionInfo.SessionID)); err != nil { - return - } - } +// // 绑定知识库session,下次可以使用 +// ext.SessionInfo.KnowlegeSessionID = sessionIdKnowledge +// if err = r.sessionImpl.Update(&ext.SessionInfo, r.sessionImpl.WithSessionId(ext.SessionInfo.SessionID)); err != nil { +// return +// } +// } - // 用户输入解析 - var ok bool - input := make(map[string]string) - if err = json.Unmarshal([]byte(rec.Match.Parameters), &input); err != nil { - return - } - if query, ok = input["query"]; !ok { - return fmt.Errorf("query不能为空") - } +// // 用户输入解析 +// var ok bool +// input := make(map[string]string) +// if err = json.Unmarshal([]byte(rec.Match.Parameters), &input); err != nil { +// return +// } +// if query, ok = input["query"]; !ok { +// return fmt.Errorf("query不能为空") +// } - ext.KnowledgeConf = entitys.KnowledgeBaseRequest{ - Session: ext.SessionInfo.KnowlegeSessionID, - ApiKey: ext.Sys.KnowlegeTenantKey, - Query: query, - } - rec.Ext = pkg.JsonByteIgonErr(ext) - // 执行工具 - err = r.toolManager.ExecuteTool(ctx, configData.Tool, rec) - if err != nil { - return - } +// ext.KnowledgeConf = entitys.KnowledgeBaseRequest{ +// Session: ext.SessionInfo.KnowlegeSessionID, +// ApiKey: ext.Sys.KnowlegeTenantKey, +// Query: query, +// } +// rec.Ext = pkg.JsonByteIgonErr(ext) +// // 执行工具 +// err = r.toolManager.ExecuteTool(ctx, configData.Tool, rec) +// if err != nil { +// return +// } - return -} +// return +// } // 知识库V2 - lightRAG自建 func (r *Handle) handleKnowleV2(ctx context.Context, rec *entitys.Recognize, task *model.AiTask) (err error) { diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index 5c2146b..8e97c5e 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -270,7 +270,8 @@ func (g *GroupConfigBiz) handleMatch(ctx context.Context, rec *entitys.Recognize case constants.TaskTypeCozeWorkflow: return g.handleCozeWorkflow(ctx, rec, pointTask) case constants.TaskTypeKnowle: // 知识库lightRAG版本 - return g.handleKnowledge(ctx, rec, groupConfig, callback) + _, err = g.handleKnowledge(ctx, rec, groupConfig, callback) + return err default: return g.otherTask(ctx, rec) } @@ -477,7 +478,7 @@ func (g *GroupConfigBiz) GetReportCache(ctx context.Context, day time.Time, tota } // handleKnowledge 处理知识库V2版本 -func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (err error) { +func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recognize, groupConfig *model.AiBotGroupConfig, callback *chatbot.BotCallbackDataModel) (isRetrieved bool, err error) { // 请求知识库工具 knowledgeBase := knowledge_base.New(g.conf.KnowledgeConfig) knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ @@ -489,19 +490,19 @@ func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recog OnlyRAG: true, }) if err != nil { - return fmt.Errorf("请求知识库工具失败,err: %v", err) + return false, fmt.Errorf("请求知识库工具失败,err: %v", err) } // 读取知识库SSE数据 - isRetrieved, err := g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) + isRetrieved, err = g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) if err != nil { return } - // 未检索到匹配信息,询问是否拉群 - if !isRetrieved { + // 未检索到匹配信息,群聊时询问是否拉群 + if !isRetrieved && callback.ConversationType == constants.ConversationTypeGroup { g.shouldCreateIssueHandlingGroup(ctx, rec, groupConfig, callback) - return nil + return false, nil } return diff --git a/internal/biz/llm_service/ollama.go b/internal/biz/llm_service/ollama.go index 7333de5..eb74adc 100644 --- a/internal/biz/llm_service/ollama.go +++ b/internal/biz/llm_service/ollama.go @@ -62,6 +62,14 @@ func (r *OllamaService) IntentRecognize(ctx context.Context, req *entitys.ToolSe return } +func (r *OllamaService) Chat(ctx context.Context, messages []api.Message) (string, error) { + res, err := r.client.Chat(ctx, r.config.Ollama.Model, messages) + if err != nil { + return "", err + } + return res.Message.Content, nil +} + //func (r *OllamaService) RecognizeWithImg(ctx context.Context, imgByte []api.ImageData, ch chan entitys.Response) (desc api.GenerateResponse, err error) { // if imgByte == nil { // return diff --git a/internal/data/impl/ai_issue_manager.go b/internal/data/impl/ai_issue_manager.go new file mode 100644 index 0000000..c827bd1 --- /dev/null +++ b/internal/data/impl/ai_issue_manager.go @@ -0,0 +1,64 @@ +package impl + +import ( + "ai_scheduler/internal/data/model" + "ai_scheduler/utils" + + "gorm.io/gorm" +) + +type IssueImpl struct { + IssueType BaseRepository[model.AiIssueType] + IssueAssignRule BaseRepository[model.AiIssueAssignRule] + IssueAssignUser BaseRepository[model.AiIssueAssignUser] +} + +func NewIssueImpl(db *utils.Db) *IssueImpl { + return &IssueImpl{ + IssueType: NewBaseModel[model.AiIssueType](db.Client), + IssueAssignRule: NewBaseModel[model.AiIssueAssignRule](db.Client), + IssueAssignUser: NewBaseModel[model.AiIssueAssignUser](db.Client), + } +} + +// WithName 名称查询 +func (a *IssueImpl) WithName(name string) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("name = ?", name) + } +} + +// WithCode 编码查询 +func (a *IssueImpl) WithCode(code string) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("code = ?", code) + } +} + +// WithSysID 系统ID查询 +func (a *IssueImpl) WithSysID(sysID any) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("sys_id = ?", sysID) + } +} + +// WithIssueTypeID 问题类型ID查询 +func (a *IssueImpl) WithIssueTypeID(issueTypeID any) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("issue_type_id = ?", issueTypeID) + } +} + +// WithRuleID 规则ID查询 +func (a *IssueImpl) WithRuleID(ruleID any) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("rule_id = ?", ruleID) + } +} + +// WithStatus 状态查询 +func (a *IssueImpl) WithStatus(status any) CondFunc { + return func(db *gorm.DB) *gorm.DB { + return db.Where("status = ?", status) + } +} diff --git a/internal/data/impl/base.go b/internal/data/impl/base.go index 501f710..f33159f 100644 --- a/internal/data/impl/base.go +++ b/internal/data/impl/base.go @@ -22,7 +22,8 @@ BaseModel 是一个泛型结构体,用于封装GORM数据库通用操作。 // 定义受支持的PO类型集合(可根据需要扩展), 只有包含表结构才能使用BaseModel,避免使用出现问题 type PO interface { model.AiChatHi | - model.AiSy | model.AiSession | model.AiTask | model.AiBotConfig + model.AiSy | model.AiSession | model.AiTask | model.AiBotConfig | + model.AiIssueType | model.AiIssueAssignRule | model.AiIssueAssignUser } type BaseModel[P PO] struct { diff --git a/internal/data/impl/bot_user.go b/internal/data/impl/bot_user.go index 862292f..ce47281 100644 --- a/internal/data/impl/bot_user.go +++ b/internal/data/impl/bot_user.go @@ -5,6 +5,8 @@ import ( "ai_scheduler/tmpl/dataTemp" "ai_scheduler/utils" "database/sql" + + "xorm.io/builder" ) type BotUserImpl struct { @@ -25,3 +27,12 @@ func (k BotUserImpl) GetByStaffId(staffId string) (*model.AiBotUser, error) { } return &data, err } + +func (k BotUserImpl) GetByUserIds(userIds []int32) ([]model.AiBotUser, error) { + var data []model.AiBotUser + cond := builder.NewCond() + cond = cond.And(builder.In("user_id", userIds)) + _, err := k.GetListToStruct2(&cond, nil, &data) + + return data, err +} diff --git a/internal/data/impl/provider_set.go b/internal/data/impl/provider_set.go index c200234..21320f0 100644 --- a/internal/data/impl/provider_set.go +++ b/internal/data/impl/provider_set.go @@ -18,4 +18,5 @@ var ProviderImpl = wire.NewSet( NewBotGroupConfigImpl, NewBotGroupQywxImpl, NewReportDailyCacheImpl, + NewIssueImpl, ) diff --git a/internal/data/model/ai_issue_assign_rule.gen.go b/internal/data/model/ai_issue_assign_rule.gen.go index a1dfbbc..067e578 100644 --- a/internal/data/model/ai_issue_assign_rule.gen.go +++ b/internal/data/model/ai_issue_assign_rule.gen.go @@ -12,9 +12,9 @@ const TableNameAiIssueAssignRule = "ai_issue_assign_rule" // AiIssueAssignRule AI问题分配规则表,指定系统+问题类型对应分配规则 type AiIssueAssignRule struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID - SysID int64 `gorm:"column:sys_id;not null;comment:系统ID,关联 ai_sys.id" json:"sys_id"` // 系统ID,关联 ai_sys.id - IssueTypeID int64 `gorm:"column:issue_type_id;not null;comment:问题类型ID,关联 ai_issue_type.id" json:"issue_type_id"` // 问题类型ID,关联 ai_issue_type.id + ID int32 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + SysID int32 `gorm:"column:sys_id;not null;comment:系统ID,关联 ai_sys.id" json:"sys_id"` // 系统ID,关联 ai_sys.id + IssueTypeID int32 `gorm:"column:issue_type_id;not null;comment:问题类型ID,关联 ai_issue_type.id" json:"issue_type_id"` // 问题类型ID,关联 ai_issue_type.id Status int32 `gorm:"column:status;not null;default:1;comment:规则状态:1=启用,0=停用" json:"status"` // 规则状态:1=启用,0=停用 Description string `gorm:"column:description;comment:规则描述,用于说明规则用途" json:"description"` // 规则描述,用于说明规则用途 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 diff --git a/internal/data/model/ai_issue_assign_user.gen.go b/internal/data/model/ai_issue_assign_user.gen.go index c05f88d..b45bf28 100644 --- a/internal/data/model/ai_issue_assign_user.gen.go +++ b/internal/data/model/ai_issue_assign_user.gen.go @@ -12,9 +12,9 @@ const TableNameAiIssueAssignUser = "ai_issue_assign_user" // AiIssueAssignUser 规则对应的用户表,命中规则时需要通知的钉钉用户 type AiIssueAssignUser struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID - RuleID int64 `gorm:"column:rule_id;not null;comment:规则ID,关联 ai_issue_assign_rule.id" json:"rule_id"` // 规则ID,关联 ai_issue_assign_rule.id - UserID int64 `gorm:"column:user_id;not null;comment:钉钉用户ID,关联 ai_bot_user.id" json:"user_id"` // 钉钉用户ID,关联 ai_bot_user.id + ID int32 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + RuleID int32 `gorm:"column:rule_id;not null;comment:规则ID,关联 ai_issue_assign_rule.id" json:"rule_id"` // 规则ID,关联 ai_issue_assign_rule.id + UserID int32 `gorm:"column:user_id;not null;comment:钉钉用户ID,关联 ai_bot_user.id" json:"user_id"` // 钉钉用户ID,关联 ai_bot_user.id CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 } diff --git a/internal/data/model/ai_issue_type.gen.go b/internal/data/model/ai_issue_type.gen.go index 2a91fe5..13c6eb6 100644 --- a/internal/data/model/ai_issue_type.gen.go +++ b/internal/data/model/ai_issue_type.gen.go @@ -12,7 +12,7 @@ const TableNameAiIssueType = "ai_issue_type" // AiIssueType AI问题类型表 type AiIssueType struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + ID int32 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID Code string `gorm:"column:code;not null;comment:问题类型编码,例如: ui, bug, demand" json:"code"` // 问题类型编码,例如: ui, bug, demand Name string `gorm:"column:name;not null;comment:问题类型名称,例如: UI问题, Bug, 需求" json:"name"` // 问题类型名称,例如: UI问题, Bug, 需求 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 From 847eb8b5db79fd7ad584e16cc67e57a5a66fe0aa Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 2 Feb 2026 16:32:19 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20=E5=8D=95=E8=81=8A=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E5=88=9D=E6=AD=A5=E5=BC=80=E5=8F=91=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 179 ++++++++++++++++++++-------- internal/biz/group_config.go | 30 +++-- internal/services/dtalk_bot_test.go | 3 +- 3 files changed, 151 insertions(+), 61 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 4820961..f40cc3c 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -25,11 +25,13 @@ import ( "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" dingtalkPkg "ai_scheduler/internal/pkg/dingtalk" + "ai_scheduler/internal/pkg/util" "github.com/alibabacloud-go/dingtalk/card_1_0" "github.com/alibabacloud-go/tea/tea" "github.com/duke-git/lancet/v2/slice" "github.com/gofiber/fiber/v2/log" + "github.com/redis/go-redis/v9" "xorm.io/builder" ) @@ -56,7 +58,7 @@ type DingTalkBotBiz struct { dingtalkOauth2Client *dingtalkPkg.Oauth2Client dingTalkOld *dingtalkPkg.OldClient dingtalkCardClient *dingtalkPkg.CardClient - rdb *utils.Rdb + redisCli *redis.Client issueImpl *impl.IssueImpl sysImpl *impl.SysImpl } @@ -103,7 +105,7 @@ func NewDingTalkBotBiz( dingtalkOauth2Client: dingtalkOauth2Client, dingTalkOld: dingTalkOld, dingtalkCardClient: dingtalkCardClient, - rdb: rdb, + redisCli: rdb.Rdb, issueImpl: issueImpl, sysImpl: sysImpl, } @@ -167,12 +169,12 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti // 2. 检查会话状态 (Redis) statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - status, _ := d.rdb.Rdb.Get(ctx, statusKey).Result() + status, _ := d.redisCli.Get(ctx, statusKey).Result() if status == "WAITING_FOR_SYS_CONFIRM" { // 用户回复了系统名称 sysName := requireData.Req.Text.Content - d.rdb.Rdb.Del(ctx, statusKey) + d.redisCli.Del(ctx, statusKey) return d.handleWithSpecificSys(ctx, requireData, sysName) } @@ -283,7 +285,7 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat if sys.SysID == 0 { // 无法明确系统,询问用户 statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - d.rdb.Rdb.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) + d.redisCli.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) entitys.ResText(requireData.Ch, "", "抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您安排对应的技术支持。") return nil } @@ -360,44 +362,30 @@ func (d *DingTalkBotBiz) fallbackToGroupCreationWithSys(ctx context.Context, req // 合并提问者 staffIds = append([]string{requireData.Req.SenderStaffId}, staffIds...) - // 4. 拉群 + // 4. 发送确认卡片 groupName := fmt.Sprintf("[%s]-%s", classification.IssueTypeName, classification.Summary) - return d.createIssueHandlingGroupAndInitSingle(ctx, requireData.Req.RobotCode, groupName, staffIds, classification.Summary) + return d.SendGroupCreationConfirmCard(ctx, &SendGroupCreationConfirmCardParams{ + RobotCode: requireData.Req.RobotCode, + ConversationId: requireData.Req.ConversationId, + SenderStaffId: requireData.Req.SenderStaffId, + UserIds: staffIds, + GroupName: groupName, + Summary: classification.Summary, + }) } -// createDefaultGroup 兜底拉群 +// createDefaultGroup 兜底发送确认卡片 func (d *DingTalkBotBiz) createDefaultGroup(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, reason string) error { userIds := []string{requireData.Req.SenderStaffId, "17415698414368678"} groupName := fmt.Sprintf("[未知]-%s", reason) - return d.createIssueHandlingGroupAndInitSingle(ctx, requireData.Req.RobotCode, groupName, userIds, "由于无法识别具体问题类型,已为您拉起默认技术支持群。") -} - -// createIssueHandlingGroupAndInitSingle 创建群聊并初始化(单聊专用) -func (d *DingTalkBotBiz) createIssueHandlingGroupAndInitSingle(ctx context.Context, robotCode string, groupName string, userIds []string, summary string) error { - // 获取机器人配置 - appKey, err := d.botConfigImpl.GetRobotAppKey(robotCode) - if err != nil { - log.Errorf("get robot app key failed; err: %v", err) - return fmt.Errorf("未找到机器人配置") - } - - // 获取 access_token - accessToken, err := d.dingtalkOauth2Client.GetAccessToken(appKey) - if err != nil { - log.Errorf("get access token failed; err: %v", err) - return fmt.Errorf("获取 access token 失败") - } - appKey.AccessToken = accessToken - - // 创建群聊 - _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, groupName, userIds) - if err != nil { - log.Errorf("create issue handling group failed; err: %v", err) - return fmt.Errorf("创建群聊失败") - } - - // 初始化群聊 - return d.initIssueHandlingGroup(appKey, openConversationId, summary) + return d.SendGroupCreationConfirmCard(ctx, &SendGroupCreationConfirmCardParams{ + RobotCode: requireData.Req.RobotCode, + ConversationId: requireData.Req.ConversationId, + SenderStaffId: requireData.Req.SenderStaffId, + UserIds: userIds, + GroupName: groupName, + Summary: reason, + }) } func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) { @@ -675,27 +663,124 @@ func (d *DingTalkBotBiz) CreateIssueHandlingGroupAndInit(ctx context.Context, da // 解析 OutTrackId 以获取 SpaceId 和 BotId spaceId, botId := constants.ParseCardOutTrackId(data.OutTrackId) - // 获取新群聊人员 - var userIds []string - userIds, err = d.buildNewGroupUserIds(spaceId, botId, data.UserId) - if err != nil { - return nil, err - } + // 获取操作状态 + status := data.CardActionData.CardPrivateData.Params["status"] + if status == "confirm" { + // 获取新群聊人员 (从卡片参数中统一解析) + targetUserIdsStr := data.CardActionData.CardPrivateData.Params["target_user_ids"].(string) + var userIds []string + if targetUserIdsStr != "" { + userIds = strings.Split(targetUserIdsStr, ",") + } - // 创建群聊及群初始化(异步响应) - if data.CardActionData.CardPrivateData.Params["status"] == "confirm" { - go func() { + if len(userIds) == 0 { + return nil, errors.New("target_user_ids 参数不能为空") + } + + // 创建群聊及群初始化(异步响应) + util.SafeGo("CreateIssueHandlingGroupAndInit", func() { err := d.createIssueHandlingGroupAndInit(ctx, data.CardActionData.CardPrivateData.Params, spaceId, botId, userIds) if err != nil { log.Errorf("创建群聊及群初始化失败: %v", err) } - }() + }) } // 构建关闭创建群组卡片按钮的响应 return d.buildCreateGroupCardResp(), nil } +type SendGroupCreationConfirmCardParams struct { + RobotCode string + ConversationId string + SenderStaffId string + UserIds []string + GroupName string + Summary string + IsGroupChat bool +} + +// SendGroupCreationConfirmCard 发送创建群聊确认卡片 +func (d *DingTalkBotBiz) SendGroupCreationConfirmCard(ctx context.Context, params *SendGroupCreationConfirmCardParams) error { + // 获取人员姓名用于展示 + var userNames []string + for _, uid := range params.UserIds { + if uid == params.SenderStaffId { + continue + } + user, err := d.botUserImpl.GetByStaffId(uid) + if err == nil && user != nil { + userNames = append(userNames, "@"+user.Name) + } else { + userNames = append(userNames, "@"+uid) + } + } + issueOwnerStr := strings.Join(userNames, "、") + + // 获取应用配置 + appKey, err := d.botConfigImpl.GetRobotAppKey(params.RobotCode) + if err != nil { + return err + } + + // 构建卡片 OutTrackId + outTrackId := constants.BuildCardOutTrackId(params.ConversationId, params.RobotCode) + + // 准备可见人员列表 + var recipients []*string + if params.IsGroupChat { + // 群聊:提问者 + 负责人可见 + for _, uid := range params.UserIds { + recipients = append(recipients, tea.String(uid)) + } + // 确保提问者也在可见列表中 + foundSender := false + for _, uid := range params.UserIds { + if uid == params.SenderStaffId { + foundSender = true + break + } + } + if !foundSender { + recipients = append(recipients, tea.String(params.SenderStaffId)) + } + } else { + // 单聊:仅提问者可见 + recipients = append(recipients, tea.String(params.SenderStaffId)) + } + + // 发送钉钉卡片 + _, err = d.dingtalkCardClient.CreateAndDeliver( + appKey, + &card_1_0.CreateAndDeliverRequest{ + CardTemplateId: tea.String(d.conf.Dingtalk.Card.Template.CreateGroupApprove), + 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(params.Summary), + "target_user_ids": tea.String(strings.Join(params.UserIds, ",")), + }, + }, + ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ + SupportForward: tea.Bool(false), + }, + OpenSpaceId: tea.String("dtv1.card//im_group." + params.ConversationId), + ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ + RobotCode: tea.String(params.RobotCode), + Recipients: recipients, + }, + }) + return err +} + // buildNewGroupUserIds 构建新群聊人员列表 func (d *DingTalkBotBiz) buildNewGroupUserIds(spaceId, botId, groupOwner string) ([]string, error) { // 群id+机器人id确认一个群配置 diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index 8e97c5e..49f9059 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -33,6 +33,7 @@ import ( "github.com/alibabacloud-go/dingtalk/card_1_0" "github.com/alibabacloud-go/tea/tea" "github.com/coze-dev/coze-go" + "github.com/duke-git/lancet/v2/slice" "github.com/gofiber/fiber/v2/log" "xorm.io/builder" ) @@ -586,12 +587,16 @@ func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec } // 合并所有name、Id userNames := make([]string, 0, len(issueOwner)) - userIds := make([]*string, 0, len(issueOwner)) + userIds := make([]string, 0, len(issueOwner)) for _, owner := range issueOwner { userNames = append(userNames, "@"+owner.Name) - userIds = append(userIds, tea.String(owner.UserId)) + userIds = append(userIds, owner.UserId) } issueOwnerStr := strings.Join(userNames, "、") + targetUserIds := append(userIds, callback.SenderStaffId) + recipientsUsers := slice.Map(targetUserIds, func(_ int, item string) *string { + return tea.String(item) + }) // 获取应用配置 appKey, err := g.botConfigImpl.GetRobotAppKey(callback.RobotCode) @@ -601,7 +606,6 @@ func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec // 构建卡片 OutTrackId outTrackId := constants.BuildCardOutTrackId(callback.ConversationId, callback.RobotCode) - // 发送钉钉卡片 _, err = g.dingtalkCardClient.CreateAndDeliver( appKey, @@ -611,15 +615,15 @@ func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec 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(g.conf.Dingtalk.Card.DebugToolEntryShow), // 调试字段 + "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)), + "target_user_ids": tea.String(strings.Join(targetUserIds, ",")), }, }, ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ @@ -628,7 +632,7 @@ func (g *GroupConfigBiz) shouldCreateIssueHandlingGroup(ctx context.Context, rec OpenSpaceId: tea.String("dtv1.card//im_group." + callback.ConversationId), ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ RobotCode: tea.String(callback.RobotCode), - Recipients: append(userIds, tea.String(callback.SenderStaffId)), + Recipients: recipientsUsers, }, }) if err != nil { diff --git a/internal/services/dtalk_bot_test.go b/internal/services/dtalk_bot_test.go index 0053382..34e3836 100644 --- a/internal/services/dtalk_bot_test.go +++ b/internal/services/dtalk_bot_test.go @@ -126,7 +126,8 @@ func run() { qywxAppBiz := biz.NewQywxAppBiz(configConfig, botGroupQywxImpl, group, other) groupConfigBiz := biz.NewGroupConfigBiz(toolRegis, utils_ossClient, botGroupConfigImpl, botConfigImpl, registry, configConfig, impl.NewReportDailyCacheImpl(db), rdb, manager, cardClient) macro := do.NewMacro(botGroupImpl, impl.NewReportDailyCacheImpl(db)) - dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, botGroupConfigImpl, user, botChatHisImpl, impl.NewReportDailyCacheImpl(db), manager, configConfig, sendCardClient, groupConfigBiz, macro, oauth2Client, oldClient, cardClient) + issueImpl := impl.NewIssueImpl(db) + dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, botGroupConfigImpl, user, botChatHisImpl, botUserImpl, impl.NewReportDailyCacheImpl(db), manager, configConfig, sendCardClient, groupConfigBiz, macro, oauth2Client, oldClient, cardClient, rdb, issueImpl, sysImpl) // 初始化钉钉机器人服务 cronService = NewCronService(configConfig, dingTalkBotBiz, qywxAppBiz, groupConfigBiz) } From 7e71ad52a42e116e879dbfa42c72992456d1753b Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 2 Feb 2026 17:27:24 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix:=201.=E5=A2=9E=E5=8A=A0env=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=202.=E5=A2=9E=E5=8A=A0=E4=BB=BB=E5=8A=A1=E5=90=8E?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 6 ++++++ config/config_env.yaml | 17 ++++++++++++++++- internal/server/cron.go | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 806c43d..a589006 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ func main() { configPath := flag.String("config", "./config/config_test.yaml", "Path to configuration file") onBot := flag.String("bot", "", "bot start") cron := flag.String("cron", "", "close") + runJob := flag.String("runJob", "", "run single job and exit") flag.Parse() ctx := context.Background() bc, err := config.LoadConfig(*configPath) @@ -33,6 +34,11 @@ func main() { if *cron == "start" { app.Cron.Run(ctx) } + // 运行指定任务并退出 + if *runJob != "" { + app.Cron.RunOnce(ctx, *runJob) + return + } log.Fatal(app.HttpServer.Listen(fmt.Sprintf(":%d", bc.Server.Port))) } diff --git a/config/config_env.yaml b/config/config_env.yaml index 9d07c06..b0f5953 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -88,7 +88,22 @@ tools: zltxOrderAfterSaleResellerBatch: enabled: true base_url: "https://gateway.dev.cdlsxd.cn/zltx_api/admin/afterSales/reseller_pre_ai" - + weather: + enabled: true + base_url: "https://restapi.amap.com/v3/weather/weatherInfo" + api_key: "12afbde5ab78cb7e575ff76bd0bdef2b" + cozeExpress: + enabled: true + base_url: "https://api.coze.cn" + api_key: "7582477438102552616" + api_secret: "pat_eEN0BdLNDughEtABjJJRYTW71olvDU0qUbfQUeaPc2NnYWO8HeyNoui5aR9z0sSZ" + cozeCompany: + enabled: true + base_url: "https://api.coze.cn" + api_key: "7583905168607100978" + api_secret: "pat_eEN0BdLNDughEtABjJJRYTW71olvDU0qUbfQUeaPc2NnYWO8HeyNoui5aR9z0sSZ" + zltxResellerAuthProductToManagerAndDefaultLossReason: + base_url: "https://revcl.1688sup.com/api/admin/reseller/resellerAuthProduct/getManagerAndDefaultLossReason" # eino tool 配置 eino_tools: # == 货易通 hyt == diff --git a/internal/server/cron.go b/internal/server/cron.go index 066f357..2bd1c8d 100644 --- a/internal/server/cron.go +++ b/internal/server/cron.go @@ -3,6 +3,7 @@ package server import ( "ai_scheduler/internal/services" "context" + "fmt" "github.com/gofiber/fiber/v2/log" "github.com/robfig/cron/v3" @@ -20,6 +21,7 @@ type cronJob struct { EntryId int32 Func func(context.Context) error Name string + Key string Schedule string } @@ -42,11 +44,13 @@ func (c *CronServer) InitJobs(ctx context.Context) { { Func: c.cronService.CronReportSendDingTalk, Name: "直连天下报表推送(钉钉)", + Key: "ding_report_dingtalk", Schedule: "20 12,18,23 * * *", }, { Func: c.cronService.CronReportSendQywx, Name: "直连天下报表推送(微信)", + Key: "ding_report_qywx", Schedule: "20 12,18,23 * * *", }, } @@ -96,3 +100,39 @@ func (c *CronServer) Stop() { c.log.Info("Cron调度器已停止") } } + +func (c *CronServer) RunOnce(ctx context.Context, key string) error { + + if c.jobs == nil { + c.InitJobs(ctx) + } + + // 获取key对应的任务 + var job *cronJob + for _, j := range c.jobs { + if j.Key == key { + job = j + break + } + } + if job == nil { + return fmt.Errorf("unknown job key: %s\n", key) + } + + defer func() { + if r := recover(); r != nil { + fmt.Printf("任务[once]:%s执行时发生panic: %v\n", job.Name, r) + } + fmt.Printf("任务[once]:%s执行结束\n", job.Name) + }() + + fmt.Printf("任务[once]:%s开始执行\n", job.Name) + + err := job.Func(ctx) + if err != nil { + return fmt.Errorf("任务[once]:%s执行失败: %s\n", job.Name, err.Error()) + } + + fmt.Printf("任务[once]:%s执行成功\n", job.Name) + return nil +} From c1971e71c193431012798340258e11ac140a43a3 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 2 Feb 2026 18:31:04 +0800 Subject: [PATCH 09/16] =?UTF-8?q?chore:=20=E5=91=8A=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 9 +++++--- internal/biz/ding_talk_bot.go | 43 +++++++++++++++++++++++++++++------ internal/biz/do/handle.go | 4 ++-- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/config/config_env.yaml b/config/config_env.yaml index b0f5953..08315f4 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -5,9 +5,12 @@ server: ollama: base_url: "http://192.168.6.115:11434" - model: "qwen3-coder:480b-cloud" - generate_model: "qwen3-coder:480b-cloud" - mapping_model: "deepseek-v3.2:cloud" + model: "qwen3:8b" + generate_model: "qwen3:8b" + mapping_model: "qwen3:8b" + # model: "qwen3-coder:480b-cloud" + # generate_model: "qwen3-coder:480b-cloud" + # mapping_model: "deepseek-v3.2:cloud" vl_model: "qwen2.5vl:7b" timeout: "120s" level: "info" diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 5fbd711..5ae8f27 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -283,12 +283,31 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat } } + // 4. 匹配问题类型 + var issueType model.AiIssueType + for _, it := range allIssueTypes { + if it.Name == classification.IssueTypeName { + issueType = it + break + } + } + if sys.SysID == 0 { - // 无法明确系统,询问用户 - statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - d.redisCli.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) - entitys.ResText(requireData.Ch, "", "抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您安排对应的技术支持。") - return nil + + // 判断全局是否存在该规则 + _, found, _ := d.issueImpl.IssueAssignRule.FindOne( + d.issueImpl.WithSysID(0), + d.issueImpl.WithIssueTypeID(issueType.ID), + d.issueImpl.WithStatus(1), + ) + if !found { + // 无法明确系统,且全局无该能力,询问用户 + statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) + d.redisCli.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) + entitys.ResText(requireData.Ch, "", "抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您安排对应的技术支持。") + return nil + } + } return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) @@ -323,8 +342,18 @@ func (d *DingTalkBotBiz) fallbackToGroupCreationWithSys(ctx context.Context, req d.issueImpl.WithStatus(1), ) if !found { - log.Errorf("assign rule not found for sys %s and issue type %s; err: %v", sys.SysName, issueType.Name, err) - return fmt.Errorf("分配规则 %s-%s 不存在", sys.SysName, issueType.Name) + // 创建默认分配规则 - 暂不考虑并发,有唯一索引 + rule = model.AiIssueAssignRule{ + SysID: sys.SysID, + IssueTypeID: issueType.ID, + Status: 1, + } + if err := d.issueImpl.IssueAssignRule.Create(&rule); err != nil { + log.Errorf("create assign rule for sys %s and issue type %s failed; err: %v", sys.SysName, issueType.Name, err) + return fmt.Errorf("创建分配规则 %s-%s 失败", sys.SysName, issueType.Name) + } else { + log.Infof("create assign rule for sys %s and issue type %s success; rule id: %d", sys.SysName, issueType.Name, rule.ID) + } } var staffIds []string diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 9b705a8..6508a69 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -137,10 +137,10 @@ func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes 可用问题类型: [%s] 请仅以 JSON 格式回复,包含以下字段: -- sys_name: 系统名称 +- sys_name: 系统名称,若未提及系统关键词,则为"全局" - issue_type_name: 问题类型名称 - summary: 15字以内的问题简述(用于群命名) -- reason: 分类理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) +- reason: 分类判断理由;系统名称判断理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) messages := []api.Message{ {Role: "system", Content: systemPrompt}, From c174ab683adbdf50024fa76c94aa3fc9fabb0052 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Tue, 3 Feb 2026 18:50:51 +0800 Subject: [PATCH 10/16] =?UTF-8?q?fix=EF=BC=9A1.=E8=B0=83=E6=95=B4=E9=92=89?= =?UTF-8?q?=E9=92=89=E5=8D=95=E8=81=8A=E9=97=AE=E9=A2=98=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E8=B4=9F=E8=B4=A3=E4=BA=BA=E6=95=B4=E4=BD=93=E9=80=BB=E8=BE=91?= =?UTF-8?q?=202.=E5=A2=9E=E5=8A=A0=E6=9C=BA=E5=99=A8=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E5=9B=9E=E5=A4=8D=203.=20=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=8C=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 259 +++++++++++++++++---------------- internal/biz/do/handle.go | 18 ++- internal/biz/group_config.go | 2 +- internal/data/impl/bot_user.go | 6 +- 4 files changed, 155 insertions(+), 130 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 5ae8f27..7f7a251 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -8,6 +8,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/entitys" "ai_scheduler/internal/pkg" "ai_scheduler/internal/tools" @@ -162,22 +163,27 @@ func (d *DingTalkBotBiz) Do(ctx context.Context, requireData *entitys.RequireDat // 先不接意图识别-仅提供问题处理 func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) { // 1. 获取用户信息 - requireData.UserInfo, err = d.dingTalkUser.GetUserInfoFromBot(ctx, requireData.Req.SenderStaffId, dingtalk.WithId(1)) + user, err := d.botUserImpl.GetByStaffId(requireData.Req.SenderStaffId) if err != nil { return } - requireData.ID = int32(requireData.UserInfo.UserId) + requireData.ID = int32(user.UserID) + requireData.UserInfo = &entitys.DingTalkUserInfo{ + UserId: int(user.UserID), + StaffId: user.StaffID, + Name: user.Name, + } // 2. 检查会话状态 (Redis) - statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - status, _ := d.redisCli.Get(ctx, statusKey).Result() + // statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) + // status, _ := d.redisCli.Get(ctx, statusKey).Result() - if status == "WAITING_FOR_SYS_CONFIRM" { - // 用户回复了系统名称 - sysName := requireData.Req.Text.Content - d.redisCli.Del(ctx, statusKey) - return d.handleWithSpecificSys(ctx, requireData, sysName) - } + // if status == "WAITING_FOR_SYS_CONFIRM" { + // // 用户回复了系统名称 + // sysName := requireData.Req.Text.Content + // d.redisCli.Del(ctx, statusKey) + // return d.handleWithSpecificSys(ctx, requireData, sysName) + // } // 3. 获取历史记录 (最近6轮用户输入) userHist, err := d.getRecentUserHistory(ctx, constants.ConversationTypeSingle, requireData.ID, 6) @@ -192,76 +198,96 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti queryText = rewrittenQuery } - // 构造识别对象 - rec := &entitys.Recognize{ - Ch: requireData.Ch, - SystemPrompt: d.defaultPrompt(), - UserContent: &entitys.RecognizeUserContent{ - Text: queryText, - }, - } - - // 5. 调用知识库 - isRetrieved, err := d.groupConfigBiz.handleKnowledge(ctx, rec, nil, requireData.Req) + // 分类 + resolveResult, err := d.resolveSystemAndIssueType(ctx, requireData, queryText) if err != nil { return err } + switch resolveResult.IssueType.Code { + case "knowledge_qa": + // 知识库问答 + return d.handleKnowledgeQA(ctx, requireData, queryText, resolveResult) + default: // 其他问题类型 + // 系统为空,再次询问 + if resolveResult.Sys.SysID == 0 { + entitys.ResText(requireData.Ch, "", "\n抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您准确解答或安排对应的技术支持。") + return nil + } + return d.fallbackToGroupCreation(ctx, requireData, resolveResult) + } +} + +// 知识库问答 +func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, queryText string, resolveResult *resolveSystemAndIssueTypeResult) error { + // 获取租户ID + tenantId := constants.KnowledgeTenantIdDefault + if resolveResult.Sys.KnowlegeTenantKey != "" { + tenantId = resolveResult.Sys.KnowlegeTenantKey + } + + // 获取知识库结果 + isRetrieved, err := d.getKnowledgeAnswer(ctx, requireData, tenantId, queryText) + if err != nil { + return err + } if isRetrieved { return nil } - // 6. Fallback: 分类 -> 规则 -> 拉群 - return d.fallbackToGroupCreation(ctx, requireData) -} - -// handleWithSpecificSys 处理用户明确指定的系统 -func (d *DingTalkBotBiz) handleWithSpecificSys(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, sysName string) error { - // 1. 查找系统 - var sys model.AiSy - cond := builder.NewCond().And(builder.Eq{"sys_name": sysName}) - err := d.sysImpl.GetOneBySearchToStrut(&cond, &sys) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - entitys.ResText(requireData.Ch, "", "抱歉,我还是没有找到名为“"+sysName+"”的系统。请联系管理员确认系统名称。") - return nil - } - return err + // 未匹配&全局 -> 明确具体系统 + if !isRetrieved && resolveResult.Sys.SysID == 0 { + entitys.ResText(requireData.Ch, "", "\n抱歉,知识库未命中,无法回答您的问题。\n若您的问题是某一具体系统的,请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您准确解答。") + return nil + } + // 未匹配&指定系统 -> 拉群卡片 + if !isRetrieved && resolveResult.Sys.SysID != 0 { + entitys.ResText(requireData.Ch, "", fmt.Sprintf("\n抱歉,%s知识库未命中,无法回答您的问题。即将为您创建群聊解答。", resolveResult.Sys.SysName)) + return d.fallbackToGroupCreation(ctx, requireData, resolveResult) } - // 2. 既然已经明确了系统,直接尝试拉群(这里假设问题类型为“其他”或由LLM再次分析) - // 为简化,这里再次调用分类逻辑,但带上已确定的系统 - return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) + return nil } -// getRecentUserHistory 获取最近的用户输入历史 -func (d *DingTalkBotBiz) getRecentUserHistory(ctx context.Context, conversationType constants.ConversationType, id int32, limit int) ([]model.AiBotChatHi, error) { - var his []model.AiBotChatHi - cond := builder.NewCond(). - And(builder.Eq{"his_type": conversationType}). - And(builder.Eq{"id": id}). - And(builder.Eq{"role": "user"}) - - _, err := d.chatHis.GetListToStruct(&cond, &dataTemp.ReqPageBo{Limit: limit}, &his, "his_id desc") +// 获取知识库问答结果 +func (d *DingTalkBotBiz) getKnowledgeAnswer(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, tenantId string, queryText string) (bool, error) { + // 请求知识库工具 + knowledgeBase := knowledge_base.New(d.conf.KnowledgeConfig) + knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ + TenantID: tenantId, // 后续动态接参 + Query: queryText, + Mode: constants.KnowledgeModeMix, + Stream: true, + Think: false, + OnlyRAG: true, + }) if err != nil { - return nil, err + return false, fmt.Errorf("请求知识库工具失败,err: %v", err) } - return his, nil + + // 读取知识库SSE数据 + return d.groupConfigBiz.readKnowledgeSSE(knowledgeResp, requireData.Ch, true) } -// fallbackToGroupCreation 分类并拉群 -func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) error { +type resolveSystemAndIssueTypeResult struct { + Sys model.AiSy + IssueType model.AiIssueType + Classification *do.IssueClassification +} + +// 解析系统和问题类型 +func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, queryText string) (*resolveSystemAndIssueTypeResult, error) { // 1. 获取所有系统和问题类型用于分类 allSys, err := d.sysImpl.FindAll() if err != nil { - return err + return nil, err } sysNames := slice.Map(allSys, func(_ int, sys model.AiSy) string { return sys.SysName }) allIssueTypes, err := d.issueImpl.IssueType.FindAll() if err != nil { - return err + return nil, err } issueTypeNames := slice.Map(allIssueTypes, func(_ int, it model.AiIssueType) string { return it.Name @@ -270,8 +296,7 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat // 2. LLM 分类 classification, err := d.handle.ClassifyIssue(ctx, sysNames, issueTypeNames, requireData.Req.Text.Content) if err != nil { - // 分类失败,使用兜底 - return d.createDefaultGroup(ctx, requireData, "系统无法识别") + return nil, err } // 3. 匹配系统 @@ -292,68 +317,58 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat } } - if sys.SysID == 0 { - - // 判断全局是否存在该规则 - _, found, _ := d.issueImpl.IssueAssignRule.FindOne( - d.issueImpl.WithSysID(0), - d.issueImpl.WithIssueTypeID(issueType.ID), - d.issueImpl.WithStatus(1), - ) - if !found { - // 无法明确系统,且全局无该能力,询问用户 - statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - d.redisCli.Set(ctx, statusKey, "WAITING_FOR_SYS_CONFIRM", time.Hour) - entitys.ResText(requireData.Ch, "", "抱歉,我无法确定您咨询的是哪个系统。请告诉我具体系统名称(如:直连天下系统、货易通系统),以便我为您安排对应的技术支持。") - return nil - } - - } - - return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) + return &resolveSystemAndIssueTypeResult{ + Sys: sys, + IssueType: issueType, + Classification: classification, + }, nil } -// fallbackToGroupCreationWithSys 在已知系统的情况下进行分类并拉群 -func (d *DingTalkBotBiz) fallbackToGroupCreationWithSys(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, sys *model.AiSy) error { - // 1. 获取所有问题类型 - allIssueTypes, err := d.issueImpl.IssueType.FindAll() - if err != nil { - return err - } - issueTypeNames := slice.Map(allIssueTypes, func(_ int, it model.AiIssueType) string { - return it.Name - }) +// handleWithSpecificSys 处理用户明确指定的系统 +// func (d *DingTalkBotBiz) handleWithSpecificSys(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, sysName string) error { +// // 1. 查找系统 +// var sys model.AiSy +// cond := builder.NewCond().And(builder.Eq{"sys_name": sysName}) +// err := d.sysImpl.GetOneBySearchToStrut(&cond, &sys) +// if err != nil { +// if errors.Is(err, sql.ErrNoRows) { +// entitys.ResText(requireData.Ch, "", "抱歉,我还是没有找到名为“"+sysName+"”的系统。请联系管理员确认系统名称。") +// return nil +// } +// return err +// } - // 2. LLM 再次分类(确定问题类型和简述) - classification, err := d.handle.ClassifyIssue(ctx, []string{sys.SysName}, issueTypeNames, requireData.Req.Text.Content) - if err != nil { - return d.createDefaultGroup(ctx, requireData, "问题类型识别失败") - } - issueType, found, err := d.issueImpl.IssueType.FindOne(d.issueImpl.WithName(classification.IssueTypeName)) - if !found { - log.Errorf("issue type %s not found; err: %v", classification.IssueTypeName, err) - return fmt.Errorf("问题类型 %s 不存在", classification.IssueTypeName) - } +// // 2. 既然已经明确了系统,直接尝试拉群(这里假设问题类型为“其他”或由LLM再次分析) +// // 为简化,这里再次调用分类逻辑,但带上已确定的系统 +// return d.fallbackToGroupCreationWithSys(ctx, requireData, &sys) +// } - // 3. 查找分配规则 - rule, found, err := d.issueImpl.IssueAssignRule.FindOne( - d.issueImpl.WithSysID(sys.SysID), - d.issueImpl.WithIssueTypeID(issueType.ID), +// getRecentUserHistory 获取最近的用户输入历史 +func (d *DingTalkBotBiz) getRecentUserHistory(ctx context.Context, conversationType constants.ConversationType, id int32, limit int) ([]model.AiBotChatHi, error) { + var his []model.AiBotChatHi + cond := builder.NewCond(). + And(builder.Eq{"his_type": conversationType}). + And(builder.Eq{"id": id}). + And(builder.Eq{"role": "user"}) + + _, err := d.chatHis.GetListToStruct(&cond, &dataTemp.ReqPageBo{Limit: limit}, &his, "his_id desc") + if err != nil { + return nil, err + } + return his, nil +} + +// 在已知系统&问题类型的情况下进行分类并拉群 +func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, resolveResult *resolveSystemAndIssueTypeResult) error { + // 查找分配规则 + rule, found, _ := d.issueImpl.IssueAssignRule.FindOne( + d.issueImpl.WithSysID(resolveResult.Sys.SysID), + d.issueImpl.WithIssueTypeID(resolveResult.IssueType.ID), d.issueImpl.WithStatus(1), ) if !found { - // 创建默认分配规则 - 暂不考虑并发,有唯一索引 - rule = model.AiIssueAssignRule{ - SysID: sys.SysID, - IssueTypeID: issueType.ID, - Status: 1, - } - if err := d.issueImpl.IssueAssignRule.Create(&rule); err != nil { - log.Errorf("create assign rule for sys %s and issue type %s failed; err: %v", sys.SysName, issueType.Name, err) - return fmt.Errorf("创建分配规则 %s-%s 失败", sys.SysName, issueType.Name) - } else { - log.Infof("create assign rule for sys %s and issue type %s success; rule id: %d", sys.SysName, issueType.Name, rule.ID) - } + entitys.ResText(requireData.Ch, "", fmt.Sprintf("\n抱歉,当前系统未配置路由规则 %s-%s,请联系管理员配置。", resolveResult.Sys.SysName, resolveResult.IssueType.Name)) + return nil } var staffIds []string @@ -392,15 +407,15 @@ func (d *DingTalkBotBiz) fallbackToGroupCreationWithSys(ctx context.Context, req // 合并提问者 staffIds = append([]string{requireData.Req.SenderStaffId}, staffIds...) - // 4. 发送确认卡片 - groupName := fmt.Sprintf("[%s]-%s", classification.IssueTypeName, classification.Summary) + // 发送确认卡片 + groupName := fmt.Sprintf("[%s]-%s", resolveResult.IssueType.Name, resolveResult.Classification.Summary) return d.SendGroupCreationConfirmCard(ctx, &SendGroupCreationConfirmCardParams{ RobotCode: requireData.Req.RobotCode, ConversationId: requireData.Req.ConversationId, SenderStaffId: requireData.Req.SenderStaffId, UserIds: staffIds, GroupName: groupName, - Summary: classification.Summary, + Summary: resolveResult.Classification.Summary, }) } @@ -759,7 +774,7 @@ func (d *DingTalkBotBiz) SendGroupCreationConfirmCard(ctx context.Context, param } // 构建卡片 OutTrackId - outTrackId := constants.BuildCardOutTrackId(params.ConversationId, params.RobotCode) + outTrackId := constants.BuildCardOutTrackId(params.SenderStaffId, params.RobotCode) // 准备可见人员列表 var recipients []*string @@ -804,13 +819,13 @@ func (d *DingTalkBotBiz) SendGroupCreationConfirmCard(ctx context.Context, param "target_user_ids": tea.String(strings.Join(params.UserIds, ",")), }, }, - ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ - SupportForward: tea.Bool(false), + OpenSpaceId: tea.String("dtv1.card//IM_ROBOT." + params.SenderStaffId), + ImRobotOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImRobotOpenDeliverModel{ + SpaceType: tea.String("IM_ROBOT"), + RobotCode: tea.String(params.RobotCode), }, - OpenSpaceId: tea.String("dtv1.card//im_group." + params.ConversationId), - ImGroupOpenDeliverModel: &card_1_0.CreateAndDeliverRequestImGroupOpenDeliverModel{ - RobotCode: tea.String(params.RobotCode), - Recipients: recipients, + ImRobotOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImRobotOpenSpaceModel{ + SupportForward: tea.Bool(false), }, }) return err diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 6508a69..588f57c 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -132,15 +132,23 @@ type IssueClassification struct { // ClassifyIssue 问题分类分析 func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes []string, userInput string) (*IssueClassification, error) { - systemPrompt := fmt.Sprintf(`你是一个技术支持路由专家。请分析用户的输入,并将其归类到最合适的系统和问题类型中。 + systemPrompt := fmt.Sprintf(`## 角色 +你是一个技术支持路由专家。输出必须是 JSON 格式。 +## 任务 +请分析用户的输入,并将其归类到最合适的系统和问题类型中。 +- 系统名称:必须是可用系统列表中的一个,若未提及可用系统关键词,则为"全局",不要臆想! +- 问题类型名称:必须是可用问题类型列表中的一个,若未提及可用问题类型关键词,则为空字符串 +- 问题简述:15字以内的问题简述(用于群聊命名) +- 分类判断理由:对系统名称和问题类型名称的判断理由 +## 背景与材料: 可用系统列表: [%s] 可用问题类型: [%s] - +## 输出 请仅以 JSON 格式回复,包含以下字段: -- sys_name: 系统名称,若未提及系统关键词,则为"全局" +- sys_name: 系统名称 - issue_type_name: 问题类型名称 -- summary: 15字以内的问题简述(用于群命名) -- reason: 分类判断理由;系统名称判断理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) +- summary: 问题简述 +- reason: 分类判断理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) messages := []api.Message{ {Role: "system", Content: systemPrompt}, diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index 102ea84..ce7589f 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -542,7 +542,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit // 知识库未命中 输出提示后中断 if delta.XRagStatus == constants.KnowledgeRagStatusMiss { - var missContent string = "知识库未检测到匹配信息,即将为您创建群聊解决问题。" + var missContent string = "知识库未检测到匹配信息。" entitys.ResStream(channel, "", missContent) return false, nil } diff --git a/internal/data/impl/bot_user.go b/internal/data/impl/bot_user.go index ce47281..3a8f5fd 100644 --- a/internal/data/impl/bot_user.go +++ b/internal/data/impl/bot_user.go @@ -31,8 +31,10 @@ func (k BotUserImpl) GetByStaffId(staffId string) (*model.AiBotUser, error) { func (k BotUserImpl) GetByUserIds(userIds []int32) ([]model.AiBotUser, error) { var data []model.AiBotUser cond := builder.NewCond() - cond = cond.And(builder.In("user_id", userIds)) - _, err := k.GetListToStruct2(&cond, nil, &data) + for _, userId := range userIds { + cond = cond.Or(builder.Eq{"user_id": userId}) + } + _, err := k.GetListToStruct(&cond, nil, &data, "user_id") return data, err } From 719fd805e69c92ebfb3acecefcc47b170c7a9ebf Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 4 Feb 2026 11:20:11 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix:=201.=E8=B0=83=E6=95=B4=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E7=94=A8=E6=B3=95=202.=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9B=B8=E5=85=B3=E5=B8=B8=E9=87=8F=203.=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=94=B9=E4=B8=8B=E7=9F=A5=E8=AF=86=E5=BA=93Query?= =?UTF-8?q?=E7=94=A8=E6=B3=95=204.=E5=A2=9E=E5=8A=A0=E5=8F=8B=E5=A5=BD?= =?UTF-8?q?=E8=BE=93=E5=87=BA=205.=E8=B0=83=E6=95=B4=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 69 +++++++++++++++++----------------- internal/biz/do/handle.go | 59 ++++++++++++++++++++--------- internal/data/constants/bot.go | 8 ++++ 3 files changed, 84 insertions(+), 52 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 7f7a251..d009ade 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -161,11 +161,11 @@ func (d *DingTalkBotBiz) Do(ctx context.Context, requireData *entitys.RequireDat // handleSingleChat 单聊处理 // 先不接意图识别-仅提供问题处理 -func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) { +func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) error { // 1. 获取用户信息 user, err := d.botUserImpl.GetByStaffId(requireData.Req.SenderStaffId) if err != nil { - return + return err } requireData.ID = int32(user.UserID) requireData.UserInfo = &entitys.DingTalkUserInfo{ @@ -174,40 +174,24 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti Name: user.Name, } - // 2. 检查会话状态 (Redis) - // statusKey := fmt.Sprintf("ai_bot:session:status:%s", requireData.Req.SenderStaffId) - // status, _ := d.redisCli.Get(ctx, statusKey).Result() - - // if status == "WAITING_FOR_SYS_CONFIRM" { - // // 用户回复了系统名称 - // sysName := requireData.Req.Text.Content - // d.redisCli.Del(ctx, statusKey) - // return d.handleWithSpecificSys(ctx, requireData, sysName) - // } - - // 3. 获取历史记录 (最近6轮用户输入) - userHist, err := d.getRecentUserHistory(ctx, constants.ConversationTypeSingle, requireData.ID, 6) + // 2. 获取历史记录 (最近6轮用户输入) + userHist, err := d.getRecentUserHistory(ctx, constants.ConversationTypeSingle, requireData.ID, d.conf.Sys.SessionLen) if err != nil { return err } - // 4. 改写 Query (Query Rewriting) - rewrittenQuery, err := d.handle.RewriteQuery(ctx, userHist, requireData.Req.Text.Content) - var queryText = requireData.Req.Text.Content - if err == nil && rewrittenQuery != "" { - queryText = rewrittenQuery - } - - // 分类 - resolveResult, err := d.resolveSystemAndIssueType(ctx, requireData, queryText) + // 3. 系统&问题分类(意图识别阶段) + resolveResult, err := d.resolveSystemAndIssueType(ctx, requireData, userHist) if err != nil { return err } + log.Debugf("系统&分类结果: %s - %s", resolveResult.Sys.SysName, resolveResult.IssueType.Name) + // 4. 分类处理(后续考虑接各自的工作流/agent) switch resolveResult.IssueType.Code { - case "knowledge_qa": + case constants.IssueTypeKnowledgeQA: // 知识库问答 - return d.handleKnowledgeQA(ctx, requireData, queryText, resolveResult) + return d.handleKnowledgeQA(ctx, requireData, userHist, resolveResult) default: // 其他问题类型 // 系统为空,再次询问 if resolveResult.Sys.SysID == 0 { @@ -219,13 +203,21 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti } // 知识库问答 -func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, queryText string, resolveResult *resolveSystemAndIssueTypeResult) error { +func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, userHist []model.AiBotChatHi, resolveResult *resolveSystemAndIssueTypeResult) error { // 获取租户ID tenantId := constants.KnowledgeTenantIdDefault if resolveResult.Sys.KnowlegeTenantKey != "" { tenantId = resolveResult.Sys.KnowlegeTenantKey } + // 改写 Query (Query Rewriting) + rewrittenQuery, err := d.handle.RewriteQuery(ctx, userHist, requireData.Req.Text.Content) + var queryText = requireData.Req.Text.Content + if err == nil && rewrittenQuery != "" { + queryText = rewrittenQuery + } + log.Debugf("改写前后的Query: %s -> %s", requireData.Req.Text.Content, queryText) + // 获取知识库结果 isRetrieved, err := d.getKnowledgeAnswer(ctx, requireData, tenantId, queryText) if err != nil { @@ -276,7 +268,7 @@ type resolveSystemAndIssueTypeResult struct { } // 解析系统和问题类型 -func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, queryText string) (*resolveSystemAndIssueTypeResult, error) { +func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, userHist []model.AiBotChatHi) (*resolveSystemAndIssueTypeResult, error) { // 1. 获取所有系统和问题类型用于分类 allSys, err := d.sysImpl.FindAll() if err != nil { @@ -294,7 +286,7 @@ func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireD }) // 2. LLM 分类 - classification, err := d.handle.ClassifyIssue(ctx, sysNames, issueTypeNames, requireData.Req.Text.Content) + classification, err := d.handle.ClassifyIssue(ctx, sysNames, issueTypeNames, requireData.Req.Text.Content, userHist) if err != nil { return nil, err } @@ -360,6 +352,8 @@ func (d *DingTalkBotBiz) getRecentUserHistory(ctx context.Context, conversationT // 在已知系统&问题类型的情况下进行分类并拉群 func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, resolveResult *resolveSystemAndIssueTypeResult) error { + entitys.ResText(requireData.Ch, "", fmt.Sprintf("\n检测到您想咨询 %s-%s 问题。", resolveResult.Sys.SysName, resolveResult.IssueType.Name)) + // 查找分配规则 rule, found, _ := d.issueImpl.IssueAssignRule.FindOne( d.issueImpl.WithSysID(resolveResult.Sys.SysID), @@ -371,7 +365,7 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat return nil } - var staffIds []string + var groupMember, groupMemberName []string if rule.ID != 0 { // 获取配置的用户 assignUsers, err := d.issueImpl.IssueAssignUser.FindAll(d.issueImpl.WithRuleID(rule.ID)) @@ -394,18 +388,23 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat return bu.UserID == au.UserID }) if found && botUser.StaffID != "" { - staffIds = append(staffIds, botUser.StaffID) + groupMember = append(groupMember, botUser.StaffID) + groupMemberName = append(groupMemberName, "@"+botUser.Name) } } } // 兜底处理人 - if len(staffIds) == 0 { - staffIds = []string{"17415698414368678"} + if len(groupMember) == 0 { + groupMember = []string{"17415698414368678"} } // 合并提问者 - staffIds = append([]string{requireData.Req.SenderStaffId}, staffIds...) + groupMember = append([]string{requireData.Req.SenderStaffId}, groupMember...) + groupMember = slice.Unique(groupMember) + + // 先回复用户 + entitys.ResText(requireData.Ch, "", fmt.Sprintf("\n已检索到处理人\n%s\n是否创建群聊?", strings.Join(groupMemberName, "、"))) // 发送确认卡片 groupName := fmt.Sprintf("[%s]-%s", resolveResult.IssueType.Name, resolveResult.Classification.Summary) @@ -413,7 +412,7 @@ func (d *DingTalkBotBiz) fallbackToGroupCreation(ctx context.Context, requireDat RobotCode: requireData.Req.RobotCode, ConversationId: requireData.Req.ConversationId, SenderStaffId: requireData.Req.SenderStaffId, - UserIds: staffIds, + UserIds: groupMember, GroupName: groupName, Summary: resolveResult.Classification.Summary, }) diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 588f57c..3401f0c 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -131,27 +131,52 @@ type IssueClassification struct { } // ClassifyIssue 问题分类分析 -func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes []string, userInput string) (*IssueClassification, error) { +func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { systemPrompt := fmt.Sprintf(`## 角色 -你是一个技术支持路由专家。输出必须是 JSON 格式。 -## 任务 -请分析用户的输入,并将其归类到最合适的系统和问题类型中。 -- 系统名称:必须是可用系统列表中的一个,若未提及可用系统关键词,则为"全局",不要臆想! -- 问题类型名称:必须是可用问题类型列表中的一个,若未提及可用问题类型关键词,则为空字符串 -- 问题简述:15字以内的问题简述(用于群聊命名) -- 分类判断理由:对系统名称和问题类型名称的判断理由 -## 背景与材料: -可用系统列表: [%s] -可用问题类型: [%s] -## 输出 -请仅以 JSON 格式回复,包含以下字段: -- sys_name: 系统名称 -- issue_type_name: 问题类型名称 -- summary: 问题简述 -- reason: 分类判断理由`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) +你是一个技术支持路由专家。你的核心能力是通过深度语义分析和上下文回溯,将碎片化的用户输入通过时间先后拼接成完整的意图。输出必须是严格的 JSON 格式。 + +## 核心推理引擎(关键逻辑) + +请执行以下三步推理,不要只看当前这一句话: + +1. **构建完整意图**: + * 将“当前输入”与“历史对话”合并视为用户的完整诉求。 + * **特殊规则**:如果“当前输入”仅包含一个系统名称(例如用户只输入了“CRM”),这被视为**“上下文补充”**,而非新问题。此时,**必须保留最近历史对话中已识别的问题类型**。 + +2. **系统判定 (sys_name)** + * **策略**:覆盖式更新。 + * 如果当前输入提到了系统A,则 sys_name = 系统A(不管历史是什么)。 + * 如果当前输入未提系统,但历史有,则继承最近历史。 + * 如果都无,设为 "全局"。 + +3. **问题类型判定 (issue_type_name)** + * **策略**:回溯与推断。 + * **核心原则**:基于合并后的完整意图进行分析。推断出最近历史对话中的问题类型。 + * **严禁清空**:除非用户是在闲聊(如“你好”),否则绝不允许为空。如果当前句没提问题,但历史有,必须继承历史的 issue_type_name。 + +## 背景数据 +- 可用系统列表: [%s] +- 可用问题类型: [%s] + +## 输出格式 +{ + "sys_name": "系统名称", + "issue_type_name": "问题类型名称", + "summary": "15字内标题", + "reason": "1. 系统:说明来源(当前/历史/默认)。2. 问题类型:说明是基于哪句话推断的,或说明是继承了历史意图。" +}`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) + + historyStr := strings.Builder{} + historyStr.WriteString("### 历史对话:\n") + for _, h := range userHist { + if h.Role == "user" { + historyStr.WriteString(fmt.Sprintf("%s:%s\n", h.CreateAt, h.Content)) + } + } messages := []api.Message{ {Role: "system", Content: systemPrompt}, + {Role: "assistant", Content: historyStr.String()}, {Role: "user", Content: userInput}, } diff --git a/internal/data/constants/bot.go b/internal/data/constants/bot.go index 8516626..7dba0fa 100644 --- a/internal/data/constants/bot.go +++ b/internal/data/constants/bot.go @@ -45,3 +45,11 @@ const ( PermissionTypeNone = 1 PermissionTypeDept = 2 ) + +// IssueType 问题类型 +const ( + IssueTypeKnowledgeQA = "knowledge_qa" // 知识问答 + IssueTypeUI = "ui" // UI需求 + IssueTypeBug = "bug" // Bug + IssueTypeDemand = "demand" // 开发需求 +) From ec41a3d78729a3603fa1aac8f4d08360ae8a7885 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 4 Feb 2026 15:35:11 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E7=BE=A4?= =?UTF-8?q?=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index d009ade..5b53eca 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -816,6 +816,7 @@ func (d *DingTalkBotBiz) SendGroupCreationConfirmCard(ctx context.Context, param "button_display": tea.String("true"), "group_scope": tea.String(params.Summary), "target_user_ids": tea.String(strings.Join(params.UserIds, ",")), + "group_name": tea.String(params.GroupName), }, }, OpenSpaceId: tea.String("dtv1.card//IM_ROBOT." + params.SenderStaffId), @@ -882,7 +883,11 @@ func (d *DingTalkBotBiz) createIssueHandlingGroupAndInit(ctx context.Context, ca appKey.AccessToken = accessToken // 创建群聊 - _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, "问题处理群", userIds) + var groupName string + if s, ok := callbackParams["group_name"].(string); ok { + groupName = s + } + _, openConversationId, err := d.createIssueHandlingGroup(ctx, accessToken, groupName, userIds) if err != nil { return err } From c9c9bca9cedbe573d0c7480640e89709bfdc6aff Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 4 Feb 2026 16:52:23 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=88=A4=E6=96=AD=E4=B8=8E=E9=97=AE=E9=A2=98=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 15 ++++- internal/biz/do/handle.go | 103 ++++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 5b53eca..0a74b22 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -286,10 +286,23 @@ func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireD }) // 2. LLM 分类 - classification, err := d.handle.ClassifyIssue(ctx, sysNames, issueTypeNames, requireData.Req.Text.Content, userHist) + // 系统名称 + classificationSys, err := d.handle.ClassifyIssueSystem(ctx, sysNames, requireData.Req.Text.Content, userHist) if err != nil { return nil, err } + // 问题类型 + classificationIssueType, err := d.handle.ClassifyIssueType(ctx, issueTypeNames, requireData.Req.Text.Content, userHist) + if err != nil { + return nil, err + } + // 合并 + classification := &do.IssueClassification{ + SysName: classificationSys.SysName, + IssueTypeName: classificationIssueType.IssueTypeName, + Summary: classificationIssueType.Summary, + Reason: fmt.Sprintf("系统名称推断理由:%s\n问题类型推断理由:%s", classificationSys.Reason, classificationIssueType.Reason), + } // 3. 匹配系统 var sys model.AiSy diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 3401f0c..4adda02 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -130,41 +130,96 @@ type IssueClassification struct { Reason string `json:"reason"` } -// ClassifyIssue 问题分类分析 -func (r *Handle) ClassifyIssue(ctx context.Context, systems []string, issueTypes []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { +// ClassifyIssueSys 问题系统分析 +func (r *Handle) ClassifyIssueSystem(ctx context.Context, systems []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { systemPrompt := fmt.Sprintf(`## 角色 -你是一个技术支持路由专家。你的核心能力是通过深度语义分析和上下文回溯,将碎片化的用户输入通过时间先后拼接成完整的意图。输出必须是严格的 JSON 格式。 +你是一个系统类型判定专家。你的唯一任务是基于多轮对话识别用户当前讨论的系统(sys_name)。不需要输出问题类型。输出必须严格遵守 JSON 格式。 -## 核心推理引擎(关键逻辑) +## 推理规则 -请执行以下三步推理,不要只看当前这一句话: +1. 系统判定逻辑: + - 当前输入明确提到系统 → 直接覆盖历史系统 + - 当前输入未提系统,但历史对话有 → 继承最近历史系统 + - 当前输入和历史均未出现 → "全局" -1. **构建完整意图**: - * 将“当前输入”与“历史对话”合并视为用户的完整诉求。 - * **特殊规则**:如果“当前输入”仅包含一个系统名称(例如用户只输入了“CRM”),这被视为**“上下文补充”**,而非新问题。此时,**必须保留最近历史对话中已识别的问题类型**。 - -2. **系统判定 (sys_name)** - * **策略**:覆盖式更新。 - * 如果当前输入提到了系统A,则 sys_name = 系统A(不管历史是什么)。 - * 如果当前输入未提系统,但历史有,则继承最近历史。 - * 如果都无,设为 "全局"。 - -3. **问题类型判定 (issue_type_name)** - * **策略**:回溯与推断。 - * **核心原则**:基于合并后的完整意图进行分析。推断出最近历史对话中的问题类型。 - * **严禁清空**:除非用户是在闲聊(如“你好”),否则绝不允许为空。如果当前句没提问题,但历史有,必须继承历史的 issue_type_name。 +2. 特殊规则: + - 如果当前输入仅包含系统名称(如“CRM”),视为系统上下文补充,仅更新 sys_name,不做其他推断 ## 背景数据 -- 可用系统列表: [%s] -- 可用问题类型: [%s] +可用系统列表:[%s] ## 输出格式 { "sys_name": "系统名称", + "reason": "说明系统来源:当前输入 / 历史继承 / 默认" +} +`, strings.Join(systems, ", ")) + + historyStr := strings.Builder{} + historyStr.WriteString("### 历史对话:\n") + for _, h := range userHist { + if h.Role == "user" { + historyStr.WriteString(fmt.Sprintf("%s:%s\n", h.CreateAt, h.Content)) + } + } + + messages := []api.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "assistant", Content: historyStr.String()}, + {Role: "user", Content: userInput}, + } + + resp, err := r.Ollama.Chat(ctx, messages) + if err != nil { + return nil, err + } + + // 尝试清理 JSON 内容(有时模型会返回 markdown 块) + resp = strings.TrimPrefix(resp, "```json") + resp = strings.TrimSuffix(resp, "```") + resp = strings.TrimSpace(resp) + + var result IssueClassification + if err := json.Unmarshal([]byte(resp), &result); err != nil { + return nil, fmt.Errorf("解析分类结果失败: %w, 原文: %s", err, resp) + } + + return &result, nil +} + +// ClassifyIssueType 问题分类分析 +func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { + systemPrompt := fmt.Sprintf(`## 角色 +你是一个业务问题类型分析专家。你的任务是基于多轮对话识别用户讨论的**问题类型(issue_type_name)**,问题类型必须严格来自可用问题类型列表 [%s]。 + +你不负责系统名称判断。输出必须严格遵守 JSON 格式。 + +## 推理规则 + +1. 构建完整问题意图 + - 将当前输入与历史对话合并理解为完整问题演进 + - 当前输入可能是补充条件、追问、修正或只给模块名/报错片段 + - 不要只看当前一句 + +2. 问题类型判定逻辑 + - 当前输入明确匹配列表中某个类型 → 使用该类型 + - 当前输入未明确,但历史已有 → 继承历史类型 + - 当前输入未匹配,历史也没有 → 选择最接近的列表类型(尽量匹配意图) + - 除非是闲聊(如“你好”“在吗”),禁止返回空值 + +3. 特殊规则 + - 当前输入只包含系统名/模块名/参数名 → 视为问题补充,继承历史 issue_type_name + - 输出必须严格匹配列表中的类型,不允许生成列表外的自造类型 + +## 背景数据 +可用问题类型列表:[%s] + +## 输出格式 +{ "issue_type_name": "问题类型名称", - "summary": "15字内标题", - "reason": "1. 系统:说明来源(当前/历史/默认)。2. 问题类型:说明是基于哪句话推断的,或说明是继承了历史意图。" -}`, strings.Join(systems, ", "), strings.Join(issueTypes, ", ")) + "summary": "15字内问题标题", + "reason": "说明问题类型是基于哪句话判断,或说明继承自历史,继承自哪条历史" +}`, strings.Join(issueTypes, ", "), strings.Join(issueTypes, ", ")) historyStr := strings.Builder{} historyStr.WriteString("### 历史对话:\n") From cfeaa6e201126885585b7f0e32340a14e45b8987 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 4 Feb 2026 16:53:35 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=88=A4=E6=96=AD=E4=B8=8E=E9=97=AE=E9=A2=98=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 2 +- internal/biz/do/handle.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 0a74b22..a10907f 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -292,7 +292,7 @@ func (d *DingTalkBotBiz) resolveSystemAndIssueType(ctx context.Context, requireD return nil, err } // 问题类型 - classificationIssueType, err := d.handle.ClassifyIssueType(ctx, issueTypeNames, requireData.Req.Text.Content, userHist) + classificationIssueType, err := d.handle.ClassifyIssueType(ctx, issueTypeNames, sysNames, requireData.Req.Text.Content, userHist) if err != nil { return nil, err } diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 4adda02..844c004 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -188,7 +188,7 @@ func (r *Handle) ClassifyIssueSystem(ctx context.Context, systems []string, user } // ClassifyIssueType 问题分类分析 -func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { +func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, systems []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { systemPrompt := fmt.Sprintf(`## 角色 你是一个业务问题类型分析专家。你的任务是基于多轮对话识别用户讨论的**问题类型(issue_type_name)**,问题类型必须严格来自可用问题类型列表 [%s]。 @@ -213,13 +213,14 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, use ## 背景数据 可用问题类型列表:[%s] +系统名称列表参考:[%s] ## 输出格式 { "issue_type_name": "问题类型名称", "summary": "15字内问题标题", "reason": "说明问题类型是基于哪句话判断,或说明继承自历史,继承自哪条历史" -}`, strings.Join(issueTypes, ", "), strings.Join(issueTypes, ", ")) +}`, strings.Join(issueTypes, ", "), strings.Join(issueTypes, ", "), strings.Join(systems, ", ")) historyStr := strings.Builder{} historyStr.WriteString("### 历史对话:\n") From 99865c2bc475fde82cde88dea77eb771d91e24cb Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 4 Feb 2026 17:27:35 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/ding_talk_bot.go | 2 +- internal/biz/do/handle.go | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index a10907f..2c84274 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -185,7 +185,7 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti if err != nil { return err } - log.Debugf("系统&分类结果: %s - %s", resolveResult.Sys.SysName, resolveResult.IssueType.Name) + log.Debugf("系统&分类结果: %s - %s,原因:%s", resolveResult.Sys.SysName, resolveResult.IssueType.Name, resolveResult.Classification.Reason) // 4. 分类处理(后续考虑接各自的工作流/agent) switch resolveResult.IssueType.Code { diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 844c004..e6f8767 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -98,20 +98,19 @@ func (r *Handle) RewriteQuery(ctx context.Context, history []model.AiBotChatHi, return currentQuery, nil } - var histStr strings.Builder + histStr := strings.Builder{} for _, h := range history { - role := "用户" - if h.Role != "user" { - role = "助手" + if h.Role == "user" { + histStr.WriteString(fmt.Sprintf("%s:%s\n", h.CreateAt, h.Content)) } - histStr.WriteString(fmt.Sprintf("%s: %s\n", role, h.Content)) } systemPrompt := `你是一个搜索查询改写专家。请结合用户的历史对话上下文,将用户当前的输入改写为一个独立的、语义完整的、适合知识库检索的中文查询词。 要求: -1. 保持原意,补全指代(如“它”、“刚才那个问题”)。 -2. 只返回改写后的查询词,不要有任何解释。 -3. 如果当前输入已经很完整,直接返回原句。` +1. 当前输入最能反映用户的意图,权重按照时间逆序依次减弱,改写后的查询词应与当前输入的语义相关。 +2. 保持原意,补全指代(如“它”、“刚才那个问题”)。 +3. 只返回改写后的查询词,不要有任何解释。 +4. 如果当前输入已经很完整,直接返回原句。` userPrompt := fmt.Sprintf("### 历史对话:\n%s\n### 当前输入:\n%s\n### 改写后的查询词:", histStr.String(), currentQuery) @@ -141,6 +140,7 @@ func (r *Handle) ClassifyIssueSystem(ctx context.Context, systems []string, user - 当前输入明确提到系统 → 直接覆盖历史系统 - 当前输入未提系统,但历史对话有 → 继承最近历史系统 - 当前输入和历史均未出现 → "全局" + - 询问公司、企业、制度层面的问题 → "全局" 2. 特殊规则: - 如果当前输入仅包含系统名称(如“CRM”),视为系统上下文补充,仅更新 sys_name,不做其他推断 @@ -190,7 +190,7 @@ func (r *Handle) ClassifyIssueSystem(ctx context.Context, systems []string, user // ClassifyIssueType 问题分类分析 func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, systems []string, userInput string, userHist []model.AiBotChatHi) (*IssueClassification, error) { systemPrompt := fmt.Sprintf(`## 角色 -你是一个业务问题类型分析专家。你的任务是基于多轮对话识别用户讨论的**问题类型(issue_type_name)**,问题类型必须严格来自可用问题类型列表 [%s]。 +你是一个业务问题类型分析专家。你的任务是基于多轮对话识别用户讨论的**问题类型(issue_type_name)**,问题类型必须严格来自“背景数据-可用问题类型列表”。 你不负责系统名称判断。输出必须严格遵守 JSON 格式。 @@ -200,12 +200,14 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys - 将当前输入与历史对话合并理解为完整问题演进 - 当前输入可能是补充条件、追问、修正或只给模块名/报错片段 - 不要只看当前一句 + - 忽略历史中的系统名称相关 2. 问题类型判定逻辑 - 当前输入明确匹配列表中某个类型 → 使用该类型 - 当前输入未明确,但历史已有 → 继承历史类型 - 当前输入未匹配,历史也没有 → 选择最接近的列表类型(尽量匹配意图) - 除非是闲聊(如“你好”“在吗”),禁止返回空值 + - 除非明确是需求,否则禁止返回“开发需求”类型 3. 特殊规则 - 当前输入只包含系统名/模块名/参数名 → 视为问题补充,继承历史 issue_type_name @@ -220,7 +222,7 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys "issue_type_name": "问题类型名称", "summary": "15字内问题标题", "reason": "说明问题类型是基于哪句话判断,或说明继承自历史,继承自哪条历史" -}`, strings.Join(issueTypes, ", "), strings.Join(issueTypes, ", "), strings.Join(systems, ", ")) +}`, strings.Join(issueTypes, ", "), strings.Join(systems, ", ")) historyStr := strings.Builder{} historyStr.WriteString("### 历史对话:\n") From b104572e1b7f42eac8e881af45063cbbe113e5cb Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Thu, 5 Feb 2026 10:10:08 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix:=201.=E4=BF=AE=E6=94=B9HTTP=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E5=9B=9E=E8=B0=83=202.=E4=BF=AE=E6=94=B9HTTP?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=9B=9E=E8=B0=83=203.=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E5=91=BD=E4=B8=AD=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/callback.go | 37 ++++++------- internal/biz/ding_talk_bot.go | 14 +++-- internal/biz/do/handle.go | 52 ++++++++++++++++++- internal/biz/group_config.go | 16 +++--- internal/biz/llm_service/ollama.go | 13 +++++ internal/data/constants/dingtalk.go | 10 +--- .../tools/common/knowledge_base/client.go | 51 ++++++++++++++++++ .../tools/common/knowledge_base/type.go | 10 ++++ 8 files changed, 162 insertions(+), 41 deletions(-) diff --git a/internal/biz/callback.go b/internal/biz/callback.go index 40b8e40..7724bf5 100644 --- a/internal/biz/callback.go +++ b/internal/biz/callback.go @@ -77,33 +77,23 @@ func (c *CallbackBiz) issueHandlingExtractContent(data chatbot.BotCallbackDataMo } // 解析 JSON 响应 var resp struct { - Items []struct { - Question string `json:"question"` - Answer string `json:"answer"` - Confidence string `json:"confidence"` - } `json:"items"` + Question string `json:"question"` + Answer string `json:"answer"` + Confidence string `json:"confidence"` } if err := json.Unmarshal([]byte(generateResp.Response), &resp); err != nil { log.Errorf("解析 JSON 响应失败: %v", err) return } - // 2.构建文本域内容 - 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") - - // 3.获取应用AppKey + // 2.获取应用AppKey appKey, err := c.botConfigImpl.GetRobotAppKey(data.RobotCode) if err != nil { log.Errorf("获取应用配置失败: %v", err) return } - // 4.创建并投放卡片 + // 3.创建并投放卡片 outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) // 构建卡片 OutTrackId _, err = c.dingtalkCardClient.CreateAndDeliver( appKey, @@ -114,13 +104,14 @@ func (c *CallbackBiz) issueHandlingExtractContent(data chatbot.BotCallbackDataMo CallbackRouteKey: tea.String(c.cfg.Dingtalk.Card.CallbackRouteKey), CardData: &card_1_0.CreateAndDeliverRequestCardData{ CardParamMap: map[string]*string{ + "_CARD_DEBUG_TOOL_ENTRY": tea.String(c.cfg.Dingtalk.Card.DebugToolEntryShow), // 调试字段 "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(c.cfg.Dingtalk.Card.DebugToolEntryShow), // 调试字段 + "question": tea.String(resp.Question), + "answer": tea.String(resp.Answer), }, }, ImGroupOpenSpaceModel: &card_1_0.CreateAndDeliverRequestImGroupOpenSpaceModel{ @@ -223,15 +214,21 @@ func (c *CallbackBiz) issueHandlingQueryKnowledgeBase(data chatbot.BotCallbackDa func (c *CallbackBiz) IssueHandlingCollectQA(data card.CardRequest) *card.CardResponse { // 确认提交,文本写入知识库 if data.CardActionData.CardPrivateData.Params["submit"] == "submit" { - content := data.CardActionData.CardPrivateData.Params["QA_details"].(string) + question := data.CardActionData.CardPrivateData.Params["question_local"].(string) + answer := data.CardActionData.CardPrivateData.Params["answer_local"].(string) tenantID := data.CardActionData.CardPrivateData.Params["tenant_id"].(string) // 协程执行耗时操作,防止阻塞 util.SafeGo("inject_knowledge_base", func() { knowledgeBase := knowledge_base.New(c.cfg.KnowledgeConfig) - err := knowledgeBase.IngestText(&knowledge_base.IngestTextRequest{ + err := knowledgeBase.IngestBatchQA(&knowledge_base.IngestBacthQARequest{ TenantID: tenantID, - Text: content, + QAList: []*knowledge_base.QA{ + { + Question: question, + Answer: answer, + }, + }, }) if err != nil { log.Errorf("注入知识库失败: %v", err) diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 2c84274..397a0bc 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -219,12 +219,16 @@ func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *ent log.Debugf("改写前后的Query: %s -> %s", requireData.Req.Text.Content, queryText) // 获取知识库结果 - isRetrieved, err := d.getKnowledgeAnswer(ctx, requireData, tenantId, queryText) + isRetrieved, responseContent, err := d.getKnowledgeAnswer(ctx, requireData.Ch, tenantId, queryText) if err != nil { return err } if isRetrieved { - return nil + // 过一遍 LLM 判断是否真的命中知识库 + isRetrieved, err = d.handle.IsAnswerRelevant(ctx, queryText, responseContent) + if err != nil { + return err + } } // 未匹配&全局 -> 明确具体系统 @@ -242,7 +246,7 @@ func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *ent } // 获取知识库问答结果 -func (d *DingTalkBotBiz) getKnowledgeAnswer(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, tenantId string, queryText string) (bool, error) { +func (d *DingTalkBotBiz) getKnowledgeAnswer(ctx context.Context, ch chan entitys.Response, tenantId string, queryText string) (bool, string, error) { // 请求知识库工具 knowledgeBase := knowledge_base.New(d.conf.KnowledgeConfig) knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ @@ -254,11 +258,11 @@ func (d *DingTalkBotBiz) getKnowledgeAnswer(ctx context.Context, requireData *en OnlyRAG: true, }) if err != nil { - return false, fmt.Errorf("请求知识库工具失败,err: %v", err) + return false, "", fmt.Errorf("请求知识库工具失败,err: %v", err) } // 读取知识库SSE数据 - return d.groupConfigBiz.readKnowledgeSSE(knowledgeResp, requireData.Ch, true) + return d.groupConfigBiz.readKnowledgeSSE(knowledgeResp, ch, true) } type resolveSystemAndIssueTypeResult struct { diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index e6f8767..c2cf43d 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -207,7 +207,7 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys - 当前输入未明确,但历史已有 → 继承历史类型 - 当前输入未匹配,历史也没有 → 选择最接近的列表类型(尽量匹配意图) - 除非是闲聊(如“你好”“在吗”),禁止返回空值 - - 除非明确是需求,否则禁止返回“开发需求”类型 + - 除非明确是需求,否则禁止返回“开发需求”类型,疑问句式一定不能返回“开发需求”类型 3. 特殊规则 - 当前输入只包含系统名/模块名/参数名 → 视为问题补充,继承历史 issue_type_name @@ -256,6 +256,56 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys return &result, nil } +type IsAnswerRelevant struct { + Relevance string `json:"relevance"` + Reason string `json:"reason"` +} + +// 判断答案是否回答了问题 +func (r *Handle) IsAnswerRelevant(ctx context.Context, question string, answer string) (bool, error) { + prompt := `## 角色 +你是一个答案评估专家,你的任务是判断给定的答案是否真正回答了用户的问题。你必须严格分析语义、意图和信息覆盖情况,避免只看关键词。 + +## 输入 +- question: %s +- answer: %s + +## 判断逻辑 +1. **直接回答**:答案明确提供了解决方案、步骤、结论或可执行信息 → 输出 True +2. **未回答**:答案仅泛泛提示、缺少关键步骤或信息,或者只是提供背景、登录信息等无关内容 → 输出 False +3. **部分回答**:答案提供了一部分可用信息,但未完全解决问题 → 输出 “Partial” + +## 输出要求 +输出严格 JSON 格式,只包含以下字段: + +{ + "relevance": "True / False / Partial", + "reason": "简要说明为什么答案被认为回答或未回答问题" +}` + resp, err := r.Ollama.Generation(ctx, fmt.Sprintf(prompt, question, answer)) + if err != nil { + return false, err + } + + // 尝试清理 JSON 内容(有时模型会返回 markdown 块) + resp = strings.TrimPrefix(resp, "```json") + resp = strings.TrimSuffix(resp, "```") + resp = strings.TrimSpace(resp) + + var result IsAnswerRelevant + if err := json.Unmarshal([]byte(resp), &result); err != nil { + return false, fmt.Errorf("解析分类结果失败: %w, 原文: %s", err, resp) + } + + log.Debug("分析结果:%s,原因:%s", result.Relevance, result.Reason) + + if result.Relevance == "True" { + return true, nil + } + + return false, nil +} + func (r *Handle) handleOtherTask(ctx context.Context, requireData *entitys.RequireData) (err error) { entitys.ResText(requireData.Ch, "", requireData.Match.Reasoning) return diff --git a/internal/biz/group_config.go b/internal/biz/group_config.go index ce7589f..d478b25 100644 --- a/internal/biz/group_config.go +++ b/internal/biz/group_config.go @@ -507,7 +507,7 @@ func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recog } // 读取知识库SSE数据 - isRetrieved, err = g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) + isRetrieved, _, err = g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) if err != nil { return } @@ -522,16 +522,16 @@ func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recog } // 读取知识库 SSE 数据 -func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entitys.Response, useParagraphMode bool) (isRetrieved bool, err error) { +func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entitys.Response, useParagraphMode bool) (isRetrieved bool, allContent string, err error) { scanner := bufio.NewScanner(resp) var buffer strings.Builder - + var allContentBuilder strings.Builder for scanner.Scan() { line := scanner.Text() delta, done, err := knowledge_base.ParseOpenAIStreamData(line) if err != nil { - return false, fmt.Errorf("解析SSE数据失败: %w", err) + return false, "", fmt.Errorf("解析SSE数据失败: %w", err) } if done { break @@ -544,7 +544,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit if delta.XRagStatus == constants.KnowledgeRagStatusMiss { var missContent string = "知识库未检测到匹配信息。" entitys.ResStream(channel, "", missContent) - return false, nil + return false, missContent, nil } // 推理内容 if delta.ReasoningContent != "" { @@ -555,6 +555,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit if delta.Content != "" && useParagraphMode { // 存入缓冲区 buffer.WriteString(delta.Content) + allContentBuilder.WriteString(delta.Content) content := buffer.String() // 检查是否有换行符,按段落输出 @@ -572,10 +573,11 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit // 输出内容 - 逐字 if delta.Content != "" && !useParagraphMode { entitys.ResStream(channel, "", delta.Content) + allContentBuilder.WriteString(delta.Content) } } if err := scanner.Err(); err != nil { - return true, fmt.Errorf("读取SSE流中断: %w", err) + return true, "", fmt.Errorf("读取SSE流中断: %w", err) } // 发送缓冲区剩余内容(仅在段落模式下需要) @@ -583,7 +585,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit entitys.ResStream(channel, "", buffer.String()) } - return true, nil + return true, allContentBuilder.String(), nil } // 询问是否创建群聊处理问题 diff --git a/internal/biz/llm_service/ollama.go b/internal/biz/llm_service/ollama.go index eb74adc..ff2fe46 100644 --- a/internal/biz/llm_service/ollama.go +++ b/internal/biz/llm_service/ollama.go @@ -70,6 +70,19 @@ func (r *OllamaService) Chat(ctx context.Context, messages []api.Message) (strin return res.Message.Content, nil } +func (r *OllamaService) Generation(ctx context.Context, prompt string) (string, error) { + res, err := r.client.Generation(ctx, &api.GenerateRequest{ + Model: r.config.Ollama.GenerateModel, + Stream: new(bool), + Prompt: prompt, + Think: &api.ThinkValue{Value: false}, + }) + if err != nil { + return "", err + } + return res.Response, nil +} + //func (r *OllamaService) RecognizeWithImg(ctx context.Context, imgByte []api.ImageData, ch chan entitys.Response) (desc api.GenerateResponse, err error) { // if imgByte == nil { // return diff --git a/internal/data/constants/dingtalk.go b/internal/data/constants/dingtalk.go index 1ec6894..43e5482 100644 --- a/internal/data/constants/dingtalk.go +++ b/internal/data/constants/dingtalk.go @@ -126,8 +126,7 @@ const IssueHandlingExtractContentPrompt string = `你是一个【问题与答案 当用户输入为【多条群聊聊天记录】时: - 结合问题主题,判断聊天记录中正在讨论或试图解决的问题 - - 一个群聊中可能包含多个相互独立的问题,但它们都围绕着一个主题,一般为用户提出的第一个问题,尽可能总结为一个问题 - - 若确实问题很独立,需要分别识别,对每个问题,整理出清晰、可复用的“问题描述”和“对应答案” + - 一个群聊中可能包含多个相互独立的问题,但它们都围绕着一个主题,一般为用户提出的第一个问题。尽可能总结为一个问题、一个答案 生成答案时的原则: - 答案必须来源于聊天内容中已经给出的信息或共识 @@ -142,23 +141,18 @@ const IssueHandlingExtractContentPrompt string = `你是一个【问题与答案 JSON 结构约定: { - "items": [ - { "question": "清晰、独立、可复用的问题描述", "answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”", "confidence": "high | medium | low" - } - ] } 字段说明: - - items:问题与答案列表;若未识别到有效问题,则返回空数组 [] - question:抽象后的标准问题表述,不包含具体聊天语句 - answer:整理后的答案,不得引入聊天之外的信息 - confidence:根据聊天中信息的一致性和明确程度给出判断 如果无法从输入中识别出任何有效问题,返回: - { "items": [] } + { "confidence": "low" } 用户输入: %s diff --git a/internal/domain/tools/common/knowledge_base/client.go b/internal/domain/tools/common/knowledge_base/client.go index ae72661..66eba5b 100644 --- a/internal/domain/tools/common/knowledge_base/client.go +++ b/internal/domain/tools/common/knowledge_base/client.go @@ -3,6 +3,7 @@ package knowledge_base import ( "ai_scheduler/internal/config" "ai_scheduler/internal/pkg/l_request" + "encoding/json" "fmt" "io" "net/http" @@ -110,3 +111,53 @@ func (c *Client) IngestText(req *IngestTextRequest) error { return nil } + +// IngestBatchQA 向知识库中注入问答对 +func (c *Client) IngestBatchQA(req *IngestBacthQARequest) error { + if req == nil { + return fmt.Errorf("req is nil") + } + if req.TenantID == "" { + return fmt.Errorf("tenantID is empty") + } + for _, item := range req.QAList { + if item.Question == "" { + return fmt.Errorf("question is empty") + } + if item.Answer == "" { + return fmt.Errorf("answer is empty") + } + } + data := []map[string]string{} + for _, item := range req.QAList { + data = append(data, map[string]string{ + "question": item.Question, + "answer": item.Answer, + }) + } + jsonByte, err := json.Marshal(data) + if err != nil { + return err + } + + baseURL := strings.TrimRight(c.cfg.BaseURL, "/") + + rsp, err := (&l_request.Request{ + Method: "POST", + Url: baseURL + "/ingest/batch_qa", + Headers: map[string]string{ + "Content-Type": "application/json", + "X-Tenant-ID": req.TenantID, + }, + JsonByte: jsonByte, + }).Send() + if err != nil { + return err + } + + if rsp.StatusCode != http.StatusOK { + return fmt.Errorf("knowledge base returned status %d: %s", rsp.StatusCode, rsp.Text) + } + + return nil +} diff --git a/internal/domain/tools/common/knowledge_base/type.go b/internal/domain/tools/common/knowledge_base/type.go index 10acf55..9415645 100644 --- a/internal/domain/tools/common/knowledge_base/type.go +++ b/internal/domain/tools/common/knowledge_base/type.go @@ -13,3 +13,13 @@ type IngestTextRequest struct { TenantID string // 租户 ID Text string // 要注入的文本内容 } + +type IngestBacthQARequest struct { + TenantID string // 租户 ID + QAList []*QA // 问答对列表 +} + +type QA struct { + Question string // 问题 + Answer string // 答案 +}