import logging import time import streamlit as st from db import ( debate_ip_column_available, ensure_db_login, fetch_debate_status, fetch_live_messages, fetch_opinion_change, fetch_pending_teacher_accounts, fetch_topic_data, init_db, opinion_changes_available, submit_opinion, update_topic, using_service_role_key, ) from config import APP_CSS, MAX_STUDENT_NAME_LEN from utils import anonymize_ip, get_client_ip, get_kst_now_str, log_audit from validators import validate_opinion_content, validate_student_name from views.home import render_home_page from views.lobby import render_lobby_page from components.admin_panel import render_admin_page from components.sidebar import render_sidebar logger = logging.getLogger("talk_trace_ai") if not logger.handlers: logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s" ) supabase = init_db() if not using_service_role_key(): ensure_db_login(supabase) st.set_page_config(page_title="말자취(Talk-Trace) AI", layout="wide") st.markdown(APP_CSS, unsafe_allow_html=True) if 'reset_key' not in st.session_state: st.session_state['reset_key'] = 0 if 'ai_hint_text' not in st.session_state: st.session_state['ai_hint_text'] = "" if 'ai_report_text' not in st.session_state: st.session_state['ai_report_text'] = "" if 'page' not in st.session_state: st.session_state['page'] = "home" if 'current_room' not in st.session_state: st.session_state['current_room'] = "" if 'joined' not in st.session_state: st.session_state['joined'] = False if 'teacher_auth' not in st.session_state: st.session_state['teacher_auth'] = False if 'admin_auth' not in st.session_state: st.session_state['admin_auth'] = False if 'teacher_id' not in st.session_state: st.session_state['teacher_id'] = "" if 'is_working' not in st.session_state: st.session_state['is_working'] = False if 'ai_hint_manual_mode' not in st.session_state: st.session_state['ai_hint_manual_mode'] = False if '_last_debate_status' not in st.session_state: st.session_state['_last_debate_status'] = None if st.session_state['page'] != "home": col_home_btn, _ = st.columns([1, 7]) with col_home_btn: if st.button("🏠 ν™ˆ", use_container_width=True): st.session_state['page'] = "home" st.session_state['joined'] = False st.session_state['teacher_auth'] = False st.rerun() if st.session_state['page'] == "home": render_home_page() # plotly / google.generativeai μž„ν¬νŠΈλŠ” ν™ˆ ν™”λ©΄μ—μ„œ λΆˆν•„μš” β€” st.stop() 이후에 λ‘œλ“œ from components.chat_board import render_chat_board from components.teacher_dashboard import render_teacher_dashboard from components.opinion_change import render_pre_opinion_form, render_post_opinion_section sidebar_ctx = render_sidebar(supabase) user_role = sidebar_ctx['user_role'] room_name = sidebar_ctx['room_name'] teacher_auth = sidebar_ctx['teacher_auth'] admin_auth = sidebar_ctx['admin_auth'] student_name = sidebar_ctx['student_name'] student_number = sidebar_ctx['student_number'] # admin 첫 접속 μ‹œ pending 여뢀에 따라 첫 ν™”λ©΄ κ²°μ • (졜초 1회) if admin_auth and not st.session_state.get('_admin_redirected'): _pending = fetch_pending_teacher_accounts(supabase) st.session_state['page'] = "admin_approval" if _pending else "lobby" st.session_state['joined'] = False st.session_state['_admin_redirected'] = True st.rerun() if st.session_state['page'] == "admin_approval": render_admin_page(supabase, user_role, teacher_auth, admin_auth) if not st.session_state['joined']: render_lobby_page(supabase, user_role, teacher_auth, room_name, student_number) # === Joined room view === topic_data = fetch_topic_data(supabase, room_name) current_topic = topic_data.get('title', "자유 주제둜 λŒ€ν™”ν•΄ λ΄…μ‹œλ‹€.") current_mode = topic_data.get('mode', "βš”οΈ 찬반 ν† λ‘ ") act_type = "ν† λ‘ " if "ν† λ‘ " in current_mode else "ν† μ˜" if admin_auth: col_title, col_btn1, col_btn2 = st.columns([4, 1, 1]) with col_title: st.title(f"πŸŽ™οΈ 말자취(Talk-Trace) AI [{room_name}]") with col_btn1: if st.button("πŸ“ ID μš”μ²­ 수락", use_container_width=True): st.session_state['page'] = "admin_approval" st.rerun() with col_btn2: if st.button("πŸšͺ 말자취 AI λŒ€κΈ°μ‹€", use_container_width=True): st.session_state['page'] = "lobby" st.rerun() else: st.title(f"πŸŽ™οΈ 말자취(Talk-Trace) AI [{room_name}]") st.info(f"**ν˜„μž¬ 주제:** {current_topic} ({current_mode})") if user_role == "ꡐ사" and teacher_auth: with st.expander("✏️ 주제 μˆ˜μ •", expanded=False): _edit_title = st.text_input("μƒˆ 주제", value=current_topic, max_chars=120, key="edit_topic_title") _edit_mode = st.radio( "μ§„ν–‰ 방식", ["βš”οΈ 찬반 ν† λ‘ ", "πŸ’‘ 자유 ν† μ˜"], index=0 if "ν† λ‘ " in current_mode else 1, horizontal=True, key="edit_topic_mode", ) if st.button("βœ… 주제 μ €μž₯", type="primary", use_container_width=True, key="edit_topic_save"): if not _edit_title.strip(): st.warning("주제λ₯Ό μž…λ ₯ν•΄ μ£Όμ„Έμš”.") else: if update_topic(supabase, room_name, _edit_title.strip(), _edit_mode) is not None: st.toast("βœ… μ£Όμ œκ°€ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", icon="✏️") st.rerun() @st.fragment(run_every=5) def _poll_debate_status(room_name): """학생 ν™”λ©΄μ—μ„œ 5μ΄ˆλ§ˆλ‹€ ν† λ‘  μƒνƒœλ₯Ό ν™•μΈν•˜κ³  λ³€κ²½ μ‹œ 전체 rerun.""" current = fetch_debate_status(supabase, room_name) if current != st.session_state.get("_last_debate_status"): st.session_state["_last_debate_status"] = current st.rerun(scope="app") @st.fragment def _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode): st.subheader("πŸ—£οΈ λ‚΄ 의견 μž‘μ„±") col_input, col_stt = st.columns([4, 1]) with col_input: user_input = st.text_area( "μ˜κ²¬μ„ μž…λ ₯ν•˜μ„Έμš”", key=f"input_{st.session_state['reset_key']}", height=80, label_visibility="collapsed", ) opts = ["πŸ”΅ μ°¬μ„±", "πŸ”΄ λ°˜λŒ€"] if current_mode == "βš”οΈ 찬반 ν† λ‘ " else ["πŸ’‘ 아이디어", "βž• 보좩", "❓ 질문"] sentiment = st.radio("의견 성격", opts, horizontal=True) with col_stt: st.components.v1.html( """

