VioletWizard commited on
Commit
fe15e44
·
unverified ·
1 Parent(s): 70202b7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +59 -209
app.py CHANGED
@@ -58,7 +58,7 @@ if not logger.handlers:
58
  )
59
 
60
  # ==========================================
61
- # [1] 앱 설정 및 데이터베이스 (Supabase REST API) 연결
62
  # ==========================================
63
  DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
64
  DISPLAY_DATETIME_FMT = "%Y-%m-%d %p %I:%M:%S"
@@ -76,36 +76,27 @@ MAX_TOPIC_LEN = 120
76
  MAX_ENTRY_CODE_LEN = 60
77
  UI_FONT_FAMILY = "sans-serif"
78
 
79
- # 1. supabase 변수 생성 (딱 한 번만 실행)
80
  supabase = init_db()
81
-
82
- # 2. 자동 로그인 로직 (세션 체크 후 필요할 때만 로그인)
83
  ensure_db_login(supabase)
84
 
85
- # --- 기타 유틸리티 함수들 ---
86
  def get_kst_now():
87
  return datetime.utcnow() + timedelta(hours=9)
88
 
89
- # ✅ 수정 1: 중복 정의 제거 — get_kst_now_str() 하나만 유지
90
  def get_kst_now_str():
91
  return get_kst_now().strftime(DATETIME_FMT)
92
 
93
  def format_kst_datetime(value):
94
  if value is None:
95
  return "-"
96
-
97
  kst_tz = timezone(timedelta(hours=9))
98
  parsed_dt = None
99
  should_assume_utc = False
100
-
101
  if isinstance(value, datetime):
102
  parsed_dt = value
103
  else:
104
  raw = str(value).strip()
105
  if not raw:
106
  return "-"
107
- # Supabase timestamptz 문자열이 타임존 없이 내려오는 경우(예: 2026-04-11T14:20:31.063918)
108
- # UTC 기준으로 간주해 KST로 변환합니다.
109
  if "T" in raw and "+" not in raw and "Z" not in raw:
110
  should_assume_utc = True
111
  iso_candidate = raw.replace("Z", "+00:00")
@@ -118,15 +109,12 @@ def format_kst_datetime(value):
118
  break
119
  except ValueError:
120
  continue
121
-
122
  if parsed_dt is None:
123
  return str(value)
124
-
125
  if parsed_dt.tzinfo is not None:
126
  parsed_dt = parsed_dt.astimezone(kst_tz)
127
  elif should_assume_utc:
128
  parsed_dt = parsed_dt.replace(tzinfo=timezone.utc).astimezone(kst_tz)
129
-
130
  return parsed_dt.strftime(DISPLAY_DATETIME_FMT)
131
 
132
  def get_client_ip():
@@ -136,7 +124,6 @@ def get_client_ip():
136
  return ""
137
  if not headers:
138
  return ""
139
-
140
  for key in ["x-forwarded-for", "x-real-ip", "cf-connecting-ip", "fly-client-ip"]:
141
  raw_ip = headers.get(key)
142
  if raw_ip:
@@ -158,7 +145,6 @@ def build_word_frequencies(text_series):
158
  "로서", "보다", "까지", "부터", "만큼", "이나", "라도", "이며", "이고", "에서", "으로", "이라",
159
  "라고", "와", "과", "을", "를", "이", "가", "은", "는", "에", "도", "만", "로", "랑", "나"
160
  ]
161
-
162
  def normalize_token(token):
163
  cleaned = re.sub(r"^[^\w가-힣]+|[^\w가-힣]+$", "", token)
164
  if len(cleaned) < 2:
@@ -169,7 +155,6 @@ def build_word_frequencies(text_series):
169
  normalized = normalized[: -len(suffix)]
170
  break
171
  return normalized
172
-
173
  for content in text_series.fillna("").astype(str):
174
  for token in content.replace("\n", " ").split():
175
  cleaned = normalize_token(token)
@@ -183,7 +168,6 @@ def build_word_frequencies(text_series):
183
  def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=520):
184
  if not frequencies:
185
  return ""
186
-
187
  sorted_words = sorted(frequencies.items(), key=lambda item: (-item[1], item[0]))[:max_words]
188
  max_count = sorted_words[0][1]
189
  min_count = sorted_words[-1][1]
@@ -191,23 +175,16 @@ def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=5
191
  cx, cy = width / 2, height / 2
192
  placed_rects = []
193
  svg_text_nodes = []
194
-
195
  def estimate_text_units(word):
196
  units = 0.0
197
  for ch in word:
198
  code = ord(ch)
199
- if 0xAC00 <= code <= 0xD7A3: # 한글 음절
200
- units += 1.0
201
- elif 0x3130 <= code <= 0x318F: # 한글 자모
202
- units += 0.95
203
- elif 0x4E00 <= code <= 0x9FFF: # CJK
204
- units += 1.0
205
- elif ch.isascii() and (ch.isalpha() or ch.isdigit()):
206
- units += 0.62
207
- else:
208
- units += 0.75
209
  return max(units, 1.0)
210
-
211
  def overlaps(rect):
212
  x, y, w, h = rect
213
  padding = 2
@@ -215,11 +192,9 @@ def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=5
215
  if not (x + w + padding < ox or ox + ow + padding < x or y + h + padding < oy or oy + oh + padding < y):
216
  return True
217
  return False
218
-
219
  def is_inside_canvas(x, y, w, h):
220
  margin = 10
221
  return x >= margin and y >= margin and (x + w) <= (width - margin) and (y + h) <= (height - margin)
222
-
223
  for index, (word, count) in enumerate(sorted_words):
224
  if max_count == min_count:
225
  font_size = 28
@@ -228,12 +203,10 @@ def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=5
228
  eased_ratio = ratio ** 0.85
229
  font_size = int(18 + eased_ratio * 86)
230
  font_size = min(font_size, 140)
231
-
232
  color = palette[index % len(palette)]
233
  text_units = estimate_text_units(word)
234
  text_width = max(32, font_size * (text_units + 0.62))
235
  text_height = max(24, font_size * 1.22)
236
-
237
  placed = False
238
  for step in range(1, 3200):
239
  angle = step * 0.4 + index * 0.18
@@ -241,11 +214,8 @@ def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=5
241
  x = cx + spiral_radius * math.cos(angle) - text_width / 2
242
  y = cy + spiral_radius * math.sin(angle) - text_height / 2
243
  rect = (x, y, text_width, text_height)
244
- if not is_inside_canvas(x, y, text_width, text_height):
245
- continue
246
- if overlaps(rect):
247
- continue
248
-
249
  placed_rects.append(rect)
250
  safe_word = html.escape(word)
251
  tx, ty = x + 1.5, y + text_height * 0.84
@@ -255,10 +225,8 @@ def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=5
255
  )
256
  placed = True
257
  break
258
-
259
  if not placed:
260
  continue
