Claude commited on
Commit
352ddf6
·
unverified ·
1 Parent(s): 5c3354b

feat: AI 분석 결과 이미지 저장 + 교사 대시보드 학생별 분석 뷰어

Browse files

- 학생/교사 화면에 AI 배움 분석 PNG 이미지 다운로드 버튼 추가
- Dockerfile.base에 NanumGothic 한글 폰트 설치 추가
- 교사 대시보드에 학생별 분석 결과 조회 기능 추가 (드롭다운 선택)
- fetch_all_opinion_changes() DB 함수 추가

https://claude.ai/code/session_016DE3uHLsVmA5Uv7gZGGaKT

Dockerfile.base CHANGED
@@ -1,4 +1,6 @@
1
  FROM python:3.11-slim
2
  WORKDIR /app
3
  COPY requirements.txt .
 
 
4
  RUN pip install --no-cache-dir -r requirements.txt
 
1
  FROM python:3.11-slim
2
  WORKDIR /app
3
  COPY requirements.txt .
4
+ RUN apt-get update && apt-get install -y --no-install-recommends fonts-nanum \
5
+ && rm -rf /var/lib/apt/lists/*
6
  RUN pip install --no-cache-dir -r requirements.txt
components/opinion_change.py CHANGED
@@ -1,6 +1,7 @@
1
  import streamlit as st
2
 
3
  from config import AI_MODEL_NAME, LIVE_BOARD_FETCH_LIMIT
 
4
  from db import (
5
  fetch_debate_status,
6
  fetch_live_messages,
@@ -77,6 +78,20 @@ def render_post_opinion_section(supabase, room_name, student_name, act_type, cur
77
  if ai_analysis:
78
  st.info("🤖 **AI 배움 분석**")
79
  st.markdown(ai_analysis.replace("\n", "\n\n"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  else:
81
  if st.button("🤖 AI 배움 분석 받기", use_container_width=True):
82
  _trigger_analysis(supabase, room_name, student_name, act_type, current_topic, pre_opinion, post_opinion)
 
1
  import streamlit as st
2
 
3
  from config import AI_MODEL_NAME, LIVE_BOARD_FETCH_LIMIT
4
+ from utils import create_analysis_image, get_kst_now
5
  from db import (
6
  fetch_debate_status,
7
  fetch_live_messages,
 
78
  if ai_analysis:
79
  st.info("🤖 **AI 배움 분석**")
80
  st.markdown(ai_analysis.replace("\n", "\n\n"))
81
+ try:
82
+ img_bytes = create_analysis_image(
83
+ student_name, current_topic, pre_opinion, post_opinion, ai_analysis
84
+ )
85
+ filename = f"배움분석_{student_name}_{get_kst_now().strftime('%Y%m%d_%H%M')}.png"
86
+ st.download_button(
87
+ "🖼️ 분석 결과 이미지로 저장",
88
+ data=img_bytes,
89
+ file_name=filename,
90
+ mime="image/png",
91
+ use_container_width=True,
92
+ )
93
+ except Exception:
94
+ pass
95
  else:
96
  if st.button("🤖 AI 배움 분석 받기", use_container_width=True):
97
  _trigger_analysis(supabase, room_name, student_name, act_type, current_topic, pre_opinion, post_opinion)
components/teacher_dashboard.py CHANGED
@@ -3,7 +3,8 @@ import logging
3
  import plotly.express as px
4
  import streamlit as st
5
 
6
- from db import destroy_room_data, fetch_debate_status, fetch_live_messages, session_control_available, set_debate_status
 
7
  from validators import with_fallback_author_role
8
  from utils import log_audit
9
  from config import DASHBOARD_FETCH_LIMIT, ROOM_DESTROY_ENABLED, UI_FONT_FAMILY
@@ -47,6 +48,43 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
47
  else:
48
  st.info(f"{act_type} 데이터가 없습니다.")
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  if session_control_available():
51
  st.divider()
52
  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
7
+ from utils import create_analysis_image, get_kst_now
8
  from validators import with_fallback_author_role
9
  from utils import log_audit
10
  from config import DASHBOARD_FETCH_LIMIT, ROOM_DESTROY_ENABLED, UI_FONT_FAMILY
 
48
  else:
49
  st.info(f"{act_type} 데이터가 없습니다.")
50
 
51
+ if opinion_changes_available():
52
+ df_oc = fetch_all_opinion_changes(supabase, room_name)
53
+ if not df_oc.empty:
54
+ st.divider()
55
+ st.subheader("🔍 학생별 배움 분석")
56
+ students = df_oc["student_name"].tolist()
57
+ selected = st.selectbox("학생 선택", students, key="oc_student_select")
58
+ row = df_oc[df_oc["student_name"] == selected].iloc[0]
59
+ pre = row.get("pre_opinion") or "(없음)"
60
+ post = row.get("post_opinion") or "(없음)"
61
+ ai = row.get("ai_analysis") or ""
62
+ col_pre, col_post = st.columns(2)
63
+ with col_pre:
64
+ st.caption("📌 토론 전 생각")
65
+ st.info(pre)
66
+ with col_post:
67
+ st.caption("🔄 토론 후 생각")
68
+ st.info(post)
69
+ if ai:
70
+ st.caption("🤖 AI 배움 분석")
71
+ st.markdown(ai.replace("\n", "\n\n"))
72
+ try:
73
+ img_bytes = create_analysis_image(selected, current_topic, pre, post, ai)
74
+ filename = f"배움분석_{selected}_{get_kst_now().strftime('%Y%m%d_%H%M')}.png"
75
+ st.download_button(
76
+ "🖼️ 분석 결과 이미지로 저장",
77
+ data=img_bytes,
78
+ file_name=filename,
79
+ mime="image/png",
80
+ use_container_width=True,
81
+ key="oc_img_download",
82
+ )
83
+ except Exception:
84
+ pass
85
+ else:
86
+ st.caption("AI 분석이 아직 없습니다.")
87
+
88
  if session_control_available():
89
  st.divider()
90
  st.subheader("🎛️ 토론 진행 제어")
db.py CHANGED
@@ -429,6 +429,21 @@ def upsert_post_opinion(supabase: Client, room_name: str, student_name: str, pos
429
  )
430
 
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  def save_opinion_analysis(supabase: Client, room_name: str, student_name: str, ai_analysis: str):
433
  if not opinion_changes_available():
434
  return None
 
429
  )
430
 
431
 
432
+ def fetch_all_opinion_changes(supabase: Client, room_name: str):
433
+ if not opinion_changes_available():
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="학생별 생각 변화 조회 실패",
441
+ )
442
+ if not res or not res.data:
443
+ return pd.DataFrame()
444
+ return pd.DataFrame(res.data)
445
+
446
+
447
  def save_opinion_analysis(supabase: Client, room_name: str, student_name: str, ai_analysis: str):
448
  if not opinion_changes_available():
449
  return None
utils.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import logging
2
  import re
3
  from datetime import datetime, timedelta, timezone
@@ -81,6 +82,160 @@ def to_bool_flag(value):
81
  return str(value).strip().lower() in {"true", "t", "1", "yes", "y", "on"}
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def compact_ai_report_output(text):
85
  raw_lines = [str(line).strip() for line in str(text or "").splitlines()]
86
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]
 
1
+ import io
2
  import logging
3
  import re
4
  from datetime import datetime, timedelta, timezone
 
82
  return str(value).strip().lower() in {"true", "t", "1", "yes", "y", "on"}
83
 
84
 
85
+ _FONT_CANDIDATES = [
86
+ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
87
+ "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
88
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
89
+ ]
90
+
91
+
92
+ def _get_pil_font(size: int):
93
+ from PIL import ImageFont
94
+ for path in _FONT_CANDIDATES:
95
+ try:
96
+ return ImageFont.truetype(path, size)
97
+ except Exception:
98
+ continue
99
+ return ImageFont.load_default()
100
+
101
+
102
+ def _strip_non_renderable(text: str) -> str:
103
+ """이미지 렌더링 시 깨질 수 있는 이모지/심볼 제거."""
104
+ result = []
105
+ for ch in (text or ""):
106
+ code = ord(ch)
107
+ if code > 0xFFFF or 0xD800 <= code <= 0xDFFF:
108
+ continue
109
+ result.append(ch)
110
+ return "".join(result)
111
+
112
+
113
+ def _wrap_to_width(draw, text: str, font, max_width: int) -> list:
114
+ lines = []
115
+ for para in _strip_non_renderable(text).split("\n"):
116
+ if not para.strip():
117
+ lines.append("")
118
+ continue
119
+ words = para.split()
120
+ line: list = []
121
+ for word in words:
122
+ test = " ".join(line + [word])
123
+ try:
124
+ w = draw.textbbox((0, 0), test, font=font)[2]
125
+ except Exception:
126
+ w = len(test) * (font.size // 2 if hasattr(font, "size") else 9)
127
+ if w <= max_width:
128
+ line.append(word)
129
+ else:
130
+ if line:
131
+ lines.append(" ".join(line))
132
+ line = [word]
133
+ else:
134
+ lines.append(word)
135
+ if line:
136
+ lines.append(" ".join(line))
137
+ return lines or [""]
138
+
139
+
140
+ def create_analysis_image(
141
+ student_name: str,
142
+ topic: str,
143
+ pre_opinion: str,
144
+ post_opinion: str,
145
+ ai_analysis: str,
146
+ ) -> bytes:
147
+ """AI 배움 분석 결과를 PNG 이미지 bytes로 반환합니다."""
148
+ from PIL import Image, ImageDraw
149
+
150
+ W, PAD = 800, 40
151
+ CONTENT_W = W - PAD * 2
152
+
153
+ C_BG = (255, 255, 255)
154
+ C_HDR_BG = (25, 80, 160)
155
+ C_HDR_FG = (255, 255, 255)
156
+ C_HDR_SUB = (170, 200, 240)
157
+ C_ACCENT = (25, 80, 160)
158
+ C_TEXT = (30, 30, 30)
159
+ C_MUTED = (110, 110, 110)
160
+ C_DIVIDER = (220, 220, 220)
161
+ C_FTR_BG = (245, 245, 248)
162
+
163
+ f_title = _get_pil_font(22)
164
+ f_label = _get_pil_font(15)
165
+ f_body = _get_pil_font(17)
166
+ f_small = _get_pil_font(13)
167
+
168
+ LH_BODY = 27 # body line height px
169
+ LH_LABEL = 22
170
+ SEC_GAP = 16
171
+ HEADER_H = 74
172
+ FOOTER_H = 38
173
+ DIV_H = 20
174
+
175
+ # ── measure total height with dummy canvas ──────────────────────────
176
+ dummy = Image.new("RGB", (W, 10))
177
+ d = ImageDraw.Draw(dummy)
178
+
179
+ def section_h(text):
180
+ return LH_LABEL + 10 + len(_wrap_to_width(d, text, f_body, CONTENT_W)) * LH_BODY + SEC_GAP
181
+
182
+ total_h = (
183
+ HEADER_H + 18
184
+ + LH_LABEL + 14 # info line
185
+ + DIV_H
186
+ + section_h(pre_opinion)
187
+ + section_h(post_opinion)
188
+ + DIV_H
189
+ + section_h(ai_analysis)
190
+ + FOOTER_H
191
+ )
192
+
193
+ # ── render ──────────────────────────────────────────────────────────
194
+ img = Image.new("RGB", (W, total_h), C_BG)
195
+ draw = ImageDraw.Draw(img)
196
+
197
+ # Header
198
+ draw.rectangle([0, 0, W, HEADER_H], fill=C_HDR_BG)
199
+ draw.text((PAD, 14), "말자취 Talk-Trace AI", font=f_small, fill=C_HDR_SUB)
200
+ draw.text((PAD, 34), "AI 배움 분석 결과", font=f_title, fill=C_HDR_FG)
201
+
202
+ y = HEADER_H + 18
203
+
204
+ # Student / topic info line
205
+ info = f"학생: {_strip_non_renderable(student_name)} | 주제: {_strip_non_renderable(topic)}"
206
+ draw.text((PAD, y), info, font=f_label, fill=C_MUTED)
207
+ y += LH_LABEL + 14
208
+
209
+ def divider():
210
+ nonlocal y
211
+ draw.line([PAD, y, W - PAD, y], fill=C_DIVIDER, width=1)
212
+ y += DIV_H
213
+
214
+ def section(label, text):
215
+ nonlocal y
216
+ draw.text((PAD, y), label, font=f_label, fill=C_ACCENT)
217
+ y += LH_LABEL + 10
218
+ for line in _wrap_to_width(draw, text, f_body, CONTENT_W):
219
+ draw.text((PAD, y), line, font=f_body, fill=C_TEXT)
220
+ y += LH_BODY
221
+ y += SEC_GAP
222
+
223
+ divider()
224
+ section("토론 전 생각", pre_opinion or "(없음)")
225
+ section("토론 후 생각", post_opinion or "(없음)")
226
+ divider()
227
+ section("AI 배움 분석", ai_analysis or "(분석 없음)")
228
+
229
+ # Footer
230
+ draw.rectangle([0, total_h - FOOTER_H, W, total_h], fill=C_FTR_BG)
231
+ now_str = datetime.now(tz=KST).strftime("%Y-%m-%d %H:%M")
232
+ draw.text((PAD, total_h - FOOTER_H + 13), f"생성: {now_str}", font=f_small, fill=C_MUTED)
233
+
234
+ buf = io.BytesIO()
235
+ img.save(buf, format="PNG", optimize=True)
236
+ return buf.getvalue()
237
+
238
+
239
  def compact_ai_report_output(text):
240
  raw_lines = [str(line).strip() for line in str(text or "").splitlines()]
241
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]