gen
This commit is contained in:
+26
-54
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"] = "";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,6 @@ export default {
|
||||
},
|
||||
plugins: [require("daisyui")],
|
||||
daisyui: {
|
||||
themes: ["dark"],
|
||||
themes: true,
|
||||
},
|
||||
}
|
||||
+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