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"
2026-07-05 08:45:54 +00:00
"sync"
2026-06-25 07:43:27 +09:00
"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
2026-07-05 08:45:54 +00:00
dbMu sync . Mutex // SQLite database lock prevention mutex
2026-06-28 23:48:13 +00:00
}
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,
2026-07-05 08:45:54 +00:00
keyword TEXT,
2026-07-05 02:06:37 +00:00
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-07-05 08:45:54 +00:00
// Migration: Add keyword column to StudyElements if it doesn't exist
var hasKeyword int
err = db . QueryRow ( "SELECT COUNT(*) FROM pragma_table_info('StudyElements') WHERE name='keyword'" ). Scan ( & hasKeyword )
if err == nil && hasKeyword == 0 {
_ , err = db . Exec ( "ALTER TABLE StudyElements ADD COLUMN keyword TEXT DEFAULT ''" )
if err != nil {
log . Printf ( "Warning: Failed to add keyword column to StudyElements: %v" , err )
} else {
log . Println ( "Database Migration: Added keyword column to StudyElements table." )
}
}
// Cleanup: Delete study elements with empty content
_ , err = db . Exec ( "DELETE FROM StudyElements WHERE content IS NULL OR trim(content) = ''" )
if err != nil {
log . Printf ( "Warning: Failed to clean empty study elements: %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-07-05 08:45:54 +00:00
// Migration: Fill NULL values for correct_answer_str, passage, and image_url to prevent scan crashes
_ , _ = db . Exec ( "UPDATE QuizQuestions SET correct_answer_str = '' WHERE correct_answer_str IS NULL" )
_ , _ = db . Exec ( "UPDATE QuizQuestions SET passage = '' WHERE passage IS NULL" )
_ , _ = db . Exec ( "UPDATE QuizQuestions SET image_url = '' WHERE image_url IS NULL" )
s := & QuizService {
2026-06-25 07:43:27 +09:00
db : db ,
geminiKey : "" ,
}
2026-07-05 08:45:54 +00:00
// Migration: Fill missing keywords from content for existing data
go func () {
// Wait 1 second to let Wails start
time . Sleep ( 1 * time . Second )
s . dbMu . Lock ()
defer s . dbMu . Unlock ()
rows , err := s . db . Query ( `
SELECT question_id, source, content
FROM StudyElements
WHERE keyword IS NULL OR trim(keyword) = '' OR keyword = '개념 학습'
` )
if err != nil {
log . Printf ( "Failed to query study elements for keyword migration: %v" , err )
return
}
defer rows . Close ()
type MigrationTask struct {
QuestionID int
Source string
Content string
}
var mTasks [] MigrationTask
for rows . Next () {
var mt MigrationTask
if err := rows . Scan ( & mt . QuestionID , & mt . Source , & mt . Content ); err == nil {
mTasks = append ( mTasks , mt )
}
}
if len ( mTasks ) == 0 {
return
}
log . Printf ( "Starting keyword migration for %d study elements..." , len ( mTasks ))
stmt , err := s . db . Prepare ( "UPDATE StudyElements SET keyword = ? WHERE question_id = ? AND source = ?" )
if err != nil {
log . Printf ( "Failed to prepare statement for keyword migration: %v" , err )
return
}
defer stmt . Close ()
migratedCount := 0
for _ , mt := range mTasks {
content := strings . TrimSpace ( mt . Content )
if content == "" {
continue
}
lines := strings . Split ( content , "\n" )
keyword := "개념 학습"
if len ( lines ) > 0 {
firstLine := strings . TrimSpace ( lines [ 0 ])
firstLine = strings . TrimLeft ( firstLine , "#*-\t " )
if firstLine != "" {
keyword = firstLine
}
}
_ , err := stmt . Exec ( keyword , mt . QuestionID , mt . Source )
if err == nil {
migratedCount ++
} else {
log . Printf ( "Failed to migrate keyword for question %d: %v" , mt . QuestionID , err )
}
}
log . Printf ( "Successfully migrated keyword for %d study elements." , migratedCount )
}()
return s
2026-06-25 07:43:27 +09:00
}
// 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,
2026-07-05 08:45:54 +00:00
q.correct_answer, COALESCE(q.correct_answer_str, '') as correct_answer_str, q.explanation, q.difficulty, COALESCE(q.passage, '') as passage, COALESCE(q.image_url, '') as image_url,
2026-06-25 07:43:27 +09:00
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"`
2026-07-05 08:45:54 +00:00
Keyword string `json:"keyword"`
2026-07-05 02:06:37 +00:00
Category string `json:"category"`
}
2026-07-05 08:45:54 +00:00
// callAIEngine routes the prompt to either Gemini API (via keys DB) or Antigravity SDK CLI
func ( s * QuizService ) callAIEngine ( ctx context . Context , useGeminiAPI bool , prompt string ) ( string , error ) {
if useGeminiAPI {
keys , err := s . GetGeminiKeys ()
if err != nil || len ( keys ) == 0 {
if s . geminiKey != "" {
keys = [] string { s . geminiKey }
} else {
return "" , fmt . Errorf ( "등록된 Gemini API Key가 없습니다. 설정에서 키를 먼저 추가해 주세요." )
}
}
parts := [] genai . Part { genai . Text ( prompt )}
resp , err := s . executeGeminiWithRetry ( ctx , parts )
if err != nil {
return "" , err
}
if len ( resp . Candidates ) == 0 || len ( resp . Candidates [ 0 ]. Content . Parts ) == 0 {
return "" , fmt . Errorf ( "Gemini API가 빈 응답을 반환했습니다." )
}
return fmt . Sprintf ( "%v" , resp . Candidates [ 0 ]. Content . Parts [ 0 ]), nil
} else {
return antigravity . CallAgy ( ctx , prompt )
}
}
2026-07-05 02:06:37 +00:00
// GetStudyElements retrieves all extracted study elements for a category.
func ( s * QuizService ) GetStudyElements ( category string ) ([] StudyElementDB , error ) {
query := `
2026-07-05 08:45:54 +00:00
SELECT se.question_id, se.source, COALESCE(se.content, ''), COALESCE(se.keyword, ''), q.question_text, q.category
2026-07-05 02:06:37 +00:00
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
2026-07-05 08:45:54 +00:00
err := rows . Scan ( & se . QuestionID , & se . Source , & se . Content , & se . Keyword , & se . QuestionText , & se . Category )
2026-07-05 02:06:37 +00:00
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.
2026-07-05 08:45:54 +00:00
func ( s * QuizService ) ExtractStudyElementsForCategory ( category string , useGeminiAPI bool , delaySeconds int ) ( string , error ) {
if delaySeconds < 1 {
delaySeconds = 1
}
// 1. Get questions in this category that don't have study elements yet or have empty content.
2026-07-05 02:06:37 +00:00
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
2026-07-05 08:45:54 +00:00
WHERE q.category = ? AND (se.question_id IS NULL OR se.content IS NULL OR trim(se.content) = '')
2026-07-05 02:06:37 +00:00
`
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
}
2026-07-05 08:45:54 +00:00
// 2. Start a background sequential goroutine to process with configurable delay.
2026-07-05 02:06:37 +00:00
go func () {
ctx := context . Background ()
total := len ( tasks )
2026-07-05 08:45:54 +00:00
for idx , task := range tasks {
// Update progress
s . emitEvent ( "study:progress" , map [ string ] interface {}{
"current" : idx + 1 ,
"total" : total ,
"message" : fmt . Sprintf ( "문제 %d 추출 중... (%d/%d)" , task . ID , idx + 1 , total ),
})
2026-07-05 02:06:37 +00:00
2026-07-05 08:45:54 +00:00
// 1) Build prompt
prompt , _ := antigravity . ExtractStudyElement (
ctx ,
task . QuestionText ,
task . Choice1 ,
task . Choice2 ,
task . Choice3 ,
task . Choice4 ,
task . CorrectAnswerStr ,
task . Explanation ,
)
// 2) AI Call
content , err := s . callAIEngine ( ctx , useGeminiAPI , prompt )
if err != nil {
log . Printf ( "Failed to extract study element for question %d: %v" , task . ID , err )
// Delay and continue to retry on subsequent questions
time . Sleep ( time . Duration ( delaySeconds ) * time . Second )
continue
}
2026-07-05 02:06:37 +00:00
2026-07-05 08:45:54 +00:00
// Clean content
content = strings . TrimSpace ( content )
if content == "" {
log . Printf ( "Question %d returned empty content, skipping DB save." , task . ID )
time . Sleep ( time . Duration ( delaySeconds ) * time . Second )
continue
}
2026-07-05 02:06:37 +00:00
2026-07-05 08:45:54 +00:00
// 3) Parse keyword from the first line of content
lines := strings . Split ( content , "\n" )
keyword := "개념 학습"
if len ( lines ) > 0 {
firstLine := strings . TrimSpace ( lines [ 0 ])
firstLine = strings . TrimLeft ( firstLine , "#*-\t " )
if firstLine != "" {
keyword = firstLine
2026-07-05 02:06:37 +00:00
}
2026-07-05 08:45:54 +00:00
}
2026-07-05 02:06:37 +00:00
2026-07-05 08:45:54 +00:00
// 4) Save to DB (Mutex Lock)
s . dbMu . Lock ()
_ , err = s . db . Exec ( `
INSERT INTO StudyElements (question_id, source, content, keyword)
VALUES (?, ?, ?, ?)
ON CONFLICT(source, question_id) DO UPDATE SET content=excluded.content, keyword=excluded.keyword
` , task . ID , task . Source , content , keyword )
s . dbMu . Unlock ()
if err != nil {
log . Printf ( "Failed to save study element to DB for question %d: %v" , task . ID , err )
}
2026-07-05 02:06:37 +00:00
2026-07-05 08:45:54 +00:00
// Sleep to maintain rate limit and avoid CPU locks
if idx < total - 1 {
time . Sleep ( time . Duration ( delaySeconds ) * time . Second )
}
2026-07-05 02:06:37 +00:00
}
s . emitEvent ( "study:done" , map [ string ] interface {}{
"category" : category ,
"message" : "학습요소 추출이 완료되었습니다." ,
})
}()
return "학습요소 추출을 시작합니다." , nil
}
// GenerateGoPracticalExams creates new exam questions based on existing ones.
2026-07-05 08:45:54 +00:00
func ( s * QuizService ) GenerateGoPracticalExams ( count int , useGeminiAPI bool , delaySeconds int , examType string ) ( string , error ) {
2026-07-05 02:06:37 +00:00
if count <= 0 {
count = 5
}
2026-07-05 08:45:54 +00:00
if delaySeconds < 1 {
delaySeconds = 1
}
if examType == "" {
examType = "혼합형"
}
2026-07-05 02:06:37 +00:00
// 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 ),
})
2026-07-05 08:45:54 +00:00
// Branch generation directives dynamically based on requested examType
var typeGuideline string
var correctAnswerStrGuideline string
if examType == "단답형" {
typeGuideline = "문제 유형은 반드시 정보보호기사 실기 단답형 또는 괄호(빈칸) 채우기 형태의 문제여야 합니다. (지문을 제시하고 특정 용어를 (A), (B) 형태로 채우게 하는 방식)"
correctAnswerStrGuideline = "이 문제의 핵심 정답 단어 또는 용어. 지문의 빈칸(A, B 등)에 해당하는 정답을 더블 세미콜론 ';;'으로 구분하여 명시하세요 (예: 스니핑;;스푸핑 또는 A: 스니핑;;B: 스푸핑). 사용자의 대소문자나 한글/영어 혼용 입력에 대비해 유효한 복수 정답 후보가 있다면 그것들도 전부 ';;'으로 구분해 기재하세요. (예: 스니핑;;sniffing). 절대 빈값이나 공백으로 남겨두지 마십시오."
} else if examType == "서술형" {
typeGuideline = "문제 유형은 반드시 정보보호기사 실기 서술형 문제여야 합니다. (특정 침해 사고 시나리오나 분석 로그, 방화벽 설정 덤프를 상세히 제시하고 1) 원인 및 취약점 분석, 2) 구체적인 조치/대응 방안 등을 다각도로 길게 서술하도록 지시하는 방식)"
correctAnswerStrGuideline = "주관식" // 서술형 주관식 에디터 및 채점기를 활성화하기 위해 이 필드는 반드시 '주관식'으로 고정해 주세요.
} else {
// 혼합형 (단답형과 서술형을 번갈아 생성)
if i % 2 == 0 {
typeGuideline = "문제 유형은 반드시 정보보호기사 실기 서술형 문제여야 합니다. (특정 침해 사고 시나리오나 분석 로그, 방화벽 설정 덤프를 상세히 제시하고 1) 원인 및 취약점 분석, 2) 구체적인 조치/대응 방안 등을 다각도로 길게 서술하도록 지시하는 방식)"
correctAnswerStrGuideline = "주관식"
} else {
typeGuideline = "문제 유형은 반드시 정보보호기사 실기 단답형 또는 괄호(빈칸) 채우기 형태의 문제여야 합니다. (지문을 제시하고 특정 용어를 (A), (B) 형태로 채우게 하는 방식)"
correctAnswerStrGuideline = "이 문제의 핵심 정답 단어 (예: 스니핑 또는 IP 스푸핑)"
}
}
// Format prompt inside quiz_service to reuse callAIEngine
refs := strings . Join ( references , "\n---\n" )
prompt := fmt . Sprintf ( "기존 정보보호기사 실기 문제들을 분석하여, 이와 유사한 출제 패턴과 난이도를 가진 \"새로운 정보보호기사 실기 문제\"를 만들어 주세요.\n\n" +
"[기존 실기 문제 예시]\n" +
"%s\n\n" +
"[출제 지침]\n" +
"1. %s\n" +
"2. 문제의 설명, 예시, 지문이 풍부하고 실무적이어야 합니다.\n" +
"3. 수식이나 차트가 필요한 경우, KaTeX 수식($...$ 또는 $$...$$) 및 Mermaid 차트(```mermaid ... ```)를 사용하여 풍부하게 구성하세요.\n" +
" - **Mermaid 문법 규칙**: syntax error 를 절대 방지하기 위해 다음 사항을 엄격히 준수하세요.\n" +
" * `subgraph` 이름(타이틀)에는 공백이나 한글, 괄호가 들어가므로 **반드시** 쌍따옴표로 감싸서 선언하세요. (예: `subgraph \"정상 스택 (Normal)\"`)\n" +
" * 화살표 연결선의 텍스트 라벨 `|설명|` 안에는 괄호 `()`, 슬래시 `/`, 백슬래시 `\\` 등의 특수문자를 절대 쓰지 마세요. (예: `-->|SMTP 프로토콜 포트 25|` 로 표현)\n" +
" * 노드 라벨 정의 시 한글, 공백, 백슬래시(`\\`) 또는 괄호가 들어가면 **무조건** 쌍따옴표로 감싸세요. (예: `A{\"HKLM\\\\... \\\\RunOnce 존재?\"}`)\n" +
" * 백슬래시(`\\`)는 이스케이프 오류 방지를 위해 두 번(`\\\\`) 겹쳐 적으세요.\n" +
"4. **매우 중요 - 명령어 덤프/실행 결과의 정렬**: 터미널 출력(예: lsof, netstat 등), 명령어 실행 로그, 덤프 텍스트처럼 공백 정렬이 필요한 결과물은 텍스트 줄 맞춤이 깨지지 않도록 **반드시** 마크다운 코드 블록(```bash ... ```)으로 감싸서 고정폭 글꼴로 출력되게 하세요.\n" +
"5. 생성된 문제는 반드시 아래의 JSON 포맷으로만 출력되어야 합니다. 마크다운 코드 블록(```json ... ```)을 사용하지 말고, 원시 JSON 텍스트만 출력하세요.\n\n" +
"JSON 구조:\n" +
"{\n" +
" \"questionText\": \"문제 지문 및 지시 사항\",\n" +
" \"explanation\": \"이 문제에 대한 모범 답안 및 상세한 해설\",\n" +
" \"correctAnswerStr\": \"%s\",\n" +
" \"difficulty\": \"실기(Go)\",\n" +
" \"subject\": \"만든 자료\"\n" +
"}\n\n" +
"오직 JSON만 출력해야 합니다. 다른 텍스트는 절대 포함하지 마십시오." , refs , typeGuideline , correctAnswerStrGuideline )
resp , err := s . callAIEngine ( ctx , useGeminiAPI , prompt )
2026-07-05 02:06:37 +00:00
if err != nil {
log . Printf ( "Failed to generate exam question %d: %v" , i + 1 , err )
continue
}
2026-07-05 08:45:54 +00:00
// Clean JSON response
cleaned := resp
if idx := strings . Index ( cleaned , "{" ); idx != - 1 {
cleaned = cleaned [ idx :]
}
if idx := strings . LastIndex ( cleaned , "}" ); idx != - 1 {
cleaned = cleaned [: idx + 1 ]
}
var gq antigravity . GeneratedQuestion
err = json . Unmarshal ([] byte ( cleaned ), & gq )
2026-07-05 02:06:37 +00:00
if err != nil {
2026-07-05 08:45:54 +00:00
log . Printf ( "Failed to parse JSON from AI for exam question %d: %v, raw output: %s" , i + 1 , err , resp )
2026-07-05 02:06:37 +00:00
continue
}
2026-07-05 12:29:33 +00:00
// Force correctAnswerStr to "주관식" for descriptive questions to guarantee descriptive UI layout
if examType == "서술형" {
gq . CorrectAnswerStr = "주관식"
} else if examType == "혼합형" && i % 2 == 0 {
gq . CorrectAnswerStr = "주관식"
}
2026-07-05 08:45:54 +00:00
// Save to DB (Mutex Lock)
s . dbMu . Lock ()
// 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 {
_ , err = s . db . Exec ( `
INSERT INTO QuizQuestions (id, source, category, question_text, choice1, choice2, choice3, choice4, correct_answer, correct_answer_str, explanation, difficulty, subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` , nextID , source , category , gq . QuestionText , gq . Choice1 , gq . Choice2 , gq . Choice3 , gq . Choice4 , gq . CorrectAnswer , gq . CorrectAnswerStr , gq . Explanation , difficulty , subject )
}
s . dbMu . Unlock ()
2026-07-05 02:06:37 +00:00
if err != nil {
log . Printf ( "Failed to insert generated question: %v" , err )
}
2026-07-05 08:45:54 +00:00
// Sleep to maintain rate limit and database lock spacing
if i < count - 1 {
time . Sleep ( time . Duration ( delaySeconds ) * time . Second )
}
}
// Ensure the new category is registered for all users so it appears in the quiz menu immediately
s . dbMu . Lock ()
_ , err = s . db . Exec ( "INSERT OR IGNORE INTO UserCategories (username, category) SELECT username, ? FROM Users" , category )
if err != nil {
log . Printf ( "Failed to register category for users: %v" , err )
2026-07-05 02:06:37 +00:00
}
2026-07-05 08:45:54 +00:00
s . dbMu . Unlock ()
2026-07-05 02:06:37 +00:00
s . emitEvent ( "exam:done" , map [ string ] interface {}{
"message" : fmt . Sprintf ( "%d개의 실기 문제가 생성 및 저장되었습니다." , count ),
})
}()
return "실기 문제 생성을 시작합니다." , nil
}