diff --git a/combined_app.py b/combined_app.py new file mode 100644 index 0000000..e268da7 --- /dev/null +++ b/combined_app.py @@ -0,0 +1,968 @@ +import base64 +import re +import ast +import nltk +import pandas as pd +from pathlib import Path +from io import BytesIO +from PIL import Image, ImageFile +import streamlit as st +from streamlit_extras.stylable_container import stylable_container +import google.generativeai as genai +from google.cloud import texttospeech +from google.oauth2 import service_account +from database import register_user, verify_user, save_learning_history, get_learning_history, update_username, find_username, reset_password +import extra_streamlit_components as stx +import time + +# Constants and directory setup +wORK_DIR = Path(__file__).parent +IMG_DIR, IN_DIR, OUT_DIR = wORK_DIR / "img", wORK_DIR / "input", wORK_DIR / "output" + +# Create directories if they don't exist +IMG_DIR.mkdir(exist_ok=True) +IN_DIR.mkdir(exist_ok=True) +OUT_DIR.mkdir(exist_ok=True) + +def init_page(): + st.set_page_config( + page_title="앵무새 스쿨", + layout="wide", + page_icon="🦜" + ) + +def show_auth_page(): + st.markdown( + """ +
+

🔊앵무새 스쿨

+

+ 영어공인시험 학습 사이트 +

+
+ """, unsafe_allow_html=True) + + # 탭 생성 + tab1, tab2, tab3 = st.tabs(["로그인", "회원가입", "아이디/비밀번호 찾기"]) + + with tab1: + with st.form("login_form", border=True): + st.markdown(""" +
+

로그인

+
+ """, unsafe_allow_html=True) + + username = st.text_input("아이디", placeholder="아이디를 입력하세요") + password = st.text_input("비밀번호", type="password", placeholder="비밀번호를 입력하세요") + submitted = st.form_submit_button("로그인", use_container_width=True) + + if submitted: + if not username or not password: + st.error("아이디와 비밀번호를 모두 입력해주세요.") + else: + success, user_id = verify_user(username, password) + if success: + # 닉네임(name) 가져오기 + import database + conn = database.sqlite3.connect(database.DB_PATH) + c = conn.cursor() + c.execute('SELECT name FROM users WHERE id = ?', (user_id,)) + nickname = c.fetchone()[0] + conn.close() + st.session_state["authenticated"] = True + st.session_state["username"] = username # 아이디 + st.session_state["user_id"] = user_id + st.session_state["nickname"] = nickname # 닉네임 + st.success("로그인 성공!") + st.rerun() + else: + st.error("아이디 또는 비밀번호가 올바르지 않습니다.") + + with tab2: + with st.form("register_form", border=True): + st.markdown(""" +
+

회원가입

+
+ """, unsafe_allow_html=True) + + name = st.text_input("닉네임", placeholder="닉네임을 입력하세요") + new_username = st.text_input("사용할 아이디", placeholder="아이디를 입력하세요") + new_password = st.text_input("사용할 비밀번호", type="password", placeholder="비밀번호를 입력하세요") + confirm_password = st.text_input("비밀번호 확인", type="password", placeholder="비밀번호를 다시 입력하세요") + email = st.text_input("이메일", placeholder="이메일을 입력하세요") + submitted = st.form_submit_button("회원가입", use_container_width=True) + + if submitted: + if not all([new_username, new_password, confirm_password, name, email]): + st.error("모든 항목을 입력해주세요.") + elif new_password != confirm_password: + st.error("비밀번호가 일치하지 않습니다.") + elif len(new_password) < 6: + st.error("비밀번호는 최소 6자 이상이어야 합니다.") + elif not re.match(r"[^@]+@[^@]+\.[^@]+", email): + st.error("올바른 이메일 형식이 아닙니다.") + else: + if register_user(new_username, new_password, email, name): + st.success("회원가입이 완료되었습니다! 이제 로그인해주세요.") + else: + st.error("이미 존재하는 아이디 또는 이메일입니다.") + + with tab3: + st.markdown(""" +
+

아이디/비밀번호 찾기