261
-
262
  return (
263
  "<div style='padding:10px; border:1px solid #e9e9e9; border-radius:10px; background:#f3f5f7;'>"
264
  f"<svg viewBox='0 0 {width} {height}' style='width:100%; height:auto; display:block;' "
@@ -274,7 +242,6 @@ def render_admin_approval_panel():
274
  if not pending_accounts:
275
  st.info("승인 대기 중인 교사 계정이 없습니다.")
276
  return
277
-
278
  for pending in pending_accounts:
279
  acc_id = pending.get("id")
280
  pending_teacher_id = pending.get("teacher_id", "")
@@ -294,73 +261,28 @@ def render_admin_approval_panel():
294
  # [2] 앱 기본 설정 및 세션/CSS
295
  # ==========================================
296
  st.set_page_config(page_title="말자취(Talk-Trace) AI", layout="wide")
297
-
298
  st.markdown(
299
  """
300
  <style>
301
- /* 세특 기록 보관함 전용 테이블 */
302
- .records-db-table-wrap {
303
- overflow-x: auto;
304
- border: 1px solid #e6e6e6;
305
- border-radius: 10px;
306
- background: #fff;
307
- }
308
- .records-db-table-wrap table {
309
- width: 100%;
310
- border-collapse: collapse;
311
- table-layout: fixed;
312
- }
313
- .records-db-table-wrap th, .records-db-table-wrap td {
314
- border-bottom: 1px solid #efefef;
315
- border-right: 1px solid #efefef;
316
- padding: 10px 12px;
317
- text-align: left;
318
- vertical-align: top;
319
- }
320
- .records-db-table-wrap th:last-child, .records-db-table-wrap td:last-child {
321
- border-right: none;
322
- }
323
- .records-db-table-wrap th {
324
- white-space: nowrap;
325
- font-weight: 700;
326
- }
327
- .records-db-table-wrap th:nth-child(1), .records-db-table-wrap td:nth-child(1) {
328
- width: 5%;
329
- white-space: nowrap;
330
- }
331
- .records-db-table-wrap th:nth-child(2), .records-db-table-wrap td:nth-child(2) {
332
- width: 15%;
333
- white-space: nowrap;
334
- }
335
- .records-db-table-wrap th:nth-child(3), .records-db-table-wrap td:nth-child(3) {
336
- width: 12%;
337
- white-space: nowrap;
338
- }
339
- .records-db-table-wrap th:nth-child(4), .records-db-table-wrap td:nth-child(4) {
340
- width: 68%;
341
- white-space: pre-wrap;
342
- word-break: break-word;
343
- }
344
-
345
- /* 기존 스타일들 */
346
  [data-testid="stDecoration"] { display: none !important; }
347
-
348
  .stApp, [data-testid="stAppViewContainer"], [data-testid="stMainBlockContainer"],
349
  [data-testid="stAppViewBlockContainer"], [data-testid="stFragment"],
350
  [data-testid="stVerticalBlock"], [data-testid="stElementContainer"],
351
  [data-testid="stExpander"], details, summary,
352
  *[data-stale="true"], div[data-stale="true"] {
353
- opacity: 1 !important;
354
- transition: none !important;
355
- filter: none !important;
356
- -webkit-filter: none !important;
357
  }
358
-
359
  .stTextArea textarea, .stTextInput input, .stSelectbox, .stRadio label,
360
- .stMarkdown p, div[data-testid="stChatMessageContent"] {
361
- font-size: 18px !important;
362
- }
363
-
364
  .stAlert p { font-size: 20px !important; font-weight: bold; }
365
  </style>
366
  """,
@@ -395,29 +317,22 @@ def redirect_from_admin_page_if_needed():
395
  st.session_state['page'] = "lobby"
396
 
397
  def to_bool_flag(value):
398
- if isinstance(value, bool):
399
- return value
400
- if value is None:
401
- return False
402
  return str(value).strip().lower() in {"true", "t", "1", "yes", "y", "on"}
403
 
404
  def compact_ai_report_output(text):
405
  raw_lines = [str(line).strip() for line in str(text or "").splitlines()]
406
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]
407
- if not cleaned:
408
- return ""
409
-
410
  report_labels = ("핵심요약 1:", "핵심요약 2:", "핵심요약 3:", "베스트 학생:", "선정 이유:")
411
  normalized_text = " ".join(cleaned)
412
  if report_labels[0] in normalized_text:
413
  for label in report_labels[1:]:
414
  normalized_text = normalized_text.replace(f" {label}", f"\n{label}")
415
  normalized_text = normalized_text.replace(label, f"\n{label}")
416
-
417
  normalized_lines = [line.strip() for line in normalized_text.splitlines() if line.strip()]
418
- max_lines = 5
419
- compact_lines = normalized_lines[:max_lines]
420
- return "\n".join(compact_lines)
421
 
422
  # ==========================================
423
  # [3] 홈/네비게이션
@@ -440,7 +355,6 @@ if st.session_state['page'] == "home":
440
  2. 왼쪽 사이드바에서 **학생/교사 모드**를 선택합니다.
441
  3. 접속할 **토론/토의방**을 선택하고 입장합니다.
442
  4. 주제에 맞게 의견을 작성하고 제출하면 실시간 보드에 반영됩니다.
443
-
444
  ---
445
  - 교사 모드에서는 방 개설/관리 및 대시보드 기능을 사용할 수 있습니다.
446
  - 언제든 왼쪽 상단의 **🏠 홈** 버튼으로 이 화면으로 돌아올 수 있습니다.
@@ -452,7 +366,7 @@ if st.session_state['page'] == "home":
452
  st.stop()
453
 
454
  # ==========================================
455
- # [4] 사이드바 (방 관리 - 심플 모드)
456
  # ==========================================
457
  with st.sidebar:
458
  st.header("👤 접속 권한")
@@ -474,23 +388,14 @@ with st.sidebar:
474
  auth_mode = st.radio("교사 계정", ["로그인", "ID/PW 신청"], horizontal=True)
475
 
476
  if auth_mode == "로그인":
477
- # ✅ 수정 2: st.form으로 감싸서 엔터키로 로그인 가능하도록 수정
478
  with st.form("teacher_login_form"):
479
  teacher_id_input = st.text_input("교사 ID", key="teacher_id_input")
480
  teacher_pw_input = st.text_input("교사 PW", type="password", key="teacher_pw_input")
481
  login_submitted = st.form_submit_button("교사 로그인", use_container_width=True)
482
 
483
  if login_submitted:
484
- id_ok, safe_teacher_id, id_error_code, id_error_message = validate_teacher_credential(
485
- teacher_id_input,
486
- field_name="교사 ID",
487
- max_len=60,
488
- )
489
- pw_ok, safe_pw, pw_error_code, pw_error_message = validate_teacher_credential(
490
- teacher_pw_input,
491
- field_name="교사 PW",
492
- max_len=60,
493
- )
494
  if not id_ok:
495
  st.error(f"❌ {id_error_message} ({id_error_code})")
496
  st.stop()
@@ -546,21 +451,12 @@ with st.sidebar:
546
  st.rerun()
547
  else:
548
  st.toast("✅ 교사 로그인 성공", icon="✅")
549
-
550
  else:
551
  req_teacher_id = st.text_input("신청할 교사 ID", key="req_teacher_id")
552
  req_teacher_pw = st.text_input("신청할 교사 PW", type="password", key="req_teacher_pw")
553
  if st.button("교사 계정 신청", type="primary", use_container_width=True):
554
- id_ok, safe_id, id_error_code, id_error_message = validate_teacher_credential(
555
- req_teacher_id,
556
- field_name="교사 ID",
557
- max_len=60,
558
- )
559
- pw_ok, safe_pw, pw_error_code, pw_error_message = validate_teacher_credential(
560
- req_teacher_pw,
561
- field_name="교사 PW",
562
- max_len=60,
563
- )
564
  if not id_ok:
565
  st.error(f"❌ {id_error_message} ({id_error_code})")
566
  elif not pw_ok:
@@ -576,7 +472,6 @@ with st.sidebar:
576
  admin_auth = st.session_state['admin_auth']
577
  teacher_id_for_scope = st.session_state.get("teacher_id", "")
578
 
579
- # ✅ 수정 3: 로그아웃 버튼 추가
580
  if teacher_auth:
581
  st.caption(f"🔐 {teacher_id_for_scope} 로그인 중")
582
  if st.button("🚪 로그아웃", use_container_width=True):
@@ -609,7 +504,6 @@ with st.sidebar:
609
  st.warning("교사별 방 조회를 위해 topic.created_by_teacher_id(권장) 또는 topic.created_by 컬럼이 필요합니다.")
610
 
611
  room_opt = st.radio("방 관리", ["기존 방 선택", "새 방 만들기"])
612
-
613
  if room_opt == "기존 방 선택" and existing_rooms:
614
  room_name = st.selectbox("토론/토의방 목록", existing_rooms)
615
  else:
@@ -617,13 +511,11 @@ with st.sidebar:
617
  new_title = st.text_input("주제 직접 입력 (예: 인공지능 윤리)")
618
  new_mode = st.radio("진행 방식", ["⚔️ 찬반 토론", "💡 자유 토의"], horizontal=True)
619
  new_pw = st.text_input("🔒 학생 입장용 암호 (비워두면 공개방)")
620
-
621
  if st.button("새 방 개설하기", type="primary"):
622
  room_ok, safe_new_room, room_error_code, room_error_message = validate_room_name(new_room, max_len=MAX_ROOM_NAME_LEN)
623
  title_ok, safe_new_title, title_error_code, title_error_message = validate_opinion_content(new_title, max_len=MAX_TOPIC_LEN)
624
  entry_ok, safe_new_pw, entry_error_code, entry_error_message = validate_entry_code(new_pw, max_len=MAX_ENTRY_CODE_LEN)
625
  can_store_room_pw = topic_entry_code_column_available()
626
-
627
  if not room_ok:
628
  st.error(f"❌ {room_error_message} ({room_error_code})")
629
  elif not title_ok:
@@ -631,20 +523,15 @@ with st.sidebar:
631
  elif not entry_ok:
632
  st.error(f"❌ {entry_error_message} ({entry_error_code})")
633
  elif safe_new_pw and not can_store_room_pw:
634
- st.error("현재 DB 구조에서는 방 비밀번호 저장을 지원하지 않습니다. 비밀번호를 비우거나 DB에 topic.entry_code 컬럼을 추가해 주세요.")
635
  elif safe_new_room and safe_new_title:
636
  res = upsert_topic_room(
637
- supabase=supabase,
638
- room_name=safe_new_room,
639
- title=safe_new_title,
640
- mode=new_mode,
641
- entry_code=safe_new_pw,
642
- created_by=teacher_id_for_scope,
643
  )
644
  if res is not None:
645
  st.success(f"'{safe_new_room}' 방이 개설되었습니다! '기존 방 선택'을 눌러 입장하세요.")
646
  room_name = ""
647
-
648
  else:
649
  st.session_state['teacher_auth'] = False
650
  st.session_state['admin_auth'] = False
@@ -668,7 +555,6 @@ with st.sidebar:
668
  st.session_state['ai_hint_text'] = ""
669
  st.session_state['ai_report_text'] = ""
670
  st.session_state['ai_result_text'] = ""
671
-
672
  if st.session_state['joined']:
673
  st.session_state['joined'] = False
674
  log_audit("room_switched_to_lobby", room_name=room_name, actor_name=student_name, role=user_role, previous_room=prev_room)
@@ -723,7 +609,6 @@ if not st.session_state['joined']:
723
  # [6] 메인 화면 (의견 입력부)
724
  # ==========================================
725
  topic_data = fetch_topic_data(supabase, room_name)
726
-
727
  current_topic = topic_data.get('title', "자유 주제로 대화해 봅시다.")
728
  current_mode = topic_data.get('mode', "⚔️ 찬반 토론")
729
  act_type = "토론" if "토론" in current_mode else "토의"
@@ -780,19 +665,14 @@ if st.button("의견 제출", use_container_width=True, type="primary"):
780
  author_role_for_submit = "교사" if user_role == "교사" else "학생"
781
  client_ip = get_client_ip()
782
  insert_payload = {
783
- "room_name": room_name,
784
- "timestamp": now,
785
- "student_name": safe_student_name,
786
- "content": safe_input,
787
- "sentiment": sentiment,
788
- "author_role": author_role_for_submit
789
  }
790
  if debate_ip_column_available() and client_ip:
791
  insert_payload["ip_address"] = client_ip
792
  try:
793
  res = submit_opinion(supabase, insert_payload)
794
- if res is None:
795
- st.stop()
796
  log_audit("opinion_submitted", room_name=room_name, actor_name=safe_student_name, role=author_role_for_submit, sentiment=sentiment, client_ip=client_ip if client_ip else "N/A")
797
  st.session_state['reset_key'] += 1
798
  st.rerun()
@@ -807,10 +687,16 @@ st.divider()
807
  # [7] 실시간 업데이트 영역
808
  # ==========================================
809
  def live_chat_board_core():
810
- board_df = fetch_live_messages(supabase, room_name, LIVE_BOARD_FETCH_LIMIT)
811
- opinion_df = with_fallback_author_role(board_df)
812
- stats_df = fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT)
813
- stats_opinion_df = with_fallback_author_role(stats_df)
 
 
 
 
 
 
814
 
