seawolf2357 commited on
Commit
de02f0c
·
verified ·
1 Parent(s): 82c635c

Delete app-backup.py

Browse files
Files changed (1) hide show
  1. 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)