angmuse_school/combined_app.py

1126 lines
50 KiB
Python

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
import random
import difflib
# 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)
# 미리 준비한 이미지 파일 경로 리스트
PRESET_IMAGES = [
"preset1.png",
"preset2.png",
"preset3.png",
"preset4.png",
"preset5.png"
]
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"] = "en-US-Neural2-F" # 더 자연스러운 영어 음성으로 변경
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')
# 이미지 비율 유지하면서 크기 조정
guide_img.thumbnail((300, 300), Image.Resampling.LANCZOS)
st.markdown(
f"""
<div style="text-align: center; padding-bottom: 10px;">
<img src="data:image/png;base64,{img_to_base64(guide_img)}"
style="max-width: 100%; height: auto; 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
}
# 이미지 크기 조정
display_img = img.copy()
display_img.thumbnail((300, 300), Image.Resampling.LANCZOS)
with st.container(border=True):
st.image(display_img, use_column_width=True)
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:
# 이미지 크기 조정
display_img = img.copy()
display_img.thumbnail((300, 300), Image.Resampling.LANCZOS)
with st.container(border=True):
st.image(display_img, use_column_width=True)
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")
model = genai.GenerativeModel(
GEMINI_MODEL,
generation_config={
"temperature": 0.7,
"top_p": 0.8,
"top_k": 40,
"max_output_tokens": 2048,
}
)
return model
def generate_quiz(img: ImageFile.ImageFile, group: str, difficulty: str):
if not can_generate_more_questions():
return None, None, None, None
max_retries = 10 # 재시도 횟수 증가
retry_delay = 2
def is_complete_sentence(s):
s = s.strip()
if len(s) < 5:
return False
if len(s.split()) < 3:
return False
if not s[0].isalpha() or not s[0].isupper():
return False
if not s.endswith('.'):
return False
return True
for attempt in range(max_retries):
try:
with st.spinner(f"문제를 생성하는 중입니다... (시도 {attempt + 1}/{max_retries})"):
# 1. 이미지 설명 생성
prompt_desc = IN_DIR / "p1_desc.txt"
sys_prompt_desc = prompt_desc.read_text(encoding="utf8")
model_desc = get_model()
try:
resp_desc = model_desc.generate_content(
[img, f"{sys_prompt_desc}\nDescribe this image in detail."],
safety_settings=[
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}
]
)
except Exception as e:
raise ValueError(f"이미지 설명 생성 중 오류 발생: {str(e)}")
if not resp_desc or not resp_desc.text:
raise ValueError("이미지 설명을 생성할 수 없습니다.")
description = resp_desc.text.strip()
if not description:
raise ValueError("생성된 이미지 설명이 비어있습니다.")
# 2. 퀴즈 생성
quiz_prompt_path = get_prompt(group, difficulty)
sys_prompt_quiz = quiz_prompt_path.read_text(encoding="utf8")
model_quiz = get_model()
# 시험 유형과 난이도 컨텍스트 추가
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, "중급 수준의")
# 3. AI에 퀴즈 생성 요청
prompt = f"""You are an expert English teacher. Create a listening comprehension quiz based on this image description:
{description}
{exam_type} {level} 문제를 생성해주세요.
Create a question that requires careful listening to understand which of the four choices is correct. All four choices should be related to the image, but only one should be the correct answer to the specific question asked.
IMPORTANT:
- Do NOT create True/False or single letter answers.
- The correct answer MUST be a full sentence, at least 3 words, ending with a period, and must be EXACTLY one of the choices below.
- Each choice MUST be a full sentence, at least 3 words, ending with a period.
- If you output a single letter or a True/False answer, you will be rejected and must try again.
Example:
Question: What is the main activity in the office?
Correct Answer: The employees are participating in a training session.
Choices: [
"The employees are participating in a training session.",
"The manager is conducting a performance review with staff.",
"The team is presenting the quarterly sales report to the board.",
"The executives are discussing the marketing strategy."
]
Remember to format your response EXACTLY as follows:
Question: [Write a specific question that requires listening carefully to understand which choice is correct]
Correct Answer: [Write the correct sentence that matches the question]
Choices: [
"Correct Answer",
"First incorrect answer (related to image but wrong for the specific question)",
"Second incorrect answer (related to image but wrong for the specific question)",
"Third incorrect answer (related to image but wrong for the specific question)"
]"""
try:
resp_quiz = model_quiz.generate_content(
prompt,
safety_settings=[
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}
]
)
except Exception as e:
raise ValueError(f"퀴즈 생성 중 오류 발생: {str(e)}")
if not resp_quiz or not resp_quiz.text:
raise ValueError("퀴즈를 생성할 수 없습니다.")
quiz_text = resp_quiz.text.strip()
if not quiz_text:
raise ValueError("생성된 퀴즈가 비어있습니다.")
# 4. 응답 파싱
try:
# Question 파싱
question_match = re.search(r'Question:\s*(.*?)(?=\nCorrect Answer:|$)', quiz_text, re.MULTILINE | re.DOTALL)
if not question_match:
raise ValueError("Question을 찾을 수 없습니다.")
question = question_match.group(1).strip()
# Correct Answer 파싱
ans_match = re.search(r'Correct Answer:\s*(.*?)(?=\nChoices:|$)', quiz_text, re.MULTILINE | re.DOTALL)
if not ans_match:
raise ValueError("Correct Answer를 찾을 수 없습니다.")
correct_answer = ans_match.group(1).strip()
# Choices 파싱
choices_match = re.search(r'Choices:\s*\[(.*?)\]', quiz_text, re.MULTILINE | re.DOTALL)
if not choices_match:
raise ValueError("Choices를 찾을 수 없습니다.")
choices_str = choices_match.group(1)
# 따옴표로 둘러싸인 문자열을 정확히 추출
choices = re.findall(r'"([^"]*)"', choices_str)
if not choices:
# 따옴표가 없는 경우 쉼표로 분리
choices = [choice.strip().strip('"\'') for choice in choices_str.split(',')]
# 선택지 검증
if len(choices) != 4:
raise ValueError(f"선택지가 4개가 아닙니다. (현재: {len(choices)}개)\n선택지 내용: {choices}")
if any(not choice for choice in choices):
raise ValueError("빈 선택지가 있습니다.")
if len(set(choices)) != len(choices):
raise ValueError("중복된 선택지가 있습니다.")
# T/F 또는 True/False 객관식 방지
lower_choices = [c.strip().lower() for c in choices]
if set(lower_choices) in [{"t", "f"}, {"true", "false"}] or any(c in {"t", "f", "true", "false"} for c in lower_choices):
raise ValueError(f"T/F 또는 True/False 객관식이 감지되어 문제를 재생성합니다.\nChoices: {choices}")
if correct_answer.strip().lower() in {"t", "f", "true", "false"}:
raise ValueError(f"정답이 T/F 또는 True/False로 감지되어 문제를 재생성합니다. 정답: {correct_answer}")
# 정답/선택지 모두 완전한 문장인지 검증
if not is_complete_sentence(correct_answer) or any(not is_complete_sentence(c) for c in choices):
continue # 재생성
# 중복 문제 방지: 이미 푼 문제(question)가 있으면 예외 발생시켜 재생성
if "answered_questions" in st.session_state and question in st.session_state["answered_questions"]:
raise ValueError(f"중복된 문제입니다: '{question}'")
st.session_state["question_count"] = st.session_state.get("question_count", 0) + 1
return question, correct_answer, choices, description
except Exception as e:
continue # 파싱 실패 시 재생성
except Exception as e:
if attempt < max_retries - 1:
st.warning(f"문제 생성 중 오류가 발생했습니다: {str(e)}\n{retry_delay}초 후 다시 시도합니다... ({attempt + 1}/{max_retries})")
time.sleep(retry_delay)
else:
st.error(f"문제 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.\n오류 내용: {str(e)}")
for key in ["quiz", "answ", "choices", "audio"]:
if key in st.session_state:
del st.session_state[key]
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
question, correct_answer, choices, full_desc = generate_quiz(img, group, difficulty)
if not question or not correct_answer or not choices:
st.session_state["quiz"] = []
st.session_state["answ"] = []
st.session_state["audio"] = []
st.session_state["choices"] = []
st.session_state["quiz_data"] = []
st.error("문제 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.")
return
if isinstance(choices[0], list):
choices = choices[0]
question_audio = question
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 = "음성을 듣고 질문에 맞는 답을 선택하세요."
st.session_state["img"] = img
st.session_state["quiz"] = [quiz_display]
st.session_state["answ"] = [correct_answer]
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"):
quiz_run_id = st.session_state.get("quiz_run_id", "default")
def is_valid_sentence(s):
s = s.strip()
if len(s) < 5:
return False
if len(s.split()) < 3:
return False
if not s[0].isalpha() or not s[0].isupper():
return False
if not s.endswith('.'):
return False
return True
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}_{quiz_run_id}"
init_session({key_feedback: "", f"submitted_{idx}_{quiz_run_id}": False})
with st.form(f"form_question_{idx}_{quiz_run_id}", 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)
st.markdown(f"<p style='font-size:17px;'>{quiz}</p>", unsafe_allow_html=True)
if not choices:
st.error("선택지가 없습니다. 다시 문제를 생성하세요.")
continue
key_choice = f"choice_{idx}_0_{quiz_run_id}"
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}_{quiz_run_id}"):
if not is_valid_sentence(user_choice):
st.error("정답은 반드시 3단어 이상, 5글자 이상, 대문자로 시작하고 마침표로 끝나는 완전한 문장이어야 합니다.")
else:
st.session_state[f"submitted_{idx}_{quiz_run_id}"] = True
with st.spinner("채점 중입니다..."):
# 정답 비교는 완전한 문장일 때만
if is_valid_sentence(answ[idx]):
is_correct = user_choice.strip().lower() == answ[idx].strip().lower()
else:
is_correct = False
st.session_state["last_question"] = quiz
st.session_state["last_user_choice"] = user_choice
st.session_state["last_correct_answer"] = answ[idx]
update_score(quiz, is_correct)
if is_correct:
feedback = "✅ 정답입니다! 🎉"
else:
feedback = f"❌ 오답입니다.\n\n{generate_feedback(user_choice, answ[idx])}"
st.session_state[key_feedback] = feedback
feedback = st.session_state.get(key_feedback, "")
if feedback:
with st.expander("📚 해설 보기", expanded=True):
if is_valid_sentence(answ[idx]):
st.markdown(f"**정답:** {answ[idx]}")
# else: 아무것도 표시하지 않음
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)
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()
# 로그인 상태 확인
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-allow: 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()
# 이미지 선택 (로그아웃 바로 아래)
IMAGE_LABELS = [
"수업을 듣는 초등학생",
"책을 보고 있는 한 여성",
"봄날의 운동장",
"아침의 식탁",
"놀이공원에서 즐기는 아이들"
]
# selectbox를 index 기반으로, label_visibility를 'visible'로 하여 텍스트 입력 불가하게
img_index = PRESET_IMAGES.index(st.session_state.get("sidebar_img_choice", PRESET_IMAGES[0])) if "sidebar_img_choice" in st.session_state else 0
img_index = st.selectbox(
"이미지 선택",
range(len(PRESET_IMAGES)),
format_func=lambda i: IMAGE_LABELS[i],
key="sidebar_img_index",
label_visibility="visible"
)
img_choice = PRESET_IMAGES[img_index]
st.session_state["sidebar_img_choice"] = img_choice
# 이미지 선택창과 이미지가 항상 함께 보이도록
img = Image.open(f"img/{img_choice}")
st.image(img, caption=f"선택된 이미지: {IMAGE_LABELS[img_index]}", use_column_width=True)
# 이미지 선택 시 퀴즈 생성 콜백 (난이도 선택 아래로 이동)
# 메인 컨텐츠
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="선택한 난이도에 따라 문제의 복잡도가 달라집니다.",
key="difficulty_selectbox"
)
difficulty_mapping = {
"쉬움": "easy",
"중간": "normal",
"어려움": "hard"
}
global_difficulty = difficulty_mapping.get(difficulty_display, "normal")
st.session_state["global_difficulty"] = global_difficulty
# 이미지 선택 시 퀴즈 생성 콜백 (난이도 선택 아래로 이동)
img_choice = st.session_state.get("sidebar_img_choice", PRESET_IMAGES[0])
img = Image.open(f"img/{img_choice}")
# 문제 표시 (세션에 퀴즈가 있으면)
if st.session_state.get("quiz"):
show_quiz()
if st.session_state.get("quiz_data"):
show_score_summary()
show_learning_history()
# '새로운 문제' 버튼: 항상 퀴즈 아래에 표시
if st.button("새로운 문제", type="primary"):
reset_quiz()
st.session_state["quiz_run_id"] = str(time.time()) # 새로운 문제마다 고유한 ID 생성
st.rerun()
except Exception as e:
st.error(f"오류가 발생했습니다: {str(e)}")
st.info("페이지를 새로고침하거나 다시 시도해주세요.")