815
  with st.expander("📊 실시간 의견 통계 보기 (클릭하여 펼치기)"):
816
  if not stats_opinion_df.empty:
@@ -848,8 +734,7 @@ def live_chat_board_core():
848
 
849
  def delete_chat_msg(msg_id):
850
  try:
851
- if delete_opinion_message(supabase, msg_id) is None:
852
- return
853
  log_audit("chat_deleted", room_name=room_name, actor_name=student_name, role=user_role, message_id=msg_id)
854
  st.toast("의견이 즉시 삭제되었습니다.", icon="🗑️")
855
  except Exception as e:
@@ -916,7 +801,6 @@ else:
916
  # ==========================================
917
  if user_role == "교사" and teacher_auth:
918
  st.divider()
919
-
920
  col_dash_title, col_dash_refresh = st.columns([8, 2])
921
  with col_dash_title:
922
  st.header("👨‍🏫 교사 관리 대시보드")
@@ -927,9 +811,9 @@ if user_role == "교사" and teacher_auth:
927
  if admin_auth:
928
  render_admin_approval_panel()
929
  st.divider()
 
930
  df_all = with_fallback_author_role(fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT))
931
 
932
- # --- 1. 통계 ---
933
  st.subheader("📊 학생 참여도 현황")
934
  if not df_all.empty:
935
  student_only_df = df_all[(df_all['author_role'] == '학생') & ~df_all['student_name'].str.contains('익명|AI', na=False, regex=True)].copy()
@@ -946,7 +830,6 @@ if user_role == "교사" and teacher_auth:
946
 
947
  st.divider()
948
 
949
- # --- 2. Teacher in the loop ---
950
  @st.fragment
951
  def teacher_hint_section():
952
  st.subheader(f"💡 AI {act_type} 촉진 (Teacher-in-the-loop)")
