This commit is contained in:
root
2026-07-05 08:45:54 +00:00
parent c3596c12f3
commit 073d1dd383
8 changed files with 655 additions and 158 deletions
+291 -58
View File
@@ -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),
})