Files
resume/internal/service/ai/question_generator.go

295 lines
7.5 KiB
Go

package ai
import (
"context"
"encoding/json"
"fmt"
"resume-platform/internal/ai"
"resume-platform/internal/prompt"
"resume-platform/pkg/logger"
"strings"
"sync"
)
type QuestionGeneratorService struct {
provider ai.Provider
}
func NewQuestionGeneratorService(provider ai.Provider) *QuestionGeneratorService {
return &QuestionGeneratorService{provider: provider}
}
func (s *QuestionGeneratorService) GenerateQuestions(ctx context.Context, resumeText, keywordsStr, typesStr string) ([]map[string]interface{}, error) {
if resumeText == "" && keywordsStr == "" {
return nil, fmt.Errorf("resume_text or keywords is required")
}
if resumeText == "" {
resumeText = "基于关键词:" + keywordsStr
}
techKeywords := extractTechKeywords(resumeText)
if len(techKeywords) == 0 {
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
}
if keywordsStr != "" {
techKeywords = strings.Split(keywordsStr, ",")
}
selectedTypes := parseTypes(typesStr)
logger.CtxInfof(ctx, "[QuestionGenerator] Generating questions for keywords: %v, types: %v", techKeywords, selectedTypes)
var wg sync.WaitGroup
resultChan := make(chan []map[string]interface{}, len(selectedTypes))
errChan := make(chan error, len(selectedTypes))
for _, qType := range selectedTypes {
wg.Add(1)
go func(t string) {
defer wg.Done()
prompt := buildQuestionGenerationPromptForType(resumeText, techKeywords, t)
logger.CtxInfof(ctx, "[QuestionGenerator] Generating %s questions...", t)
result, err := s.provider.Generate(ctx, prompt)
if err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] AI generate failed for %s: %v", t, err)
errChan <- err
return
}
questions, err := parseAIQuestionsWithCtx(ctx, result)
if err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] Parse failed for %s: %v", t, err)
errChan <- err
return
}
if len(questions) > 0 {
resultChan <- questions
logger.CtxInfof(ctx, "[QuestionGenerator] Generated %d %s questions", len(questions), t)
}
}(qType)
}
go func() {
wg.Wait()
close(resultChan)
close(errChan)
}()
var allQuestions []map[string]interface{}
for questions := range resultChan {
allQuestions = append(allQuestions, questions...)
}
logger.CtxInfof(ctx, "[QuestionGenerator] Total generated %d questions", len(allQuestions))
if len(allQuestions) == 0 {
return nil, fmt.Errorf("未能生成任何题目")
}
return allQuestions, nil
}
func buildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
return prompt.BuildQuestionGenerationPrompt(resumeText, keywords, types)
}
func buildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
return prompt.BuildQuestionGenerationPromptForType(resumeText, keywords, qType)
}
func parseAIQuestionsWithCtx(ctx context.Context, result string) ([]map[string]interface{}, error) {
result = strings.TrimSpace(result)
logger.CtxInfof(ctx, "[QuestionGenerator] AI response length: %d", len(result))
logger.CtxInfof(ctx, "[QuestionGenerator] AI response raw result (first 2000 chars): %s", substr(result, 0, 2000))
result = strings.ReplaceAll(result, "```json", "")
result = strings.ReplaceAll(result, "```", "")
result = strings.TrimSpace(result)
startIdx := strings.Index(result, "[")
endIdx := strings.LastIndex(result, "]")
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
logger.CtxErrorf(ctx, "[QuestionGenerator] Cannot find JSON array boundaries")
return nil, fmt.Errorf("无法找到JSON数组边界")
}
jsonStr := result[startIdx : endIdx+1]
logger.CtxInfof(ctx, "[QuestionGenerator] Extracted JSON array (length=%d)", len(jsonStr))
var questions []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &questions); err != nil {
logger.CtxErrorf(ctx, "[QuestionGenerator] Failed to unmarshal as array: %v", err)
logger.CtxErrorf(ctx, "[QuestionGenerator] Raw result that failed (first 500 chars): %s", substr(jsonStr, 0, 500))
return nil, fmt.Errorf("AI返回的JSON格式不正确: %w", err)
}
var validQuestions []map[string]interface{}
for _, q := range questions {
if !isValidQuestion(q) {
logger.CtxWarnf(ctx, "[QuestionGenerator] Skipping invalid question: %v", q)
continue
}
q = normalizeQuestion(q)
validQuestions = append(validQuestions, q)
}
logger.CtxInfof(ctx, "[QuestionGenerator] Successfully parsed %d valid questions (filtered %d invalid)", len(validQuestions), len(questions)-len(validQuestions))
if len(validQuestions) == 0 {
return nil, fmt.Errorf("未解析到有效题目")
}
return validQuestions, nil
}
func isValidQuestion(q map[string]interface{}) bool {
if q == nil {
return false
}
qType := fmt.Sprintf("%v", q["type"])
text := fmt.Sprintf("%v", q["text"])
answer := fmt.Sprintf("%v", q["answer"])
if qType == "" || text == "" || answer == "" {
return false
}
if qType == "mcq" {
if options, ok := q["options"].([]interface{}); ok {
if len(options) != 4 {
return false
}
for _, opt := range options {
if fmt.Sprintf("%v", opt) == "" {
return false
}
}
} else {
return false
}
answer = strings.ToUpper(strings.TrimSpace(answer))
if len(answer) != 1 || (answer < "A" || answer > "D") {
return false
}
}
if qType == "fill" {
if !strings.Contains(text, "___") {
return false
}
}
if qType == "sa" || qType == "algo" {
if len(answer) < 10 {
return false
}
}
analysis := fmt.Sprintf("%v", q["analysis"])
if len(analysis) < 30 {
return false
}
return true
}
func normalizeQuestion(q map[string]interface{}) map[string]interface{} {
qType := fmt.Sprintf("%v", q["type"])
if qType == "mcq" {
answer := strings.TrimSpace(fmt.Sprintf("%v", q["answer"]))
if len(answer) > 0 {
q["answer"] = strings.ToUpper(answer[:1])
}
if options, ok := q["options"].([]interface{}); ok {
var optionStrings []string
for _, opt := range options {
optStr := strings.TrimSpace(fmt.Sprintf("%v", opt))
optionStrings = append(optionStrings, optStr)
}
q["options"] = optionStrings
}
}
if _, ok := q["score"]; !ok {
diff := fmt.Sprintf("%v", q["difficulty"])
q["score"] = getScoreByDifficulty(diff)
}
if _, ok := q["category"]; !ok {
q["category"] = "综合"
}
if _, ok := q["keywords"]; !ok {
q["keywords"] = []string{}
}
if _, ok := q["options"]; !ok {
q["options"] = []string{}
}
if _, ok := q["analysis"]; !ok {
q["analysis"] = ""
}
if _, ok := q["difficulty"]; !ok {
q["difficulty"] = "初级"
}
return q
}
func isSingleLetter(s string) bool {
return len(s) == 1 && (s >= "A" && s <= "Z" || s >= "a" && s <= "z")
}
func substr(s string, start, length int) string {
if len(s) <= start {
return ""
}
end := start + length
if end > len(s) {
end = len(s)
}
return s[start:end]
}
func extractTechKeywords(text string) []string {
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
var result []string
for _, pattern := range techPatterns {
if strings.Contains(text, pattern) {
result = append(result, pattern)
}
}
return result
}
func parseTypes(typesStr string) []string {
if typesStr == "" {
return []string{"mcq", "fill", "sa", "algo"}
}
return strings.Split(typesStr, ",")
}
func getScoreByDifficulty(difficulty string) int {
switch difficulty {
case "入门":
return 5
case "初级":
return 10
case "中级":
return 15
case "进阶":
return 20
case "高级":
return 25
default:
return 10
}
}