@@ -961,8 +844,7 @@ if user_role == "교사" and teacher_auth:
961
  "room_name": room_name, "timestamp": now, "student_name": "👨‍🏫 선생님 (AI 보조)",
962
  "content": val, "sentiment": "❓ 질문", "author_role": "교사"
963
  })
964
- if res is None:
965
- return
966
  log_audit("teacher_hint_sent", room_name=room_name, actor_name=student_name, role=user_role)
967
  st.session_state['hint_input_widget'] = ""
968
  except Exception as e:
@@ -975,11 +857,8 @@ if user_role == "교사" and teacher_auth:
975
  context = "\n".join(df_all['content'].tail(5).tolist()) if not df_all.empty else "대화 없음"
976
  prompt = f"당신은 고등학교 {act_type} 조력자입니다. '{current_topic}' 주제로 {act_type} 중입니다. 학생들의 균형을 맞추거나 더 깊은 생각을 유도할 수 있는 예리한 질문을 1문장만 제안하세요. 번호 매기기나 번잡한 서론 없이 질문 자체만 출력하세요.\n최근 대화: {context}"
977
  res_text = generate_ai_response(
978
- prompt,
979
- model_name=AI_MODEL_NAME,
980
- api_key=st.secrets["GEMINI_API_KEY"],
981
- log_message="AI 힌트 생성 실패",
982
- room_name=room_name,
983
  )
984
  if res_text:
985
  st.session_state['hint_input_widget'] = res_text.strip().split('\n')[0]
@@ -1000,11 +879,9 @@ if user_role == "교사" and teacher_auth:
1000
  teacher_hint_section()
1001
  st.divider()
1002
 
1003
- # --- 3. 수업 종료 요약 리포트 ---
1004
  @st.fragment
1005
  def teacher_summary_section():
1006
  st.subheader(f"📝 수업 종료 및 전체 {act_type} 요약 리포트")
1007
-
1008
  if st.button(f"{act_type} 요약 및 베스트 발언 추출 🪄", use_container_width=True):
1009
  st.toast("👀 AI가 전체 기록을 꼼꼼히 읽고 있습니다...", icon="⏳")
1010
  with st.spinner("✍️ 요약 리포트를 작성하고 있습니다..."):
@@ -1013,24 +890,16 @@ if user_role == "교사" and teacher_auth:
1013
  prompt = (
1014
  f"'{current_topic}' 주제의 고등학교 {act_type} 기록입니다.\n\n"
1015
  "[출력 형식 - 반드시 그대로]\n"
1016
- "핵심요약 1: ...\n"
1017
- "핵심요약 2: ...\n"
1018
- "핵심요약 3: ...\n"
1019
- "베스트 학생: ...\n"
1020
- "선정 이유: ...\n\n"
1021
  "[엄격한 규칙]\n"
1022
  "- 핵심요약 1,2,3과 베스트 학생, 선정이유를 줄바꿈을 하여 보기 편하게 합니다.\n"
1023
- "- 5~10줄로 출력합니다.\n"
1024
- "- 제목/헤더(#,##,###), 소제목을 절대 쓰지 않습니다.\n"
1025
  "- 불필요한 서론/결론 없이 바로 결과만 출력합니다.\n\n"
1026
  f"기록:\n{full_history}"
1027
  )
1028
  res_text = generate_ai_response(
1029
- prompt,
1030
- model_name=AI_MODEL_NAME,
1031
- api_key=st.secrets["GEMINI_API_KEY"],
1032
- log_message="AI 요약 리포트 생성 실패",
1033
- room_name=room_name,
1034
  )
1035
  if res_text:
1036
  st.session_state['ai_report_text'] = compact_ai_report_output(res_text)
@@ -1039,27 +908,21 @@ if user_role == "교사" and teacher_auth:
1039
  st.toast("🚨 AI 호출 오류가 발생했습니다.", icon="❌")
1040
  else:
1041
  st.toast("🚨 분석할 데이터가 없습니다.", icon="⚠️")
1042
-
1043
  if st.session_state.get('ai_report_text'):
1044
  st.info(f"📊 **AI 수업 {act_type} 요약 리포트**")
1045
  report_html = html.escape(st.session_state['ai_report_text']).replace("\n", "<br>")
1046
- st.markdown(
1047
- f"<div style='line-height:1.8;'>{report_html}</div>",
1048
- unsafe_allow_html=True,
1049
- )
1050
 
1051
  teacher_summary_section()
1052
  st.divider()
1053
 
1054
- # --- 4. 세특 생성 및 다운로드 ---
1055
  @st.fragment
1056
  def teacher_record_section():
1057
  def delete_selected_record():
1058
  del_id = st.session_state.get('del_record_dropdown')
1059
  if del_id:
1060
  try:
1061
- if delete_student_record(supabase, del_id) is None:
1062
- return
1063
  log_audit("record_deleted", room_name=room_name, actor_name=student_name, role=user_role, record_id=del_id)
1064
  st.toast("기록이 삭제되었습니다.", icon="��️")
1065
  except Exception as e:
@@ -1076,10 +939,8 @@ if user_role == "교사" and teacher_auth:
1076
  with col4:
1077
  st.subheader("🤖 개인별 AI 세특 초안 생성")
1078
  student_list = student_only_df['student_name'].unique() if not df_all.empty else []
1079
-
1080
  if len(student_list) > 0:
1081
  selected_student = st.selectbox("학생을 선택하세요", student_list)
1082
-
1083
  if st.button(f"'{selected_student}' 세특 생성 🪄", use_container_width=True):
1084
  st.toast(f"👀 AI가 '{selected_student}' 학생의 활동을 분석합니다...", icon="⏳")
1085
  with st.spinner(f"✍️ '{selected_student}' 학생의 세특 초안 작성 중..."):
@@ -1088,14 +949,9 @@ if user_role == "교사" and teacher_auth:
1088
  debate_history = "\n".join([f"- [{row['sentiment']}] {row['content']}" for _, row in student_data.iterrows()])
1089
  prompt = f"당신은 정보 교사입니다. '{current_topic}' 주제 {act_type}에 참여한 '{selected_student}' 학생의 활동 기록입니다. 이를 바탕으로 생활기록부 교과세특 초안을 약 300자 내외로 작성하세요. 교육적 성장을 강조하세요.\n\n[활동 기록]\n{debate_history}"
1090
  res_text = generate_ai_response(
1091
- prompt,
1092
- model_name=AI_MODEL_NAME,
1093
- api_key=st.secrets["GEMINI_API_KEY"],
1094
- log_message="AI 세특 생성 실패",
1095
- room_name=room_name,
1096
- student=selected_student,
1097
  )
1098
-
1099
  if res_text:
1100
  st.session_state['ai_result_text'] = res_text
1101
  now = get_kst_now_str()
@@ -1103,15 +959,13 @@ if user_role == "교사" and teacher_auth:
1103
  "room_name": room_name, "timestamp": now,
1104
  "student_name": selected_student, "content": res_text
1105
  })
1106
- if save_res is None:
1107
- raise RuntimeError("보관함 저장 실패")
1108
  st.toast("✅ 세특 생성 및 보관함 저장 완료!", icon="🎉")
1109
  else:
1110
  raise RuntimeError("AI 응답 비어있음")
1111
  except Exception as e:
1112
  logger.exception("세특 생성 후처리 실패")
