首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
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 "内容"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user