File size: 13,354 Bytes
3bb2388
bf6465c
3bb2388
 
 
 
 
 
5c3354b
9d7fd67
5c3354b
9395c32
3bb2388
 
5c3354b
3bb2388
096e84c
23fafdc
3bb2388
 
02c069f
09bd939
69b8ae1
 
8673167
3bb2388
 
 
 
 
 
 
 
 
 
23fafdc
 
3bb2388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdf2fff
3bb2388
 
 
 
 
 
 
 
 
 
 
 
 
85d74ac
 
 
5c3354b
85d74ac
3bb2388
 
 
 
 
 
 
 
9395c32
 
 
 
 
 
 
 
3bb2388
 
 
 
 
 
 
 
 
 
 
 
f327ff5
 
 
 
 
 
 
 
 
 
 
 
 
 
3bb2388
 
096e84c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c65e101
bdf2fff
c65e101
bdf2fff
 
 
abcc046
bdf2fff
 
8dbc191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f7e056
8dbc191
 
 
 
 
 
 
 
 
 
 
 
 
02c069f
8dbc191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f7e056
e109735
8dbc191
 
 
 
 
5c3354b
bdf2fff
5c3354b
9dcf3c3
 
 
 
 
78687ed
 
5c3354b
78687ed
 
 
 
5c3354b
 
1f31eaa
5c3354b
 
 
 
 
3bb2388
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import logging
import time

import streamlit as st

from db import (
    debate_ip_column_available,
    ensure_db_login,
    fetch_debate_status,
    fetch_live_messages,
    fetch_opinion_change,
    fetch_pending_teacher_accounts,
    fetch_topic_data,
    init_db,
    opinion_changes_available,
    submit_opinion,
    update_topic,
    using_service_role_key,
)
from config import APP_CSS, MAX_STUDENT_NAME_LEN
from utils import anonymize_ip, get_client_ip, get_kst_now_str, log_audit
from validators import validate_opinion_content, validate_student_name
from views.home import render_home_page
from views.lobby import render_lobby_page
from components.admin_panel import render_admin_page
from components.sidebar import render_sidebar

logger = logging.getLogger("talk_trace_ai")
if not logger.handlers:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s | %(levelname)s | %(name)s | %(message)s"
    )

supabase = init_db()
if not using_service_role_key():
    ensure_db_login(supabase)

st.set_page_config(page_title="말자취(Talk-Trace) AI", layout="wide")
st.markdown(APP_CSS, unsafe_allow_html=True)

if 'reset_key' not in st.session_state: st.session_state['reset_key'] = 0
if 'ai_hint_text' not in st.session_state: st.session_state['ai_hint_text'] = ""
if 'ai_report_text' not in st.session_state: st.session_state['ai_report_text'] = ""
if 'page' not in st.session_state: st.session_state['page'] = "home"
if 'current_room' not in st.session_state: st.session_state['current_room'] = ""
if 'joined' not in st.session_state: st.session_state['joined'] = False
if 'teacher_auth' not in st.session_state: st.session_state['teacher_auth'] = False
if 'admin_auth' not in st.session_state: st.session_state['admin_auth'] = False
if 'teacher_id' not in st.session_state: st.session_state['teacher_id'] = ""
if 'is_working' not in st.session_state: st.session_state['is_working'] = False
if 'ai_hint_manual_mode' not in st.session_state: st.session_state['ai_hint_manual_mode'] = False
if '_last_debate_status' not in st.session_state: st.session_state['_last_debate_status'] = None

if st.session_state['page'] != "home":
    col_home_btn, _ = st.columns([1, 7])
    with col_home_btn:
        if st.button("🏠 홈", use_container_width=True):
            st.session_state['page'] = "home"
            st.session_state['joined'] = False
            st.session_state['teacher_auth'] = False
            st.rerun()

if st.session_state['page'] == "home":
    render_home_page()

# plotly / google.generativeai 임포트는 홈 화면에서 불필요 — st.stop() 이후에 로드
from components.chat_board import render_chat_board
from components.teacher_dashboard import render_teacher_dashboard
from components.opinion_change import render_pre_opinion_form, render_post_opinion_section

sidebar_ctx = render_sidebar(supabase)
user_role = sidebar_ctx['user_role']
room_name = sidebar_ctx['room_name']
teacher_auth = sidebar_ctx['teacher_auth']
admin_auth = sidebar_ctx['admin_auth']
student_name = sidebar_ctx['student_name']
student_number = sidebar_ctx['student_number']