1113
  st.toast("🚨 오류가 발생했습니다. 다시 시도해주세요.", icon="❌")
1114
-
1115
  if st.session_state.get('ai_result_text'):
1116
  st.success("🤖 **개인별 세특 초안** (보관함에 자동 저장되었습니다)")
1117
  st.text_area("내용 수정 후 복사하여 사용하세요", value=st.session_state['ai_result_text'], height=200, label_visibility="collapsed")
@@ -1119,9 +973,7 @@ if user_role == "교사" and teacher_auth:
1119
 
1120
  st.divider()
1121
  st.subheader("📂 저장된 세특 기록 보관함")
1122
-
1123
  records_df = fetch_student_records(supabase, room_name, RECORDS_FETCH_LIMIT)
1124
-
1125
  if not records_df.empty:
1126
  records_display_df = records_df.rename(columns={"id": "No.", "content": "세특 내용"})
1127
  records_html = records_display_df.to_html(index=False, escape=True)
@@ -1132,7 +984,6 @@ if user_role == "교사" and teacher_auth:
1132
  with pd.ExcelWriter(buffer_records, engine='openpyxl') as writer:
1133
  records_df.drop(columns=['id']).to_excel(writer, index=False)
1134
  st.download_button("📥 세특 보관함 다운로드 (Excel)", data=buffer_records.getvalue(), file_name=f"{room_name}_세특보관함.xlsx")
1135
-
1136
  with col_del:
1137
  st.selectbox("🗑️ 삭제할 '고유 번호(No.)' 선택", records_df['id'].tolist(), key="del_record_dropdown")
1138
  st.button("선택한 세특 기록 영구 삭제", type="primary", on_click=delete_selected_record)
@@ -1149,8 +1000,7 @@ if user_role == "교사" and teacher_auth:
1149
  st.error(f"🚨 경고: '{room_name}' 방의 모든 {act_type} 기록과 세특 보관함이 완전히 삭제됩니다.")
1150
  if st.button(f"네, '{room_name}' 방의 모든 데이터를 영구 삭제합니다", type="primary", use_container_width=True):
1151
  try:
1152
- if destroy_room_data(supabase, room_name) is None:
1153
- st.stop()
1154
  log_audit("room_destroyed", room_name=room_name, actor_name=student_name, role=user_role)
1155
  st.success("성공적으로 파괴되었습니다.")
1156
  st.session_state['ai_result_text'] = ""
 
58
  )
59
 
60
  # ==========================================
61
+ # [1] 앱 설정 및 데이터베이스 연결
62
  # ==========================================
63
  DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
64
  DISPLAY_DATETIME_FMT = "%Y-%m-%d %p %I:%M:%S"
 
76
  MAX_ENTRY_CODE_LEN = 60
77
  UI_FONT_FAMILY = "sans-serif"
78
 
 
79
  supabase = init_db()
 
 
80
  ensure_db_login(supabase)
81
 
 
82
  def get_kst_now():
83
  return datetime.utcnow() + timedelta(hours=9)
84
 
 
85
  def get_kst_now_str():
86
  return get_kst_now().strftime(DATETIME_FMT)
87
 
88
  def format_kst_datetime(value):
89
  if value is None:
90
  return "-"
 
91
  kst_tz = timezone(timedelta(hours=9))
92
  parsed_dt = None
93
  should_assume_utc = False
 
94
  if isinstance(value, datetime):
95
  parsed_dt = value
96
  else:
97
  raw = str(value).strip()
98
  if not raw:
99
  return "-"
 
 
100
  if "T" in raw and "+" not in raw and "Z" not in raw:
101
  should_assume_utc = True
102
  iso_candidate = raw.replace("Z", "+00:00")
 
109
  break
110
  except ValueError:
111
  continue
 
112
  if parsed_dt is None:
113
  return str(value)
 
114
  if parsed_dt.tzinfo is not None:
115
  parsed_dt = parsed_dt.astimezone(kst_tz)
116
  elif should_assume_utc:
117
  parsed_dt = parsed_dt.replace(tzinfo=timezone.utc).astimezone(kst_tz)
 
118
  return parsed_dt.strftime(DISPLAY_DATETIME_FMT)
119
 
120
  def get_client_ip():
 
124
  return ""
125
  if not headers:
126
  return ""
 
127
  for key in ["x-forwarded-for", "x-real-ip", "cf-connecting-ip", "fly-client-ip"]:
128
  raw_ip = headers.get(key)
129
  if raw_ip:
 
145
  "로서", "보다", "까지", "부터", "만큼", "이나", "라도", "이며", "이고", "에서", "으로", "이라",
146
  "라고", "와", "과", "을", "를", "이", "가", "은", "는", "에", "도", "만", "로", "랑", "나"
147
  ]
 
148
  def normalize_token(token):
149
  cleaned = re.sub(r"^[^\w가-힣]+|[^\w가-힣]+$", "", token)
150
  if len(cleaned) < 2:
 
155
  normalized = normalized[: -len(suffix)]
156
  break
157
  return normalized
 
158
  for content in text_series.fillna("").astype(str):
159
  for token in content.replace("\n", " ").split():
160
  cleaned = normalize_token(token)
 
168
  def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=520):
169
  if not frequencies:
170
  return ""
 
171
  sorted_words = sorted(frequencies.items(), key=lambda item: (-item[1], item[0]))[:max_words]
172
  max_count = sorted_words[0][1]
173
  min_count = sorted_words[-1][1]
 
175
  cx, cy = width / 2, height / 2
176
  placed_rects = []
177
  svg_text_nodes = []
 
178
  def estimate_text_units(word):
179
  units = 0.0
180
  for ch in word:
181
  code = ord(ch)
182
+ if 0xAC00 <= code <= 0xD7A3: units += 1.0
183
+ elif 0x3130 <= code <= 0x318F: units += 0.95
184
+ elif 0x4E00 <= code <= 0x9FFF: units += 1.0
185
+ elif ch.isascii() and (ch.isalpha() or ch.isdigit()): units += 0.62
186
+ else: units += 0.75
 
 
 
 
 
187
  return max(units, 1.0)
 
188
  def overlaps(rect):
189
  x, y, w, h = rect
190
  padding = 2
 
192
  if not (x + w + padding < ox or ox + ow + padding < x or y + h + padding < oy or oy + oh + padding < y):
193
  return True
194
  return False
 
195
  def is_inside_canvas(x, y, w, h):
196
  margin = 10
197
  return x >= margin and y >= margin and (x + w) <= (width - margin) and (y + h) <= (height - margin)
 
198
  for index, (word, count) in enumerate(sorted_words):
199
  if max_count == min_count:
200
  font_size = 28
 
203
  eased_ratio = ratio ** 0.85
204
  font_size = int(18 + eased_ratio * 86)
205
  font_size = min(font_size, 140)
 
206
  color = palette[index % len(palette)]
207
  text_units = estimate_text_units(word)
208
  text_width = max(32, font_size * (text_units + 0.62))
209
  text_height = max(24, font_size * 1.22)
 
210
  placed = False
211
  for step in range(1, 3200):
212
  angle = step * 0.4 + index * 0.18
 
214
  x = cx + spiral_radius * math.cos(angle) - text_width / 2
215
  y = cy + spiral_radius * math.sin(angle) - text_height / 2
216
  rect = (x, y, text_width, text_height)
