Claude commited on
Commit
c65e101
·
unverified ·
1 Parent(s): e5bb2a6

fix: 실시간 보드 삭제 반영, 내용 마크다운 렌더링, 참여도 자동갱신, 성능 개선

Browse files

1. 삭제 즉시 반영: 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

Files changed (3) hide show
  1. app.py +2 -2
  2. components/chat_board.py +37 -17
  3. 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=3)
133
  def _poll_debate_status(room_name):
134
- """학생 화면에서 3초마다 토론 상태를 확인하고 변경 시 전체 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
 
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
- frequencies = build_word_frequencies(stats_opinion_df["content"])
33
- if frequencies:
34
- st.markdown(build_circular_wordcloud_html(frequencies), unsafe_allow_html=True)
35
- top_words = ", ".join([f"{word}({count})" for word, count in frequencies.most_common(8)])
 
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
- on_click=delete_chat_msg, args=(msg_id,))
 
 
 
 
 
 
 
 
 
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
- st.subheader("📊 학생 참여도 현황")
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)