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
+26 -54
View File
@@ -3,7 +3,6 @@ package antigravity
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
@@ -19,15 +18,16 @@ type StudyElement struct {
// 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"`
QuestionText string `json:"questionText"`
Choice1 string `json:"choice1"`
Choice2 string `json:"choice2"`
Choice3 string `json:"choice3"`
Choice4 string `json:"choice4"`
CorrectAnswer int `json:"correctAnswer"`
CorrectAnswerStr string `json:"correctAnswerStr"`
Explanation string `json:"explanation"`
Difficulty string `json:"difficulty"`
Subject string `json:"subject"`
}
// CallAgy runs the agy CLI with the given prompt.
@@ -58,54 +58,26 @@ func ExtractStudyElement(ctx context.Context, questionText, choice1, choice2, ch
"정답: %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. 다른 잡다한 설명(예: \"네, 작성해 드리겠습니다\" 등)은 모두 제외하고, 오직 마크다운으로 구성된 학습요소 내용만 반환해 주세요.",
"1. **매우 중요**: 문서의 가장 첫 번째 줄은 반드시 핵심 학습개념/키워드를 설명하는 제목으로 시작해 주세요. (예: `# AES 암호화 알고리즘` 또는 `# OSI 7계층과 캡슐화`)\n"+
" - 첫 줄 외에 다른 서두(예: \"네, 아래는 개념 정리입니다\" 등)는 일절 포함하지 마십시오.\n"+
"2. 단순 정답 해설을 넘어, 문제에 포함된 핵심 개념의 정의, 특징, 작동 방식, 장단점을 일목요연하게 정리해 주세요. (표, 리스트 적극 활용)\n"+
"3. **KaTeX 문법 규칙**: 수식 작성 시 파싱 오류를 철저히 방지하세요. 괄호 매칭을 완벽히 지키고, 수식 내부에 한글을 포함해야 하는 경우 반드시 `\\text{한글}` 형식을 사용하세요. 인라인 수식은 `$수식$`, 디스플레이 수식은 `$$수식$$`로만 작성하세요.\n"+
"4. **Mermaid 문법 규칙**: Mermaid 버전 10.9.6 문법을 엄격히 지켜 `syntax error in text mermaid` 에러를 예방하세요.\n"+
" - `subgraph` 이름(타이틀)에는 공백이나 한글, 괄호가 들어가므로 **반드시** 쌍따옴표로 감싸서 선언하세요. (예: `subgraph \"정상 스택 (Normal)\"`)\n"+
" - 화살표 연결선의 텍스트 라벨 `|설명|` 안에는 괄호 `()`, 슬래시 `/`, 백슬래시 `\\` 등의 특수문자를 절대 쓰지 마세요. (예: `-->|SMTP 프로토콜 포트 25|` 로 표현)\n"+
" - 노드 내부에 한글, 공백, 기호, 괄호가 포함되는 경우 **반드시** 쌍따옴표로 감싸서 선언하세요 (예: `A[\"AES 암호화\"] --> B[\"키 생성\"]`).\n"+
" - 연결 화살표 기호는 오직 `-->` 또는 `-.->` 만 사용해야 하며, `->` 나 `- >` 처럼 깨진 형식을 쓰지 마세요.\n"+
" - 서브그래프(subgraph)를 열었다면 반드시 `end`로 닫아 마감하세요.\n"+
" - 백슬래시(`\\`)는 이스케이프 오류 방지를 위해 두 번(`\\\\`) 겹쳐 적으세요.\n"+
"5. 다른 불필요한 설명은 제외하고, 오직 첫 줄 개념명(# ...)으로 시작하는 마크다운 본문만 반환해 주세요.",
questionText, choice1, choice2, choice3, choice4, correctAnswer, explanation)
return CallAgy(ctx, prompt)
return prompt, nil
}
// 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
// We will create the prompt text in quiz_service.go using callAIEngine.
// This function is kept for backwards compatibility but we will format the prompt inside quiz_service to leverage both API and agy engines.
return nil, fmt.Errorf("use the prompt constructor and callAIEngine helper instead")
}
+4
View File
@@ -185,6 +185,7 @@ export class StudyElementDB {
"source": string;
"questionText": string;
"content": string;
"keyword": string;
"category": string;
/** Creates a new StudyElementDB instance. */
@@ -201,6 +202,9 @@ export class StudyElementDB {
if (!("content" in $$source)) {
this["content"] = "";
}
if (!("keyword" in $$source)) {
this["keyword"] = "";
}
if (!("category" in $$source)) {
this["category"] = "";
}
+4 -4
View File
@@ -63,15 +63,15 @@ export function DeleteUser(username: string): $CancellablePromise<void> {
* 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<string> {
return $Call.ByID(3340623770, category);
export function ExtractStudyElementsForCategory(category: string, useGeminiAPI: boolean, delaySeconds: number): $CancellablePromise<string> {
return $Call.ByID(3340623770, category, useGeminiAPI, delaySeconds);
}
/**
* GenerateGoPracticalExams creates new exam questions based on existing ones.
*/
export function GenerateGoPracticalExams(count: number): $CancellablePromise<string> {
return $Call.ByID(4059537052, count);
export function GenerateGoPracticalExams(count: number, useGeminiAPI: boolean, delaySeconds: number, examType: string): $CancellablePromise<string> {
return $Call.ByID(4059537052, count, useGeminiAPI, delaySeconds, examType);
}
/**
+37 -3
View File
@@ -1,5 +1,5 @@
<template>
<div class="drawer lg:drawer-open" data-theme="dark">
<div class="drawer lg:drawer-open" :data-theme="currentTheme">
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
@@ -121,6 +121,18 @@
<p class="text-[10px] mt-2 px-2 opacity-40 font-medium">문제 순서 무작위 배치</p>
</div>
</div>
<!-- Theme Settings Card -->
<div class="p-6 m-4 mt-0 bg-base-300 rounded-3xl border border-base-content/5 space-y-3">
<div class="flex items-center gap-2 px-2">
<PaletteIcon :size="16" class="text-secondary" />
<span class="text-xs font-black uppercase tracking-widest opacity-60">테마 설정</span>
</div>
<button @click="randomizeTheme" class="btn btn-outline btn-sm btn-secondary w-full text-xs font-bold gap-2">
<ShuffleIcon :size="14" />
랜덤 테마: {{ currentTheme }}
</button>
</div>
</aside>
</div>
@@ -244,13 +256,35 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { LayoutDashboardIcon, BookOpenIcon, KeyIcon, TrophyIcon, MenuIcon, ShuffleIcon, BookmarkIcon, UsersIcon, UserIcon, UserCogIcon, LockIcon, GraduationCapIcon } from 'lucide-vue-next'
import { LayoutDashboardIcon, BookOpenIcon, KeyIcon, TrophyIcon, MenuIcon, ShuffleIcon, BookmarkIcon, UsersIcon, UserIcon, UserCogIcon, LockIcon, GraduationCapIcon, PaletteIcon } from 'lucide-vue-next'
import { useQuizStore } from './store/quiz'
import { useAuthStore } from './store/auth'
import { GetGeminiKeys, ChangePassword, UpdateUserApiKey } from '../bindings/changeme/quizservice'
const themes = [
"light", "dark", "cupcake", "bumblebee", "emerald", "corporate",
"synthwave", "retro", "cyberpunk", "valentine", "halloween",
"garden", "forest", "aqua", "lofi", "pastel", "fantasy",
"wireframe", "black", "luxury", "dracula", "cmyk", "autumn",
"business", "acid", "lemonade", "night", "coffee", "winter",
"dim", "nord", "sunset", "caramellatte", "abyss", "silk"
]
const currentTheme = ref(localStorage.getItem('app-theme') || 'dark')
// Sync the theme to html element directly for DaisyUI scoping
watch(currentTheme, (newTheme) => {
document.documentElement.setAttribute('data-theme', newTheme)
}, { immediate: true })
function randomizeTheme() {
const randomIndex = Math.floor(Math.random() * themes.length)
const nextTheme = themes[randomIndex]
currentTheme.value = nextTheme
localStorage.setItem('app-theme', nextTheme)
}
const store = useQuizStore()
const authStore = useAuthStore()
const router = useRouter()
+167 -11
View File
@@ -40,9 +40,18 @@
</div>
</div>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs sm:text-sm font-medium opacity-70 text-base-content">{{ currentIndex + 1 }} / {{ questions.length }}</span>
<progress class="progress progress-primary w-20 sm:w-28 md:w-32" :value="currentIndex + 1" :max="questions.length"></progress>
<div class="flex items-center gap-4 shrink-0">
<!-- 중앙 정렬 옵션 스위치 -->
<label v-if="!loading && questions.length > 0 && !quizFinished" class="label cursor-pointer gap-2 bg-base-300/40 px-3 py-1.5 rounded-xl border border-base-content/5">
<span class="label-text text-[10px] font-bold opacity-70">중앙 정렬</span>
<input type="checkbox" v-model="centerAlign" class="checkbox checkbox-xs checkbox-primary" />
</label>
<div class="flex flex-col items-end gap-1">
<span class="text-xs sm:text-sm font-medium opacity-70 text-base-content">{{ currentIndex + 1 }} / {{ questions.length }}</span>
<progress class="progress progress-primary w-20 sm:w-28 md:w-32" :value="currentIndex + 1" :max="questions.length"></progress>
</div>
</div>
</div>
@@ -127,14 +136,17 @@
<!-- Text/Markdown Passage -->
<div
v-if="currentQuestion.passage"
:class="passageClass"
:class="[passageClass, centerAlign ? 'text-center' : 'text-left']"
class="markdown-content prose prose-sm prose-invert max-w-none"
v-html="renderedPassage"
>
</div>
</div>
<h3 class="text-base sm:text-lg md:text-xl lg:text-2xl font-bold leading-relaxed text-base-content markdown-content prose prose-sm prose-invert inline-markdown">
<h3
class="text-base sm:text-lg md:text-xl lg:text-2xl font-bold leading-relaxed text-base-content markdown-content prose prose-sm prose-invert inline-markdown"
:class="centerAlign ? 'text-center' : 'text-left'"
>
<span class="inline-flex items-center align-middle gap-1.5 mr-2">
<span class="text-primary">Q{{ currentIndex + 1 }}.</span>
<button
@@ -417,7 +429,11 @@
<div v-if="currentQuestion.explanation" class="card bg-base-100 shadow-sm">
<div class="card-body p-4 sm:p-5">
<h5 class="text-[10px] sm:text-xs font-black uppercase tracking-widest text-slate-500 mb-2">해설</h5>
<div class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none" v-html="renderedExplanationHuman"></div>
<div
class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none"
:class="centerAlign ? 'text-center' : 'text-left'"
v-html="renderedExplanationHuman"
></div>
</div>
</div>
@@ -453,8 +469,12 @@
<span class="loading loading-dots loading-md text-info"></span>
<p class="text-xs text-info font-medium italic">Gemini 생각중</p>
</div>
<div v-else-if="currentQuestion.geminiExplanation" class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none" v-html="renderedExplanation">
</div>
<div
v-else-if="currentQuestion.geminiExplanation"
class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none"
:class="centerAlign ? 'text-center' : 'text-left'"
v-html="renderedExplanation"
></div>
<p v-else-if="!geminiError" class="text-xs text-slate-500 italic">상세한 해설이 필요한가요~? Gemini를 불러보세요.</p>
</div>
</div>
@@ -523,7 +543,25 @@ import { marked } from 'marked'
import katex from 'katex'
import 'katex/dist/katex.min.css'
const renderer = new marked.Renderer()
renderer.code = function({ text, lang }) {
if (lang === 'mermaid') {
return `<div class="mermaid">${text}</div>`
}
return `<pre><code class="language-${lang}">${text}</code></pre>`
}
// Table wrapper to prevent collapsing and support horizontal scrolling
const originalTable = renderer.table.bind(renderer)
renderer.table = function(token) {
const rawTable = originalTable(token)
return `<div class="overflow-x-auto my-4 border border-base-content/15 rounded-xl bg-base-300/10 shadow-inner">${rawTable}</div>`
}
marked.use({
gfm: true,
breaks: true,
renderer,
tokenizer: {
del(src) {
const doubleMatch = src.match(/^~~(?=\S)([\s\S]*?\S)~~(?![~])/)
@@ -570,6 +608,11 @@ const quizFinished = ref(false)
const userTextAnswer = ref('')
const userOXAnswer = ref('')
const centerAlign = ref(localStorage.getItem('quiz_center_align') === 'true')
watch(centerAlign, (val) => {
localStorage.setItem('quiz_center_align', val ? 'true' : 'false')
})
const userDescriptiveAnswer = ref('')
const descriptiveGradingResult = ref<DescriptiveGradingRecord | null>(null)
const descriptiveHistory = ref<DescriptiveGradingRecord[]>([])
@@ -819,9 +862,37 @@ function applyHighlighting() {
})
}
// 문제 전환 시 구문 강조 다시 적용
function triggerMermaid() {
nextTick(() => {
// @ts-ignore
if (window.mermaid) {
try {
// @ts-ignore
window.mermaid.run({ querySelector: '.mermaid' })
} catch (e) {
console.error('Mermaid run error:', e)
}
}
})
}
// 문제 전환 시 구문 강조 및 mermaid 다시 적용
watch(currentIndex, () => {
applyHighlighting()
triggerMermaid()
})
// 피드백/해설이 노출되거나 AI 해설이 업데이트될 때도 mermaid 렌더링
watch(showFeedback, (newVal) => {
if (newVal) {
triggerMermaid()
}
})
watch(() => currentQuestion.value?.geminiExplanation, (newVal) => {
if (newVal) {
triggerMermaid()
}
})
const renderedPassage = computed(() => {
@@ -1021,6 +1092,24 @@ onUnmounted(() => {
})
onMounted(async () => {
// Load mermaid library if not present
if (!document.getElementById('mermaid-script')) {
const script = document.createElement('script')
script.id = 'mermaid-script'
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js'
script.onload = () => {
// @ts-ignore
if (window.mermaid) {
// @ts-ignore
window.mermaid.initialize({ startOnLoad: false, theme: 'dark' })
triggerMermaid()
}
}
document.head.appendChild(script)
} else {
triggerMermaid()
}
cancelExplanationDone = Events.On('ai:explanation:done', (event: any) => {
const data = event.data
const key = `${data.source}_${data.id}`
@@ -1352,15 +1441,50 @@ async function toggleBookmark() {
const touchStartX = ref(0)
const touchStartY = ref(0)
const touchThreshold = 75 // min horizontal drag threshold in pixels
const isSwipeIgnored = ref(false)
function shouldIgnoreSwipe(target: EventTarget | null): boolean {
if (!target) return false
let el = target as HTMLElement | null
while (el) {
// 1. 클래스 기반 예외 영역 감지
if (el.classList && (
el.classList.contains('katex') ||
el.classList.contains('katex-display') ||
el.classList.contains('mermaid') ||
el.classList.contains('vue-pdf-embed') ||
el.classList.contains('overflow-x-auto') ||
el.classList.contains('no-swipe')
)) {
return true
}
// 2. 태그 기반 예외 영역 감지
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
if (tagName === 'table' || tagName === 'pre' || tagName === 'code' || tagName === 'img' || tagName === 'canvas' || tagName === 'svg') {
return true
}
el = el.parentElement
}
return false
}
function handleTouchStart(e: TouchEvent) {
if (e.touches.length > 0) {
const target = e.touches[0].target
if (shouldIgnoreSwipe(target)) {
isSwipeIgnored.value = true
return
}
isSwipeIgnored.value = false
touchStartX.value = e.touches[0].clientX
touchStartY.value = e.touches[0].clientY
}
}
function handleTouchEnd(e: TouchEvent) {
if (isSwipeIgnored.value) {
return
}
if (e.changedTouches.length > 0) {
const endX = e.changedTouches[0].clientX
const endY = e.changedTouches[0].clientY
@@ -1442,7 +1566,7 @@ function handleTouchEnd(e: TouchEvent) {
.markdown-content table {
width: 100% !important;
max-width: 100% !important;
display: block !important;
display: table !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
border-collapse: collapse !important;
@@ -1476,11 +1600,26 @@ function handleTouchEnd(e: TouchEvent) {
white-space: pre !important;
word-wrap: normal !important;
word-break: normal !important;
padding: 1rem !important;
border-radius: var(--rounded-box, 0.5rem) !important;
background-color: var(--fallback-b3, oklch(var(--b3)/0.3)) !important;
}
.markdown-content code {
/* pre 태그 내부가 아닌 순수 인라인 code 태그는 줄바꿈을 허용함 */
.markdown-content :not(pre) > code {
white-space: pre-wrap !important;
word-break: break-all !important;
background-color: var(--fallback-b3, oklch(var(--b3)/0.3)) !important;
padding: 0.2rem 0.4rem !important;
border-radius: 0.25rem !important;
}
/* pre 태그 내부의 code 블록은 줄바꿈을 차단하여 터미널 한 줄 형식 그대로 스크롤되도록 보존 */
.markdown-content pre > code {
white-space: pre !important;
word-break: normal !important;
word-wrap: normal !important;
display: block !important;
}
/* 수식(KaTeX) 영역이 화면을 벗어나지 않게 하고 가로 스크롤 적용 */
@@ -1498,4 +1637,21 @@ function handleTouchEnd(e: TouchEvent) {
overflow-y: hidden !important;
vertical-align: middle;
}
/* Mermaid 차트가 모바일 화면에서 잘리지 않도록 강제 방어 및 가로스크롤 적용 */
.markdown-content .mermaid {
max-width: 100% !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
margin: 1.5rem 0 !important;
padding: 0.5rem !important;
background-color: var(--fallback-b2, oklch(var(--b2)/0.3)) !important;
border-radius: var(--rounded-box, 0.5rem) !important;
}
.markdown-content .mermaid svg {
height: auto !important;
max-width: 100% !important;
min-width: 320px !important;
}
</style>
+125 -27
View File
@@ -49,6 +49,19 @@
</select>
</div>
<!-- Gemini API Toggle & Delay Option -->
<div class="space-y-3 pt-4 border-t border-base-content/5">
<label class="label cursor-pointer justify-between p-0">
<span class="text-xs font-bold opacity-80">Gemini API 직접 사용</span>
<input type="checkbox" class="toggle toggle-primary toggle-sm" v-model="useGeminiAPI" />
</label>
<div class="form-control w-full">
<label class="label font-bold text-[10px] opacity-70 p-0 mb-1">추출 간격 ()</label>
<input type="number" v-model.number="delaySeconds" min="1" max="120" class="input input-bordered input-sm w-full bg-base-100" />
<span class="text-[9px] opacity-40 mt-1 leading-normal">요청 제한(Rate Limit) DB 락을 예방하기 위해 딜레이가 적용됩니다.</span>
</div>
</div>
<!-- Progress and Operations -->
<div v-if="selectedCategory" class="space-y-4 pt-4 border-t border-base-content/5">
<div class="flex justify-between items-center text-xs gap-2 whitespace-nowrap">
@@ -114,11 +127,12 @@
class="card bg-base-200 border border-base-content/5 hover:border-primary/30 rounded-2xl p-5 cursor-pointer hover:shadow-md transition-all active:scale-[0.99] flex flex-row items-center justify-between gap-4"
>
<div class="space-y-1 min-w-0 flex-1">
<div class="flex items-center gap-2 whitespace-nowrap">
<div class="flex items-center gap-2 whitespace-nowrap flex-wrap">
<span class="badge badge-primary badge-sm font-bold shrink-0">문제 #{{ se.questionId }}</span>
<span class="text-[10px] opacity-40 uppercase tracking-widest font-black truncate max-w-[120px]">{{ se.source }}</span>
</div>
<h4 class="text-sm font-bold text-base-content truncate pr-4">{{ se.questionText }}</h4>
<h4 class="text-base font-black text-primary truncate pr-4">{{ se.keyword || '개념 학습' }}</h4>
<p class="text-xs opacity-60 truncate pr-4">{{ se.questionText }}</p>
</div>
<ChevronRightIcon :size="20" class="opacity-40 shrink-0" />
</div>
@@ -140,17 +154,40 @@
</div>
<div class="space-y-6">
<div class="form-control w-full">
<label class="label font-bold text-xs opacity-70">생성할 문제 개수</label>
<div class="flex items-center gap-4">
<input
v-model.number="examCount"
type="range"
min="1"
max="15"
class="range range-primary flex-1"
/>
<span class="badge badge-primary font-black text-sm px-3 py-3">{{ examCount }}</span>
<div class="flex items-center justify-between bg-base-300/30 p-4 rounded-2xl border border-base-content/5">
<span class="text-sm font-bold opacity-80">Gemini API 직접 사용</span>
<input type="checkbox" class="toggle toggle-primary" v-model="useGeminiAPI" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="space-y-2">
<label class="label font-bold text-xs opacity-70 p-0">생성할 문제 개수</label>
<div class="flex items-center gap-4">
<input
v-model.number="examCount"
type="range"
min="1"
max="15"
class="range range-primary flex-1"
/>
<span class="badge badge-primary font-black text-sm px-3 py-3">{{ examCount }}</span>
</div>
</div>
<div class="form-control w-full">
<label class="label font-bold text-xs opacity-70 p-0 mb-1">출제 유형</label>
<select v-model="examType" class="select select-bordered select-sm w-full bg-base-100 font-bold">
<option value="혼합형">혼합형 (단답/서술 섞음)</option>
<option value="단답형">단답형 (괄호 채우기)</option>
<option value="서술형">서술형 (시나리오 대응)</option>
</select>
<span class="text-[9px] opacity-40 mt-1 leading-normal">주관식 서술형 선택 퀴즈창에서 OCR 채점 기능이 열립니다.</span>
</div>
<div class="form-control w-full">
<label class="label font-bold text-xs opacity-70 p-0 mb-1">생성 간격 ()</label>
<input type="number" v-model.number="examDelaySeconds" min="1" max="120" class="input input-bordered input-sm w-full bg-base-100 font-bold" />
<span class="text-[9px] opacity-40 mt-1 leading-normal">제한 방지를 위해 문제 생성 사이에 지연이 적용됩니다.</span>
</div>
</div>
@@ -218,7 +255,7 @@
</div>
<!-- Extracted Concepts Markdown viewer -->
<div class="flex-1 overflow-y-auto prose max-w-none text-sm leading-relaxed pr-2" v-html="renderedContent"></div>
<div class="flex-1 overflow-y-auto prose max-w-none text-[13px] leading-relaxed pr-2 text-left" v-html="renderedContent"></div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="closeModal">close</button>
@@ -246,6 +283,7 @@ interface StudyElement {
source: string
questionText: string
content: string
keyword: string
category: string
}
@@ -285,6 +323,12 @@ const isGeneratingExams = ref(false)
const examStatus = ref('')
const examProgress = ref(0)
const showQuizRedirect = ref(false)
const examType = ref('혼합형')
// Config Settings
const useGeminiAPI = ref(false)
const delaySeconds = ref(10)
const examDelaySeconds = ref(2)
// Custom Markdown rendering with KaTeX and Mermaid setup
const renderer = new marked.Renderer()
@@ -294,7 +338,22 @@ renderer.code = function({ text, lang }) {
}
return `<pre><code class="language-${lang}">${text}</code></pre>`
}
marked.setOptions({ renderer })
// Table wrapper to prevent collapsing and support horizontal scrolling
const originalTable = renderer.table.bind(renderer)
renderer.table = function(token) {
const rawTable = originalTable(token)
return `<div class="overflow-x-auto my-4 border border-base-content/15 rounded-xl bg-base-300/10 shadow-inner">${rawTable}</div>`
}
marked.setOptions({ renderer, gfm: true, breaks: true })
// Backup registration for marked v4/v5/v9+ newer API
marked.use({
gfm: true,
breaks: true,
renderer,
})
function renderMarkdown(text: string): string {
if (!text) return ''
@@ -333,6 +392,7 @@ const filteredElements = computed(() => {
return studyElements.value.filter(se =>
se.questionId.toString().includes(q) ||
se.questionText.toLowerCase().includes(q) ||
se.keyword.toLowerCase().includes(q) ||
se.content.toLowerCase().includes(q)
)
})
@@ -433,7 +493,7 @@ async function startExtraction() {
extractionProgress.value = 0
extractionStatus.value = '추출 시작 중...'
try {
await ExtractStudyElementsForCategory(selectedCategory.value)
await ExtractStudyElementsForCategory(selectedCategory.value, useGeminiAPI.value, delaySeconds.value)
} catch (e) {
console.error(e)
isExtracting.value = false
@@ -448,7 +508,7 @@ async function startGeneratingExams() {
examStatus.value = '기존 기출문제 데이터 수집 중...'
showQuizRedirect.value = false
try {
await GenerateGoPracticalExams(examCount.value)
await GenerateGoPracticalExams(examCount.value, useGeminiAPI.value, examDelaySeconds.value, examType.value)
} catch (e) {
console.error(e)
isGeneratingExams.value = false
@@ -463,8 +523,15 @@ function openModal(se: StudyElement) {
// @ts-ignore
if (window.mermaid) {
setTimeout(() => {
// @ts-ignore
window.mermaid.run()
try {
// @ts-ignore
window.mermaid.run({
querySelector: '.mermaid',
suppressErrors: true
})
} catch (e) {
console.error('Mermaid render error:', e)
}
}, 100)
}
})
@@ -644,9 +711,8 @@ function goToQuiz() {
}
/* KaTeX and math layout overrides */
:deep(.math-display) {
width: 100%;
display: flex;
justify-content: center;
justify-content: flex-start;
margin: 1.5rem 0;
overflow-x: auto;
}
@@ -656,14 +722,17 @@ function goToQuiz() {
/* Markdown Table and Typography Styling */
:deep(.prose) {
color: var(--fallback-bc,oklch(var(--bc)/1));
font-size: 13px !important;
text-align: left !important;
line-height: 1.6;
}
:deep(.prose h1, .prose h2, .prose h3) {
font-weight: 800;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
:deep(.prose h2) { font-size: 1.25rem; color: oklch(var(--p)); }
:deep(.prose h3) { font-size: 1.1rem; }
:deep(.prose h2) { font-size: 1.2rem; color: oklch(var(--p)); }
:deep(.prose h3) { font-size: 1.05rem; }
:deep(.prose p) { margin-bottom: 1rem; }
:deep(.prose table) {
width: 100%;
@@ -701,10 +770,39 @@ function goToQuiz() {
}
:deep(.mermaid) {
background-color: #1a1f2c;
padding: 1.5rem;
padding: 1.25rem;
border-radius: 0.75rem;
margin: 1.5rem 0;
display: flex;
justify-content: center;
margin: 1.25rem 0;
overflow-x: auto;
max-width: 100%;
display: block;
text-align: left;
color: #e2e8f0;
}
:deep(.mermaid svg) {
display: inline-block;
min-width: 380px;
}
/* SVG interior elements style preservation */
:deep(.mermaid text) {
fill: #e2e8f0 !important;
font-size: 12px !important;
}
:deep(.mermaid .node rect),
:deep(.mermaid .node circle),
:deep(.mermaid .node polygon),
:deep(.mermaid .node path) {
fill: #2d3748 !important;
stroke: #4a5568 !important;
}
:deep(.mermaid .edgePath .path) {
stroke: #a0aec0 !important;
}
:deep(.mermaid .edgeLabel text) {
fill: #e2e8f0 !important;
}
:deep(.mermaid .marker) {
fill: #a0aec0 !important;
stroke: #a0aec0 !important;
}
</style>
+1 -1
View File
@@ -8,6 +8,6 @@ export default {
},
plugins: [require("daisyui")],
daisyui: {
themes: ["dark"],
themes: true,
},
}
+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),
})