217
+ if not is_inside_canvas(x, y, text_width, text_height): continue
218
+ if overlaps(rect): continue
 
 
 
219
  placed_rects.append(rect)
220
  safe_word = html.escape(word)
221
  tx, ty = x + 1.5, y + text_height * 0.84
 
225
  )
226
  placed = True
227
  break
 
228
  if not placed:
229
  continue
 
230
  return (
231
  "<div style='padding:10px; border:1px solid #e9e9e9; border-radius:10px; background:#f3f5f7;'>"
232
  f"<svg viewBox='0 0 {width} {height}' style='width:100%; height:auto; display:block;' "
 
242
  if not pending_accounts:
243
  st.info("승인 대기 중인 교사 계정이 없습니다.")
244
  return
 
245
  for pending in pending_accounts:
246
  acc_id = pending.get("id")
247
  pending_teacher_id = pending.get("teacher_id", "")
 
261
  # [2] 앱 기본 설정 및 세션/CSS
262
  # ==========================================
263
  st.set_page_config(page_title="말자취(Talk-Trace) AI", layout="wide")
 
264
  st.markdown(
265
  """
266
  <style>
267
+ .records-db-table-wrap { overflow-x: auto; border: 1px solid #e6e6e6; border-radius: 10px; background: #fff; }
268
+ .records-db-table-wrap table { width: 100%; border-collapse: collapse; table-layout: fixed; }
269
+ .records-db-table-wrap th, .records-db-table-wrap td { border-bottom: 1px solid #efefef; border-right: 1px solid #efefef; padding: 10px 12px; text-align: left; vertical-align: top; }
270
+ .records-db-table-wrap th:last-child, .records-db-table-wrap td:last-child { border-right: none; }
271
+ .records-db-table-wrap th { white-space: nowrap; font-weight: 700; }
272
+ .records-db-table-wrap th:nth-child(1), .records-db-table-wrap td:nth-child(1) { width: 5%; white-space: nowrap; }
273
+ .records-db-table-wrap th:nth-child(2), .records-db-table-wrap td:nth-child(2) { width: 15%; white-space: nowrap; }
274
+ .records-db-table-wrap th:nth-child(3), .records-db-table-wrap td:nth-child(3) { width: 12%; white-space: nowrap; }
275
+ .records-db-table-wrap th:nth-child(4), .records-db-table-wrap td:nth-child(4) { width: 68%; white-space: pre-wrap; word-break: break-word; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  [data-testid="stDecoration"] { display: none !important; }
 
277
  .stApp, [data-testid="stAppViewContainer"], [data-testid="stMainBlockContainer"],
278
  [data-testid="stAppViewBlockContainer"], [data-testid="stFragment"],
279
  [data-testid="stVerticalBlock"], [data-testid="stElementContainer"],
280
  [data-testid="stExpander"], details, summary,
281
  *[data-stale="true"], div[data-stale="true"] {
282
+ opacity: 1 !important; transition: none !important; filter: none !important; -webkit-filter: none !important;
 
 
 
283
  }
 
284
  .stTextArea textarea, .stTextInput input, .stSelectbox, .stRadio label,
285
+ .stMarkdown p, div[data-testid="stChatMessageContent"] { font-size: 18px !important; }
 
 
 
286
  .stAlert p { font-size: 20px !important; font-weight: bold; }
287
  </style>
288
  """,
 
317
  st.session_state['page'] = "lobby"
318
 
319
  def to_bool_flag(value):
320
+ if isinstance(value, bool): return value
321
+ if value is None: return False
 
 
322
  return str(value).strip().lower() in {"true", "t", "1", "yes", "y", "on"}
323
 
324
  def compact_ai_report_output(text):
325
  raw_lines = [str(line).strip() for line in str(text or "").splitlines()]
326
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]
327
+ if not cleaned: return ""
 
 
328
  report_labels = ("핵심요약 1:", "핵심요약 2:", "핵심요약 3:", "베스트 학생:", "선정 이유:")
329
  normalized_text = " ".join(cleaned)
330
  if report_labels[0] in normalized_text:
331
  for label in report_labels[1:]:
332
  normalized_text = normalized_text.replace(f" {label}", f"\n{label}")
333
  normalized_text = normalized_text.replace(label, f"\n{label}")
 
334
  normalized_lines = [line.strip() for line in normalized_text.splitlines() if line.strip()]
335
+ return "\n".join(normalized_lines[:5])
 
 
336
 
337
  # ==========================================
338
  # [3] 홈/네비게이션
 
355
  2. 왼쪽 사이드바에서 **학생/교사 모드**를 선택합니다.
356
  3. 접속할 **토론/토의방**을 선택하고 입장합니다.
357
  4. 주제에 맞게 의견을 작성하고 제출하면 실시간 보드에 반영됩니다.
 
358
  ---
359
  - 교사 모드에서는 방 개설/관리 및 대시보드 기능을 사용할 수 있습니다.
360
  - 언제든 왼쪽 상단의 **🏠 홈** 버튼으로 이 화면으로 돌아올 수 있습니다.
 
366
  st.stop()
367
 
368
  # ==========================================
369
+ # [4] 사이드바
370
  # ==========================================
371
  with st.sidebar:
372
  st.header("👤 접속 권한")
 
388
  auth_mode = st.radio("교사 계정", ["로그인", "ID/PW 신청"], horizontal=True)
389
 
390
  if auth_mode == "로그인":
 
391
  with st.form("teacher_login_form"):
392
  teacher_id_input = st.text_input("교사 ID", key="teacher_id_input")
393
  teacher_pw_input = st.text_input("교사 PW", type="password", key="teacher_pw_input")
394
  login_submitted = st.form_submit_button("교사 로그인", use_container_width=True)
395
 
396
  if login_submitted:
397
+ id_ok, safe_teacher_id, id_error_code, id_error_message = validate_teacher_credential(teacher_id_input, field_name="교사 ID", max_len=60)
398
+ pw_ok, safe_pw, pw_error_code, pw_error_message = validate_teacher_credential(teacher_pw_input, field_name="교사 PW", max_len=60)
 
 
 
 
 
 
 
 
399
  if not id_ok:
400
  st.error(f"❌ {id_error_message} ({id_error_code})")
401
  st.stop()
 
451
  st.rerun()
452
  else:
453
  st.toast("✅ 교사 로그인 성공", icon="✅")
 
454
  else:
455
  req_teacher_id = st.text_input("신청할 교사 ID", key="req_teacher_id")
456
  req_teacher_pw = st.text_input("신청할 교사 PW", type="password", key="req_teacher_pw")
457
  if st.button("교사 계정 신청", type="primary", use_container_width=True):
458
+ id_ok, safe_id, id_error_code, id_error_message = validate_teacher_credential(req_teacher_id, field_name="교사 ID", max_len=60)
459
+ pw_ok, safe_pw, pw_error_code, pw_error_message = validate_teacher_credential(req_teacher_pw, field_name="교사 PW", max_len=60)
 
 
 
 
 
 
 
 
460
  if not id_ok:
461
  st.error(f"❌ {id_error_message} ({id_error_code})")
462
  elif not pw_ok:
 
472
  admin_auth = st.session_state['admin_auth']
473
  teacher_id_for_scope = st.session_state.get("teacher_id", "")
474
 
 
475
  if teacher_auth:
476
  st.caption(f"🔐 {teacher_id_for_scope} 로그인 중")
477
  if st.button("🚪 로그아웃", use_container_width=True):
 
504
  st.warning("교사별 방 조회를 위해 topic.created_by_teacher_id(권장) 또는 topic.created_by 컬럼이 필요합니다.")
505
 
506
  room_opt = st.radio("방 관리", ["기존 방 선택", "새 방 만들기"])
 
507
  if room_opt == "기존 방 선택" and existing_rooms:
508
  room_name = st.selectbox("토론/토의방 목록", existing_rooms)
509
  else:
 
511
  new_title = st.text_input("주제 직접 입력 (예: 인공지능 윤리)")
512
  new_mode = st.radio("진행 방식", ["⚔️ 찬반 토론", "💡 자유 토의"], horizontal=True)
513
  new_pw = st.text_input("🔒 학생 입장용 암호 (비워두면 공개방)")
 
514
  if st.button("새 방 개설하기", type="primary"):
515
  room_ok, safe_new_room, room_error_code, room_error_message = validate_room_name(new_room, max_len=MAX_ROOM_NAME_LEN)
516
  title_ok, safe_new_title, title_error_code, title_error_message = validate_opinion_content(new_title, max_len=MAX_TOPIC_LEN)
517
  entry_ok, safe_new_pw, entry_error_code, entry_error_message = validate_entry_code(new_pw, max_len=MAX_ENTRY_CODE_LEN)
518
  can_store_room_pw = topic_entry_code_column_available()
 
519
  if not room_ok:
520
  st.error(f"❌ {room_error_message} ({room_error_code})")
521
  elif not title_ok:
 
523
  elif not entry_ok:
524
  st.error(f"❌ {entry_error_message} ({entry_error_code})")
525
  elif safe_new_pw and not can_store_room_pw:
526
+ st.error("현재 DB 구조에서는 방 비밀번호 저장을 지원하지 않습니다.")
527
  elif safe_new_room and safe_new_title:
528
  res = upsert_topic_room(
529
+ supabase=supabase, room_name=safe_new_room, title=safe_new_title,
530
+ mode=new_mode, entry_code=safe_new_pw, created_by=teacher_id_for_scope,
 
 
 
 
531
  )
532
  if res is not None:
533
  st.success(f"'{safe_new_room}' 방이 개설되었습니다! '기존 방 선택'을 눌러 입장하세요.")
534
  room_name = ""
 
535
  else:
536
  st.session_state['teacher_auth'] = False
537
  st.session_state['admin_auth'] = False
 
555
  st.session_state['ai_hint_text'] = ""
556
  st.session_state['ai_report_text'] = ""
557
  st.session_state['ai_result_text'] = ""
 
558
  if st.session_state['joined']:
559
  st.session_state['joined'] = False
560
  log_audit("room_switched_to_lobby", room_name=room_name, actor_name=student_name, role=user_role, previous_room=prev_room)
 
609
  # [6] 메인 화면 (의견 입력부)
610
  # ==========================================
611
  topic_data = fetch_topic_data(supabase, room_name)
 
612
  current_topic = topic_data.get('title', "자유 주제로 대화해 봅시다.")
613
  current_mode = topic_data.get('mode', "⚔️ 찬반 토론")
614
  act_type = "토론" if "토론" in current_mode else "토의"
 
665
  author_role_for_submit = "교사" if user_role == "교사" else "학생"
666
  client_ip = get_client_ip()
667
  insert_payload = {
668
+ "room_name": room_name, "timestamp": now, "student_name": safe_student_name,
669
+ "content": safe_input, "sentiment": sentiment, "author_role": author_role_for_submit
 
 
 
 
670
  }
671
  if debate_ip_column_available() and client_ip:
672
  insert_payload["ip_address"] = client_ip
673
  try:
674
  res = submit_opinion(supabase, insert_payload)
675
+ if res is None: st.stop()
 
676
  log_audit("opinion_submitted", room_name=room_name, actor_name=safe_student_name, role=author_role_for_submit, sentiment=sentiment, client_ip=client_ip if client_ip else "N/A")
677
  st.session_state['reset_key'] += 1
678
  st.rerun()
 
687
  # [7] 실시간 업데이트 영역
688
  # ==========================================
689
  def live_chat_board_core():
690
+ # ✅ Phase 2 핵심: fetch_live_messages 이중 호출 제거
691
+ # 기존: limit=300, limit=2000 각각 2회 호출 → Supabase 요청 2배 낭비
692
+ # 개선: DASHBOARD_FETCH_LIMIT(2000)으로 1회만 호출 후 보드/통계 모두 재사용
693
+ all_df = with_fallback_author_role(
694
+ fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT)
695
+ )
696
+ # 실시간 보드용: 최신 300건 슬라이싱 (id desc 정렬이므로 앞에서 자름)
697
+ opinion_df = all_df.head(LIVE_BOARD_FETCH_LIMIT) if not all_df.empty else all_df
698
+ # 통계용: 전체 2000건 사용
699
+ stats_opinion_df = all_df
700
 
