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] =?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 // 答案 +}