package main import ( "context" "database/sql" "encoding/base64" "encoding/json" "fmt" "log" "os" "path/filepath" "regexp" "strings" "time" "github.com/google/generative-ai-go/genai" _ "modernc.org/sqlite" "google.golang.org/api/option" ) type Question struct { ID int `json:"id"` Source string `json:"source"` Category string `json:"category"` QuestionText string `json:"questionText"` Choice1 string `json:"choice1"` Choice2 string `json:"choice2"` Choice3 string `json:"choice3"` Choice4 string `json:"choice4"` Choice5 string `json:"choice5"` CorrectAnswer int `json:"correctAnswer"` CorrectAnswerStr string `json:"correctAnswerStr"` Explanation string `json:"explanation"` Difficulty string `json:"difficulty"` Passage string `json:"passage"` ImageUrl string `json:"imageUrl"` GeminiExplanation string `json:"geminiExplanation"` IsBookmarked bool `json:"isBookmarked"` Subject string `json:"subject"` CorrectAnswers string `json:"correctAnswers"` Oid int `json:"oid"` } type DescriptiveGradingRecord struct { ID int `json:"id"` QuestionID int `json:"questionId"` Source string `json:"source"` UserAnswer string `json:"userAnswer"` Score int `json:"score"` Evaluation string `json:"evaluation"` Username string `json:"username"` CreatedAt string `json:"createdAt"` } type CategoryInfo struct { Name string `json:"name"` Count int `json:"count"` } type TargetInfo struct { Name string `json:"name"` Count int `json:"count"` } type QuizService struct { db *sql.DB geminiKey string } func NewQuizService() *QuizService { dbPath := os.Getenv("DB_PATH") if dbPath == "" { // Prefer the database in the parent directory first if it exists, // as it contains the real quiz database for development. parentPath := filepath.Join("..", "qple_quiz.db") if _, err := os.Stat(parentPath); err == nil { dbPath = parentPath } else { dbPath = "qple_quiz.db" } } log.Printf("Connecting to database at: %s", dbPath) db, err := sql.Open("sqlite", dbPath) if err != nil { log.Fatalf("Failed to open database: %v", err) } // Create QuizExplain table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS QuizExplain ( id INTEGER NOT NULL, source TEXT NOT NULL, explain TEXT, PRIMARY KEY (source, id), FOREIGN KEY (source, id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE ) `) if err != nil { log.Fatalf("Failed to create QuizExplain table: %v", err) } // Create Bookmark table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS Bookmark ( question_id INTEGER NOT NULL, source TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (source, question_id), FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE ) `) if err != nil { log.Fatalf("Failed to create Bookmark table: %v", err) } // Create DescriptiveGrading table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS DescriptiveGrading ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, source TEXT NOT NULL, user_answer TEXT NOT NULL, score INTEGER NOT NULL, evaluation TEXT NOT NULL, username TEXT NOT NULL DEFAULT 'admin', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE ) `) if err != nil { log.Fatalf("Failed to create DescriptiveGrading table: %v", err) } // Ensure subject column exists in QuizQuestions table var hasSubject int err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='subject'").Scan(&hasSubject) if err == nil && hasSubject == 0 { _, err = db.Exec("ALTER TABLE QuizQuestions ADD COLUMN subject TEXT DEFAULT ''") if err != nil { log.Printf("Warning: Failed to add subject column: %v", err) } else { log.Println("Database Migration: Added subject column to QuizQuestions table.") } } // Ensure correct_answers column exists in QuizQuestions table var hasCorrectAnswers int err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='correct_answers'").Scan(&hasCorrectAnswers) if err == nil && hasCorrectAnswers == 0 { _, err = db.Exec("ALTER TABLE QuizQuestions ADD COLUMN correct_answers TEXT DEFAULT ''") if err != nil { log.Printf("Warning: Failed to add correct_answers column: %v", err) } else { log.Println("Database Migration: Added correct_answers column to QuizQuestions table.") } } return &QuizService{ db: db, // Initialize this via some UI setting later, or env var geminiKey: "", } } // SetGeminiKey sets the API key for Gemini calls func (s *QuizService) SetGeminiKey(key string) { s.geminiKey = key } func (s *QuizService) GetCategories() []CategoryInfo { rows, err := s.db.Query("SELECT category, COUNT(*) as count FROM QuizQuestions WHERE category IS NOT NULL AND category != '' GROUP BY category") if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var categories []CategoryInfo for rows.Next() { var cat CategoryInfo if err := rows.Scan(&cat.Name, &cat.Count); err != nil { continue } categories = append(categories, cat) } return categories } func (s *QuizService) GetTargets(category string) []TargetInfo { rows, err := s.db.Query("SELECT difficulty, COUNT(*) as count FROM QuizQuestions WHERE category = ? AND difficulty IS NOT NULL AND difficulty != '' GROUP BY difficulty", category) if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var targets []TargetInfo for rows.Next() { var t TargetInfo if err := rows.Scan(&t.Name, &t.Count); err != nil { continue } targets = append(targets, t) } return targets } func (s *QuizService) GetSubjects(category, difficulty string) []string { rows, err := s.db.Query("SELECT DISTINCT subject FROM QuizQuestions WHERE category = ? AND difficulty = ? AND subject IS NOT NULL AND subject != '' ORDER BY subject", category, difficulty) if err != nil { log.Printf("Query error in GetSubjects: %v", err) return nil } defer rows.Close() var subjects []string for rows.Next() { var sub string if err := rows.Scan(&sub); err != nil { continue } subjects = append(subjects, sub) } return subjects } func (s *QuizService) GetQuestions(category, difficulty, subject string) []Question { query := ` SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5, q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url, COALESCE(e.explain, '') as geminiExplanation, EXISTS(SELECT 1 FROM Bookmark WHERE source = q.source AND question_id = q.id) as isBookmarked, COALESCE(q.subject, '') as subject, COALESCE(q.correct_answers, '') as correct_answers, COALESCE(q.oid, 0) as oid FROM QuizQuestions q LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id WHERE q.category = ? ` var args []interface{} args = append(args, category) if difficulty != "" && difficulty != "All" { query += " AND q.difficulty = ?" args = append(args, difficulty) } if subject != "" && subject != "All" && subject != "dummy" { query += " AND q.subject = ?" args = append(args, subject) } rows, err := s.db.Query(query, args...) if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var questions []Question for rows.Next() { var q Question err := rows.Scan( &q.ID, &q.Source, &q.Category, &q.QuestionText, &q.Choice1, &q.Choice2, &q.Choice3, &q.Choice4, &q.Choice5, &q.CorrectAnswer, &q.CorrectAnswerStr, &q.Explanation, &q.Difficulty, &q.Passage, &q.ImageUrl, &q.GeminiExplanation, &q.IsBookmarked, &q.Subject, &q.CorrectAnswers, &q.Oid, ) if err != nil { log.Printf("Scan error: %v", err) continue } questions = append(questions, q) } return questions } // AddBookmark adds a source and question ID to the Bookmark table func (s *QuizService) AddBookmark(source string, questionID int) error { _, err := s.db.Exec("INSERT OR IGNORE INTO Bookmark (source, question_id) VALUES (?, ?)", source, questionID) return err } // RemoveBookmark removes a source and question ID from the Bookmark table func (s *QuizService) RemoveBookmark(source string, questionID int) error { _, err := s.db.Exec("DELETE FROM Bookmark WHERE source = ? AND question_id = ?", source, questionID) return err } // GetBookmarkedCategories retrieves categories that have bookmarked questions func (s *QuizService) GetBookmarkedCategories() []CategoryInfo { query := ` SELECT q.category, COUNT(*) as count FROM Bookmark b JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id WHERE q.category IS NOT NULL AND q.category != '' GROUP BY q.category ` rows, err := s.db.Query(query) if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var categories []CategoryInfo for rows.Next() { var cat CategoryInfo if err := rows.Scan(&cat.Name, &cat.Count); err != nil { continue } categories = append(categories, cat) } return categories } // GetBookmarkedTargets retrieves difficulties (targets) that have bookmarked questions within a category func (s *QuizService) GetBookmarkedTargets(category string) []TargetInfo { query := ` SELECT q.difficulty, COUNT(*) as count FROM Bookmark b JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id WHERE q.category = ? AND q.difficulty IS NOT NULL AND q.difficulty != '' GROUP BY q.difficulty ` rows, err := s.db.Query(query, category) if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var targets []TargetInfo for rows.Next() { var t TargetInfo if err := rows.Scan(&t.Name, &t.Count); err != nil { continue } targets = append(targets, t) } return targets } // GetBookmarkedQuestions retrieves all bookmarked questions for a given category and difficulty func (s *QuizService) GetBookmarkedQuestions(category, difficulty string) []Question { query := ` SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5, q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url, COALESCE(e.explain, '') as geminiExplanation, 1 as isBookmarked, COALESCE(q.subject, '') as subject, COALESCE(q.correct_answers, '') as correct_answers, COALESCE(q.oid, 0) as oid FROM Bookmark b JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id WHERE q.category = ? AND q.difficulty = ? ` rows, err := s.db.Query(query, category, difficulty) if err != nil { log.Printf("Query error: %v", err) return nil } defer rows.Close() var questions []Question for rows.Next() { var q Question err := rows.Scan( &q.ID, &q.Source, &q.Category, &q.QuestionText, &q.Choice1, &q.Choice2, &q.Choice3, &q.Choice4, &q.Choice5, &q.CorrectAnswer, &q.CorrectAnswerStr, &q.Explanation, &q.Difficulty, &q.Passage, &q.ImageUrl, &q.GeminiExplanation, &q.IsBookmarked, &q.Subject, &q.CorrectAnswers, &q.Oid, ) if err != nil { log.Printf("Scan error: %v", err) continue } questions = append(questions, q) } return questions } func (s *QuizService) SaveAiExplanation(source string, id int, explanation string) error { _, err := s.db.Exec(` INSERT INTO QuizExplain (source, id, explain) VALUES (?, ?, ?) ON CONFLICT(source, id) DO UPDATE SET explain=excluded.explain `, source, id, explanation) return err } func parseBase64Image(base64Str string) (string, []byte, error) { mimeType := "image/png" // default fallback rawData := base64Str if strings.HasPrefix(base64Str, "data:") { parts := strings.SplitN(base64Str, ";base64,", 2) if len(parts) == 2 { mimeType = strings.TrimPrefix(parts[0], "data:") rawData = parts[1] } } rawData = strings.ReplaceAll(rawData, "\n", "") rawData = strings.ReplaceAll(rawData, "\r", "") rawData = strings.ReplaceAll(rawData, " ", "") decoded, err := base64.StdEncoding.DecodeString(rawData) if err != nil { return "", nil, err } return mimeType, decoded, nil } func (s *QuizService) AskGemini(source string, id int, prompt string, base64Image string) (string, error) { if s.geminiKey == "" { return "", fmt.Errorf("Gemini API key is not set") } ctx := context.Background() client, err := genai.NewClient(ctx, option.WithAPIKey(s.geminiKey)) if err != nil { return "", err } defer client.Close() model := client.GenerativeModel("gemini-3.5-flash") // Using the latest model // Build parts (Prompt text + Optional base64 image + auto-extracted question assets) var parts []genai.Part parts = append(parts, genai.Text(prompt)) if base64Image != "" { mimeType, decodedData, err := parseBase64Image(base64Image) if err == nil { parts = append(parts, genai.Blob{ MIMEType: mimeType, Data: decodedData, }) log.Printf("Successfully attached base64 image asset to Gemini (MIME: %s, size: %d bytes)", mimeType, len(decodedData)) } else { log.Printf("Error decoding base64 image for Gemini: %v", err) } } // Automatically scan and attach all image assets associated with this question parts = s.attachQuestionAssets(parts, source, id) var resp *genai.GenerateContentResponse for attempt := 1; attempt <= 3; attempt++ { resp, err = model.GenerateContent(ctx, parts...) if err == nil { break } if strings.Contains(err.Error(), "429") { log.Printf("[Gemini] Request returned 429 (Rate Limit). Attempt %d of 3. Retrying in %d seconds...", attempt, attempt*2) time.Sleep(time.Duration(attempt*2) * time.Second) continue } return "", err } if err != nil { return "", err } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { return "", fmt.Errorf("No response from Gemini") } explanation := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0]) // Save to DB automatically err = s.SaveAiExplanation(source, id, explanation) if err != nil { log.Printf("Failed to save explanation to DB: %v", err) } return explanation, nil } func (s *QuizService) attachQuestionAssets(parts []genai.Part, source string, id int) []genai.Part { var questionText, choice1, choice2, choice3, choice4, choice5, passage, explanation, imageURL string queryQ := ` SELECT q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5, q.passage, q.explanation, q.image_url FROM QuizQuestions q WHERE q.source = ? AND q.id = ? ` _ = s.db.QueryRow(queryQ, source, id).Scan(&questionText, &choice1, &choice2, &choice3, &choice4, &choice5, &passage, &explanation, &imageURL) var aiExplanation string _ = s.db.QueryRow("SELECT explain FROM QuizExplain WHERE source = ? AND id = ?", source, id).Scan(&aiExplanation) // Combine all texts to scan for assets combinedTexts := strings.Join([]string{ questionText, choice1, choice2, choice3, choice4, choice5, passage, explanation, aiExplanation, imageURL, }, " \n ") // Scan for unique asset paths uniqueAssets := make(map[string]bool) assetRegex := regexp.MustCompile(`/assets/[a-zA-Z0-9_\-\.]+`) matches := assetRegex.FindAllString(combinedTexts, -1) for _, m := range matches { uniqueAssets[m] = true } for assetPath := range uniqueAssets { filename := strings.TrimPrefix(assetPath, "/assets/") var filePath string path1 := filepath.Join("frontend", "public", "assets", filename) path2 := filepath.Join("qple_quiz_app", "frontend", "public", "assets", filename) path3 := filepath.Join("..", "qple_quiz_app", "frontend", "public", "assets", filename) if _, err := os.Stat(path1); err == nil { filePath = path1 } else if _, err := os.Stat(path2); err == nil { filePath = path2 } else if _, err := os.Stat(path3); err == nil { filePath = path3 } if filePath != "" { data, err := os.ReadFile(filePath) if err == nil { ext := strings.ToLower(filepath.Ext(filePath)) mimeType := "image/png" // default fallback if ext == ".pdf" { mimeType = "application/pdf" } else if ext == ".jpg" || ext == ".jpeg" { mimeType = "image/jpeg" } else if ext == ".webp" { mimeType = "image/webp" } else if ext == ".gif" { mimeType = "image/gif" } parts = append(parts, genai.Blob{ MIMEType: mimeType, Data: data, }) log.Printf("Successfully attached local asset to Gemini: %s", filePath) } else { log.Printf("Error reading local asset for Gemini: %v", err) } } } return parts } func (s *QuizService) GetDescriptiveGradingHistory(source string, questionID int) []DescriptiveGradingRecord { query := ` SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') as created_at FROM DescriptiveGrading WHERE source = ? AND question_id = ? ORDER BY created_at DESC ` rows, err := s.db.Query(query, source, questionID) if err != nil { log.Printf("Query error in GetDescriptiveGradingHistory: %v", err) return nil } defer rows.Close() var records []DescriptiveGradingRecord for rows.Next() { var r DescriptiveGradingRecord err := rows.Scan(&r.ID, &r.QuestionID, &r.Source, &r.UserAnswer, &r.Score, &r.Evaluation, &r.Username, &r.CreatedAt) if err != nil { log.Printf("Scan error in GetDescriptiveGradingHistory: %v", err) continue } records = append(records, r) } return records } func (s *QuizService) GradeDescriptiveAnswer(source string, questionID int, userAnswer string) (*DescriptiveGradingRecord, error) { if s.geminiKey == "" { return nil, fmt.Errorf("Gemini API key is not set") } var qText, passage, modelAnswer string queryQ := `SELECT question_text, passage, explanation FROM QuizQuestions WHERE source = ? AND id = ?` err := s.db.QueryRow(queryQ, source, questionID).Scan(&qText, &passage, &modelAnswer) if err != nil { return nil, fmt.Errorf("Failed to query question details: %v", err) } if modelAnswer == "" { var aiExplain string errExplain := s.db.QueryRow("SELECT explain FROM QuizExplain WHERE source = ? AND id = ?", source, questionID).Scan(&aiExplain) if errExplain == nil && aiExplain != "" { modelAnswer = aiExplain } } ctx := context.Background() client, err := genai.NewClient(ctx, option.WithAPIKey(s.geminiKey)) if err != nil { return nil, err } defer client.Close() model := client.GenerativeModel("gemini-3.5-flash") prompt := fmt.Sprintf(`사용자가 작성한 주관식/서술형 답안을 모범답안과 문제에 대조하여 공정하게 채점해 주세요. [문제 정보] - 지문: %s - 문제: %s - 모범 답안: %s [사용자 제출 답안] %s 결과는 반드시 아래의 JSON 포맷으로만 응답해 주세요. 포맷 이외의 텍스트(Markdown 코드 블럭 등)는 포함하지 마세요. { "score": <0~100 사이의 정수 점수>, "evaluation": "<잘한 부분, 아쉬운 부분, 보완점에 대한 상세한 평가 내용 (한글 마크다운 형식)>" } `, passage, qText, modelAnswer, userAnswer) var parts []genai.Part parts = append(parts, genai.Text(prompt)) parts = s.attachQuestionAssets(parts, source, questionID) var resp *genai.GenerateContentResponse for attempt := 1; attempt <= 3; attempt++ { resp, err = model.GenerateContent(ctx, parts...) if err == nil { break } if strings.Contains(err.Error(), "429") { log.Printf("[Gemini] Grade request returned 429 (Rate Limit). Attempt %d of 3. Retrying in %d seconds...", attempt, attempt*2) time.Sleep(time.Duration(attempt*2) * time.Second) continue } return nil, err } if err != nil { return nil, err } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { return nil, fmt.Errorf("No response from Gemini") } respText := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0]) cleanJSON := strings.TrimSpace(respText) if strings.HasPrefix(cleanJSON, "```json") { cleanJSON = strings.TrimPrefix(cleanJSON, "```json") cleanJSON = strings.TrimSuffix(cleanJSON, "```") } else if strings.HasPrefix(cleanJSON, "```") { cleanJSON = strings.TrimPrefix(cleanJSON, "```") cleanJSON = strings.TrimSuffix(cleanJSON, "```") } cleanJSON = strings.TrimSpace(cleanJSON) var result struct { Score int `json:"score"` Evaluation string `json:"evaluation"` } err = json.Unmarshal([]byte(cleanJSON), &result) if err != nil { log.Printf("Failed to parse Gemini response JSON: %q, error: %v", cleanJSON, err) result.Score = 70 result.Evaluation = respText } stmt, err := s.db.Prepare(` INSERT INTO DescriptiveGrading (source, question_id, user_answer, score, evaluation, username) VALUES (?, ?, ?, ?, ?, 'admin') `) if err != nil { return nil, err } defer stmt.Close() res, err := stmt.Exec(source, questionID, userAnswer, result.Score, result.Evaluation) if err != nil { return nil, err } lastID, err := res.LastInsertId() if err != nil { return nil, err } var record DescriptiveGradingRecord err = s.db.QueryRow(` SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') FROM DescriptiveGrading WHERE id = ? `, lastID).Scan(&record.ID, &record.QuestionID, &record.Source, &record.UserAnswer, &record.Score, &record.Evaluation, &record.Username, &record.CreatedAt) if err != nil { return nil, err } return &record, nil }