Compare commits
10 Commits
104107bf1d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e8dfcafa6 | |||
| 99c2c2f755 | |||
| b0ae5dd740 | |||
| 2dac119791 | |||
| 2b2070d11b | |||
| 3a6bc8d4cb | |||
| 60c82bd188 | |||
| a9bb83b4e5 | |||
| 073d1dd383 | |||
| c3596c12f3 |
@@ -0,0 +1,83 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StudyElement represents the extracted study content from a question.
|
||||
type StudyElement struct {
|
||||
QuestionID int `json:"questionId"`
|
||||
Source string `json:"source"`
|
||||
Category string `json:"category"`
|
||||
Content string `json:"content"` // Markdown content containing formulas, charts, etc.
|
||||
}
|
||||
|
||||
// GeneratedQuestion matches the structure to be saved in QuizQuestions DB.
|
||||
type GeneratedQuestion struct {
|
||||
QuestionText string `json:"questionText"`
|
||||
Choice1 string `json:"choice1"`
|
||||
Choice2 string `json:"choice2"`
|
||||
Choice3 string `json:"choice3"`
|
||||
Choice4 string `json:"choice4"`
|
||||
CorrectAnswer int `json:"correctAnswer"`
|
||||
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.
|
||||
func CallAgy(ctx context.Context, prompt string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "agy", "--dangerously-skip-permissions", "--print", prompt)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("agy error: %v, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// ExtractStudyElement requests the AI to generate a comprehensive study sheet/note for a specific quiz question.
|
||||
func ExtractStudyElement(ctx context.Context, questionText, choice1, choice2, choice3, choice4, correctAnswer, explanation string) (string, error) {
|
||||
prompt := fmt.Sprintf("아래 기출문제의 문제, 보기, 정답, 해설을 철저히 분석하여 핵심 개념을 담은 \"학습요소(개념 정리)\"를 작성해 주세요.\n"+
|
||||
"이 학습요소는 사용자가 이 문제를 완벽하게 이해하고 관련된 이론적 배경까지 학습할 수 있도록 돕는 학습 노트입니다.\n\n"+
|
||||
"[기출문제]\n"+
|
||||
"문제: %s\n"+
|
||||
"보기 1: %s\n"+
|
||||
"보기 2: %s\n"+
|
||||
"보기 3: %s\n"+
|
||||
"보기 4: %s\n"+
|
||||
"정답: %s\n"+
|
||||
"해설: %s\n\n"+
|
||||
"[작성 지침]\n"+
|
||||
"1. **매우 중요**: 문서의 가장 첫 번째 줄은 반드시 핵심 학습개념/키워드를 설명하는 제목으로 시작해 주세요. (예: `# 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 prompt, nil
|
||||
}
|
||||
|
||||
// GenerateDescriptiveQuestion generates a new descriptive/practical exam question based on given reference questions.
|
||||
func GenerateDescriptiveQuestion(ctx context.Context, referenceExams []string) (*GeneratedQuestion, error) {
|
||||
// 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")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
@@ -10,6 +10,7 @@ export {
|
||||
CategoryInfo,
|
||||
DescriptiveGradingRecord,
|
||||
Question,
|
||||
StudyElementDB,
|
||||
TargetInfo,
|
||||
User
|
||||
} from "./models.js";
|
||||
|
||||
@@ -180,6 +180,47 @@ export class Question {
|
||||
}
|
||||
}
|
||||
|
||||
export class StudyElementDB {
|
||||
"questionId": number;
|
||||
"source": string;
|
||||
"questionText": string;
|
||||
"content": string;
|
||||
"keyword": string;
|
||||
"category": string;
|
||||
|
||||
/** Creates a new StudyElementDB instance. */
|
||||
constructor($$source: Partial<StudyElementDB> = {}) {
|
||||
if (!("questionId" in $$source)) {
|
||||
this["questionId"] = 0;
|
||||
}
|
||||
if (!("source" in $$source)) {
|
||||
this["source"] = "";
|
||||
}
|
||||
if (!("questionText" in $$source)) {
|
||||
this["questionText"] = "";
|
||||
}
|
||||
if (!("content" in $$source)) {
|
||||
this["content"] = "";
|
||||
}
|
||||
if (!("keyword" in $$source)) {
|
||||
this["keyword"] = "";
|
||||
}
|
||||
if (!("category" in $$source)) {
|
||||
this["category"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new StudyElementDB instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): StudyElementDB {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new StudyElementDB($$parsedSource as Partial<StudyElementDB>);
|
||||
}
|
||||
}
|
||||
|
||||
export class TargetInfo {
|
||||
"name": string;
|
||||
"count": number;
|
||||
|
||||
@@ -59,6 +59,21 @@ export function DeleteUser(username: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(4061081732, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractStudyElementsForCategory extracts study elements for all questions in the category.
|
||||
* It skips questions that already have an extracted study element.
|
||||
*/
|
||||
export function ExtractStudyElementsForCategory(category: string, 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, useGeminiAPI: boolean, delaySeconds: number, examType: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(4059537052, count, useGeminiAPI, delaySeconds, examType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetBookmarkedCategories retrieves categories that have bookmarked questions for a user
|
||||
*/
|
||||
@@ -138,6 +153,15 @@ export function GetQuestions(username: string, category: string, difficulty: str
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetStudyElements retrieves all extracted study elements for a category.
|
||||
*/
|
||||
export function GetStudyElements(category: string): $CancellablePromise<$models.StudyElementDB[]> {
|
||||
return $Call.ByID(1892893004, category).then(($result: any) => {
|
||||
return $$createType12($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function GetSubjects(category: string, difficulty: string): $CancellablePromise<string[]> {
|
||||
return $Call.ByID(1060436249, category, difficulty).then(($result: any) => {
|
||||
return $$createType8($result);
|
||||
@@ -170,7 +194,7 @@ export function GetUsers(): $CancellablePromise<$models.User[]> {
|
||||
|
||||
export function GradeDescriptiveAnswer(username: string, source: string, questionID: number, userAnswer: string, ocrImageBase64: string): $CancellablePromise<$models.DescriptiveGradingRecord | null> {
|
||||
return $Call.ByID(642001377, username, source, questionID, userAnswer, ocrImageBase64).then(($result: any) => {
|
||||
return $$createType11($result);
|
||||
return $$createType13($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,7 +203,7 @@ export function GradeDescriptiveAnswer(username: string, source: string, questio
|
||||
*/
|
||||
export function Login(username: string, password: string): $CancellablePromise<$models.User | null> {
|
||||
return $Call.ByID(3841810241, username, password).then(($result: any) => {
|
||||
return $$createType12($result);
|
||||
return $$createType14($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,5 +287,7 @@ const $$createType7 = $Create.Array($$createType6);
|
||||
const $$createType8 = $Create.Array($Create.Any);
|
||||
const $$createType9 = $models.User.createFrom;
|
||||
const $$createType10 = $Create.Array($$createType9);
|
||||
const $$createType11 = $Create.Nullable($$createType6);
|
||||
const $$createType12 = $Create.Nullable($$createType9);
|
||||
const $$createType11 = $models.StudyElementDB.createFrom;
|
||||
const $$createType12 = $Create.Array($$createType11);
|
||||
const $$createType13 = $Create.Nullable($$createType6);
|
||||
const $$createType14 = $Create.Nullable($$createType9);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.17.0/dist/katex.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<title>Master CBT</title>
|
||||
</head>
|
||||
|
||||
+48
-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">
|
||||
@@ -59,6 +59,11 @@
|
||||
<BookmarkIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
책갈피
|
||||
</router-link>
|
||||
|
||||
<router-link to="/study" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
<GraduationCapIcon :size="20" class="group-hover:scale-110 transition-transform" />
|
||||
학습요소 & 실기 생성
|
||||
</router-link>
|
||||
|
||||
<!-- Gemini Menu Link (Admin Only) -->
|
||||
<router-link v-if="authStore.isAdmin" to="/settings" class="flex items-center gap-4 px-5 py-4 rounded-2xl hover:bg-base-300 transition-all font-bold group" active-class="bg-primary text-primary-content shadow-lg shadow-primary/20" @click="closeDrawer">
|
||||
@@ -116,6 +121,23 @@
|
||||
<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>
|
||||
<div class="form-control w-full">
|
||||
<select v-model="currentTheme" class="select select-bordered select-sm w-full bg-base-100 font-bold text-xs">
|
||||
<option v-for="t in themes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="randomizeTheme" class="btn btn-outline btn-sm btn-secondary w-full text-xs font-bold gap-2">
|
||||
<ShuffleIcon :size="14" />
|
||||
랜덤 테마
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -239,13 +261,36 @@
|
||||
</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 } 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)
|
||||
localStorage.setItem('app-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()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import 'daisyui/themes.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ const router = createRouter({
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/study',
|
||||
name: 'study',
|
||||
component: () => import('../views/StudyView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
|
||||
@@ -7,8 +7,6 @@ html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #1b2636;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-6xl mx-auto space-y-8 select-none" data-theme="dark">
|
||||
<div class="p-8 max-w-6xl mx-auto space-y-8 select-none">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between pb-6 border-b border-base-content/10">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
+312
-37
@@ -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
|
||||
@@ -193,7 +205,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Descriptive / Essay Question -->
|
||||
<div v-else-if="currentQuestion.correctAnswerStr === '주관식'" class="space-y-4 w-full">
|
||||
<div v-else-if="isDescriptiveQuestion" class="space-y-4 w-full">
|
||||
<div class="form-control w-full bg-base-100 rounded-2xl p-4 border border-base-300 shadow-inner">
|
||||
<label class="w-full label pt-0 pb-2 border-b border-base-200">
|
||||
<span class="w-full label-text font-bold flex items-center gap-1.5 opacity-80">
|
||||
@@ -280,15 +292,35 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!showFeedback" class="flex flex-col sm:flex-row justify-center items-center gap-3 w-full">
|
||||
<button
|
||||
@click="submitDescriptiveGrading"
|
||||
:disabled="!userDescriptiveAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-primary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md gap-2 text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
<span v-if="gradingDescriptive" class="loading loading-spinner loading-sm"></span>
|
||||
<SparklesIcon v-else :size="18" />
|
||||
{{ gradingDescriptive ? 'AI 채점 진행중...' : 'AI 채점 요청하기' }}
|
||||
</button>
|
||||
<template v-if="hasStoredCorrectAnswer">
|
||||
<button
|
||||
@click="submitDescriptiveNormalGrading"
|
||||
:disabled="!userDescriptiveAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-primary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
일반 채점
|
||||
</button>
|
||||
<button
|
||||
@click="submitDescriptiveGrading"
|
||||
:disabled="!userDescriptiveAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-secondary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md gap-2 text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
<span v-if="gradingDescriptive" class="loading loading-spinner loading-sm"></span>
|
||||
<SparklesIcon v-else :size="16" />
|
||||
{{ gradingDescriptive ? 'AI 채점중...' : 'AI 채점' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
@click="submitDescriptiveGrading"
|
||||
:disabled="!userDescriptiveAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-primary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md gap-2 text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
<span v-if="gradingDescriptive" class="loading loading-spinner loading-sm"></span>
|
||||
<SparklesIcon v-else :size="18" />
|
||||
{{ gradingDescriptive ? 'AI 채점 진행중...' : 'AI 채점 요청하기' }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
@click="skipToExplanations"
|
||||
:disabled="gradingDescriptive"
|
||||
@@ -318,16 +350,35 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner for AI Grading -->
|
||||
<div v-if="gradingError" class="alert alert-error shadow-sm text-xs sm:text-sm">
|
||||
<XCircleIcon :size="20" class="shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-bold text-error-content">AI 채점 실패</h4>
|
||||
<p class="font-mono text-[10px] mt-1 opacity-90 whitespace-pre-wrap break-all">{{ gradingError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!showFeedback" class="flex flex-col sm:flex-row justify-center items-center gap-3 w-full">
|
||||
<button
|
||||
@click="submitTextAnswer"
|
||||
:disabled="!userTextAnswer.trim()"
|
||||
:disabled="!userTextAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-primary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
제출하기
|
||||
채점 (텍스트 일치)
|
||||
</button>
|
||||
<button
|
||||
@click="submitTextAnswerWithAi"
|
||||
:disabled="!userTextAnswer.trim() || gradingDescriptive"
|
||||
class="btn btn-secondary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md gap-2 text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
<span v-if="gradingDescriptive" class="loading loading-spinner loading-sm"></span>
|
||||
<SparklesIcon v-else :size="16" />
|
||||
{{ gradingDescriptive ? 'AI 채점중...' : 'AI 채점' }}
|
||||
</button>
|
||||
<button
|
||||
@click="skipToExplanations"
|
||||
:disabled="gradingDescriptive"
|
||||
class="btn btn-outline btn-neutral btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-sm text-sm sm:text-base whitespace-normal break-keep"
|
||||
>
|
||||
정답/해설 바로보기
|
||||
@@ -338,7 +389,7 @@
|
||||
|
||||
<!-- Feedback & Explanations -->
|
||||
<div v-if="showFeedback" class="bg-base-300/80 border-t border-base-300 p-5 sm:p-6 md:p-8">
|
||||
<div v-if="currentQuestion.correctAnswerStr !== '주관식'" class="alert shadow-lg mb-6" :class="isCorrect ? 'alert-success' : 'alert-error'">
|
||||
<div v-if="!isDescriptiveQuestion || usedDescriptiveNormalGrading" class="alert shadow-lg mb-6" :class="isCorrect ? 'alert-success' : 'alert-error'">
|
||||
<CheckCircleIcon v-if="isCorrect" :size="24" />
|
||||
<XCircleIcon v-else :size="24" />
|
||||
<div>
|
||||
@@ -356,6 +407,9 @@
|
||||
<div>
|
||||
<h4 class="font-bold text-base sm:text-lg">주관식/논술형 문제</h4>
|
||||
<p class="text-xs sm:text-sm">아래 모범답안 및 AI 상세 해설을 참고하여 학습을 진행하세요.</p>
|
||||
<div v-if="currentQuestion.correctAnswerStr && currentQuestion.correctAnswerStr.trim() !== '주관식'" class="text-xs sm:text-sm mt-1.5 pt-1.5 border-t border-info-content/10">
|
||||
정답: <span class="font-bold underline" v-html="formatCorrectAnswer(currentQuestion.correctAnswerStr)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,7 +471,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 +511,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 +585,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,10 +650,16 @@ 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[]>([])
|
||||
const gradingError = ref('')
|
||||
const usedDescriptiveNormalGrading = ref(false)
|
||||
|
||||
// OCR state
|
||||
const ocrFileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -725,7 +811,7 @@ const gradingDescriptive = computed({
|
||||
|
||||
async function loadGradingHistory() {
|
||||
const q = currentQuestion.value
|
||||
if (!q || q.correctAnswerStr !== '주관식') {
|
||||
if (!q || !isDescriptiveQuestion.value) {
|
||||
descriptiveHistory.value = []
|
||||
return
|
||||
}
|
||||
@@ -737,6 +823,31 @@ async function loadGradingHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
function submitTextAnswerWithAi() {
|
||||
userDescriptiveAnswer.value = userTextAnswer.value
|
||||
submitDescriptiveGrading()
|
||||
}
|
||||
|
||||
function submitDescriptiveNormalGrading() {
|
||||
if (showFeedback.value || !userDescriptiveAnswer.value.trim()) return
|
||||
|
||||
const userClean = userDescriptiveAnswer.value.trim().replace(/\s+/g, '').toLowerCase()
|
||||
const correctAnswers = (currentQuestion.value.correctAnswerStr || '')
|
||||
.split(';;')
|
||||
.map(ans => ans.trim().replace(/\s+/g, '').toLowerCase())
|
||||
|
||||
isCorrect.value = correctAnswers.includes(userClean)
|
||||
|
||||
const key = `${currentQuestion.value.source}_${currentQuestion.value.id}`
|
||||
if (isCorrect.value && !scoredQuestions.value[key]) {
|
||||
scoredQuestions.value[key] = true
|
||||
score.value++
|
||||
}
|
||||
|
||||
usedDescriptiveNormalGrading.value = true
|
||||
showFeedback.value = true
|
||||
}
|
||||
|
||||
async function submitDescriptiveGrading() {
|
||||
const q = currentQuestion.value
|
||||
if (!q || !userDescriptiveAnswer.value.trim() || gradingDescriptive.value) return
|
||||
@@ -819,9 +930,39 @@ function applyHighlighting() {
|
||||
})
|
||||
}
|
||||
|
||||
// 문제 전환 시 구문 강조 다시 적용
|
||||
watch(currentIndex, () => {
|
||||
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(currentQuestion, (newVal) => {
|
||||
if (newVal) {
|
||||
applyHighlighting()
|
||||
triggerMermaid()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 피드백/해설이 노출되거나 AI 해설이 업데이트될 때도 mermaid 렌더링
|
||||
watch(showFeedback, (newVal) => {
|
||||
if (newVal) {
|
||||
triggerMermaid()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => currentQuestion.value?.geminiExplanation, (newVal) => {
|
||||
if (newVal) {
|
||||
triggerMermaid()
|
||||
}
|
||||
})
|
||||
|
||||
const renderedPassage = computed(() => {
|
||||
@@ -889,6 +1030,28 @@ const isOXQuestion = computed(() => {
|
||||
return ans === 'O' || ans === 'X'
|
||||
})
|
||||
|
||||
// Check if the question is descriptive
|
||||
const isDescriptiveQuestion = computed(() => {
|
||||
const q = currentQuestion.value
|
||||
if (!q) return false
|
||||
|
||||
// 객관식 보기가 있는 경우 제외
|
||||
if (choices.value.length > 0) return false
|
||||
|
||||
// OX 문항인 경우 제외
|
||||
if (isOXQuestion.value) return false
|
||||
|
||||
const ans = q.correctAnswerStr?.trim() || ""
|
||||
|
||||
// 정답이 '주관식'이거나 10자 이상 긴 문장이거나 공백을 포함하는 경우 주관식 서술형 취급
|
||||
return ans === '주관식' || ans.length > 8 || ans.includes(' ') || ans.includes('및') || ans.includes('와')
|
||||
})
|
||||
|
||||
const hasStoredCorrectAnswer = computed(() => {
|
||||
const ans = currentQuestion.value?.correctAnswerStr?.trim()
|
||||
return !!ans && ans !== '주관식'
|
||||
})
|
||||
|
||||
const isPdf = computed(() => {
|
||||
const url = currentQuestion.value.imageUrl
|
||||
if (!url) return false
|
||||
@@ -914,11 +1077,20 @@ function decodeHTMLEntities(text: string): string {
|
||||
function renderMarkdownWithMath(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
// 0. Extract and protect Mermaid blocks
|
||||
const mermaidBlocks: string[] = []
|
||||
let workingText = text
|
||||
workingText = workingText.replace(/```mermaid\r?\\n([\s\S]*?)\r?\\n```/g, (match, code) => {
|
||||
const placeholder = `MERMAIDBLOCKPLACEHOLDER${mermaidBlocks.length}`
|
||||
mermaidBlocks.push(code.trim())
|
||||
return placeholder
|
||||
})
|
||||
|
||||
const mathBlocks: { placeholder: string; isBlock: boolean; rawExpr: string; renderedHtml: string }[] = []
|
||||
let placeholderCount = 0
|
||||
|
||||
// Normalize slash/backslash ctdot representations to standard cdots
|
||||
let workingText = text
|
||||
workingText = workingText
|
||||
.replace(/\\ctdot/g, '\\cdots')
|
||||
.replace(/\/ctdot/g, '\\cdots')
|
||||
|
||||
@@ -977,6 +1149,13 @@ function renderMarkdownWithMath(text: string): string {
|
||||
html = html.replace(block.placeholder, block.renderedHtml)
|
||||
}
|
||||
|
||||
// 5. Restore protected Mermaid blocks
|
||||
mermaidBlocks.forEach((code, idx) => {
|
||||
const placeholder = `MERMAIDBLOCKPLACEHOLDER${idx}`
|
||||
const replacement = `<div class="mermaid">${code}</div>`
|
||||
html = html.replace(new RegExp(`<p>\\s*${placeholder}\\s*</p>|${placeholder}`, 'g'), replacement)
|
||||
})
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -1021,6 +1200,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}`
|
||||
@@ -1252,6 +1449,7 @@ interface QuestionState {
|
||||
isCorrect: boolean
|
||||
userDescriptiveAnswer: string
|
||||
descriptiveGradingResult: DescriptiveGradingRecord | null
|
||||
usedDescriptiveNormalGrading: boolean
|
||||
}
|
||||
const questionStates = ref<Record<number, QuestionState>>({})
|
||||
|
||||
@@ -1263,7 +1461,8 @@ function saveCurrentState() {
|
||||
showFeedback: showFeedback.value,
|
||||
isCorrect: isCorrect.value,
|
||||
userDescriptiveAnswer: userDescriptiveAnswer.value,
|
||||
descriptiveGradingResult: descriptiveGradingResult.value
|
||||
descriptiveGradingResult: descriptiveGradingResult.value,
|
||||
usedDescriptiveNormalGrading: usedDescriptiveNormalGrading.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1478,7 @@ function loadState(index: number) {
|
||||
isCorrect.value = state.isCorrect
|
||||
userDescriptiveAnswer.value = state.userDescriptiveAnswer || ''
|
||||
descriptiveGradingResult.value = state.descriptiveGradingResult || null
|
||||
usedDescriptiveNormalGrading.value = state.usedDescriptiveNormalGrading || false
|
||||
} else {
|
||||
selectedChoiceIndex.value = -1
|
||||
userTextAnswer.value = ''
|
||||
@@ -1289,6 +1489,7 @@ function loadState(index: number) {
|
||||
descriptiveGradingResult.value = null
|
||||
uploadedOcrImageBase64.value = ''
|
||||
ocrResultText.value = null
|
||||
usedDescriptiveNormalGrading.value = false
|
||||
}
|
||||
loadGradingHistory()
|
||||
}
|
||||
@@ -1326,6 +1527,7 @@ function resetQuiz() {
|
||||
userTextAnswer.value = ''
|
||||
userOXAnswer.value = ''
|
||||
questionStates.value = {}
|
||||
scoredQuestions.value = {}
|
||||
}
|
||||
|
||||
const togglingBookmark = ref(false)
|
||||
@@ -1352,15 +1554,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
|
||||
@@ -1439,24 +1676,30 @@ function handleTouchEnd(e: TouchEvent) {
|
||||
}
|
||||
|
||||
/* 마크다운 표가 화면을 벗어나지 않게 하고 반응형 가로스크롤 및 폰트 축소 적용 */
|
||||
.markdown-content table {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
display: block !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
.markdown-content table,
|
||||
.prose table {
|
||||
min-width: 100% !important;
|
||||
width: max-content !important;
|
||||
max-width: none !important;
|
||||
display: table !important;
|
||||
border-collapse: collapse !important;
|
||||
margin-top: 1rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
font-size: 0.85em !important;
|
||||
}
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
.markdown-content td,
|
||||
.prose th,
|
||||
.prose td {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
border: 1px solid var(--fallback-bc, oklch(var(--bc)/0.15)) !important;
|
||||
white-space: nowrap; /* 셀 내용 잘림 방지하고 깔끔한 스크롤 보장 */
|
||||
white-space: normal !important;
|
||||
word-break: keep-all !important;
|
||||
word-wrap: break-word !important;
|
||||
min-width: 120px !important;
|
||||
}
|
||||
.markdown-content th {
|
||||
.markdown-content th,
|
||||
.prose th {
|
||||
background-color: var(--fallback-b3, oklch(var(--b3)/0.5)) !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
@@ -1476,11 +1719,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 +1756,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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto space-y-8" data-theme="dark">
|
||||
<div class="p-8 max-w-4xl mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between pb-6 border-b border-base-content/10">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@ export default {
|
||||
},
|
||||
plugins: [require("daisyui")],
|
||||
daisyui: {
|
||||
themes: ["dark"],
|
||||
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"
|
||||
],
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+475
-4
@@ -12,8 +12,11 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"changeme/antigravity"
|
||||
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"google.golang.org/api/option"
|
||||
@@ -82,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) {
|
||||
@@ -121,6 +125,39 @@ func NewQuizService() *QuizService {
|
||||
log.Fatalf("Failed to create QuizExplain table: %v", err)
|
||||
}
|
||||
|
||||
// Create StudyElements table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS StudyElements (
|
||||
question_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
content TEXT,
|
||||
keyword TEXT,
|
||||
PRIMARY KEY (source, question_id),
|
||||
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create StudyElements table: %v", err)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@@ -289,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
|
||||
@@ -419,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,
|
||||
@@ -537,7 +654,7 @@ func (s *QuizService) GetBookmarkedTargets(username, category string) []TargetIn
|
||||
func (s *QuizService) GetBookmarkedQuestions(username, category, difficulty 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, COALESCE(q.difficulty, '') as difficulty, COALESCE(q.passage, '') as passage, COALESCE(q.image_url, '') as image_url,
|
||||
COALESCE(e.explain, '') as geminiExplanation,
|
||||
1 as isBookmarked,
|
||||
COALESCE(q.subject, '') as subject,
|
||||
@@ -1377,3 +1494,357 @@ func (s *QuizService) UpdateUserInfoForce(username, nickname, phone, password st
|
||||
}
|
||||
}
|
||||
|
||||
type StudyElementDB struct {
|
||||
QuestionID int `json:"questionId"`
|
||||
Source string `json:"source"`
|
||||
QuestionText string `json:"questionText"`
|
||||
Content string `json:"content"`
|
||||
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, 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 = ?
|
||||
ORDER BY se.question_id ASC
|
||||
`
|
||||
rows, err := s.db.Query(query, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []StudyElementDB
|
||||
for rows.Next() {
|
||||
var se StudyElementDB
|
||||
err := rows.Scan(&se.QuestionID, &se.Source, &se.Content, &se.Keyword, &se.QuestionText, &se.Category)
|
||||
if err == nil {
|
||||
list = append(list, se)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ExtractStudyElementsForCategory extracts study elements for all questions in the category.
|
||||
// It skips questions that already have an extracted study element.
|
||||
func (s *QuizService) ExtractStudyElementsForCategory(category string, 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 OR se.content IS NULL OR trim(se.content) = '')
|
||||
`
|
||||
rows, err := s.db.Query(query, category)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Task struct {
|
||||
ID int
|
||||
Source string
|
||||
QuestionText string
|
||||
Choice1 string
|
||||
Choice2 string
|
||||
Choice3 string
|
||||
Choice4 string
|
||||
CorrectAnswerStr string
|
||||
Explanation string
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
for rows.Next() {
|
||||
var t Task
|
||||
err := rows.Scan(&t.ID, &t.Source, &t.QuestionText, &t.Choice1, &t.Choice2, &t.Choice3, &t.Choice4, &t.CorrectAnswerStr, &t.Explanation)
|
||||
if err == nil {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return "이미 모든 문제의 학습요소가 추출되어 있습니다.", nil
|
||||
}
|
||||
|
||||
// 2. Start a background sequential goroutine to process with configurable delay.
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
total := len(tasks)
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
// 1) Build prompt
|
||||
prompt, _ := 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
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{}{
|
||||
"category": category,
|
||||
"message": "학습요소 추출이 완료되었습니다.",
|
||||
})
|
||||
}()
|
||||
|
||||
return "학습요소 추출을 시작합니다.", nil
|
||||
}
|
||||
|
||||
// GenerateGoPracticalExams creates new exam questions based on existing ones.
|
||||
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(`
|
||||
SELECT question_text, explanation
|
||||
FROM QuizQuestions
|
||||
WHERE category = '정보보안기사 실기' AND explanation != ''
|
||||
LIMIT 10
|
||||
`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var references []string
|
||||
for rows.Next() {
|
||||
var qText, explanation string
|
||||
if err := rows.Scan(&qText, &explanation); err == nil {
|
||||
references = append(references, fmt.Sprintf("질문:\n%s\n\n모범답안/해설:\n%s", qText, explanation))
|
||||
}
|
||||
}
|
||||
|
||||
if len(references) == 0 {
|
||||
references = []string{
|
||||
"질문:\n다음 지문을 읽고 빈칸 A, B에 들어갈 적절한 용어를 쓰시오.\n- ( A )은 네트워크 상의 패킷을 가로채어 분석하는 행위 또는 도구를 말한다.\n- ( B )은 IP 주소를 스푸핑하여 정상적인 호스트인 것처럼 위장하는 공격이다.\n\n모범답안/해설:\nA: 스니핑(Sniffing)\nB: IP 스푸핑(IP Spoofing)",
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Start a background goroutine to generate questions
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
category := "정보보호기사 실기(Go)"
|
||||
source := "qple"
|
||||
difficulty := "실기(Go)"
|
||||
subject := "만든 자료"
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
s.emitEvent("exam:progress", map[string]interface{}{
|
||||
"current": i + 1,
|
||||
"total": count,
|
||||
"message": fmt.Sprintf("%d번째 실기 문제 생성 중...", i+1),
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 parse JSON from AI for exam question %d: %v, raw output: %s", i+1, err, resp)
|
||||
continue
|
||||
}
|
||||
|
||||
// Force correctAnswerStr to "주관식" for descriptive questions to guarantee descriptive UI layout
|
||||
if examType == "서술형" {
|
||||
gq.CorrectAnswerStr = "주관식"
|
||||
} else if examType == "혼합형" && i%2 == 0 {
|
||||
gq.CorrectAnswerStr = "주관식"
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}()
|
||||
|
||||
return "실기 문제 생성을 시작합니다.", nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user