Claude commited on
Commit
44ebedd
·
unverified ·
1 Parent(s): cf97f2e

fix: 코드 리뷰 지적사항 8건 수정

Browse files

[🔴 심각]
- env.py 신설: _get_secret → get_secret 공개 API로 전환
db.py, config.py, teacher_auth.py, teacher_dashboard.py 임포트 일괄 정리
- app.py: 의견 제출 Rate Limiting 추가 (10초 제한)

[🟡 경고]
- validators.py: validate_opinion_content에 allowed_pattern=None 적용
(특수문자·이모지 허용, 길이만 체크)
- validators.py: _validate_text_field 길이 체크를 truncate 이전에 수행
(TOO_LONG 오류가 절대 발생하지 않던 버그 수정)
- utils.py: get_kst_now()를 timezone-aware KST datetime 반환으로 변경
- app.py: DB 저장 전 IP를 SHA-256 해시(16자)로 처리

[🔵 개선]
- tests/ 신설: validators, utils 단위 테스트 24개 (pytest)
- CI에 test job 추가
- teacher_dashboard.py를 hint/summary/records 3개 컴포넌트로 분리
- wordcloud.py: max_words 75→40으로 축소 (O(n²) 성능 개선)
- utils.py: compact_ai_report_output 이중 치환 버그 → 정규식으로 수정

https://claude.ai/code/session_01BRbPoMBBfvgN58bGkNhmv7

.github/workflows/ci.yml CHANGED
@@ -24,6 +24,7 @@ jobs:
24
  run: |
25
  python -m py_compile app.py
26
  python -m py_compile auth.py
 
27
  python -m py_compile db.py
28
  python -m py_compile config.py
29
  python -m py_compile validators.py
@@ -37,4 +38,23 @@ jobs:
37
  python -m py_compile components/sidebar.py
38
  python -m py_compile components/chat_board.py
39
  python -m py_compile components/teacher_dashboard.py
 
 
 
40
  echo "✅ 문법 점검 통과!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  run: |
25
  python -m py_compile app.py
26
  python -m py_compile auth.py
27
+ python -m py_compile env.py
28
  python -m py_compile db.py
29
  python -m py_compile config.py
30
  python -m py_compile validators.py
 
38
  python -m py_compile components/sidebar.py
39
  python -m py_compile components/chat_board.py
40
  python -m py_compile components/teacher_dashboard.py
41
+ python -m py_compile components/teacher_hint.py
42
+ python -m py_compile components/teacher_summary.py
43
+ python -m py_compile components/teacher_records.py
44
  echo "✅ 문법 점검 통과!"
45
+
46
+ test:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v5
50
+
51
+ - name: Python 설정
52
+ uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.11"
55
+
56
+ - name: 의존성 설치
57
+ run: pip install -r requirements.txt pytest
58
+
59
+ - name: 단위 테스트
60
+ run: pytest tests/ -v
app.py CHANGED
@@ -1,4 +1,6 @@
 
1
  import logging
 
2
 
3
  import streamlit as st
4
 
@@ -140,6 +142,10 @@ with col_stt:
140
  )
141
 
142
  if st.button("의견 제출", use_container_width=True, type="primary"):
 
 
 
 
143
  input_ok, safe_input, input_error_code, input_error_message = validate_opinion_content(user_input, max_len=700)
144
  student_ok, safe_student_name, student_error_code, student_error_message = validate_student_name(student_name, max_len=MAX_STUDENT_NAME_LEN)
145
  student_number_ok, safe_student_number, _, student_number_error_message = validate_student_name(student_number, max_len=20)
@@ -157,11 +163,13 @@ if st.button("의견 제출", use_container_width=True, type="primary"):
157
  "content": safe_input, "sentiment": sentiment, "author_role": author_role_for_submit,
158
  }
159
  if debate_ip_column_available() and client_ip:
160
- insert_payload["ip_address"] = client_ip
 
161
  try:
162
  res = submit_opinion(supabase, insert_payload)
163
  if res is None:
164
  st.stop()
 
165
  fetch_live_messages.clear()
166
  log_audit(
167
  "opinion_submitted",
 
1
+ import hashlib
2
  import logging
3
+ import time
4
 
5
  import streamlit as st
6
 
 
142
  )
143
 
144
  if st.button("의견 제출", use_container_width=True, type="primary"):
145
+ last_submit = st.session_state.get('last_submit_ts', 0)
146
+ if time.time() - last_submit < 10:
147
+ st.warning("⏳ 10초 후 다시 제출할 수 있습니다.")
148
+ st.stop()
149
  input_ok, safe_input, input_error_code, input_error_message = validate_opinion_content(user_input, max_len=700)
150
  student_ok, safe_student_name, student_error_code, student_error_message = validate_student_name(student_name, max_len=MAX_STUDENT_NAME_LEN)
151
  student_number_ok, safe_student_number, _, student_number_error_message = validate_student_name(student_number, max_len=20)
 
163
  "content": safe_input, "sentiment": sentiment, "author_role": author_role_for_submit,
164
  }
165
  if debate_ip_column_available() and client_ip:
166
+ hashed_ip = hashlib.sha256(client_ip.encode()).hexdigest()[:16]
167
+ insert_payload["ip_address"] = hashed_ip
168
  try:
169
  res = submit_opinion(supabase, insert_payload)
170
  if res is None:
171
  st.stop()
172
+ st.session_state['last_submit_ts'] = time.time()
173
  fetch_live_messages.clear()
174
  log_audit(
175
  "opinion_submitted",
components/teacher_auth.py CHANGED
@@ -6,8 +6,8 @@ from db import (
6
  request_teacher_account,
7
  upgrade_teacher_password,
8
  using_service_role_key,
9
- _get_secret,
10
  )
 
11
  from validators import validate_teacher_credential
12
  from utils import to_bool_flag
13
 
@@ -53,7 +53,7 @@ def _handle_login(supabase, teacher_id_input, teacher_pw_input):
53
  "등록된 계정도 미등록으로 보일 수 있습니다."
54
  )
