Files
ai_job_chat_agent/internal/service/chat_service.go
2026-01-12 11:33:43 +08:00

1043 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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), &params); 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))
// 发送岗位chunkrole留空不是第一个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
}