+
+ """, unsafe_allow_html=True) + + find_option = st.radio( + "찾으실 항목을 선택하세요", + ["아이디 찾기", "비밀번호 재설정"], + horizontal=True + ) + + if find_option == "아이디 찾기": + with st.form("find_username_form", border=True): + name = st.text_input("닉네임", placeholder="가입 시 등록한 닉네임을 입력하세요") + email = st.text_input("이메일", placeholder="가입 시 등록한 이메일을 입력하세요") + submitted = st.form_submit_button("아이디 찾기", use_container_width=True) + + if submitted: + if not name or not email: + st.error("닉네임과 이메일을 모두 입력해주세요.") + else: + username = find_username(name, email) + if username: + st.success(f"회원님의 아이디는 **{username}** 입니다.") + else: + st.error("입력하신 정보와 일치하는 계정이 없습니다.") + + else: # 비밀번호 재설정 + with st.form("reset_password_form", border=True): + username = st.text_input("아이디", placeholder="아이디를 입력하세요") + email = st.text_input("이메일", placeholder="가입 시 등록한 이메일을 입력하세요") + new_password = st.text_input("새 비밀번호", type="password", placeholder="새로운 비밀번호를 입력하세요") + confirm_password = st.text_input("새 비밀번호 확인", type="password", placeholder="새로운 비밀번호를 다시 입력하세요") + submitted = st.form_submit_button("비밀번호 재설정", use_container_width=True) + + if submitted: + if not all([username, email, new_password, confirm_password]): + st.error("모든 항목을 입력해주세요.") + elif new_password != confirm_password: + st.error("새 비밀번호가 일치하지 않습니다.") + elif len(new_password) < 6: + st.error("비밀번호는 최소 6자 이상이어야 합니다.") + else: + if reset_password(username, email, new_password): + st.success("비밀번호가 성공적으로 변경되었습니다. 새로운 비밀번호로 로그인해주세요.") + else: + st.error("입력하신 정보와 일치하는 계정이 없습니다.") + +def init_session(initial_state: dict = None): + if initial_state: + for key, value in initial_state.items(): + if key not in st.session_state: + st.session_state[key] = value + +def init_score(): + if "total_score" not in st.session_state: + st.session_state["total_score"] = 0 + if "quiz_data" not in st.session_state: + st.session_state["quiz_data"] = [] + if "answered_questions" not in st.session_state: + st.session_state["answered_questions"] = set() + if "correct_answers" not in st.session_state: + st.session_state["correct_answers"] = 0 + if "total_questions" not in st.session_state: + st.session_state["total_questions"] = 0 + if "learning_history" not in st.session_state: + st.session_state["learning_history"] = [] + # Initialize quiz-related session state variables + if "quiz" not in st.session_state: + st.session_state["quiz"] = [] + if "answ" not in st.session_state: + st.session_state["answ"] = [] + if "audio" not in st.session_state: + st.session_state["audio"] = [] + if "choices" not in st.session_state: + st.session_state["choices"] = [] + if "img" not in st.session_state: + st.session_state["img"] = None + if "has_image" not in st.session_state: + st.session_state["has_image"] = False + if "img_bytes" not in st.session_state: + st.session_state["img_bytes"] = None + if "current_group" not in st.session_state: + st.session_state["current_group"] = "default" + if "voice" not in st.session_state: + st.session_state["voice"] = "ko-KR-Standard-A" # 기본 음성 설정 + +def init_question_count(): + if "question_count" not in st.session_state: + st.session_state["question_count"] = 0 + if "max_questions" not in st.session_state: + st.session_state["max_questions"] = 10 # 최대 10문제 + +def can_generate_more_questions() -> bool: + return st.session_state.get("question_count", 0) < st.session_state.get("max_questions", 10) + +def uploaded_image(on_change=None, args=None) -> Image.Image | None: + with st.sidebar: + st.markdown( + "
이미지 업로드
", + unsafe_allow_html=True + ) + + # 안내 이미지 표시 (실패해도 계속 진행) + try: + guide_img = Image.open('img/angmose.jpg').resize((300, 300)) + st.markdown( + f""" +
+ +
+ """, + unsafe_allow_html=True + ) + except Exception as e: + st.warning("안내 이미지를 불러올 수 없습니다.") + + st.markdown( + """ +
+ 이미지를 업로드 하시면
+ AI가 문장을 생성해 퀴즈를 출제합니다.
+ 문장을 잘 듣고 퀴즈를 풀어보세요. +
+ """, + unsafe_allow_html=True + ) + + # 이미지 상태 초기화 + if "img_state" not in st.session_state: + st.session_state["img_state"] = { + "has_image": False, + "img_bytes": None, + "img": None + } + + # 파일 업로더 + uploaded = st.file_uploader( + label="", + label_visibility="collapsed", + on_change=on_change, + args=args, + type=["jpg", "jpeg", "png", "gif", "bmp", "webp"] + ) + + # 새로 업로드된 이미지가 있는 경우 + if uploaded is not None: + try: + # 이미지 파일 크기 확인 + if uploaded.size > 10 * 1024 * 1024: # 10MB 제한 + st.error("이미지 파일 크기는 10MB 이하여야 합니다.") + return None + + # 이미지 로드 및 변환 + img = Image.open(uploaded) + if img.format not in ["JPEG", "PNG", "GIF", "BMP", "WEBP"]: + st.error("지원되지 않는 이미지 형식입니다.") + return None + + img = img.convert("RGB") + + # 이미지를 세션 상태에 저장 + buf = BytesIO() + img.save(buf, format="PNG") + img_bytes = buf.getvalue() + + # 이미지 상태 업데이트 + st.session_state["img_state"] = { + "has_image": True, + "img_bytes": img_bytes, + "img": img + } + + with st.container(border=True): + st.image(img, width=300) + return img + except Exception as e: + st.error(f"이미지를 불러올 수 없습니다. 오류: {str(e)}") + return None + + # 이전에 업로드된 이미지가 있는 경우 + elif st.session_state["img_state"]["has_image"]: + try: + img = st.session_state["img_state"]["img"] + if img: + with st.container(border=True): + st.image(img, width=300) + return img + except Exception as e: + st.error("저장된 이미지를 불러올 수 없습니다. 새로운 이미지를 업로드해주세요.") + st.session_state["img_state"] = { + "has_image": False, + "img_bytes": None, + "img": None + } + return None + + return None + +# Utility functions +def img_to_base64(img: Image.Image) -> str: + buf = BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + +def get_prompt(group: str, difficulty: str = None) -> Path: + # Map group to exam type + exam_mapping = { + "yle": "YLE", + "toefl_junior": "TOEFL_JUNIOR", + "toeic": "TOEIC", + "toefl": "TOEFL" + } + + # Map difficulty to exam level + difficulty_mapping = { + "easy": "easy", + "normal": "medium", + "hard": "hard" + } + + exam_type = exam_mapping.get(group, "default") + exam_level = difficulty_mapping.get(difficulty, "medium") + + # First try to get the specific exam type and difficulty prompt + if difficulty: + prompt_file = f"prompt_{exam_type.lower()}_{exam_level}.txt" + path = IN_DIR / prompt_file + if path.exists(): + return path + + # If no specific prompt found or difficulty not provided, try exam-specific prompt + path = IN_DIR / f"prompt_{exam_type.lower()}.txt" + if path.exists(): + return path + + # If no exam-specific prompt found, use default + st.warning(f"⚠️ '{exam_type}' 시험 유형의 프롬프트가 존재하지 않아 기본값을 사용합니다.") + return IN_DIR / "prompt_default.txt" + +def get_model() -> genai.GenerativeModel: + GEMINI_KEY = st.secrets['GEMINI_KEY'] + GEMINI_MODEL = "gemini-2.0-flash" + genai.configure(api_key=GEMINI_KEY, transport="rest") + return genai.GenerativeModel(GEMINI_MODEL) + +def generate_quiz(img: ImageFile.ImageFile, group: str, difficulty: str): + if not can_generate_more_questions(): + return None, None, None, None + + max_retries = 5 # 재시도 횟수 증가 + retry_delay = 3 # 대기 시간 증가 + + for attempt in range(max_retries): + try: + with st.spinner(f"문제를 생성하는 중입니다... (시도 {attempt + 1}/{max_retries})"): + prompt_desc = IN_DIR / "p1_desc.txt" + sys_prompt_desc = prompt_desc.read_text(encoding="utf8") + model_desc = get_model() + resp_desc = model_desc.generate_content( + [img, f"{sys_prompt_desc}\nDescribe this image"] + ) + description = resp_desc.text.strip() + + quiz_prompt_path = get_prompt(group, difficulty) + sys_prompt_quiz = quiz_prompt_path.read_text(encoding="utf8") + model_quiz = get_model() + + # Add exam-specific context to the prompt + exam_context = { + "elementary": "YLE 시험 형식에 맞춰", + "middle": "TOEFL Junior 시험 형식에 맞춰", + "high": "TOEIC 시험 형식에 맞춰", + "adult": "TOEFL 시험 형식에 맞춰" + } + + exam_type = exam_context.get(group, "") + difficulty_context = { + "easy": "기초 수준의", + "normal": "중급 수준의", + "hard": "고급 수준의" + } + + level = difficulty_context.get(difficulty, "중급 수준의") + + resp_quiz = model_quiz.generate_content( + f"{sys_prompt_quiz}\n{exam_type} {level} 문제를 생성해주세요.\n{description}" + ) + + # Try different patterns to match the response + quiz_text = resp_quiz.text.strip() + + # Pattern 1: Standard format with quotes + quiz_match = re.search(r'Quiz:\s*["\'](.*?)["\']\s*$', quiz_text, re.MULTILINE) + answer_match = re.search(r'Answer:\s*["\'](.*?)["\']\s*$', quiz_text, re.MULTILINE) + choices_match = re.search(r'Choices:\s*(\[[^\]]+\](?:,\s*\[[^\]]+\])*)', quiz_text, re.MULTILINE | re.DOTALL) + + # Pattern 2: Format without quotes + if not (quiz_match and answer_match and choices_match): + quiz_match = re.search(r'Quiz:\s*(.*?)\s*$', quiz_text, re.MULTILINE) + answer_match = re.search(r'Answer:\s*(.*?)\s*$', quiz_text, re.MULTILINE) + choices_match = re.search(r'Choices:\s*\[(.*?)\]', quiz_text, re.MULTILINE | re.DOTALL) + + if choices_match: + # Convert comma-separated choices to proper list format + choices_str = choices_match.group(1) + choices = [choice.strip().strip('"\'') for choice in choices_str.split(',')] + choices = [f'"{choice}"' for choice in choices] + choices_str = f"[{', '.join(choices)}]" + choices_match = type('obj', (object,), {'group': lambda x: choices_str}) + + if quiz_match and answer_match and choices_match: + quiz_sentence = quiz_match.group(1).strip().strip('"\'') + answer_word = [answer_match.group(1).strip().strip('"\'')] + try: + choices = ast.literal_eval(choices_match.group(1)) + if isinstance(choices, str): + choices = [choice.strip().strip('"\'') for choice in choices.split(',')] + except: + # If parsing fails, try to extract choices manually + choices_str = choices_match.group(1) + choices = [choice.strip().strip('"\'') for choice in choices_str.split(',')] + + original_sentence = quiz_sentence.replace("_____", answer_word[0]) + st.session_state["question_count"] = st.session_state.get("question_count", 0) + 1 + return quiz_sentence, answer_word, choices, original_sentence + + # If all parsing attempts fail, raise error with the full response + raise ValueError(f"AI 응답 파싱 실패! AI 응답 내용:\n{quiz_text}") + + except Exception as e: + if attempt < max_retries - 1: + st.warning(f"문제 생성 중 오류가 발생했습니다. {retry_delay}초 후 다시 시도합니다... ({attempt + 1}/{max_retries})") + time.sleep(retry_delay) + else: + st.error(""" + 문제 생성에 실패했습니다. 다음 중 하나를 시도해보세요: + 1. 잠시 후 다시 시도 + 2. 다른 이미지로 시도 + 3. 페이지 새로고침 + """) + return None, None, None, None + +def synth_speech(text: str, voice: str, audio_encoding: str = None) -> bytes: + lang_code = "-".join(voice.split("-")[:2]) + MP3 = texttospeech.AudioEncoding.MP3 + WAV = texttospeech.AudioEncoding.LINEAR16 + audio_type = MP3 if audio_encoding == "mp3" else WAV + + client = tts_client() + resp = client.synthesize_speech( + input=texttospeech.SynthesisInput(text=text), + voice=texttospeech.VoiceSelectionParams(language_code=lang_code, name=voice), + audio_config=texttospeech.AudioConfig(audio_encoding=audio_type), + ) + return resp.audio_content + +def tts_client() -> texttospeech.TextToSpeechClient: + cred = service_account.Credentials.from_service_account_info( + st.secrets["gcp_service_account"] + ) + return texttospeech.TextToSpeechClient(credentials=cred) + +def tokenize_sent(text: str) -> list[str]: + nltk.download(["punkt", "punkt_tab"], quiet=True) + return nltk.tokenize.sent_tokenize(text) + +def set_quiz(img: ImageFile.ImageFile, group: str, difficulty: str): + if img and not st.session_state["quiz"]: + with st.spinner("이미지 퀴즈를 준비 중입니다...🦜"): + # 이미지가 변경되었을 때 question_count 초기화 + if "last_image" not in st.session_state or st.session_state["last_image"] != img: + st.session_state["question_count"] = 0 + st.session_state["last_image"] = img + + if not can_generate_more_questions(): + st.warning(f"현재 {st.session_state['question_count']}문제를 풀었어요! 새로운 이미지를 업로드하면 새로운 문제를 풀 수 있습니다.") + return + + quiz_sentence, answer_word, choices, full_desc = generate_quiz(img, group, difficulty) + if not quiz_sentence: # If we've reached the question limit + st.warning(f"현재 {st.session_state['question_count']}문제를 풀었어요! 새로운 이미지를 업로드하면 새로운 문제를 풀 수 있습니다.") + return + + if isinstance(choices[0], list): + choices = choices[0] + answer_words = [answer_word] + + # Generate simple audio instruction + question_audio = "Look at the image carefully." + wav_file = synth_speech(question_audio, st.session_state["voice"], "wav") + path = OUT_DIR / f"{Path(__file__).stem}.wav" + with open(path, "wb") as fp: + fp.write(wav_file) + + quiz_display = f"""이미지를 보고 설명을 잘 들은 후, 빈칸에 들어갈 알맞은 단어를 선택하세요. + +**{quiz_sentence}**""" + st.session_state["img"] = img + st.session_state["quiz"] = [quiz_display] + st.session_state["answ"] = answer_words + st.session_state["audio"] = [path.as_posix()] + st.session_state["choices"] = [choices] + st.session_state["quiz_data"] = [{ + "question": quiz_display, + "topic": "지문화", + "difficulty": difficulty, + "correct": False + }] + +def show_quiz(difficulty="medium"): + zipped = zip( + range(len(st.session_state["quiz"])), + st.session_state["quiz"], + st.session_state["answ"], + st.session_state["audio"], + st.session_state["choices"], + ) + for idx, quiz, answ, audio, choices in zipped: + key_feedback = f"feedback_{idx}" + init_session({key_feedback: "", f"submitted_{idx}": False}) + + with st.form(f"form_question_{idx}", border=True): + st.markdown(""" +
+