λŒ€κΈ° 쀑...

""", height=120, ) _elapsed = time.time() - st.session_state.get('last_submit_ts', 0) _submit_cooldown = _elapsed < 3 _submit_disabled = _submit_cooldown or st.session_state.get('is_working', False) if st.button("의견 제좜", use_container_width=True, type="primary", disabled=_submit_disabled): if st.session_state.get('is_working', False): st.stop() st.session_state['is_working'] = True input_ok, safe_input, input_error_code, input_error_message = validate_opinion_content(user_input, max_len=700) student_ok, safe_student_name, student_error_code, student_error_message = validate_student_name(student_name, max_len=MAX_STUDENT_NAME_LEN) student_number_ok, safe_student_number, _, student_number_error_message = validate_student_name(student_number, max_len=20) if not student_number_ok and user_role == "학생": st.session_state['is_working'] = False st.error(f"❌ {student_number_error_message}") st.stop() if user_role == "학생" and (not safe_student_name or safe_student_name == "읡λͺ…"): safe_student_name = safe_student_number or "ν•™λ²ˆλ―Έμž…λ ₯" if input_ok and safe_input: now = get_kst_now_str() author_role_for_submit = "ꡐ사" if user_role == "ꡐ사" else "학생" client_ip = get_client_ip() insert_payload = { "room_name": room_name, "timestamp": now, "student_name": safe_student_name, "content": safe_input, "sentiment": sentiment, "author_role": author_role_for_submit, } if debate_ip_column_available() and client_ip: anonymized_ip = anonymize_ip(client_ip) if anonymized_ip: insert_payload["ip_address"] = anonymized_ip try: res = submit_opinion(supabase, insert_payload) if res is None: st.session_state['is_working'] = False st.stop() st.session_state['last_submit_ts'] = time.time() st.session_state['is_working'] = False fetch_live_messages.clear() log_audit( "opinion_submitted", room_name=room_name, actor_name=safe_student_name, role=author_role_for_submit, sentiment=sentiment, client_ip=client_ip if client_ip else "N/A", ) st.session_state['reset_key'] += 1 st.rerun(scope="app") except Exception as e: st.session_state['is_working'] = False st.error(f"μ €μž₯ μ‹€νŒ¨: {e}") else: st.session_state['is_working'] = False st.warning(f"{input_error_message}") if _submit_cooldown: st.caption("⏳ 제좜 성곡 ν›„ 3μ΄ˆκ°„ μ˜κ²¬μ„ μ œμΆœν•  수 μ—†μŠ΅λ‹ˆλ‹€.") time.sleep(0.5) st.rerun() if user_role == "학생" and opinion_changes_available(): _poll_debate_status(room_name) debate_status = fetch_debate_status(supabase, room_name) # ν•™μƒμ—κ²Œ ν† λ‘  μ§„ν–‰ μƒνƒœλ₯Ό 항상 λͺ…μ‹œμ μœΌλ‘œ ν‘œμ‹œ if debate_status == "ended": st.warning(f"πŸ”΄ **{act_type} μ’…λ£Œ** β€” μ•„λž˜μ—μ„œ {act_type} ν›„ 생각 λ³€ν™”λ₯Ό κΈ°λ‘ν•΄μ£Όμ„Έμš”.") else: st.success(f"🟒 **{act_type} μ§„ν–‰ 쀑** β€” 자유둭게 μ˜κ²¬μ„ λ‚˜λˆ λ³΄μ„Έμš”.") row = fetch_opinion_change(supabase, room_name, student_name) has_pre_opinion = bool((row or {}).get("pre_opinion")) if debate_status == "ended": if has_pre_opinion: render_post_opinion_section(supabase, room_name, student_name, act_type, current_topic) else: st.warning(f"{'ν† λ‘ ' if act_type == 'ν† λ‘ ' else 'ν† μ˜'}이 μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. ν† λ‘  μ „ 생각을 미리 κΈ°λ‘ν•˜μ§€ μ•Šμ•„ μ°Έμ—¬ν•  수 μ—†μŠ΅λ‹ˆλ‹€.") else: if not has_pre_opinion: render_pre_opinion_form(supabase, room_name, student_name, current_topic, act_type) st.caption("πŸ’‘ μœ„μ—μ„œ ν† λ‘  μ „ 생각을 μ œμΆœν•˜λ©΄ 의견 μž‘μ„±μ΄ ν™œμ„±ν™”λ©λ‹ˆλ‹€.") else: _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode) else: _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode) st.divider() render_chat_board(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type) if user_role == "ꡐ사" and teacher_auth: render_teacher_dashboard(supabase, room_name, user_role, student_name, current_topic, current_mode, act_type)