fix: 1.修改HTTP机器人回调 2.修改HTTP卡片回调 3.追加知识库命中判断

This commit is contained in:
fuzhongyun 2026-02-05 10:10:08 +08:00
parent 99865c2bc4
commit b104572e1b
8 changed files with 162 additions and 41 deletions

View File

@ -77,33 +77,23 @@ func (c *CallbackBiz) issueHandlingExtractContent(data chatbot.BotCallbackDataMo
} }
// 解析 JSON 响应 // 解析 JSON 响应
var resp struct { var resp struct {
Items []struct {
Question string `json:"question"` Question string `json:"question"`
Answer string `json:"answer"` Answer string `json:"answer"`
Confidence string `json:"confidence"` Confidence string `json:"confidence"`
} `json:"items"`
} }
if err := json.Unmarshal([]byte(generateResp.Response), &resp); err != nil { if err := json.Unmarshal([]byte(generateResp.Response), &resp); err != nil {
log.Errorf("解析 JSON 响应失败: %v", err) log.Errorf("解析 JSON 响应失败: %v", err)
return return
} }
// 2.构建文本域内容 // 2.获取应用AppKey
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
appKey, err := c.botConfigImpl.GetRobotAppKey(data.RobotCode) appKey, err := c.botConfigImpl.GetRobotAppKey(data.RobotCode)
if err != nil { if err != nil {
log.Errorf("获取应用配置失败: %v", err) log.Errorf("获取应用配置失败: %v", err)
return return
} }
// 4.创建并投放卡片 // 3.创建并投放卡片
outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) // 构建卡片 OutTrackId outTrackId := constants.BuildCardOutTrackId(data.ConversationId, data.RobotCode) // 构建卡片 OutTrackId
_, err = c.dingtalkCardClient.CreateAndDeliver( _, err = c.dingtalkCardClient.CreateAndDeliver(
appKey, appKey,
@ -114,13 +104,14 @@ func (c *CallbackBiz) issueHandlingExtractContent(data chatbot.BotCallbackDataMo
CallbackRouteKey: tea.String(c.cfg.Dingtalk.Card.CallbackRouteKey), CallbackRouteKey: tea.String(c.cfg.Dingtalk.Card.CallbackRouteKey),
CardData: &card_1_0.CreateAndDeliverRequestCardData{ CardData: &card_1_0.CreateAndDeliverRequestCardData{
CardParamMap: map[string]*string{ CardParamMap: map[string]*string{
"_CARD_DEBUG_TOOL_ENTRY": tea.String(c.cfg.Dingtalk.Card.DebugToolEntryShow), // 调试字段
"title": tea.String("QA知识收集"), "title": tea.String("QA知识收集"),
"button_display": tea.String("true"), "button_display": tea.String("true"),
"QA_details_now": tea.String(cardContent),
"textarea_display": tea.String("normal"), "textarea_display": tea.String("normal"),
"action_id": tea.String("collect_qa"), "action_id": tea.String("collect_qa"),
"tenant_id": tea.String(constants.KnowledgeTenantIdDefault), "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{ 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 { func (c *CallbackBiz) IssueHandlingCollectQA(data card.CardRequest) *card.CardResponse {
// 确认提交,文本写入知识库 // 确认提交,文本写入知识库
if data.CardActionData.CardPrivateData.Params["submit"] == "submit" { 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) tenantID := data.CardActionData.CardPrivateData.Params["tenant_id"].(string)
// 协程执行耗时操作,防止阻塞 // 协程执行耗时操作,防止阻塞
util.SafeGo("inject_knowledge_base", func() { util.SafeGo("inject_knowledge_base", func() {
knowledgeBase := knowledge_base.New(c.cfg.KnowledgeConfig) knowledgeBase := knowledge_base.New(c.cfg.KnowledgeConfig)
err := knowledgeBase.IngestText(&knowledge_base.IngestTextRequest{ err := knowledgeBase.IngestBatchQA(&knowledge_base.IngestBacthQARequest{
TenantID: tenantID, TenantID: tenantID,
Text: content, QAList: []*knowledge_base.QA{
{
Question: question,
Answer: answer,
},
},
}) })
if err != nil { if err != nil {
log.Errorf("注入知识库失败: %v", err) log.Errorf("注入知识库失败: %v", err)

View File

@ -219,12 +219,16 @@ func (d *DingTalkBotBiz) handleKnowledgeQA(ctx context.Context, requireData *ent
log.Debugf("改写前后的Query: %s -> %s", requireData.Req.Text.Content, queryText) 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 { if err != nil {
return err return err
} }
if isRetrieved { 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) knowledgeBase := knowledge_base.New(d.conf.KnowledgeConfig)
knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{ knowledgeResp, err := knowledgeBase.Query(&knowledge_base.QueryRequest{
@ -254,11 +258,11 @@ func (d *DingTalkBotBiz) getKnowledgeAnswer(ctx context.Context, requireData *en
OnlyRAG: true, OnlyRAG: true,
}) })
if err != nil { if err != nil {
return false, fmt.Errorf("请求知识库工具失败err: %v", err) return false, "", fmt.Errorf("请求知识库工具失败err: %v", err)
} }
// 读取知识库SSE数据 // 读取知识库SSE数据
return d.groupConfigBiz.readKnowledgeSSE(knowledgeResp, requireData.Ch, true) return d.groupConfigBiz.readKnowledgeSSE(knowledgeResp, ch, true)
} }
type resolveSystemAndIssueTypeResult struct { type resolveSystemAndIssueTypeResult struct {

View File

@ -207,7 +207,7 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys
- 当前输入未明确但历史已有 继承历史类型 - 当前输入未明确但历史已有 继承历史类型
- 当前输入未匹配历史也没有 选择最接近的列表类型尽量匹配意图 - 当前输入未匹配历史也没有 选择最接近的列表类型尽量匹配意图
- 除非是闲聊你好在吗禁止返回空值 - 除非是闲聊你好在吗禁止返回空值
- 除非明确是需求否则禁止返回开发需求类型 - 除非明确是需求否则禁止返回开发需求类型疑问句式一定不能返回开发需求类型
3. 特殊规则 3. 特殊规则
- 当前输入只包含系统名/模块名/参数名 视为问题补充继承历史 issue_type_name - 当前输入只包含系统名/模块名/参数名 视为问题补充继承历史 issue_type_name
@ -256,6 +256,56 @@ func (r *Handle) ClassifyIssueType(ctx context.Context, issueTypes []string, sys
return &result, nil 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) { func (r *Handle) handleOtherTask(ctx context.Context, requireData *entitys.RequireData) (err error) {
entitys.ResText(requireData.Ch, "", requireData.Match.Reasoning) entitys.ResText(requireData.Ch, "", requireData.Match.Reasoning)
return return

View File

@ -507,7 +507,7 @@ func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recog
} }
// 读取知识库SSE数据 // 读取知识库SSE数据
isRetrieved, err = g.readKnowledgeSSE(knowledgeResp, rec.Ch, true) isRetrieved, _, err = g.readKnowledgeSSE(knowledgeResp, rec.Ch, true)
if err != nil { if err != nil {
return return
} }
@ -522,16 +522,16 @@ func (g *GroupConfigBiz) handleKnowledge(ctx context.Context, rec *entitys.Recog
} }
// 读取知识库 SSE 数据 // 读取知识库 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) scanner := bufio.NewScanner(resp)
var buffer strings.Builder var buffer strings.Builder
var allContentBuilder strings.Builder
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
delta, done, err := knowledge_base.ParseOpenAIStreamData(line) delta, done, err := knowledge_base.ParseOpenAIStreamData(line)
if err != nil { if err != nil {
return false, fmt.Errorf("解析SSE数据失败: %w", err) return false, "", fmt.Errorf("解析SSE数据失败: %w", err)
} }
if done { if done {
break break
@ -544,7 +544,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit
if delta.XRagStatus == constants.KnowledgeRagStatusMiss { if delta.XRagStatus == constants.KnowledgeRagStatusMiss {
var missContent string = "知识库未检测到匹配信息。" var missContent string = "知识库未检测到匹配信息。"
entitys.ResStream(channel, "", missContent) entitys.ResStream(channel, "", missContent)
return false, nil return false, missContent, nil
} }
// 推理内容 // 推理内容
if delta.ReasoningContent != "" { if delta.ReasoningContent != "" {
@ -555,6 +555,7 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit
if delta.Content != "" && useParagraphMode { if delta.Content != "" && useParagraphMode {
// 存入缓冲区 // 存入缓冲区
buffer.WriteString(delta.Content) buffer.WriteString(delta.Content)
allContentBuilder.WriteString(delta.Content)
content := buffer.String() content := buffer.String()
// 检查是否有换行符,按段落输出 // 检查是否有换行符,按段落输出
@ -572,10 +573,11 @@ func (g *GroupConfigBiz) readKnowledgeSSE(resp io.ReadCloser, channel chan entit
// 输出内容 - 逐字 // 输出内容 - 逐字
if delta.Content != "" && !useParagraphMode { if delta.Content != "" && !useParagraphMode {
entitys.ResStream(channel, "", delta.Content) entitys.ResStream(channel, "", delta.Content)
allContentBuilder.WriteString(delta.Content)
} }
} }
if err := scanner.Err(); err != nil { 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()) entitys.ResStream(channel, "", buffer.String())
} }
return true, nil return true, allContentBuilder.String(), nil
} }
// 询问是否创建群聊处理问题 // 询问是否创建群聊处理问题

View File

@ -70,6 +70,19 @@ func (r *OllamaService) Chat(ctx context.Context, messages []api.Message) (strin
return res.Message.Content, nil 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) { //func (r *OllamaService) RecognizeWithImg(ctx context.Context, imgByte []api.ImageData, ch chan entitys.Response) (desc api.GenerateResponse, err error) {
// if imgByte == nil { // if imgByte == nil {
// return // return

View File

@ -126,8 +126,7 @@ const IssueHandlingExtractContentPrompt string = `你是一个【问题与答案
当用户输入为多条群聊聊天记录 当用户输入为多条群聊聊天记录
- 结合问题主题判断聊天记录中正在讨论或试图解决的问题 - 结合问题主题判断聊天记录中正在讨论或试图解决的问题
- 一个群聊中可能包含多个相互独立的问题但它们都围绕着一个主题一般为用户提出的第一个问题尽可能总结为一个问题 - 一个群聊中可能包含多个相互独立的问题但它们都围绕着一个主题一般为用户提出的第一个问题尽可能总结为一个问题一个答案
- 若确实问题很独立需要分别识别对每个问题整理出清晰可复用的问题描述对应答案
生成答案时的原则 生成答案时的原则
- 答案必须来源于聊天内容中已经给出的信息或共识 - 答案必须来源于聊天内容中已经给出的信息或共识
@ -141,24 +140,19 @@ const IssueHandlingExtractContentPrompt string = `你是一个【问题与答案
- JSON 结构必须严格符合以下约定 - JSON 结构必须严格符合以下约定
JSON 结构约定 JSON 结构约定
{
"items": [
{ {
"question": "清晰、独立、可复用的问题描述", "question": "清晰、独立、可复用的问题描述",
"answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”", "answer": "基于聊天内容整理出的答案;如无结论则为“暂无明确结论”",
"confidence": "high | medium | low" "confidence": "high | medium | low"
} }
]
}
字段说明 字段说明
- items问题与答案列表若未识别到有效问题则返回空数组 []
- question抽象后的标准问题表述不包含具体聊天语句 - question抽象后的标准问题表述不包含具体聊天语句
- answer整理后的答案不得引入聊天之外的信息 - answer整理后的答案不得引入聊天之外的信息
- confidence根据聊天中信息的一致性和明确程度给出判断 - confidence根据聊天中信息的一致性和明确程度给出判断
如果无法从输入中识别出任何有效问题返回 如果无法从输入中识别出任何有效问题返回
{ "items": [] } { "confidence": "low" }
用户输入 用户输入
%s %s

View File

@ -3,6 +3,7 @@ package knowledge_base
import ( import (
"ai_scheduler/internal/config" "ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request" "ai_scheduler/internal/pkg/l_request"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -110,3 +111,53 @@ func (c *Client) IngestText(req *IngestTextRequest) error {
return nil 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
}

View File

@ -13,3 +13,13 @@ type IngestTextRequest struct {
TenantID string // 租户 ID TenantID string // 租户 ID
Text string // 要注入的文本内容 Text string // 要注入的文本内容
} }
type IngestBacthQARequest struct {
TenantID string // 租户 ID
QAList []*QA // 问答对列表
}
type QA struct {
Question string // 问题
Answer string // 答案
}