701
  with st.expander("📊 실시간 의견 통계 보기 (클릭하여 펼치기)"):
702
  if not stats_opinion_df.empty:
 
734
 
735
  def delete_chat_msg(msg_id):
736
  try:
737
+ if delete_opinion_message(supabase, msg_id) is None: return
 
738
  log_audit("chat_deleted", room_name=room_name, actor_name=student_name, role=user_role, message_id=msg_id)
739
  st.toast("의견이 즉시 삭제되었습니다.", icon="🗑️")
740
  except Exception as e:
 
801
  # ==========================================
802
  if user_role == "교사" and teacher_auth:
803
  st.divider()
 
804
  col_dash_title, col_dash_refresh = st.columns([8, 2])
805
  with col_dash_title:
806
  st.header("👨‍🏫 교사 관리 대시보드")
 
811
  if admin_auth:
812
  render_admin_approval_panel()
813
  st.divider()
814
+
815
  df_all = with_fallback_author_role(fetch_live_messages(supabase, room_name, DASHBOARD_FETCH_LIMIT))
816
 
 
817
  st.subheader("📊 학생 참여도 현황")
818
  if not df_all.empty:
819
  student_only_df = df_all[(df_all['author_role'] == '학생') & ~df_all['student_name'].str.contains('익명|AI', na=False, regex=True)].copy()
 
830
 
831
  st.divider()
832
 
 
833
  @st.fragment
834
  def teacher_hint_section():
835
  st.subheader(f"💡 AI {act_type} 촉진 (Teacher-in-the-loop)")
 
844
  "room_name": room_name, "timestamp": now, "student_name": "👨‍🏫 선생님 (AI 보조)",
845
  "content": val, "sentiment": "❓ 질문", "author_role": "교사"
846
  })
847
+ if res is None: return
 
848
  log_audit("teacher_hint_sent", room_name=room_name, actor_name=student_name, role=user_role)
849
  st.session_state['hint_input_widget'] = ""
850
  except Exception as e:
 
857
  context = "\n".join(df_all['content'].tail(5).tolist()) if not df_all.empty else "대화 없음"
858
  prompt = f"당신은 고등학교 {act_type} 조력자입니다. '{current_topic}' 주제로 {act_type} 중입니다. 학생들의 균형을 맞추거나 더 깊은 생각을 유도할 수 있는 예리한 질문을 1문장만 제안하세요. 번호 매기기나 번잡한 서론 없이 질문 자체만 출력하세요.\n최근 대화: {context}"
