import time import streamlit as st import plotly.express as px from collections import Counter from db import fetch_live_messages, delete_opinion_message, fetch_room_likes, toggle_like, likes_available from validators import with_fallback_author_role, mask_ip_for_teacher from utils import format_kst_datetime, log_audit from wordcloud import build_word_frequencies, build_circular_wordcloud_html from config import DASHBOARD_FETCH_LIMIT, LIVE_BOARD_FETCH_LIMIT, LIVE_REFRESH_INTERVAL, UI_FONT_FAMILY _RANK_BADGES = {1: "πŸ₯‡", 2: "πŸ₯ˆ", 3: "πŸ₯‰"} _LIKE_COOLDOWN = 3 def _escape_md(text: str) -> str: """st.info() λ§ˆν¬λ‹€μš΄ 해석 λ°©μ§€ β€” ~, *, _, ` λ“± μ΄μŠ€μΌ€μ΄ν”„.""" s = str(text or "") for ch in ('\\', '`', '*', '_', '~'): s = s.replace(ch, '\\' + ch) return s @st.cache_data(ttl=5) def _cached_wordcloud(content_tuple: tuple): """같은 데이터면 μ›Œλ“œν΄λΌμš°λ“œλ₯Ό μž¬μƒμ„±ν•˜μ§€ μ•Šκ³  μΊμ‹œ λ°˜ν™˜.""" import pandas as pd frequencies = build_word_frequencies(pd.Series(list(content_tuple))) if not frequencies: return None, None wc_html = build_circular_wordcloud_html(frequencies) top_words = ", ".join([f"{w}({c})" for w, c in frequencies.most_common(8)]) return wc_html, top_words def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type): opinion_df = with_fallback_author_role( fetch_live_messages(supabase, room_name, LIVE_BOARD_FETCH_LIMIT) ) stats_opinion_df = opinion_df col_board_title, col_board_ref = st.columns([8, 2]) with col_board_title: st.subheader(f"πŸ’¬ μ‹€μ‹œκ°„ {act_type} λ³΄λ“œ") with col_board_ref: if user_role == "ꡐ사" and teacher_auth: st.button("πŸ”„ μ‹€μ‹œκ°„ λ³΄λ“œ μƒˆλ‘œκ³ μΉ¨", use_container_width=True, key="refresh_chat_board") if not opinion_df.empty: teacher_df = opinion_df[opinion_df['student_name'].str.contains('μ„ μƒλ‹˜', na=False)] if not teacher_df.empty: st.success(f"πŸ‘¨β€πŸ« **μ„ μƒλ‹˜μ˜ 생각 힌트!** ➑️ {teacher_df.iloc[0]['content']}") student_df = opinion_df[~opinion_df['student_name'].str.contains('μ„ μƒλ‹˜', na=False)] # 곡감 데이터 λ‘œλ“œ use_likes = likes_available() likes_count = {} my_likes = set() badge_map = {} on_like_cooldown = False if use_likes: likes_data = fetch_room_likes(supabase, room_name) likes_count = Counter(item['opinion_id'] for item in likes_data) my_likes = {item['opinion_id'] for item in likes_data if item['student_name'] == student_name} # 동점 처리: 같은 곡감 수 = 같은 λ“±μˆ˜ (dense rank) distinct_counts = sorted({c for c in likes_count.values() if c > 0}, reverse=True)[:3] count_to_rank = {c: rank for rank, c in enumerate(distinct_counts, 1)} badge_map = { oid: _RANK_BADGES[count_to_rank[cnt]] for oid, cnt in likes_count.items() if cnt > 0 and cnt in count_to_rank } # μΏ¨λ‹€μš΄ 확인 on_like_cooldown = (time.time() - st.session_state.get('_last_like_ts', 0)) < _LIKE_COOLDOWN def do_toggle_like(msg_id): toggle_like(supabase, msg_id, room_name, student_name) fetch_room_likes.clear() st.session_state['_last_like_ts'] = time.time() def render_msg(row): formatted_timestamp = format_kst_datetime(row.get("timestamp", "")) msg_id = row['id'] count = likes_count.get(msg_id, 0) is_liked = msg_id in my_likes badge = badge_map.get(msg_id, "") # μ…€ν”„ 곡감 차단: 의견 μž‘μ„±μž 이름과 ν˜„μž¬ μ‚¬μš©μž 이름 비ꡐ is_self = bool(student_name and row.get('student_name') == student_name) like_disabled = is_self or not use_likes or (on_like_cooldown and not is_liked) like_label = f"πŸ‘ {count}" if count > 0 else "πŸ‘" like_type = "primary" if is_liked else "secondary" name_badge = f"{badge} " if badge else "" row_ip = str(row.get("ip_address") or "").strip() if hasattr(row, "get") else "" if user_role == "ꡐ사" and teacher_auth: c_name, c_like, c_del = st.columns([5, 1, 1]) with c_name: st.markdown( f"**{name_badge}{row['student_name']}** " f"{formatted_timestamp}", unsafe_allow_html=True, ) if row_ip: st.caption(f"IP: {mask_ip_for_teacher(row_ip)}") with c_like: st.button(like_label, key=f"like_{msg_id}", disabled=like_disabled, type=like_type, use_container_width=True, on_click=do_toggle_like, args=(msg_id,)) with c_del: if st.button("❌", key=f"del_{msg_id}", help="κ°•μ œ μ‚­μ œ"): try: if delete_opinion_message(supabase, msg_id) is not None: fetch_live_messages.clear() _cached_wordcloud.clear() log_audit("chat_deleted", room_name=room_name, actor_name=student_name, role=user_role, message_id=msg_id) st.toast("의견이 μ¦‰μ‹œ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", icon="πŸ—‘οΈ") st.rerun(scope="app") except Exception as e: st.error(f"μ‚­μ œ μ‹€νŒ¨: {e}") else: c_name, c_like = st.columns([5, 1]) with c_name: st.markdown( f"**{name_badge}{row['student_name']}** " f"{formatted_timestamp}", unsafe_allow_html=True, ) with c_like: st.button(like_label, key=f"like_{msg_id}", disabled=like_disabled, type=like_type, use_container_width=True, on_click=do_toggle_like, args=(msg_id,)) st.info(_escape_md(row['content'])) st.write("") if current_mode == "βš”οΈ 찬반 ν† λ‘ ": col_pro, col_con = st.columns(2) with col_pro: st.markdown("### πŸ”΅ μ°¬μ„± μΈ‘") with st.container(height=450): for _, row in student_df[student_df['sentiment'] == 'πŸ”΅ μ°¬μ„±'].iterrows(): render_msg(row) with col_con: st.markdown("### πŸ”΄ λ°˜λŒ€ μΈ‘") with st.container(height=450): for _, row in student_df[student_df['sentiment'] == 'πŸ”΄ λ°˜λŒ€'].iterrows(): render_msg(row) else: col_idea, col_plus, col_q = st.columns(3) with col_idea: st.markdown("### πŸ’‘ 아이디어") with st.container(height=450): for _, row in student_df[student_df['sentiment'] == 'πŸ’‘ 아이디어'].iterrows(): render_msg(row) with col_plus: st.markdown("### βž• 보좩") with st.container(height=450): for _, row in student_df[student_df['sentiment'] == 'βž• 보좩'].iterrows(): render_msg(row) with col_q: st.markdown("### ❓ 질문") with st.container(height=450): for _, row in student_df[student_df['sentiment'] == '❓ 질문'].iterrows(): render_msg(row) else: st.info(f"아직 λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€. 첫 {act_type} μ˜κ²¬μ„ λ‚¨κ²¨μ£Όμ„Έμš”!") with st.expander("πŸ“Š μ‹€μ‹œκ°„ 의견 톡계", expanded=True): if not stats_opinion_df.empty: left_col, right_col = st.columns(2) with left_col: st.caption("의견 μœ ν˜• 뢄포 κ·Έλž˜ν”„") live_pie_fig = px.pie(stats_opinion_df, names="sentiment", hole=0.4, height=320) live_pie_fig.update_layout(font={"family": UI_FONT_FAMILY}) st.plotly_chart(live_pie_fig, use_container_width=True, config={'displayModeBar': False, 'scrollZoom': False}) with right_col: st.caption("λˆ„μ  ν† μ˜/ν† λ‘  μ›Œλ“œν΄λΌμš°λ“œ") wc_html, top_words = _cached_wordcloud( tuple(stats_opinion_df["content"].fillna("").tolist()) ) if wc_html: st.markdown(wc_html, unsafe_allow_html=True) st.caption(f"μƒμœ„ ν‚€μ›Œλ“œ: {top_words}") else: st.info("μ›Œλ“œν΄λΌμš°λ“œλ₯Ό λ§Œλ“€ 단어가 아직 λΆ€μ‘±ν•©λ‹ˆλ‹€.") else: st.write("데이터 μˆ˜μ§‘ 쀑...") @st.fragment(run_every=LIVE_REFRESH_INTERVAL) def _live_chat_board_auto(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type): _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type) def render_chat_board(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type): if st.session_state.get('is_working', False): _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type) else: _live_chat_board_auto(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type)