diff --git a/antigravity/sdk.go b/antigravity/sdk.go new file mode 100644 index 0000000..a25eab3 --- /dev/null +++ b/antigravity/sdk.go @@ -0,0 +1,111 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// StudyElement represents the extracted study content from a question. +type StudyElement struct { + QuestionID int `json:"questionId"` + Source string `json:"source"` + Category string `json:"category"` + Content string `json:"content"` // Markdown content containing formulas, charts, etc. +} + +// GeneratedQuestion matches the structure to be saved in QuizQuestions DB. +type GeneratedQuestion struct { + QuestionText string `json:"questionText"` + Choice1 string `json:"choice1"` + Choice2 string `json:"choice2"` + Choice3 string `json:"choice3"` + Choice4 string `json:"choice4"` + CorrectAnswer int `json:"correctAnswer"` + Explanation string `json:"explanation"` + Difficulty string `json:"difficulty"` + Subject string `json:"subject"` +} + +// CallAgy runs the agy CLI with the given prompt. +func CallAgy(ctx context.Context, prompt string) (string, error) { + cmd := exec.CommandContext(ctx, "agy", "--dangerously-skip-permissions", "--print", prompt) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("agy error: %v, stderr: %s", err, stderr.String()) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// ExtractStudyElement requests the AI to generate a comprehensive study sheet/note for a specific quiz question. +func ExtractStudyElement(ctx context.Context, questionText, choice1, choice2, choice3, choice4, correctAnswer, explanation string) (string, error) { + prompt := fmt.Sprintf("아래 기출문제의 문제, 보기, 정답, 해설을 철저히 분석하여 핵심 개념을 담은 \"학습요소(개념 정리)\"를 작성해 주세요.\n"+ + "이 학습요소는 사용자가 이 문제를 완벽하게 이해하고 관련된 이론적 배경까지 학습할 수 있도록 돕는 학습 노트입니다.\n\n"+ + "[기출문제]\n"+ + "문제: %s\n"+ + "보기 1: %s\n"+ + "보기 2: %s\n"+ + "보기 3: %s\n"+ + "보기 4: %s\n"+ + "정답: %s\n"+ + "해설: %s\n\n"+ + "[작성 지침]\n"+ + "1. 단순 정답 해설을 넘어, 문제에 포함된 핵심 개념(예: 특정 암호화 알고리즘, 시스템 보안 기술, 네트워크 프로토콜 등)의 정의, 특징, 작동 방식, 장단점을 일목요연하게 정리해 주세요.\n"+ + "2. 가독성을 위해 마크다운 포맷을 사용해 주세요. (예: 표, 리스트)\n"+ + "3. 수식이나 암호학적 기호가 필요한 경우 KaTeX 문법(예: $E_k(M) = C$)을 적극적으로 사용해 주세요.\n"+ + "4. 아키텍처나 흐름을 시각화할 수 있도록 Mermaid 다이어그램(예: ```mermaid ... ```) 또는 간단한 아스키 차트(ASCII Chart/Plot)를 본문에 반드시 하나 이상 포함해 주세요.\n"+ + "5. 다른 잡다한 설명(예: \"네, 작성해 드리겠습니다\" 등)은 모두 제외하고, 오직 마크다운으로 구성된 학습요소 내용만 반환해 주세요.", + questionText, choice1, choice2, choice3, choice4, correctAnswer, explanation) + + return CallAgy(ctx, prompt) +} + +// GenerateDescriptiveQuestion generates a new descriptive/practical exam question based on given reference questions. +func GenerateDescriptiveQuestion(ctx context.Context, referenceExams []string) (*GeneratedQuestion, error) { + refs := strings.Join(referenceExams, "\n---\n") + prompt := fmt.Sprintf("기존 정보보호기사 실기 문제들을 분석하여, 이와 유사한 출제 패턴과 난이도를 가진 \"새로운 정보보호기사 실기 문제\"를 만들어 주세요.\n\n"+ + "[기존 실기 문제 예시]\n"+ + "%s\n\n"+ + "[출제 지침]\n"+ + "1. 문제 유형은 정보보호기사 실기 시험의 단답형 또는 서술형 형태여야 합니다. (지문을 제시하고 빈칸 채우기, 또는 특정 시나리오를 주고 대처 방안 서술하기 등)\n"+ + "2. 문제의 설명, 예시, 지문이 풍부하고 실무적이어야 합니다.\n"+ + "3. 생성된 문제는 반드시 아래의 JSON 포맷으로만 출력되어야 합니다. 마크다운 코드 블록(```json ... ```)을 사용하지 말고, 원시 JSON 텍스트만 출력하세요.\n\n"+ + "JSON 구조:\n"+ + "{\n"+ + " \"questionText\": \"문제 지문 및 지시 사항 (예: 다음 지문을 읽고 빈칸 A, B에 들어갈 단어를 기술하시오...)\",\n"+ + " \"explanation\": \"이 문제에 대한 모범 답안 및 상세한 해설\",\n"+ + " \"difficulty\": \"실기(Go)\",\n"+ + " \"subject\": \"만든 자료\"\n"+ + "}\n\n"+ + "오직 JSON만 출력해야 합니다. 다른 텍스트는 절대 포함하지 마십시오.", refs) + + resp, err := CallAgy(ctx, prompt) + if err != nil { + return nil, err + } + + // Clean JSON response (sometimes LLM might return markdown code blocks despite instructions) + 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 GeneratedQuestion + err = json.Unmarshal([]byte(cleaned), &gq) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON from AI: %v, raw output: %s", err, resp) + } + + return &gq, nil +} diff --git a/assets/ocr_uploads/ocr_admin_newbt_86198_1783153914853022667.jpg b/assets/ocr_uploads/ocr_admin_newbt_86198_1783153914853022667.jpg new file mode 100644 index 0000000..9ee1715 Binary files /dev/null and b/assets/ocr_uploads/ocr_admin_newbt_86198_1783153914853022667.jpg differ diff --git a/assets/ocr_uploads/ocr_admin_newbt_9687_1783152191946148892.jpg b/assets/ocr_uploads/ocr_admin_newbt_9687_1783152191946148892.jpg new file mode 100644 index 0000000..bf381df Binary files /dev/null and b/assets/ocr_uploads/ocr_admin_newbt_9687_1783152191946148892.jpg differ diff --git a/assets/ocr_uploads/ocr_admin_newbt_9716_1783153265801276651.jpg b/assets/ocr_uploads/ocr_admin_newbt_9716_1783153265801276651.jpg new file mode 100644 index 0000000..38501a7 Binary files /dev/null and b/assets/ocr_uploads/ocr_admin_newbt_9716_1783153265801276651.jpg differ diff --git a/frontend/bindings/changeme/index.ts b/frontend/bindings/changeme/index.ts index 4483a0a..f429f93 100644 --- a/frontend/bindings/changeme/index.ts +++ b/frontend/bindings/changeme/index.ts @@ -10,6 +10,7 @@ export { CategoryInfo, DescriptiveGradingRecord, Question, + StudyElementDB, TargetInfo, User } from "./models.js"; diff --git a/frontend/bindings/changeme/models.ts b/frontend/bindings/changeme/models.ts index 51e1002..eff6642 100644 --- a/frontend/bindings/changeme/models.ts +++ b/frontend/bindings/changeme/models.ts @@ -180,6 +180,43 @@ export class Question { } } +export class StudyElementDB { + "questionId": number; + "source": string; + "questionText": string; + "content": string; + "category": string; + + /** Creates a new StudyElementDB instance. */ + constructor($$source: Partial = {}) { + if (!("questionId" in $$source)) { + this["questionId"] = 0; + } + if (!("source" in $$source)) { + this["source"] = ""; + } + if (!("questionText" in $$source)) { + this["questionText"] = ""; + } + if (!("content" in $$source)) { + this["content"] = ""; + } + if (!("category" in $$source)) { + this["category"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new StudyElementDB instance from a string or object. + */ + static createFrom($$source: any = {}): StudyElementDB { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new StudyElementDB($$parsedSource as Partial); + } +} + export class TargetInfo { "name": string; "count": number; diff --git a/frontend/bindings/changeme/quizservice.ts b/frontend/bindings/changeme/quizservice.ts index d7a37a8..f308cb1 100644 --- a/frontend/bindings/changeme/quizservice.ts +++ b/frontend/bindings/changeme/quizservice.ts @@ -59,6 +59,21 @@ export function DeleteUser(username: string): $CancellablePromise { return $Call.ByID(4061081732, username); } +/** + * ExtractStudyElementsForCategory extracts study elements for all questions in the category. + * It skips questions that already have an extracted study element. + */ +export function ExtractStudyElementsForCategory(category: string): $CancellablePromise { + return $Call.ByID(3340623770, category); +} + +/** + * GenerateGoPracticalExams creates new exam questions based on existing ones. + */ +export function GenerateGoPracticalExams(count: number): $CancellablePromise { + return $Call.ByID(4059537052, count); +} + /** * GetBookmarkedCategories retrieves categories that have bookmarked questions for a user */ @@ -138,6 +153,15 @@ export function GetQuestions(username: string, category: string, difficulty: str }); } +/** + * GetStudyElements retrieves all extracted study elements for a category. + */ +export function GetStudyElements(category: string): $CancellablePromise<$models.StudyElementDB[]> { + return $Call.ByID(1892893004, category).then(($result: any) => { + return $$createType12($result); + }); +} + export function GetSubjects(category: string, difficulty: string): $CancellablePromise { return $Call.ByID(1060436249, category, difficulty).then(($result: any) => { return $$createType8($result); @@ -170,7 +194,7 @@ export function GetUsers(): $CancellablePromise<$models.User[]> { export function GradeDescriptiveAnswer(username: string, source: string, questionID: number, userAnswer: string, ocrImageBase64: string): $CancellablePromise<$models.DescriptiveGradingRecord | null> { return $Call.ByID(642001377, username, source, questionID, userAnswer, ocrImageBase64).then(($result: any) => { - return $$createType11($result); + return $$createType13($result); }); } @@ -179,7 +203,7 @@ export function GradeDescriptiveAnswer(username: string, source: string, questio */ export function Login(username: string, password: string): $CancellablePromise<$models.User | null> { return $Call.ByID(3841810241, username, password).then(($result: any) => { - return $$createType12($result); + return $$createType14($result); }); } @@ -263,5 +287,7 @@ const $$createType7 = $Create.Array($$createType6); const $$createType8 = $Create.Array($Create.Any); const $$createType9 = $models.User.createFrom; const $$createType10 = $Create.Array($$createType9); -const $$createType11 = $Create.Nullable($$createType6); -const $$createType12 = $Create.Nullable($$createType9); +const $$createType11 = $models.StudyElementDB.createFrom; +const $$createType12 = $Create.Array($$createType11); +const $$createType13 = $Create.Nullable($$createType6); +const $$createType14 = $Create.Nullable($$createType9); diff --git a/frontend/index.html b/frontend/index.html index d2f77ae..0850539 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,7 @@ + Master CBT diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a96f369..9437fb1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -59,6 +59,11 @@ 책갈피 + + + + 학습요소 & 실기 생성 + @@ -241,7 +246,7 @@ + + diff --git a/quiz_service.go b/quiz_service.go index 73823c1..dc5fd37 100644 --- a/quiz_service.go +++ b/quiz_service.go @@ -14,6 +14,8 @@ import ( "strings" "time" + "changeme/antigravity" + "github.com/google/generative-ai-go/genai" "github.com/wailsapp/wails/v3/pkg/application" "google.golang.org/api/option" @@ -121,6 +123,20 @@ func NewQuizService() *QuizService { log.Fatalf("Failed to create QuizExplain table: %v", err) } + // Create StudyElements table if it doesn't exist + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS StudyElements ( + question_id INTEGER NOT NULL, + source TEXT NOT NULL, + content TEXT, + PRIMARY KEY (source, question_id), + FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE + ) + `) + if err != nil { + log.Fatalf("Failed to create StudyElements table: %v", err) + } + // Create Users table if it doesn't exist _, err = db.Exec(` CREATE TABLE IF NOT EXISTS Users ( @@ -1377,3 +1393,218 @@ func (s *QuizService) UpdateUserInfoForce(username, nickname, phone, password st } } +type StudyElementDB struct { + QuestionID int `json:"questionId"` + Source string `json:"source"` + QuestionText string `json:"questionText"` + Content string `json:"content"` + Category string `json:"category"` +} + +// GetStudyElements retrieves all extracted study elements for a category. +func (s *QuizService) GetStudyElements(category string) ([]StudyElementDB, error) { + query := ` + SELECT se.question_id, se.source, se.content, q.question_text, q.category + FROM StudyElements se + JOIN QuizQuestions q ON se.source = q.source AND se.question_id = q.id + WHERE q.category = ? + ORDER BY se.question_id ASC + ` + rows, err := s.db.Query(query, category) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []StudyElementDB + for rows.Next() { + var se StudyElementDB + err := rows.Scan(&se.QuestionID, &se.Source, &se.Content, &se.QuestionText, &se.Category) + if err == nil { + list = append(list, se) + } + } + return list, nil +} + +// ExtractStudyElementsForCategory extracts study elements for all questions in the category. +// It skips questions that already have an extracted study element. +func (s *QuizService) ExtractStudyElementsForCategory(category string) (string, error) { + // 1. Get questions in this category that don't have study elements yet. + query := ` + SELECT q.id, q.source, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, q.correct_answer_str, q.explanation + FROM QuizQuestions q + LEFT JOIN StudyElements se ON q.source = se.source AND q.id = se.question_id + WHERE q.category = ? AND se.question_id IS NULL + ` + rows, err := s.db.Query(query, category) + if err != nil { + return "", err + } + defer rows.Close() + + type Task struct { + ID int + Source string + QuestionText string + Choice1 string + Choice2 string + Choice3 string + Choice4 string + CorrectAnswerStr string + Explanation string + } + + var tasks []Task + for rows.Next() { + var t Task + err := rows.Scan(&t.ID, &t.Source, &t.QuestionText, &t.Choice1, &t.Choice2, &t.Choice3, &t.Choice4, &t.CorrectAnswerStr, &t.Explanation) + if err == nil { + tasks = append(tasks, t) + } + } + + if len(tasks) == 0 { + return "이미 모든 문제의 학습요소가 추출되어 있습니다.", nil + } + + // 2. Start a background goroutine to process with limited concurrency. + go func() { + ctx := context.Background() + sem := make(chan struct{}, 3) // limit to 3 concurrent agy calls + total := len(tasks) + + for i, t := range tasks { + sem <- struct{}{} + go func(idx int, task Task) { + defer func() { <-sem }() + + // Update progress + s.emitEvent("study:progress", map[string]interface{}{ + "current": idx + 1, + "total": total, + "message": fmt.Sprintf("문제 %d 추출 중...", task.ID), + }) + + content, err := antigravity.ExtractStudyElement( + ctx, + task.QuestionText, + task.Choice1, + task.Choice2, + task.Choice3, + task.Choice4, + task.CorrectAnswerStr, + task.Explanation, + ) + + if err != nil { + log.Printf("Failed to extract study element for question %d: %v", task.ID, err) + return + } + + // Save to DB + _, err = s.db.Exec(` + INSERT INTO StudyElements (question_id, source, content) + VALUES (?, ?, ?) + ON CONFLICT(source, question_id) DO UPDATE SET content=excluded.content + `, task.ID, task.Source, content) + if err != nil { + log.Printf("Failed to save study element to DB: %v", err) + } + }(i, t) + } + + // Wait for all goroutines to finish + for i := 0; i < 3; i++ { + sem <- struct{}{} + } + + s.emitEvent("study:done", map[string]interface{}{ + "category": category, + "message": "학습요소 추출이 완료되었습니다.", + }) + }() + + return "학습요소 추출을 시작합니다.", nil +} + +// GenerateGoPracticalExams creates new exam questions based on existing ones. +func (s *QuizService) GenerateGoPracticalExams(count int) (string, error) { + if count <= 0 { + count = 5 + } + + // 1. Fetch sample real exams as references for the AI + rows, err := s.db.Query(` + SELECT question_text, explanation + FROM QuizQuestions + WHERE category = '정보보안기사 실기' AND explanation != '' + LIMIT 10 + `) + if err != nil { + return "", err + } + defer rows.Close() + + var references []string + for rows.Next() { + var qText, explanation string + if err := rows.Scan(&qText, &explanation); err == nil { + references = append(references, fmt.Sprintf("질문:\n%s\n\n모범답안/해설:\n%s", qText, explanation)) + } + } + + if len(references) == 0 { + references = []string{ + "질문:\n다음 지문을 읽고 빈칸 A, B에 들어갈 적절한 용어를 쓰시오.\n- ( A )은 네트워크 상의 패킷을 가로채어 분석하는 행위 또는 도구를 말한다.\n- ( B )은 IP 주소를 스푸핑하여 정상적인 호스트인 것처럼 위장하는 공격이다.\n\n모범답안/해설:\nA: 스니핑(Sniffing)\nB: IP 스푸핑(IP Spoofing)", + } + } + + // 2. Start a background goroutine to generate questions + go func() { + ctx := context.Background() + category := "정보보호기사 실기(Go)" + source := "qple" + difficulty := "실기(Go)" + subject := "만든 자료" + + for i := 0; i < count; i++ { + s.emitEvent("exam:progress", map[string]interface{}{ + "current": i + 1, + "total": count, + "message": fmt.Sprintf("%d번째 실기 문제 생성 중...", i+1), + }) + + gq, err := antigravity.GenerateDescriptiveQuestion(ctx, references) + if err != nil { + log.Printf("Failed to generate exam question %d: %v", i+1, err) + continue + } + + // Generate next ID + var nextID int + err = s.db.QueryRow("SELECT COALESCE(MAX(id), 0) + 1 FROM QuizQuestions WHERE source = ?", source).Scan(&nextID) + if err != nil { + log.Printf("Failed to get next ID: %v", err) + continue + } + + // Insert into QuizQuestions + _, err = s.db.Exec(` + INSERT INTO QuizQuestions (id, source, category, question_text, choice1, choice2, choice3, choice4, correct_answer, explanation, difficulty, subject) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, nextID, source, category, gq.QuestionText, gq.Choice1, gq.Choice2, gq.Choice3, gq.Choice4, gq.CorrectAnswer, gq.Explanation, difficulty, subject) + + if err != nil { + log.Printf("Failed to insert generated question: %v", err) + } + } + + s.emitEvent("exam:done", map[string]interface{}{ + "message": fmt.Sprintf("%d개의 실기 문제가 생성 및 저장되었습니다.", count), + }) + }() + + return "실기 문제 생성을 시작합니다.", nil +} +