# admin 첫 접속 시 pending 여부에 따라 첫 화면 결정 (최초 1회)
if admin_auth and not st.session_state.get('_admin_redirected'):
    _pending = fetch_pending_teacher_accounts(supabase)
    st.session_state['page'] = "admin_approval" if _pending else "lobby"
    st.session_state['joined'] = False
    st.session_state['_admin_redirected'] = True
    st.rerun()

if st.session_state['page'] == "admin_approval":
    render_admin_page(supabase, user_role, teacher_auth, admin_auth)

if not st.session_state['joined']:
    render_lobby_page(supabase, user_role, teacher_auth, room_name, student_number)

# === Joined room view ===
topic_data = fetch_topic_data(supabase, room_name)
current_topic = topic_data.get('title', "자유 주제로 대화해 봅시다.")
current_mode = topic_data.get('mode', "⚔️ 찬반 토론")
act_type = "토론" if "토론" in current_mode else "토의"

if admin_auth:
    col_title, col_btn1, col_btn2 = st.columns([4, 1, 1])
    with col_title:
        st.title(f"🎙️ 말자취(Talk-Trace) AI [{room_name}]")
    with col_btn1:
        if st.button("📝 ID 요청 수락", use_container_width=True):
            st.session_state['page'] = "admin_approval"
            st.rerun()
    with col_btn2:
        if st.button("🚪 말자취 AI 대기실", use_container_width=True):
            st.session_state['page'] = "lobby"
            st.rerun()
else:
    st.title(f"🎙️ 말자취(Talk-Trace) AI [{room_name}]")
st.info(f"**현재 주제:** {current_topic} ({current_mode})")

if user_role == "교사" and teacher_auth:
    with st.expander("✏️ 주제 수정", expanded=False):
        _edit_title = st.text_input("새 주제", value=current_topic, max_chars=120, key="edit_topic_title")
        _edit_mode = st.radio(
            "진행 방식", ["⚔️ 찬반 토론", "💡 자유 토의"],
            index=0 if "토론" in current_mode else 1,
            horizontal=True, key="edit_topic_mode",
        )
        if st.button("✅ 주제 저장", type="primary", use_container_width=True, key="edit_topic_save"):
            if not _edit_title.strip():
                st.warning("주제를 입력해 주세요.")
            else:
                if update_topic(supabase, room_name, _edit_title.strip(), _edit_mode) is not None:
                    st.toast("✅ 주제가 수정되었습니다.", icon="✏️")
                    st.rerun()

@st.fragment(run_every=5)
def _poll_debate_status(room_name):
    """학생 화면에서 5초마다 토론 상태를 확인하고 변경 시 전체 rerun."""
    current = fetch_debate_status(supabase, room_name)
    if current != st.session_state.get("_last_debate_status"):
        st.session_state["_last_debate_status"] = current
        st.rerun(scope="app")