55
  try:
56
- supabase_url = str(_get_secret("SUPABASE_URL", ""))
57
  project_ref = supabase_url.split("//", 1)[-1].split(".", 1)[0] if supabase_url else ""
58
  if project_ref:
59
  st.caption(f"현재 앱 연결 DB 프로젝트: `{project_ref}`")
 
6
  request_teacher_account,
7
  upgrade_teacher_password,
8
  using_service_role_key,
 
9
  )
10
+ from env import get_secret
11
  from validators import validate_teacher_credential
12
  from utils import to_bool_flag
13
 
 
53
  "등록된 계정도 미등록으로 보일 수 있습니다."
54
  )
55
  try:
56
+ supabase_url = str(get_secret("SUPABASE_URL", ""))
57
  project_ref = supabase_url.split("//", 1)[-1].split(".", 1)[0] if supabase_url else ""
58
  if project_ref:
59
  st.caption(f"현재 앱 연결 DB 프로젝트: `{project_ref}`")
components/teacher_dashboard.py CHANGED
@@ -1,31 +1,15 @@
1
- import html
2
- import io
3
  import logging
4
 
5
- import pandas as pd
6
  import plotly.express as px
7
  import streamlit as st
8
 
9
- from db import (
10
- create_teacher_hint,
11
- delete_student_record,
12
- destroy_room_data,
13
- fetch_live_messages,
14
- fetch_student_records,
15
- save_student_record,
16
- _get_secret,
17
- )
18
- from services.ai import generate_ai_response, build_hint_prompt, build_summary_prompt, build_record_prompt
19
  from validators import with_fallback_author_role
20
- from utils import compact_ai_report_output, get_kst_now, get_kst_now_str, log_audit
21
- from config import (
22
- AI_MODEL_NAME,
23
- AI_HINT_ENABLED,
24
- DASHBOARD_FETCH_LIMIT,
25
- RECORDS_FETCH_LIMIT,
26
- ROOM_DESTROY_ENABLED,
27
- UI_FONT_FAMILY,
28
- )
29
 
30
  logger = logging.getLogger("talk_trace_ai")
31
 
