Spaces:
Runtime error
Runtime error
Claude commited on
feat: AI 분석 결과 이미지 저장 + 교사 대시보드 학생별 분석 뷰어
Browse files- 학생/교사 화면에 AI 배움 분석 PNG 이미지 다운로드 버튼 추가
- Dockerfile.base에 NanumGothic 한글 폰트 설치 추가
- 교사 대시보드에 학생별 분석 결과 조회 기능 추가 (드롭다운 선택)
- fetch_all_opinion_changes() DB 함수 추가
https://claude.ai/code/session_016DE3uHLsVmA5Uv7gZGGaKT
- Dockerfile.base +2 -0
- components/opinion_change.py +15 -0
- components/teacher_dashboard.py +39 -1
- db.py +15 -0
- utils.py +155 -0
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("#")]
|