package handler import ( "context" "encoding/json" "fmt" "resume-platform/internal/agent" "resume-platform/internal/model" aiservice "resume-platform/internal/service/ai" "resume-platform/internal/service/quiz" "resume-platform/internal/service/resume" "resume-platform/pkg/logger" "strings" "github.com/gin-gonic/gin" ) // AIHandler AI 能力处理器,封装简历润色、分析、题目生成、评分等 AI 相关接口 type AIHandler struct { aiService *aiservice.AIService questionGenerator *aiservice.QuestionGeneratorService resumeAgent *agent.ResumeAgent resumeService resume.ResumeService quizService quiz.QuizService } // NewAIHandler 创建 AI 处理器实例 // @param aiService AI 服务层 // @param questionGenerator 题目生成服务层 // @param resumeAgent AI Agent 实例 // @param resumeService 简历服务层 // @param quizService 答题服务层 // @return *AIHandler 处理器实例 // @author sunct func NewAIHandler(aiService *aiservice.AIService, questionGenerator *aiservice.QuestionGeneratorService, resumeAgent *agent.ResumeAgent, resumeService resume.ResumeService, quizService quiz.QuizService) *AIHandler { return &AIHandler{ aiService: aiService, questionGenerator: questionGenerator, resumeAgent: resumeAgent, resumeService: resumeService, quizService: quizService, } } // SetResumeAgent 设置 ResumeAgent(延迟初始化使用) // @param resumeAgent AI Agent 实例 func (h *AIHandler) SetResumeAgent(resumeAgent *agent.ResumeAgent) { h.resumeAgent = resumeAgent } // buildResumeText 将简历对象拼接为纯文本,用于 AI 关键词提取 // @param resume 简历数据 // @return string 拼接后的纯文本 // @author sunct func buildResumeText(resume *model.Resume) string { if resume == nil { return "" } var sb strings.Builder sb.WriteString(resume.BasicInfo.Name + " ") sb.WriteString(resume.BasicInfo.Title + " ") sb.WriteString(resume.BasicInfo.Summary + " ") sb.WriteString(resume.BasicInfo.Industry + " ") sb.WriteString(resume.BasicInfo.JobTarget + " ") for _, e := range resume.Experience { sb.WriteString(e.Company + " ") sb.WriteString(e.Position + " ") sb.WriteString(e.Description + " ") for _, h := range e.Highlights { sb.WriteString(h + " ") } } for _, s := range resume.Skills { sb.WriteString(s.Name + " ") sb.WriteString(s.Category + " ") } for _, p := range resume.Projects { sb.WriteString(p.Name + " ") sb.WriteString(p.Description + " ") for _, t := range p.TechStack { sb.WriteString(t + " ") } } return sb.String() } // PolishText 单字段润色接口,根据字段类型调用不同的 AI 润色 Prompt // @param c Gin 上下文 // @author sunct func (h *AIHandler) PolishText(c *gin.Context) { var req struct { Original string `json:"original"` FieldType string `json:"field_type"` Company string `json:"company,omitempty"` Position string `json:"position,omitempty"` ProjectName string `json:"project_name,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if req.Original == "" { BadRequest(c, "original text is required") return } var result string var err error switch req.FieldType { case "summary": result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original) case "experience": result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position) case "project": result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName) default: result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType) } if err != nil { Error(c, err.Error()) return } OK(c, gin.H{ "result": result, "provider": h.aiService.GetProviderName(), }) } // GetProviderInfo 获取当前使用的 AI 供应商名称 // @param c Gin 上下文 // @author sunct func (h *AIHandler) GetProviderInfo(c *gin.Context) { provider := h.aiService.GetProviderName() if h.resumeAgent != nil { provider = h.resumeAgent.GetProvider() } OK(c, gin.H{"provider": provider}) } // AgentPolish Agent 模式润色接口,优先使用 Agent 工具链,降级为普通 AI 润色 // @param c Gin 上下文 // @author sunct func (h *AIHandler) AgentPolish(c *gin.Context) { var req struct { Original string `json:"original"` FieldType string `json:"field_type"` Company string `json:"company,omitempty"` Position string `json:"position,omitempty"` ProjectName string `json:"project_name,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if req.Original == "" { BadRequest(c, "original text is required") return } context := "" if req.Company != "" { context += "公司:" + req.Company + " " } if req.Position != "" { context += "职位:" + req.Position + " " } if req.ProjectName != "" { context += "项目名称:" + req.ProjectName } if h.resumeAgent != nil { query := fmt.Sprintf("请帮我优化简历的%s字段。原文:%s", getFieldTypeName(req.FieldType), req.Original) if context != "" { query += "。上下文:" + context } result, err := h.resumeAgent.Run(c.Request.Context(), query) if err != nil { Error(c, err.Error()) return } OK(c, gin.H{ "result": result, "provider": h.resumeAgent.GetProvider(), "agent_mode": true, }) return } var result string var err error switch req.FieldType { case "summary": result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original) case "experience": result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position) case "project": result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName) default: result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType) } if err != nil { Error(c, err.Error()) return } OK(c, gin.H{ "result": result, "provider": h.aiService.GetProviderName(), "agent_mode": false, }) } // AgentAnalyze 简历整体分析接口,调用 Agent 对简历进行专业评估 // @param c Gin 上下文 // @author sunct func (h *AIHandler) AgentAnalyze(c *gin.Context) { var req struct { ResumeText string `json:"resume_text"` TargetPosition string `json:"target_position,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if req.ResumeText == "" { BadRequest(c, "resume_text is required") return } if h.resumeAgent != nil { query := "请分析以下简历内容并提供专业评估:" + req.ResumeText if req.TargetPosition != "" { query += "。目标职位:" + req.TargetPosition } result, err := h.resumeAgent.Run(c.Request.Context(), query) if err != nil { Error(c, err.Error()) return } OK(c, gin.H{ "result": result, "provider": h.resumeAgent.GetProvider(), }) return } OK(c, gin.H{ "result": "Eino Agent not available", "provider": h.aiService.GetProviderName(), }) } // GenerateQuestions AI 生成面试题接口,支持按简历内容或关键词出题 // @param c Gin 上下文 // @author sunct func (h *AIHandler) GenerateQuestions(c *gin.Context) { var req struct { ResumeID string `json:"resume_id,omitempty"` ResumeText string `json:"resume_text,omitempty"` Keywords string `json:"keywords,omitempty"` Types string `json:"types,omitempty"` Seed string `json:"seed,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if req.ResumeText == "" && req.ResumeID == "" && req.Keywords == "" { BadRequest(c, "resume_text, resume_id or keywords is required") return } resumeText := req.ResumeText keywords := req.Keywords // 当提供 resume_id 时,真正获取简历内容并提取文本 if req.ResumeID != "" { resume, err := h.resumeService.GetResume(req.ResumeID) if err != nil || resume == nil { Error(c, "简历不存在") return } text := buildResumeText(resume) if text != "" { // 简历文本作为基础,与已有 resumeText 合并 if resumeText != "" { resumeText = resumeText + " " + text } else { resumeText = text } } // 从简历技能中补充关键词 if resume != nil && len(resume.Skills) > 0 && keywords == "" { var skillNames []string for _, s := range resume.Skills { if s.Name != "" { skillNames = append(skillNames, s.Name) } } if len(skillNames) > 0 { keywords = strings.Join(skillNames, ",") } } } // 当只有 keywords 时,构造基础文本 if resumeText == "" && keywords != "" { resumeText = "基于关键词:" + keywords } if resumeText == "" { BadRequest(c, "无法获取简历内容或关键词") return } ctx := c.Request.Context() logger.CtxInfof(ctx, "[AIHandler] GenerateQuestions resume_id=%s, keywords=%s, resume_text_len=%d", req.ResumeID, keywords, len(resumeText)) var questions []map[string]interface{} var err error if h.questionGenerator != nil { logger.CtxInfof(ctx, "[AIHandler] Using AI question generator") questions, err = h.questionGenerator.GenerateQuestions(ctx, resumeText, keywords, req.Types) if err != nil { logger.CtxErrorf(ctx, "[AIHandler] GenerateQuestions failed: %v", err) Error(c, err.Error()) return } } else { logger.CtxInfof(ctx, "[AIHandler] Using fallback question generator") questions, err = h.generateFallbackQuestions(ctx, resumeText, keywords, req.Types, req.Seed) if err != nil { logger.CtxErrorf(ctx, "[AIHandler] generateFallbackQuestions failed: %v", err) Error(c, err.Error()) return } } if len(questions) == 0 { Error(c, "未找到与关键词匹配的题目,请尝试其他关键词") return } OK(c, gin.H{"questions": questions}) } // generateFallbackQuestions 备用题目生成方法,当 AI 服务不可用时使用本地题库 // @param resumeText 简历文本 // @param keywords 关键词 // @param types 题目类型 // @param seed 随机种子 // @return []map[string]interface{} 题目列表 // @return error 错误信息 // @author sunct func (h *AIHandler) generateFallbackQuestions(ctx context.Context, resumeText, keywords, types, seed string) ([]map[string]interface{}, error) { logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions keywords=%s, types=%s", keywords, types) tool := agent.NewGenerateQuestionsTool() args := map[string]interface{}{ "resume_text": resumeText, "keywords": keywords, "types": types, "seed": seed, "random": true, } argsJSON, err := json.Marshal(args) if err != nil { logger.CtxErrorf(ctx, "[AIHandler] Failed to marshal arguments: %v", err) return nil, fmt.Errorf("failed to marshal arguments: %w", err) } resultJSON, err := tool.InvokableRun(ctx, string(argsJSON)) if err != nil { logger.CtxErrorf(ctx, "[AIHandler] tool.InvokableRun failed: %v", err) return nil, err } var questions []map[string]interface{} if err := json.Unmarshal([]byte(resultJSON), &questions); err != nil { logger.CtxErrorf(ctx, "[AIHandler] Failed to parse questions: %v", err) return nil, fmt.Errorf("failed to parse questions: %w", err) } logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions success, count=%d", len(questions)) return questions, nil } // EvaluateAnswers 本地答题评分接口,选择/填空按答案比对,简答按非空判定 // @param c Gin 上下文 // @author sunct func (h *AIHandler) EvaluateAnswers(c *gin.Context) { var req struct { Questions []map[string]interface{} `json:"questions"` Answers map[string]string `json:"answers"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if len(req.Questions) == 0 { BadRequest(c, "questions is required") return } correctCount := 0 for i, question := range req.Questions { answerKey := fmt.Sprintf("%d", i) userAnswer := req.Answers[answerKey] correctAnswer := question["answer"] if correctAnswer == nil { continue } correctStr := fmt.Sprintf("%v", correctAnswer) if question["type"] == "mcq" || question["type"] == "fill" { if strings.EqualFold(strings.TrimSpace(userAnswer), strings.TrimSpace(correctStr)) { correctCount++ } } else if question["type"] == "sa" { if len(strings.TrimSpace(userAnswer)) > 0 { correctCount++ } } } totalQuestions := len(req.Questions) score := float64(correctCount) / float64(totalQuestions) * 100 OK(c, gin.H{ "score": score, "correct_count": correctCount, "total_count": totalQuestions, }) } // SubmitAnswers 提交答题记录接口,计算得分并保存答题记录到数据库 // @param c Gin 上下文 // @author sunct func (h *AIHandler) SubmitAnswers(c *gin.Context) { var req struct { Questions []map[string]interface{} `json:"questions"` UserAnswers map[string]string `json:"user_answers"` Score float64 `json:"score"` CorrectCount int `json:"correct_count"` TotalCount int `json:"total_count"` UnansweredCount int `json:"unanswered_count"` UserID string `json:"user_id,omitempty"` ResumeID string `json:"resume_id,omitempty"` ResumeRoute string `json:"resume_route,omitempty"` Title string `json:"title,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { BadRequest(c, err.Error()) return } if len(req.Questions) == 0 { BadRequest(c, "questions is required") return } questions := make([]model.QuizQuestion, 0, len(req.Questions)) for _, q := range req.Questions { question := model.QuizQuestion{ Type: getStringValue(q, "type"), Text: getStringValue(q, "text"), Answer: getStringValue(q, "answer"), Analysis: getStringValue(q, "analysis"), Score: getIntValue(q, "score"), Difficulty: getStringValue(q, "difficulty"), Category: getStringValue(q, "category"), } if options, ok := q["options"].([]interface{}); ok { question.Options = make([]string, 0, len(options)) for _, opt := range options { question.Options = append(question.Options, fmt.Sprintf("%v", opt)) } } if keywords, ok := q["keywords"].([]interface{}); ok { question.Keywords = make([]string, 0, len(keywords)) for _, kw := range keywords { question.Keywords = append(question.Keywords, fmt.Sprintf("%v", kw)) } } questions = append(questions, question) } record := &model.QuizRecord{ UserID: req.UserID, ResumeID: req.ResumeID, ResumeRoute: req.ResumeRoute, Title: req.Title, Questions: questions, UserAnswers: req.UserAnswers, Score: req.Score, CorrectCount: req.CorrectCount, TotalCount: req.TotalCount, } if h.quizService != nil { err := h.quizService.CreateQuizRecord(record) if err != nil { logger.CtxErrorf(c.Request.Context(), "[AIHandler] SubmitAnswers failed to save record: %v", err) } } message := fmt.Sprintf("答题完成!得分:%.1f分", req.Score) if req.UnansweredCount > 0 { message += fmt.Sprintf("(%d/%d正确,%d题未作答)", req.CorrectCount, req.TotalCount-req.UnansweredCount, req.UnansweredCount) } else { message += fmt.Sprintf("(%d/%d正确)", req.CorrectCount, req.TotalCount) } OK(c, gin.H{ "message": message, "score": req.Score, "correct_count": req.CorrectCount, "total_count": req.TotalCount, "unanswered_count": req.UnansweredCount, "record_id": record.ID, }, message) } func getStringValue(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { return fmt.Sprintf("%v", v) } return "" } func getIntValue(m map[string]interface{}, key string) int { if v, ok := m[key]; ok { switch val := v.(type) { case int: return val case float64: return int(val) default: return 0 } } return 0 } // getFieldTypeName 将字段类型英文标识转换为中文名称 // @param fieldType 字段类型 // @return string 中文名称 // @author sunct func getFieldTypeName(fieldType string) string { switch fieldType { case "summary": return "个人简介" case "experience": return "工作经历" case "project": return "项目描述" case "education": return "教育背景" case "skill": return "技能描述" default: return "内容" } }