package main import ( "context" "database/sql" "encoding/base64" "encoding/json" "fmt" "log" "math/rand" "os" "path/filepath" "regexp" "strings" "time" "github.com/google/generative-ai-go/genai" "github.com/wailsapp/wails/v3/pkg/application" "google.golang.org/api/option" _ "modernc.org/sqlite" ) type User struct { ID int `json:"id"` Username string `json:"username"` Password string `json:"password"` Nickname string `json:"nickname"` Phone string `json:"phone"` Status string `json:"status"` // 'pending', 'approved', 'rejected' ApiKey string `json:"apiKey"` CreatedAt string `json:"createdAt"` } 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"` OcrImageUrl string `json:"ocrImageUrl"` } 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 app *application.App } func (s *QuizService) SetApp(app *application.App) { s.app = app } 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 Users table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS Users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, nickname TEXT NOT NULL, phone TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { log.Fatalf("Failed to create Users table: %v", err) } // Create UserCategories table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS UserCategories ( username TEXT NOT NULL, category TEXT NOT NULL, PRIMARY KEY (username, category) ) `) if err != nil { log.Fatalf("Failed to create UserCategories table: %v", err) } // Insert default admin account if not exists var adminCount int err = db.QueryRow("SELECT COUNT(*) FROM Users WHERE username = 'admin'").Scan(&adminCount) if err == nil && adminCount == 0 { _, err = db.Exec("INSERT INTO Users (username, password, nickname, phone, status) VALUES ('admin', '150295', '관리자', '010-0000-0000', 'approved')") if err != nil { log.Printf("Warning: Failed to seed admin account: %v", err) } else { log.Println("Database Seed: Created default admin account.") } } // Create Bookmark table with username if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS Bookmark ( username TEXT NOT NULL DEFAULT 'admin', question_id INTEGER NOT NULL, source TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (username, 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) } // Bookmark table schema migration if legacy schema (without username) exists var hasUsernameInBookmark int err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('Bookmark') WHERE name='username'").Scan(&hasUsernameInBookmark) if err == nil && hasUsernameInBookmark == 0 { log.Println("Database Migration: Upgrading Bookmark table schema to include username...") _, err = db.Exec("ALTER TABLE Bookmark RENAME TO Bookmark_old") if err != nil { log.Printf("Warning: Failed to rename Bookmark table for migration: %v", err) } else { _, err = db.Exec(` CREATE TABLE Bookmark ( username TEXT NOT NULL DEFAULT 'admin', question_id INTEGER NOT NULL, source TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (username, source, question_id), FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE ) `) if err != nil { log.Fatalf("Failed to create new Bookmark table during migration: %v", err) } _, err = db.Exec("INSERT OR IGNORE INTO Bookmark (username, question_id, source, created_at) SELECT 'admin', question_id, source, created_at FROM Bookmark_old") if err != nil { log.Printf("Warning: Failed to copy old bookmark data: %v", err) } _, err = db.Exec("DROP TABLE Bookmark_old") if err != nil { log.Printf("Warning: Failed to drop old Bookmark table: %v", err) } log.Println("Database Migration: Bookmark table successfully migrated.") } } // 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) } // Alter DescriptiveGrading to add ocr_image_url if it doesn't exist _, _ = db.Exec(`ALTER TABLE DescriptiveGrading ADD COLUMN ocr_image_url TEXT DEFAULT ''`) // Create GeminiKeys table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS GeminiKeys ( api_key TEXT PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { log.Fatalf("Failed to create GeminiKeys table: %v", err) } // Create GeminiSettings table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS GeminiSettings ( key TEXT PRIMARY KEY, value TEXT ) `) if err != nil { log.Fatalf("Failed to create GeminiSettings 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.") } } // Ensure api_key column exists in Users table var hasApiKey int err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('Users') WHERE name='api_key'").Scan(&hasApiKey) if err == nil && hasApiKey == 0 { _, err = db.Exec("ALTER TABLE Users ADD COLUMN api_key TEXT DEFAULT ''") if err != nil { log.Printf("Warning: Failed to add api_key column to Users table: %v", err) } else { log.Println("Database Migration: Added api_key column to Users 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 // Also save it to the multiple keys DB as well to prevent losing it if strings.TrimSpace(key) != "" { _ = s.AddGeminiKey(key) } } // AddGeminiKey adds a new Gemini API Key to database func (s *QuizService) AddGeminiKey(key string) error { trimmed := strings.TrimSpace(key) if trimmed == "" { return fmt.Errorf("API Key cannot be empty") } _, err := s.db.Exec("INSERT OR IGNORE INTO GeminiKeys (api_key) VALUES (?)", trimmed) return err } // DeleteGeminiKey deletes a Gemini API Key from database func (s *QuizService) DeleteGeminiKey(key string) error { _, err := s.db.Exec("DELETE FROM GeminiKeys WHERE api_key = ?", key) return err } // GetGeminiKeys returns all stored Gemini API Keys func (s *QuizService) GetGeminiKeys() ([]string, error) { rows, err := s.db.Query("SELECT api_key FROM GeminiKeys ORDER BY created_at ASC") if err != nil { return nil, err } defer rows.Close() var keys []string for rows.Next() { var k string if err := rows.Scan(&k); err == nil { keys = append(keys, k) } } return keys, nil } // GetPreferredModel returns the user's preferred Gemini model from database func (s *QuizService) GetPreferredModel() (string, error) { var model string err := s.db.QueryRow("SELECT value FROM GeminiSettings WHERE key = 'preferred_model'").Scan(&model) if err == sql.ErrNoRows { return "gemini-3.1-flash-lite", nil // default } return model, err } // SetPreferredModel saves the user's preferred Gemini model to database func (s *QuizService) SetPreferredModel(modelName string) error { trimmed := strings.TrimSpace(modelName) if trimmed == "" { return fmt.Errorf("model name cannot be empty") } _, err := s.db.Exec("INSERT OR REPLACE INTO GeminiSettings (key, value) VALUES ('preferred_model', ?)", trimmed) return err } 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(username, 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 username = ? AND 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, username, 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 for a user func (s *QuizService) AddBookmark(username, source string, questionID int) error { _, err := s.db.Exec("INSERT OR IGNORE INTO Bookmark (username, source, question_id) VALUES (?, ?, ?)", username, source, questionID) return err } // RemoveBookmark removes a source and question ID from the Bookmark table for a user func (s *QuizService) RemoveBookmark(username, source string, questionID int) error { _, err := s.db.Exec("DELETE FROM Bookmark WHERE username = ? AND source = ? AND question_id = ?", username, source, questionID) return err } // GetBookmarkedCategories retrieves categories that have bookmarked questions for a user func (s *QuizService) GetBookmarkedCategories(username string) []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 b.username = ? AND q.category IS NOT NULL AND q.category != '' GROUP BY q.category ` rows, err := s.db.Query(query, username) 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 for a user func (s *QuizService) GetBookmarkedTargets(username, 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 b.username = ? AND q.category = ? AND q.difficulty IS NOT NULL AND q.difficulty != '' GROUP BY q.difficulty ` rows, err := s.db.Query(query, username, 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 for a user func (s *QuizService) GetBookmarkedQuestions(username, 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 b.username = ? AND q.category = ? ` var args []interface{} args = append(args, username, category) if difficulty != "" && difficulty != "All" { query += " AND q.difficulty = ?" args = append(args, difficulty) } 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 } 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 minVal(a, b int) int { if a < b { return a } return b } func (s *QuizService) executeGeminiWithRetry(ctx context.Context, parts []genai.Part) (*genai.GenerateContentResponse, error) { keys, err := s.GetGeminiKeys() if err != nil { return nil, fmt.Errorf("failed to load API keys: %v", err) } if len(keys) == 0 { if s.geminiKey != "" { keys = []string{s.geminiKey} } else { return nil, fmt.Errorf("Gemini API key is not set. Please add at least one key in the settings.") } } defaultModels := []string{ "gemini-3.1-flash-lite", "gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", } prefModel, err := s.GetPreferredModel() var models []string if err == nil && prefModel != "" { models = append(models, prefModel) for _, m := range defaultModels { if m != prefModel { models = append(models, m) } } } else { models = defaultModels } var lastErr error for _, modelName := range models { log.Printf("[Gemini] Trying model: %s", modelName) shuffledKeys := make([]string, len(keys)) copy(shuffledKeys, keys) rand.Seed(time.Now().UnixNano()) rand.Shuffle(len(shuffledKeys), func(i, j int) { shuffledKeys[i], shuffledKeys[j] = shuffledKeys[j], shuffledKeys[i] }) for _, key := range shuffledKeys { client, err := genai.NewClient(ctx, option.WithAPIKey(key)) if err != nil { lastErr = err log.Printf("[Gemini] Failed to create client with key (ending in ...%s): %v", key[len(key)-minVal(4, len(key)):], err) continue } model := client.GenerativeModel(modelName) resp, apiErr := model.GenerateContent(ctx, parts...) if apiErr == nil { client.Close() return resp, nil } client.Close() lastErr = apiErr log.Printf("[Gemini] API Key (ending in ...%s) failed: %v. Trying next key immediately.", key[len(key)-minVal(4, len(key)):], apiErr) } } if lastErr != nil { return nil, fmt.Errorf("all models and keys failed. Last error: %v", lastErr) } return nil, fmt.Errorf("all models and keys failed without error info") } func (s *QuizService) emitEvent(name string, data interface{}) { if s.app != nil { s.app.Event.Emit(name, data) } else { log.Printf("Warning: cannot emit event %s, app instance is nil", name) } } func (s *QuizService) AskGemini(source string, id int, prompt string, base64Image string) (string, error) { // Start background goroutine for Gemini explanation creation go func() { ctx := context.Background() // 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) resp, err := s.executeGeminiWithRetry(ctx, parts) if err != nil { log.Printf("Background Gemini error for question %d: %v", id, err) s.emitEvent("ai:explanation:done", map[string]interface{}{ "source": source, "id": id, "error": err.Error(), }) return } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { log.Printf("Background Gemini returned no response for question %d", id) s.emitEvent("ai:explanation:done", map[string]interface{}{ "source": source, "id": id, "error": "No response from Gemini", }) return } 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) } s.emitEvent("ai:explanation:done", map[string]interface{}{ "source": source, "id": id, "explanation": explanation, "error": "", }) }() return "started", 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("assets", filename) path2 := filepath.Join("frontend", "public", "assets", filename) path3 := filepath.Join("qple_quiz_app", "frontend", "public", "assets", filename) path4 := 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 } else if _, err := os.Stat(path4); err == nil { filePath = path4 } 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(username string, 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, COALESCE(ocr_image_url, '') as ocr_image_url FROM DescriptiveGrading WHERE username = ? AND source = ? AND question_id = ? ORDER BY created_at DESC ` rows, err := s.db.Query(query, username, 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, &r.OcrImageUrl) if err != nil { log.Printf("Scan error in GetDescriptiveGradingHistory: %v", err) continue } records = append(records, r) } return records } func (s *QuizService) GradeDescriptiveAnswer(username string, source string, questionID int, userAnswer string, ocrImageBase64 string) (*DescriptiveGradingRecord, error) { 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 } } var ocrImageUrl string if ocrImageBase64 != "" { base64Data := ocrImageBase64 ext := "png" if idx := strings.Index(base64Data, ";base64,"); idx != -1 { header := base64Data[:idx] if strings.Contains(header, "image/jpeg") || strings.Contains(header, "image/jpg") { ext = "jpg" } else if strings.Contains(header, "image/webp") { ext = "webp" } base64Data = base64Data[idx+8:] } dec, err := base64.StdEncoding.DecodeString(base64Data) if err == nil { os.MkdirAll("assets/ocr_uploads", 0755) safeSource := sanitizeFilename(source) filename := fmt.Sprintf("ocr_%s_%s_%d_%d.%s", username, safeSource, questionID, time.Now().UnixNano(), ext) localPath := filepath.Join("assets", "ocr_uploads", filename) err = os.WriteFile(localPath, dec, 0644) if err == nil { ocrImageUrl = "/assets/ocr_uploads/" + filename } else { log.Printf("Failed to write OCR image file: %v", err) } } else { log.Printf("Failed to decode base64 OCR image: %v", err) } } // Start background goroutine for Gemini grading go func() { ctx := context.Background() 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) resp, err := s.executeGeminiWithRetry(ctx, parts) if err != nil { log.Printf("Background grading error for question %d: %v", questionID, err) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": err.Error(), }) return } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { log.Printf("Background grading returned no response for question %d", questionID) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": "No response from Gemini", }) return } 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, ocr_image_url) VALUES (?, ?, ?, ?, ?, ?, ?) `) if err != nil { log.Printf("Failed to prepare INSERT statement: %v", err) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": err.Error(), }) return } defer stmt.Close() res, err := stmt.Exec(source, questionID, userAnswer, result.Score, result.Evaluation, username, ocrImageUrl) if err != nil { log.Printf("Failed to execute INSERT statement: %v", err) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": err.Error(), }) return } lastID, err := res.LastInsertId() if err != nil { log.Printf("Failed to get last insert ID: %v", err) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": err.Error(), }) return } 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'), COALESCE(ocr_image_url, '') as ocr_image_url FROM DescriptiveGrading WHERE id = ? `, lastID).Scan(&record.ID, &record.QuestionID, &record.Source, &record.UserAnswer, &record.Score, &record.Evaluation, &record.Username, &record.CreatedAt, &record.OcrImageUrl) if err != nil { log.Printf("Failed to scan grading record: %v", err) s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "error": err.Error(), }) return } // Emit completed event s.emitEvent("ai:grading:done", map[string]interface{}{ "source": source, "questionId": questionID, "record": record, "error": "", }) }() // Return a temporary processing record immediately return &DescriptiveGradingRecord{ ID: -1, QuestionID: questionID, Source: source, UserAnswer: userAnswer, Score: -1, // -1 means in progress Evaluation: "채점 진행 중...", Username: username, CreatedAt: time.Now().Format("2006-01-02 15:04:05"), OcrImageUrl: ocrImageUrl, }, nil } func sanitizeFilename(s string) string { r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_") return r.Replace(s) } // Login authenticates a user func (s *QuizService) Login(username, password string) (*User, error) { var u User err := s.db.QueryRow(` SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') FROM Users WHERE username = ? AND password = ? `, username, password).Scan(&u.ID, &u.Username, &u.Password, &u.Nickname, &u.Phone, &u.Status, &u.ApiKey, &u.CreatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다.") } else if err != nil { return nil, fmt.Errorf("로그인 중 오류가 발생했습니다: %v", err) } if u.Status == "pending" { return nil, fmt.Errorf("가입 승인 대기 중입니다. 관리자의 승인을 기다려 주세요.") } else if u.Status == "rejected" { return nil, fmt.Errorf("가입 신청이 거절되었습니다. 관리자에게 문의하세요.") } return &u, nil } // Register creates a new registration application func (s *QuizService) Register(username, password, nickname, phone, apiKey string) error { username = strings.TrimSpace(username) password = strings.TrimSpace(password) nickname = strings.TrimSpace(nickname) phone = strings.TrimSpace(phone) apiKey = strings.TrimSpace(apiKey) if username == "" || password == "" || nickname == "" || phone == "" { return fmt.Errorf("모든 항목을 입력해 주세요.") } var count int err := s.db.QueryRow("SELECT COUNT(*) FROM Users WHERE username = ?", username).Scan(&count) if err == nil && count > 0 { return fmt.Errorf("이미 존재하는 아이디입니다.") } _, err = s.db.Exec(` INSERT INTO Users (username, password, nickname, phone, api_key, status) VALUES (?, ?, ?, ?, ?, 'pending') `, username, password, nickname, phone, apiKey) return err } // ChangePassword updates user password func (s *QuizService) ChangePassword(username, oldPassword, newPassword string) error { username = strings.TrimSpace(username) oldPassword = strings.TrimSpace(oldPassword) newPassword = strings.TrimSpace(newPassword) if oldPassword == "" || newPassword == "" { return fmt.Errorf("비밀번호를 입력해 주세요.") } var count int err := s.db.QueryRow("SELECT COUNT(*) FROM Users WHERE username = ? AND password = ?", username, oldPassword).Scan(&count) if err != nil || count == 0 { return fmt.Errorf("현재 비밀번호가 일치하지 않습니다.") } _, err = s.db.Exec("UPDATE Users SET password = ? WHERE username = ?", newPassword, username) return err } // GetPendingRegistrations retrieves list of pending sign-ups (admin only) func (s *QuizService) GetPendingRegistrations() ([]User, error) { rows, err := s.db.Query(` SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') FROM Users WHERE status = 'pending' ORDER BY created_at ASC `) if err != nil { return nil, err } defer rows.Close() var list []User for rows.Next() { var u User err := rows.Scan(&u.ID, &u.Username, &u.Password, &u.Nickname, &u.Phone, &u.Status, &u.ApiKey, &u.CreatedAt) if err == nil { list = append(list, u) } } return list, nil } // ApproveRegistration approves a registration request func (s *QuizService) ApproveRegistration(username string) error { // 1. Get user's API Key var apiKey string err := s.db.QueryRow("SELECT COALESCE(api_key, '') FROM Users WHERE username = ?", username).Scan(&apiKey) if err != nil { return err } // 2. If API Key exists, add it to system GeminiKeys table apiKey = strings.TrimSpace(apiKey) if apiKey != "" { _, _ = s.db.Exec("INSERT OR IGNORE INTO GeminiKeys (key) VALUES (?)", apiKey) } // 3. Update status to approved _, err = s.db.Exec("UPDATE Users SET status = 'approved' WHERE username = ?", username) return err } // RejectRegistration rejects a registration request func (s *QuizService) RejectRegistration(username string) error { _, err := s.db.Exec("UPDATE Users SET status = 'rejected' WHERE username = ?", username) return err } // GetUsers returns list of all users (admin only) func (s *QuizService) GetUsers() ([]User, error) { rows, err := s.db.Query(` SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') FROM Users ORDER BY username ASC `) if err != nil { return nil, err } defer rows.Close() var list []User for rows.Next() { var u User err := rows.Scan(&u.ID, &u.Username, &u.Password, &u.Nickname, &u.Phone, &u.Status, &u.ApiKey, &u.CreatedAt) if err == nil { list = append(list, u) } } return list, nil } // GetUserAllowedCategories returns allowed category names for a specific user func (s *QuizService) GetUserAllowedCategories(username string) ([]string, error) { rows, err := s.db.Query("SELECT category FROM UserCategories WHERE username = ?", username) if err != nil { return nil, err } defer rows.Close() var list []string for rows.Next() { var cat string if err := rows.Scan(&cat); err == nil { list = append(list, cat) } } return list, nil } // SetUserAllowedCategories replaces user's allowed categories func (s *QuizService) SetUserAllowedCategories(username string, categories []string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec("DELETE FROM UserCategories WHERE username = ?", username) if err != nil { return err } for _, cat := range categories { if strings.TrimSpace(cat) == "" { continue } _, err = tx.Exec("INSERT INTO UserCategories (username, category) VALUES (?, ?)", username, cat) if err != nil { return err } } return tx.Commit() } // GetCategoriesForUser retrieves categories based on user authorization func (s *QuizService) GetCategoriesForUser(username string) []CategoryInfo { if username == "admin" { return s.GetCategories() } rows, err := s.db.Query(` SELECT q.category, COUNT(*) as count FROM QuizQuestions q JOIN UserCategories uc ON q.category = uc.category WHERE uc.username = ? AND q.category IS NOT NULL AND q.category != '' GROUP BY q.category `, username) if err != nil { log.Printf("Query error in GetCategoriesForUser: %v", err) return nil } defer rows.Close() var list []CategoryInfo for rows.Next() { var c CategoryInfo if err := rows.Scan(&c.Name, &c.Count); err == nil { list = append(list, c) } } return list } // UpdateUserApiKey updates a user's personal API key in Users table and seeds it to GeminiKeys func (s *QuizService) UpdateUserApiKey(username, apiKey string) error { username = strings.TrimSpace(username) apiKey = strings.TrimSpace(apiKey) _, err := s.db.Exec("UPDATE Users SET api_key = ? WHERE username = ?", apiKey, username) if err != nil { return err } if apiKey != "" { _, _ = s.db.Exec("INSERT OR IGNORE INTO GeminiKeys (key) VALUES (?)", apiKey) } return nil } // DeleteUser deletes a user from Users table (admin only) func (s *QuizService) DeleteUser(username string) error { username = strings.TrimSpace(username) if username == "admin" { return fmt.Errorf("관리자 계정은 삭제할 수 없습니다.") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() // Delete user relations _, _ = tx.Exec("DELETE FROM UserCategories WHERE username = ?", username) _, _ = tx.Exec("DELETE FROM DescriptiveGrading WHERE username = ?", username) _, _ = tx.Exec("DELETE FROM Bookmark WHERE username = ?", username) // Delete user _, err = tx.Exec("DELETE FROM Users WHERE username = ?", username) if err != nil { return err } return tx.Commit() } // UpdateUserInfoForce updates user details by admin force (admin only) func (s *QuizService) UpdateUserInfoForce(username, nickname, phone, password string) error { username = strings.TrimSpace(username) nickname = strings.TrimSpace(nickname) phone = strings.TrimSpace(phone) password = strings.TrimSpace(password) if username == "" || nickname == "" || phone == "" { return fmt.Errorf("아이디, 닉네임, 연락처는 필수 입력 항목입니다.") } if password != "" { _, err := s.db.Exec("UPDATE Users SET nickname = ?, phone = ?, password = ? WHERE username = ?", nickname, phone, password, username) return err } else { _, err := s.db.Exec("UPDATE Users SET nickname = ?, phone = ? WHERE username = ?", nickname, phone, username) return err } }