Spaces:
Runtime error
Runtime error
Claude commited on
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 +20 -0
- app.py +9 -1
- components/teacher_auth.py +2 -2
- components/teacher_dashboard.py +9 -187
- components/teacher_hint.py +57 -0
- components/teacher_records.py +96 -0
- components/teacher_summary.py +37 -0
- config.py +4 -4
- db.py +7 -27
- env.py +14 -0
- tests/__init__.py +0 -0
- tests/test_utils.py +27 -0
- tests/test_validators.py +134 -0
- utils.py +4 -6
- validators.py +7 -4
- wordcloud.py +1 -1
.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 |
-
|
|
|
|
| 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(
|
| 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
|
| 21 |
-
from config import
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 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
|
| 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(
|
| 9 |
-
ROOM_DESTROY_ENABLED = str(
|
| 10 |
-
AUTO_JOIN_ON_REFRESH = str(
|
| 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 =
|
| 49 |
supabase_key = (
|
| 50 |
-
|
| 51 |
-
or
|
| 52 |
)
|
| 53 |
return create_client(supabase_url, supabase_key)
|
| 54 |
|
| 55 |
|
| 56 |
def using_service_role_key() -> bool:
|
| 57 |
-
return bool(
|
| 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":
|
| 75 |
-
"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(
|
| 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 |
-
|
| 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 |
-
|
| 146 |
|
| 147 |
-
if not
|
| 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(
|
| 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=
|
| 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]
|