문제

+
+ """, unsafe_allow_html=True) + + st.audio(audio) + quiz_display = quiz.replace("**", "").replace( + "_____", "_____" + ) + st.markdown(f"

{quiz_display}

", unsafe_allow_html=True) + + if not choices: + st.error("선택지가 없습니다. 다시 문제를 생성하세요.") + continue + + key_choice = f"choice_{idx}_0" + init_session({key_choice: ""}) + if st.session_state[key_choice] not in choices: + st.session_state[key_choice] = choices[0] + user_choice = st.radio( + "보기 중 하나를 선택하세요👇", + choices, + key=key_choice + ) + + submitted = st.form_submit_button("정답 제출 ✅", use_container_width=True) + + if submitted and not st.session_state.get(f"submitted_{idx}"): + st.session_state[f"submitted_{idx}"] = True + + with st.spinner("채점 중입니다..."): + is_correct = user_choice == answ[0] + # 마지막 문제 정보 저장 + st.session_state["last_question"] = quiz_display + st.session_state["last_user_choice"] = user_choice + st.session_state["last_correct_answer"] = answ[0] + update_score(quiz_display, is_correct) + + if is_correct: + feedback = "✅ 정답입니다! 🎉" + else: + feedback = f"❌ 오답입니다.\n\n{generate_feedback(user_choice, answ[0])}" + + st.session_state[key_feedback] = feedback + + feedback = st.session_state.get(key_feedback, "") + if feedback: + with st.expander("📚 해설 보기", expanded=True): + st.markdown(f"**정답:** {answ[0]}") + st.markdown(feedback, unsafe_allow_html=True) + +def update_score(question: str, is_correct: bool): + init_score() + + # Only update if this question hasn't been answered before + if question not in st.session_state["answered_questions"]: + st.session_state["answered_questions"].add(question) + st.session_state["total_questions"] += 1 # 모든 문제에 대해 문제 수 증가 + + if is_correct: + st.session_state["correct_answers"] += 1 + # 최대 100점까지만 점수 추가 + if st.session_state["total_score"] < 100: + st.session_state["total_score"] += 10 + + # 현재 문제의 피드백 생성 + feedback = generate_feedback( + st.session_state.get("last_user_choice", ""), + st.session_state.get("last_correct_answer", "") + ) if not is_correct else "정답입니다! 🎉" + + # Add to current quiz data + st.session_state["quiz_data"].append({ + "question": question, + "correct": is_correct, + "score": 10 if is_correct else 0, # 정답은 10점, 오답은 0점 + "timestamp": pd.Timestamp.now(), + "feedback": feedback, + "question_content": st.session_state.get("last_question", ""), + "user_choice": st.session_state.get("last_user_choice", ""), + "correct_answer": st.session_state.get("last_correct_answer", "") + }) + + # Save to database if user is authenticated + if st.session_state.get("authenticated") and st.session_state.get("user_id"): + # 현재까지 푼 문제 수 계산 + current_question_count = len(st.session_state["answered_questions"]) + + save_learning_history( + user_id=st.session_state["user_id"], + group_code=st.session_state.get("current_group", "default"), + score=10 if is_correct else 0, # 정답은 10점, 오답은 0점 + total_questions=current_question_count, # 현재까지 푼 문제 수 + question_content=st.session_state.get("last_question", ""), + feedback=feedback, + user_choice=st.session_state.get("last_user_choice", ""), + correct_answer=st.session_state.get("last_correct_answer", "") + ) + +def generate_feedback(user_input: str, answ: str) -> str: + try: + prompt_path = IN_DIR / "p3_feedback.txt" + template = prompt_path.read_text(encoding="utf8") + prompt = template.format(user=user_input, correct=answ) + model = get_model() + response = model.generate_content( + f"""다음과 같은 형식으로 피드백을 제공해주세요: +1. 정답과 오답의 관계 설명 +2. 간단한 학습 조언 +3. 다음에 도움이 될 팁 + +정답: {answ} +학생 답변: {user_input} + +위 형식에 맞춰 한국어로만 답변해주세요. 번역은 하지 마세요.""" + ) + return response.text.strip() if response and response.text else "(⚠️ 응답 없음)" + except Exception as e: + return f"(⚠️ 피드백 생성 중 오류: {e})" + +def show_score_summary(): + if "quiz_data" not in st.session_state or not st.session_state["quiz_data"]: + return + + # Get the latest counts from session state + total = st.session_state["total_questions"] + correct = st.session_state["correct_answers"] + accuracy = round((correct / total) * 100, 1) if total else 0.0 + score = st.session_state["total_score"] + + # Show current progress + st.info(f"현재 {total}문제를 풀었어요! (정답률: {accuracy}%, 현재 점수: {score}점)") + + # Only show detailed summary when all 10 questions are answered + if total < 10: + return + + # Create a more visually appealing and accessible score display + st.markdown("---") + + # Score header with emoji + st.markdown(""" +
+

