Spaces:
Runtime error
Runtime error
Claude commited on
fix: 실시간 보드 삭제 반영, 내용 마크다운 렌더링, 참여도 자동갱신, 성능 개선
Browse files1. 삭제 즉시 반영: on_click 콜백 → 직접 if 버튼 패턴으로 변경 후
fetch_live_messages.clear() + st.rerun(scope="app") 호출
2. 내용 표시 오류: st.info(content) 마크다운 해석 방지 — ~, *, _, ` 이스케이프
3. 학생 참여도 현황: @st .fragment(run_every=10)으로 분리해 자동 갱신
4. 성능: 워드클라우드 @st .cache_data(ttl=5) 캐싱으로 재생성 방지,
토론 상태 폴링 3초 → 5초로 완화
https://claude.ai/code/session_016DE3uHLsVmA5Uv7gZGGaKT
- app.py +2 -2
- components/chat_board.py +37 -17
- components/teacher_dashboard.py +28 -22
app.py
CHANGED
|
@@ -129,9 +129,9 @@ if user_role == "교사" and teacher_auth:
|
|
| 129 |
st.toast("✅ 주제가 수정되었습니다.", icon="✏️")
|
| 130 |
st.rerun()
|
| 131 |
|
| 132 |
-
@st.fragment(run_every=
|
| 133 |
def _poll_debate_status(room_name):
|
| 134 |
-
"""학생 화면에서
|
| 135 |
current = fetch_debate_status(supabase, room_name)
|
| 136 |
if current != st.session_state.get("_last_debate_status"):
|
| 137 |
st.session_state["_last_debate_status"] = current
|
|
|
|
| 129 |
st.toast("✅ 주제가 수정되었습니다.", icon="✏️")
|
| 130 |
st.rerun()
|
| 131 |
|
| 132 |
+
@st.fragment(run_every=5)
|
| 133 |
def _poll_debate_status(room_name):
|
| 134 |
+
"""학생 화면에서 5초마다 토론 상태를 확인하고 변경 시 전체 rerun."""
|
| 135 |
current = fetch_debate_status(supabase, room_name)
|
| 136 |
if current != st.session_state.get("_last_debate_status"):
|
| 137 |
st.session_state["_last_debate_status"] = current
|
components/chat_board.py
CHANGED
|
@@ -13,6 +13,26 @@ _RANK_BADGES = {1: "🥇", 2: "🥈", 3: "🥉"}
|
|
| 13 |
_LIKE_COOLDOWN = 3
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type):
|
| 17 |
opinion_df = with_fallback_author_role(
|
| 18 |
fetch_live_messages(supabase, room_name, LIVE_BOARD_FETCH_LIMIT)
|
|
@@ -29,10 +49,11 @@ def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_
|
|
| 29 |
st.plotly_chart(live_pie_fig, use_container_width=True, config={'displayModeBar': False, 'scrollZoom': False})
|
| 30 |
with right_col:
|
| 31 |
st.caption("누적 토의/토론 워드클라우드")
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
st.caption(f"상위 키워드: {top_words}")
|
| 37 |
else:
|
| 38 |
st.info("워드클라우드를 만들 단어가 아직 부족합니다.")
|
|
@@ -74,16 +95,6 @@ def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_
|
|
| 74 |
# 쿨다운 확인
|
| 75 |
on_like_cooldown = (time.time() - st.session_state.get('_last_like_ts', 0)) < _LIKE_COOLDOWN
|
| 76 |
|
| 77 |
-
def delete_chat_msg(msg_id):
|
| 78 |
-
try:
|
| 79 |
-
if delete_opinion_message(supabase, msg_id) is None:
|
| 80 |
-
return
|
| 81 |
-
fetch_live_messages.clear()
|
| 82 |
-
log_audit("chat_deleted", room_name=room_name, actor_name=student_name, role=user_role, message_id=msg_id)
|
| 83 |
-
st.toast("의견이 즉시 삭제되었습니다.", icon="🗑️")
|
| 84 |
-
except Exception as e:
|
| 85 |
-
st.error(f"삭제 실패: {e}")
|
| 86 |
-
|
| 87 |
def do_toggle_like(msg_id):
|
| 88 |
toggle_like(supabase, msg_id, room_name, student_name)
|
| 89 |
fetch_room_likes.clear()
|
|
@@ -120,8 +131,17 @@ def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_
|
|
| 120 |
type=like_type, use_container_width=True,
|
| 121 |
on_click=do_toggle_like, args=(msg_id,))
|
| 122 |
with c_del:
|
| 123 |
-
st.button("❌", key=f"del_{msg_id}", help="강제 삭제"
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
else:
|
| 126 |
c_name, c_like = st.columns([5, 1])
|
| 127 |
with c_name:
|
|
@@ -134,7 +154,7 @@ def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_
|
|
| 134 |
st.button(like_label, key=f"like_{msg_id}", disabled=like_disabled,
|
| 135 |
type=like_type, use_container_width=True,
|
| 136 |
on_click=do_toggle_like, args=(msg_id,))
|
| 137 |
-
st.info(row['content'])
|
| 138 |
st.write("")
|
| 139 |
|
| 140 |
if current_mode == "⚔️ 찬반 토론":
|
|
|
|
| 13 |
_LIKE_COOLDOWN = 3
|
| 14 |
|
| 15 |
|
| 16 |
+
def _escape_md(text: str) -> str:
|
| 17 |
+
"""st.info() 마크다운 해석 방지 — ~, *, _, ` 등 이스케이프."""
|
| 18 |
+
s = str(text or "")
|
| 19 |
+
for ch in ('\\', '`', '*', '_', '~'):
|
| 20 |
+
s = s.replace(ch, '\\' + ch)
|
| 21 |
+
return s
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@st.cache_data(ttl=5)
|
| 25 |
+
def _cached_wordcloud(content_tuple: tuple):
|
| 26 |
+
"""같은 데이터면 워드클라우드를 재생성하지 않고 캐시 반환."""
|
| 27 |
+
import pandas as pd
|
| 28 |
+
frequencies = build_word_frequencies(pd.Series(list(content_tuple)))
|
| 29 |
+
if not frequencies:
|
| 30 |
+
return None, None
|
| 31 |
+
wc_html = build_circular_wordcloud_html(frequencies)
|
| 32 |
+
top_words = ", ".join([f"{w}({c})" for w, c in frequencies.most_common(8)])
|
| 33 |
+
return wc_html, top_words
|
| 34 |
+
|
| 35 |
+
|
| 36 |
def _live_chat_board_core(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type):
|
| 37 |
opinion_df = with_fallback_author_role(
|
| 38 |
fetch_live_messages(supabase, room_name, LIVE_BOARD_FETCH_LIMIT)
|
|
|
|
| 49 |
st.plotly_chart(live_pie_fig, use_container_width=True, config={'displayModeBar': False, 'scrollZoom': False})
|
| 50 |
with right_col:
|
| 51 |
st.caption("누적 토의/토론 워드클라우드")
|
| 52 |
+
wc_html, top_words = _cached_wordcloud(
|
| 53 |
+
tuple(stats_opinion_df["content"].fillna("").tolist())
|
| 54 |
+
)
|
| 55 |
+
if wc_html:
|
| 56 |
+
st.markdown(wc_html, unsafe_allow_html=True)
|
| 57 |
st.caption(f"상위 키워드: {top_words}")
|
| 58 |
else:
|
| 59 |
st.info("워드클라우드를 만들 단어가 아직 부족합니다.")
|
|
|
|
| 95 |
# 쿨다운 확인
|
| 96 |
on_like_cooldown = (time.time() - st.session_state.get('_last_like_ts', 0)) < _LIKE_COOLDOWN
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def do_toggle_like(msg_id):
|
| 99 |
toggle_like(supabase, msg_id, room_name, student_name)
|
| 100 |
fetch_room_likes.clear()
|
|
|
|
| 131 |
type=like_type, use_container_width=True,
|
| 132 |
on_click=do_toggle_like, args=(msg_id,))
|
| 133 |
with c_del:
|
| 134 |
+
if st.button("❌", key=f"del_{msg_id}", help="강제 삭제"):
|
| 135 |
+
try:
|
| 136 |
+
if delete_opinion_message(supabase, msg_id) is not None:
|
| 137 |
+
fetch_live_messages.clear()
|
| 138 |
+
_cached_wordcloud.clear()
|
| 139 |
+
log_audit("chat_deleted", room_name=room_name, actor_name=student_name,
|
| 140 |
+
role=user_role, message_id=msg_id)
|
| 141 |
+
st.toast("의견이 즉시 삭제되었습니다.", icon="🗑️")
|
| 142 |
+
st.rerun(scope="app")
|
| 143 |
+
except Exception as e:
|
| 144 |
+
st.error(f"삭제 실패: {e}")
|
| 145 |
else:
|
| 146 |
c_name, c_like = st.columns([5, 1])
|
| 147 |
with c_name:
|
|
|
|
| 154 |
st.button(like_label, key=f"like_{msg_id}", disabled=like_disabled,
|
| 155 |
type=like_type, use_container_width=True,
|
| 156 |
on_click=do_toggle_like, args=(msg_id,))
|
| 157 |
+
st.info(_escape_md(row['content']))
|
| 158 |
st.write("")
|
| 159 |
|
| 160 |
if current_mode == "⚔️ 찬반 토론":
|
components/teacher_dashboard.py
CHANGED
|
@@ -201,6 +201,32 @@ def _render_oc_section(supabase, room_name, act_type, current_topic, df_all):
|
|
| 201 |
st.info("아직 제출된 결론이 없습니다.")
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
def render_teacher_dashboard(supabase, room_name, user_role, student_name, current_topic, current_mode, act_type):
|
| 205 |
st.divider()
|
| 206 |
col_dash_title, col_dash_refresh = st.columns([8, 2])
|
|
@@ -211,13 +237,6 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
|
|
| 211 |
st.rerun()
|
| 212 |
|
| 213 |
df_all = with_fallback_author_role(fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT))
|
| 214 |
-
student_only_df = (
|
| 215 |
-
df_all[
|
| 216 |
-
(df_all['author_role'] == '학생') &
|
| 217 |
-
~df_all['student_name'].str.contains('익명|AI', na=False, regex=True)
|
| 218 |
-
].copy()
|
| 219 |
-
if not df_all.empty else df_all
|
| 220 |
-
)
|
| 221 |
|
| 222 |
# ── 1. AI 토의 촉진 ──
|
| 223 |
render_hint_section(supabase, room_name, user_role, student_name, current_topic, act_type, df_all)
|
|
@@ -244,22 +263,9 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
|
|
| 244 |
st.divider()
|
| 245 |
render_summary_section(room_name, act_type, current_topic, df_all)
|
| 246 |
|
| 247 |
-
# ── 4. 학생 참여도 현황 ──
|
| 248 |
st.divider()
|
| 249 |
-
|
| 250 |
-
if not df_all.empty:
|
| 251 |
-
if not student_only_df.empty:
|
| 252 |
-
counts = student_only_df['student_name'].astype(str).value_counts().reset_index()
|
| 253 |
-
counts.columns = ['학생 이름', '참여 횟수']
|
| 254 |
-
counts['학생 이름'] = counts['학생 이름'] + " "
|
| 255 |
-
fig = px.bar(counts, x='학생 이름', y='참여 횟수', text='참여 횟수', color='학생 이름')
|
| 256 |
-
fig.update_xaxes(type='category', title="")
|
| 257 |
-
fig.update_layout(yaxis_title="의견 수", dragmode=False, showlegend=False, font={"family": UI_FONT_FAMILY})
|
| 258 |
-
st.plotly_chart(fig, use_container_width=True, config={'scrollZoom': False, 'displayModeBar': False})
|
| 259 |
-
else:
|
| 260 |
-
st.info("실명 참여 데이터가 없습니다.")
|
| 261 |
-
else:
|
| 262 |
-
st.info(f"{act_type} 데이터가 없습니다.")
|
| 263 |
|
| 264 |
_render_oc_section(supabase, room_name, act_type, current_topic, df_all)
|
| 265 |
render_depth_analysis_section(supabase, room_name, act_type)
|
|
|
|
| 201 |
st.info("아직 제출된 결론이 없습니다.")
|
| 202 |
|
| 203 |
|
| 204 |
+
@st.fragment(run_every=10)
|
| 205 |
+
def _render_participation_section(supabase, room_name, act_type):
|
| 206 |
+
df = with_fallback_author_role(fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT))
|
| 207 |
+
student_df = (
|
| 208 |
+
df[
|
| 209 |
+
(df['author_role'] == '학생') &
|
| 210 |
+
~df['student_name'].str.contains('익명|AI', na=False, regex=True)
|
| 211 |
+
].copy()
|
| 212 |
+
if not df.empty else df
|
| 213 |
+
)
|
| 214 |
+
st.subheader("📊 학생 참여도 현황")
|
| 215 |
+
if not df.empty:
|
| 216 |
+
if not student_df.empty:
|
| 217 |
+
counts = student_df['student_name'].astype(str).value_counts().reset_index()
|
| 218 |
+
counts.columns = ['학생 이름', '참여 횟수']
|
| 219 |
+
counts['학생 이름'] = counts['학생 이름'] + " "
|
| 220 |
+
fig = px.bar(counts, x='학생 이름', y='참여 횟수', text='참여 횟수', color='학생 이름')
|
| 221 |
+
fig.update_xaxes(type='category', title="")
|
| 222 |
+
fig.update_layout(yaxis_title="의견 수", dragmode=False, showlegend=False, font={"family": UI_FONT_FAMILY})
|
| 223 |
+
st.plotly_chart(fig, use_container_width=True, config={'scrollZoom': False, 'displayModeBar': False})
|
| 224 |
+
else:
|
| 225 |
+
st.info("실명 참여 데이터가 없습니다.")
|
| 226 |
+
else:
|
| 227 |
+
st.info(f"{act_type} 데이터가 없습니다.")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def render_teacher_dashboard(supabase, room_name, user_role, student_name, current_topic, current_mode, act_type):
|
| 231 |
st.divider()
|
| 232 |
col_dash_title, col_dash_refresh = st.columns([8, 2])
|
|
|
|
| 237 |
st.rerun()
|
| 238 |
|
| 239 |
df_all = with_fallback_author_role(fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# ── 1. AI 토의 촉진 ──
|
| 242 |
render_hint_section(supabase, room_name, user_role, student_name, current_topic, act_type, df_all)
|
|
|
|
| 263 |
st.divider()
|
| 264 |
render_summary_section(room_name, act_type, current_topic, df_all)
|
| 265 |
|
| 266 |
+
# ── 4. 학생 참여도 현황 (10초마다 자동 갱신) ──
|
| 267 |
st.divider()
|
| 268 |
+
_render_participation_section(supabase, room_name, act_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
_render_oc_section(supabase, room_name, act_type, current_topic, df_all)
|
| 271 |
render_depth_analysis_section(supabase, room_name, act_type)
|