Files

595 lines
16 KiB
Go
Raw Permalink 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 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 "内容"
}
}