@@ -64,173 +48,11 @@ def render_teacher_dashboard(supabase, room_name, user_role, student_name, curre
64
  st.info(f"{act_type} 데이터가 없습니다.")
65
 
66
  st.divider()
67
-
68
- @st.fragment
69
- def teacher_hint_section():
70
- st.subheader(f"💡 AI {act_type} 촉진 (Teacher-in-the-loop)")
71
- st.info("AI 제안을 수정 후 전송하세요.")
72
-
73
- def send_hint():
74
- val = st.session_state.get('hint_input_widget', '').strip()
75
- if val:
76
- now = get_kst_now_str()
77
- try:
78
- res = create_teacher_hint(supabase, {
79
- "room_name": room_name, "timestamp": now,
80
- "student_name": "👨‍🏫 선생님 (AI 보조)",
81
- "content": val, "sentiment": "❓ 질문", "author_role": "교사"
82
- })
83
- if res is None:
84
- return
85
- fetch_live_messages.clear()
86
- log_audit("teacher_hint_sent", room_name=room_name, actor_name=student_name, role=user_role)
87
- st.session_state['hint_input_widget'] = ""
88
- except Exception as e:
89
- st.error(f"힌트 전송 실패: {e}")
90
-
91
- if AI_HINT_ENABLED:
92
- if st.button("🪄 AI 힌트 초안 생성", use_container_width=True):
93
- st.toast("👀 AI가 대화 맥락을 읽고 있습니다...", icon="⏳")
94
- with st.spinner("✍️ 예리한 질문을 작성하고 있습니다..."):
95
- context = "\n".join(df_all['content'].tail(5).tolist()) if not df_all.empty else "대화 없음"
96
- prompt = build_hint_prompt(act_type, current_topic, context)
97
- res_text = generate_ai_response(
98
- prompt, model_name=AI_MODEL_NAME, api_key=_get_secret("GEMINI_API_KEY", ""),
99
- log_message="AI 힌트 생성 실패", room_name=room_name,
100
- )
101
- if res_text:
102
- st.session_state['hint_input_widget'] = res_text.strip().split('\n')[0]
103
- st.session_state['ai_hint_manual_mode'] = False
104
- st.toast("✅ 힌트 작성 완료!", icon="🎉")
105
- else:
106
- st.session_state['ai_hint_manual_mode'] = True
107
- st.toast("🚨 AI 호출 오류: 수동 모드로 전환되었습니다.", icon="❌")
108
- else:
109
- st.session_state['ai_hint_manual_mode'] = True
110
-
111
- col_edit_txt, col_edit_btn = st.columns([8, 2])
112
- with col_edit_txt:
113
- st.text_input("선생님의 검토 및 수정", key="hint_input_widget", label_visibility="collapsed", placeholder="여기에 AI 힌트가 나타납니다.")
114
- with col_edit_btn:
115
- st.button("🚀 학생 화면 전송", use_container_width=True, type="primary", on_click=send_hint)
116
-
117
- teacher_hint_section()
118
  st.divider()
119
-
120
- @st.fragment
121
- def teacher_summary_section():
122
- st.subheader(f"📝 수업 종료 및 전체 {act_type} 요약 리포트")
123
- if st.button(f"{act_type} 요약 및 베스트 발언 추출 🪄", use_container_width=True):
124
- st.toast("👀 AI가 전체 기록을 꼼꼼히 읽고 있습니다...", icon="⏳")
125
- with st.spinner("✍️ 요약 리포트를 작성하고 있습니다..."):
126
- if not df_all.empty:
127
- full_history = "\n".join([
128
- f"[{row['student_name']} - {row['sentiment']}] {row['content']}"
129
- for _, row in df_all.iterrows()
130
- ])
131
- prompt = build_summary_prompt(act_type, current_topic, full_history)
132
- res_text = generate_ai_response(
133
- prompt, model_name=AI_MODEL_NAME, api_key=_get_secret("GEMINI_API_KEY", ""),
134
- log_message="AI 요약 리포트 생성 실패", room_name=room_name,
135
- )
136
- if res_text:
137
- st.session_state['ai_report_text'] = compact_ai_report_output(res_text)
138
- st.toast("✅ 리포트 작성 완료!", icon="🎉")
139
- else:
140
- st.toast("🚨 AI 호출 오류가 발생했습니다.", icon="❌")
141
- else:
142
- st.toast("🚨 분석할 데이터가 없습니다.", icon="⚠️")
143
- if st.session_state.get('ai_report_text'):
144
- st.info(f"📊 **AI 수업 {act_type} 요약 리포트**")
145
- report_html = html.escape(st.session_state['ai_report_text']).replace("\n", "<br>")
146
- st.markdown(f"<div style='line-height:1.8;'>{report_html}</div>", unsafe_allow_html=True)
147
-
148
- teacher_summary_section()
149
  st.divider()
150
-
151
- @st.fragment
152
- def teacher_record_section():
153
- def delete_selected_record():
154
- del_id = st.session_state.get('del_record_dropdown')
155
- if del_id:
156
- try:
157
- if delete_student_record(supabase, del_id) is None:
158
- return
159
- log_audit("record_deleted", room_name=room_name, actor_name=student_name, role=user_role, record_id=del_id)
160
- st.toast("기록이 삭제되었습니다.", icon="🗑️")
161
- except Exception as e:
162
- st.error(f"기록 삭제 실패: {e}")
163
-
164
- col3, col4 = st.columns([1, 1])
165
- with col3:
166
- st.subheader("📥 활동 데이터 다운로드")
167
- if not df_all.empty:
168
- buffer = io.BytesIO()
169
- with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
170
- df_all.to_excel(writer, index=False)
171
- st.download_button(
172
- f"{act_type} 전체 활동 로그 (Excel)",
173
- data=buffer.getvalue(),
174
- file_name=f"{room_name}_log_{get_kst_now().strftime('%Y%m%d_%H%M')}.xlsx",
175
- )
176
-
177
- with col4:
178
- st.subheader("🤖 개인별 AI 세특 초안 생성")
179
- student_list = student_only_df['student_name'].unique() if not student_only_df.empty else []
180
- if len(student_list) > 0:
181
- selected_student = st.selectbox("학생을 선택하세요", student_list)
182
- if st.button(f"'{selected_student}' 세특 생성 🪄", use_container_width=True):
183
- st.toast(f"👀 AI가 '{selected_student}' 학생의 활동을 분석합니다...", icon="⏳")
184
- with st.spinner(f"✍️ '{selected_student}' 학생의 세특 초안 작성 중..."):
185
- try:
186
- student_data = df_all[df_all['student_name'] == selected_student]
187
- debate_history = "\n".join([f"- [{row['sentiment']}] {row['content']}" for _, row in student_data.iterrows()])
188
- prompt = build_record_prompt(act_type, current_topic, selected_student, debate_history)
189
- res_text = generate_ai_response(
190
- prompt, model_name=AI_MODEL_NAME, api_key=_get_secret("GEMINI_API_KEY", ""),
191
- log_message="AI 세특 생성 실패", room_name=room_name, student=selected_student,
192
- )
193
- if res_text:
194
- st.session_state['ai_result_text'] = res_text
195
- now = get_kst_now_str()
196
- save_res = save_student_record(supabase, {
197
- "room_name": room_name, "timestamp": now,
198
- "student_name": selected_student, "content": res_text,
199
- })
200
- if save_res is None:
201
- raise RuntimeError("보관함 저장 실패")
202
- st.toast("✅ 세특 생성 및 보관함 저장 완료!", icon="🎉")
203
- else:
204
- raise RuntimeError("AI 응답 비어있음")
205
- except Exception:
206
- logger.exception("세특 생성 후처리 실패")
207
- st.toast("🚨 오류가 발생했습니다. 다시 시도해주세요.", icon="❌")
208
- if st.session_state.get('ai_result_text'):
209
- st.success("🤖 **개인별 세특 초안** (보관함에 자동 저장되었습니다)")
210
- st.text_area("내용 수정 후 복사하여 사용하세요", value=st.session_state['ai_result_text'], height=200, label_visibility="collapsed")
211
- else:
212
- st.info("실명 참여 학생이 없습니다.")
213
-
214
- st.divider()
215
- st.subheader("📂 저장된 세특 기록 보관함")
216
- records_df = fetch_student_records(supabase, room_name, RECORDS_FETCH_LIMIT)
217
- if not records_df.empty:
218
- records_display_df = records_df.rename(columns={"id": "No.", "content": "세특 내용"})
219
- records_html = records_display_df.to_html(index=False, escape=True)
220
- st.markdown(f"<div class='records-db-table-wrap'>{records_html}</div>", unsafe_allow_html=True)
221
- col_down, col_del = st.columns([1, 1])
222
- with col_down:
223
- buffer_records = io.BytesIO()
224
- with pd.ExcelWriter(buffer_records, engine='openpyxl') as writer:
225
- records_df.drop(columns=['id']).to_excel(writer, index=False)
226
- st.download_button("📥 세특 보관함 다운로드 (Excel)", data=buffer_records.getvalue(), file_name=f"{room_name}_세특보관함.xlsx")
227
- with col_del:
228
- st.selectbox("🗑️ 삭제할 '고유 번호(No.)' 선택", records_df['id'].tolist(), key="del_record_dropdown")
229
- st.button("선택한 세특 기록 영구 삭제", type="primary", on_click=delete_selected_record)
230
- else:
231
- st.info("저장된 기록이 없습니다.")
232
-
233
- teacher_record_section()
234
 
235
  st.divider()
236
  st.subheader("🚨 위험 구역 (방 폭파)")
 
 
 
1
  import logging
2
 
 
3
  import plotly.express as px
4
  import streamlit as st
5
 
6
+ from db import destroy_room_data, fetch_live_messages
 
 
 
 
 
 
 
 
 
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
10
+ from components.teacher_hint import render_hint_section
11
+ from components.teacher_summary import render_summary_section
12
+ from components.teacher_records import render_records_section
 
 
 
 
13
 
14
  logger = logging.getLogger("talk_trace_ai")
15
 
 
48
  st.info(f"{act_type} 데이터가 없습니다.")
49
 
50
  st.divider()
51
+ render_hint_section(supabase, room_name, user_role, student_name, current_topic, act_type, df_all)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  st.divider()
53
+ render_summary_section(room_name, act_type, current_topic, df_all)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  st.divider()
55
+ render_records_section(supabase, room_name, user_role, student_name, act_type, current_topic, df_all, student_only_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  st.divider()
58
  st.subheader("🚨 위험 구역 (방 폭파)")
components/teacher_hint.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ from db import create_teacher_hint, fetch_live_messages
4
+ from env import get_secret
5
+ from services.ai import generate_ai_response, build_hint_prompt
6
+ from utils import get_kst_now_str, log_audit
7
+ from config import AI_MODEL_NAME, AI_HINT_ENABLED
8
+
9
+
10
+ @st.fragment
11
+ def render_hint_section(supabase, room_name, user_role, student_name, current_topic, act_type, df_all):
12
+ st.subheader(f"💡 AI {act_type} 촉진 (Teacher-in-the-loop)")
13
+ st.info("AI 제안을 수정 후 전송하세요.")
14
+
15
+ def send_hint():
16
+ val = st.session_state.get('hint_input_widget', '').strip()
17
+ if val:
18
+ now = get_kst_now_str()
19
+ try:
20
+ res = create_teacher_hint(supabase, {
21
+ "room_name": room_name, "timestamp": now,
22
+ "student_name": "👨‍🏫 선생님 (AI 보조)",
23
+ "content": val, "sentiment": "❓ 질문", "author_role": "교사"
24
+ })
25
+ if res is None:
26
+ return
27
+ fetch_live_messages.clear()
28
+ log_audit("teacher_hint_sent", room_name=room_name, actor_name=student_name, role=user_role)
29
+ st.session_state['hint_input_widget'] = ""
30
+ except Exception as e:
31
+ st.error(f"힌트 전송 실패: {e}")
32
+
33
+ if AI_HINT_ENABLED:
34
+ if st.button("🪄 AI 힌트 초안 생성", use_container_width=True):
35
+ st.toast("👀 AI가 대화 맥락을 읽고 있습니다...", icon="⏳")
36
+ with st.spinner("✍️ 예리한 질문을 작성하고 있습니다..."):
37
+ context = "\n".join(df_all['content'].tail(5).tolist()) if not df_all.empty else "대화 없음"
38
+ prompt = build_hint_prompt(act_type, current_topic, context)
39
+ res_text = generate_ai_response(
40
+ prompt, model_name=AI_MODEL_NAME, api_key=get_secret("GEMINI_API_KEY", ""),
41
+ log_message="AI 힌트 생성 실패", room_name=room_name,
42
+ )
43
+ if res_text:
44
+ st.session_state['hint_input_widget'] = res_text.strip().split('\n')[0]
45
+ st.session_state['ai_hint_manual_mode'] = False
46
+ st.toast("✅ 힌트 작성 완료!", icon="🎉")
47
+ else:
48
+ st.session_state['ai_hint_manual_mode'] = True
49
+ st.toast("🚨 AI 호출 오류: 수동 모드로 전환되었습니다.", icon="❌")
50
+ else:
51
+ st.session_state['ai_hint_manual_mode'] = True
52
+
53
+ col_edit_txt, col_edit_btn = st.columns([8, 2])
54
+ with col_edit_txt:
55
+ st.text_input("선생님의 검토 및 수정", key="hint_input_widget", label_visibility="collapsed", placeholder="여기에 AI 힌트가 나타납니다.")
56
+ with col_edit_btn:
57
+ st.button("🚀 학생 화면 전송", use_container_width=True, type="primary", on_click=send_hint)
components/teacher_records.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import logging
3
+
4
+ import pandas as pd
5
+ import streamlit as st
6
+
7
+ from db import delete_student_record, fetch_student_records, save_student_record
8
+ from env import get_secret
9
+ from services.ai import generate_ai_response, build_record_prompt
10
+ from utils import get_kst_now, get_kst_now_str, log_audit
11
+ from config import AI_MODEL_NAME, RECORDS_FETCH_LIMIT
12
+
13
+ logger = logging.getLogger("talk_trace_ai")
14
+
15
+
16
+ @st.fragment
17
+ def render_records_section(supabase, room_name, user_role, student_name, act_type, current_topic, df_all, student_only_df):
18
+ def delete_selected_record():
19
+ del_id = st.session_state.get('del_record_dropdown')
20
+ if del_id:
21
+ try:
22
+ if delete_student_record(supabase, del_id) is None:
23
+ return
24
+ log_audit("record_deleted", room_name=room_name, actor_name=student_name, role=user_role, record_id=del_id)
25
+ st.toast("기록이 삭제되었습니다.", icon="🗑️")
26
+ except Exception as e:
27
+ st.error(f"기록 삭제 실패: {e}")
28
+
29
+ col3, col4 = st.columns([1, 1])
30
+ with col3:
31
+ st.subheader("📥 활동 데이터 다운로드")
32
+ if not df_all.empty:
33
+ buffer = io.BytesIO()
34
+ with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
35
+ df_all.to_excel(writer, index=False)
36
+ st.download_button(
37
+ f"{act_type} 전체 활동 로그 (Excel)",
38
+ data=buffer.getvalue(),
39
+ file_name=f"{room_name}_log_{get_kst_now().strftime('%Y%m%d_%H%M')}.xlsx",
40
+ )
41
+
42
+ with col4:
43
+ st.subheader("🤖 개인별 AI 세특 초안 생성")
44
+ student_list = student_only_df['student_name'].unique() if not student_only_df.empty else []
45
+ if len(student_list) > 0:
46
+ selected_student = st.selectbox("학생을 선택하세요", student_list)
47
+ if st.button(f"'{selected_student}' 세특 생성 🪄", use_container_width=True):
48
+ st.toast(f"👀 AI가 '{selected_student}' 학생의 활동을 분석합니다...", icon="⏳")
49
+ with st.spinner(f"✍️ '{selected_student}' 학생의 세특 초안 작성 중..."):
50
+ try:
51
+ student_data = df_all[df_all['student_name'] == selected_student]
52
+ debate_history = "\n".join([f"- [{row['sentiment']}] {row['content']}" for _, row in student_data.iterrows()])
53
+ prompt = build_record_prompt(act_type, current_topic, selected_student, debate_history)
54
+ res_text = generate_ai_response(
55
+ prompt, model_name=AI_MODEL_NAME, api_key=get_secret("GEMINI_API_KEY", ""),
56
+ log_message="AI 세특 생성 실패", room_name=room_name, student=selected_student,
57
+ )
58
+ if res_text:
59
+ st.session_state['ai_result_text'] = res_text
60
+ now = get_kst_now_str()
61
+ save_res = save_student_record(supabase, {
62
+ "room_name": room_name, "timestamp": now,
63
+ "student_name": selected_student, "content": res_text,
64
+ })
65
+ if save_res is None:
66
+ raise RuntimeError("보관함 저장 실패")
67
+ st.toast("✅ 세특 생성 및 보관함 저장 완료!", icon="🎉")
68
+ else:
69
+ raise RuntimeError("AI 응답 비어있음")
70
+ except Exception:
71
+ logger.exception("세특 생성 후처리 실패")
72
+ st.toast("🚨 오류가 발생했습니다. 다시 시도해주세요.", icon="❌")
73
+ if st.session_state.get('ai_result_text'):
74
+ st.success("🤖 **개인별 세특 초안** (보관함에 자동 저장되었습니다)")
75
+ st.text_area("내용 수정 후 복사하여 사용하세요", value=st.session_state['ai_result_text'], height=200, label_visibility="collapsed")
76
+ else:
77
+ st.info("실명 참여 학생이 없습니다.")
78
+
79
+ st.divider()
80
+ st.subheader("📂 저장된 세특 기록 보관함")
81
+ records_df = fetch_student_records(supabase, room_name, RECORDS_FETCH_LIMIT)
82
+ if not records_df.empty:
83
+ records_display_df = records_df.rename(columns={"id": "No.", "content": "세특 내용"})
84
+ records_html = records_display_df.to_html(index=False, escape=True)
85
+ st.markdown(f"<div class='records-db-table-wrap'>{records_html}</div>", unsafe_allow_html=True)
86
+ col_down, col_del = st.columns([1, 1])
87
+ with col_down:
88
+ buffer_records = io.BytesIO()
89
+ with pd.ExcelWriter(buffer_records, engine='openpyxl') as writer:
90
+ records_df.drop(columns=['id']).to_excel(writer, index=False)
91
+ st.download_button("��� 세특 보관함 다운로드 (Excel)", data=buffer_records.getvalue(), file_name=f"{room_name}_세특보관함.xlsx")
92
+ with col_del:
93
+ st.selectbox("🗑️ 삭제할 '고유 번호(No.)' 선택", records_df['id'].tolist(), key="del_record_dropdown")
94
+ st.button("선택한 세특 기록 영구 삭제", type="primary", on_click=delete_selected_record)
95
+ else:
96
+ st.info("저장된 기록이 없습니다.")
components/teacher_summary.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+
3
+ import streamlit as st
4
+
5
+ from env import get_secret
6
+ from services.ai import generate_ai_response, build_summary_prompt
7
+ from utils import compact_ai_report_output
8
+ from config import AI_MODEL_NAME
9
+
10
+
11
+ @st.fragment
12
+ def render_summary_section(room_name, act_type, current_topic, df_all):
13
+ st.subheader(f"📝 수업 종료 및 전체 {act_type} 요약 리포트")
14
+ if st.button(f"{act_type} 요약 및 베스트 발언 추출 🪄", use_container_width=True):
15
+ st.toast("👀 AI가 전체 기록을 꼼꼼히 읽고 있습니다...", icon="⏳")
16
+ with st.spinner("✍️ 요약 리포트를 작성하고 있습니다..."):
17
+ if not df_all.empty:
18
+ full_history = "\n".join([
19
+ f"[{row['student_name']} - {row['sentiment']}] {row['content']}"
20
+ for _, row in df_all.iterrows()
21
+ ])
22
+ prompt = build_summary_prompt(act_type, current_topic, full_history)
23
+ res_text = generate_ai_response(
24
+ prompt, model_name=AI_MODEL_NAME, api_key=get_secret("GEMINI_API_KEY", ""),
25
+ log_message="AI 요약 리포트 생성 실패", room_name=room_name,
26
+ )
27
+ if res_text:
28
+ st.session_state['ai_report_text'] = compact_ai_report_output(res_text)
29
+ st.toast("✅ 리포트 작성 완료!", icon="🎉")
30
+ else:
31
+ st.toast("🚨 AI 호출 오류가 발생했습니다.", icon="❌")
32
+ else:
33
+ st.toast("🚨 분석할 데이터가 없습니다.", icon="⚠️")
34
+ if st.session_state.get('ai_report_text'):
35
+ st.info(f"📊 **AI 수업 {act_type} 요약 리포트**")
36
+ report_html = html.escape(st.session_state['ai_report_text']).replace("\n", "<br>")
37
+ st.markdown(f"<div style='line-height:1.8;'>{report_html}</div>", unsafe_allow_html=True)
config.py CHANGED
@@ -1,13 +1,13 @@
1
- from db import _get_secret
2
 
3
  AI_MODEL_NAME = "gemini-2.5-flash"
4
  LIVE_BOARD_FETCH_LIMIT = 300
5
  DASHBOARD_FETCH_LIMIT = 2000
6
  RECORDS_FETCH_LIMIT = 500
7
  LIVE_REFRESH_INTERVAL = "5s"
8
- AI_HINT_ENABLED = str(_get_secret("AI_HINT_ENABLED", "true")).lower() not in ("false", "0", "no")
9
- ROOM_DESTROY_ENABLED = str(_get_secret("ROOM_DESTROY_ENABLED", "true")).lower() not in ("false", "0", "no")
10
- AUTO_JOIN_ON_REFRESH = str(_get_secret("AUTO_JOIN_ON_REFRESH", "false")).lower() not in ("false", "0", "no")
11
  MAX_ROOM_NAME_LEN = 60
12
  MAX_STUDENT_NAME_LEN = 30
13
  MAX_TOPIC_LEN = 120
 
1
+ from env import get_secret
2
 
3
  AI_MODEL_NAME = "gemini-2.5-flash"
4
  LIVE_BOARD_FETCH_LIMIT = 300
5
  DASHBOARD_FETCH_LIMIT = 2000
6
  RECORDS_FETCH_LIMIT = 500
7
  LIVE_REFRESH_INTERVAL = "5s"
8
+ AI_HINT_ENABLED = str(get_secret("AI_HINT_ENABLED", "true")).lower() not in ("false", "0", "no")
9
+ ROOM_DESTROY_ENABLED = str(get_secret("ROOM_DESTROY_ENABLED", "true")).lower() not in ("false", "0", "no")
10
+ AUTO_JOIN_ON_REFRESH = str(get_secret("AUTO_JOIN_ON_REFRESH", "false")).lower() not in ("false", "0", "no")
11
  MAX_ROOM_NAME_LEN = 60
12
  MAX_STUDENT_NAME_LEN = 30
13
  MAX_TOPIC_LEN = 120
db.py CHANGED
@@ -1,11 +1,11 @@
1
  import logging
2
- import os
3
 
4
  import pandas as pd
5
  import streamlit as st
6
  from supabase import Client, create_client
7
 
8
  from auth import _hash_password, _is_hashed, _verify_password # noqa: F401
 
9
 
10
  logger = logging.getLogger("talk_trace_ai")
11
 
@@ -19,42 +19,22 @@ def upgrade_teacher_password(supabase: Client, account_id: int, plain: str):
19
  )
20
 
21
 
22
- # ==========================================
23
- # [0] 환경변수 → st.secrets 통합 읽기 헬퍼
24
- # ==========================================
25
-
26
- def _get_secret(key: str, default: str = "") -> str:
27
- """
28
- 환경변수(HF Spaces) → st.secrets(Streamlit Cloud) 순서로 값을 읽습니다.
29
- 두 플랫폼 모두 코드 수정 없이 동작합니다.
30
- """
31
- # 1순위: 환경변수 (Hugging Face Spaces)
32
- env_val = os.environ.get(key, "").strip()
33
- if env_val:
34
- return env_val
35
- # 2순위: st.secrets (Streamlit Cloud)
36
- try:
37
- return str(st.secrets.get(key, default)).strip()
38
- except Exception:
39
- return default
40
-
41
-
42
  # ==========================================
43
  # [1] DB 초기화 및 인증
44
  # ==========================================
45
 
46
  @st.cache_resource
47
  def init_db() -> Client:
48
- supabase_url = _get_secret("SUPABASE_URL")
49
  supabase_key = (
50
- _get_secret("SUPABASE_SERVICE_ROLE_KEY")
51
- or _get_secret("SUPABASE_KEY")
52
  )
53
  return create_client(supabase_url, supabase_key)
54
 
55
 
56
  def using_service_role_key() -> bool:
57
- return bool(_get_secret("SUPABASE_SERVICE_ROLE_KEY"))
58
 
59
 
60
  def ensure_db_login(supabase: Client) -> bool:
@@ -71,8 +51,8 @@ def ensure_db_login(supabase: Client) -> bool:
71
  try:
72
  supabase.auth.sign_in_with_password(
73
  {
74
- "email": _get_secret("SUPABASE_APP_EMAIL"),
75
- "password": _get_secret("SUPABASE_APP_PASSWORD"),
76
  }
77
  )
78
  return True
 
1
  import logging
 
2
 
3
  import pandas as pd
4
  import streamlit as st
5
  from supabase import Client, create_client
6
 
7
  from auth import _hash_password, _is_hashed, _verify_password # noqa: F401
8
+ from env import get_secret
9
 
10
  logger = logging.getLogger("talk_trace_ai")
11
 
 
19
  )
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # ==========================================
23
  # [1] DB 초기화 및 인증
