gen
This commit is contained in:
+291
-58
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"changeme/antigravity"
|
||||
@@ -84,6 +85,7 @@ type QuizService struct {
|
||||
db *sql.DB
|
||||
geminiKey string
|
||||
app *application.App
|
||||
dbMu sync.Mutex // SQLite database lock prevention mutex
|
||||
}
|
||||
|
||||
func (s *QuizService) SetApp(app *application.App) {
|
||||
@@ -129,6 +131,7 @@ func NewQuizService() *QuizService {
|
||||
question_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
content TEXT,
|
||||
keyword TEXT,
|
||||
PRIMARY KEY (source, question_id),
|
||||
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -137,6 +140,24 @@ func NewQuizService() *QuizService {
|
||||
log.Fatalf("Failed to create StudyElements table: %v", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Create Users table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS Users (
|
||||
@@ -305,11 +326,91 @@ func NewQuizService() *QuizService {
|
||||
}
|
||||
}
|
||||
|
||||
return &QuizService{
|
||||
// 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{
|
||||
db: db,
|
||||
// Initialize this via some UI setting later, or env var
|
||||
geminiKey: "",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SetGeminiKey sets the API key for Gemini calls
|
||||
@@ -435,7 +536,7 @@ func (s *QuizService) GetSubjects(category, difficulty string) []string {
|
||||
func (s *QuizService) GetQuestions(username, category, difficulty, subject string) []Question {
|
||||
query := `
|
||||
SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5,
|
||||
q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url,
|
||||
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,
|
||||
COALESCE(e.explain, '') as geminiExplanation,
|
||||
EXISTS(SELECT 1 FROM Bookmark WHERE username = ? AND source = q.source AND question_id = q.id) as isBookmarked,
|
||||
COALESCE(q.subject, '') as subject,
|
||||
@@ -1398,13 +1499,40 @@ type StudyElementDB struct {
|
||||
Source string `json:"source"`
|
||||
QuestionText string `json:"questionText"`
|
||||
Content string `json:"content"`
|
||||
Keyword string `json:"keyword"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
SELECT se.question_id, se.source, COALESCE(se.content, ''), COALESCE(se.keyword, ''), 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 = ?
|
||||
@@ -1419,7 +1547,7 @@ func (s *QuizService) GetStudyElements(category string) ([]StudyElementDB, error
|
||||
var list []StudyElementDB
|
||||
for rows.Next() {
|
||||
var se StudyElementDB
|
||||
err := rows.Scan(&se.QuestionID, &se.Source, &se.Content, &se.QuestionText, &se.Category)
|
||||
err := rows.Scan(&se.QuestionID, &se.Source, &se.Content, &se.Keyword, &se.QuestionText, &se.Category)
|
||||
if err == nil {
|
||||
list = append(list, se)
|
||||
}
|
||||
@@ -1429,13 +1557,17 @@ func (s *QuizService) GetStudyElements(category string) ([]StudyElementDB, error
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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
|
||||
WHERE q.category = ? AND (se.question_id IS NULL OR se.content IS NULL OR trim(se.content) = '')
|
||||
`
|
||||
rows, err := s.db.Query(query, category)
|
||||
if err != nil {
|
||||
@@ -1468,55 +1600,76 @@ func (s *QuizService) ExtractStudyElementsForCategory(category string) (string,
|
||||
return "이미 모든 문제의 학습요소가 추출되어 있습니다.", nil
|
||||
}
|
||||
|
||||
// 2. Start a background goroutine to process with limited concurrency.
|
||||
// 2. Start a background sequential goroutine to process with configurable delay.
|
||||
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 }()
|
||||
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),
|
||||
})
|
||||
|
||||
// Update progress
|
||||
s.emitEvent("study:progress", map[string]interface{}{
|
||||
"current": idx + 1,
|
||||
"total": total,
|
||||
"message": fmt.Sprintf("문제 %d 추출 중...", task.ID),
|
||||
})
|
||||
// 1) Build prompt
|
||||
prompt, _ := antigravity.ExtractStudyElement(
|
||||
ctx,
|
||||
task.QuestionText,
|
||||
task.Choice1,
|
||||
task.Choice2,
|
||||
task.Choice3,
|
||||
task.Choice4,
|
||||
task.CorrectAnswerStr,
|
||||
task.Explanation,
|
||||
)
|
||||
|
||||
content, err := 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
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to extract study element for question %d: %v", task.ID, err)
|
||||
return
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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()
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
for i := 0; i < 3; i++ {
|
||||
sem <- struct{}{}
|
||||
if err != nil {
|
||||
log.Printf("Failed to save study element to DB for question %d: %v", task.ID, err)
|
||||
}
|
||||
|
||||
// Sleep to maintain rate limit and avoid CPU locks
|
||||
if idx < total-1 {
|
||||
time.Sleep(time.Duration(delaySeconds) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
s.emitEvent("study:done", map[string]interface{}{
|
||||
@@ -1529,10 +1682,16 @@ func (s *QuizService) ExtractStudyElementsForCategory(category string) (string,
|
||||
}
|
||||
|
||||
// GenerateGoPracticalExams creates new exam questions based on existing ones.
|
||||
func (s *QuizService) GenerateGoPracticalExams(count int) (string, error) {
|
||||
func (s *QuizService) GenerateGoPracticalExams(count int, useGeminiAPI bool, delaySeconds int, examType string) (string, error) {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
if delaySeconds < 1 {
|
||||
delaySeconds = 1
|
||||
}
|
||||
if examType == "" {
|
||||
examType = "혼합형"
|
||||
}
|
||||
|
||||
// 1. Fetch sample real exams as references for the AI
|
||||
rows, err := s.db.Query(`
|
||||
@@ -1575,31 +1734,105 @@ func (s *QuizService) GenerateGoPracticalExams(count int) (string, error) {
|
||||
"message": fmt.Sprintf("%d번째 실기 문제 생성 중...", i+1),
|
||||
})
|
||||
|
||||
gq, err := antigravity.GenerateDescriptiveQuestion(ctx, references)
|
||||
// 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)
|
||||
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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get next ID: %v", err)
|
||||
log.Printf("Failed to parse JSON from AI for exam question %d: %v, raw output: %s", i+1, err, resp)
|
||||
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)
|
||||
// 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()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert generated question: %v", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
s.dbMu.Unlock()
|
||||
|
||||
s.emitEvent("exam:done", map[string]interface{}{
|
||||
"message": fmt.Sprintf("%d개의 실기 문제가 생성 및 저장되었습니다.", count),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user