Files
QUZ/quiz_service.go
T

1611 lines
49 KiB
Go
Raw Normal View History

2026-06-25 07:43:27 +09:00
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
2026-06-28 23:48:13 +00:00
"math/rand"
2026-06-25 07:43:27 +09:00
"os"
"path/filepath"
"regexp"
"strings"
"time"
2026-07-05 02:06:37 +00:00
"changeme/antigravity"
2026-06-25 07:43:27 +09:00
"github.com/google/generative-ai-go/genai"
2026-06-28 23:48:13 +00:00
"github.com/wailsapp/wails/v3/pkg/application"
2026-06-25 07:43:27 +09:00
"google.golang.org/api/option"
2026-06-28 23:48:13 +00:00
_ "modernc.org/sqlite"
2026-06-25 07:43:27 +09:00
)
2026-06-28 23:48:13 +00:00
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"`
}
2026-06-25 07:43:27 +09:00
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 {
2026-07-04 07:42:19 +00:00
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"`
2026-06-25 07:43:27 +09:00
}
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
2026-06-28 23:48:13 +00:00
app *application.App
}
func (s *QuizService) SetApp(app *application.App) {
s.app = app
2026-06-25 07:43:27 +09:00
}
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)
}
2026-07-05 02:06:37 +00:00
// Create StudyElements table if it doesn't exist
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS StudyElements (
question_id INTEGER NOT NULL,
source TEXT NOT NULL,
content TEXT,
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 StudyElements table: %v", err)
}
2026-06-28 23:48:13 +00:00
// 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
2026-06-25 07:43:27 +09:00
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS Bookmark (
2026-06-28 23:48:13 +00:00
username TEXT NOT NULL DEFAULT 'admin',
2026-06-25 07:43:27 +09:00
question_id INTEGER NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
2026-06-28 23:48:13 +00:00
PRIMARY KEY (username, source, question_id),
2026-06-25 07:43:27 +09:00
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
)
`)
if err != nil {
log.Fatalf("Failed to create Bookmark table: %v", err)
}
2026-06-28 23:48:13 +00:00
// 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.")
}
}
2026-06-25 07:43:27 +09:00
// 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)
}
2026-07-04 07:42:19 +00:00
// Alter DescriptiveGrading to add ocr_image_url if it doesn't exist
_, _ = db.Exec(`ALTER TABLE DescriptiveGrading ADD COLUMN ocr_image_url TEXT DEFAULT ''`)
2026-06-28 23:48:13 +00:00
// 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)
}
2026-06-25 07:43:27 +09:00
// 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.")
}
}
2026-06-28 23:48:13 +00:00
// 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.")
}
}
2026-06-25 07:43:27 +09:00
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
2026-06-28 23:48:13 +00:00
// 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
2026-06-25 07:43:27 +09:00
}
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
}
2026-06-28 23:48:13 +00:00
func (s *QuizService) GetQuestions(username, category, difficulty, subject string) []Question {
2026-06-25 07:43:27 +09:00
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,
2026-06-28 23:48:13 +00:00
EXISTS(SELECT 1 FROM Bookmark WHERE username = ? AND source = q.source AND question_id = q.id) as isBookmarked,
2026-06-25 07:43:27 +09:00
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{}
2026-06-28 23:48:13 +00:00
args = append(args, username, category)
2026-06-25 07:43:27 +09:00
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
}
2026-06-28 23:48:13 +00:00
// 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)
2026-06-25 07:43:27 +09:00
return err
}
2026-06-28 23:48:13 +00:00
// 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)
2026-06-25 07:43:27 +09:00
return err
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedCategories retrieves categories that have bookmarked questions for a user
func (s *QuizService) GetBookmarkedCategories(username string) []CategoryInfo {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.category, COUNT(*) as count
FROM Bookmark b
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category IS NOT NULL AND q.category != ''
2026-06-25 07:43:27 +09:00
GROUP BY q.category
`
2026-06-28 23:48:13 +00:00
rows, err := s.db.Query(query, username)
2026-06-25 07:43:27 +09:00
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
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedTargets retrieves difficulties (targets) that have bookmarked questions within a category for a user
func (s *QuizService) GetBookmarkedTargets(username, category string) []TargetInfo {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.difficulty, COUNT(*) as count
FROM Bookmark b
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category = ? AND q.difficulty IS NOT NULL AND q.difficulty != ''
2026-06-25 07:43:27 +09:00
GROUP BY q.difficulty
`
2026-06-28 23:48:13 +00:00
rows, err := s.db.Query(query, username, category)
2026-06-25 07:43:27 +09:00
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
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedQuestions retrieves all bookmarked questions for a given category and difficulty for a user
func (s *QuizService) GetBookmarkedQuestions(username, category, difficulty string) []Question {
2026-06-25 07:43:27 +09:00
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
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category = ?
2026-06-25 07:43:27 +09:00
`
2026-06-28 23:48:13 +00:00
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...)
2026-06-25 07:43:27 +09:00
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
}
2026-06-28 23:48:13 +00:00
func minVal(a, b int) int {
if a < b {
return a
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
return b
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
func (s *QuizService) executeGeminiWithRetry(ctx context.Context, parts []genai.Part) (*genai.GenerateContentResponse, error) {
keys, err := s.GetGeminiKeys()
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return nil, fmt.Errorf("failed to load API keys: %v", err)
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if len(keys) == 0 {
if s.geminiKey != "" {
keys = []string{s.geminiKey}
2026-06-25 07:43:27 +09:00
} else {
2026-06-28 23:48:13 +00:00
return nil, fmt.Errorf("Gemini API key is not set. Please add at least one key in the settings.")
2026-06-25 07:43:27 +09:00
}
}
2026-06-28 23:48:13 +00:00
defaultModels := []string{
"gemini-3.1-flash-lite",
"gemini-3.5-flash",
"gemini-2.5-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-flash",
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
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)
}
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
} else {
models = defaultModels
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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)
}
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if lastErr != nil {
return nil, fmt.Errorf("all models and keys failed. Last error: %v", lastErr)
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
return nil, fmt.Errorf("all models and keys failed without error info")
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
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)
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
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
2026-06-25 07:43:27 +09:00
}
2026-07-04 08:01:01 +00:00
func (s *QuizService) PerformGeminiOcr(base64Image string) (string, error) {
if base64Image == "" {
return "", fmt.Errorf("Empty image data")
}
mimeType, decodedData, err := parseBase64Image(base64Image)
if err != nil {
return "", fmt.Errorf("Failed to parse base64 image: %v", err)
}
ctx := context.Background()
prompt := "이 이미지에서 손글씨 또는 인쇄된 글자(한글/영어 등)를 오직 텍스트로만 정확하게 추출해 주세요. 질문에 대한 해설이나 부가적인 설명(예: '추출한 텍스트:', 마크다운 장식 등)은 일절 제외하고, 이미지에 써져 있는 내용 그대로만 텍스트로 반환해야 합니다."
parts := []genai.Part{
genai.Text(prompt),
genai.Blob{
MIMEType: mimeType,
Data: decodedData,
},
}
resp, err := s.executeGeminiWithRetry(ctx, parts)
if err != nil {
return "", fmt.Errorf("Gemini API call failed: %v", err)
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("No text detected by Gemini")
}
resultText := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0])
return strings.TrimSpace(resultText), nil
}
2026-06-25 07:43:27 +09:00
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
2026-06-28 23:48:13 +00:00
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)
2026-06-25 07:43:27 +09:00
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
2026-06-28 23:48:13 +00:00
} else if _, err := os.Stat(path4); err == nil {
filePath = path4
2026-06-25 07:43:27 +09:00
}
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
}
2026-06-28 23:48:13 +00:00
func (s *QuizService) GetDescriptiveGradingHistory(username string, source string, questionID int) []DescriptiveGradingRecord {
2026-06-25 07:43:27 +09:00
query := `
2026-07-04 07:42:19 +00:00
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
2026-06-25 07:43:27 +09:00
FROM DescriptiveGrading
2026-06-28 23:48:13 +00:00
WHERE username = ? AND source = ? AND question_id = ?
2026-06-25 07:43:27 +09:00
ORDER BY created_at DESC
`
2026-06-28 23:48:13 +00:00
rows, err := s.db.Query(query, username, source, questionID)
2026-06-25 07:43:27 +09:00
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
2026-07-04 07:42:19 +00:00
err := rows.Scan(&r.ID, &r.QuestionID, &r.Source, &r.UserAnswer, &r.Score, &r.Evaluation, &r.Username, &r.CreatedAt, &r.OcrImageUrl)
2026-06-25 07:43:27 +09:00
if err != nil {
log.Printf("Scan error in GetDescriptiveGradingHistory: %v", err)
continue
}
records = append(records, r)
}
return records
}
2026-07-04 07:42:19 +00:00
func (s *QuizService) GradeDescriptiveAnswer(username string, source string, questionID int, userAnswer string, ocrImageBase64 string) (*DescriptiveGradingRecord, error) {
2026-06-25 07:43:27 +09:00
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
}
}
2026-07-04 07:42:19 +00:00
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)
}
}
2026-06-28 23:48:13 +00:00
// Start background goroutine for Gemini grading
go func() {
ctx := context.Background()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
prompt := fmt.Sprintf(`사용자가 작성한 주관식/서술형 답안을 모범답안과 문제에 대조하여 공정하게 채점해 주세요.
2026-06-25 07:43:27 +09:00
[문제 정보]
- 지문: %s
- 문제: %s
- 모범 답안: %s
[사용자 제출 답안]
%s
결과는 반드시 아래의 JSON 포맷으로만 응답해 주세요. 포맷 이외의 텍스트(Markdown 코드 블럭 등)는 포함하지 마세요.
{
"score": <0~100 사이의 정수 점수>,
"evaluation": "<잘한 부분, 아쉬운 부분, 보완점에 대한 상세한 평가 내용 (한글 마크다운 형식)>"
}
`, passage, qText, modelAnswer, userAnswer)
2026-06-28 23:48:13 +00:00
var parts []genai.Part
parts = append(parts, genai.Text(prompt))
parts = s.attachQuestionAssets(parts, source, questionID)
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
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
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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(`
2026-07-04 07:42:19 +00:00
INSERT INTO DescriptiveGrading (source, question_id, user_answer, score, evaluation, username, ocr_image_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
2026-06-28 23:48:13 +00:00
`)
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()
2026-07-04 07:42:19 +00:00
res, err := stmt.Exec(source, questionID, userAnswer, result.Score, result.Evaluation, username, ocrImageUrl)
2026-06-28 23:48:13 +00:00
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(`
2026-07-04 07:42:19 +00:00
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
2026-06-28 23:48:13 +00:00
FROM DescriptiveGrading
WHERE id = ?
2026-07-04 07:42:19 +00:00
`, lastID).Scan(&record.ID, &record.QuestionID, &record.Source, &record.UserAnswer, &record.Score, &record.Evaluation, &record.Username, &record.CreatedAt, &record.OcrImageUrl)
2026-06-28 23:48:13 +00:00
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{
2026-07-04 07:42:19 +00:00
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,
2026-06-28 23:48:13 +00:00
}, nil
}
2026-07-04 07:42:19 +00:00
func sanitizeFilename(s string) string {
r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_")
return r.Replace(s)
}
2026-06-28 23:48:13 +00:00
// 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)
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if u.Status == "pending" {
return nil, fmt.Errorf("가입 승인 대기 중입니다. 관리자의 승인을 기다려 주세요.")
} else if u.Status == "rejected" {
return nil, fmt.Errorf("가입 신청이 거절되었습니다. 관리자에게 문의하세요.")
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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("이미 존재하는 아이디입니다.")
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
_, 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)
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
if oldPassword == "" || newPassword == "" {
return fmt.Errorf("비밀번호를 입력해 주세요.")
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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("현재 비밀번호가 일치하지 않습니다.")
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
_, 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)
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
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)
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
// 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
2026-06-25 07:43:27 +09:00
`)
if err != nil {
return nil, err
}
2026-06-28 23:48:13 +00:00
defer rows.Close()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
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)
2026-06-25 07:43:27 +09:00
if err != nil {
return nil, err
}
2026-06-28 23:48:13 +00:00
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
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
// SetUserAllowedCategories replaces user's allowed categories
func (s *QuizService) SetUserAllowedCategories(username string, categories []string) error {
tx, err := s.db.Begin()
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return err
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
defer tx.Rollback()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
_, err = tx.Exec("DELETE FROM UserCategories WHERE username = ?", username)
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return err
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
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
}
2026-06-25 07:43:27 +09:00
}
2026-07-05 02:06:37 +00:00
type StudyElementDB struct {
QuestionID int `json:"questionId"`
Source string `json:"source"`
QuestionText string `json:"questionText"`
Content string `json:"content"`
Category string `json:"category"`
}
// GetStudyElements retrieves all extracted study elements for a category.
func (s *QuizService) GetStudyElements(category string) ([]StudyElementDB, error) {
query := `
SELECT se.question_id, se.source, se.content, q.question_text, q.category
FROM StudyElements se
JOIN QuizQuestions q ON se.source = q.source AND se.question_id = q.id
WHERE q.category = ?
ORDER BY se.question_id ASC
`
rows, err := s.db.Query(query, category)
if err != nil {
return nil, err
}
defer rows.Close()
var list []StudyElementDB
for rows.Next() {
var se StudyElementDB
err := rows.Scan(&se.QuestionID, &se.Source, &se.Content, &se.QuestionText, &se.Category)
if err == nil {
list = append(list, se)
}
}
return list, nil
}
// ExtractStudyElementsForCategory extracts study elements for all questions in the category.
// It skips questions that already have an extracted study element.
func (s *QuizService) ExtractStudyElementsForCategory(category string) (string, error) {
// 1. Get questions in this category that don't have study elements yet.
query := `
SELECT q.id, q.source, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, q.correct_answer_str, q.explanation
FROM QuizQuestions q
LEFT JOIN StudyElements se ON q.source = se.source AND q.id = se.question_id
WHERE q.category = ? AND se.question_id IS NULL
`
rows, err := s.db.Query(query, category)
if err != nil {
return "", err
}
defer rows.Close()
type Task struct {
ID int
Source string
QuestionText string
Choice1 string
Choice2 string
Choice3 string
Choice4 string
CorrectAnswerStr string
Explanation string
}
var tasks []Task
for rows.Next() {
var t Task
err := rows.Scan(&t.ID, &t.Source, &t.QuestionText, &t.Choice1, &t.Choice2, &t.Choice3, &t.Choice4, &t.CorrectAnswerStr, &t.Explanation)
if err == nil {
tasks = append(tasks, t)
}
}
if len(tasks) == 0 {
return "이미 모든 문제의 학습요소가 추출되어 있습니다.", nil
}
// 2. Start a background goroutine to process with limited concurrency.
go func() {
ctx := context.Background()
sem := make(chan struct{}, 3) // limit to 3 concurrent agy calls
total := len(tasks)
for i, t := range tasks {
sem <- struct{}{}
go func(idx int, task Task) {
defer func() { <-sem }()
// Update progress
s.emitEvent("study:progress", map[string]interface{}{
"current": idx + 1,
"total": total,
"message": fmt.Sprintf("문제 %d 추출 중...", task.ID),
})
content, err := antigravity.ExtractStudyElement(
ctx,
task.QuestionText,
task.Choice1,
task.Choice2,
task.Choice3,
task.Choice4,
task.CorrectAnswerStr,
task.Explanation,
)
if err != nil {
log.Printf("Failed to extract study element for question %d: %v", task.ID, err)
return
}
// Save to DB
_, err = s.db.Exec(`
INSERT INTO StudyElements (question_id, source, content)
VALUES (?, ?, ?)
ON CONFLICT(source, question_id) DO UPDATE SET content=excluded.content
`, task.ID, task.Source, content)
if err != nil {
log.Printf("Failed to save study element to DB: %v", err)
}
}(i, t)
}
// Wait for all goroutines to finish
for i := 0; i < 3; i++ {
sem <- struct{}{}
}
s.emitEvent("study:done", map[string]interface{}{
"category": category,
"message": "학습요소 추출이 완료되었습니다.",
})
}()
return "학습요소 추출을 시작합니다.", nil
}
// GenerateGoPracticalExams creates new exam questions based on existing ones.
func (s *QuizService) GenerateGoPracticalExams(count int) (string, error) {
if count <= 0 {
count = 5
}
// 1. Fetch sample real exams as references for the AI
rows, err := s.db.Query(`
SELECT question_text, explanation
FROM QuizQuestions
WHERE category = '정보보안기사 실기' AND explanation != ''
LIMIT 10
`)
if err != nil {
return "", err
}
defer rows.Close()
var references []string
for rows.Next() {
var qText, explanation string
if err := rows.Scan(&qText, &explanation); err == nil {
references = append(references, fmt.Sprintf("질문:\n%s\n\n모범답안/해설:\n%s", qText, explanation))
}
}
if len(references) == 0 {
references = []string{
"질문:\n다음 지문을 읽고 빈칸 A, B에 들어갈 적절한 용어를 쓰시오.\n- ( A )은 네트워크 상의 패킷을 가로채어 분석하는 행위 또는 도구를 말한다.\n- ( B )은 IP 주소를 스푸핑하여 정상적인 호스트인 것처럼 위장하는 공격이다.\n\n모범답안/해설:\nA: 스니핑(Sniffing)\nB: IP 스푸핑(IP Spoofing)",
}
}
// 2. Start a background goroutine to generate questions
go func() {
ctx := context.Background()
category := "정보보호기사 실기(Go)"
source := "qple"
difficulty := "실기(Go)"
subject := "만든 자료"
for i := 0; i < count; i++ {
s.emitEvent("exam:progress", map[string]interface{}{
"current": i + 1,
"total": count,
"message": fmt.Sprintf("%d번째 실기 문제 생성 중...", i+1),
})
gq, err := antigravity.GenerateDescriptiveQuestion(ctx, references)
if err != nil {
log.Printf("Failed to generate exam question %d: %v", i+1, err)
continue
}
// Generate next ID
var nextID int
err = s.db.QueryRow("SELECT COALESCE(MAX(id), 0) + 1 FROM QuizQuestions WHERE source = ?", source).Scan(&nextID)
if err != nil {
log.Printf("Failed to get next ID: %v", err)
continue
}
// Insert into QuizQuestions
_, err = s.db.Exec(`
INSERT INTO QuizQuestions (id, source, category, question_text, choice1, choice2, choice3, choice4, correct_answer, explanation, difficulty, subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, nextID, source, category, gq.QuestionText, gq.Choice1, gq.Choice2, gq.Choice3, gq.Choice4, gq.CorrectAnswer, gq.Explanation, difficulty, subject)
if err != nil {
log.Printf("Failed to insert generated question: %v", err)
}
}
s.emitEvent("exam:done", map[string]interface{}{
"message": fmt.Sprintf("%d개의 실기 문제가 생성 및 저장되었습니다.", count),
})
}()
return "실기 문제 생성을 시작합니다.", nil
}