24
  # ==========================================
25
 
26
  @st.cache_resource
27
  def init_db() -> Client:
28
+ supabase_url = get_secret("SUPABASE_URL")
29
  supabase_key = (
30
+ get_secret("SUPABASE_SERVICE_ROLE_KEY")
31
+ or get_secret("SUPABASE_KEY")
32
  )
33
  return create_client(supabase_url, supabase_key)
34
 
35
 
36
  def using_service_role_key() -> bool:
37
+ return bool(get_secret("SUPABASE_SERVICE_ROLE_KEY"))
38
 
39
 
40
  def ensure_db_login(supabase: Client) -> bool:
 
51
  try:
52
  supabase.auth.sign_in_with_password(
53
  {
54
+ "email": get_secret("SUPABASE_APP_EMAIL"),
55
+ "password": get_secret("SUPABASE_APP_PASSWORD"),
56
  }
57
  )
58
  return True
env.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import streamlit as st
4
+
5
+
6
+ def get_secret(key: str, default: str = "") -> str:
7
+ """환경변수(HF Spaces) → st.secrets(Streamlit Cloud) 순서로 값을 읽습니다."""
8
+ env_val = os.environ.get(key, "").strip()
9
+ if env_val:
10
+ return env_val
11
+ try:
12
+ return str(st.secrets.get(key, default)).strip()
13
+ except Exception:
14
+ return default
tests/__init__.py ADDED
File without changes
tests/test_utils.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timezone
2
+ from utils import get_kst_now, compact_ai_report_output
3
+
4
+
5
+ def test_get_kst_now_is_aware():
6
+ dt = get_kst_now()
7
+ assert dt.tzinfo is not None
8
+ assert dt.utcoffset().seconds == 9 * 3600
9
+
10
+
11
+ def test_compact_ai_report_output_empty():
12
+ assert compact_ai_report_output("") == ""
13
+ assert compact_ai_report_output(None) == ""
14
+
15
+
16
+ def test_compact_ai_report_output_no_double_newline():
17
+ text = "핵심요약 1: A 핵심요약 2: B 핵심요약 3: C 베스트 학생: D 선정 이유: E"
18
+ result = compact_ai_report_output(text)
19
+ assert "\n\n" not in result
20
+ lines = result.splitlines()
21
+ assert len(lines) <= 5
22
+
23
+
24
+ def test_compact_ai_report_output_strips_headers():
25
+ text = "# 제목\n핵심요약 1: 내용\n핵심요약 2: 내용2"
26
+ result = compact_ai_report_output(text)
27
+ assert "# 제목" not in result
tests/test_validators.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from validators import (
3
+ ValidationError,
4
+ validate_opinion_content,
5
+ validate_room_name,
6
+ validate_student_name,
7
+ validate_entry_code,
8
+ validate_teacher_credential,
9
+ normalize_user_text,
10
+ normalize_room_name,
11
+ mask_ip_for_teacher,
12
+ )
13
+
14
+
15
+ # ── validate_opinion_content ──────────────────────────────────────
16
+
17
+ def test_opinion_empty():
18
+ ok, _, code, _ = validate_opinion_content("")
19
+ assert not ok
20
+ assert code == ValidationError.EMPTY
21
+
22
+
23
+ def test_opinion_valid():
24
+ ok, safe, _, _ = validate_opinion_content("인공지능은 교육에 도움이 됩니다.")
25
+ assert ok
26
+ assert safe == "인공지능은 교육에 도움이 됩니다."
27
+
28
+
29
+ def test_opinion_special_chars_allowed():
30
+ ok, _, _, _ = validate_opinion_content("좋아요! 이 의견 → 매우 중요합니다… 🎉")
31
+ assert ok
32
+
33
+
34
+ def test_opinion_too_long():
35
+ ok, _, code, _ = validate_opinion_content("a" * 701)
36
+ assert not ok
37
+ assert code == ValidationError.TOO_LONG
38
+
39
+
40
+ # ── validate_room_name ────────────────────────────────────────────
41
+
42
+ def test_room_name_valid():
43
+ ok, safe, _, _ = validate_room_name("1학년 3반")
44
+ assert ok
45
+ assert safe == "1학년 3반"
46
+
47
+
48
+ def test_room_name_empty():
49
+ ok, _, code, _ = validate_room_name("")
50
+ assert not ok
51
+ assert code == ValidationError.EMPTY
52
+
53
+
54
+ def test_room_name_forbidden():
55
+ ok, _, code, _ = validate_room_name("관리자방")
56
+ assert not ok
57
+ assert code == ValidationError.FORBIDDEN
58
+
59
+
60
+ def test_room_name_collapse_spaces():
61
+ ok, safe, _, _ = validate_room_name("1학년 3반")
62
+ assert ok
63
+ assert safe == "1학년 3반"
64
+
65
+
66
+ # ── validate_student_name ─────────────────────────────────────────
67
+
68
+ def test_student_name_valid():
69
+ ok, safe, _, _ = validate_student_name("홍길동")
70
+ assert ok
71
+ assert safe == "홍길동"
72
+
73
+
74
+ def test_student_name_forbidden_admin():
75
+ ok, _, code, _ = validate_student_name("admin")
76
+ assert not ok
77
+ assert code == ValidationError.FORBIDDEN
78
+
79
+
80
+ # ── validate_entry_code ───────────────────────────────────────────
81
+
82
+ def test_entry_code_empty_allowed():
83
+ ok, safe, _, _ = validate_entry_code("")
84
+ assert ok
85
+ assert safe == ""
86
+
87
+
88
+ def test_entry_code_valid():
89
+ ok, safe, _, _ = validate_entry_code("secret123!")
90
+ assert ok
91
+ assert safe == "secret123!"
92
+
93
+
94
+ # ── validate_teacher_credential ───────────────────────────────────
95
+
96
+ def test_credential_valid():
97
+ ok, safe, _, _ = validate_teacher_credential("teacher01", field_name="교사 ID")
98
+ assert ok
99
+ assert safe == "teacher01"
100
+
101
+
102
+ def test_credential_empty():
103
+ ok, _, code, _ = validate_teacher_credential("", field_name="교사 ID")
104
+ assert not ok
105
+ assert code == ValidationError.EMPTY
106
+
107
+
108
+ # ── normalize helpers ─────────────────────────────────────────────
109
+
110
+ def test_normalize_user_text_strips():
111
+ assert normalize_user_text(" hello ") == "hello"
112
+
113
+
114
+ def test_normalize_user_text_truncates():
115
+ assert normalize_user_text("abc", max_len=2) == "ab"
116
+
117
+
118
+ def test_normalize_room_name_collapses_whitespace():
119
+ assert normalize_room_name("a b") == "a b"
120
+
121
+
122
+ # ── mask_ip_for_teacher ───────────────────────────────────────────
123
+
124
+ def test_mask_ipv4():
125
+ assert mask_ip_for_teacher("1.2.3.4") == "1.XXX.XXX.4"
126
+
127
+
128
+ def test_mask_ipv6():
129
+ result = mask_ip_for_teacher("2001:db8:85a3:0:0:8a2e:370:7334")
130
+ assert "XXXX" in result
131
+
132
+
133
+ def test_mask_empty():
134
+ assert mask_ip_for_teacher("") == ""
utils.py CHANGED
@@ -1,4 +1,5 @@
1
  import logging
 
