首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 14:44:48 +08:00
commit a4fe393571
119 changed files with 29105 additions and 0 deletions
+594
View File
@@ -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 "内容"
}
}