Add combined_app.py

main
no 2025-05-15 05:12:10 +00:00
parent 46eea8a444
commit 9f194fdd6d
1 changed files with 968 additions and 0 deletions

968
combined_app.py Normal file
View File

@ -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(
"""
<div style='text-align: center; margin-bottom: 30px;'>
<h1 style='font-size:48px; color: #4B89DC;'>🔊앵무새 스쿨</h1>
<p style='font-size: 20px; color: #555;'>
<b>영어공인시험 학습 사이트</b>
</p>
</div>
""", unsafe_allow_html=True)
# 탭 생성
tab1, tab2, tab3 = st.tabs(["로그인", "회원가입", "아이디/비밀번호 찾기"])
with tab1:
with st.form("login_form", border=True):
st.markdown("""
<div style='text-align: center; margin-bottom: 20px;'>
<h3 style='color: #4B89DC;'>로그인</h3>
</div>
""", 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("""
<div style='text-align: center; margin-bottom: 20px;'>
<h3 style='color: #4B89DC;'>회원가입</h3>
</div>
""", 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("""
<div style='text-align: center; margin-bottom: 20px;'>
<h3 style='color: #4B89DC;'>아이디/비밀번호 찾기</h3>
</div>
""", 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(
"<div style='text-align: center; font-weight: bold; font-size: 25px;'>이미지 업로드</div>",
unsafe_allow_html=True
)
# 안내 이미지 표시 (실패해도 계속 진행)
try:
guide_img = Image.open('img/angmose.jpg').resize((300, 300))
st.markdown(
f"""
<div style="text-align: center; padding-bottom: 10px;">
<img src="data:image/png;base64,{img_to_base64(guide_img)}"
width="200"
style="border-radius: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" />
</div>
""",
unsafe_allow_html=True
)
except Exception as e:
st.warning("안내 이미지를 불러올 수 없습니다.")
st.markdown(
"""
<div style='text-align: left; font-size: 15px; color: #444; line-height: 1.6; padding-left: 5px;'>
이미지를 업로드 하시면<br>
AI가 문장을 생성해 퀴즈를 출제합니다.<br>
문장을 듣고 퀴즈를 풀어보세요.
</div>
""",
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("""
<div style="background-color:#e6f4ea; padding:10px; border-radius:10px; text-align: center;">
<h4 style="color:#006d2c; margin: 0;">문제</h4>
</div>
""", unsafe_allow_html=True)
st.audio(audio)
quiz_display = quiz.replace("**", "").replace(
"_____", "<span style='color:black; font-weight:bold;'>_____</span>"
)
st.markdown(f"<p style='font-size:17px;'>{quiz_display}</p>", 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("""
<div style='text-align: center; margin-bottom: 20px;'>
<h2 style='color: #4B89DC;'>🏆 최종 점수</h2>
</div>
""", 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"""
<div style='background-color: #f0f8ff; padding: 20px; border-radius: 10px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1);'>
<h3 style='color: #4B89DC; margin-bottom: 10px;'>최종 점수</h3>
<h1 style='font-size: 48px; color: #2E7D32; margin: 0;'>{score}</h1>
</div>
""", unsafe_allow_html=True)
with col2:
# Progress card with clear statistics
st.markdown(f"""
<div style='background-color: #f0f8ff; padding: 20px; border-radius: 10px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1);'>
<h3 style='color: #4B89DC; margin-bottom: 10px;'>정답률</h3>
<h2 style='color: #2E7D32; margin: 0;'>{accuracy}%</h2>
<p style='color: #666; margin-top: 10px;'>맞춘 문제: {correct} / {total}</p>
</div>
""", unsafe_allow_html=True)
# Progress bar with better visibility
st.markdown(f"""
<div style='margin-top: 20px;'>
<div style='background-color: #e0e0e0; height: 20px; border-radius: 10px; margin-bottom: 10px;'>
<div style='background-color: #4B89DC; width: {accuracy}%; height: 20px; border-radius: 10px;'></div>
</div>
</div>
""", 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("<br>", 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("""
<div style='text-align: center; margin-bottom: 20px;'>
<h2 style='color: #4B89DC;'>📚 학습 기록</h2>
</div>
""", 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(
"""
<div style='text-align: center; margin-bottom: 30px;'>
<h1 style='font-size:48px; color: #4B89DC;'>🔊앵무새 스쿨</h1>
<p style='font-size: 20px; color: #555;'>
<b>영어공인시험 학습 프로그램</b>
</p>
</div>
""", unsafe_allow_html=True
)
# 사이드바에 사용자 정보 표시
with st.sidebar:
st.markdown(f"""
<div style='text-align: center; padding: 10px; background-color: #f0f8ff; border-radius: 10px; margin-bottom: 20px;'>
<h3 style='color: #4B89DC;'>👤 닉네임: {st.session_state.get('nickname', '')}</h3>
</div>
""", 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("페이지를 새로고침하거나 다시 시도해주세요.")