sciencefair1 / app.py
ll7098ll's picture
Update app.py
30157bf verified
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()