package service import ( "context" "encoding/json" "fmt" "log" "qd-sc/internal/client" "qd-sc/internal/config" "qd-sc/internal/model" "qd-sc/pkg/utils" "regexp" "strings" "time" ) // ExposedModelName 对外暴露的固定模型名称 const ExposedModelName = "qd-job-turbo" // 岗位查询意图关键词 var jobIntentKeywords = []string{ "岗位", "工作", "招聘", "职位", "就业", "求职", "找工作", "应聘", "薪资", "薪酬", "工资", "待遇", "月薪", "年薪", "推荐岗位", "推荐工作", "推荐职位", "附近的工作", "附近的岗位", "附近招聘", "适合我的", "匹配的岗位", "匹配的工作", "开发工程师", "产品经理", "设计师", "运营", "销售", "会计", "财务", "前端", "后端", "全栈", "Java", "Python", "测试", "运维", } // 简历内容特征关键词(用于判断OCR内容是否是简历) var resumeKeywords = []string{ // 个人信息相关 "姓名", "性别", "年龄", "出生", "籍贯", "民族", "身份证", "电话", "手机", "邮箱", "邮件", "地址", "住址", // 教育背景 "学历", "学位", "毕业", "本科", "硕士", "博士", "大专", "高中", "专业", "院校", "大学", "学院", "在读", "应届", // 工作经历 "工作经验", "工作经历", "任职", "就职", "离职", "在职", "公司", "企业", "单位", "部门", "岗位", "职位", "职务", // 技能相关 "技能", "特长", "证书", "资格", "熟练", "精通", "掌握", // 自我评价 "自我评价", "个人简介", "自我介绍", "个人总结", "求职意向", // 简历特有标记 "简历", "履历", "个人资料", "基本信息", "联系方式", } // resumeKeywordThreshold 简历关键词匹配阈值(需要匹配到的最小数量) const resumeKeywordThreshold = 3 // isResumeContent 检测OCR内容是否是简历 func isResumeContent(content string) bool { if content == "" { return false } matchCount := 0 contentLower := strings.ToLower(content) for _, keyword := range resumeKeywords { if strings.Contains(contentLower, strings.ToLower(keyword)) { matchCount++ if matchCount >= resumeKeywordThreshold { log.Printf("检测到简历内容,匹配关键词数量: %d", matchCount) return true } } } log.Printf("内容不符合简历特征,仅匹配到 %d 个关键词(阈值: %d)", matchCount, resumeKeywordThreshold) return false } // 岗位信息输出的特征模式(用于检测AI幻觉) var jobHallucinationPatterns = []*regexp.Regexp{ regexp.MustCompile(`岗位名称[::]\s*\S+`), regexp.MustCompile(`公司名称[::]\s*\S+`), regexp.MustCompile(`薪资范围[::]\s*\d+`), regexp.MustCompile(`工作地点[::]\s*\S+`), regexp.MustCompile(`学历要求[::]\s*\S+`), regexp.MustCompile(`经验要求[::]\s*\S+`), regexp.MustCompile(`\d+[-~到至]\d+元[//每]月`), regexp.MustCompile(`\d+[kK][-~到至]\d+[kK]`), regexp.MustCompile(`(?:推荐|适合)[^。]*(?:岗位|职位|工作)[::]\s*\d+[.、]`), regexp.MustCompile(`以下是[^。]*(?:岗位|职位|工作)`), regexp.MustCompile(`为您(?:推荐|找到)[^。]*(?:岗位|职位|工作)`), } // containsNonResumeImageHint 检测消息中是否包含非简历图片的提示 func containsNonResumeImageHint(content string) bool { return strings.Contains(content, "[用户上传的图片内容(非简历格式)]") } // isJobQueryIntent 检测用户输入是否是岗位查询意图 func (s *ChatService) isJobQueryIntent(messages []model.Message) bool { // 获取最后一条用户消息 var lastUserMessage string for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { if content, ok := messages[i].Content.(string); ok { lastUserMessage = content break } } } if lastUserMessage == "" { return false } // 如果消息中包含非简历图片的提示,不视为岗位查询意图 // 因为需要先询问用户意图 if containsNonResumeImageHint(lastUserMessage) { log.Printf("检测到非简历图片,不强制岗位查询意图,需先询问用户") return false } // 转换为小写进行匹配 lowerMsg := strings.ToLower(lastUserMessage) // 检查是否包含岗位相关关键词 for _, keyword := range jobIntentKeywords { if strings.Contains(lowerMsg, strings.ToLower(keyword)) { log.Printf("检测到岗位查询意图,匹配关键词: %s", keyword) return true } } return false } // containsJobHallucination 检测AI回复是否包含岗位幻觉(在没有调用工具的情况下自行输出岗位信息) func (s *ChatService) containsJobHallucination(content string) bool { if content == "" { return false } matchCount := 0 for _, pattern := range jobHallucinationPatterns { if pattern.MatchString(content) { matchCount++ if matchCount >= 2 { log.Printf("检测到岗位幻觉输出,匹配模式数量: %d", matchCount) return true } } } return false } // getJobToolChoice 获取强制调用岗位工具的 tool_choice 配置 func (s *ChatService) getJobToolChoice() interface{} { // 返回 "required" 强制模型必须调用某个工具 // 或者返回特定工具的配置强制调用该工具 return map[string]interface{}{ "type": "function", "function": map[string]string{ "name": "queryJobsByArea", }, } } // getHallucinationWarningMessage 获取幻觉拦截后的警告消息 func (s *ChatService) getHallucinationWarningMessage() string { return "抱歉,我需要先查询实际的岗位数据才能为您推荐。请稍等,我正在为您搜索符合条件的岗位..." } // ChatService 对话服务 type ChatService struct { cfg *config.Config llmClient *client.LLMClient ocrClient *client.OCRClient locationService *LocationService jobService *JobService policyService *PolicyService } // NewChatService 创建对话服务 func NewChatService( cfg *config.Config, llmClient *client.LLMClient, ocrClient *client.OCRClient, locationService *LocationService, jobService *JobService, policyService *PolicyService, ) *ChatService { return &ChatService{ cfg: cfg, llmClient: llmClient, ocrClient: ocrClient, locationService: locationService, jobService: jobService, policyService: policyService, } } // ProcessChatRequest 处理聊天请求 func (s *ChatService) ProcessChatRequest(req *model.ChatCompletionRequest) (*model.ChatCompletionResponse, error) { // 准备消息 messages := s.prepareMessages(req.Messages) // 添加工具定义 tools := model.GetAvailableTools() // 检测是否是岗位查询意图 isJobIntent := s.isJobQueryIntent(req.Messages) var toolChoice interface{} = "auto" if isJobIntent { // 岗位场景:强制要求调用工具 toolChoice = "required" log.Printf("检测到岗位查询意图,设置 tool_choice=required") } // 构建请求(使用配置文件中的实际模型名称) llmReq := &model.ChatCompletionRequest{ Model: s.cfg.LLM.Model, Messages: messages, Tools: tools, ToolChoice: toolChoice, Temperature: req.Temperature, TopP: req.TopP, MaxTokens: req.MaxTokens, } // 追踪是否已调用过岗位工具 jobToolCalled := false // 开始对话循环(支持多轮工具调用) maxIterations := 10 for i := 0; i < maxIterations; i++ { resp, err := s.llmClient.ChatCompletion(llmReq) if err != nil { return nil, fmt.Errorf("LLM请求失败: %w", err) } if len(resp.Choices) == 0 { return nil, fmt.Errorf("LLM返回空结果") } choice := resp.Choices[0] // 检查finish_reason,如果是"stop"表示对话已完成 if choice.FinishReason == "stop" && len(choice.Message.ToolCalls) == 0 { // 岗位意图场景下的幻觉检测 if isJobIntent && !jobToolCalled { if content, ok := choice.Message.Content.(string); ok { if s.containsJobHallucination(content) { log.Printf("拦截岗位幻觉输出,强制重新调用工具") // 修改消息,添加系统提示强制调用工具 llmReq.Messages = append(llmReq.Messages, model.Message{ Role: "user", Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", }) llmReq.ToolChoice = s.getJobToolChoice() continue } } } log.Printf("模型返回finish_reason=stop,对话结束") return resp, nil } // 如果没有工具调用,返回最终结果 if len(choice.Message.ToolCalls) == 0 { // 岗位意图场景下的幻觉检测 if isJobIntent && !jobToolCalled { if content, ok := choice.Message.Content.(string); ok { if s.containsJobHallucination(content) { log.Printf("拦截岗位幻觉输出,强制重新调用工具") llmReq.Messages = append(llmReq.Messages, model.Message{ Role: "user", Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", }) llmReq.ToolChoice = s.getJobToolChoice() continue } } } return resp, nil } // 处理工具调用 log.Printf("检测到工具调用,finish_reason: %s", choice.FinishReason) llmReq.Messages = append(llmReq.Messages, choice.Message) for _, toolCall := range choice.Message.ToolCalls { // 检查是否是岗位工具调用 if toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation" { jobToolCalled = true } result, err := s.executeToolCall(&toolCall) if err != nil { result = fmt.Sprintf("工具调用失败: %s", err.Error()) log.Printf("工具调用失败 [%s]: %v", toolCall.Function.Name, err) } // 添加工具响应 llmReq.Messages = append(llmReq.Messages, model.Message{ Role: "tool", Content: result, ToolCallID: toolCall.ID, }) } // 岗位工具调用成功后,恢复为 auto 模式 if jobToolCalled { llmReq.ToolChoice = "auto" } } return nil, fmt.Errorf("超过最大工具调用次数") } // ProcessChatRequestStream 处理聊天请求(流式) func (s *ChatService) ProcessChatRequestStream(ctx context.Context, req *model.ChatCompletionRequest) (chan *model.ChatCompletionChunk, chan error) { chunkChan := make(chan *model.ChatCompletionChunk, 100) errChan := make(chan error, 1) go func() { defer close(chunkChan) defer close(errChan) // 准备消息 messages := s.prepareMessages(req.Messages) // 添加工具定义 tools := model.GetAvailableTools() // 检测是否是岗位查询意图 isJobIntent := s.isJobQueryIntent(req.Messages) var toolChoice interface{} = "auto" if isJobIntent { // 岗位场景:强制要求调用工具 toolChoice = "required" log.Printf("【流式】检测到岗位查询意图,设置 tool_choice=required") } // 构建请求(使用配置文件中的实际模型名称) llmReq := &model.ChatCompletionRequest{ Model: s.cfg.LLM.Model, Messages: messages, Tools: tools, ToolChoice: toolChoice, Temperature: req.Temperature, TopP: req.TopP, MaxTokens: req.MaxTokens, Stream: true, } // 追踪是否已发送第一个chunk(用于正确设置role) firstChunkSent := false // 追踪是否已调用过岗位工具 jobToolCalled := false // 追踪是否已经发送过幻觉拦截消息(避免重复发送) hallucinationIntercepted := false // 开始对话循环 maxIterations := 10 for iteration := 0; iteration < maxIterations; iteration++ { // 检查context是否已取消 select { case <-ctx.Done(): log.Printf("请求被取消: %v", ctx.Err()) return default: } responseChan, respErrChan, err := s.llmClient.ChatCompletionStream(llmReq) if err != nil { errChan <- fmt.Errorf("LLM流式请求失败: %w", err) return } var currentMessage model.Message var toolCalls []model.ToolCall var finishReason string currentMessage.Role = "assistant" // 用于合并重复日志的计数器 filteredToolCallsCount := 0 filteredFinishReasonCount := 0 // 用于岗位幻觉检测的内容缓冲(岗位场景下先缓冲,检测后再决定是否转发) var contentBuffer strings.Builder var pendingChunks []*model.ChatCompletionChunk // 收集流式响应 for { select { case <-ctx.Done(): // context被取消,停止处理 log.Printf("流式处理被取消: %v", ctx.Err()) return case chunk, ok := <-responseChan: if !ok { responseChan = nil break } // 处理chunk的role字段:只有第一个chunk保留role,后续chunk清除role if len(chunk.Choices) > 0 { if !firstChunkSent { // 第一个chunk,确保有role="assistant" if chunk.Choices[0].Delta.Role == "" { chunk.Choices[0].Delta.Role = "assistant" } firstChunkSent = true } else { // 后续chunks,清除role让omitempty生效 chunk.Choices[0].Delta.Role = "" } } // 收集消息内容和finish_reason(在转发之前) if len(chunk.Choices) > 0 { delta := chunk.Choices[0].Delta if content, ok := delta.Content.(string); ok && content != "" { if currentMessage.Content == nil { currentMessage.Content = "" } currentMessage.Content = currentMessage.Content.(string) + content // 同时写入缓冲区 contentBuffer.WriteString(content) } // 收集工具调用(注意:流式响应中工具调用可能分块到达) if len(delta.ToolCalls) > 0 { toolCalls = append(toolCalls, delta.ToolCalls...) } // 收集finish_reason if chunk.Choices[0].FinishReason != "" { finishReason = chunk.Choices[0].FinishReason log.Printf("收到finish_reason: %s", finishReason) } } // 只转发内容chunk,不转发包含tool_calls或finish_reason=tool_calls的chunk // 因为我们的工具调用是在服务端自动处理的,不需要客户端参与 shouldForward := true if len(chunk.Choices) > 0 { delta := chunk.Choices[0].Delta // 如果chunk包含tool_calls,不转发 if len(delta.ToolCalls) > 0 { shouldForward = false filteredToolCallsCount++ } // 如果finish_reason是tool_calls,不转发 if chunk.Choices[0].FinishReason == "tool_calls" { shouldForward = false filteredFinishReasonCount++ } } // 岗位意图场景下的特殊处理:先缓冲内容,检测幻觉后再决定转发 if shouldForward && isJobIntent && !jobToolCalled && len(toolCalls) == 0 { // 岗位场景且未调用工具:缓冲chunk,稍后检测 chunkCopy := *chunk pendingChunks = append(pendingChunks, &chunkCopy) } else if shouldForward { // 非岗位场景或已调用工具:直接转发 chunkChan <- chunk } case err, ok := <-respErrChan: if ok && err != nil { errChan <- err return } } if responseChan == nil { break } } // 输出合并后的过滤日志 if filteredToolCallsCount > 0 { log.Printf("过滤tool_calls chunk x%d,不转发给客户端", filteredToolCallsCount) } if filteredFinishReasonCount > 0 { log.Printf("过滤finish_reason=tool_calls chunk x%d,不转发给客户端", filteredFinishReasonCount) } // 岗位意图场景下的幻觉检测 if isJobIntent && !jobToolCalled && len(toolCalls) == 0 { bufferedContent := contentBuffer.String() if s.containsJobHallucination(bufferedContent) { log.Printf("【流式】拦截岗位幻觉输出,内容长度: %d,丢弃缓冲的 %d 个chunks", len(bufferedContent), len(pendingChunks)) // 不转发幻觉内容,发送警告消息并强制重新调用工具 if !hallucinationIntercepted { hallucinationIntercepted = true // 发送警告消息给用户 warningChunk := &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: ExposedModelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{ Content: s.getHallucinationWarningMessage(), }, }, }, } chunkChan <- warningChunk } // 修改消息,强制调用工具 llmReq.Messages = append(llmReq.Messages, model.Message{ Role: "user", Content: "请调用岗位查询工具获取真实数据,不要自行编造岗位信息。", }) llmReq.ToolChoice = s.getJobToolChoice() continue } else { // 没有幻觉,转发缓冲的chunks for _, pendingChunk := range pendingChunks { chunkChan <- pendingChunk } } } // 根据finish_reason决定是否继续 // 如果finish_reason是"stop",表示模型认为对话已完成,应该结束 if finishReason == "stop" { log.Printf("模型返回finish_reason=stop,对话结束") return } // 如果没有工具调用,结束 if len(toolCalls) == 0 { return } // 合并工具调用 currentMessage.ToolCalls = s.mergeToolCalls(toolCalls) llmReq.Messages = append(llmReq.Messages, currentMessage) // 执行工具调用并继续对话 for _, toolCall := range currentMessage.ToolCalls { // 检查是否是岗位工具调用 if toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation" { jobToolCalled = true } result, err := s.executeToolCall(&toolCall) var callSuccess bool if err != nil { result = fmt.Sprintf("工具调用失败: %s", err.Error()) log.Printf("工具调用失败 [%s]: %v", toolCall.Function.Name, err) callSuccess = false } else { callSuccess = true } // 检查是否是岗位查询工具,且调用成功,需要分块输出 if callSuccess && (toolCall.Function.Name == "queryJobsByArea" || toolCall.Function.Name == "queryJobsByLocation") { // 分块输出岗位信息 if err := s.streamJobResults(chunkChan, result, ExposedModelName); err != nil { log.Printf("流式输出岗位失败: %v", err) } // 岗位展示完成后,直接发送一个空的final chunk结束对话 // 这样客户端会正确识别对话已完成,不会再发起后续请求 finalChunk := &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: ExposedModelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{}, FinishReason: "stop", }, }, } chunkChan <- finalChunk log.Printf("岗位推荐完成,发送finish_reason=stop并结束") return } // 确保result不为空 if result == "" { result = "工具执行完成" } llmReq.Messages = append(llmReq.Messages, model.Message{ Role: "tool", Content: result, ToolCallID: toolCall.ID, }) } // 岗位工具调用成功后,恢复为 auto 模式 if jobToolCalled { llmReq.ToolChoice = "auto" } // 发送一个提示chunk,表示正在处理工具调用 // role留空,因为这不是第一个chunk chunkChan <- &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: ExposedModelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{ Content: "\n\n", }, }, }, } } errChan <- fmt.Errorf("超过最大工具调用次数") }() return chunkChan, errChan } // prepareMessages 准备消息列表 func (s *ChatService) prepareMessages(userMessages []model.Message) []model.Message { messages := []model.Message{ { Role: "system", Content: model.GetSystemPrompt(), }, } // 处理用户消息,支持 OpenAI Vision API 格式的文件URL消息 for _, msg := range userMessages { processedMsg := s.processMessageWithFileURLs(msg) messages = append(messages, processedMsg) } return messages } // processMessageWithFileURLs 处理消息中的文件URL,使用OCR服务解析 // 支持 OpenAI Vision API 兼容格式(image_url 字段),可解析图片、PDF、Excel、PPT 等文件 func (s *ChatService) processMessageWithFileURLs(msg model.Message) model.Message { // 检查 Content 是否是数组类型(OpenAI Vision API 格式) contentArray, ok := msg.Content.([]interface{}) if !ok { // 不是数组,直接返回原消息 return msg } var textParts []string var imageContents []string for _, item := range contentArray { itemMap, ok := item.(map[string]interface{}) if !ok { continue } contentType, _ := itemMap["type"].(string) switch contentType { case "text": if text, ok := itemMap["text"].(string); ok { textParts = append(textParts, text) } case "image_url": // 处理图片 URL imageURLData, ok := itemMap["image_url"].(map[string]interface{}) if !ok { continue } imageURL, ok := imageURLData["url"].(string) if !ok || imageURL == "" { continue } // 使用 OCR 服务解析图片 log.Printf("检测到图片URL,使用OCR服务解析: %s", imageURL) ocrContent, err := s.ocrClient.ParseURL(imageURL) if err != nil { log.Printf("OCR解析图片失败: %v", err) imageContents = append(imageContents, fmt.Sprintf("[图片解析失败: %s]", err.Error())) } else { log.Printf("OCR解析图片成功,内容长度: %d", len(ocrContent)) // 检测OCR内容是否是简历 if isResumeContent(ocrContent) { // 是简历,正常处理 imageContents = append(imageContents, fmt.Sprintf("[用户上传的简历内容]:\n%s", ocrContent)) } else { // 不是简历,添加提示让模型先询问用户意图 imageContents = append(imageContents, fmt.Sprintf("[用户上传的图片内容(非简历格式)]:\n%s\n\n[重要提示]: 该图片内容不像标准简历,请先询问用户上传这张图片的意图是什么,确认用户需求后再提供相应帮助。不要直接假设用户想找工作。", ocrContent)) log.Printf("OCR内容不是简历格式,已添加询问用户意图的提示") } } } } // 合并文本和图片内容 var finalContent string if len(textParts) > 0 { finalContent = strings.Join(textParts, "\n") } if len(imageContents) > 0 { if finalContent != "" { finalContent += "\n\n" } finalContent += strings.Join(imageContents, "\n\n") } return model.Message{ Role: msg.Role, Content: finalContent, Name: msg.Name, } } // executeToolCall 执行工具调用 func (s *ChatService) executeToolCall(toolCall *model.ToolCall) (string, error) { funcName := toolCall.Function.Name arguments := toolCall.Function.Arguments log.Printf("执行工具调用: %s", funcName) log.Printf("工具参数: %s", arguments) // 验证参数不为空 if arguments == "" { return "", fmt.Errorf("工具参数为空") } // 解析参数 var params map[string]interface{} if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { log.Printf("参数解析失败,原始参数: [%s]", arguments) return "", fmt.Errorf("解析工具参数失败: %w", err) } log.Printf("解析后的参数: %+v", params) // 根据工具名称执行相应操作 switch funcName { case "queryLocation": return s.handleQueryLocation(params) case "queryJobsByArea": return s.handleQueryJobsByArea(params) case "queryJobsByLocation": return s.handleQueryJobsByLocation(params) case "parsePDF": return s.handleParsePDF(params) case "parseImage": return s.handleParseImage(params) case "queryPolicy": return s.handleQueryPolicy(params) default: return "", fmt.Errorf("未知的工具: %s", funcName) } } // handleQueryLocation 处理地理位置查询 func (s *ChatService) handleQueryLocation(params map[string]interface{}) (string, error) { keywords, ok := params["keywords"].(string) if !ok { return "", fmt.Errorf("缺少keywords参数") } lat, lng, err := s.locationService.QueryLocation(keywords) if err != nil { return "", err } result := map[string]string{ "keywords": keywords, "latitude": lat, "longitude": lng, "message": fmt.Sprintf("成功获取地点 %s 的坐标", keywords), } return utils.ToJSONStringPretty(result) } // handleQueryJobsByArea 处理按区域查询岗位 func (s *ChatService) handleQueryJobsByArea(params map[string]interface{}) (string, error) { return s.jobService.QueryJobsByArea(params) } // handleQueryJobsByLocation 处理按位置查询岗位 func (s *ChatService) handleQueryJobsByLocation(params map[string]interface{}) (string, error) { return s.jobService.QueryJobsByLocation(params) } // handleParsePDF 处理PDF解析 func (s *ChatService) handleParsePDF(params map[string]interface{}) (string, error) { _, ok := params["fileUrl"].(string) if !ok { return "", fmt.Errorf("缺少fileUrl参数") } // 这里应该调用OCR服务解析,但由于URL可能是本地路径,需要特殊处理 // 实际使用时,文件已在上传阶段被OCR服务解析,此工具仅作为备用 return "", fmt.Errorf("PDF解析功能需要配合文件上传使用") } // handleParseImage 处理图片解析 func (s *ChatService) handleParseImage(params map[string]interface{}) (string, error) { _, ok := params["imageUrl"].(string) if !ok { return "", fmt.Errorf("缺少imageUrl参数") } // 这里应该调用视觉模型解析,但由于URL可能是本地路径,需要特殊处理 // 实际使用时,文件已在上传阶段被解析,此工具仅作为备用 return "", fmt.Errorf("图片解析功能需要配合文件上传使用") } // handleQueryPolicy 处理政策咨询 func (s *ChatService) handleQueryPolicy(params map[string]interface{}) (string, error) { message, ok := params["message"].(string) if !ok || message == "" { return "", fmt.Errorf("缺少message参数") } // 可选参数 chatID := "" conversationID := "" realName := false aac001 := "" aac147 := "" aac003 := "" if v, ok := params["chatId"].(string); ok { chatID = v } if v, ok := params["conversationId"].(string); ok { conversationID = v } if v, ok := params["realName"].(bool); ok { realName = v } if v, ok := params["aac001"].(string); ok { aac001 = v } if v, ok := params["aac147"].(string); ok { aac147 = v } if v, ok := params["aac003"].(string); ok { aac003 = v } // 如果是实名咨询,验证必要参数 if realName && (aac001 == "" || aac147 == "" || aac003 == "") { return "", fmt.Errorf("实名咨询需要提供个人编号(aac001)、身份证号(aac147)和姓名(aac003)") } // 调用政策咨询服务 responseMsg, newChatID, newConversationID, err := s.policyService.QueryPolicy( message, chatID, conversationID, realName, aac001, aac147, aac003, ) if err != nil { return "", err } // 构建返回结果(包含chatID和conversationID供后续多轮对话使用) result := map[string]interface{}{ "message": responseMsg, "chatId": newChatID, "conversationId": newConversationID, } resultJSON, err := json.Marshal(result) if err != nil { return "", fmt.Errorf("序列化结果失败: %w", err) } return string(resultJSON), nil } // mergeToolCalls 合并流式响应中的工具调用 // 流式响应中,每个工具调用会分成多个chunk,每个chunk可能只包含几个字符 // 需要按照每个chunk中delta.tool_calls的index字段来正确合并 func (s *ChatService) mergeToolCalls(toolCalls []model.ToolCall) []model.ToolCall { if len(toolCalls) == 0 { return nil } // 使用map按index合并 mergedMap := make(map[int]*model.ToolCall) maxIndex := -1 for _, tc := range toolCalls { // 获取index idx := 0 if tc.Index != nil { idx = *tc.Index } if idx > maxIndex { maxIndex = idx } // 如果该index已存在,合并数据 if existing, ok := mergedMap[idx]; ok { // 合并ID(只在第一次出现时设置) if tc.ID != "" { existing.ID = tc.ID } // 合并Type if tc.Type != "" { existing.Type = tc.Type } // 累加函数名称 if tc.Function.Name != "" { existing.Function.Name += tc.Function.Name } // 累加参数 if tc.Function.Arguments != "" { existing.Function.Arguments += tc.Function.Arguments } } else { // 新的工具调用 tcCopy := tc mergedMap[idx] = &tcCopy } } // 按index顺序转换为数组 result := make([]model.ToolCall, 0, len(mergedMap)) for i := 0; i <= maxIndex; i++ { if tc, ok := mergedMap[i]; ok { // 验证工具调用是否完整 if tc.Function.Name != "" && tc.Function.Arguments != "" { result = append(result, *tc) log.Printf("合并后的工具调用 [%d]: %s, 参数长度: %d", i, tc.Function.Name, len(tc.Function.Arguments)) } else { log.Printf("警告:工具调用 [%d] 不完整 - Name: '%s', Args length: %d", i, tc.Function.Name, len(tc.Function.Arguments)) } } } return result } // streamJobResults 流式输出岗位结果(分块,每个岗位间隔1秒) func (s *ChatService) streamJobResults(chunkChan chan *model.ChatCompletionChunk, jobsJSON string, modelName string) error { // 解析岗位列表 var jobResp model.JobResponse if err := json.Unmarshal([]byte(jobsJSON), &jobResp); err != nil { return fmt.Errorf("解析岗位数据失败: %w", err) } if len(jobResp.JobListings) == 0 { // 没有岗位,发送提示信息(role留空,不是第一个chunk) chunk := &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: modelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{ Content: "\n\n未找到符合条件的岗位。\n", }, }, }, } chunkChan <- chunk return nil } // 发送提示信息(role留空,不是第一个chunk) introChunk := &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d", time.Now().Unix()), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: modelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{ Content: fmt.Sprintf("\n\n为您找到 %d 个相关岗位:\n\n", len(jobResp.JobListings)), }, }, }, } chunkChan <- introChunk // 逐个输出岗位,每个间隔1秒 for i, job := range jobResp.JobListings { // 如果是最后一个岗位且有data字段,添加data if i == len(jobResp.JobListings)-1 && jobResp.Data != nil { job.Data = jobResp.Data } // 将岗位格式化为JSON jobJSON, err := json.MarshalIndent(job, "", " ") if err != nil { log.Printf("格式化岗位失败: %v", err) continue } // 使用 ``` job-json 包裹 jobContent := fmt.Sprintf("``` job-json\n%s\n```\n\n", string(jobJSON)) // 发送岗位chunk(role留空,不是第一个chunk) jobChunk := &model.ChatCompletionChunk{ ID: fmt.Sprintf("chatcmpl-%d-%d", time.Now().Unix(), i), Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: modelName, Choices: []model.ChunkChoice{ { Index: 0, Delta: model.Message{ Content: jobContent, }, }, }, } chunkChan <- jobChunk // 间隔1秒(除了最后一个) if i < len(jobResp.JobListings)-1 { time.Sleep(1 * time.Second) } } return nil }