295 lines
7.5 KiB
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
|
|
}
|
|
} |