Claude commited on
Commit
1f31eaa
·
unverified ·
1 Parent(s): 8848482

feat: 작업 2 - 최종 입장 표현 구현

Browse files

[토론] 토론 전 초기 입장(찬성/반대/중립) 선택 UI 추가
[토론] 토론 후 최종 입장 투표 UI 추가
[토론] 교사 대시보드 전후 입장 변화율 도넛 차트 시각화
[토의] 토의 종료 후 '가장 중요한 결론은?' 한 줄 입력 UI 추가
[토의] 교사 대시보드 결론 워드클라우드 시각화
DB: opinion_changes에 initial_stance, final_stance, discussion_conclusion 컬럼 추가 필요 (SQL 별도 안내)

https://claude.ai/code/session_016DE3uHLsVmA5Uv7gZGGaKT

Files changed (4) hide show
  1. app.py +1 -1
  2. components/opinion_change.py +52 -5
  3. components/teacher_dashboard.py +68 -2
  4. db.py +20 -16
app.py CHANGED
@@ -209,7 +209,7 @@ if user_role == "학생" and opinion_changes_available():
209
  row = fetch_opinion_change(supabase, room_name, student_name)
210
  has_pre_opinion = bool((row or {}).get("pre_opinion"))
211
  if not has_pre_opinion:
212
- render_pre_opinion_form(supabase, room_name, student_name, current_topic)
213
  st.caption("💡 위에서 토론 전 생각을 제출하면 의견 작성이 활성화됩니다.")
214
  else:
215
  _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode)
 
209
  row = fetch_opinion_change(supabase, room_name, student_name)
210
  has_pre_opinion = bool((row or {}).get("pre_opinion"))
211
  if not has_pre_opinion:
212
+ render_pre_opinion_form(supabase, room_name, student_name, current_topic, act_type)
213
  st.caption("💡 위에서 토론 전 생각을 제출하면 의견 작성이 활성화됩니다.")
214
  else:
215
  _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode)
components/opinion_change.py CHANGED
@@ -7,6 +7,7 @@ from db import (
7
  fetch_opinion_change,
8
  opinion_changes_available,
9
  save_opinion_analysis,
 
10
  upsert_post_opinion,
11
  upsert_pre_opinion,
12
  )
@@ -14,9 +15,22 @@ from env import get_secret
14
  from services.ai import build_opinion_change_prompt, generate_ai_response
15
 
16
 
17
- def render_pre_opinion_form(supabase, room_name, student_name, current_topic):
 
 
 
18
  """토론 전 생각 입력 폼. 제출 완료 시 전체 앱 재실행."""
