diff --git a/combined_app.py b/combined_app.py index ab8ee54..fce23de 100644 --- a/combined_app.py +++ b/combined_app.py @@ -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,16 +819,22 @@ 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) if submitted and not st.session_state.get(f"submitted_{idx}_{quiz_run_id}"): @@ -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"