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