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"
]
# 시험/난이도별 최소 음성 길이(초)
MIN_AUDIO_SECONDS = {
'yle': {'easy': 10, 'normal': 20, 'hard': 25},
'toefl_junior': {'easy': 30, 'normal': 40, 'hard': 45},
'toeic': {'easy': 50, 'normal': 55, 'hard': 60},
'toefl': {'easy': 70, 'normal': 80, 'hard': 90}
}
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"] = []
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(
"이미지 업로드
",
unsafe_allow_html=True
)
# 안내 이미지 표시 (실패해도 계속 진행)
try:
guide_img = Image.open('img/angmose.jpg')
# 이미지 비율 유지하면서 크기 조정
guide_img.thumbnail((300, 300), Image.Resampling.LANCZOS)
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
}
# 이미지 크기 조정
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 estimate_audio_seconds(text):
# 한글/영어 혼합 기준: 1초 ≈ 7글자
return max(len(text) // 7, 1)
def get_extra_sentence(group, difficulty):
# 시험/난이도별로 난이도 차이 반영한 추가 문장
if group == "yle":
if difficulty == "easy":
return "문제를 다시 한 번 잘 듣고, 보기 중에서 가장 알맞은 답을 골라보세요. "
elif difficulty == "normal":
return "각 선택지를 주의 깊게 듣고, 문제의 핵심 내용을 파악해 보세요. 정답을 고르기 전에 한 번 더 생각해 보세요. "
else:
return "문제와 선택지의 세부적인 차이점을 잘 구분해 보세요. 모든 정보를 종합적으로 고려해서 정답을 선택해 보세요. "
elif group == "toefl_junior":
if difficulty == "easy":
return "문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. 정답을 찾기 위해 집중해서 들어주세요. "
elif difficulty == "normal":
return "문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. 각 선택지의 의미를 잘 파악해 보세요. "
else:
return "문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. 모든 정보를 종합적으로 고려해 보세요. "
elif group == "toeic":
if difficulty == "easy":
return "문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. 정답을 찾기 위해 집중해서 들어주세요. "
elif difficulty == "normal":
return "문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. 각 선택지의 의미를 잘 파악해 보세요. "
else:
return "문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. 모든 정보를 종합적으로 고려해 보세요. "
elif group == "toefl":
if difficulty == "easy":
return "문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. 정답을 찾기 위해 집중해서 들어주세요. 예시와 세부 설명을 참고하여 답을 선택해 보세요. "
elif difficulty == "normal":
return "문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. 각 선택지의 의미와 맥락을 잘 파악해 보세요. 추가적인 정보와 예시를 활용해 보세요. "
else:
return "문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. 고급 어휘와 복잡한 상황 설명을 이해하고, 모든 정보를 종합적으로 고려해 보세요. 예시와 추가 설명을 참고하여 답을 선택해 보세요. "
else:
return "Listen carefully to all the information and take your time to answer. "
def generate_quiz(img: ImageFile.ImageFile, group: str, difficulty: str):
if not can_generate_more_questions():
return None, None, None, None
max_retries = 15 # 재시도 횟수 더 증가
retry_delay = 2
def is_complete_sentence(s):
s = s.strip()
if s.lower() in {"t", "f", "true", "false"}:
return False
if len(s) < 15:
return False
if len(s.split()) < 5:
return False
if not s[0].isalpha() or not s[0].isupper():
return False
if not s.endswith('.'):
return False
# 주어/동사 포함 등 추가 검증
if not re.search(r'\b(I|You|He|She|They|We|It|[A-Z][a-z]+)\b', s):
return False
if not re.search(r'\b(is|are|was|were|am|be|being|been|has|have|had|does|do|did|go|goes|went|see|sees|saw|make|makes|made|play|plays|played|study|studies|studied|work|works|worked|run|runs|ran|eat|eats|ate|read|reads|read|write|writes|wrote|draw|draws|drew|paint|paints|painted|talk|talks|talked|say|says|said|give|gives|gave|take|takes|took|sit|sits|sat|stand|stands|stood|walk|walks|walked|look|looks|looked|show|shows|showed|help|helps|helped|teach|teaches|taught|learn|learns|learned|prepare|prepares|prepared|discuss|discusses|discussed|analyze|analyzes|analyzed|explain|explains|explained|organize|organizes|organized|participate|participates|participated|present|presents|presented|review|reviews|reviewed|plan|plans|planned|report|reports|reported|listen|listens|listened|watch|watches|watched|clean|cleans|cleaned)\b', s, re.IGNORECASE):
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)
or correct_answer.strip().lower() in {"t", "f", "true", "false"}
or any(c.strip().lower() in {"t", "f", "true", "false"} for c in choices)
or len(correct_answer.strip().split()) == 1
or any(len(c.strip().split()) == 1 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"] and st.session_state.get("question_count", 0) < 10:
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]
# 시험 유형과 난이도에 따른 프리앰블(더 길고 자연스럽게)
if group == "yle" and difficulty == "easy":
preamble = (
"이제 곧 들려드릴 문제는 YLE 시험의 쉬운 난이도에 맞춰 출제되었습니다. "
"문제를 주의 깊게 듣고, 각 선택지를 잘 비교해 보세요. "
"정확한 답을 고르기 위해 모든 정보를 신중히 생각해 보세요. "
)
elif group == "yle" and difficulty == "normal":
preamble = (
"이제 YLE 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 차이점을 잘 파악해 보세요. "
)
elif group == "yle" and difficulty == "hard":
preamble = (
"이제 YLE 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toefl_junior" and difficulty == "easy":
preamble = (
"TOEFL Junior 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. "
)
elif group == "toefl_junior" and difficulty == "normal":
preamble = (
"TOEFL Junior 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 의미를 잘 파악해 보세요. "
)
elif group == "toefl_junior" and difficulty == "hard":
preamble = (
"TOEFL Junior 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toeic" and difficulty == "easy":
preamble = (
"TOEIC 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. "
)
elif group == "toeic" and difficulty == "normal":
preamble = (
"TOEIC 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 의미를 잘 파악해 보세요. "
)
elif group == "toeic" and difficulty == "hard":
preamble = (
"TOEIC 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toefl" and difficulty == "easy":
preamble = (
"TOEFL 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. 예시와 세부 설명을 참고하여 답을 선택해 보세요. "
)
elif group == "toefl" and difficulty == "normal":
preamble = (
"TOEFL 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. 각 선택지의 의미와 맥락을 잘 파악해 보세요. 추가적인 정보와 예시를 활용해 보세요. "
)
elif group == "toefl" and difficulty == "hard":
preamble = (
"TOEFL 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. 고급 어휘와 복잡한 상황 설명을 이해하고, 모든 정보를 종합적으로 고려해 보세요. 예시와 추가 설명을 참고하여 답을 선택해 보세요. "
)
else:
preamble = "Listen carefully to the following question and all the choices. Take your time to think before answering. "
question_audio = f"{preamble}{question}"
# 최소 길이 보장: 부족하면 추가 문장 반복
min_sec = MIN_AUDIO_SECONDS.get(group, {}).get(difficulty, 10)
while estimate_audio_seconds(question_audio) < min_sec:
question_audio += get_extra_sentence(group, difficulty)
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("""
문제
""", unsafe_allow_html=True)
st.audio(audio)
st.markdown(f"{quiz}
", unsafe_allow_html=True)
if not choices:
st.error("선택지가 없습니다. 다시 문제를 생성하세요.")
continue
# 보기 번호와 문장 함께 표시
numbered_choices = [f"{i+1}. {c}" for i, c in enumerate(choices)]
for nc in numbered_choices:
st.markdown(nc)
key_choice = f"choice_{idx}_0_{quiz_run_id}"
init_session({key_choice: "1"}) # 기본값 1번
user_choice_num = st.radio(
"번호를 선택하세요👇",
["1", "2", "3", "4"],
key=key_choice,
format_func=lambda x: f"{x}번"
)
user_choice_idx = int(user_choice_num) - 1
user_choice = choices[user_choice_idx]
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("채점 중입니다..."):
# 정답 인덱스
correct_idx = choices.index(answ[idx]) if answ[idx] in choices else 0
is_correct = (user_choice_idx == correct_idx)
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정답: {answ[idx]}\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]}")
st.markdown(feedback, unsafe_allow_html=True)
# 정답을 항상 명확하게 표시
if is_valid_sentence(answ[idx]):
st.info(f"정답: {answ[idx]}")
def update_score(question: str, is_correct: bool):
init_score()
# Only update if this question hasn't been answered before
if question not in [q["question"] for q in st.session_state["quiz_data"]]:
st.session_state["quiz_data"].append({
"question": question,
"correct": is_correct,
"score": 10 if is_correct else 0,
"timestamp": pd.Timestamp.now(),
"feedback": generate_feedback(
st.session_state.get("last_user_choice", ""),
st.session_state.get("last_correct_answer", "")
) if not is_correct else "정답입니다.",
"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["quiz_data"])
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,
total_questions=current_question_count,
question_content=st.session_state.get("last_question", ""),
feedback=st.session_state["quiz_data"][-1]["feedback"],
user_choice=st.session_state.get("last_user_choice", ""),
correct_answer=st.session_state.get("last_correct_answer", "")
)
# 항상 동기화
st.session_state["total_questions"] = len(st.session_state["quiz_data"])
st.session_state["correct_answers"] = sum(1 for q in st.session_state["quiz_data"] if q["correct"])
st.session_state["total_score"] = min(sum(q["score"] for q in st.session_state["quiz_data"]), 100)
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():
total = st.session_state.get("total_questions", 0)
correct = st.session_state.get("correct_answers", 0)
score = st.session_state.get("total_score", 0)
accuracy = round((correct / total) * 100, 1) if total else 0.0
st.markdown("---")
st.markdown(f"현재 푼 문제 수: {total} 정답률: {accuracy}% 현재 점수: {score}점
", unsafe_allow_html=True)
if total == 0:
st.info("아직 문제를 풀지 않았어요! 문제를 풀고 점수를 확인해보세요.")
elif accuracy == 100 and total > 0:
st.success("🎉 완벽합니다! 계속 도전해보세요!")
elif accuracy >= 80:
st.info("아주 잘하고 있어요! 조금만 더 힘내요!")
elif accuracy >= 60:
st.info("좋아요! 계속 연습해봐요!")
else:
st.info("조금 더 연습하면 더 좋아질 거예요!")
def reset_quiz():
# 문제 관련 세션 상태만 초기화 (키가 없으면 기본값으로 초기화)
keys_defaults = {
"quiz": [],
"answ": [],
"audio": [],
"choices": []
}
for key, default in keys_defaults.items():
st.session_state[key] = default
# 폼 관련 상태 초기화
for key in list(st.session_state.keys()):
if key.startswith(("submitted_", "feedback_", "choice_", "form_question_")):
del st.session_state[key]
# quiz_run_id 갱신
st.session_state["quiz_run_id"] = str(time.time())
# 문제 생성은 메인 코드에서 처리
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'])
if 'score' not in history_df.columns:
history_df['score'] = 0
history_df['score'] = history_df['score'].fillna(0)
# 날짜/시간 포맷 변경
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)
# 누적 점수와 문제 수를 정확히 계산
n = len(history_df)
# 문제 수: 최근 기록이 1, 그 전이 2, ... 마지막이 n
total_questions = list(range(n, 0, -1))
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
history_df['total_questions'] = total_questions
# 표시할 컬럼만 선택
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(
"""
""", 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()
# 이미지 선택 (로그아웃 바로 아래)
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 not st.session_state.get("quiz") and st.session_state.get("question_count", 0) < 10:
set_quiz(img, st.session_state.get("current_group", "default"), st.session_state.get("global_difficulty", "normal"))
if st.session_state.get("quiz"):
show_quiz()
if st.session_state.get("quiz_data"):
show_score_summary()
show_learning_history()
# '새로운 문제' 버튼: 10문제까지 허용, 그 이상은 비활성화 및 안내
if st.session_state.get("question_count", 0) < 10:
if st.button("새로운 문제", type="primary"):
reset_quiz()
# 새로운 문제 즉시 생성
img_choice = st.session_state.get("sidebar_img_choice", PRESET_IMAGES[0])
img = Image.open(f"img/{img_choice}")
set_quiz(img, st.session_state.get("current_group", "default"), st.session_state.get("global_difficulty", "normal"))
st.rerun()
else:
st.info("이 이미지로 풀 수 있는 최대 10문제를 모두 풀었습니다! 다른 이미지를 선택해 주세요.")
except Exception as e:
st.error(f"오류가 발생했습니다: {str(e)}")
st.info("페이지를 새로고침하거나 다시 시도해주세요.")
# '새로운 문제' 버튼: 항상 퀴즈 아래에 표시
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("페이지를 새로고침하거나 다시 시도해주세요.")