🏆 최종 점수

+
+ """, unsafe_allow_html=True) + + # Create two columns for better layout + col1, col2 = st.columns(2) + + with col1: + # Score card with large, clear numbers + st.markdown(f""" +
+

최종 점수

+

{score}점

+
+ """, unsafe_allow_html=True) + + with col2: + # Progress card with clear statistics + st.markdown(f""" +
+

정답률

+

{accuracy}%

+

맞춘 문제: {correct} / {total}

+
+ """, unsafe_allow_html=True) + + # Progress bar with better visibility + st.markdown(f""" +
+
+
+
+
+ """, unsafe_allow_html=True) + + # Add encouraging message based on performance + if total == 10 and correct == 10: + st.success("🎉 축하합니다! 100점입니다! 완벽한 성적이에요!") + elif accuracy >= 80: + st.success("🎉 훌륭해요! 계속 이렇게 잘 해봐요!") + elif accuracy >= 60: + st.info("👍 잘하고 있어요! 조금만 더 노력해봐요!") + else: + st.warning("💪 조금 더 연습하면 더 잘할 수 있을 거예요!") + + clear_all_scores() + +def reset_quiz(): + if st.session_state.get("quiz"): + # Add some vertical space before the button + st.markdown("
", unsafe_allow_html=True) + if st.button("🔄 새로운 문제", type="primary"): + # Keep authentication state + auth_state = { + "authenticated": st.session_state.get("authenticated", False), + "username": st.session_state.get("username", ""), + "user_id": st.session_state.get("user_id", None) + } + + # Keep image state + img_state = st.session_state.get("img_state", { + "has_image": False, + "img_bytes": None, + "img": None + }) + + # Keep score-related states + score_state = { + "total_score": st.session_state.get("total_score", 0), + "quiz_data": st.session_state.get("quiz_data", []), + "answered_questions": st.session_state.get("answered_questions", set()), + "correct_answers": st.session_state.get("correct_answers", 0), + "total_questions": st.session_state.get("total_questions", 0), + "question_count": st.session_state.get("question_count", 0), + "last_image": st.session_state.get("last_image", None) + } + + # Clear only current quiz states + keys_to_clear = ["quiz", "answ", "audio", "choices"] + for key in keys_to_clear: + if key in st.session_state: + del st.session_state[key] + + # Clear form-related states + for key in list(st.session_state.keys()): + if key.startswith(("submitted_", "feedback_", "choice_", "form_question_")): + del st.session_state[key] + + # Restore important states + st.session_state.update(auth_state) + st.session_state["img_state"] = img_state + st.session_state.update(score_state) + + st.rerun() + +def show_learning_history(): + if not st.session_state.get("authenticated") or not st.session_state.get("user_id"): + return + + st.markdown("---") + st.markdown(""" +
+

