Files
QUZ/quiz_service.go
T
2026-06-25 07:43:27 +09:00

711 lines
22 KiB
Go

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
}