859
  res_text = generate_ai_response(
860
+ prompt, model_name=AI_MODEL_NAME, api_key=st.secrets["GEMINI_API_KEY"],
861
+ log_message="AI 힌트 생성 실패", room_name=room_name,
 
 
 
862
  )
863
  if res_text:
864
  st.session_state['hint_input_widget'] = res_text.strip().split('\n')[0]
 
879
  teacher_hint_section()
880
  st.divider()
881
 
 
882
  @st.fragment
883
  def teacher_summary_section():
884
  st.subheader(f"📝 수업 종료 및 전체 {act_type} 요약 리포트")
 
885
  if st.button(f"{act_type} 요약 및 베스트 발언 추출 🪄", use_container_width=True):
886
  st.toast("👀 AI가 전체 기록을 꼼꼼히 읽고 있습니다...", icon="⏳")
887
  with st.spinner("✍️ 요약 리포트를 작성하고 있습니다..."):
 
890
  prompt = (
891
  f"'{current_topic}' 주제의 고등학교 {act_type} 기록입니다.\n\n"
892
  "[출력 형식 - 반드시 그대로]\n"
893
+ "핵심요약 1: ...\n핵심요약 2: ...\n핵심요약 3: ...\n베스트 학생: ...\n선정 이유: ...\n\n"
 
 
 
 
894
  "[엄격한 규칙]\n"
895
  "- 핵심요약 1,2,3과 베스트 학생, 선정이유를 줄바꿈을 하여 보기 편하게 합니다.\n"
896
+ "- 5~10줄로 출력합니다.\n- 제목/헤더(#,##,###), 소제목을 절대 쓰지 않습니다.\n"
 
897
  "- 불필요한 서론/결론 없이 바로 결과만 출력합니다.\n\n"
898
  f"기록:\n{full_history}"
899
  )
900
  res_text = generate_ai_response(
901
+ prompt, model_name=AI_MODEL_NAME, api_key=st.secrets["GEMINI_API_KEY"],
902
+ log_message="AI 요약 리포트 생성 실패", room_name=room_name,
 
 
 
903
  )
904
  if res_text:
905
  st.session_state['ai_report_text'] = compact_ai_report_output(res_text)
 
908
  st.toast("🚨 AI 호출 오류가 발생했습니다.", icon="❌")
909
  else:
910
  st.toast("🚨 분석할 데이터가 없습니다.", icon="⚠️")
 
911
  if st.session_state.get('ai_report_text'):
912
  st.info(f"📊 **AI 수업 {act_type} 요약 리포트**")
913
  report_html = html.escape(st.session_state['ai_report_text']).replace("\n", "<br>")
914
+ st.markdown(f"<div style='line-height:1.8;'>{report_html}</div>", unsafe_allow_html=True)
 
 
 
915
 
916
  teacher_summary_section()
917
  st.divider()
918
 
 
919
  @st.fragment
920
  def teacher_record_section():
921
  def delete_selected_record():
922
  del_id = st.session_state.get('del_record_dropdown')
923
  if del_id:
924
  try:
925
+ if delete_student_record(supabase, del_id) is None: return
 
926
  log_audit("record_deleted", room_name=room_name, actor_name=student_name, role=user_role, record_id=del_id)
927
  st.toast("기록이 삭제되었습니다.", icon="��️")
928
  except Exception as e:
 
939
  with col4:
940
  st.subheader("🤖 개인별 AI 세특 초안 생성")
941
  student_list = student_only_df['student_name'].unique() if not df_all.empty else []
 
942
  if len(student_list) > 0:
943
  selected_student = st.selectbox("학생을 선택하세요", student_list)
 
944
  if st.button(f"'{selected_student}' 세특 생성 🪄", use_container_width=True):
945
  st.toast(f"👀 AI가 '{selected_student}' 학생의 활동을 분석합니다...", icon="⏳")
946
  with st.spinner(f"✍️ '{selected_student}' 학생의 세특 초안 작성 중..."):
 
949
  debate_history = "\n".join([f"- [{row['sentiment']}] {row['content']}" for _, row in student_data.iterrows()])
950
  prompt = f"당신은 정보 교사입니다. '{current_topic}' 주제 {act_type}에 참여한 '{selected_student}' 학생의 활동 기록입니다. 이를 바탕으로 생활기록부 교과세특 초안을 약 300자 내외로 작성하세요. 교육적 성장을 강조하세요.\n\n[활동 기록]\n{debate_history}"
951
  res_text = generate_ai_response(
952
+ prompt, model_name=AI_MODEL_NAME, api_key=st.secrets["GEMINI_API_KEY"],
953
+ log_message="AI 세특 생성 실패", room_name=room_name, student=selected_student,
 
 
 
 
954
  )
 
955
  if res_text:
956
  st.session_state['ai_result_text'] = res_text
957
  now = get_kst_now_str()
 
959
  "room_name": room_name, "timestamp": now,
960
  "student_name": selected_student, "content": res_text
961
  })
962
+ if save_res is None: raise RuntimeError("보관함 저장 실패")
 
963
  st.toast("✅ 세특 생성 및 보관함 저장 완료!", icon="🎉")
964
  else:
965
  raise RuntimeError("AI 응답 비어있음")
966
  except Exception as e:
967
  logger.exception("세특 생성 후처리 실패")
968
  st.toast("🚨 오류가 발생했습니다. 다시 시도해주세요.", icon="❌")
 
969
  if st.session_state.get('ai_result_text'):
970
  st.success("🤖 **개인별 세특 초안** (보관함에 자동 저장되었습니다)")
971
  st.text_area("내용 수정 후 복사하여 사용하세요", value=st.session_state['ai_result_text'], height=200, label_visibility="collapsed")
 
973
 
974
  st.divider()
975
  st.subheader("📂 저장된 세특 기록 보관함")
 
976
  records_df = fetch_student_records(supabase, room_name, RECORDS_FETCH_LIMIT)
 
977
  if not records_df.empty:
978
  records_display_df = records_df.rename(columns={"id": "No.", "content": "세특 내용"})
979
  records_html = records_display_df.to_html(index=False, escape=True)
 
984
  with pd.ExcelWriter(buffer_records, engine='openpyxl') as writer:
985
  records_df.drop(columns=['id']).to_excel(writer, index=False)
986
  st.download_button("📥 세특 보관함 다운로드 (Excel)", data=buffer_records.getvalue(), file_name=f"{room_name}_세특보관함.xlsx")
 
987
  with col_del:
988
  st.selectbox("🗑️ 삭제할 '고유 번호(No.)' 선택", records_df['id'].tolist(), key="del_record_dropdown")
989
  st.button("선택한 세특 기록 영구 삭제", type="primary", on_click=delete_selected_record)
 
1000
  st.error(f"🚨 경고: '{room_name}' 방의 모든 {act_type} 기록과 세특 보관함이 완전히 삭제됩니다.")
1001
  if st.button(f"네, '{room_name}' 방의 모든 데이터를 영구 삭제합니다", type="primary", use_container_width=True):
1002
  try:
1003
+ if destroy_room_data(supabase, room_name) is None: st.stop()
 
1004
  log_audit("room_destroyed", room_name=room_name, actor_name=student_name, role=user_role)
1005
  st.success("성공적으로 파괴되었습니다.")
1006
  st.session_state['ai_result_text'] = ""