@st.fragment
def _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode):
    st.subheader("🗣️ 내 의견 작성")
    col_input, col_stt = st.columns([4, 1])
    with col_input:
        user_input = st.text_area(
            "의견을 입력하세요",
            key=f"input_{st.session_state['reset_key']}",
            height=80,
            label_visibility="collapsed",
        )
        opts = ["🔵 찬성", "🔴 반대"] if current_mode == "⚔️ 찬반 토론" else ["💡 아이디어", "➕ 보충", "❓ 질문"]
        sentiment = st.radio("의견 성격", opts, horizontal=True)

    with col_stt:
        st.components.v1.html(
            """
            <button id="stt-btn" style="width:100%; height:80px; font-weight:bold; border-radius:10px; background-color:#e8f0fe; border:1px solid #1a73e8; color:#1a73e8; cursor:pointer;">🎤 음성 입력 시작</button>
            <p id="status" style="font-size:11px; color:gray; text-align:center; margin-top:5px;">대기 중...</p>
            <script>
                const btn = document.getElementById('stt-btn');
                const status = document.getElementById('status');
                const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
                recognition.lang = 'ko-KR';
                let isRecognizing = false;
                btn.onclick = () => { if (!isRecognizing) recognition.start(); else recognition.stop(); };
                recognition.onstart = () => { isRecognizing = true; status.innerText = "듣는 중..."; btn.style.backgroundColor = "#ff4b4b"; btn.style.color = "white"; btn.innerHTML = "🛑 음성 입력 중지"; };
                recognition.onresult = (event) => {
                    const text = event.results[0][0].transcript;
                    const textArea = window.parent.document.querySelector('textarea[aria-label="의견을 입력하세요"]');
                    if (textArea) {
                        const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
                        nativeInputValueSetter.call(textArea, textArea.value + " " + text);
                        textArea.dispatchEvent(new Event('input', { bubbles: true }));
                        status.innerText = "입력 완료!";
                    }
                };
                recognition.onend = () => { isRecognizing = false; setTimeout(() => { status.innerText = "대기 중..."; btn.style.backgroundColor = "#e8f0fe"; btn.style.color = "#1a73e8"; btn.innerHTML = "🎤 음성 입력 시작"; }, 1500); };
            </script>
            """,
            height=120,
        )

    _elapsed = time.time() - st.session_state.get('last_submit_ts', 0)
    _submit_cooldown = _elapsed < 3
    _submit_disabled = _submit_cooldown or st.session_state.get('is_working', False)
    if st.button("의견 제출", use_container_width=True, type="primary", disabled=_submit_disabled):
        if st.session_state.get('is_working', False):
            st.stop()
        st.session_state['is_working'] = True
        input_ok, safe_input, input_error_code, input_error_message = validate_opinion_content(user_input, max_len=700)
        student_ok, safe_student_name, student_error_code, student_error_message = validate_student_name(student_name, max_len=MAX_STUDENT_NAME_LEN)
        student_number_ok, safe_student_number, _, student_number_error_message = validate_student_name(student_number, max_len=20)
        if not student_number_ok and user_role == "학생":
            st.session_state['is_working'] = False
            st.error(f"❌ {student_number_error_message}")
            st.stop()
        if user_role == "학생" and (not safe_student_name or safe_student_name == "익명"):
            safe_student_name = safe_student_number or "학번미입력"
        if input_ok and safe_input:
            now = get_kst_now_str()
            author_role_for_submit = "교사" if user_role == "교사" else "학생"
            client_ip = get_client_ip()
            insert_payload = {
                "room_name": room_name, "timestamp": now, "student_name": safe_student_name,
                "content": safe_input, "sentiment": sentiment, "author_role": author_role_for_submit,
            }
            if debate_ip_column_available() and client_ip:
                anonymized_ip = anonymize_ip(client_ip)
                if anonymized_ip:
                    insert_payload["ip_address"] = anonymized_ip
            try:
                res = submit_opinion(supabase, insert_payload)
                if res is None:
                    st.session_state['is_working'] = False
                    st.stop()
                st.session_state['last_submit_ts'] = time.time()
                st.session_state['is_working'] = False
                fetch_live_messages.clear()
                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",
                )
                st.session_state['reset_key'] += 1
                st.rerun(scope="app")
            except Exception as e:
                st.session_state['is_working'] = False
                st.error(f"저장 실패: {e}")
        else:
            st.session_state['is_working'] = False
            st.warning(f"{input_error_message}")
    if _submit_cooldown:
        st.caption("⏳ 제출 성공 후 3초간 의견을 제출할 수 없습니다.")
        time.sleep(0.5)
        st.rerun()

if user_role == "학생" and opinion_changes_available():
    _poll_debate_status(room_name)
    debate_status = fetch_debate_status(supabase, room_name)
    # 학생에게 토론 진행 상태를 항상 명시적으로 표시
    if debate_status == "ended":
        st.warning(f"🔴 **{act_type} 종료** — 아래에서 {act_type} 후 생각 변화를 기록해주세요.")
    else:
        st.success(f"🟢 **{act_type} 진행 중** — 자유롭게 의견을 나눠보세요.")
    row = fetch_opinion_change(supabase, room_name, student_name)
    has_pre_opinion = bool((row or {}).get("pre_opinion"))
    if debate_status == "ended":
        if has_pre_opinion:
            render_post_opinion_section(supabase, room_name, student_name, act_type, current_topic)
        else:
            st.warning(f"{'토론' if act_type == '토론' else '토의'}이 종료되었습니다. 토론 전 생각을 미리 기록하지 않아 참여할 수 없습니다.")
    else:
        if not has_pre_opinion:
            render_pre_opinion_form(supabase, room_name, student_name, current_topic, act_type)
            st.caption("💡 위에서 토론 전 생각을 제출하면 의견 작성이 활성화됩니다.")
        else:
            _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode)
else:
    _render_opinion_input(supabase, room_name, user_role, student_name, student_number, current_mode)

st.divider()

render_chat_board(supabase, room_name, user_role, teacher_auth, student_name, current_mode, act_type)

if user_role == "교사" and teacher_auth:
    render_teacher_dashboard(supabase, room_name, user_role, student_name, current_topic, current_mode, act_type)