Update combined_app.py

main
no 2025-05-26 08:24:09 +00:00
parent ba7878220e
commit 59805cff64
1 changed files with 190 additions and 122 deletions

View File

@ -21,6 +21,7 @@ import difflib
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)
@ -34,6 +35,14 @@ PRESET_IMAGES = [
"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="앵무새 스쿨",
@ -147,8 +156,8 @@ def show_auth_page():
st.success(f"회원님의 아이디는 **{username}** 입니다.")
else:
st.error("입력하신 정보와 일치하는 계정이 없습니다.")
# 비밀번호 재설정
else:
else: # 비밀번호 재설정
with st.form("reset_password_form", border=True):
username = st.text_input("아이디", placeholder="아이디를 입력하세요")
email = st.text_input("이메일", placeholder="가입 시 등록한 이메일을 입력하세요")
@ -330,12 +339,14 @@ def uploaded_image(on_change=None, args=None) -> Image.Image | 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",
@ -343,6 +354,7 @@ def get_prompt(group: str, difficulty: str = None) -> Path:
"toefl": "TOEFL"
}
# Map difficulty to exam level
difficulty_mapping = {
"easy": "easy",
"normal": "medium",
@ -351,6 +363,8 @@ def get_prompt(group: str, difficulty: str = None) -> Path:
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
@ -381,6 +395,43 @@ def get_model() -> genai.GenerativeModel:
)
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
@ -563,7 +614,7 @@ Choices: [
or len(correct_answer.strip().split()) == 1
or any(len(c.strip().split()) == 1 for c in choices)
):
continue
continue # 재생성
# 중복 문제 방지: 이미 푼 문제(question)가 있으면 예외 발생시켜 재생성
if "answered_questions" in st.session_state and question in st.session_state["answered_questions"]:
@ -629,33 +680,87 @@ def set_quiz(img: ImageFile.ImageFile, group: str, difficulty: str):
return
if isinstance(choices[0], list):
choices = choices[0]
# 시험 유형과 난이도에 따른 음성 길이/스타일 조절 (모든 조합 커버, 12가지)
# 시험 유형과 난이도에 따른 프리앰블(더 길고 자연스럽게)
if group == "yle" and difficulty == "easy":
question_audio = f"Listen carefully. {question}"
preamble = (
"이제 곧 들려드릴 문제는 YLE 시험의 쉬운 난이도에 맞춰 출제되었습니다. "
"문제를 주의 깊게 듣고, 각 선택지를 잘 비교해 보세요. "
"정확한 답을 고르기 위해 모든 정보를 신중히 생각해 보세요. "
)
elif group == "yle" and difficulty == "normal":
question_audio = f"Listen to the following question and think carefully before you answer. {question}"
preamble = (
"이제 YLE 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 차이점을 잘 파악해 보세요. "
)
elif group == "yle" and difficulty == "hard":
question_audio = f"Listen closely and try to understand all the details in the question. {question}"
preamble = (
"이제 YLE 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toefl_junior" and difficulty == "easy":
question_audio = f"Please listen to the question and choose the best answer. {question}"
preamble = (
"TOEFL Junior 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. "
)
elif group == "toefl_junior" and difficulty == "normal":
question_audio = f"Listen carefully to the following question and take your time to answer. {question}"
preamble = (
"TOEFL Junior 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 의미를 잘 파악해 보세요. "
)
elif group == "toefl_junior" and difficulty == "hard":
question_audio = f"Listen very carefully and consider all the information before you answer. {question}"
preamble = (
"TOEFL Junior 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toeic" and difficulty == "easy":
question_audio = f"Listen to the question and select the most appropriate answer. {question}"
preamble = (
"TOEIC 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. "
)
elif group == "toeic" and difficulty == "normal":
question_audio = f"Listen carefully and pay attention to the details in the question. {question}"
preamble = (
"TOEIC 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. "
"각 선택지의 의미를 잘 파악해 보세요. "
)
elif group == "toeic" and difficulty == "hard":
question_audio = f"Listen to the following question. Consider all the details and select the most accurate answer. {question}"
preamble = (
"TOEIC 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. "
"모든 정보를 종합적으로 고려해 보세요. "
)
elif group == "toefl" and difficulty == "easy":
question_audio = f"Listen to the question and answer carefully. {question}"
preamble = (
"TOEFL 시험의 쉬운 난이도 문제입니다. "
"문제를 잘 듣고, 각 선택지를 꼼꼼히 비교해 보세요. "
"정확한 답을 찾기 위해 집중해서 들어주세요. 예시와 세부 설명을 참고하여 답을 선택해 보세요. "
)
elif group == "toefl" and difficulty == "normal":
question_audio = f"Listen carefully to the following question. Think about all the information before answering. {question}"
preamble = (
"TOEFL 시험의 중간 난이도 문제를 들려드리겠습니다. "
"문제와 선택지를 모두 주의 깊게 듣고, 정답을 고르기 전에 한 번 더 생각해 보세요. 각 선택지의 의미와 맥락을 잘 파악해 보세요. 추가적인 정보와 예시를 활용해 보세요. "
)
elif group == "toefl" and difficulty == "hard":
question_audio = f"Listen very carefully. Consider all aspects and select the most accurate answer. {question}"
preamble = (
"TOEFL 시험의 어려운 난이도 문제입니다. "
"문제와 선택지를 집중해서 듣고, 세부적인 내용까지 신경 써서 정답을 골라보세요. 고급 어휘와 복잡한 상황 설명을 이해하고, 모든 정보를 종합적으로 고려해 보세요. 예시와 추가 설명을 참고하여 답을 선택해 보세요. "
)
else:
question_audio = question
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:
@ -714,15 +819,21 @@ def show_quiz(difficulty="medium"):
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: ""})
if st.session_state[key_choice] not in choices:
st.session_state[key_choice] = choices[0]
user_choice = st.radio(
"보기 중 하나를 선택하세요👇",
choices,
key=key_choice
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)
@ -732,20 +843,18 @@ def show_quiz(difficulty="medium"):
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
# 정답 인덱스
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 = "정답입니다."
feedback = "정답입니다!"
else:
feedback = f"오답입니다.\n정답: {answ[idx]}\n\n{generate_feedback(user_choice, answ[idx])}"
feedback = f"오답입니다.\n정답: {answ[idx]}\n\n{generate_feedback(user_choice, answ[idx])}"
st.session_state[key_feedback] = feedback
@ -761,48 +870,38 @@ def show_quiz(difficulty="medium"):
def update_score(question: str, is_correct: bool):
init_score()
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 "정답입니다."
# 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, # 정답은 10점, 오답은 0점
"score": 10 if is_correct else 0,
"timestamp": pd.Timestamp.now(),
"feedback": feedback,
"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["answered_questions"])
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, # 정답은 10점, 오답은 0점
total_questions=current_question_count, # 현재까지 푼 문제 수
score=10 if is_correct else 0,
total_questions=current_question_count,
question_content=st.session_state.get("last_question", ""),
feedback=feedback,
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:
@ -826,14 +925,12 @@ def generate_feedback(user_input: str, answ: str) -> str:
return f"(⚠️ 피드백 생성 중 오류: {e})"
def show_score_summary():
total = st.session_state.get("question_count", 0)
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"<div style='text-align:center;'><b>현재 푼 문제 수:</b> {total} &nbsp;&nbsp; <b>정답률:</b> {accuracy}% &nbsp;&nbsp; <b>현재 점수:</b> {score}점</div>", unsafe_allow_html=True)
if total == 0:
st.info("아직 문제를 풀지 않았어요! 문제를 풀고 점수를 확인해보세요.")
elif accuracy == 100 and total > 0:
@ -846,80 +943,45 @@ def show_score_summary():
st.info("조금 더 연습하면 더 좋아질 거예요!")
def reset_quiz():
if st.session_state.get("quiz"):
st.markdown("<br>", unsafe_allow_html=True)
if st.button("🔄 새로운 문제", type="primary"):
auth_state = {
"authenticated": st.session_state.get("authenticated", False),
"username": st.session_state.get("username", ""),
"user_id": st.session_state.get("user_id", None)
}
img_state = {
"has_image": st.session_state.get("has_image", False),
"img_bytes": st.session_state.get("img_bytes", None),
"img": st.session_state.get("img", None),
"last_image": st.session_state.get("last_image", None),
"sidebar_img_choice": st.session_state.get("sidebar_img_choice", None)
}
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),
"current_group": st.session_state.get("current_group", "default"),
"global_difficulty": st.session_state.get("global_difficulty", "normal")
}
keys_to_clear = ["quiz", "answ", "audio", "choices"]
for key in keys_to_clear:
if key in st.session_state:
del st.session_state[key]
for key in list(st.session_state.keys()):
if key.startswith(("submitted_", "feedback_", "choice_", "form_question_")):
del st.session_state[key]
st.session_state.update(auth_state)
st.session_state.update(img_state)
st.session_state.update(score_state)
if st.session_state.get("img") and st.session_state.get("question_count", 0) < 10:
set_quiz(
st.session_state["img"],
st.session_state.get("current_group", "default"),
st.session_state.get("global_difficulty", "normal")
)
st.rerun()
# 문제 관련 세션 상태만 초기화 (키가 없으면 기본값으로 초기화)
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("""
<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'])
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",
@ -928,13 +990,14 @@ def show_learning_history():
"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점)
# 누적 점수와 문제 수를 정확히 계산
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']:
@ -942,11 +1005,10 @@ def show_learning_history():
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,
@ -968,8 +1030,10 @@ def clear_all_scores():
st.success("현재 점수가 초기화되었습니다.")
st.rerun()
# Main application
if __name__ == "__main__":
try:
# Initialize page configuration first
init_page()
@ -1021,6 +1085,7 @@ if __name__ == "__main__":
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()
@ -1103,7 +1168,10 @@ if __name__ == "__main__":
if st.session_state.get("question_count", 0) < 10:
if st.button("새로운 문제", type="primary"):
reset_quiz()
st.session_state["quiz_run_id"] = str(time.time())
# 새로운 문제 즉시 생성
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문제를 모두 풀었습니다! 다른 이미지를 선택해 주세요.")