📚 학습 기록

+
+ """, unsafe_allow_html=True) + + # Get learning history from database + history = get_learning_history(st.session_state["user_id"]) + if not history: + st.info("아직 학습 기록이 없습니다. 퀴즈를 풀어보세요!") + return + + # 데이터프레임 생성 (모든 컬럼 포함) + history_df = pd.DataFrame(history, columns=['group_code', 'score', 'total_questions', 'timestamp', 'question_content', 'feedback', 'user_choice', 'correct_answer']) + + # 날짜/시간 포맷 변경 + history_df['timestamp'] = pd.to_datetime(history_df['timestamp']) + history_df['date'] = history_df['timestamp'].dt.strftime('%Y-%m-%d %H:%M') + + # 시험 유형 이름 매핑 + group_name_mapping = { + "yle": "YLE", + "toefl_junior": "TOEFL JUNIOR", + "toeic": "TOEIC", + "toefl": "TOEFL" + } + history_df['group_code'] = history_df['group_code'].map(group_name_mapping) + + # 정답 여부 표시를 위한 함수 + def get_result_icon(row): + return "✅" if row['score'] > 0 else "❌" + history_df['result'] = history_df.apply(get_result_icon, axis=1) + + # 누적 점수 계산 (최대 100점) + cumulative_score = [] + current_score = 0 + for s in history_df['score']: + if s > 0 and current_score < 100: + current_score = min(current_score + 10, 100) + cumulative_score.append(current_score) + history_df['cumulative_score'] = cumulative_score + + # 표시할 컬럼만 선택 + display_df = history_df[['date', 'group_code', 'result', 'cumulative_score', 'total_questions']] + display_df.columns = ['날짜', '시험 유형', '결과', '누적 점수', '문제 수'] + + if not display_df.empty: + st.dataframe( + display_df, + use_container_width=True, + hide_index=True + ) + else: + st.info("학습 기록이 없습니다.") + +def clear_all_scores(): + if st.button("🗑️ 현재 점수 초기화", type="secondary"): + # Only clear current score-related data, not learning history + st.session_state["total_score"] = 0 + st.session_state["quiz_data"] = [] + st.session_state["answered_questions"] = set() + st.session_state["correct_answers"] = 0 + st.session_state["total_questions"] = 0 + st.session_state["question_count"] = 0 # Reset question count + st.success("현재 점수가 초기화되었습니다.") + st.rerun() + +# Main application +if __name__ == "__main__": + try: + # Initialize page configuration first + init_page() + + # Initialize session state + init_score() + init_question_count() + + # 로그인 상태 확인 + if not st.session_state.get("authenticated", False): + show_auth_page() + else: + # 메인 페이지 타이틀 + st.markdown( + """ +
+

