Delete app-backup.py
Browse files- app-backup.py +0 -2097
app-backup.py
DELETED
|
@@ -1,2097 +0,0 @@
|
|
| 1 |
-
import gradio as gr
|
| 2 |
-
from groq import Groq
|
| 3 |
-
import fitz # PyMuPDF
|
| 4 |
-
import json
|
| 5 |
-
import os
|
| 6 |
-
import re
|
| 7 |
-
import random
|
| 8 |
-
import sys
|
| 9 |
-
import zlib
|
| 10 |
-
import zipfile
|
| 11 |
-
import subprocess
|
| 12 |
-
from pathlib import Path
|
| 13 |
-
from typing import List, Dict, Any, Tuple
|
| 14 |
-
from dataclasses import dataclass
|
| 15 |
-
from reportlab.lib.pagesizes import letter, A4
|
| 16 |
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
|
| 17 |
-
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 18 |
-
from reportlab.lib import colors
|
| 19 |
-
from reportlab.lib.units import inch
|
| 20 |
-
from reportlab.pdfbase import pdfmetrics
|
| 21 |
-
from reportlab.pdfbase.ttfonts import TTFont
|
| 22 |
-
import tempfile
|
| 23 |
-
from datetime import datetime
|
| 24 |
-
from xml.etree import ElementTree as ET
|
| 25 |
-
|
| 26 |
-
# ============================================================
|
| 27 |
-
# SOMA Framework v3.1: Gradio Edition
|
| 28 |
-
# PDF/HWP/HWPX TO EXAM - Multi-Agent AI System
|
| 29 |
-
# Bilingual Support: English / Korean
|
| 30 |
-
# WITH INTERACTIVE EXAM TAKING FEATURE!
|
| 31 |
-
# ============================================================
|
| 32 |
-
|
| 33 |
-
# Groq Client
|
| 34 |
-
client = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
| 35 |
-
|
| 36 |
-
# ============================================================
|
| 37 |
-
# HWP/HWPX Support - Module Imports
|
| 38 |
-
# ============================================================
|
| 39 |
-
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if '__file__' in dir() else os.getcwd()
|
| 40 |
-
PYHWP_PATH = os.path.join(SCRIPT_DIR, 'pyhwp')
|
| 41 |
-
|
| 42 |
-
if os.path.exists(PYHWP_PATH):
|
| 43 |
-
sys.path.insert(0, PYHWP_PATH)
|
| 44 |
-
|
| 45 |
-
# olefile for HWP parsing
|
| 46 |
-
try:
|
| 47 |
-
import olefile
|
| 48 |
-
OLEFILE_AVAILABLE = True
|
| 49 |
-
print("✅ olefile loaded for HWP support")
|
| 50 |
-
except ImportError:
|
| 51 |
-
OLEFILE_AVAILABLE = False
|
| 52 |
-
print("⚠️ olefile not available - HWP support limited")
|
| 53 |
-
|
| 54 |
-
# ============================================================
|
| 55 |
-
# Register Korean Font for PDF (NanumMyeongjo)
|
| 56 |
-
# ============================================================
|
| 57 |
-
KOREAN_FONT = 'Helvetica' # Default fallback
|
| 58 |
-
|
| 59 |
-
def register_korean_font():
|
| 60 |
-
"""Register Korean font for PDF generation"""
|
| 61 |
-
global KOREAN_FONT
|
| 62 |
-
|
| 63 |
-
script_dir = os.path.dirname(os.path.abspath(__file__)) if '__file__' in dir() else os.getcwd()
|
| 64 |
-
|
| 65 |
-
font_paths = [
|
| 66 |
-
os.path.join(script_dir, "NanumMyeongjo-Regular.ttf"),
|
| 67 |
-
"NanumMyeongjo-Regular.ttf",
|
| 68 |
-
"./NanumMyeongjo-Regular.ttf",
|
| 69 |
-
"/app/NanumMyeongjo-Regular.ttf",
|
| 70 |
-
]
|
| 71 |
-
|
| 72 |
-
print(f"🔍 Looking for Korean font...")
|
| 73 |
-
|
| 74 |
-
for font_path in font_paths:
|
| 75 |
-
if os.path.exists(font_path):
|
| 76 |
-
try:
|
| 77 |
-
pdfmetrics.registerFont(TTFont('NanumMyeongjo', font_path))
|
| 78 |
-
KOREAN_FONT = 'NanumMyeongjo'
|
| 79 |
-
print(f"✅ Korean font registered: {font_path}")
|
| 80 |
-
return 'NanumMyeongjo'
|
| 81 |
-
except Exception as e:
|
| 82 |
-
print(f"⚠️ Font registration failed: {e}")
|
| 83 |
-
continue
|
| 84 |
-
|
| 85 |
-
print("❌ NanumMyeongjo-Regular.ttf not found - Korean PDF may have issues")
|
| 86 |
-
KOREAN_FONT = 'Helvetica'
|
| 87 |
-
return 'Helvetica'
|
| 88 |
-
|
| 89 |
-
register_korean_font()
|
| 90 |
-
|
| 91 |
-
@dataclass
|
| 92 |
-
class PageContent:
|
| 93 |
-
page_num: int
|
| 94 |
-
text: str
|
| 95 |
-
|
| 96 |
-
# ============================================================
|
| 97 |
-
# Language Text Dictionary
|
| 98 |
-
# ============================================================
|
| 99 |
-
LANG_TEXT = {
|
| 100 |
-
"English": {
|
| 101 |
-
"title": "📄 PDF/HWP TO EXAM GENERATOR 🎓",
|
| 102 |
-
"subtitle": "🤖 SOMA AI transforms your PDF/HWP documents into comprehensive exams instantly! 📝",
|
| 103 |
-
"upload_label": "📄 Upload Your Document (PDF/HWP/HWPX)",
|
| 104 |
-
"num_questions_label": "📊 Number of Questions",
|
| 105 |
-
"choices_label": "🔢 Answer Choices (2/4/5)",
|
| 106 |
-
"choices_info": "Select number of options per question",
|
| 107 |
-
"difficulty_label": "📈 Difficulty Level",
|
| 108 |
-
"difficulty_choices": ["Mixed", "Easy", "Medium", "Hard"],
|
| 109 |
-
"language_label": "🌐 Language",
|
| 110 |
-
"generate_btn": "🚀 GENERATE EXAM! 📝",
|
| 111 |
-
"log_title": "📜 Generation Log",
|
| 112 |
-
"log_placeholder": "Upload a PDF/HWP file and click generate to see results...",
|
| 113 |
-
"download_title": "### 📥 Download Your Exam PDFs",
|
| 114 |
-
"exam_label": "📝 Exam (Questions Only)",
|
| 115 |
-
"answer_label": "🔑 Answer Key & Explanations",
|
| 116 |
-
"preview_title": "👀 Preview Questions",
|
| 117 |
-
"preview_placeholder": "Generated questions will appear here...",
|
| 118 |
-
"howto_title": "📖 How to Use",
|
| 119 |
-
"error_no_file": "⚠️ Please upload a PDF/HWP/HWPX file!",
|
| 120 |
-
"error_no_text": "❌ Could not extract text from document!",
|
| 121 |
-
"error_no_questions": "❌ Failed to generate valid questions!",
|
| 122 |
-
"phase_extract": "📖 Extracting document...",
|
| 123 |
-
"phase_analyze": "🔍 Analyzing concepts...",
|
| 124 |
-
"phase_generate": "📝 Generating questions...",
|
| 125 |
-
"phase_validate": "✅ Validating questions...",
|
| 126 |
-
"phase_pdf": "📄 Creating PDFs...",
|
| 127 |
-
"phase_final": "🎉 Finalizing...",
|
| 128 |
-
"phase_complete": "✨ Complete!",
|
| 129 |
-
"success_title": "✅ EXAM GENERATED SUCCESSFULLY!",
|
| 130 |
-
"stats_title": "📊 Exam Statistics:",
|
| 131 |
-
"total_questions": "Total Questions",
|
| 132 |
-
"choices_per_q": "Choices per Question",
|
| 133 |
-
"difficulty": "Difficulty",
|
| 134 |
-
"doc_pages": "Document Pages",
|
| 135 |
-
"answer_dist": "📈 Answer Distribution:",
|
| 136 |
-
"topic": "📚 Topic",
|
| 137 |
-
"doc_level": "Document Level",
|
| 138 |
-
"pdf_ready": "PDFs ready for download!",
|
| 139 |
-
"preview_header": "GENERATED EXAM PREVIEW",
|
| 140 |
-
"correct_mark": "<- CORRECT",
|
| 141 |
-
"pdf_exam_title": "SOMA EXAM",
|
| 142 |
-
"pdf_answer_title": "SOMA ANSWER KEY",
|
| 143 |
-
"pdf_date": "Date",
|
| 144 |
-
"pdf_total_q": "Total Questions",
|
| 145 |
-
"pdf_quick_summary": "Quick Answer Summary:",
|
| 146 |
-
"pdf_detailed": "Detailed Explanations:",
|
| 147 |
-
"pdf_correct_answer": "Correct Answer",
|
| 148 |
-
"pdf_source": "Source: Page",
|
| 149 |
-
"pdf_reference": "Reference",
|
| 150 |
-
"pdf_why_correct": "Why Correct",
|
| 151 |
-
"pdf_why_wrong": "Why other options are wrong:",
|
| 152 |
-
"bloom_remember": "Remember",
|
| 153 |
-
"bloom_understand": "Understand",
|
| 154 |
-
"bloom_apply": "Apply",
|
| 155 |
-
"bloom_analyze": "Analyze",
|
| 156 |
-
"tab_generate": "📝 Generate Exam",
|
| 157 |
-
"tab_take_exam": "🎯 Take Exam",
|
| 158 |
-
"start_exam_btn": "🚀 START EXAM!",
|
| 159 |
-
"submit_answer_btn": "✅ SUBMIT ANSWER",
|
| 160 |
-
"next_btn": "➡️ NEXT",
|
| 161 |
-
"prev_btn": "⬅️ PREVIOUS",
|
| 162 |
-
"finish_exam_btn": "🏁 FINISH EXAM",
|
| 163 |
-
"restart_exam_btn": "🔄 RESTART EXAM",
|
| 164 |
-
"question_progress": "Question {current} of {total}",
|
| 165 |
-
"select_answer": "🎯 Select your answer:",
|
| 166 |
-
"no_exam_yet": "⚠️ No exam generated yet! Please go to 'Generate Exam' tab first.",
|
| 167 |
-
"exam_ready": "✅ Exam ready! {total} questions loaded. Click START to begin!",
|
| 168 |
-
"answer_submitted": "✅ Answer submitted!",
|
| 169 |
-
"correct_feedback": "✅ CORRECT! Well done!",
|
| 170 |
-
"incorrect_feedback": "❌ INCORRECT",
|
| 171 |
-
"your_answer": "Your answer",
|
| 172 |
-
"correct_answer_is": "Correct answer",
|
| 173 |
-
"explanation_label": "💡 Explanation",
|
| 174 |
-
"results_title": "🎓 EXAM RESULTS",
|
| 175 |
-
"final_score": "Final Score",
|
| 176 |
-
"percentage": "Percentage",
|
| 177 |
-
"grade": "Grade",
|
| 178 |
-
"time_spent": "Time Spent",
|
| 179 |
-
"review_title": "📋 Review Your Answers",
|
| 180 |
-
"question_review": "Question {num}",
|
| 181 |
-
"not_answered": "Not answered",
|
| 182 |
-
"correct_count": "Correct",
|
| 183 |
-
"incorrect_count": "Incorrect",
|
| 184 |
-
"skipped_count": "Skipped",
|
| 185 |
-
},
|
| 186 |
-
"한국어": {
|
| 187 |
-
"title": "📄 PDF/HWP 시험 문제 생성기 🎓",
|
| 188 |
-
"subtitle": "🤖 SOMA AI가 PDF/HWP 문서를 종합 시험 문제로 즉시 변환합니다! 📝",
|
| 189 |
-
"upload_label": "📄 문서 업로드 (PDF/HWP/HWPX)",
|
| 190 |
-
"num_questions_label": "📊 문제 개수",
|
| 191 |
-
"choices_label": "🔢 선택지 개수 (2/4/5)",
|
| 192 |
-
"choices_info": "문제당 선택지 개수를 선택하세요",
|
| 193 |
-
"difficulty_label": "📈 난이도",
|
| 194 |
-
"difficulty_choices": ["혼합", "쉬움", "보통", "어려움"],
|
| 195 |
-
"language_label": "🌐 언어",
|
| 196 |
-
"generate_btn": "🚀 시험 문제 생성! 📝",
|
| 197 |
-
"log_title": "📜 생성 로그",
|
| 198 |
-
"log_placeholder": "PDF/HWP를 업로드하고 생성 버튼을 클릭하세요...",
|
| 199 |
-
"download_title": "### 📥 시험 PDF 다운로드",
|
| 200 |
-
"exam_label": "📝 시험지 (문제만)",
|
| 201 |
-
"answer_label": "🔑 정답 및 해설",
|
| 202 |
-
"preview_title": "👀 문제 미리보기",
|
| 203 |
-
"preview_placeholder": "생성된 문제가 여기에 표시됩니다...",
|
| 204 |
-
"howto_title": "📖 사용 방법",
|
| 205 |
-
"error_no_file": "⚠️ PDF/HWP/HWPX 파일을 업로드해주세요!",
|
| 206 |
-
"error_no_text": "❌ 문서에서 텍스트를 추출할 수 없습니다!",
|
| 207 |
-
"error_no_questions": "❌ 유효한 문제를 생성하지 못했습니다!",
|
| 208 |
-
"phase_extract": "📖 문서 추출 중...",
|
| 209 |
-
"phase_analyze": "🔍 개념 분석 중...",
|
| 210 |
-
"phase_generate": "📝 문제 생성 중...",
|
| 211 |
-
"phase_validate": "✅ 문제 검증 중...",
|
| 212 |
-
"phase_pdf": "📄 PDF 생성 중...",
|
| 213 |
-
"phase_final": "🎉 마무리 중...",
|
| 214 |
-
"phase_complete": "✨ 완료!",
|
| 215 |
-
"success_title": "✅ 시험 문제 생성 완료!",
|
| 216 |
-
"stats_title": "시험 통계:",
|
| 217 |
-
"total_questions": "총 문제 수",
|
| 218 |
-
"choices_per_q": "문제당 선택지",
|
| 219 |
-
"difficulty": "난이도",
|
| 220 |
-
"doc_pages": "문서 페이지 수",
|
| 221 |
-
"answer_dist": "정답 분포:",
|
| 222 |
-
"topic": "주제",
|
| 223 |
-
"doc_level": "문서 수준",
|
| 224 |
-
"pdf_ready": "PDF 다운로드 준비 완료!",
|
| 225 |
-
"preview_header": "생성된 시험 미리보기",
|
| 226 |
-
"correct_mark": "<- 정답",
|
| 227 |
-
"pdf_exam_title": "SOMA 시험",
|
| 228 |
-
"pdf_answer_title": "SOMA 정답 및 해설",
|
| 229 |
-
"pdf_date": "날짜",
|
| 230 |
-
"pdf_total_q": "총 문제 수",
|
| 231 |
-
"pdf_quick_summary": "정답 요약:",
|
| 232 |
-
"pdf_detailed": "상�� 해설:",
|
| 233 |
-
"pdf_correct_answer": "정답",
|
| 234 |
-
"pdf_source": "출처: 페이지",
|
| 235 |
-
"pdf_reference": "참조",
|
| 236 |
-
"pdf_why_correct": "정답인 이유",
|
| 237 |
-
"pdf_why_wrong": "오답인 이유:",
|
| 238 |
-
"bloom_remember": "기억",
|
| 239 |
-
"bloom_understand": "이해",
|
| 240 |
-
"bloom_apply": "적용",
|
| 241 |
-
"bloom_analyze": "분석",
|
| 242 |
-
"tab_generate": "📝 시험 생성",
|
| 243 |
-
"tab_take_exam": "🎯 시험 보기",
|
| 244 |
-
"start_exam_btn": "🚀 시험 시작!",
|
| 245 |
-
"submit_answer_btn": "✅ 답안 제출",
|
| 246 |
-
"next_btn": "➡️ 다음",
|
| 247 |
-
"prev_btn": "⬅️ 이전",
|
| 248 |
-
"finish_exam_btn": "🏁 시험 종료",
|
| 249 |
-
"restart_exam_btn": "🔄 다시 시작",
|
| 250 |
-
"question_progress": "문제 {current} / {total}",
|
| 251 |
-
"select_answer": "🎯 답을 선택하세요:",
|
| 252 |
-
"no_exam_yet": "⚠️ 생성된 시험이 없습니다! '시험 생성' 탭에서 먼저 시험을 만드세요.",
|
| 253 |
-
"exam_ready": "✅ 시험 준비 완료! {total}개 문제가 로드되었습니다. 시작을 클릭하세요!",
|
| 254 |
-
"answer_submitted": "✅ 답안이 제출되었습니다!",
|
| 255 |
-
"correct_feedback": "✅ 정답! 잘했습니다!",
|
| 256 |
-
"incorrect_feedback": "❌ 오답",
|
| 257 |
-
"your_answer": "당신의 답",
|
| 258 |
-
"correct_answer_is": "정답",
|
| 259 |
-
"explanation_label": "💡 해설",
|
| 260 |
-
"results_title": "🎓 시험 결과",
|
| 261 |
-
"final_score": "최종 점수",
|
| 262 |
-
"percentage": "정답률",
|
| 263 |
-
"grade": "등급",
|
| 264 |
-
"time_spent": "소요 시간",
|
| 265 |
-
"review_title": "📋 답안 검토",
|
| 266 |
-
"question_review": "문제 {num}",
|
| 267 |
-
"not_answered": "미응답",
|
| 268 |
-
"correct_count": "정답",
|
| 269 |
-
"incorrect_count": "오답",
|
| 270 |
-
"skipped_count": "건너뜀",
|
| 271 |
-
}
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
# ============================================================
|
| 275 |
-
# File Type Detection Utilities
|
| 276 |
-
# ============================================================
|
| 277 |
-
def is_hwp_file(fp: str) -> bool:
|
| 278 |
-
return Path(fp).suffix.lower() == '.hwp'
|
| 279 |
-
|
| 280 |
-
def is_hwpx_file(fp: str) -> bool:
|
| 281 |
-
return Path(fp).suffix.lower() == '.hwpx'
|
| 282 |
-
|
| 283 |
-
def is_pdf_file(fp: str) -> bool:
|
| 284 |
-
return Path(fp).suffix.lower() == '.pdf'
|
| 285 |
-
|
| 286 |
-
def get_file_type(file_path: str) -> str:
|
| 287 |
-
"""Determine file type from path"""
|
| 288 |
-
ext = Path(file_path).suffix.lower()
|
| 289 |
-
if ext == '.pdf':
|
| 290 |
-
return 'pdf'
|
| 291 |
-
elif ext == '.hwp':
|
| 292 |
-
return 'hwp'
|
| 293 |
-
elif ext == '.hwpx':
|
| 294 |
-
return 'hwpx'
|
| 295 |
-
else:
|
| 296 |
-
return 'unknown'
|
| 297 |
-
|
| 298 |
-
# ============================================================
|
| 299 |
-
# HWPX Text Extraction
|
| 300 |
-
# ============================================================
|
| 301 |
-
def extract_text_from_hwpx(file_path: str) -> Tuple[str, str]:
|
| 302 |
-
"""Extract text from HWPX file (Office Open XML format)"""
|
| 303 |
-
try:
|
| 304 |
-
text_parts = []
|
| 305 |
-
with zipfile.ZipFile(file_path, 'r') as zf:
|
| 306 |
-
file_list = zf.namelist()
|
| 307 |
-
section_files = sorted([f for f in file_list if f.startswith('Contents/section') and f.endswith('.xml')])
|
| 308 |
-
if not section_files:
|
| 309 |
-
section_files = sorted([f for f in file_list if 'section' in f.lower() and f.endswith('.xml')])
|
| 310 |
-
|
| 311 |
-
for section_file in section_files:
|
| 312 |
-
try:
|
| 313 |
-
with zf.open(section_file) as sf:
|
| 314 |
-
content = sf.read()
|
| 315 |
-
content_str = content.decode('utf-8')
|
| 316 |
-
content_str = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content_str)
|
| 317 |
-
content_str = re.sub(r'<[a-zA-Z]+:', '<', content_str)
|
| 318 |
-
content_str = re.sub(r'</[a-zA-Z]+:', '</', content_str)
|
| 319 |
-
|
| 320 |
-
try:
|
| 321 |
-
root = ET.fromstring(content_str)
|
| 322 |
-
texts = []
|
| 323 |
-
for elem in root.iter():
|
| 324 |
-
if elem.tag.endswith('t') or elem.tag == 't':
|
| 325 |
-
if elem.text:
|
| 326 |
-
texts.append(elem.text)
|
| 327 |
-
elif elem.text and elem.text.strip():
|
| 328 |
-
if any(x in elem.tag.lower() for x in ['text', 'run', 'para', 'char']):
|
| 329 |
-
texts.append(elem.text.strip())
|
| 330 |
-
if texts:
|
| 331 |
-
text_parts.append(' '.join(texts))
|
| 332 |
-
except ET.ParseError:
|
| 333 |
-
text_matches = re.findall(r'>([^<]+)<', content.decode('utf-8', errors='ignore'))
|
| 334 |
-
clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1]
|
| 335 |
-
if clean_texts:
|
| 336 |
-
text_parts.append(' '.join(clean_texts))
|
| 337 |
-
except:
|
| 338 |
-
continue
|
| 339 |
-
|
| 340 |
-
if text_parts:
|
| 341 |
-
result = '\n\n'.join(text_parts)
|
| 342 |
-
result = re.sub(r'\s+', ' ', result)
|
| 343 |
-
result = re.sub(r'\n{3,}', '\n\n', result)
|
| 344 |
-
return result.strip(), None
|
| 345 |
-
return None, "HWPX에서 텍스트를 찾을 수 없습니다"
|
| 346 |
-
except zipfile.BadZipFile:
|
| 347 |
-
return None, "유효하지 않은 HWPX 파일"
|
| 348 |
-
except Exception as e:
|
| 349 |
-
return None, f"HWPX 처리 오류: {str(e)}"
|
| 350 |
-
|
| 351 |
-
# ============================================================
|
| 352 |
-
# HWP Text Extraction (using hwp5txt and olefile)
|
| 353 |
-
# ============================================================
|
| 354 |
-
def extract_text_with_hwp5txt(file_path: str) -> Tuple[str, str]:
|
| 355 |
-
"""Extract text using hwp5txt command or pyhwp library"""
|
| 356 |
-
# Method 1: Try hwp5txt command
|
| 357 |
-
try:
|
| 358 |
-
result = subprocess.run(['hwp5txt', file_path], capture_output=True, timeout=60)
|
| 359 |
-
if result.returncode == 0 and result.stdout:
|
| 360 |
-
for enc in ['utf-8', 'cp949', 'euc-kr']:
|
| 361 |
-
try:
|
| 362 |
-
text = result.stdout.decode(enc)
|
| 363 |
-
if text.strip() and len(text.strip()) > 10:
|
| 364 |
-
return text.strip(), None
|
| 365 |
-
except:
|
| 366 |
-
continue
|
| 367 |
-
except FileNotFoundError:
|
| 368 |
-
pass
|
| 369 |
-
except Exception as e:
|
| 370 |
-
print(f"hwp5txt error: {e}")
|
| 371 |
-
|
| 372 |
-
# Method 2: Try pyhwp subprocess
|
| 373 |
-
try:
|
| 374 |
-
code = f'''
|
| 375 |
-
import sys
|
| 376 |
-
sys.path.insert(0, "{PYHWP_PATH}")
|
| 377 |
-
from hwp5.filestructure import Hwp5File
|
| 378 |
-
from hwp5.hwp5txt import extract_text
|
| 379 |
-
hwp = Hwp5File("{file_path}")
|
| 380 |
-
for idx in hwp.bodytext.sections():
|
| 381 |
-
section = hwp.bodytext.section(idx)
|
| 382 |
-
for para in extract_text(section):
|
| 383 |
-
if para.strip():
|
| 384 |
-
print(para.strip())
|
| 385 |
-
hwp.close()
|
| 386 |
-
'''
|
| 387 |
-
result = subprocess.run([sys.executable, '-c', code], capture_output=True, timeout=60)
|
| 388 |
-
if result.returncode == 0 and result.stdout:
|
| 389 |
-
for enc in ['utf-8', 'cp949', 'euc-kr']:
|
| 390 |
-
try:
|
| 391 |
-
text = result.stdout.decode(enc)
|
| 392 |
-
if text.strip() and len(text.strip()) > 10:
|
| 393 |
-
return text.strip(), None
|
| 394 |
-
except:
|
| 395 |
-
continue
|
| 396 |
-
except Exception as e:
|
| 397 |
-
print(f"hwp5txt subprocess error: {e}")
|
| 398 |
-
|
| 399 |
-
return None, "hwp5txt 실패"
|
| 400 |
-
|
| 401 |
-
def decode_para_text(data: bytes) -> str:
|
| 402 |
-
"""Decode paragraph text from HWP binary data"""
|
| 403 |
-
result = []
|
| 404 |
-
i = 0
|
| 405 |
-
while i < len(data) - 1:
|
| 406 |
-
code = int.from_bytes(data[i:i+2], 'little')
|
| 407 |
-
if code == 0:
|
| 408 |
-
pass
|
| 409 |
-
elif code == 1:
|
| 410 |
-
i += 14
|
| 411 |
-
elif code == 2:
|
| 412 |
-
i += 14
|
| 413 |
-
elif code == 3:
|
| 414 |
-
i += 14
|
| 415 |
-
elif code == 4:
|
| 416 |
-
pass
|
| 417 |
-
elif code == 9:
|
| 418 |
-
result.append('\t')
|
| 419 |
-
elif code == 10:
|
| 420 |
-
result.append('\n')
|
| 421 |
-
elif code == 13:
|
| 422 |
-
result.append('\n')
|
| 423 |
-
elif code == 24:
|
| 424 |
-
result.append('-')
|
| 425 |
-
elif code == 30 or code == 31:
|
| 426 |
-
result.append(' ')
|
| 427 |
-
elif code < 32:
|
| 428 |
-
pass
|
| 429 |
-
else:
|
| 430 |
-
try:
|
| 431 |
-
char = chr(code)
|
| 432 |
-
if char.isprintable() or char in '\n\t ':
|
| 433 |
-
result.append(char)
|
| 434 |
-
except:
|
| 435 |
-
pass
|
| 436 |
-
i += 2
|
| 437 |
-
text = ''.join(result).strip()
|
| 438 |
-
text = re.sub(r'[ \t]+', ' ', text)
|
| 439 |
-
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 440 |
-
return text if len(text) > 2 else None
|
| 441 |
-
|
| 442 |
-
def extract_hwp_section_text(data: bytes) -> str:
|
| 443 |
-
"""Extract text from HWP section binary data"""
|
| 444 |
-
texts = []
|
| 445 |
-
pos = 0
|
| 446 |
-
while pos < len(data) - 4:
|
| 447 |
-
try:
|
| 448 |
-
header = int.from_bytes(data[pos:pos+4], 'little')
|
| 449 |
-
tag_id = header & 0x3FF
|
| 450 |
-
size = (header >> 20) & 0xFFF
|
| 451 |
-
pos += 4
|
| 452 |
-
if size == 0xFFF:
|
| 453 |
-
if pos + 4 > len(data):
|
| 454 |
-
break
|
| 455 |
-
size = int.from_bytes(data[pos:pos+4], 'little')
|
| 456 |
-
pos += 4
|
| 457 |
-
if pos + size > len(data):
|
| 458 |
-
break
|
| 459 |
-
record_data = data[pos:pos+size]
|
| 460 |
-
pos += size
|
| 461 |
-
if tag_id == 67 and size > 0:
|
| 462 |
-
text = decode_para_text(record_data)
|
| 463 |
-
if text:
|
| 464 |
-
texts.append(text)
|
| 465 |
-
except:
|
| 466 |
-
pos += 1
|
| 467 |
-
continue
|
| 468 |
-
return '\n'.join(texts) if texts else None
|
| 469 |
-
|
| 470 |
-
def extract_text_with_olefile(file_path: str) -> Tuple[str, str]:
|
| 471 |
-
"""Extract text from HWP using olefile library"""
|
| 472 |
-
if not OLEFILE_AVAILABLE:
|
| 473 |
-
return None, "olefile 모듈 없음"
|
| 474 |
-
|
| 475 |
-
try:
|
| 476 |
-
ole = olefile.OleFileIO(file_path)
|
| 477 |
-
if not ole.exists('FileHeader'):
|
| 478 |
-
ole.close()
|
| 479 |
-
return None, "HWP 파일 헤더 없음"
|
| 480 |
-
|
| 481 |
-
header_data = ole.openstream('FileHeader').read()
|
| 482 |
-
is_compressed = (header_data[36] & 1) == 1 if len(header_data) > 36 else True
|
| 483 |
-
|
| 484 |
-
all_texts = []
|
| 485 |
-
for entry in ole.listdir():
|
| 486 |
-
entry_path = '/'.join(entry)
|
| 487 |
-
if entry_path.startswith('BodyText/Section'):
|
| 488 |
-
try:
|
| 489 |
-
stream_data = ole.openstream(entry).read()
|
| 490 |
-
if is_compressed:
|
| 491 |
-
try:
|
| 492 |
-
stream_data = zlib.decompress(stream_data, -15)
|
| 493 |
-
except:
|
| 494 |
-
try:
|
| 495 |
-
stream_data = zlib.decompress(stream_data)
|
| 496 |
-
except:
|
| 497 |
-
pass
|
| 498 |
-
|
| 499 |
-
section_text = extract_hwp_section_text(stream_data)
|
| 500 |
-
if section_text:
|
| 501 |
-
all_texts.append(section_text)
|
| 502 |
-
except:
|
| 503 |
-
continue
|
| 504 |
-
|
| 505 |
-
ole.close()
|
| 506 |
-
if all_texts:
|
| 507 |
-
return '\n\n'.join(all_texts).strip(), None
|
| 508 |
-
return None, "텍스트를 찾을 수 없습니다"
|
| 509 |
-
except Exception as e:
|
| 510 |
-
return None, f"olefile 오류: {str(e)}"
|
| 511 |
-
|
| 512 |
-
def extract_text_from_hwp(file_path: str) -> Tuple[str, str]:
|
| 513 |
-
"""Main HWP text extraction function - tries multiple methods"""
|
| 514 |
-
print(f"\n📖 [HWP 읽기] {os.path.basename(file_path)}")
|
| 515 |
-
|
| 516 |
-
# Method 1: hwp5txt
|
| 517 |
-
text, error = extract_text_with_hwp5txt(file_path)
|
| 518 |
-
if text and len(text.strip()) > 20:
|
| 519 |
-
print(f" ✅ hwp5txt 성공: {len(text)} 글자")
|
| 520 |
-
return text, None
|
| 521 |
-
|
| 522 |
-
# Method 2: olefile
|
| 523 |
-
text, error = extract_text_with_olefile(file_path)
|
| 524 |
-
if text and len(text.strip()) > 20:
|
| 525 |
-
print(f" ✅ olefile 성공: {len(text)} 글자")
|
| 526 |
-
return text, None
|
| 527 |
-
|
| 528 |
-
print(f" ❌ 실패: {error}")
|
| 529 |
-
return None, "모든 추출 방법 실패"
|
| 530 |
-
|
| 531 |
-
def extract_text_from_hwp_or_hwpx(file_path: str) -> Tuple[str, str]:
|
| 532 |
-
"""Extract text from either HWP or HWPX file"""
|
| 533 |
-
if is_hwpx_file(file_path):
|
| 534 |
-
print(f"\n📖 [HWPX 읽기] {os.path.basename(file_path)}")
|
| 535 |
-
return extract_text_from_hwpx(file_path)
|
| 536 |
-
else:
|
| 537 |
-
return extract_text_from_hwp(file_path)
|
| 538 |
-
|
| 539 |
-
# ============================================================
|
| 540 |
-
# PDF Text Extraction (Original)
|
| 541 |
-
# ============================================================
|
| 542 |
-
def extract_text_from_pdf_with_pages(file_path: str) -> List[PageContent]:
|
| 543 |
-
"""Extract text from PDF with page numbers"""
|
| 544 |
-
page_contents = []
|
| 545 |
-
|
| 546 |
-
try:
|
| 547 |
-
doc = fitz.open(file_path)
|
| 548 |
-
|
| 549 |
-
for page_num, page in enumerate(doc, 1):
|
| 550 |
-
text = page.get_text("text")
|
| 551 |
-
|
| 552 |
-
if not text.strip():
|
| 553 |
-
text = page.get_text("text", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
| 554 |
-
|
| 555 |
-
if not text.strip():
|
| 556 |
-
blocks = page.get_text("blocks")
|
| 557 |
-
text = "\n".join([b[4] for b in blocks if b[6] == 0])
|
| 558 |
-
|
| 559 |
-
if not text.strip():
|
| 560 |
-
text_dict = page.get_text("dict")
|
| 561 |
-
text_parts = []
|
| 562 |
-
for block in text_dict.get("blocks", []):
|
| 563 |
-
if "lines" in block:
|
| 564 |
-
for line in block["lines"]:
|
| 565 |
-
for span in line.get("spans", []):
|
| 566 |
-
text_parts.append(span.get("text", ""))
|
| 567 |
-
text = " ".join(text_parts)
|
| 568 |
-
|
| 569 |
-
if text.strip():
|
| 570 |
-
page_contents.append(PageContent(page_num=page_num, text=text.strip()))
|
| 571 |
-
|
| 572 |
-
doc.close()
|
| 573 |
-
|
| 574 |
-
except Exception as e:
|
| 575 |
-
raise Exception(f"PDF extraction failed: {str(e)}")
|
| 576 |
-
|
| 577 |
-
return page_contents
|
| 578 |
-
|
| 579 |
-
# ============================================================
|
| 580 |
-
# Unified Document Extraction
|
| 581 |
-
# ============================================================
|
| 582 |
-
def extract_text_with_pages(file_path: str) -> List[PageContent]:
|
| 583 |
-
"""
|
| 584 |
-
Universal document text extraction with page numbers
|
| 585 |
-
Supports: PDF, HWP, HWPX
|
| 586 |
-
"""
|
| 587 |
-
file_type = get_file_type(file_path)
|
| 588 |
-
print(f"\n📄 [문서 추출] 파일 유형: {file_type.upper()}")
|
| 589 |
-
|
| 590 |
-
if file_type == 'pdf':
|
| 591 |
-
return extract_text_from_pdf_with_pages(file_path)
|
| 592 |
-
|
| 593 |
-
elif file_type in ['hwp', 'hwpx']:
|
| 594 |
-
# Extract HWP/HWPX text
|
| 595 |
-
text, error = extract_text_from_hwp_or_hwpx(file_path)
|
| 596 |
-
|
| 597 |
-
if not text or len(text.strip()) < 20:
|
| 598 |
-
print(f" ❌ HWP/HWPX 추출 실패: {error}")
|
| 599 |
-
return []
|
| 600 |
-
|
| 601 |
-
# Split into pseudo-pages (every ~2000 characters)
|
| 602 |
-
page_contents = []
|
| 603 |
-
paragraphs = text.split('\n\n')
|
| 604 |
-
current_page_text = ""
|
| 605 |
-
current_page_num = 1
|
| 606 |
-
|
| 607 |
-
for para in paragraphs:
|
| 608 |
-
if len(current_page_text) + len(para) > 2000:
|
| 609 |
-
if current_page_text.strip():
|
| 610 |
-
page_contents.append(PageContent(
|
| 611 |
-
page_num=current_page_num,
|
| 612 |
-
text=current_page_text.strip()
|
| 613 |
-
))
|
| 614 |
-
current_page_num += 1
|
| 615 |
-
current_page_text = para + "\n\n"
|
| 616 |
-
else:
|
| 617 |
-
current_page_text += para + "\n\n"
|
| 618 |
-
|
| 619 |
-
# Add remaining text
|
| 620 |
-
if current_page_text.strip():
|
| 621 |
-
page_contents.append(PageContent(
|
| 622 |
-
page_num=current_page_num,
|
| 623 |
-
text=current_page_text.strip()
|
| 624 |
-
))
|
| 625 |
-
|
| 626 |
-
print(f" ✅ {len(page_contents)} 페이지 추출 완료 (총 {len(text)} 글자)")
|
| 627 |
-
return page_contents
|
| 628 |
-
|
| 629 |
-
else:
|
| 630 |
-
raise Exception(f"Unsupported file type: {file_type}")
|
| 631 |
-
|
| 632 |
-
# ============================================================
|
| 633 |
-
# Concept Analysis
|
| 634 |
-
# ============================================================
|
| 635 |
-
def analyze_concepts(page_contents: List[PageContent], language: str) -> Dict[str, Any]:
|
| 636 |
-
"""Analyze key concepts from document"""
|
| 637 |
-
|
| 638 |
-
full_text = "\n\n".join([f"[Page {p.page_num}]\n{p.text}" for p in page_contents])
|
| 639 |
-
|
| 640 |
-
if language == "한국어":
|
| 641 |
-
prompt = f"""당신은 교육 전문가입니다. 다음 문서를 분석하고 핵심 개념을 추출하세요.
|
| 642 |
-
|
| 643 |
-
문서:
|
| 644 |
-
{full_text[:10000]}
|
| 645 |
-
|
| 646 |
-
JSON 형식으로만 응답하세요:
|
| 647 |
-
{{
|
| 648 |
-
"main_topic": "문서의 핵심 주제",
|
| 649 |
-
"key_concepts": [
|
| 650 |
-
{{"concept": "개념1", "page": 1, "importance": "high"}},
|
| 651 |
-
{{"concept": "개념2", "page": 2, "importance": "medium"}}
|
| 652 |
-
],
|
| 653 |
-
"difficulty_assessment": "초급/중급/고급"
|
| 654 |
-
}}"""
|
| 655 |
-
else:
|
| 656 |
-
prompt = f"""You are an education expert. Analyze the following document and extract key concepts.
|
| 657 |
-
|
| 658 |
-
Document:
|
| 659 |
-
{full_text[:10000]}
|
| 660 |
-
|
| 661 |
-
Respond ONLY in JSON format:
|
| 662 |
-
{{
|
| 663 |
-
"main_topic": "Core topic of the document",
|
| 664 |
-
"key_concepts": [
|
| 665 |
-
{{"concept": "concept1", "page": 1, "importance": "high"}},
|
| 666 |
-
{{"concept": "concept2", "page": 2, "importance": "medium"}}
|
| 667 |
-
],
|
| 668 |
-
"difficulty_assessment": "beginner/intermediate/advanced"
|
| 669 |
-
}}"""
|
| 670 |
-
|
| 671 |
-
try:
|
| 672 |
-
completion = client.chat.completions.create(
|
| 673 |
-
model="openai/gpt-oss-120b",
|
| 674 |
-
messages=[{"role": "user", "content": prompt}],
|
| 675 |
-
temperature=0.3,
|
| 676 |
-
max_completion_tokens=2048,
|
| 677 |
-
top_p=1,
|
| 678 |
-
)
|
| 679 |
-
|
| 680 |
-
response = completion.choices[0].message.content
|
| 681 |
-
if response is None:
|
| 682 |
-
response = ""
|
| 683 |
-
response = response.strip()
|
| 684 |
-
|
| 685 |
-
print(f"📊 Analyze response: {response[:500]}...")
|
| 686 |
-
|
| 687 |
-
if "```json" in response:
|
| 688 |
-
response = response.split("```json")[1].split("```")[0]
|
| 689 |
-
elif "```" in response:
|
| 690 |
-
response = response.split("```")[1].split("```")[0]
|
| 691 |
-
|
| 692 |
-
return json.loads(response.strip())
|
| 693 |
-
except Exception as e:
|
| 694 |
-
print(f"⚠️ Analyze concepts error: {e}")
|
| 695 |
-
return {"main_topic": "Document", "key_concepts": [], "difficulty_assessment": "intermediate"}
|
| 696 |
-
|
| 697 |
-
# ============================================================
|
| 698 |
-
# Question Generation with Random Answer Placement
|
| 699 |
-
# ============================================================
|
| 700 |
-
def shuffle_options_and_update_answer(question: Dict, num_choices: int) -> Dict:
|
| 701 |
-
"""Shuffle options randomly and update the correct answer position"""
|
| 702 |
-
|
| 703 |
-
options = question.get("options", [])
|
| 704 |
-
answer = question.get("answer", "")
|
| 705 |
-
explanation = question.get("explanation", {})
|
| 706 |
-
|
| 707 |
-
correct_idx = -1
|
| 708 |
-
for i, opt in enumerate(options):
|
| 709 |
-
if opt == answer or answer in opt or opt in answer:
|
| 710 |
-
correct_idx = i
|
| 711 |
-
break
|
| 712 |
-
|
| 713 |
-
if correct_idx == -1:
|
| 714 |
-
correct_idx = 0
|
| 715 |
-
|
| 716 |
-
if len(options) > num_choices:
|
| 717 |
-
correct_option = options[correct_idx]
|
| 718 |
-
other_options = [opt for i, opt in enumerate(options) if i != correct_idx]
|
| 719 |
-
random.shuffle(other_options)
|
| 720 |
-
options = [correct_option] + other_options[:num_choices-1]
|
| 721 |
-
correct_idx = 0
|
| 722 |
-
elif len(options) < num_choices:
|
| 723 |
-
while len(options) < num_choices:
|
| 724 |
-
options.append(f"Option {chr(65 + len(options))}: None of the above")
|
| 725 |
-
|
| 726 |
-
indices = list(range(len(options)))
|
| 727 |
-
random.shuffle(indices)
|
| 728 |
-
|
| 729 |
-
shuffled_options = [options[i] for i in indices]
|
| 730 |
-
new_correct_idx = indices.index(correct_idx)
|
| 731 |
-
new_answer = shuffled_options[new_correct_idx]
|
| 732 |
-
|
| 733 |
-
labels = ['A', 'B', 'C', 'D', 'E'][:num_choices]
|
| 734 |
-
labeled_options = []
|
| 735 |
-
for i, opt in enumerate(shuffled_options):
|
| 736 |
-
clean_opt = re.sub(r'^[A-E][\.\)]\s*', '', opt)
|
| 737 |
-
labeled_options.append(f"{labels[i]}. {clean_opt}")
|
| 738 |
-
|
| 739 |
-
new_answer_labeled = labeled_options[new_correct_idx]
|
| 740 |
-
|
| 741 |
-
question["options"] = labeled_options
|
| 742 |
-
question["answer"] = new_answer_labeled
|
| 743 |
-
question["correct_label"] = labels[new_correct_idx]
|
| 744 |
-
|
| 745 |
-
return question
|
| 746 |
-
|
| 747 |
-
def generate_questions(
|
| 748 |
-
page_contents: List[PageContent],
|
| 749 |
-
analysis: Dict[str, Any],
|
| 750 |
-
num_questions: int,
|
| 751 |
-
num_choices: int,
|
| 752 |
-
difficulty: str,
|
| 753 |
-
language: str
|
| 754 |
-
) -> List[Dict]:
|
| 755 |
-
"""Generate questions with SOMA framework"""
|
| 756 |
-
|
| 757 |
-
full_text = "\n\n".join([f"[Page {p.page_num}]\n{p.text}" for p in page_contents])
|
| 758 |
-
key_concepts = ", ".join([c["concept"] for c in analysis.get("key_concepts", [])])
|
| 759 |
-
|
| 760 |
-
if language == "한국어":
|
| 761 |
-
choice_text = f"{num_choices}지선다"
|
| 762 |
-
|
| 763 |
-
prompt = f"""당신은 SOMA 시험 출제 전문가입니다. {num_questions}개의 {choice_text} 객관식 문제를 생성하세요.
|
| 764 |
-
|
| 765 |
-
📚 문서 (페이지 번호 포함):
|
| 766 |
-
{full_text[:15000]}
|
| 767 |
-
|
| 768 |
-
📋 핵심 개념: {key_concepts}
|
| 769 |
-
📊 난이도: {difficulty}
|
| 770 |
-
📝 문제당 선택지 수: {num_choices}
|
| 771 |
-
|
| 772 |
-
⚠️ 중요 요구사항:
|
| 773 |
-
1. 각 문제마다 정확히 {num_choices}개의 선택지 생성 (A, B, C, D, E 필요시)
|
| 774 |
-
2. 정답 위치를 다양하게 배치 - 항상 A가 정답이면 안 됨
|
| 775 |
-
3. 정답을 A, B, C, D, E에 무작위로 분포
|
| 776 |
-
4. 각 답변에 페이지 참조와 설명 포함
|
| 777 |
-
5. 각 오답이 왜 틀렸는지 설명
|
| 778 |
-
|
| 779 |
-
JSON 배열 형식으로만 응답하세요:
|
| 780 |
-
[
|
| 781 |
-
{{
|
| 782 |
-
"question": "문제 내용?",
|
| 783 |
-
"options": ["A. 첫번째 선택지", "B. 두번째 선택지", "C. 세번째 선택지", "D. 네번째 선택지"],
|
| 784 |
-
"answer": "B. 두번째 선택지",
|
| 785 |
-
"explanation": {{
|
| 786 |
-
"correct_reason": "페이지 X에서 ...라고 명시되어 있으므로 정답입니다.",
|
| 787 |
-
"wrong_A": "A가 틀린 이유...",
|
| 788 |
-
"wrong_B": "",
|
| 789 |
-
"wrong_C": "C가 틀린 이유...",
|
| 790 |
-
"wrong_D": "D가 틀린 이유..."
|
| 791 |
-
}},
|
| 792 |
-
"source_page": 1,
|
| 793 |
-
"source_quote": "문서에서 관련 인용문",
|
| 794 |
-
"difficulty": "쉬움/보통/어려움",
|
| 795 |
-
"bloom_level": "기억/이해/적용/분석",
|
| 796 |
-
"concept_tag": "관련 개념"
|
| 797 |
-
}}
|
| 798 |
-
]
|
| 799 |
-
|
| 800 |
-
중요: 정답 위치를 무작위로 배치하세요! 모든 정답이 A이면 안 됩니다!"""
|
| 801 |
-
else:
|
| 802 |
-
choice_text = f"{num_choices}-choice" if num_choices != 4 else "4-choice (A, B, C, D)"
|
| 803 |
-
|
| 804 |
-
prompt = f"""You are a SOMA exam expert. Generate {num_questions} {choice_text} multiple choice questions.
|
| 805 |
-
|
| 806 |
-
📚 Document (with page numbers):
|
| 807 |
-
{full_text[:15000]}
|
| 808 |
-
|
| 809 |
-
📋 Key concepts: {key_concepts}
|
| 810 |
-
📊 Difficulty: {difficulty}
|
| 811 |
-
📝 Number of choices per question: {num_choices}
|
| 812 |
-
|
| 813 |
-
⚠️ CRITICAL REQUIREMENTS:
|
| 814 |
-
1. Generate exactly {num_choices} options for each question (labeled A, B, C, D, E as needed)
|
| 815 |
-
2. VARY the correct answer position - DO NOT always make A the correct answer
|
| 816 |
-
3. Distribute correct answers randomly across A, B, C, D, E
|
| 817 |
-
4. Include page reference and explanation for each answer
|
| 818 |
-
5. Explain why EACH wrong option is incorrect
|
| 819 |
-
|
| 820 |
-
Respond ONLY in JSON array format:
|
| 821 |
-
[
|
| 822 |
-
{{
|
| 823 |
-
"question": "Question text here?",
|
| 824 |
-
"options": ["A. First option", "B. Second option", "C. Third option", "D. Fourth option"],
|
| 825 |
-
"answer": "B. Second option",
|
| 826 |
-
"explanation": {{
|
| 827 |
-
"correct_reason": "This is correct because page X states...",
|
| 828 |
-
"wrong_A": "A is wrong because...",
|
| 829 |
-
"wrong_B": "",
|
| 830 |
-
"wrong_C": "C is wrong because...",
|
| 831 |
-
"wrong_D": "D is wrong because..."
|
| 832 |
-
}},
|
| 833 |
-
"source_page": 1,
|
| 834 |
-
"source_quote": "Relevant quote from document",
|
| 835 |
-
"difficulty": "easy/medium/hard",
|
| 836 |
-
"bloom_level": "Remember/Understand/Apply/Analyze",
|
| 837 |
-
"concept_tag": "Related concept"
|
| 838 |
-
}}
|
| 839 |
-
]
|
| 840 |
-
|
| 841 |
-
IMPORTANT: Randomize correct answer positions! Not all answers should be A!"""
|
| 842 |
-
|
| 843 |
-
try:
|
| 844 |
-
completion = client.chat.completions.create(
|
| 845 |
-
model="openai/gpt-oss-120b",
|
| 846 |
-
messages=[{"role": "user", "content": prompt}],
|
| 847 |
-
temperature=0.8,
|
| 848 |
-
max_completion_tokens=8192,
|
| 849 |
-
top_p=1,
|
| 850 |
-
)
|
| 851 |
-
|
| 852 |
-
response = completion.choices[0].message.content
|
| 853 |
-
if response is None:
|
| 854 |
-
raise Exception("Empty response from model")
|
| 855 |
-
response = response.strip()
|
| 856 |
-
|
| 857 |
-
print(f"📝 Generate response length: {len(response)}")
|
| 858 |
-
|
| 859 |
-
if "```json" in response:
|
| 860 |
-
response = response.split("```json")[1].split("```")[0]
|
| 861 |
-
elif "```" in response:
|
| 862 |
-
response = response.split("```")[1].split("```")[0]
|
| 863 |
-
|
| 864 |
-
start_idx = response.find('[')
|
| 865 |
-
end_idx = response.rfind(']') + 1
|
| 866 |
-
if start_idx != -1 and end_idx > start_idx:
|
| 867 |
-
response = response[start_idx:end_idx]
|
| 868 |
-
|
| 869 |
-
questions = json.loads(response.strip())
|
| 870 |
-
|
| 871 |
-
shuffled_questions = []
|
| 872 |
-
for q in questions:
|
| 873 |
-
shuffled_q = shuffle_options_and_update_answer(q, num_choices)
|
| 874 |
-
shuffled_questions.append(shuffled_q)
|
| 875 |
-
|
| 876 |
-
return shuffled_questions
|
| 877 |
-
|
| 878 |
-
except Exception as e:
|
| 879 |
-
print(f"❌ Generate questions error: {e}")
|
| 880 |
-
raise Exception(f"Question generation failed: {str(e)}")
|
| 881 |
-
|
| 882 |
-
# ============================================================
|
| 883 |
-
# Validation
|
| 884 |
-
# ============================================================
|
| 885 |
-
def validate_questions(questions: List[Dict], page_contents: List[PageContent], num_choices: int) -> List[Dict]:
|
| 886 |
-
"""Validate and fix questions"""
|
| 887 |
-
|
| 888 |
-
validated = []
|
| 889 |
-
max_page = max([p.page_num for p in page_contents]) if page_contents else 1
|
| 890 |
-
|
| 891 |
-
for q in questions:
|
| 892 |
-
if not all(key in q for key in ["question", "options", "answer"]):
|
| 893 |
-
continue
|
| 894 |
-
|
| 895 |
-
if len(q.get("options", [])) != num_choices:
|
| 896 |
-
continue
|
| 897 |
-
|
| 898 |
-
if q["answer"] not in q["options"]:
|
| 899 |
-
for opt in q["options"]:
|
| 900 |
-
if q["answer"] in opt or opt in q["answer"]:
|
| 901 |
-
q["answer"] = opt
|
| 902 |
-
break
|
| 903 |
-
else:
|
| 904 |
-
q["answer"] = q["options"][0]
|
| 905 |
-
|
| 906 |
-
for i, opt in enumerate(q["options"]):
|
| 907 |
-
if opt == q["answer"]:
|
| 908 |
-
q["correct_label"] = chr(65 + i)
|
| 909 |
-
break
|
| 910 |
-
|
| 911 |
-
source_page = q.get("source_page", 1)
|
| 912 |
-
if isinstance(source_page, str):
|
| 913 |
-
try:
|
| 914 |
-
source_page = int(re.search(r'\d+', source_page).group())
|
| 915 |
-
except:
|
| 916 |
-
source_page = 1
|
| 917 |
-
q["source_page"] = min(max(source_page, 1), max_page)
|
| 918 |
-
|
| 919 |
-
q.setdefault("explanation", {"correct_reason": "This is the correct answer."})
|
| 920 |
-
q.setdefault("difficulty", "medium")
|
| 921 |
-
q.setdefault("bloom_level", "Understand")
|
| 922 |
-
q.setdefault("concept_tag", "General")
|
| 923 |
-
q.setdefault("source_quote", "See document for reference")
|
| 924 |
-
|
| 925 |
-
validated.append(q)
|
| 926 |
-
|
| 927 |
-
return validated
|
| 928 |
-
|
| 929 |
-
# ============================================================
|
| 930 |
-
# PDF Generation
|
| 931 |
-
# ============================================================
|
| 932 |
-
def create_exam_pdf(questions: List[Dict], language: str) -> str:
|
| 933 |
-
"""Create exam PDF (questions only)"""
|
| 934 |
-
|
| 935 |
-
lang = LANG_TEXT[language]
|
| 936 |
-
|
| 937 |
-
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
| 938 |
-
doc = SimpleDocTemplate(temp_file.name, pagesize=A4,
|
| 939 |
-
rightMargin=50, leftMargin=50,
|
| 940 |
-
topMargin=50, bottomMargin=50)
|
| 941 |
-
|
| 942 |
-
styles = getSampleStyleSheet()
|
| 943 |
-
font_name = KOREAN_FONT if KOREAN_FONT != 'Helvetica' else 'Helvetica'
|
| 944 |
-
|
| 945 |
-
title_style = ParagraphStyle(
|
| 946 |
-
'CustomTitle',
|
| 947 |
-
parent=styles['Title'],
|
| 948 |
-
fontSize=24,
|
| 949 |
-
spaceAfter=30,
|
| 950 |
-
textColor=colors.HexColor('#1F2937'),
|
| 951 |
-
alignment=1,
|
| 952 |
-
fontName=font_name
|
| 953 |
-
)
|
| 954 |
-
|
| 955 |
-
question_style = ParagraphStyle(
|
| 956 |
-
'Question',
|
| 957 |
-
parent=styles['Normal'],
|
| 958 |
-
fontSize=12,
|
| 959 |
-
spaceAfter=10,
|
| 960 |
-
textColor=colors.HexColor('#1F2937'),
|
| 961 |
-
fontName=font_name
|
| 962 |
-
)
|
| 963 |
-
|
| 964 |
-
option_style = ParagraphStyle(
|
| 965 |
-
'Option',
|
| 966 |
-
parent=styles['Normal'],
|
| 967 |
-
fontSize=11,
|
| 968 |
-
leftIndent=20,
|
| 969 |
-
spaceAfter=5,
|
| 970 |
-
textColor=colors.HexColor('#374151'),
|
| 971 |
-
fontName=font_name
|
| 972 |
-
)
|
| 973 |
-
|
| 974 |
-
normal_korean = ParagraphStyle(
|
| 975 |
-
'NormalKorean',
|
| 976 |
-
parent=styles['Normal'],
|
| 977 |
-
fontName=font_name
|
| 978 |
-
)
|
| 979 |
-
|
| 980 |
-
meta_style = ParagraphStyle(
|
| 981 |
-
'Meta',
|
| 982 |
-
parent=styles['Normal'],
|
| 983 |
-
fontSize=9,
|
| 984 |
-
textColor=colors.HexColor('#6B7280'),
|
| 985 |
-
fontName=font_name
|
| 986 |
-
)
|
| 987 |
-
|
| 988 |
-
story = []
|
| 989 |
-
|
| 990 |
-
story.append(Paragraph(f"{lang['pdf_exam_title']}", title_style))
|
| 991 |
-
story.append(Paragraph(f"{lang['pdf_date']}: {datetime.now().strftime('%Y-%m-%d')}", normal_korean))
|
| 992 |
-
story.append(Paragraph(f"{lang['pdf_total_q']}: {len(questions)}", normal_korean))
|
| 993 |
-
story.append(Spacer(1, 30))
|
| 994 |
-
|
| 995 |
-
for i, q in enumerate(questions, 1):
|
| 996 |
-
q_text = f"<b>Q{i}.</b> {q['question']}"
|
| 997 |
-
story.append(Paragraph(q_text, question_style))
|
| 998 |
-
|
| 999 |
-
meta = f"[{q.get('difficulty', 'N/A').upper()}] | Bloom: {q.get('bloom_level', 'N/A')} | {q.get('concept_tag', 'N/A')}"
|
| 1000 |
-
story.append(Paragraph(meta, meta_style))
|
| 1001 |
-
story.append(Spacer(1, 10))
|
| 1002 |
-
|
| 1003 |
-
for opt in q['options']:
|
| 1004 |
-
story.append(Paragraph(f"- {opt}", option_style))
|
| 1005 |
-
|
| 1006 |
-
story.append(Spacer(1, 20))
|
| 1007 |
-
|
| 1008 |
-
doc.build(story)
|
| 1009 |
-
return temp_file.name
|
| 1010 |
-
|
| 1011 |
-
def create_answer_pdf(questions: List[Dict], language: str) -> str:
|
| 1012 |
-
"""Create answer key PDF with explanations"""
|
| 1013 |
-
|
| 1014 |
-
lang = LANG_TEXT[language]
|
| 1015 |
-
|
| 1016 |
-
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
| 1017 |
-
doc = SimpleDocTemplate(temp_file.name, pagesize=A4,
|
| 1018 |
-
rightMargin=50, leftMargin=50,
|
| 1019 |
-
topMargin=50, bottomMargin=50)
|
| 1020 |
-
|
| 1021 |
-
styles = getSampleStyleSheet()
|
| 1022 |
-
font_name = KOREAN_FONT if KOREAN_FONT != 'Helvetica' else 'Helvetica'
|
| 1023 |
-
|
| 1024 |
-
title_style = ParagraphStyle(
|
| 1025 |
-
'CustomTitle',
|
| 1026 |
-
parent=styles['Title'],
|
| 1027 |
-
fontSize=24,
|
| 1028 |
-
spaceAfter=30,
|
| 1029 |
-
textColor=colors.HexColor('#1F2937'),
|
| 1030 |
-
alignment=1,
|
| 1031 |
-
fontName=font_name
|
| 1032 |
-
)
|
| 1033 |
-
|
| 1034 |
-
question_style = ParagraphStyle(
|
| 1035 |
-
'Question',
|
| 1036 |
-
parent=styles['Normal'],
|
| 1037 |
-
fontSize=11,
|
| 1038 |
-
spaceAfter=8,
|
| 1039 |
-
textColor=colors.HexColor('#1F2937'),
|
| 1040 |
-
fontName=font_name
|
| 1041 |
-
)
|
| 1042 |
-
|
| 1043 |
-
answer_style = ParagraphStyle(
|
| 1044 |
-
'Answer',
|
| 1045 |
-
parent=styles['Normal'],
|
| 1046 |
-
fontSize=11,
|
| 1047 |
-
leftIndent=15,
|
| 1048 |
-
textColor=colors.HexColor('#059669'),
|
| 1049 |
-
fontName=font_name
|
| 1050 |
-
)
|
| 1051 |
-
|
| 1052 |
-
explanation_style = ParagraphStyle(
|
| 1053 |
-
'Explanation',
|
| 1054 |
-
parent=styles['Normal'],
|
| 1055 |
-
fontSize=10,
|
| 1056 |
-
leftIndent=15,
|
| 1057 |
-
spaceAfter=5,
|
| 1058 |
-
textColor=colors.HexColor('#374151'),
|
| 1059 |
-
fontName=font_name
|
| 1060 |
-
)
|
| 1061 |
-
|
| 1062 |
-
wrong_style = ParagraphStyle(
|
| 1063 |
-
'Wrong',
|
| 1064 |
-
parent=styles['Normal'],
|
| 1065 |
-
fontSize=9,
|
| 1066 |
-
leftIndent=25,
|
| 1067 |
-
textColor=colors.HexColor('#DC2626'),
|
| 1068 |
-
fontName=font_name
|
| 1069 |
-
)
|
| 1070 |
-
|
| 1071 |
-
normal_korean = ParagraphStyle(
|
| 1072 |
-
'NormalKorean',
|
| 1073 |
-
parent=styles['Normal'],
|
| 1074 |
-
fontName=font_name
|
| 1075 |
-
)
|
| 1076 |
-
|
| 1077 |
-
heading_korean = ParagraphStyle(
|
| 1078 |
-
'HeadingKorean',
|
| 1079 |
-
parent=styles['Heading2'],
|
| 1080 |
-
fontName=font_name
|
| 1081 |
-
)
|
| 1082 |
-
|
| 1083 |
-
story = []
|
| 1084 |
-
|
| 1085 |
-
story.append(Paragraph(f"{lang['pdf_answer_title']}", title_style))
|
| 1086 |
-
story.append(Paragraph(f"{lang['pdf_date']}: {datetime.now().strftime('%Y-%m-%d')}", normal_korean))
|
| 1087 |
-
story.append(Spacer(1, 20))
|
| 1088 |
-
|
| 1089 |
-
story.append(Paragraph(f"<b>{lang['pdf_quick_summary']}</b>", heading_korean))
|
| 1090 |
-
|
| 1091 |
-
answer_summary = []
|
| 1092 |
-
for i, q in enumerate(questions, 1):
|
| 1093 |
-
answer_summary.append(f"Q{i}: {q.get('correct_label', 'A')}")
|
| 1094 |
-
|
| 1095 |
-
rows = []
|
| 1096 |
-
for i in range(0, len(answer_summary), 5):
|
| 1097 |
-
rows.append(answer_summary[i:i+5])
|
| 1098 |
-
|
| 1099 |
-
if rows:
|
| 1100 |
-
while len(rows[-1]) < 5:
|
| 1101 |
-
rows[-1].append("")
|
| 1102 |
-
|
| 1103 |
-
table = Table(rows, colWidths=[80]*5)
|
| 1104 |
-
table.setStyle(TableStyle([
|
| 1105 |
-
('GRID', (0, 0), (-1, -1), 1, colors.HexColor('#D1D5DB')),
|
| 1106 |
-
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#F3F4F6')),
|
| 1107 |
-
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#1F2937')),
|
| 1108 |
-
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
| 1109 |
-
('FONTNAME', (0, 0), (-1, -1), font_name),
|
| 1110 |
-
('FONTSIZE', (0, 0), (-1, -1), 10),
|
| 1111 |
-
('PADDING', (0, 0), (-1, -1), 8),
|
| 1112 |
-
]))
|
| 1113 |
-
story.append(table)
|
| 1114 |
-
|
| 1115 |
-
story.append(Spacer(1, 30))
|
| 1116 |
-
story.append(Paragraph(f"<b>{lang['pdf_detailed']}</b>", heading_korean))
|
| 1117 |
-
story.append(Spacer(1, 15))
|
| 1118 |
-
|
| 1119 |
-
for i, q in enumerate(questions, 1):
|
| 1120 |
-
q_text = f"<b>Q{i}.</b> {q['question']}"
|
| 1121 |
-
story.append(Paragraph(q_text, question_style))
|
| 1122 |
-
|
| 1123 |
-
story.append(Paragraph(f"{lang['pdf_correct_answer']}: {q['answer']}", answer_style))
|
| 1124 |
-
|
| 1125 |
-
story.append(Paragraph(f"{lang['pdf_source']} {q.get('source_page', 'N/A')}", explanation_style))
|
| 1126 |
-
|
| 1127 |
-
if q.get('source_quote'):
|
| 1128 |
-
quote_text = q['source_quote'][:200] + "..." if len(q.get('source_quote', '')) > 200 else q.get('source_quote', '')
|
| 1129 |
-
story.append(Paragraph(f"{lang['pdf_reference']}: \"{quote_text}\"", explanation_style))
|
| 1130 |
-
|
| 1131 |
-
explanation = q.get('explanation', {})
|
| 1132 |
-
if explanation.get('correct_reason'):
|
| 1133 |
-
story.append(Paragraph(f"{lang['pdf_why_correct']}: {explanation['correct_reason']}", explanation_style))
|
| 1134 |
-
|
| 1135 |
-
story.append(Spacer(1, 5))
|
| 1136 |
-
story.append(Paragraph(f"<i>{lang['pdf_why_wrong']}</i>", explanation_style))
|
| 1137 |
-
|
| 1138 |
-
for key in ['wrong_A', 'wrong_B', 'wrong_C', 'wrong_D', 'wrong_E']:
|
| 1139 |
-
if explanation.get(key) and explanation[key].strip():
|
| 1140 |
-
letter = key.split('_')[1]
|
| 1141 |
-
story.append(Paragraph(f"X {letter}: {explanation[key]}", wrong_style))
|
| 1142 |
-
|
| 1143 |
-
story.append(Spacer(1, 20))
|
| 1144 |
-
|
| 1145 |
-
doc.build(story)
|
| 1146 |
-
return temp_file.name
|
| 1147 |
-
|
| 1148 |
-
# ============================================================
|
| 1149 |
-
# Main Processing Function
|
| 1150 |
-
# ============================================================
|
| 1151 |
-
def process_exam(
|
| 1152 |
-
doc_file,
|
| 1153 |
-
num_questions: int,
|
| 1154 |
-
num_choices: int,
|
| 1155 |
-
difficulty: str,
|
| 1156 |
-
language: str,
|
| 1157 |
-
progress=gr.Progress()
|
| 1158 |
-
) -> Tuple[str, str, str, str, str, List[Dict]]:
|
| 1159 |
-
"""Main function to process document and generate exam"""
|
| 1160 |
-
|
| 1161 |
-
lang = LANG_TEXT[language]
|
| 1162 |
-
|
| 1163 |
-
if doc_file is None:
|
| 1164 |
-
return lang["error_no_file"], "", None, None, "", []
|
| 1165 |
-
|
| 1166 |
-
try:
|
| 1167 |
-
file_path = doc_file.name
|
| 1168 |
-
file_type = get_file_type(file_path)
|
| 1169 |
-
file_type_display = file_type.upper()
|
| 1170 |
-
|
| 1171 |
-
print(f"\n{'='*60}")
|
| 1172 |
-
print(f"📄 Processing {file_type_display} file: {os.path.basename(file_path)}")
|
| 1173 |
-
print(f"{'='*60}")
|
| 1174 |
-
|
| 1175 |
-
progress(0.1, desc=f"{lang['phase_extract']} ({file_type_display})")
|
| 1176 |
-
page_contents = extract_text_with_pages(file_path)
|
| 1177 |
-
|
| 1178 |
-
if not page_contents:
|
| 1179 |
-
return lang["error_no_text"], "", None, None, "", []
|
| 1180 |
-
|
| 1181 |
-
total_chars = sum(len(p.text) for p in page_contents)
|
| 1182 |
-
print(f"✅ 추출 완료: {len(page_contents)} 페이지, {total_chars} 글자")
|
| 1183 |
-
|
| 1184 |
-
progress(0.3, desc=lang["phase_analyze"])
|
| 1185 |
-
analysis = analyze_concepts(page_contents, language)
|
| 1186 |
-
|
| 1187 |
-
progress(0.5, desc=lang["phase_generate"])
|
| 1188 |
-
questions = generate_questions(
|
| 1189 |
-
page_contents, analysis, num_questions, num_choices, difficulty, language
|
| 1190 |
-
)
|
| 1191 |
-
|
| 1192 |
-
progress(0.7, desc=lang["phase_validate"])
|
| 1193 |
-
validated = validate_questions(questions, page_contents, num_choices)
|
| 1194 |
-
|
| 1195 |
-
if not validated:
|
| 1196 |
-
return lang["error_no_questions"], "", None, None, "", []
|
| 1197 |
-
|
| 1198 |
-
progress(0.85, desc=lang["phase_pdf"])
|
| 1199 |
-
exam_pdf = create_exam_pdf(validated, language)
|
| 1200 |
-
answer_pdf = create_answer_pdf(validated, language)
|
| 1201 |
-
|
| 1202 |
-
progress(0.95, desc=lang["phase_final"])
|
| 1203 |
-
|
| 1204 |
-
answer_distribution = {}
|
| 1205 |
-
for q in validated:
|
| 1206 |
-
label = q.get('correct_label', 'A')
|
| 1207 |
-
answer_distribution[label] = answer_distribution.get(label, 0) + 1
|
| 1208 |
-
|
| 1209 |
-
dist_text = " | ".join([f"{k}: {v}" for k, v in sorted(answer_distribution.items())])
|
| 1210 |
-
|
| 1211 |
-
info_log = f"""{lang['success_title']}
|
| 1212 |
-
{'=' * 50}
|
| 1213 |
-
📁 File Type: {file_type_display}
|
| 1214 |
-
{'=' * 50}
|
| 1215 |
-
{lang['stats_title']}
|
| 1216 |
-
• {lang['total_questions']}: {len(validated)}
|
| 1217 |
-
• {lang['choices_per_q']}: {num_choices}
|
| 1218 |
-
• {lang['difficulty']}: {difficulty}
|
| 1219 |
-
• {lang['doc_pages']}: {len(page_contents)}
|
| 1220 |
-
• Total Characters: {total_chars:,}
|
| 1221 |
-
{'=' * 50}
|
| 1222 |
-
{lang['answer_dist']}
|
| 1223 |
-
{dist_text}
|
| 1224 |
-
{'=' * 50}
|
| 1225 |
-
{lang['topic']}: {analysis.get('main_topic', 'N/A')}
|
| 1226 |
-
{lang['doc_level']}: {analysis.get('difficulty_assessment', 'N/A')}
|
| 1227 |
-
{'=' * 50}
|
| 1228 |
-
{lang['pdf_ready']}
|
| 1229 |
-
|
| 1230 |
-
🎯 Now go to "Take Exam" tab to start your interactive exam!"""
|
| 1231 |
-
|
| 1232 |
-
display_text = format_questions_display(validated, language)
|
| 1233 |
-
|
| 1234 |
-
progress(1.0, desc=lang["phase_complete"])
|
| 1235 |
-
|
| 1236 |
-
return info_log, display_text, exam_pdf, answer_pdf, "", validated
|
| 1237 |
-
|
| 1238 |
-
except Exception as e:
|
| 1239 |
-
import traceback
|
| 1240 |
-
traceback.print_exc()
|
| 1241 |
-
return f"❌ Error: {str(e)}", "", None, None, "", []
|
| 1242 |
-
|
| 1243 |
-
def format_questions_display(questions: List[Dict], language: str) -> str:
|
| 1244 |
-
"""Format questions for text display"""
|
| 1245 |
-
|
| 1246 |
-
lang = LANG_TEXT[language]
|
| 1247 |
-
|
| 1248 |
-
lines = []
|
| 1249 |
-
lines.append("=" * 60)
|
| 1250 |
-
lines.append(lang["preview_header"])
|
| 1251 |
-
lines.append("=" * 60)
|
| 1252 |
-
|
| 1253 |
-
for i, q in enumerate(questions, 1):
|
| 1254 |
-
lines.append(f"\n🔹 Q{i}. {q['question']}")
|
| 1255 |
-
lines.append(f" [{q.get('difficulty', 'N/A').upper()}] | {q.get('bloom_level', 'N/A')} | p.{q.get('source_page', '?')}")
|
| 1256 |
-
lines.append("")
|
| 1257 |
-
|
| 1258 |
-
for opt in q['options']:
|
| 1259 |
-
if opt == q['answer']:
|
| 1260 |
-
lines.append(f" ✓ {opt} {lang['correct_mark']}")
|
| 1261 |
-
else:
|
| 1262 |
-
lines.append(f" ○ {opt}")
|
| 1263 |
-
|
| 1264 |
-
lines.append(f"\n 💡 {q.get('explanation', {}).get('correct_reason', 'N/A')[:150]}...")
|
| 1265 |
-
lines.append("-" * 60)
|
| 1266 |
-
|
| 1267 |
-
return "\n".join(lines)
|
| 1268 |
-
|
| 1269 |
-
# ============================================================
|
| 1270 |
-
# Grade Calculation
|
| 1271 |
-
# ============================================================
|
| 1272 |
-
def calculate_grade(percentage: float) -> str:
|
| 1273 |
-
"""Calculate letter grade based on percentage"""
|
| 1274 |
-
if percentage >= 90:
|
| 1275 |
-
return "A+ 🏆"
|
| 1276 |
-
elif percentage >= 85:
|
| 1277 |
-
return "A 🌟"
|
| 1278 |
-
elif percentage >= 80:
|
| 1279 |
-
return "B+ ⭐"
|
| 1280 |
-
elif percentage >= 75:
|
| 1281 |
-
return "B 👍"
|
| 1282 |
-
elif percentage >= 70:
|
| 1283 |
-
return "C+ 📖"
|
| 1284 |
-
elif percentage >= 65:
|
| 1285 |
-
return "C 📚"
|
| 1286 |
-
elif percentage >= 60:
|
| 1287 |
-
return "D 📝"
|
| 1288 |
-
else:
|
| 1289 |
-
return "F 💪 Keep trying!"
|
| 1290 |
-
|
| 1291 |
-
# ============================================================
|
| 1292 |
-
# CSS Styles
|
| 1293 |
-
# ============================================================
|
| 1294 |
-
css = """
|
| 1295 |
-
@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
|
| 1296 |
-
|
| 1297 |
-
.gradio-container {
|
| 1298 |
-
background-color: #FEF9C3 !important;
|
| 1299 |
-
background-image: radial-gradient(#1F2937 1px, transparent 1px) !important;
|
| 1300 |
-
background-size: 20px 20px !important;
|
| 1301 |
-
min-height: 100vh !important;
|
| 1302 |
-
font-family: 'Comic Neue', cursive, sans-serif !important;
|
| 1303 |
-
}
|
| 1304 |
-
|
| 1305 |
-
.huggingface-space-header, #space-header, .space-header, [class*="space-header"] {
|
| 1306 |
-
display: none !important;
|
| 1307 |
-
}
|
| 1308 |
-
|
| 1309 |
-
footer { display: none !important; }
|
| 1310 |
-
|
| 1311 |
-
#col-container { max-width: 1200px; margin: 0 auto; }
|
| 1312 |
-
|
| 1313 |
-
.tabs { margin: 20px 0 !important; }
|
| 1314 |
-
|
| 1315 |
-
.tab-nav {
|
| 1316 |
-
display: flex !important;
|
| 1317 |
-
gap: 10px !important;
|
| 1318 |
-
justify-content: center !important;
|
| 1319 |
-
margin-bottom: 25px !important;
|
| 1320 |
-
padding: 15px !important;
|
| 1321 |
-
background: linear-gradient(135deg, #1F2937 0%, #374151 100%) !important;
|
| 1322 |
-
border-radius: 16px !important;
|
| 1323 |
-
box-shadow: 8px 8px 0px #000000 !important;
|
| 1324 |
-
}
|
| 1325 |
-
|
| 1326 |
-
.tab-nav button {
|
| 1327 |
-
font-family: 'Bangers', cursive !important;
|
| 1328 |
-
font-size: 1.8rem !important;
|
| 1329 |
-
letter-spacing: 3px !important;
|
| 1330 |
-
padding: 20px 40px !important;
|
| 1331 |
-
border: 4px solid #1F2937 !important;
|
| 1332 |
-
border-radius: 12px !important;
|
| 1333 |
-
cursor: pointer !important;
|
| 1334 |
-
transition: all 0.3s ease !important;
|
| 1335 |
-
box-shadow: 5px 5px 0px #000000 !important;
|
| 1336 |
-
}
|
| 1337 |
-
|
| 1338 |
-
.tab-nav button:first-child {
|
| 1339 |
-
background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%) !important;
|
| 1340 |
-
color: #FFFFFF !important;
|
| 1341 |
-
text-shadow: 2px 2px 0px #1F2937 !important;
|
| 1342 |
-
}
|
| 1343 |
-
|
| 1344 |
-
.tab-nav button:last-child {
|
| 1345 |
-
background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%) !important;
|
| 1346 |
-
color: #FFFFFF !important;
|
| 1347 |
-
text-shadow: 2px 2px 0px #1F2937 !important;
|
| 1348 |
-
animation: pulse-glow 2s infinite !important;
|
| 1349 |
-
}
|
| 1350 |
-
|
| 1351 |
-
.tab-nav button:hover {
|
| 1352 |
-
transform: translate(-4px, -4px) !important;
|
| 1353 |
-
box-shadow: 9px 9px 0px #000000 !important;
|
| 1354 |
-
}
|
| 1355 |
-
|
| 1356 |
-
.tab-nav button.selected {
|
| 1357 |
-
transform: translate(2px, 2px) !important;
|
| 1358 |
-
box-shadow: 3px 3px 0px #000000 !important;
|
| 1359 |
-
border-color: #FACC15 !important;
|
| 1360 |
-
}
|
| 1361 |
-
|
| 1362 |
-
@keyframes pulse-glow {
|
| 1363 |
-
0%, 100% { box-shadow: 5px 5px 0px #000000, 0 0 20px #EF4444 !important; }
|
| 1364 |
-
50% { box-shadow: 5px 5px 0px #000000, 0 0 40px #EF4444, 0 0 60px #EF4444 !important; }
|
| 1365 |
-
}
|
| 1366 |
-
|
| 1367 |
-
.tabitem {
|
| 1368 |
-
background: #FFFFFF !important;
|
| 1369 |
-
border: 4px solid #1F2937 !important;
|
| 1370 |
-
border-radius: 16px !important;
|
| 1371 |
-
padding: 25px !important;
|
| 1372 |
-
box-shadow: 8px 8px 0px #1F2937 !important;
|
| 1373 |
-
}
|
| 1374 |
-
|
| 1375 |
-
.header-text h1 {
|
| 1376 |
-
font-family: 'Bangers', cursive !important;
|
| 1377 |
-
color: #1F2937 !important;
|
| 1378 |
-
font-size: 3.5rem !important;
|
| 1379 |
-
text-align: center !important;
|
| 1380 |
-
margin-bottom: 0.5rem !important;
|
| 1381 |
-
text-shadow: 4px 4px 0px #FACC15, 6px 6px 0px #1F2937 !important;
|
| 1382 |
-
letter-spacing: 3px !important;
|
| 1383 |
-
}
|
| 1384 |
-
|
| 1385 |
-
.subtitle {
|
| 1386 |
-
text-align: center !important;
|
| 1387 |
-
font-family: 'Comic Neue', cursive !important;
|
| 1388 |
-
font-size: 1.2rem !important;
|
| 1389 |
-
color: #1F2937 !important;
|
| 1390 |
-
margin-bottom: 1.5rem !important;
|
| 1391 |
-
font-weight: 700 !important;
|
| 1392 |
-
}
|
| 1393 |
-
|
| 1394 |
-
.gr-panel, .gr-box, .gr-form, .block, .gr-group {
|
| 1395 |
-
background: #FFFFFF !important;
|
| 1396 |
-
border: 3px solid #1F2937 !important;
|
| 1397 |
-
border-radius: 8px !important;
|
| 1398 |
-
box-shadow: 6px 6px 0px #1F2937 !important;
|
| 1399 |
-
transition: all 0.2s ease !important;
|
| 1400 |
-
}
|
| 1401 |
-
|
| 1402 |
-
.gr-panel:hover, .block:hover {
|
| 1403 |
-
transform: translate(-2px, -2px) !important;
|
| 1404 |
-
box-shadow: 8px 8px 0px #1F2937 !important;
|
| 1405 |
-
}
|
| 1406 |
-
|
| 1407 |
-
textarea, input[type="text"], input[type="number"] {
|
| 1408 |
-
background: #FFFFFF !important;
|
| 1409 |
-
border: 3px solid #1F2937 !important;
|
| 1410 |
-
border-radius: 8px !important;
|
| 1411 |
-
color: #1F2937 !important;
|
| 1412 |
-
font-family: 'Comic Neue', cursive !important;
|
| 1413 |
-
font-size: 1rem !important;
|
| 1414 |
-
font-weight: 700 !important;
|
| 1415 |
-
}
|
| 1416 |
-
|
| 1417 |
-
.gr-button-primary, button.primary {
|
| 1418 |
-
background: #3B82F6 !important;
|
| 1419 |
-
border: 3px solid #1F2937 !important;
|
| 1420 |
-
border-radius: 8px !important;
|
| 1421 |
-
color: #FFFFFF !important;
|
| 1422 |
-
font-family: 'Bangers', cursive !important;
|
| 1423 |
-
font-size: 1.3rem !important;
|
| 1424 |
-
letter-spacing: 2px !important;
|
| 1425 |
-
padding: 14px 28px !important;
|
| 1426 |
-
box-shadow: 5px 5px 0px #1F2937 !important;
|
| 1427 |
-
text-shadow: 1px 1px 0px #1F2937 !important;
|
| 1428 |
-
}
|
| 1429 |
-
|
| 1430 |
-
.gr-button-primary:hover {
|
| 1431 |
-
background: #2563EB !important;
|
| 1432 |
-
transform: translate(-2px, -2px) !important;
|
| 1433 |
-
box-shadow: 7px 7px 0px #1F2937 !important;
|
| 1434 |
-
}
|
| 1435 |
-
|
| 1436 |
-
.gr-button-secondary, button.secondary {
|
| 1437 |
-
background: #10B981 !important;
|
| 1438 |
-
border: 3px solid #1F2937 !important;
|
| 1439 |
-
border-radius: 8px !important;
|
| 1440 |
-
color: #FFFFFF !important;
|
| 1441 |
-
font-family: 'Bangers', cursive !important;
|
| 1442 |
-
font-size: 1.1rem !important;
|
| 1443 |
-
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 1444 |
-
}
|
| 1445 |
-
|
| 1446 |
-
.info-log textarea {
|
| 1447 |
-
background: #1F2937 !important;
|
| 1448 |
-
color: #10B981 !important;
|
| 1449 |
-
font-family: 'Courier New', monospace !important;
|
| 1450 |
-
font-size: 0.9rem !important;
|
| 1451 |
-
border: 3px solid #10B981 !important;
|
| 1452 |
-
border-radius: 8px !important;
|
| 1453 |
-
box-shadow: 4px 4px 0px #10B981 !important;
|
| 1454 |
-
}
|
| 1455 |
-
|
| 1456 |
-
.file-upload {
|
| 1457 |
-
border: 4px dashed #3B82F6 !important;
|
| 1458 |
-
border-radius: 12px !important;
|
| 1459 |
-
background: #EFF6FF !important;
|
| 1460 |
-
}
|
| 1461 |
-
|
| 1462 |
-
.file-upload:hover {
|
| 1463 |
-
border-color: #EF4444 !important;
|
| 1464 |
-
background: #FEF2F2 !important;
|
| 1465 |
-
}
|
| 1466 |
-
|
| 1467 |
-
.hwp-support-badge {
|
| 1468 |
-
background: linear-gradient(135deg, #10B981 0%, #059669 100%) !important;
|
| 1469 |
-
color: #FFFFFF !important;
|
| 1470 |
-
padding: 8px 16px !important;
|
| 1471 |
-
border-radius: 8px !important;
|
| 1472 |
-
font-family: 'Comic Neue', cursive !important;
|
| 1473 |
-
font-weight: 700 !important;
|
| 1474 |
-
display: inline-block !important;
|
| 1475 |
-
margin: 5px !important;
|
| 1476 |
-
border: 2px solid #1F2937 !important;
|
| 1477 |
-
box-shadow: 3px 3px 0px #1F2937 !important;
|
| 1478 |
-
}
|
| 1479 |
-
|
| 1480 |
-
.exam-question-box {
|
| 1481 |
-
background: linear-gradient(135deg, #FFFEF7 0%, #FEF9C3 100%) !important;
|
| 1482 |
-
border: 4px solid #3B82F6 !important;
|
| 1483 |
-
border-radius: 16px !important;
|
| 1484 |
-
padding: 25px !important;
|
| 1485 |
-
box-shadow: 8px 8px 0px #1F2937 !important;
|
| 1486 |
-
margin: 15px 0 !important;
|
| 1487 |
-
}
|
| 1488 |
-
|
| 1489 |
-
.exam-progress {
|
| 1490 |
-
background: #FFFFFF !important;
|
| 1491 |
-
border: 3px solid #1F2937 !important;
|
| 1492 |
-
border-radius: 12px !important;
|
| 1493 |
-
padding: 15px 20px !important;
|
| 1494 |
-
text-align: center !important;
|
| 1495 |
-
font-family: 'Bangers', cursive !important;
|
| 1496 |
-
font-size: 1.4rem !important;
|
| 1497 |
-
color: #3B82F6 !important;
|
| 1498 |
-
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 1499 |
-
}
|
| 1500 |
-
|
| 1501 |
-
.exam-feedback-correct {
|
| 1502 |
-
background: linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%) !important;
|
| 1503 |
-
border: 4px solid #10B981 !important;
|
| 1504 |
-
border-radius: 12px !important;
|
| 1505 |
-
padding: 20px !important;
|
| 1506 |
-
box-shadow: 6px 6px 0px #1F2937 !important;
|
| 1507 |
-
}
|
| 1508 |
-
|
| 1509 |
-
.exam-feedback-incorrect {
|
| 1510 |
-
background: linear-gradient(135deg, #FEE2E2 0%, #FECACA 100%) !important;
|
| 1511 |
-
border: 4px solid #EF4444 !important;
|
| 1512 |
-
border-radius: 12px !important;
|
| 1513 |
-
padding: 20px !important;
|
| 1514 |
-
box-shadow: 6px 6px 0px #1F2937 !important;
|
| 1515 |
-
}
|
| 1516 |
-
|
| 1517 |
-
.exam-results-box {
|
| 1518 |
-
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%) !important;
|
| 1519 |
-
border: 4px solid #3B82F6 !important;
|
| 1520 |
-
border-radius: 16px !important;
|
| 1521 |
-
padding: 30px !important;
|
| 1522 |
-
box-shadow: 8px 8px 0px #1F2937 !important;
|
| 1523 |
-
text-align: center !important;
|
| 1524 |
-
}
|
| 1525 |
-
|
| 1526 |
-
.exam-score-display {
|
| 1527 |
-
font-family: 'Bangers', cursive !important;
|
| 1528 |
-
font-size: 4rem !important;
|
| 1529 |
-
color: #1F2937 !important;
|
| 1530 |
-
text-shadow: 4px 4px 0px #FACC15 !important;
|
| 1531 |
-
margin: 20px 0 !important;
|
| 1532 |
-
}
|
| 1533 |
-
|
| 1534 |
-
.exam-grade-display {
|
| 1535 |
-
font-family: 'Bangers', cursive !important;
|
| 1536 |
-
font-size: 2.5rem !important;
|
| 1537 |
-
color: #3B82F6 !important;
|
| 1538 |
-
text-shadow: 2px 2px 0px #1F2937 !important;
|
| 1539 |
-
}
|
| 1540 |
-
|
| 1541 |
-
.start-exam-mega-btn {
|
| 1542 |
-
background: linear-gradient(135deg, #10B981 0%, #059669 100%) !important;
|
| 1543 |
-
border: 5px solid #1F2937 !important;
|
| 1544 |
-
border-radius: 16px !important;
|
| 1545 |
-
color: #FFFFFF !important;
|
| 1546 |
-
font-family: 'Bangers', cursive !important;
|
| 1547 |
-
font-size: 2rem !important;
|
| 1548 |
-
letter-spacing: 4px !important;
|
| 1549 |
-
padding: 25px 50px !important;
|
| 1550 |
-
box-shadow: 8px 8px 0px #1F2937 !important;
|
| 1551 |
-
text-shadow: 3px 3px 0px #1F2937 !important;
|
| 1552 |
-
animation: mega-pulse 1.5s infinite !important;
|
| 1553 |
-
width: 100% !important;
|
| 1554 |
-
margin: 20px 0 !important;
|
| 1555 |
-
}
|
| 1556 |
-
|
| 1557 |
-
@keyframes mega-pulse {
|
| 1558 |
-
0%, 100% { box-shadow: 8px 8px 0px #1F2937, 0 0 20px #10B981 !important; }
|
| 1559 |
-
50% { box-shadow: 8px 8px 0px #1F2937, 0 0 40px #10B981, 0 0 60px #10B981 !important; }
|
| 1560 |
-
}
|
| 1561 |
-
|
| 1562 |
-
.nav-btn {
|
| 1563 |
-
background: #6366F1 !important;
|
| 1564 |
-
border: 3px solid #1F2937 !important;
|
| 1565 |
-
color: #FFFFFF !important;
|
| 1566 |
-
font-family: 'Bangers', cursive !important;
|
| 1567 |
-
font-size: 1.2rem !important;
|
| 1568 |
-
padding: 12px 24px !important;
|
| 1569 |
-
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 1570 |
-
}
|
| 1571 |
-
|
| 1572 |
-
.finish-btn {
|
| 1573 |
-
background: #EF4444 !important;
|
| 1574 |
-
border: 3px solid #1F2937 !important;
|
| 1575 |
-
color: #FFFFFF !important;
|
| 1576 |
-
font-family: 'Bangers', cursive !important;
|
| 1577 |
-
font-size: 1.3rem !important;
|
| 1578 |
-
padding: 15px 30px !important;
|
| 1579 |
-
box-shadow: 5px 5px 0px #1F2937 !important;
|
| 1580 |
-
}
|
| 1581 |
-
|
| 1582 |
-
.restart-btn {
|
| 1583 |
-
background: #F59E0B !important;
|
| 1584 |
-
border: 3px solid #1F2937 !important;
|
| 1585 |
-
color: #FFFFFF !important;
|
| 1586 |
-
font-family: 'Bangers', cursive !important;
|
| 1587 |
-
font-size: 1.2rem !important;
|
| 1588 |
-
box-shadow: 4px 4px 0px #1F2937 !important;
|
| 1589 |
-
}
|
| 1590 |
-
"""
|
| 1591 |
-
|
| 1592 |
-
# ============================================================
|
| 1593 |
-
# Gradio Interface
|
| 1594 |
-
# ============================================================
|
| 1595 |
-
with gr.Blocks(fill_height=True, css=css, title="PDF/HWP TO EXAM | SOMA") as demo:
|
| 1596 |
-
|
| 1597 |
-
# State Variables
|
| 1598 |
-
current_lang = gr.State("English")
|
| 1599 |
-
exam_questions_state = gr.State([])
|
| 1600 |
-
current_question_idx = gr.State(0)
|
| 1601 |
-
user_answers_state = gr.State({})
|
| 1602 |
-
exam_started = gr.State(False)
|
| 1603 |
-
exam_finished = gr.State(False)
|
| 1604 |
-
|
| 1605 |
-
# Header
|
| 1606 |
-
gr.HTML("""
|
| 1607 |
-
<div style="text-align: center; margin: 20px 0 10px 0;">
|
| 1608 |
-
<a href="https://www.humangen.ai" target="_blank" style="text-decoration: none;">
|
| 1609 |
-
<img src="https://img.shields.io/static/v1?label=🏠 HOME&message=humangen.ai&color=0000ff&labelColor=ffcc00&style=for-the-badge" alt="HOME">
|
| 1610 |
-
</a>
|
| 1611 |
-
</div>
|
| 1612 |
-
""")
|
| 1613 |
-
|
| 1614 |
-
title_md = gr.Markdown(
|
| 1615 |
-
"""# 📄 PDF/HWP TO EXAM GENERATOR 🎓""",
|
| 1616 |
-
elem_classes="header-text"
|
| 1617 |
-
)
|
| 1618 |
-
|
| 1619 |
-
subtitle_md = gr.Markdown(
|
| 1620 |
-
"""<p class="subtitle">🤖 SOMA AI transforms your PDF/HWP documents into comprehensive exams instantly! 📝</p>""",
|
| 1621 |
-
)
|
| 1622 |
-
|
| 1623 |
-
# Supported formats badge
|
| 1624 |
-
gr.HTML("""
|
| 1625 |
-
<div style="text-align: center; margin: 10px 0 20px 0;">
|
| 1626 |
-
<span class="hwp-support-badge">📄 PDF</span>
|
| 1627 |
-
<span class="hwp-support-badge">📝 HWP</span>
|
| 1628 |
-
<span class="hwp-support-badge">📑 HWPX</span>
|
| 1629 |
-
<span class="hwp-support-badge">🇰🇷 한글 지원</span>
|
| 1630 |
-
</div>
|
| 1631 |
-
""")
|
| 1632 |
-
|
| 1633 |
-
# Main Tabs
|
| 1634 |
-
with gr.Tabs() as main_tabs:
|
| 1635 |
-
|
| 1636 |
-
# TAB 1: GENERATE EXAM
|
| 1637 |
-
with gr.TabItem("📝 STEP 1: Generate Exam", id=0):
|
| 1638 |
-
with gr.Row(equal_height=False):
|
| 1639 |
-
with gr.Column(scale=1, min_width=350):
|
| 1640 |
-
doc_input = gr.File(
|
| 1641 |
-
label="📄 Upload Your Document (PDF/HWP/HWPX)",
|
| 1642 |
-
file_types=[".pdf", ".hwp", ".hwpx"],
|
| 1643 |
-
elem_classes="file-upload"
|
| 1644 |
-
)
|
| 1645 |
-
|
| 1646 |
-
gr.HTML("""
|
| 1647 |
-
<div style="background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
|
| 1648 |
-
border: 2px solid #1F2937; border-radius: 8px; padding: 10px;
|
| 1649 |
-
margin: 10px 0; font-family: 'Comic Neue', cursive; font-weight: 700;">
|
| 1650 |
-
✅ <b>지원 파일 형식:</b><br>
|
| 1651 |
-
• PDF - 모든 PDF 문서<br>
|
| 1652 |
-
• HWP - 한글 2007 이상 (한컴오피스)<br>
|
| 1653 |
-
• HWPX - 한글 2018 이상 (OOXML 형식)
|
| 1654 |
-
</div>
|
| 1655 |
-
""")
|
| 1656 |
-
|
| 1657 |
-
with gr.Group():
|
| 1658 |
-
language_select = gr.Radio(
|
| 1659 |
-
choices=["English", "한국어"],
|
| 1660 |
-
value="English",
|
| 1661 |
-
label="🌐 Language / 언어",
|
| 1662 |
-
info="Select output language"
|
| 1663 |
-
)
|
| 1664 |
-
|
| 1665 |
-
num_questions = gr.Slider(
|
| 1666 |
-
minimum=3,
|
| 1667 |
-
maximum=50,
|
| 1668 |
-
value=10,
|
| 1669 |
-
step=1,
|
| 1670 |
-
label="📊 Number of Questions"
|
| 1671 |
-
)
|
| 1672 |
-
|
| 1673 |
-
num_choices = gr.Radio(
|
| 1674 |
-
choices=[2, 4, 5],
|
| 1675 |
-
value=4,
|
| 1676 |
-
label="🔢 Answer Choices (2/4/5)",
|
| 1677 |
-
info="Select number of options per question"
|
| 1678 |
-
)
|
| 1679 |
-
|
| 1680 |
-
difficulty = gr.Radio(
|
| 1681 |
-
choices=["Mixed", "Easy", "Medium", "Hard"],
|
| 1682 |
-
value="Mixed",
|
| 1683 |
-
label="📈 Difficulty Level"
|
| 1684 |
-
)
|
| 1685 |
-
|
| 1686 |
-
generate_btn = gr.Button(
|
| 1687 |
-
"🚀 GENERATE EXAM! 📝",
|
| 1688 |
-
variant="primary",
|
| 1689 |
-
size="lg"
|
| 1690 |
-
)
|
| 1691 |
-
|
| 1692 |
-
with gr.Accordion("📜 Generation Log", open=True):
|
| 1693 |
-
info_log = gr.Textbox(
|
| 1694 |
-
label="",
|
| 1695 |
-
placeholder="Upload a PDF/HWP file and click generate to see results...",
|
| 1696 |
-
lines=15,
|
| 1697 |
-
max_lines=20,
|
| 1698 |
-
interactive=False,
|
| 1699 |
-
elem_classes="info-log"
|
| 1700 |
-
)
|
| 1701 |
-
|
| 1702 |
-
with gr.Column(scale=1, min_width=350):
|
| 1703 |
-
with gr.Group():
|
| 1704 |
-
download_title = gr.Markdown("### 📥 Download Your Exam PDFs")
|
| 1705 |
-
|
| 1706 |
-
with gr.Row():
|
| 1707 |
-
exam_pdf = gr.File(
|
| 1708 |
-
label="📝 Exam (Questions Only)",
|
| 1709 |
-
elem_classes="download-btn"
|
| 1710 |
-
)
|
| 1711 |
-
answer_pdf = gr.File(
|
| 1712 |
-
label="🔑 Answer Key & Explanations",
|
| 1713 |
-
elem_classes="download-btn"
|
| 1714 |
-
)
|
| 1715 |
-
|
| 1716 |
-
with gr.Accordion("👀 Preview Questions", open=True):
|
| 1717 |
-
preview_text = gr.Textbox(
|
| 1718 |
-
label="",
|
| 1719 |
-
placeholder="Generated questions will appear here...",
|
| 1720 |
-
lines=25,
|
| 1721 |
-
max_lines=40,
|
| 1722 |
-
interactive=False
|
| 1723 |
-
)
|
| 1724 |
-
|
| 1725 |
-
error_output = gr.Textbox(visible=False)
|
| 1726 |
-
|
| 1727 |
-
with gr.Accordion("📖 How to Use / 사용 방법", open=False):
|
| 1728 |
-
gr.Markdown("""
|
| 1729 |
-
### 🎯 Quick Guide / 빠른 가이드:
|
| 1730 |
-
|
| 1731 |
-
**English:**
|
| 1732 |
-
1. **📄 Upload Document**: Upload PDF, HWP, or HWPX file
|
| 1733 |
-
2. **🌐 Select Language**: Choose English or 한국어
|
| 1734 |
-
3. **⚙️ Configure**: Set questions (3-50), choices (2/4/5), difficulty
|
| 1735 |
-
4. **🚀 Generate**: Click generate button
|
| 1736 |
-
5. **📥 Download**: Get Exam PDF and Answer Key PDF
|
| 1737 |
-
6. **🎯 Take Exam**: Go to "Take Exam" tab for interactive exam!
|
| 1738 |
-
|
| 1739 |
-
**한국어:**
|
| 1740 |
-
1. **📄 문서 업로드**: PDF, HWP, HWPX 파일을 업로드하세요
|
| 1741 |
-
2. **🌐 언어 선택**: English 또는 한국어 선택
|
| 1742 |
-
3. **⚙️ 설정**: 문제 수(3-50), 선택지(2/4/5), 난이도 설정
|
| 1743 |
-
4. **🚀 생성**: 생성 버튼 클릭
|
| 1744 |
-
5. **📥 다운로드**: 시험지 PDF와 정답 PDF 다운로드
|
| 1745 |
-
6. **🎯 시험 보기**: "시험 보기" 탭에서 인터랙티브 시험!
|
| 1746 |
-
|
| 1747 |
-
### ✨ Features / 특징:
|
| 1748 |
-
- 📄 **PDF/HWP/HWPX 지원** - 다양한 문서 형식 지원
|
| 1749 |
-
- 🎲 Random answer placement / 랜덤 정답 배치
|
| 1750 |
-
- 📍 Page references / 페이지 참조
|
| 1751 |
-
- 💡 Detailed explanations / 상세 해설
|
| 1752 |
-
- 📊 Bloom's Taxonomy / 블룸 분류
|
| 1753 |
-
- 🎯 Interactive exam taking / 인터랙티브 시험 기능
|
| 1754 |
-
""")
|
| 1755 |
-
|
| 1756 |
-
# TAB 2: TAKE EXAM
|
| 1757 |
-
with gr.TabItem("🎯 STEP 2: Take Exam! 🔥", id=1):
|
| 1758 |
-
|
| 1759 |
-
gr.HTML("""
|
| 1760 |
-
<div style="background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
|
| 1761 |
-
border: 4px solid #1F2937; border-radius: 16px; padding: 25px;
|
| 1762 |
-
margin-bottom: 20px; text-align: center;
|
| 1763 |
-
box-shadow: 8px 8px 0px #1F2937;">
|
| 1764 |
-
<h2 style="font-family: 'Bangers', cursive; font-size: 2.5rem; color: #FFFFFF;
|
| 1765 |
-
text-shadow: 3px 3px 0px #1F2937; margin: 0; letter-spacing: 3px;">
|
| 1766 |
-
🎯 INTERACTIVE EXAM MODE 🎯
|
| 1767 |
-
</h2>
|
| 1768 |
-
<p style="font-family: 'Comic Neue', cursive; font-size: 1.2rem; color: #FEF9C3;
|
| 1769 |
-
font-weight: 700; margin: 10px 0 0 0;">
|
| 1770 |
-
Answer questions → Get instant feedback → See your final score!
|
| 1771 |
-
</p>
|
| 1772 |
-
</div>
|
| 1773 |
-
""")
|
| 1774 |
-
|
| 1775 |
-
exam_status_md = gr.Markdown(
|
| 1776 |
-
"### ⚠️ No exam generated yet! Please go to 'STEP 1: Generate Exam' tab first.",
|
| 1777 |
-
elem_classes="exam-progress"
|
| 1778 |
-
)
|
| 1779 |
-
|
| 1780 |
-
start_exam_btn = gr.Button(
|
| 1781 |
-
"🚀 START EXAM NOW! 🚀",
|
| 1782 |
-
variant="primary",
|
| 1783 |
-
size="lg",
|
| 1784 |
-
visible=True,
|
| 1785 |
-
elem_classes="start-exam-mega-btn"
|
| 1786 |
-
)
|
| 1787 |
-
|
| 1788 |
-
with gr.Group(visible=False) as exam_taking_area:
|
| 1789 |
-
|
| 1790 |
-
question_progress_md = gr.Markdown(
|
| 1791 |
-
"### Question 1 of 10",
|
| 1792 |
-
elem_classes="exam-progress"
|
| 1793 |
-
)
|
| 1794 |
-
|
| 1795 |
-
question_display_md = gr.Markdown(
|
| 1796 |
-
"Question will appear here...",
|
| 1797 |
-
elem_classes="exam-question-box"
|
| 1798 |
-
)
|
| 1799 |
-
|
| 1800 |
-
answer_radio = gr.Radio(
|
| 1801 |
-
choices=[],
|
| 1802 |
-
label="🎯 Select your answer:",
|
| 1803 |
-
interactive=True
|
| 1804 |
-
)
|
| 1805 |
-
|
| 1806 |
-
with gr.Row():
|
| 1807 |
-
prev_btn = gr.Button("⬅️ PREVIOUS", variant="secondary", scale=1, elem_classes="nav-btn")
|
| 1808 |
-
submit_answer_btn = gr.Button("✅ SUBMIT ANSWER", variant="primary", scale=2)
|
| 1809 |
-
next_btn = gr.Button("➡️ NEXT", variant="secondary", scale=1, elem_classes="nav-btn")
|
| 1810 |
-
|
| 1811 |
-
feedback_md = gr.Markdown("", visible=False)
|
| 1812 |
-
|
| 1813 |
-
finish_exam_btn = gr.Button(
|
| 1814 |
-
"🏁 FINISH EXAM & SEE RESULTS",
|
| 1815 |
-
variant="primary",
|
| 1816 |
-
size="lg",
|
| 1817 |
-
elem_classes="finish-btn"
|
| 1818 |
-
)
|
| 1819 |
-
|
| 1820 |
-
with gr.Group(visible=False) as results_area:
|
| 1821 |
-
|
| 1822 |
-
gr.Markdown("## 🎓 EXAM RESULTS", elem_classes="exam-results-box")
|
| 1823 |
-
|
| 1824 |
-
score_display_md = gr.Markdown("", elem_classes="exam-score-display")
|
| 1825 |
-
grade_display_md = gr.Markdown("", elem_classes="exam-grade-display")
|
| 1826 |
-
results_stats_md = gr.Markdown("")
|
| 1827 |
-
|
| 1828 |
-
with gr.Accordion("📋 Review All Answers", open=False):
|
| 1829 |
-
review_answers_md = gr.Markdown("")
|
| 1830 |
-
|
| 1831 |
-
restart_exam_btn = gr.Button(
|
| 1832 |
-
"🔄 RESTART EXAM",
|
| 1833 |
-
variant="secondary",
|
| 1834 |
-
size="lg",
|
| 1835 |
-
elem_classes="restart-btn"
|
| 1836 |
-
)
|
| 1837 |
-
|
| 1838 |
-
# ============================================================
|
| 1839 |
-
# EVENT HANDLERS
|
| 1840 |
-
# ============================================================
|
| 1841 |
-
|
| 1842 |
-
def update_ui_language(lang):
|
| 1843 |
-
texts = LANG_TEXT[lang]
|
| 1844 |
-
diff_choices = texts["difficulty_choices"]
|
| 1845 |
-
return [
|
| 1846 |
-
gr.update(value=f"# {texts['title']}"),
|
| 1847 |
-
gr.update(value=f"<p class='subtitle'>{texts['subtitle']}</p>"),
|
| 1848 |
-
gr.update(label=texts["upload_label"]),
|
| 1849 |
-
gr.update(label=texts["num_questions_label"]),
|
| 1850 |
-
gr.update(label=texts["choices_label"], info=texts["choices_info"]),
|
| 1851 |
-
gr.update(label=texts["difficulty_label"], choices=diff_choices, value=diff_choices[0]),
|
| 1852 |
-
gr.update(value=texts["generate_btn"]),
|
| 1853 |
-
gr.update(placeholder=texts["log_placeholder"]),
|
| 1854 |
-
gr.update(value=texts["download_title"]),
|
| 1855 |
-
gr.update(label=texts["exam_label"]),
|
| 1856 |
-
gr.update(label=texts["answer_label"]),
|
| 1857 |
-
gr.update(placeholder=texts["preview_placeholder"]),
|
| 1858 |
-
]
|
| 1859 |
-
|
| 1860 |
-
language_select.change(
|
| 1861 |
-
fn=update_ui_language,
|
| 1862 |
-
inputs=[language_select],
|
| 1863 |
-
outputs=[
|
| 1864 |
-
title_md, subtitle_md, doc_input, num_questions, num_choices,
|
| 1865 |
-
difficulty, generate_btn, info_log, download_title,
|
| 1866 |
-
exam_pdf, answer_pdf, preview_text
|
| 1867 |
-
]
|
| 1868 |
-
)
|
| 1869 |
-
|
| 1870 |
-
def generate_exam_handler(doc_file, num_q, num_c, diff, lang, progress=gr.Progress()):
|
| 1871 |
-
info, preview, exam_file, answer_file, error, questions = process_exam(
|
| 1872 |
-
doc_file, num_q, num_c, diff, lang, progress
|
| 1873 |
-
)
|
| 1874 |
-
|
| 1875 |
-
if questions:
|
| 1876 |
-
status_text = LANG_TEXT[lang]["exam_ready"].format(total=len(questions))
|
| 1877 |
-
else:
|
| 1878 |
-
status_text = f"### {LANG_TEXT[lang]['no_exam_yet']}"
|
| 1879 |
-
|
| 1880 |
-
return info, preview, exam_file, answer_file, error, questions, lang, gr.update(value=f"### {status_text}")
|
| 1881 |
-
|
| 1882 |
-
generate_btn.click(
|
| 1883 |
-
fn=generate_exam_handler,
|
| 1884 |
-
inputs=[doc_input, num_questions, num_choices, difficulty, language_select],
|
| 1885 |
-
outputs=[info_log, preview_text, exam_pdf, answer_pdf, error_output,
|
| 1886 |
-
exam_questions_state, current_lang, exam_status_md]
|
| 1887 |
-
)
|
| 1888 |
-
|
| 1889 |
-
def start_exam_handler(questions, language):
|
| 1890 |
-
lang = LANG_TEXT[language]
|
| 1891 |
-
|
| 1892 |
-
if not questions:
|
| 1893 |
-
return (
|
| 1894 |
-
gr.update(value=f"### ⚠️ {lang['no_exam_yet']}"),
|
| 1895 |
-
gr.update(visible=True),
|
| 1896 |
-
gr.update(visible=False),
|
| 1897 |
-
gr.update(visible=False),
|
| 1898 |
-
0, {},
|
| 1899 |
-
gr.update(), gr.update(),
|
| 1900 |
-
gr.update(choices=[]),
|
| 1901 |
-
gr.update(visible=False),
|
| 1902 |
-
)
|
| 1903 |
-
|
| 1904 |
-
q = questions[0]
|
| 1905 |
-
progress_text = lang["question_progress"].format(current=1, total=len(questions))
|
| 1906 |
-
question_md = f"""### Q1. {q['question']}
|
| 1907 |
-
|
| 1908 |
-
*[{q.get('difficulty', 'N/A').upper()}] | {q.get('bloom_level', 'N/A')} | Page {q.get('source_page', '?')}*"""
|
| 1909 |
-
|
| 1910 |
-
return (
|
| 1911 |
-
gr.update(value=f"### 🎯 Exam in Progress - {len(questions)} Questions"),
|
| 1912 |
-
gr.update(visible=False),
|
| 1913 |
-
gr.update(visible=True),
|
| 1914 |
-
gr.update(visible=False),
|
| 1915 |
-
0, {},
|
| 1916 |
-
gr.update(value=f"### {progress_text}"),
|
| 1917 |
-
gr.update(value=question_md),
|
| 1918 |
-
gr.update(choices=q['options'], value=None),
|
| 1919 |
-
gr.update(value="", visible=False),
|
| 1920 |
-
)
|
| 1921 |
-
|
| 1922 |
-
start_exam_btn.click(
|
| 1923 |
-
fn=start_exam_handler,
|
| 1924 |
-
inputs=[exam_questions_state, current_lang],
|
| 1925 |
-
outputs=[
|
| 1926 |
-
exam_status_md, start_exam_btn, exam_taking_area, results_area,
|
| 1927 |
-
current_question_idx, user_answers_state,
|
| 1928 |
-
question_progress_md, question_display_md, answer_radio, feedback_md
|
| 1929 |
-
]
|
| 1930 |
-
)
|
| 1931 |
-
|
| 1932 |
-
def submit_answer_handler(questions, current_idx, user_answers, selected_answer, language):
|
| 1933 |
-
lang = LANG_TEXT[language]
|
| 1934 |
-
|
| 1935 |
-
if not questions or selected_answer is None:
|
| 1936 |
-
return user_answers, gr.update(value="⚠️ Please select an answer!", visible=True)
|
| 1937 |
-
|
| 1938 |
-
q = questions[current_idx]
|
| 1939 |
-
user_answers[current_idx] = selected_answer
|
| 1940 |
-
|
| 1941 |
-
is_correct = selected_answer == q['answer']
|
| 1942 |
-
|
| 1943 |
-
if is_correct:
|
| 1944 |
-
feedback_text = f"""## {lang['correct_feedback']}
|
| 1945 |
-
|
| 1946 |
-
**{lang['explanation_label']}:** {q.get('explanation', {}).get('correct_reason', 'N/A')}"""
|
| 1947 |
-
else:
|
| 1948 |
-
feedback_text = f"""## {lang['incorrect_feedback']}
|
| 1949 |
-
|
| 1950 |
-
**{lang['your_answer']}:** {selected_answer}
|
| 1951 |
-
|
| 1952 |
-
**{lang['correct_answer_is']}:** {q['answer']}
|
| 1953 |
-
|
| 1954 |
-
**{lang['explanation_label']}:** {q.get('explanation', {}).get('correct_reason', 'N/A')}"""
|
| 1955 |
-
|
| 1956 |
-
return user_answers, gr.update(value=feedback_text, visible=True)
|
| 1957 |
-
|
| 1958 |
-
submit_answer_btn.click(
|
| 1959 |
-
fn=submit_answer_handler,
|
| 1960 |
-
inputs=[exam_questions_state, current_question_idx, user_answers_state, answer_radio, current_lang],
|
| 1961 |
-
outputs=[user_answers_state, feedback_md]
|
| 1962 |
-
)
|
| 1963 |
-
|
| 1964 |
-
def navigate_question(questions, current_idx, user_answers, direction, language):
|
| 1965 |
-
lang = LANG_TEXT[language]
|
| 1966 |
-
|
| 1967 |
-
if not questions:
|
| 1968 |
-
return current_idx, gr.update(), gr.update(), gr.update(choices=[]), gr.update(visible=False)
|
| 1969 |
-
|
| 1970 |
-
new_idx = current_idx + direction
|
| 1971 |
-
new_idx = max(0, min(new_idx, len(questions) - 1))
|
| 1972 |
-
|
| 1973 |
-
q = questions[new_idx]
|
| 1974 |
-
progress_text = lang["question_progress"].format(current=new_idx + 1, total=len(questions))
|
| 1975 |
-
question_md = f"""### Q{new_idx + 1}. {q['question']}
|
| 1976 |
-
|
| 1977 |
-
*[{q.get('difficulty', 'N/A').upper()}] | {q.get('bloom_level', 'N/A')} | Page {q.get('source_page', '?')}*"""
|
| 1978 |
-
|
| 1979 |
-
previous_answer = user_answers.get(new_idx, None)
|
| 1980 |
-
|
| 1981 |
-
return (
|
| 1982 |
-
new_idx,
|
| 1983 |
-
gr.update(value=f"### {progress_text}"),
|
| 1984 |
-
gr.update(value=question_md),
|
| 1985 |
-
gr.update(choices=q['options'], value=previous_answer),
|
| 1986 |
-
gr.update(value="", visible=False)
|
| 1987 |
-
)
|
| 1988 |
-
|
| 1989 |
-
next_btn.click(
|
| 1990 |
-
fn=lambda q, idx, ans, lang: navigate_question(q, idx, ans, 1, lang),
|
| 1991 |
-
inputs=[exam_questions_state, current_question_idx, user_answers_state, current_lang],
|
| 1992 |
-
outputs=[current_question_idx, question_progress_md, question_display_md, answer_radio, feedback_md]
|
| 1993 |
-
)
|
| 1994 |
-
|
| 1995 |
-
prev_btn.click(
|
| 1996 |
-
fn=lambda q, idx, ans, lang: navigate_question(q, idx, ans, -1, lang),
|
| 1997 |
-
inputs=[exam_questions_state, current_question_idx, user_answers_state, current_lang],
|
| 1998 |
-
outputs=[current_question_idx, question_progress_md, question_display_md, answer_radio, feedback_md]
|
| 1999 |
-
)
|
| 2000 |
-
|
| 2001 |
-
def finish_exam_handler(questions, user_answers, language):
|
| 2002 |
-
lang = LANG_TEXT[language]
|
| 2003 |
-
|
| 2004 |
-
if not questions:
|
| 2005 |
-
return (
|
| 2006 |
-
gr.update(visible=True),
|
| 2007 |
-
gr.update(visible=False),
|
| 2008 |
-
gr.update(), gr.update(), gr.update(), gr.update()
|
| 2009 |
-
)
|
| 2010 |
-
|
| 2011 |
-
total = len(questions)
|
| 2012 |
-
correct_count = 0
|
| 2013 |
-
incorrect_count = 0
|
| 2014 |
-
skipped_count = 0
|
| 2015 |
-
|
| 2016 |
-
review_lines = []
|
| 2017 |
-
|
| 2018 |
-
for i, q in enumerate(questions):
|
| 2019 |
-
user_answer = user_answers.get(i, None)
|
| 2020 |
-
is_correct = user_answer == q['answer']
|
| 2021 |
-
|
| 2022 |
-
if user_answer is None:
|
| 2023 |
-
skipped_count += 1
|
| 2024 |
-
status = f"⚪ {lang['not_answered']}"
|
| 2025 |
-
elif is_correct:
|
| 2026 |
-
correct_count += 1
|
| 2027 |
-
status = f"✅ {lang['correct_feedback']}"
|
| 2028 |
-
else:
|
| 2029 |
-
incorrect_count += 1
|
| 2030 |
-
status = f"❌ {lang['incorrect_feedback']}"
|
| 2031 |
-
|
| 2032 |
-
review_lines.append(f"""
|
| 2033 |
-
---
|
| 2034 |
-
**{lang['question_review'].format(num=i+1)}:** {q['question'][:100]}{'...' if len(q['question']) > 100 else ''}
|
| 2035 |
-
|
| 2036 |
-
- **{lang['your_answer']}:** {user_answer or lang['not_answered']}
|
| 2037 |
-
- **{lang['correct_answer_is']}:** {q['answer']}
|
| 2038 |
-
- **Status:** {status}
|
| 2039 |
-
- **{lang['explanation_label']}:** {q.get('explanation', {}).get('correct_reason', 'N/A')[:200]}{'...' if len(q.get('explanation', {}).get('correct_reason', '')) > 200 else ''}
|
| 2040 |
-
""")
|
| 2041 |
-
|
| 2042 |
-
percentage = (correct_count / total) * 100 if total > 0 else 0
|
| 2043 |
-
grade = calculate_grade(percentage)
|
| 2044 |
-
|
| 2045 |
-
score_text = f"# {correct_count} / {total}"
|
| 2046 |
-
grade_text = f"## {grade}"
|
| 2047 |
-
|
| 2048 |
-
stats_text = f"""
|
| 2049 |
-
### 📊 Detailed Statistics
|
| 2050 |
-
|
| 2051 |
-
| Metric | Value |
|
| 2052 |
-
|--------|-------|
|
| 2053 |
-
| **{lang['final_score']}** | {correct_count} / {total} |
|
| 2054 |
-
| **{lang['percentage']}** | {percentage:.1f}% |
|
| 2055 |
-
| **{lang['grade']}** | {grade} |
|
| 2056 |
-
| **{lang['correct_count']}** | {correct_count} ✅ |
|
| 2057 |
-
| **{lang['incorrect_count']}** | {incorrect_count} ❌ |
|
| 2058 |
-
| **{lang['skipped_count']}** | {skipped_count} ⚪ |
|
| 2059 |
-
|
| 2060 |
-
{"🎉 Excellent work! You have mastered this material!" if percentage >= 80 else "📚 Good effort! Review the explanations to improve." if percentage >= 60 else "💪 Keep studying! You can do better next time!"}
|
| 2061 |
-
"""
|
| 2062 |
-
|
| 2063 |
-
review_text = "\n".join(review_lines)
|
| 2064 |
-
|
| 2065 |
-
return (
|
| 2066 |
-
gr.update(visible=False),
|
| 2067 |
-
gr.update(visible=True),
|
| 2068 |
-
gr.update(value=score_text),
|
| 2069 |
-
gr.update(value=grade_text),
|
| 2070 |
-
gr.update(value=stats_text),
|
| 2071 |
-
gr.update(value=review_text)
|
| 2072 |
-
)
|
| 2073 |
-
|
| 2074 |
-
finish_exam_btn.click(
|
| 2075 |
-
fn=finish_exam_handler,
|
| 2076 |
-
inputs=[exam_questions_state, user_answers_state, current_lang],
|
| 2077 |
-
outputs=[exam_taking_area, results_area, score_display_md, grade_display_md, results_stats_md, review_answers_md]
|
| 2078 |
-
)
|
| 2079 |
-
|
| 2080 |
-
def restart_exam_handler(questions, language):
|
| 2081 |
-
return start_exam_handler(questions, language)
|
| 2082 |
-
|
| 2083 |
-
restart_exam_btn.click(
|
| 2084 |
-
fn=restart_exam_handler,
|
| 2085 |
-
inputs=[exam_questions_state, current_lang],
|
| 2086 |
-
outputs=[
|
| 2087 |
-
exam_status_md, start_exam_btn, exam_taking_area, results_area,
|
| 2088 |
-
current_question_idx, user_answers_state,
|
| 2089 |
-
question_progress_md, question_display_md, answer_radio, feedback_md
|
| 2090 |
-
]
|
| 2091 |
-
)
|
| 2092 |
-
|
| 2093 |
-
# ============================================================
|
| 2094 |
-
# Launch Application
|
| 2095 |
-
# ============================================================
|
| 2096 |
-
if __name__ == "__main__":
|
| 2097 |
-
demo.launch(ssr_mode=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|