Spaces:
Paused
Paused
| import os | |
| import streamlit as st | |
| import datetime # Not strictly used in this version, but often useful | |
| import google.generativeai as genai | |
| import time | |
| import google.api_core.exceptions # 오류 처리를 위해 추가 | |
| # --- Streamlit 설정 --- | |
| st.set_page_config( | |
| page_title="과학 탐구 도우미 🔬", | |
| page_icon="🔬", | |
| layout="wide", | |
| ) | |
| # --- Custom CSS (스타일링) --- | |
| st.markdown( | |
| """ | |
| <style> | |
| /* 전체 폰트 변경 (Nanum Gothic, Google Fonts CDN 사용) */ | |
| @import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700&display=swap'); | |
| body { | |
| font-family: 'Nanum Gothic', sans-serif !important; | |
| background-color: #f4f6f9; /* 약간 더 부드러운 배경색 */ | |
| } | |
| /* --- Chat UI 개선 --- */ | |
| .message-container { | |
| min-height: 300px; /* 최소 높이 보장 */ | |
| max-height: 55vh; /* Adjusted height */ | |
| overflow-y: auto; | |
| padding: 15px; | |
| margin-bottom: 15px; /* Reduced margin */ | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid #d1d9e6; | |
| border-radius: 10px; | |
| background-color: #ffffff; | |
| } | |
| .stChatMessage[data-testid="stChatMessage"] { | |
| border-radius: 15px; padding: 12px 18px; margin-bottom: 10px; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.08); | |
| width: fit-content; max-width: 80%; | |
| font-size: 0.95rem; /* Slightly smaller chat font */ | |
| word-wrap: break-word; border: none; | |
| } | |
| div[data-testid="stChatMessage"]:has(div[data-testid="stChatMessageContent"][aria-label="user message"]) { | |
| display: flex; flex-direction: column; align-items: flex-end; margin-left: auto; | |
| } | |
| div[data-testid="stChatMessageContent"][aria-label="user message"] { | |
| background-color: #007bff; /* 사용자 메시지 배경색 (더 선명한 파랑) */ | |
| color: white; | |
| } | |
| div[data-testid="stChatMessage"]:has(div[data-testid="stChatMessageContent"][aria-label="assistant message"]) { | |
| display: flex; flex-direction: column; align-items: flex-start; margin-right: auto; | |
| } | |
| div[data-testid="stChatMessageContent"][aria-label="assistant message"] { | |
| background-color: #e9ecef; color: #383d41; border: 1px solid #ced4da; | |
| } | |
| /* Toast message 스타일 */ | |
| div.streamlit-toast-container { z-index: 10000; } | |
| div[data-testid="stToast"] { | |
| border-radius: 10px; padding: 15px 20px; /* Adjusted padding */ | |
| box-shadow: 3px 3px 8px rgba(0,0,0,0.1); /* Adjusted shadow */ | |
| font-size: 0.9rem; | |
| } | |
| /* 섹션 제목 스타일 */ | |
| .section-title { | |
| font-size: 26px; font-weight: 700; color: #004085; /* 더 진한 파란색 */ | |
| margin-bottom: 20px; padding-bottom: 12px; | |
| border-bottom: 3px solid #007bff; /* 더 선명한 파란색 밑줄 */ | |
| display: flex; /* For icon alignment */ | |
| align-items: center; /* Vertical alignment */ | |
| } | |
| .section-title-icon { | |
| font-size: 1.5em; | |
| margin-right: 10px; | |
| } | |
| .section-subtitle { | |
| font-size: 17px; color: #495057; /* 약간 더 어두운 회색 */ | |
| margin-bottom: 20px; font-weight: 400; /* 일반 두께 */ | |
| line-height: 1.6; | |
| } | |
| /* 버튼 스타일 */ | |
| div.stButton > button { | |
| background-color: #007bff; color: white; | |
| padding: 10px 20px; /* Adjusted padding */ | |
| font-size: 15px; /* Adjusted font size */ | |
| border-radius: 8px; /* 더 각진 모서리 */ | |
| border: none; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* 부드러운 그림자 */ | |
| transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; | |
| font-weight: 700; /* Bold */ | |
| } | |
| div.stButton > button:hover { | |
| background-color: #0056b3; | |
| transform: translateY(-1px); /* 살짝 뜨는 효과 */ | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.15); | |
| } | |
| div.stButton > button:disabled { | |
| background-color: #ced4da; color: #6c757d; | |
| box-shadow: none; cursor: not-allowed; | |
| transform: none; | |
| } | |
| /* Primary 버튼 (다음 단계 등) */ | |
| div.stButton > button.st-emotion-cache- Polize { /* More specific selector for primary if needed, inspect your app */ | |
| background-color: #28a745 !important; /* 초록색 계열 */ | |
| border: none !important; | |
| } | |
| div.stButton > button.st-emotion-cache- Polize:hover { | |
| background-color: #1e7e34 !important; /* 더 진한 초록색 */ | |
| } | |
| /* Secondary 버튼 (이전 단계 등) */ | |
| div.stButton > button[kind="secondary"] { /* Streamlit's way of identifying secondary */ | |
| background-color: #6c757d; /* 회색 계열 */ | |
| border: none; | |
| } | |
| div.stButton > button[kind="secondary"]:hover { | |
| background-color: #5a6268; | |
| } | |
| /* Expander 스타일 */ | |
| .streamlit-expanderHeader { | |
| font-weight: 700; /* Bold */ | |
| color: #0056b3; | |
| border-bottom: 1px solid #007bff; /* 더 얇은 밑줄 */ | |
| padding: 10px 0; /* 상하 패딩 조정 */ | |
| margin-bottom: 10px; | |
| font-size: 16px; /* 약간 작게 */ | |
| } | |
| div[data-testid="stExpander"] { | |
| border: 1px solid #dee2e6; | |
| border-radius: 8px; | |
| margin-bottom: 15px; | |
| background-color: #fff; /* White background for expander content */ | |
| } | |
| /* Info, Success, Error, Warning Box 스타일 (using st.Alert targeting) */ | |
| div.stAlert { | |
| border-radius: 8px; padding: 15px; margin-bottom: 15px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.05); | |
| font-size: 0.9rem; | |
| } | |
| /* Specific styling for alert types if Streamlit's default classes are used */ | |
| .stAlert[data-testid="stNotificationContentSuccess"] { border-left: 5px solid #28a745 !important; background-color: #d4edda !important; color: #155724; } | |
| .stAlert[data-testid="stNotificationContentInfo"] { border-left: 5px solid #17a2b8 !important; background-color: #d1ecf1 !important; color: #0c5460; } | |
| .stAlert[data-testid="stNotificationContentError"] { border-left: 5px solid #dc3545 !important; background-color: #f8d7da !important; color: #721c24; } | |
| .stAlert[data-testid="stNotificationContentWarning"] { border-left: 5px solid #ffc107 !important; background-color: #fff3cd !important; color: #856404; } | |
| /* Sidebar Stage Navigator */ | |
| .sidebar-nav-button-wrapper { | |
| margin-bottom: 6px; /* Reduced margin */ | |
| } | |
| button.sidebar-nav-button { /* Targeting the <button> tag directly */ | |
| display: flex; | |
| align-items: center; | |
| width: 100%; | |
| text-align: left; | |
| padding: 9px 12px; /* Adjusted padding */ | |
| border-radius: 6px; | |
| border: 1px solid #e0e0e0; | |
| background-color: #ffffff; | |
| color: #333; | |
| font-weight: 500; | |
| font-size: 0.9rem; | |
| transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; | |
| cursor: pointer; /* Ensure cursor indicates clickability */ | |
| } | |
| button.sidebar-nav-button:hover { | |
| background-color: #f0f2f6; | |
| border-color: #007bff; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.05); | |
| } | |
| button.sidebar-nav-button-active { | |
| background-color: #007bff !important; /* Important to override other states */ | |
| color: white !important; | |
| border-color: #0056b3 !important; | |
| font-weight: 700 !important; | |
| box-shadow: 0 2px 5px rgba(0,123,255,0.3) !important; | |
| } | |
| button.sidebar-nav-button-active:hover { | |
| background-color: #0056b3 !important; | |
| } | |
| button.sidebar-nav-button-completed .nav-icon { | |
| color: #28a745; | |
| } | |
| button.sidebar-nav-button .nav-icon { | |
| margin-right: 8px; | |
| font-size: 1.1em; | |
| } | |
| button.sidebar-nav-button .nav-status { | |
| margin-left: auto; | |
| font-size: 0.8em; | |
| font-style: italic; | |
| color: #6c757d; | |
| } | |
| button.sidebar-nav-button-active .nav-status { | |
| color: white !important; | |
| } | |
| button.sidebar-nav-button-completed .nav-status { | |
| color: #28a745 !important; | |
| } | |
| /* CSS to hide the Streamlit buttons used as triggers for custom HTML buttons */ | |
| /* Target based on the key given to st.button, assuming it's reflected in a parent div ID or class */ | |
| /* This is a common pattern but might need adjustment based on Streamlit version */ | |
| div[data-testid="stVerticalBlock"] div[data-testid="stButton"] > button { | |
| /* This is a general selector. We need to be more specific for the hidden ones. */ | |
| } | |
| /* More specific targeting for our hidden navigation trigger buttons: */ | |
| /* This targets Streamlit buttons whose direct parent has a key starting with "nav_stage_hidden_btn_" */ | |
| /* Streamlit often wraps buttons in a div that might get an ID related to the key */ | |
| /* Inspect your app's HTML to confirm the structure if this doesn't work */ | |
| div[id*="nav_stage_hidden_btn_"] > div[data-testid="stButton"] > button, | |
| button[data-testid^="st_nav_stage_hidden_btn_"] /* if key becomes part of button's testid */ | |
| { | |
| display: block !important; | |
| width: 0px !important; | |
| height: 0px !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| border: none !important; | |
| opacity: 0 !important; | |
| overflow: hidden !important; | |
| position: absolute !important; | |
| z-index: -1000 !important; /* Ensure it's behind everything */ | |
| line-height: 0 !important; | |
| font-size: 0 !important; | |
| color: transparent !important; | |
| background: transparent !important; | |
| } | |
| /* Fallback if the above is too complex or Streamlit changes structure: */ | |
| /* Hide all buttons with the specific key if Streamlit applies the key as a class or part of an ID to the button itself or its direct wrapper */ | |
| [id*="nav_stage_hidden_btn_"] button { /* General, might hide too much if not careful */ | |
| /* visibility: hidden; */ /* Alternative to opacity and size changes */ | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # --- API 키 설정 --- | |
| api_key = os.environ.get("GEMINI_API_KEY") or st.secrets.get("GEMINI_API_KEY") | |
| if not api_key: | |
| st.error("Gemini API 키가 설정되지 않았습니다. 환경 변수 또는 Streamlit Secrets에 `GEMINI_API_KEY`를 설정해주세요.") | |
| st.stop() | |
| try: | |
| genai.configure(api_key=api_key) | |
| except Exception as e: | |
| st.error(f"API 키 설정 중 오류 발생: {e}") | |
| st.stop() | |
| # --- Gemini 모델 설정 --- | |
| generation_config = { | |
| "temperature": 0.6, | |
| "top_p": 0.9, | |
| "top_k": 50, | |
| "max_output_tokens": 8192, | |
| "response_mime_type": "text/plain", | |
| } | |
| MODEL_NAME = "gemini-1.5-flash-latest" | |
| try: | |
| model = genai.GenerativeModel( | |
| model_name=MODEL_NAME, | |
| generation_config=generation_config, | |
| ) | |
| except Exception as e: | |
| st.error(f"Gemini 모델 로딩 중 오류 발생: {e}") | |
| st.stop() | |
| # --- 세션 상태 초기화 --- | |
| default_values = { | |
| "section_index": 0, | |
| "max_reached_stage_index": 0, | |
| "grade_level": None, | |
| "student_interests": "", | |
| "topic_suggestions": None, | |
| "selected_topic_area": "", | |
| "research_question": "", | |
| "hypothesis": "", | |
| "exp_vars_independent": "", | |
| "exp_vars_dependent": "", | |
| "exp_vars_controlled": "", | |
| "exp_materials": "", | |
| "exp_procedure": "", | |
| "exp_data_collection": "", | |
| "exp_safety_precautions": "", | |
| "experiment_plan_draft": "", | |
| "ai_feedback": None, | |
| "chat_session": None, | |
| "chat_messages": [], | |
| "chat_history_api": [], | |
| "data_analysis_notes": "", | |
| "conclusion_draft": "", | |
| "presentation_tips": None, | |
| } | |
| for key, value in default_values.items(): | |
| if key not in st.session_state: | |
| st.session_state[key] = value | |
| # --- AI 안내 생성 함수 --- | |
| def get_ai_guidance(prompt_template, **kwargs): | |
| prompt = prompt_template.format(**kwargs) | |
| try: | |
| response = model.generate_content(prompt) | |
| guidance_text = response.text.strip().replace("**", "") | |
| return guidance_text | |
| except google.api_core.exceptions.ResourceExhausted as e: | |
| st.error(f"API 할당량 초과 오류. 잠시 후 다시 시도해주세요. 오류: {e}") | |
| return "죄송합니다. 지금은 AI 안내를 처리할 수 없습니다. (API 할당량 초과)" | |
| except Exception as e: | |
| st.error(f"AI 안내 생성 중 오류: {e}") | |
| return "죄송합니다. AI 안내 생성 중 예상치 못한 오류가 발생했습니다." | |
| # --- 프롬프트 템플릿 --- | |
| PROMPT_WELCOME_GRADE_INFO = """ | |
| 학생의 학년 수준에 맞는 과학 탐구 안내를 제공하려고 합니다. | |
| {grade_level} 학생들에게 과학 탐구가 왜 재미있고 중요한지, 그리고 이 AI 도우미가 어떻게 도움을 줄 수 있는지 간략하고 친근하게 설명해주세요. | |
| (2-3문장으로 요약) | |
| """ | |
| PROMPT_TOPIC_SUGGESTION = """ | |
| 당신은 {grade_level} 학생의 과학 탐구 주제 찾기를 돕는 창의적이고 친절한 AI 조수입니다. | |
| 학생의 관심사는 '{interests}' 입니다. | |
| 이 관심사와 학년 수준을 고려하여, 탐구해볼 만한 3-4가지 구체적인 탐구 주제 아이디어를 제안해주세요. | |
| 각 아이디어는 다음을 포함해야 합니다: | |
| 1. 흥미로운 주제명 (질문 형태 가능) | |
| 2. 간단한 탐구 내용 설명 (1-2문장) | |
| 3. 이 주제가 왜 {grade_level} 학생에게 적합한지 또는 재미있을지 이유 (1문장) | |
| 출력은 각 주제를 명확히 구분하고, 친근한 말투(~해요, ~ 어때요?)를 사용해주세요. | |
| """ | |
| PROMPT_QUESTION_HYPOTHESIS_FEEDBACK = """ | |
| 당신은 {grade_level} 학생의 과학 탐구 질문과 가설 설정을 돕는 숙련된 과학 멘토입니다. | |
| 학생이 선택한 탐구 분야는 '{topic_area}' 입니다. | |
| 학생이 작성한 탐구 질문은 다음과 같습니다: | |
| "{research_question}" | |
| 학생이 작성한 가설은 다음과 같습니다: | |
| "{hypothesis}" | |
| 다음 사항에 초점을 맞춰 구체적이고 건설적인 피드백을 제공해주세요: | |
| 1. **탐구 질문의 명확성 및 구체성:** 질문이 너무 광범위하거나 모호하지 않은가? 측정 가능하고 탐구 가능한 질문인가? | |
| 2. **가설의 타당성 및 검증 가능성:** 가설이 탐구 질문에 대한 합리적인 예측인가? 실험이나 관찰을 통해 검증할 수 있는 형태로 작성되었는가? ('만약 ~라면, ~일 것이다' 형식 권장) | |
| 3. **질문과 가설의 연관성:** 가설이 탐구 질문에 직접적으로 답하려는 시도인가? | |
| 정답을 직접 알려주기보다는, 학생이 스스로 생각하고 개선할 수 있도록 질문을 던지거나 제안하는 방식으로 피드백해주세요. | |
| 친절하고 격려하는 말투를 사용해주세요. (예: "좋은 시작이에요! 혹시 이 질문을 조금 더 구체적으로 만들어보면 어떨까요? 예를 들어...") | |
| """ | |
| PROMPT_EXPERIMENT_PLAN_FEEDBACK = """ | |
| 당신은 {grade_level} 학생의 과학 탐구 계획을 검토하고 피드백을 주는 경험 많은 과학 교사입니다. | |
| 학생의 탐구 질문은 "{research_question}"이고, 가설은 "{hypothesis}"입니다. | |
| 학생이 작성한 탐구 계획의 각 부분은 다음과 같습니다: | |
| 1. **독립 변인:** {exp_vars_independent} | |
| 2. **종속 변인:** {exp_vars_dependent} | |
| 3. **통제 변인:** {exp_vars_controlled} | |
| 4. **준비물:** {exp_materials} | |
| 5. **실험 절차:** {exp_procedure} | |
| 6. **데이터 수집 및 기록 방법:** {exp_data_collection} | |
| 7. **안전 수칙:** {exp_safety_precautions} | |
| 이 계획의 각 부분을 검토하고, 다음 사항에 초점을 맞춰 구체적이고 건설적인 피드백을 제공해주세요. 각 항목별로 나누어 설명해주면 좋습니다: | |
| - **변인 설정 및 통제:** 독립 변인, 종속 변인, 통제 변인이 명확하게 설정되었고, 통제 변인을 어떻게 일정하게 유지할 것인지 구체적인가? | |
| - **준비물:** 필요한 준비물이 모두 포함되었는가? 빠진 것은 없는가? | |
| - **실험 절차의 구체성 및 재현 가능성:** 다른 사람이 이 계획만 보고도 실험을 똑같이 따라 할 수 있을 만큼 절차가 상세하고 명확한가? 측정 방법, 측정 도구, 측정 횟수 등이 명시되었는가? | |
| - **데이터 수집 및 기록 방법:** 어떤 데이터를 수집할 것이며, 어떻게 기록할 것인지 명확한가? 표나 그림을 활용할 계획이 있는가? | |
| - **안전 수칙 고려:** 실험 과정에서 발생할 수 있는 안전 문제에 대해 고려하고 대비책을 마련했는가? (언급이 없다면 중요성을 강조) | |
| - **결과의 객관성 확보 방안:** 실험 결과를 객관적으로 얻기 위한 노력이 포함되어 있는가? (예: 반복 실험, 대조군 설정 등 - 절차에서 확인) | |
| 정답을 직접 알려주기보다는, 학생이 스스로 생각하고 계획을 개선할 수 있도록 질문을 던지거나 제안하는 방식으로 피드백해주세요. | |
| 친절하고 격려하는 말투를 사용해주세요. | |
| """ | |
| PROMPT_DATA_ANALYSIS_CONCLUSION_FEEDBACK = """ | |
| 당신은 {grade_level} 학생의 과학 탐구 데이터 분석 및 결론 도출을 돕는 데이터 분석 전문가이자 과학자입니다. | |
| 학생의 탐구 질문은 "{research_question}"이고, 가설은 "{hypothesis}"입니다. | |
| 학생이 작성한 데이터 분석 및 결과 요약은 다음과 같습니다: | |
| --- | |
| {data_analysis_notes} | |
| --- | |
| 학생이 작성한 결론 초안은 다음과 같습니다: | |
| --- | |
| {conclusion_draft} | |
| --- | |
| 다음 사항에 초점을 맞춰 구체적이고 건설적인 피드백을 제공해주세요: | |
| 1. **데이터 해석의 타당성:** 수집된 데이터를 바탕으로 결과를 올바르게 해석하고 있는가? 그래프나 표를 사용했다면 적절히 활용되었는가? | |
| 2. **결론과 가설의 연관성:** 도출된 결론이 수립했던 가설을 지지하는지, 반증하는지, 아니면 판단하기 어려운지 명확하게 언급하고 있는가? | |
| 3. **결론의 논리성 및 근거 제시:** 결론이 실험 결과(데이터)에 근거하여 논리적으로 도출되었는가? | |
| 4. **탐구의 한계점 및 제언 (선택 사항, 고학년의 경우):** 이번 탐구의 한계점은 무엇이었으며, 이를 보완하기 위해 어떤 추가 탐구를 해볼 수 있을지 제언하고 있는가? | |
| 학생이 자신의 결과를 더 깊이 이해하고 명확하게 표현할 수 있도록 도와주세요. | |
| 친절하고 격려하는 말투를 사용해주세요. | |
| """ | |
| PROMPT_PRESENTATION_TIPS = """ | |
| 당신은 {grade_level} 학생이 과학 탐구 결과를 효과적으로 발표할 수 있도록 돕는 커뮤니케이션 전문가입니다. | |
| 학생의 탐구 주제는 '{topic_area}' 이고, 주요 탐구 질문은 "{research_question}" 입니다. | |
| 학생이 탐구 발표(예: 과학 박람회 포스터, 구두 발표)를 준비할 때 유용한 팁을 5-7가지 정도 알려주세요. | |
| 팁은 다음 내용을 포함할 수 있습니다: | |
| - 발표 자료 구성 (제목, 탐구 동기, 가설, 과정, 결과, 결론, 제언 등) | |
| - 시각 자료 활용 (그림, 사진, 그래프 등) | |
| - 명확하고 간결한 설명 방법 | |
| - 예상 질문 대비 | |
| - 자신감 있는 태도 | |
| {grade_level} 학생이 이해하기 쉽고 실천하기 좋은 내용으로 구성해주세요. | |
| 친절하고 응원하는 말투를 사용해주세요. | |
| """ | |
| # --- 단계별 제목 및 아이콘 --- | |
| STAGE_TITLES_WITH_ICONS = [ | |
| ("👋", "환영 및 학년 선택"), | |
| ("💡", "탐구 주제 탐색"), | |
| ("❓", "탐구 질문 & 가설"), | |
| ("📝", "탐구 계획 설계"), | |
| ("🔬", "탐구 수행 & Q&A"), | |
| ("📊", "데이터 분석 & 결론"), | |
| ("📢", "탐구 발표 준비"), | |
| ("📚", "유용한 과학 자료실") | |
| ] | |
| # --- 네비게이션 및 페이지 렌더링 함수 --- | |
| def navigate_to_stage(stage_index): | |
| if stage_index > 0 and not st.session_state.grade_level: | |
| st.toast("먼저 '환영 및 학년 선택' 단계에서 학년을 선택해주세요.", icon="⚠️") | |
| return | |
| st.session_state.section_index = stage_index | |
| if stage_index > st.session_state.max_reached_stage_index: | |
| st.session_state.max_reached_stage_index = stage_index | |
| st.session_state.ai_feedback = None | |
| st.rerun() | |
| def render_prev_next_buttons(): | |
| # Determine button labels and states | |
| prev_button_label = "" | |
| next_button_label = "" | |
| prev_button_disabled = True | |
| next_button_disabled = True | |
| show_prev = st.session_state.section_index > 0 | |
| show_next = st.session_state.section_index < len(STAGE_TITLES_WITH_ICONS) - 1 | |
| if show_prev: | |
| prev_button_label = f"👈 이전: {STAGE_TITLES_WITH_ICONS[st.session_state.section_index-1][1]}" | |
| prev_button_disabled = False | |
| if show_next: | |
| next_button_label = f"다음: {STAGE_TITLES_WITH_ICONS[st.session_state.section_index+1][1]} 👉" | |
| # Basic check for proceeding | |
| current_stage = st.session_state.section_index | |
| if current_stage == 0: # 환영 | |
| next_button_disabled = not st.session_state.grade_level | |
| elif current_stage == 1: # 주제 탐색 | |
| next_button_disabled = not st.session_state.selected_topic_area | |
| elif current_stage == 2: # 질문/가설 | |
| next_button_disabled = not (st.session_state.research_question and st.session_state.hypothesis) | |
| elif current_stage == 3: # 계획 설계 | |
| next_button_disabled = not all([ | |
| st.session_state.exp_vars_independent, st.session_state.exp_vars_dependent, | |
| st.session_state.exp_vars_controlled, st.session_state.exp_materials, | |
| st.session_state.exp_procedure, st.session_state.exp_data_collection | |
| ]) | |
| elif current_stage == 4: # 수행/Q&A - always allow next for now | |
| next_button_disabled = False | |
| elif current_stage == 5: # 분석/결론 | |
| next_button_disabled = not (st.session_state.data_analysis_notes and st.session_state.conclusion_draft) | |
| elif current_stage == 6: # 발표 준비 - always allow next for now | |
| next_button_disabled = False | |
| else: # Should not happen if show_next is true | |
| next_button_disabled = False | |
| cols = st.columns([1, 1.5, 1]) # Adjusted for better balance | |
| with cols[0]: | |
| if show_prev: | |
| if st.button(prev_button_label, key=f"prev_stage_btn_{st.session_state.section_index}", | |
| use_container_width=True, type="secondary", disabled=prev_button_disabled, | |
| on_click=navigate_to_stage, args=(st.session_state.section_index - 1,)): | |
| pass | |
| with cols[2]: | |
| if show_next: | |
| if st.button(next_button_label, key=f"next_stage_btn_{st.session_state.section_index}", | |
| use_container_width=True, type="primary", disabled=next_button_disabled, | |
| on_click=navigate_to_stage, args=(st.session_state.section_index + 1,)): | |
| pass | |
| elif st.session_state.section_index == len(STAGE_TITLES_WITH_ICONS) - 1: # Last stage | |
| if st.button("💖 처음으로 돌아가기 (모든 내용 초기화)", use_container_width=True, | |
| key="reset_from_last_stage", type="primary", on_click=reset_app_state): | |
| pass | |
| def reset_app_state(): | |
| # Clear existing session state keys related to the app's logic | |
| keys_to_delete = list(default_values.keys()) # Get a list of keys from our defaults | |
| for key in keys_to_delete: | |
| if key in st.session_state: | |
| del st.session_state[key] | |
| # Re-initialize with defaults by re-running the script which will hit the init block | |
| st.toast("모든 탐구 내용이 초기화되었습니다. 새로운 탐구를 시작하세요!", icon="🔄") | |
| st.rerun() | |
| # --- 메인 화면 구성 함수 --- | |
| def main(): | |
| col_empty_left, col_main, col_empty_right = st.columns([0.05, 0.9, 0.05]) # Adjusted for wider main | |
| with st.sidebar: | |
| st.markdown("## 🔬 사이언스 탐구 도우미") | |
| st.caption("AI와 함께 즐거운 과학 탐구를 시작해요! 🚀") | |
| st.divider() | |
| st.markdown("#### 탐구 단계 네비게이터") | |
| for i, (icon, title) in enumerate(STAGE_TITLES_WITH_ICONS): | |
| is_active = st.session_state.section_index == i | |
| is_completed = i < st.session_state.max_reached_stage_index and i != st.session_state.section_index | |
| status_text = "" | |
| button_class_name = "sidebar-nav-button" # Base class for the <button> tag | |
| if is_active: | |
| button_class_name += " sidebar-nav-button-active" | |
| status_text = "<span class='nav-status'>진행중</span>" | |
| elif is_completed: | |
| button_class_name += " sidebar-nav-button-completed" | |
| status_text = "<span class='nav-status'>✔ 완료</span>" | |
| # The actual Streamlit button that triggers the action (will be hidden by CSS) | |
| # The key is critical for CSS targeting if Streamlit uses it for wrapper IDs | |
| st.button(f"NavTrigger{i}", key=f"nav_stage_hidden_btn_{i}", | |
| on_click=navigate_to_stage, args=(i,), help=f"{title} 단계로 이동") | |
| # The visible HTML button that the user clicks | |
| button_html = f""" | |
| <div class="sidebar-nav-button-wrapper"> | |
| <button class='{button_class_name}' onclick='document.querySelector("[data-testid=\\"st_nav_stage_hidden_btn_{i}\\"]")?.click(); return false;'> | |
| <span class='nav-icon'>{icon}</span> {title} {status_text} | |
| </button> | |
| </div> | |
| """ | |
| st.markdown(button_html, unsafe_allow_html=True) | |
| st.divider() | |
| with st.expander("💡 앱 사용 가이드", expanded=False): | |
| st.markdown( | |
| """ | |
| **탐구 여정을 단계별로 안내해 드립니다:** | |
| - 사이드바의 네비게이터를 사용하여 각 단계로 이동할 수 있습니다. | |
| - (학년 선택은 필수입니다!) | |
| - 각 단계의 안내에 따라 탐구를 진행해보세요. | |
| """ | |
| ) | |
| for i, (icon, title) in enumerate(STAGE_TITLES_WITH_ICONS): | |
| st.markdown(f"**{i}. {icon} {title}**") | |
| st.markdown("---") | |
| st.markdown(f"<p style='font-size: 13px; color: #777; text-align: center;'>Version 1.2.1 (UI/UX Enhanced)</p>", unsafe_allow_html=True) | |
| with col_main: | |
| current_stage_index = st.session_state.section_index | |
| icon, title_text = STAGE_TITLES_WITH_ICONS[current_stage_index] | |
| # --- 0. 환영 및 학년 선택 --- | |
| if current_stage_index == 0: | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown("### 과학 탐구의 세계에 오신 것을 환영합니다! 🎉") | |
| st.markdown('<p class="section-subtitle">AI 탐구 도우미가 여러분의 호기심을 멋진 과학 탐구로 이끌어줄 거예요.<br>먼저 여러분의 학년을 선택해주세요. 맞춤형 안내를 제공해드립니다.</p>', unsafe_allow_html=True) | |
| st.divider() | |
| grade_options = ["선택하세요", "초등학교 3-4학년", "초등학교 5-6학년", "중학교 1학년", "중학교 2학년", "중학교 3학년", "고등학교 1학년", "고등학교 2학년", "고등학교 3학년"] | |
| current_grade_index = 0 | |
| if st.session_state.grade_level and st.session_state.grade_level in grade_options: | |
| current_grade_index = grade_options.index(st.session_state.grade_level) | |
| selected_grade = st.selectbox( | |
| "**👇 학년을 선택해주세요:**", | |
| grade_options, | |
| index=current_grade_index, | |
| key="grade_selector" | |
| ) | |
| if selected_grade != "선택하세요": | |
| if st.session_state.grade_level != selected_grade: | |
| st.session_state.grade_level = selected_grade | |
| st.toast(f"{selected_grade}으로 설정되었습니다! 다음 단계로 진행하세요.", icon="👍") | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 0) | |
| st.rerun() | |
| st.success(f"**{st.session_state.grade_level}**을 선택하셨습니다. 이제 탐구를 시작할 준비가 되었어요!") | |
| else: | |
| st.info("학년을 선택하면 탐구를 시작할 수 있습니다.") | |
| st.divider() | |
| render_prev_next_buttons() | |
| # --- 1. 탐구 주제 탐색 --- | |
| elif current_stage_index == 1: | |
| if not st.session_state.grade_level: | |
| st.warning("먼저 '환영 및 학년 선택' 단계에서 학년을 선택해주세요.") | |
| if st.button("🏠 학년 선택으로 돌아가기", key="back_to_grade_sel_1", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="section-subtitle">({st.session_state.grade_level} 대상) 어떤 분야에 관심이 있나요? AI가 재미있는 탐구 주제 아이디어를 찾아줄 거예요!</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| st.session_state.student_interests = st.text_input( | |
| "**여러분의 관심사를 입력해주세요 (예: 식물, 우주, 로봇, 환경 오염 등):**", | |
| value=st.session_state.student_interests, | |
| key="interests_input", | |
| placeholder="자유롭게 입력하고 'AI에게 주제 요청' 버튼을 누르세요!" | |
| ) | |
| if st.button("✨ AI에게 주제 아이디어 요청하기", use_container_width=True, key="get_topic_ideas", disabled=not st.session_state.student_interests): | |
| if st.session_state.student_interests: | |
| with st.spinner("AI가 맞춤 주제를 찾고 있어요... 잠시만 기다려주세요! 🧠"): | |
| st.session_state.topic_suggestions = get_ai_guidance( | |
| PROMPT_TOPIC_SUGGESTION, | |
| grade_level=st.session_state.grade_level, | |
| interests=st.session_state.student_interests | |
| ) | |
| st.session_state.ai_feedback = None | |
| if st.session_state.topic_suggestions and "죄송합니다" not in st.session_state.topic_suggestions : | |
| st.toast("AI 주제 제안이 도착했어요!", icon="💡") | |
| else: | |
| st.toast("AI 주제 제안 중 문제가 발생했어요. 다시 시도해주세요.", icon="⚠️") | |
| if st.session_state.topic_suggestions: | |
| with st.expander("🤖 AI의 주제 제안 보기/숨기기", expanded=True): | |
| st.info(st.session_state.topic_suggestions) | |
| st.divider() | |
| st.markdown("##### 🎯 탐구할 주제 분야 선택 또는 입력") | |
| st.session_state.selected_topic_area = st.text_input( | |
| "**가장 마음에 드는 주제나 탐구하고 싶은 분야를 여기에 적어주세요:**", | |
| value=st.session_state.selected_topic_area, | |
| key="selected_topic_input", | |
| placeholder="AI 제안을 참고하거나, 직접 입력해주세요." | |
| ) | |
| if st.session_state.selected_topic_area: | |
| st.success(f"탐구 분야: **{st.session_state.selected_topic_area}**(으)로 진행합니다.") | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 1) | |
| elif st.session_state.student_interests and not st.session_state.topic_suggestions: | |
| st.info("위 입력창에 관심사를 적고 'AI에게 주제 아이디어 요청하기' 버튼을 눌러보세요.") | |
| elif not st.session_state.student_interests: | |
| st.info("먼저 관심사를 입력해주세요.") | |
| # --- 2. 탐구 질문 및 가설 설정 --- | |
| elif current_stage_index == 2: | |
| if not st.session_state.grade_level: | |
| st.warning("먼저 '환영 및 학년 선택' 단계에서 학년을 선택해주세요.") | |
| if st.button("🏠 학년 선택으로 돌아가기", key="back_to_grade_sel_2", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| if not st.session_state.selected_topic_area: | |
| st.warning("먼저 '탐구 주제 탐색' 단계에서 탐구하고 싶은 주제 분야를 정해주세요.") | |
| if st.button("💡 주제 탐색으로 돌아가기", key="back_to_topic_sel_2", use_container_width=True): navigate_to_stage(1) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="section-subtitle">선택한 주제 분야 **"{st.session_state.selected_topic_area}"** 에 대해<br>구체적인 탐구 질문과 가설을 세워봅시다.</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| st.session_state.research_question = st.text_area( | |
| "**1. 탐구 질문 작성하기:** 무엇을 알아보고 싶나요? (구체적으로 질문 형태로 작성)", | |
| value=st.session_state.research_question, height=100, key="research_question_input", | |
| placeholder="예: 햇빛의 양은 식물 성장에 어떤 영향을 미칠까?" | |
| ) | |
| st.session_state.hypothesis = st.text_area( | |
| "**2. 가설 세우기:** 탐구 질문에 대한 예상 답은 무엇인가요? ('만약 ~라면, ~일 것이다.' 형식 권장)", | |
| value=st.session_state.hypothesis, height=100, key="hypothesis_input", | |
| placeholder="예: 만약 햇빛을 많이 받는다면, 식물은 더 잘 자랄 것이다." | |
| ) | |
| if st.button("🧐 AI에게 질문 & 가설 피드백 받기", use_container_width=True, key="get_q_h_feedback", disabled=not (st.session_state.research_question and st.session_state.hypothesis)): | |
| with st.spinner("AI가 여러분의 질문과 가설을 검토 중입니다... 🔍"): | |
| st.session_state.ai_feedback = get_ai_guidance( | |
| PROMPT_QUESTION_HYPOTHESIS_FEEDBACK, | |
| grade_level=st.session_state.grade_level, topic_area=st.session_state.selected_topic_area, | |
| research_question=st.session_state.research_question, hypothesis=st.session_state.hypothesis | |
| ) | |
| if st.session_state.ai_feedback and "죄송합니다" not in st.session_state.ai_feedback: | |
| st.toast("AI 피드백이 도착했어요!", icon="👍") | |
| else: | |
| st.toast("AI 피드백 생성 중 문제가 발생했어요.", icon="⚠️") | |
| if st.session_state.ai_feedback: | |
| with st.expander("🤖 AI의 피드백 보기/숨기기", expanded=True): | |
| st.success(st.session_state.ai_feedback) | |
| if st.session_state.research_question and st.session_state.hypothesis: | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 2) | |
| elif not (st.session_state.research_question and st.session_state.hypothesis): | |
| st.info("탐구 질문과 가설을 모두 작성한 후 AI 피드백을 받거나 다음 단계로 진행할 수 있습니다.") | |
| # --- 3. 탐구 계획 설계 (Granular) --- | |
| elif current_stage_index == 3: | |
| if not st.session_state.grade_level: | |
| st.warning("먼저 '환영 및 학년 선택' 단계에서 학년을 선택해주세요.") | |
| if st.button("🏠 학년 선택으로 돌아가기", key="back_to_grade_sel_3", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| if not st.session_state.research_question or not st.session_state.hypothesis: | |
| st.warning("먼저 '탐구 질문 및 가설 설정' 단계에서 질문과 가설을 작성해주세요.") | |
| if st.button("❓ 질문/가설 설정으로 돌아가기", key="back_to_q_h_3", use_container_width=True): navigate_to_stage(2) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="section-subtitle">탐구 질문: "{st.session_state.research_question}"<br>가설: "{st.session_state.hypothesis}"<br>어떻게 탐구를 진행할지 구체적인 계획을 단계별로 세워봅시다.</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| with st.expander("**1. 변인 설정**", expanded=True): | |
| st.session_state.exp_vars_independent = st.text_input("독립 변인 (바꾸어 주는 조건):", value=st.session_state.exp_vars_independent, key="exp_vars_ind_input", placeholder="예: 햇빛의 양 (하루 2시간, 4시간, 6시간)") | |
| st.session_state.exp_vars_dependent = st.text_input("종속 변인 (측정하려는 결과):", value=st.session_state.exp_vars_dependent, key="exp_vars_dep_input", placeholder="예: 식물의 키, 잎의 수") | |
| st.session_state.exp_vars_controlled = st.text_area("통제 변인 (똑같이 유지할 조건):", value=st.session_state.exp_vars_controlled, key="exp_vars_ctrl_input", height=80, placeholder="예: 물의 양, 흙의 종류, 화분의 크기, 온도 (쉼표로 구분)") | |
| with st.expander("**2. 준비물**", expanded=True): | |
| st.session_state.exp_materials = st.text_area("필요한 준비물 목록:", value=st.session_state.exp_materials, key="exp_materials_input", height=100, placeholder="예: 강낭콩 씨앗 9개, 화분 3개, 흙, 물뿌리개, 자, 관찰일지 등") | |
| with st.expander("**3. 실험 절차**", expanded=True): | |
| st.session_state.exp_procedure = st.text_area("실험 방법 (순서대로 자세히):", value=st.session_state.exp_procedure, key="exp_procedure_input", height=200, placeholder="예:\n1. 각 화분에 씨앗 3개씩 심기\n2. 화분별 햇빛 양 다르게 조절\n3. 2주간 관찰 및 측정") | |
| with st.expander("**4. 데이터 수집 및 기록 방법**", expanded=True): | |
| st.session_state.exp_data_collection = st.text_area("데이터 수집 및 기록 방법:", value=st.session_state.exp_data_collection, key="exp_data_coll_input", height=80, placeholder="예: 3일마다 키(cm), 잎 수 측정 후 표 기록. 사진 촬영.") | |
| with st.expander("**5. 안전 수칙** (선택 사항이지만 중요!)", expanded=False): # Default closed | |
| st.session_state.exp_safety_precautions = st.text_area("실험 중 주의할 안전 수칙:", value=st.session_state.exp_safety_precautions, key="exp_safety_input", height=80, placeholder="예: 화분 떨어뜨리지 않기, 흙 만진 후 손 씻기") | |
| st.divider() | |
| all_plan_fields_filled = all([ | |
| st.session_state.exp_vars_independent, st.session_state.exp_vars_dependent, | |
| st.session_state.exp_vars_controlled, st.session_state.exp_materials, | |
| st.session_state.exp_procedure, st.session_state.exp_data_collection | |
| ]) | |
| if st.button("🛠️ AI에게 탐구 계획 피드백 받기", use_container_width=True, key="get_plan_feedback", disabled=not all_plan_fields_filled): | |
| with st.spinner("AI가 탐구 계획을 꼼꼼히 검토 중입니다... 🧐"): | |
| st.session_state.ai_feedback = get_ai_guidance( | |
| PROMPT_EXPERIMENT_PLAN_FEEDBACK, | |
| grade_level=st.session_state.grade_level, research_question=st.session_state.research_question, | |
| hypothesis=st.session_state.hypothesis, exp_vars_independent=st.session_state.exp_vars_independent, | |
| exp_vars_dependent=st.session_state.exp_vars_dependent, exp_vars_controlled=st.session_state.exp_vars_controlled, | |
| exp_materials=st.session_state.exp_materials, exp_procedure=st.session_state.exp_procedure, | |
| exp_data_collection=st.session_state.exp_data_collection, exp_safety_precautions=st.session_state.exp_safety_precautions | |
| ) | |
| if st.session_state.ai_feedback and "죄송합니다" not in st.session_state.ai_feedback: | |
| st.toast("AI 피드백이 도착했어요! 내용을 확인하고 계획을 보완해보세요.", icon="👍") | |
| else: | |
| st.toast("AI 피드백 생성 중 문제가 발생했어요.", icon="⚠️") | |
| if st.session_state.ai_feedback: | |
| with st.expander("🤖 AI의 피드백 보기/숨기기", expanded=True): | |
| st.info(st.session_state.ai_feedback) | |
| if all_plan_fields_filled: | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 3) | |
| else: | |
| st.info("탐구 계획의 필수 항목 (변인, 준비물, 절차, 데이터 수집)을 모두 작성한 후 AI 피드백을 받거나 다음 단계로 진행할 수 있습니다. 안전 수칙도 꼭 고려해주세요!") | |
| # --- 4. 탐구 수행 및 Q&A (챗봇) --- | |
| elif current_stage_index == 4: | |
| if not st.session_state.grade_level or not st.session_state.research_question: | |
| st.warning("필수 정보(학년, 탐구 질문 등)가 부족합니다. 이전 단계를 확인해주세요.") | |
| if st.button("🏠 처음으로 돌아가기", key="back_to_start_4", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown('<p class="section-subtitle">탐구를 진행하면서 궁금한 점이나 어려운 점이 있다면 AI에게 언제든지 물어보세요!</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| with st.expander("💬 나의 탐구 요약 (AI와 대화 시 참고)", expanded=False): | |
| st.markdown(f"**학년:** {st.session_state.grade_level}") | |
| st.markdown(f"**탐구 주제 분야:** {st.session_state.get('selected_topic_area', 'N/A')}") | |
| st.markdown(f"**탐구 질문:** {st.session_state.research_question}") | |
| st.markdown(f"**가설:** {st.session_state.hypothesis}") | |
| st.markdown(f"**독립 변인:** {st.session_state.get('exp_vars_independent', 'N/A')}") | |
| st.markdown(f"**종속 변인:** {st.session_state.get('exp_vars_dependent', 'N/A')}") | |
| # Chat UI | |
| message_container_div = st.container() | |
| with message_container_div: | |
| st.markdown('<div class="message-container">', unsafe_allow_html=True) | |
| if not st.session_state.chat_messages: | |
| initial_ai_message_content = f""" | |
| 안녕하세요! '{st.session_state.get("selected_topic_area", "선택한 주제")}'에 대한 탐구를 도와드릴 준비가 되었어요. | |
| 현재 탐구 질문은 "{st.session_state.get("research_question", "정해지지 않음")}"이고, | |
| 가설은 "{st.session_state.get("hypothesis", "세우지 않음")}"이네요. | |
| 실험을 진행하거나 데이터를 분석하면서 궁금하거나 어려운 점이 있다면 언제든 저에게 물어보세요. 함께 고민해봐요! 😊 무엇을 도와드릴까요? | |
| """ | |
| st.session_state.chat_messages.append({"role": "assistant", "content": initial_ai_message_content}) | |
| st.session_state.chat_history_api.append({"role": "model", "parts": [{"text": initial_ai_message_content}]}) | |
| for msg in st.session_state.chat_messages: | |
| with st.chat_message(msg["role"]): | |
| st.markdown(msg["content"]) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| user_prompt = st.chat_input("AI 탐구 조수에게 질문해보세요... (Enter로 전송)", key="qa_chat_input") | |
| if user_prompt: | |
| st.session_state.chat_messages.append({"role": "user", "content": user_prompt}) | |
| st.session_state.chat_history_api.append({"role": "user", "parts": [{"text": user_prompt}]}) | |
| st.rerun() | |
| if st.session_state.chat_messages and st.session_state.chat_messages[-1]["role"] == "user": | |
| with st.chat_message("assistant"): | |
| with st.spinner("AI가 답변을 생각하고 있어요... 🤔"): | |
| try: | |
| if st.session_state.chat_session is None: | |
| valid_history = [] | |
| for h_item in st.session_state.chat_history_api[:-1]: | |
| if isinstance(h_item, dict) and "role" in h_item and "parts" in h_item: | |
| if isinstance(h_item["parts"], list) and all(isinstance(p, dict) and "text" in p for p in h_item["parts"]): | |
| valid_history.append(h_item) | |
| st.session_state.chat_session = model.start_chat(history=valid_history) | |
| chat_session = st.session_state.chat_session | |
| # Send only the last user message parts | |
| last_user_message_parts = st.session_state.chat_history_api[-1]["parts"] | |
| response = chat_session.send_message(last_user_message_parts) | |
| ai_response = response.text.strip() | |
| if not ai_response: ai_response = "음... 제가 지금은 답변을 드리기 어렵네요. 다시 질문해주시겠어요?" | |
| st.session_state.chat_messages.append({"role": "assistant", "content": ai_response}) | |
| st.session_state.chat_history_api.append({"role": "model", "parts": [{"text": ai_response}]}) | |
| st.markdown(ai_response) | |
| except google.api_core.exceptions.ResourceExhausted as e: | |
| st.error(f"API 할당량 초과. 잠시 후 다시 시도해주세요. 오류: {e}") | |
| error_msg = "죄송합니다. API 사용량 제한으로 지금은 답장을 드릴 수 없어요." | |
| st.session_state.chat_messages.append({"role": "assistant", "content": error_msg}) | |
| st.markdown(error_msg) | |
| except Exception as e: | |
| st.error(f"챗봇 응답 생성 중 오류: {e}") | |
| error_msg = f"죄송합니다. 지금은 답장을 드릴 수 없어요. (오류: {type(e).__name__})" | |
| st.session_state.chat_messages.append({"role": "assistant", "content": error_msg}) | |
| st.markdown(error_msg) | |
| st.rerun() | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 4) | |
| # --- 5. 데이터 분석 및 결론 도출 --- | |
| elif current_stage_index == 5: | |
| if not st.session_state.grade_level or not st.session_state.research_question: | |
| st.warning("필수 정보(학년, 탐구 질문 등)가 부족합니다. 이전 단계를 확인해주세요.") | |
| if st.button("🏠 처음으로 돌아가기", key="back_to_start_5", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="section-subtitle">탐구를 통해 얻은 데이터를 분석하고, 이를 바탕으로 결론을 내려봅시다.<br>탐구 질문: "{st.session_state.research_question}"<br>가설: "{st.session_state.hypothesis}"</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| st.session_state.data_analysis_notes = st.text_area( | |
| "**1. 데이터 분석 및 결과 요약:** (수집 데이터 정리, 분석 내용, 주요 결과, 표/그래프 설명 등)", | |
| value=st.session_state.data_analysis_notes, height=150, key="data_analysis_input", | |
| placeholder="예: 각 화분별 식물 키 평균을 계산하여 막대 그래프로 나타냈습니다. 그 결과, 햇빛을 6시간 받은 식물의 평균 키가 가장 컸습니다." | |
| ) | |
| st.session_state.conclusion_draft = st.text_area( | |
| "**2. 결론 초안 작성:** (분석 결과 기반 결론, 가설과 비교 설명, 한계점 및 제언 등)", | |
| value=st.session_state.conclusion_draft, height=150, key="conclusion_input", | |
| placeholder="예: 이 탐구를 통해 햇빛의 양이 많을수록 식물이 더 잘 자란다는 것을 알 수 있었습니다. 이는 가설을 지지합니다. 추가 탐구로..." | |
| ) | |
| if st.button("📈 AI에게 분석 & 결론 피드백 받기", use_container_width=True, key="get_analysis_feedback", disabled=not (st.session_state.data_analysis_notes and st.session_state.conclusion_draft)): | |
| with st.spinner("AI가 여러분의 분석과 결론을 검토 중입니다... 📊"): | |
| st.session_state.ai_feedback = get_ai_guidance( | |
| PROMPT_DATA_ANALYSIS_CONCLUSION_FEEDBACK, | |
| grade_level=st.session_state.grade_level, research_question=st.session_state.research_question, | |
| hypothesis=st.session_state.hypothesis, data_analysis_notes=st.session_state.data_analysis_notes, | |
| conclusion_draft=st.session_state.conclusion_draft | |
| ) | |
| if st.session_state.ai_feedback and "죄송합니다" not in st.session_state.ai_feedback: | |
| st.toast("AI 피드백이 도착했어요!", icon="👍") | |
| else: | |
| st.toast("AI 피드백 생성 중 문제가 발생했어요.", icon="⚠️") | |
| if st.session_state.ai_feedback: | |
| with st.expander("🤖 AI의 피드백 보기/숨기기", expanded=True): | |
| st.info(st.session_state.ai_feedback) | |
| if st.session_state.data_analysis_notes and st.session_state.conclusion_draft: | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 5) | |
| else: | |
| st.info("데이터 분석 내용과 결론 초안을 모두 작성한 후 AI 피드백을 받거나 다음 단계로 진행할 수 있습니다.") | |
| # --- 6. 탐구 발표 준비 --- | |
| elif current_stage_index == 6: | |
| if not st.session_state.grade_level or not st.session_state.selected_topic_area: | |
| st.warning("필수 정보(학년, 탐구 주제 등)가 부족합니다. 이전 단계를 확인해주세요.") | |
| if st.button("🏠 처음으로 돌아가기", key="back_to_start_6", use_container_width=True): navigate_to_stage(0) | |
| st.stop() | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown(f'<p class="section-subtitle">멋진 탐구 결과를 효과적으로 발표할 수 있도록 AI가 도와줄게요!<br>탐구 주제: "{st.session_state.selected_topic_area}"<br>주요 탐구 질문: "{st.session_state.research_question}"</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| if st.button("🎤 AI에게 발표 팁 요청하기", use_container_width=True, key="get_presentation_tips"): | |
| with st.spinner("AI가 발표 팁을 준비 중입니다... 💡"): | |
| st.session_state.presentation_tips = get_ai_guidance( | |
| PROMPT_PRESENTATION_TIPS, | |
| grade_level=st.session_state.grade_level, topic_area=st.session_state.selected_topic_area, | |
| research_question=st.session_state.research_question | |
| ) | |
| if st.session_state.presentation_tips and "죄송합니다" not in st.session_state.presentation_tips: | |
| st.toast("AI 발표 팁이 도착했어요!", icon="👍") | |
| else: | |
| st.toast("AI 발표 팁 생성 중 문제가 발생했어요.", icon="⚠️") | |
| if st.session_state.presentation_tips: | |
| with st.expander("🤖 AI의 발표 팁 보기/숨기기", expanded=True): | |
| st.info(st.session_state.presentation_tips) | |
| else: | |
| st.info("'AI에게 발표 팁 요청하기' 버튼을 눌러 유용한 조언을 받아보세요.") | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 6) | |
| # --- 7. 유용한 과학 자료실 --- | |
| elif current_stage_index == 7: | |
| with st.container(border=True): | |
| st.markdown(f'<p class="section-title"><span class="section-title-icon">{icon}</span> {title_text}</p>', unsafe_allow_html=True) | |
| st.markdown('<p class="section-subtitle">과학 탐구에 도움이 될 만한 웹사이트들을 모아봤어요. 자유롭게 탐색해보세요!</p>', unsafe_allow_html=True) | |
| render_prev_next_buttons() | |
| st.divider() | |
| st.markdown("#### 🇰🇷 국내 주요 과학 교육 사이트") | |
| cols_kr = st.columns(2) | |
| with cols_kr[0]: | |
| with st.expander("사이언스올 (KOFAC)"): | |
| st.markdown("한국과학창의재단 운영, 과학 콘텐츠, 교육 자료, 행사 정보 제공.") | |
| st.link_button("🔗 사이언스올 바로가기", "https://www.scienceall.com/", use_container_width=True) | |
| with st.expander("국립중앙과학관"): | |
| st.markdown("온라인 전시, 교육 프로그램, 과학 소식 제공.") | |
| st.link_button("🔗 국립중앙과학관 바로가기", "https://www.science.go.kr/", use_container_width=True) | |
| with cols_kr[1]: | |
| with st.expander("EBS 사이언스"): | |
| st.markdown("재미있는 과학 영상 클립과 다큐멘터리 제공.") | |
| st.link_button("🔗 EBS 사이언스 바로가기", "https://www.ebs.co.kr/science", use_container_width=True) | |
| with st.expander("LG사이언스랜드"): | |
| st.markdown("어린이/청소년 과학 학습 사이트. 게임, 웹툰, 실험 정보 풍부.") | |
| st.link_button("🔗 LG사이언스랜드 바로가기", "https://www.lgsl.kr/", use_container_width=True) | |
| st.divider() | |
| st.markdown("#### 🌍 해외 유용 과학 사이트 (영문)") | |
| cols_world = st.columns(2) | |
| with cols_world[0]: | |
| with st.expander("Science Buddies"): | |
| st.markdown("과학 프로젝트 아이디어, 단계별 가이드, 과학 원리 설명 등 방대한 자료 제공.") | |
| st.link_button("🔗 Science Buddies 바로가기", "https://www.sciencebuddies.org/", use_container_width=True) | |
| with st.expander("National Geographic Kids"): | |
| st.markdown("동물, 자연, 과학 등 다양한 주제의 흥미로운 콘텐츠.") | |
| st.link_button("🔗 Nat Geo Kids 바로가기", "https://kids.nationalgeographic.com/", use_container_width=True) | |
| with cols_world[1]: | |
| with st.expander("NASA Space Place"): | |
| st.markdown("어린이/청소년 대상 우주 과학 사이트. 게임, 애니메이션, 만들기 활동.") | |
| st.link_button("🔗 NASA Space Place 바로가기", "https://spaceplace.nasa.gov/", use_container_width=True) | |
| st.divider() | |
| st.success("🎉 모든 탐구 단계를 완료하신 것을 축하합니다! 🎉\n이곳의 자료들을 활용해 더 넓은 과학의 세계를 탐험해보세요.") | |
| st.session_state.max_reached_stage_index = max(st.session_state.max_reached_stage_index, 7) | |
| if __name__ == "__main__": | |
| main() |