2
  from datetime import datetime, timedelta, timezone
3
 
4
  import streamlit as st
@@ -7,10 +8,11 @@ logger = logging.getLogger("talk_trace_ai")
7
 
8
  DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
9
  DISPLAY_DATETIME_FMT = "%Y-%m-%d %p %I:%M:%S"
 
10
 
11
 
12
  def get_kst_now():
13
- return datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=9)
14
 
15
 
16
  def get_kst_now_str():
@@ -84,11 +86,7 @@ def compact_ai_report_output(text):
84
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]
85
  if not cleaned:
86
  return ""
87
- report_labels = ("핵심요약 1:", "핵심요약 2:", "핵심요약 3:", "베스트 학생:", "선정 이유:")
88
  normalized_text = " ".join(cleaned)
89
- if report_labels[0] in normalized_text:
90
- for label in report_labels[1:]:
91
- normalized_text = normalized_text.replace(f" {label}", f"\n{label}")
92
- normalized_text = normalized_text.replace(label, f"\n{label}")
93
  normalized_lines = [line.strip() for line in normalized_text.splitlines() if line.strip()]
94
  return "\n".join(normalized_lines[:5])
 
1
  import logging
2
+ import re
3
  from datetime import datetime, timedelta, timezone
4
 
5
  import streamlit as st
 
