Spaces:
Runtime error
Runtime error
File size: 13,354 Bytes
3bb2388 bf6465c 3bb2388 5c3354b 9d7fd67 5c3354b 9395c32 3bb2388 5c3354b 3bb2388 096e84c 23fafdc 3bb2388 02c069f 09bd939 69b8ae1 8673167 3bb2388 23fafdc 3bb2388 bdf2fff 3bb2388 85d74ac 5c3354b 85d74ac 3bb2388 9395c32 3bb2388 f327ff5 3bb2388 096e84c c65e101 bdf2fff c65e101 bdf2fff abcc046 bdf2fff 8dbc191 1f7e056 8dbc191 02c069f 8dbc191 1f7e056 e109735 8dbc191 5c3354b bdf2fff 5c3354b 9dcf3c3 78687ed 5c3354b 78687ed 5c3354b 1f31eaa 5c3354b 3bb2388 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | 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(
"""
<button id="stt-btn" style="width:100%; height:80px; font-weight:bold; border-radius:10px; background-color:#e8f0fe; border:1px solid #1a73e8; color:#1a73e8; cursor:pointer;">🎤 음성 입력 시작</button>
<p id="status" style="font-size:11px; color:gray; text-align:center; margin-top:5px;">대기 중...</p>
<script>
const btn = document.getElementById('stt-btn');
const status = document.getElementById('status');
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'ko-KR';
let isRecognizing = false;
btn.onclick = () => { if (!isRecognizing) recognition.start(); else recognition.stop(); };
recognition.onstart = () => { isRecognizing = true; status.innerText = "듣는 중..."; btn.style.backgroundColor = "#ff4b4b"; btn.style.color = "white"; btn.innerHTML = "🛑 음성 입력 중지"; };
recognition.onresult = (event) => {
const text = event.results[0][0].transcript;
const textArea = window.parent.document.querySelector('textarea[aria-label="의견을 입력하세요"]');
if (textArea) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
nativeInputValueSetter.call(textArea, textArea.value + " " + text);
textArea.dispatchEvent(new Event('input', { bubbles: true }));
status.innerText = "입력 완료!";
}
};
recognition.onend = () => { isRecognizing = false; setTimeout(() => { status.innerText = "대기 중..."; btn.style.backgroundColor = "#e8f0fe"; btn.style.color = "#1a73e8"; btn.innerHTML = "🎤 음성 입력 시작"; }, 1500); };
</script>
""",
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)
|