🔊앵무새 스쿨

+

+ 영어공인시험 학습 프로그램 +

+
+ """, unsafe_allow_html=True + ) + + # 사이드바에 사용자 정보 표시 + with st.sidebar: + st.markdown(f""" +
+

👤 닉네임: {st.session_state.get('nickname', '')}

+
+ """, unsafe_allow_html=True) + + # 닉네임 변경 폼 + with st.expander("✏️ 닉네임 변경", expanded=False): + with st.form("change_nickname_form"): + new_nickname = st.text_input( + "새로운 닉네임", + placeholder="변경할 닉네임을 입력하세요", + value=st.session_state.get('nickname', '') + ) + submitted = st.form_submit_button("변경하기", use_container_width=True) + + if submitted: + if not new_nickname: + st.error("닉네임을 입력해주세요.") + elif new_nickname == st.session_state.get('nickname'): + st.info("현재 사용 중인 닉네임과 동일합니다.") + else: + if update_username(st.session_state.get('user_id'), new_nickname): + st.session_state["nickname"] = new_nickname + st.success("닉네임이 변경되었습니다!") + st.rerun() + else: + st.error("이미 사용 중인 닉네임입니다.") + + if st.button("로그아웃", use_container_width=True): + # Clear all session state + for key in list(st.session_state.keys()): + del st.session_state[key] + st.rerun() + + # 메인 컨텐츠 + init_score() + init_question_count() + + # 1. 시험 종류 선택 + st.markdown("### 📚 시험 종류 선택") + group_display = st.selectbox( + "시험 종류를 선택하세요.", + ["YLE", "TOEFL JUNIOR", "TOEIC", "TOEFL"], + help="선택한 시험 유형에 맞는 퀴즈가 출제됩니다." + ) + group_mapping = { + "YLE": "yle", + "TOEFL JUNIOR": "toefl_junior", + "TOEIC": "toeic", + "TOEFL": "toefl" + } + group_code = group_mapping.get(group_display, "default") + st.session_state["current_group"] = group_code + + # 2. 난이도 선택 + st.markdown("### 🎯 난이도 선택") + difficulty_display = st.selectbox( + "문제 난이도를 선택하세요.", + ["쉬움", "중간", "어려움"], + help="선택한 난이도에 따라 문제의 복잡도가 달라집니다." + ) + difficulty_mapping = { + "쉬움": "easy", + "중간": "normal", + "어려움": "hard" + } + global_difficulty = difficulty_mapping.get(difficulty_display, "normal") + + # 3. 이미지 업로드 or 복원 + st.markdown("### 🖼️ 이미지 업로드") + img = uploaded_image() + + if img: + # 새로운 퀴즈 생성이 필요한 경우 + if not st.session_state.get("quiz"): + set_quiz(img, group_code, global_difficulty) + + show_quiz(global_difficulty) + + if st.session_state.get("quiz_data"): + show_score_summary() + show_learning_history() + + reset_quiz() + else: + st.info("이미지를 업로드하면 퀴즈가 시작됩니다!") + + except Exception as e: + st.error(f"오류가 발생했습니다: {str(e)}") + st.info("페이지를 새로고침하거나 다시 시도해주세요.") \ No newline at end of file