8
 
9
  DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
10
  DISPLAY_DATETIME_FMT = "%Y-%m-%d %p %I:%M:%S"
11
+ KST = timezone(timedelta(hours=9))
12
 
13
 
14
  def get_kst_now():
15
+ return datetime.now(tz=KST)
16
 
17
 
18
  def get_kst_now_str():
 
86
  cleaned = [line for line in raw_lines if line and not line.startswith("#")]
87
  if not cleaned:
88
  return ""
 
89
  normalized_text = " ".join(cleaned)
90
+ normalized_text = re.sub(r"\s*(핵심요약 [123]:|베스트 학생:|선정 이유:)", r"\n\1", normalized_text)
 
 
 
91
  normalized_lines = [line.strip() for line in normalized_text.splitlines() if line.strip()]
92
  return "\n".join(normalized_lines[:5])
validators.py CHANGED
@@ -142,9 +142,9 @@ def _validate_text_field(
142
  -------
143
  (ok: bool, safe_text: str, error_code: str | None, error_message: str | None)
144
  """
145
- normalized = normalize_user_text(raw_text, max_len=max_len)
146
 
147
- if not normalized:
148
  if allow_empty:
149
  return True, "", None, None
150
  return (
@@ -153,7 +153,7 @@ def _validate_text_field(
153
  VALIDATION_MESSAGES[ValidationError.EMPTY].format(field=field_name),
154
  )
155
 
156
- if len(normalized) > max_len:
157
  return (
158
  False, "",
159
  ValidationError.TOO_LONG,
@@ -162,6 +162,8 @@ def _validate_text_field(
162
  ),
163
  )
164
 
 
 
165
  if allowed_pattern and not allowed_pattern.match(normalized):
166
  return (
167
  False, "",
@@ -217,13 +219,14 @@ def validate_opinion_content(raw_text, max_len=700):
217
  """
218
  의견 내용 검증.
219
  - 최대 700자
220
- - 허용 문자 패턴 적용
221
  """
222
  return _validate_text_field(
223
  raw_text,
224
  field_name="의견 내용",
225
  max_len=max_len,
226
  allow_empty=False,
 
227
  )
228
 
229
 
 
142
  -------
143
  (ok: bool, safe_text: str, error_code: str | None, error_message: str | None)
144
  """
145
+ stripped = (raw_text or "").strip()
146
 
147
+ if not stripped:
148
  if allow_empty:
149
  return True, "", None, None
150
  return (
 
153
  VALIDATION_MESSAGES[ValidationError.EMPTY].format(field=field_name),
154
  )
155
 
156
+ if len(stripped) > max_len:
157
  return (
158
  False, "",
159
  ValidationError.TOO_LONG,
 
162
  ),
163
  )
164
 
165
+ normalized = stripped
166
+
167
  if allowed_pattern and not allowed_pattern.match(normalized):
168
  return (
169
  False, "",
 
219
  """
220
  의견 내용 검증.
221
  - 최대 700자
222
+ - 길이만 체크 (특수문자·이모지 허용)
223
  """
224
  return _validate_text_field(
225
  raw_text,
226
  field_name="의견 내용",
227
  max_len=max_len,
228
  allow_empty=False,
229
+ allowed_pattern=None,
230
  )
231
 
232
 
wordcloud.py CHANGED
@@ -39,7 +39,7 @@ def build_word_frequencies(text_series):
39
  return Counter(tokens)
40
 
41
 
42
- def build_circular_wordcloud_html(frequencies, max_words=75, width=760, height=520):
43
  if not frequencies:
44
  return ""
45
  sorted_words = sorted(frequencies.items(), key=lambda item: (-item[1], item[0]))[:max_words]
 
39
  return Counter(tokens)
40
 
41
 
42
+ def build_circular_wordcloud_html(frequencies, max_words=40, width=760, height=520):
43
  if not frequencies:
44
  return ""
45
  sorted_words = sorted(frequencies.items(), key=lambda item: (-item[1], item[0]))[:max_words]