19
- st.info(f"💬 **토론 전 내 생각 먼저 기록하기**\n\n'{current_topic}' 주제에 대한 나의 생각을 적어주세요. 제출 후 토론에 참여할 수 있습니다.")
 
 
 
 
 
 
 
 
 
 
20
  pre_input = st.text_area(
21
  "이 주제에 대한 내 생각은?",
22
  height=100,
@@ -28,7 +42,7 @@ def render_pre_opinion_form(supabase, room_name, student_name, current_topic):
28
  if not pre_input.strip():
29
  st.warning("생각을 입력해 주세요.")
30
  return
31
- res = upsert_pre_opinion(supabase, room_name, student_name, pre_input.strip())
32
  if res is not None:
33
  st.toast("✅ 내 생각이 기록되었습니다. 이제 토론에 참여할 수 있습니다!", icon="🎉")
34
  st.rerun()
@@ -54,7 +68,30 @@ def render_post_opinion_section(supabase, room_name, student_name, act_type, cur
54
  st.caption("📌 토론 전 생각 기록 없음")
55
 
56
  if not post_opinion:
57
- st.info("토론이 종료되었습니다. 토론 후 생각이 어떻게 바뀌었는지 기록해 주세요.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  post_input = st.text_area(
59
  "토론 후 생각 변화",
60
  height=100,
@@ -67,13 +104,23 @@ def render_post_opinion_section(supabase, room_name, student_name, act_type, cur
67
  if not post_input.strip():
68
  st.warning("생각을 입력해 주세요.")
69
  return
70
- res = upsert_post_opinion(supabase, room_name, student_name, post_input.strip())
 
 
 
 
71
  if res is not None:
72
  _trigger_analysis(supabase, room_name, student_name, act_type, current_topic, pre_opinion, post_input.strip())
73
  st.rerun()
74
  else:
75
  st.error("저장에 실패했습니다. 다시 시도해 주세요.")
76
  else:
 
 
 
 
 
 
77
  st.success(f"✅ **토론 후 내 생각:** {post_opinion}")
78
  st.divider()
79
  if ai_analysis:
 
7
  fetch_opinion_change,
8
  opinion_changes_available,
9
  save_opinion_analysis,
10
+ stance_available,
11
  upsert_post_opinion,
12
  upsert_pre_opinion,
13
  )
 
15
  from services.ai import build_opinion_change_prompt, generate_ai_response
16
 
17
 
18
+ _STANCE_OPTIONS = ["🔵 찬성", "🔴 반대", "⚪ 중립"]
19
+
20
+
21
+ def render_pre_opinion_form(supabase, room_name, student_name, current_topic, act_type="토론"):
22
  """토론 전 생각 입력 폼. 제출 완료 시 전체 앱 재실행."""
23
+ st.info(f"💬 **{'토론' if act_type == '토론' else '토의'} 전 내 생각 먼저 기록하기**\n\n'{current_topic}' 주제에 대한 나의 생각을 적어주세요. 제출 후 {'토론' if act_type == '토론' else '토의'}에 참여할 수 있습니다.")
24
+
25
+ initial_stance = None
26
+ if act_type == "토론" and stance_available():
27
+ initial_stance = st.radio(
28
+ "📌 토론 전 나의 초기 입장",
29
+ _STANCE_OPTIONS,
30
+ horizontal=True,
31
+ key="pre_stance_radio",
32
+ )
33
+
34
  pre_input = st.text_area(
35
  "이 주제에 대한 내 생각은?",
36
  height=100,
 
42
  if not pre_input.strip():
43
  st.warning("생각을 입력해 주세요.")
44
  return
45
+ res = upsert_pre_opinion(supabase, room_name, student_name, pre_input.strip(), initial_stance=initial_stance)
46
  if res is not None:
47
  st.toast("✅ 내 생각이 기록되었습니다. 이제 토론에 참여할 수 있습니다!", icon="🎉")
48
  st.rerun()
 
68
  st.caption("📌 토론 전 생각 기록 없음")
69
 
70
  if not post_opinion:
71
+ end_label = "토론" if act_type == "토론" else "토의"
72
+ st.info(f"{end_label}이 종료되었습니다. {end_label} 후 생각이 어떻게 바뀌었는지 기록해 주세요.")
73
+
74
+ final_stance = None
75
+ discussion_conclusion = None
76
+
77
+ if act_type == "토론" and stance_available():
78
+ initial_stance_val = (row or {}).get("initial_stance") or ""
79
+ if initial_stance_val:
80
+ st.caption(f"📌 **토론 전 나의 입장:** {initial_stance_val}")
81
+ final_stance = st.radio(
82
+ "🗳️ 토론 후 최종 입장",
83
+ _STANCE_OPTIONS,
84
+ horizontal=True,
85
+ key="post_stance_radio",
86
+ )
87
+
88
+ if act_type == "토의" and stance_available():
89
+ discussion_conclusion = st.text_input(
90
+ "💡 가장 중요한 결론은?",
91
+ max_chars=200,
92
+ placeholder="이번 토의에서 얻은 가장 중요한 결론을 한 줄로 써보세요.",
93
+ )
94
+
95
  post_input = st.text_area(
96
  "토론 후 생각 변화",
97
  height=100,
 
104
  if not post_input.strip():
105
  st.warning("생각을 입력해 주세요.")
106
  return
107
+ res = upsert_post_opinion(
108
+ supabase, room_name, student_name, post_input.strip(),
109
+ final_stance=final_stance,
110
+ discussion_conclusion=discussion_conclusion.strip() if discussion_conclusion else None,
111
+ )
112
  if res is not None:
113
  _trigger_analysis(supabase, room_name, student_name, act_type, current_topic, pre_opinion, post_input.strip())
114
  st.rerun()
115
  else:
116
  st.error("저장에 실패했습니다. 다시 시도해 주세요.")
117
  else:
118
+ final_stance_val = (row or {}).get("final_stance") or ""
119
+ discussion_conclusion_val = (row or {}).get("discussion_conclusion") or ""
120
+ if act_type == "토론" and final_stance_val:
121
+ st.success(f"✅ **최종 입장:** {final_stance_val}")
122
+ if act_type == "토의" and discussion_conclusion_val:
123
+ st.success(f"✅ **나의 결론:** {discussion_conclusion_val}")
124
  st.success(f"✅ **토론 후 내 생각:** {post_opinion}")
125
  st.divider()
126
  if ai_analysis:
components/teacher_dashboard.py CHANGED
@@ -3,9 +3,10 @@ import logging
3
  import plotly.express as px
4
  import streamlit as st
5
 
6
- from db import destroy_room_data, fetch_all_opinion_changes, fetch_debate_status, fetch_live_messages, opinion_changes_available, session_control_available, set_debate_status
7
  from utils import create_analysis_image
8
- from components.opinion_change import _render_image_download
 
9
  from validators import with_fallback_author_role
10
  from utils import log_audit
11
  from config import DASHBOARD_FETCH_LIMIT, ROOM_DESTROY_ENABLED, UI_FONT_FAMILY
@@ -60,6 +61,19 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
60
  pre = row.get("pre_opinion") or "(없음)"
61
  post = row.get("post_opinion") or "(없음)"
62
  ai = row.get("ai_analysis") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  col_pre, col_post = st.columns(2)
64
  with col_pre:
65
  st.caption("📌 토론 전 생각")
@@ -78,6 +92,58 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
78
  else:
79
  st.caption("AI 분석이 아직 없습니다.")
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  if session_control_available():
82
  st.divider()
83
  st.subheader("🎛️ 토론 진행 제어")
 
3
  import plotly.express as px
4
  import streamlit as st
5
 
6
+ from db import destroy_room_data, fetch_all_opinion_changes, fetch_debate_status, fetch_live_messages, opinion_changes_available, session_control_available, set_debate_status, stance_available
7
  from utils import create_analysis_image
8
+ from components.opinion_change import _render_image_download, _STANCE_OPTIONS
9
+ from wordcloud import build_word_frequencies, build_circular_wordcloud_html
10
  from validators import with_fallback_author_role
11
  from utils import log_audit
12
  from config import DASHBOARD_FETCH_LIMIT, ROOM_DESTROY_ENABLED, UI_FONT_FAMILY
 
61
  pre = row.get("pre_opinion") or "(없음)"
62
  post = row.get("post_opinion") or "(없음)"
63
  ai = row.get("ai_analysis") or ""
64
+
65
+ if stance_available() and act_type == "토론":
66
+ init_s = row.get("initial_stance") or ""
67
+ final_s = row.get("final_stance") or ""
68
+ if init_s or final_s:
69
+ col_is, col_fs = st.columns(2)
70
+ with col_is:
71
+ st.caption("📌 토론 전 입장")
72
+ st.info(init_s or "(미입력)")
73
+ with col_fs:
74
+ st.caption("🗳️ 토론 후 최종 입장")
75
+ st.info(final_s or "(미입력)")
76
+
77
  col_pre, col_post = st.columns(2)
78
  with col_pre:
79
  st.caption("📌 토론 전 생각")
 
92
  else:
93
  st.caption("AI 분석이 아직 없습니다.")
94
 
95
+ # 입장 변화 도넛 차트 (토론) / 워드클라우드 (토의)
96
+ if stance_available():
97
+ st.divider()
98
+ if act_type == "토론":
99
+ st.subheader("📊 입장 변화 현황")
100
+ col_d1, col_d2 = st.columns(2)
101
+ for col, col_name, label in [
102
+ (col_d1, "initial_stance", "토론 전 초기 입장"),
103
+ (col_d2, "final_stance", "토론 후 최종 입장"),
104
+ ]:
105
+ if col_name in df_oc.columns:
106
+ counts = (
107
+ df_oc[col_name]
108
+ .dropna()
109
+ .value_counts()
110
+ .reindex(_STANCE_OPTIONS, fill_value=0)
111
+ .reset_index()
112
+ )
113
+ counts.columns = ["입장", "인원"]
114
+ with col:
115
+ st.caption(label)
116
+ if counts["인원"].sum() > 0:
117
+ fig = px.pie(
118
+ counts, names="입장", values="인원",
119
+ hole=0.45,
120
+ color="입장",
121
+ color_discrete_map={
122
+ "🔵 찬성": "#1558a0",
123
+ "🔴 반대": "#d62728",
124
+ "⚪ 중립": "#aaaaaa",
125
+ },
126
+ )
127
+ fig.update_layout(
128
+ margin=dict(t=10, b=10, l=10, r=10),
129
+ font={"family": UI_FONT_FAMILY},
130
+ showlegend=True,
131
+ legend=dict(orientation="h"),
132
+ )
133
+ st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
134
+ else:
135
+ st.info("아직 입력된 입장이 없습니다.")
136
+ elif act_type == "토의":
137
+ if "discussion_conclusion" in df_oc.columns:
138
+ conclusions = df_oc["discussion_conclusion"].dropna()
139
+ if not conclusions.empty:
140
+ st.subheader("☁️ 결론 워드클라우드")
141
+ freq = build_word_frequencies(conclusions)
142
+ if freq:
143
+ st.markdown(build_circular_wordcloud_html(freq), unsafe_allow_html=True)
144
+ else:
145
+ st.info("아직 제출된 결론이 없습니다.")
146
+
147
  if session_control_available():
148
  st.divider()
149
  st.subheader("🎛️ 토론 진행 제어")
db.py CHANGED
@@ -118,6 +118,7 @@ def check_schema_columns() -> dict:
118
  ("topic.created_by", lambda: supabase.table("topic").select("created_by").limit(1).execute()),
119
  ("teacher_accounts.is_admin", lambda: supabase.table("teacher_accounts").select("is_admin").limit(1).execute()),
120
  ("opinion_changes.pre_opinion", lambda: supabase.table("opinion_changes").select("pre_opinion").limit(1).execute()),
 
121
  ("session_control.status", lambda: supabase.table("session_control").select("status").limit(1).execute()),
122
  ]
123
 
@@ -153,6 +154,9 @@ def topic_owner_column_available() -> bool:
153
  def opinion_changes_available() -> bool:
154
  return check_schema_columns().get("opinion_changes.pre_opinion", False)
155
 
 
 
 
156
  def session_control_available() -> bool:
157
  return check_schema_columns().get("session_control.status", False)
158
 
@@ -380,7 +384,7 @@ def fetch_opinion_change(supabase: Client, room_name: str, student_name: str):
380
  return None
381
  res = execute_query(
382
  supabase.table("opinion_changes")
383
- .select("pre_opinion, post_opinion, ai_analysis")
384
  .eq("room_name", room_name)
385
  .eq("student_name", student_name)
386
  .limit(1),
@@ -391,40 +395,40 @@ def fetch_opinion_change(supabase: Client, room_name: str, student_name: str):
391
  return res.data[0]
392
 
393
 
394
- def upsert_pre_opinion(supabase: Client, room_name: str, student_name: str, pre_opinion: str):
395
  if not opinion_changes_available():
396
  return None
 
 
 
397
  existing = fetch_opinion_change(supabase, room_name, student_name)
398
  if existing is not None:
399
  return execute_query(
400
- supabase.table("opinion_changes")
401
- .update({"pre_opinion": pre_opinion})
402
- .eq("room_name", room_name)
403
- .eq("student_name", student_name),
404
  fail_message="토론 전 생각 저장 실패",
405
  )
406
  return execute_query(
407
- supabase.table("opinion_changes")
408
- .insert({"room_name": room_name, "student_name": student_name, "pre_opinion": pre_opinion}),
409
  fail_message="토론 전 생각 저장 실패",
410
  )
411
 
412
 
413
- def upsert_post_opinion(supabase: Client, room_name: str, student_name: str, post_opinion: str):
414
  if not opinion_changes_available():
415
  return None
 
 
 
 
 
416
  existing = fetch_opinion_change(supabase, room_name, student_name)
417
  if existing is not None:
418
  return execute_query(
419
- supabase.table("opinion_changes")
420
- .update({"post_opinion": post_opinion})
421
- .eq("room_name", room_name)
422
- .eq("student_name", student_name),
423
  fail_message="토론 후 생각 저장 실패",
424
  )
425
  return execute_query(
426
- supabase.table("opinion_changes")
427
- .insert({"room_name": room_name, "student_name": student_name, "post_opinion": post_opinion}),
428
  fail_message="토론 후 생각 저장 실패",
429
  )
430
 
@@ -434,7 +438,7 @@ def fetch_all_opinion_changes(supabase: Client, room_name: str):
434
  return pd.DataFrame()
435
  res = execute_query(
436
  supabase.table("opinion_changes")
437
- .select("student_name, pre_opinion, post_opinion, ai_analysis")
438
  .eq("room_name", room_name)
439
  .order("student_name"),
440
  fail_message="학생별 생각 변화 조회 실패",
 
118
  ("topic.created_by", lambda: supabase.table("topic").select("created_by").limit(1).execute()),
119
  ("teacher_accounts.is_admin", lambda: supabase.table("teacher_accounts").select("is_admin").limit(1).execute()),
120
  ("opinion_changes.pre_opinion", lambda: supabase.table("opinion_changes").select("pre_opinion").limit(1).execute()),
121
+ ("opinion_changes.initial_stance", lambda: supabase.table("opinion_changes").select("initial_stance").limit(1).execute()),
122
  ("session_control.status", lambda: supabase.table("session_control").select("status").limit(1).execute()),
123
  ]
124
 
 
154
  def opinion_changes_available() -> bool:
155
  return check_schema_columns().get("opinion_changes.pre_opinion", False)
156
 
157
+ def stance_available() -> bool:
158
+ return check_schema_columns().get("opinion_changes.initial_stance", False)
159
+
160
  def session_control_available() -> bool:
161
  return check_schema_columns().get("session_control.status", False)
162
 
 
384
  return None
385
  res = execute_query(
386
  supabase.table("opinion_changes")
387
+ .select("*")
388
  .eq("room_name", room_name)
389
  .eq("student_name", student_name)
390
  .limit(1),
 
395
  return res.data[0]
396
 
397
 
398
+ def upsert_pre_opinion(supabase: Client, room_name: str, student_name: str, pre_opinion: str, initial_stance: str = None):
399
  if not opinion_changes_available():
400
  return None
401
+ payload = {"pre_opinion": pre_opinion}
402
+ if initial_stance and stance_available():
403
+ payload["initial_stance"] = initial_stance
404
  existing = fetch_opinion_change(supabase, room_name, student_name)
405
  if existing is not None:
406
  return execute_query(
407
+ supabase.table("opinion_changes").update(payload).eq("room_name", room_name).eq("student_name", student_name),
 
 
 
408
  fail_message="토론 전 생각 저장 실패",
409
  )
410
  return execute_query(
411
+ supabase.table("opinion_changes").insert({"room_name": room_name, "student_name": student_name, **payload}),
 
412
  fail_message="토론 전 생각 저장 실패",
413
  )
414
 
415
 
416
+ def upsert_post_opinion(supabase: Client, room_name: str, student_name: str, post_opinion: str, final_stance: str = None, discussion_conclusion: str = None):
417
  if not opinion_changes_available():
418
  return None
419
+ payload = {"post_opinion": post_opinion}
420
+ if final_stance and stance_available():
421
+ payload["final_stance"] = final_stance
422
+ if discussion_conclusion and stance_available():
423
+ payload["discussion_conclusion"] = discussion_conclusion
424
  existing = fetch_opinion_change(supabase, room_name, student_name)
425
  if existing is not None:
426
  return execute_query(
427
+ supabase.table("opinion_changes").update(payload).eq("room_name", room_name).eq("student_name", student_name),
 
 
 
428
  fail_message="토론 후 생각 저장 실패",
429
  )
430
  return execute_query(
431
+ supabase.table("opinion_changes").insert({"room_name": room_name, "student_name": student_name, **payload}),
 
432
  fail_message="토론 후 생각 저장 실패",
433
  )
434
 
 
438
  return pd.DataFrame()
439
  res = execute_query(
440
  supabase.table("opinion_changes")
441
+ .select("*")
442
  .eq("room_name", room_name)
443
  .order("student_name"),
444
  fail_message="학생별 생각 변화 조회 실패",