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)