jeongsoo commited on
Commit
babf3f3
·
1 Parent(s): a90b938

Add application file

Browse files
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # API 서버 설정
2
+ API_BASE_URL=http://localhost:8000
3
+ PORT=5000
4
+
5
+ # VITO STT API 키
6
+ VITO_CLIENT_ID=your_vito_client_id
7
+ VITO_CLIENT_SECRET=your_vito_client_secret
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ ENV PORT=7860
11
+ ENV API_BASE_URL=https://your-deployed-api-url.com
12
+
13
+ CMD gunicorn app:app --bind 0.0.0.0:7860
README.md CHANGED
@@ -11,3 +11,15 @@ license: mit
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+ # RAG 챗봇 클라이언트
16
+
17
+ RAG(Retrieval-Augmented Generation) 시스템을 위한 웹 클라이언트 애플리케이션입니다.
18
+ RAG API와 연동되어 질의응답 서비스를 제공하며, VITO STT를 활용한 음성 인식 기능이 포함되어 있습니다.
19
+
20
+ ## 기능
21
+
22
+ - 텍스트 기반 질의응답 대화
23
+ - VITO STT를 활용한 음성 인식 질의
24
+ - 지식베이스 문서 관리 (업로드 및 목록 조회)
25
+ - 직관적인 웹 인터페이스
app.py CHANGED
@@ -1,277 +1,188 @@
1
- """
2
- RAG 챗봇 웹 클라이언트 애플리케이션
3
-
4
- Docker 기반 RAG API와 통신하는 웹 인터페이스를 제공합니다.
5
- VITO STT 기능을 통한 음성 질의도 지원합니다.
6
- """
7
-
8
- import os
9
- import json
10
- import logging
11
- import tempfile
12
  import requests
13
- from flask import Flask, request, jsonify, render_template, send_from_directory
14
- from werkzeug.utils import secure_filename
15
  from dotenv import load_dotenv
 
16
 
 
17
  from utils.vito_stt import VitoSTT
18
 
19
- # 로거 설정
20
- logging.basicConfig(
21
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
22
- level=logging.INFO
23
- )
24
- logger = logging.getLogger(__name__)
25
-
26
  # 환경 변수 로드
27
  load_dotenv()
28
 
29
- # Flask 앱 초기화
30
- app = Flask(__name__)
31
-
32
- # 최대 파일 크기 설정 (10MB)
33
- app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
34
- app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
35
-
36
- # 업로드 폴더가 없으면 생성
37
- os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
38
 
39
  # RAG API 설정
40
- API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:8000')
41
-
42
- # 허용되는 오디오 파일 확장자
43
- ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
44
-
45
- # 허용되는 문서 파일 확장자
46
- ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
47
 
48
  # VITO STT 클라이언트 초기화
49
  stt_client = VitoSTT()
50
 
51
- def allowed_audio_file(filename):
52
- """파일이 허용된 오디오 확장자를 가지는지 확인"""
53
- return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
 
 
54
 
55
- def allowed_doc_file(filename):
56
- """파일이 허용된 문서 확장자를 가지는지 확인"""
57
- return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
58
-
59
- @app.route('/')
60
- def index():
61
- """메인 페이지"""
62
- return render_template('index.html')
63
-
64
- @app.route('/chat')
65
- def chat():
66
- """채팅 페이지"""
67
- return render_template('chat.html')
68
-
69
- @app.route('/knowledge')
70
- def knowledge():
71
- """지식베이스 관리 페이지"""
72
- return render_template('knowledge.html')
73
-
74
- @app.route('/api/chat', methods=['POST'])
75
- def api_chat():
76
- """텍스트 채팅 API"""
77
- try:
78
- data = request.get_json()
79
- if not data or 'query' not in data:
80
- return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
81
-
82
- query = data['query']
83
- logger.info(f"쿼리 처리 중: {query}")
84
-
85
- # RAG API 호출
86
- response = requests.post(
87
- f"{API_BASE_URL}/rag",
88
- json={
89
- "query": query,
90
- "retriever_type": data.get("retriever_type", "reranker"),
91
- "top_k": data.get("top_k", 3),
92
- "temperature": data.get("temperature", 0.7)
93
- }
94
- )
95
-
96
- # API 응답 확인
97
- if response.status_code != 200:
98
- logger.error(f"API 오류: {response.status_code} - {response.text}")
99
- return jsonify({"error": f"API 오류: {response.text}"}), response.status_code
100
-
101
- # API 응답 반환
102
- result = response.json()
103
- logger.info(f"API 응답 성공: {len(result['answer'])} 문자")
104
- return jsonify(result)
105
-
106
- except requests.RequestException as e:
107
- logger.error(f"API 통신 오류: {str(e)}")
108
- return jsonify({"error": f"API 서버 연결 오류: {str(e)}"}), 503
109
-
110
- except Exception as e:
111
- logger.error(f"채팅 처리 중 오류: {str(e)}", exc_info=True)
112
- return jsonify({"error": f"처리 중 오류 발생: {str(e)}"}), 500
113
-
114
- @app.route('/api/voice', methods=['POST'])
115
- def api_voice():
116
- """음성 채팅 API - 오디오 파일을 텍스트로 변환하고 RAG API에 질의"""
117
- logger.info("음성 채팅 요청 처리 중...")
118
 
119
- # 오디오 파일 확인
120
- if 'audio' not in request.files:
121
- logger.error("오디오 파일이 제공되지 않음")
122
- return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
 
 
123
 
124
- audio_file = request.files['audio']
 
 
 
 
 
 
125
 
126
- if audio_file.filename == '':
127
- logger.error("오디오 파일명이 비어있음")
128
- return jsonify({"error": "오디오 파일이 선택되지 않았습니다."}), 400
129
 
130
- if not allowed_audio_file(audio_file.filename):
131
- logger.error(f"허용되지 않는 오디오 파일 형식: {audio_file.filename}")
132
- return jsonify({"error": "허용되지 않는 오디오 파일 형식입니다."}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- try:
135
- # 오디오 파일 임시 저장
136
- filename = secure_filename(audio_file.filename)
137
- temp_dir = tempfile.mkdtemp()
138
- temp_path = os.path.join(temp_dir, filename)
139
- audio_file.save(temp_path)
140
-
141
- logger.info(f"오디오 파일 임시 저장: {temp_path}")
142
-
143
- # 파일을 바이트로 읽기
144
- with open(temp_path, 'rb') as f:
145
- audio_bytes = f.read()
146
-
147
- # VITO STT를 사용하여 음성 인식
148
- stt_result = stt_client.transcribe_audio(audio_bytes)
149
-
150
- # 임시 파일 삭제
151
- os.unlink(temp_path)
152
- os.rmdir(temp_dir)
153
-
154
- if not stt_result["success"]:
155
- logger.error(f"STT 오류: {stt_result.get('error', '알 수 없는 오류')}")
156
- return jsonify({"error": f"음성 인식 오류: {stt_result.get('error', '알 수 없는 오류')}"}), 500
157
-
158
- transcription = stt_result["text"]
159
- if not transcription:
160
- logger.warning("STT 결과가 비어있음")
161
- return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다."}), 400
162
-
163
- logger.info(f"음성 인식 결과: {transcription}")
164
-
165
- # RAG API 호출
 
 
 
166
  try:
167
- response = requests.post(
168
- f"{API_BASE_URL}/rag",
169
- json={
170
- "query": transcription,
171
- "retriever_type": "reranker",
172
- "top_k": 3
173
- }
174
- )
175
 
176
- if response.status_code != 200:
177
- logger.error(f"RAG API 오류: {response.status_code} - {response.text}")
178
- return jsonify({
179
- "transcription": transcription,
180
- "error": f"RAG API 오류: {response.text}"
181
- }), response.status_code
182
 
183
- # API 결과에 음성 인식 결과 추가
184
- result = response.json()
185
- result["transcription"] = transcription
186
 
187
- logger.info("음성 처리 RAG 응답 성공")
188
- return jsonify(result)
189
 
190
- except requests.RequestException as e:
191
- logger.error(f"RAG API 통신 오류: {str(e)}")
192
- return jsonify({
193
- "transcription": transcription,
194
- "error": f"RAG API 서버 연결 오류: {str(e)}"
195
- }), 503
196
-
197
- except Exception as e:
198
- logger.error(f"음성 처리 중 오류: {str(e)}", exc_info=True)
199
- return jsonify({"error": f"음성 처리 중 오류 발생: {str(e)}"}), 500
200
-
201
- @app.route('/api/documents', methods=['GET'])
202
- def api_get_documents():
203
- """문서 목록 조회 API"""
204
- try:
205
- # RAG API 호출
206
- response = requests.get(f"{API_BASE_URL}/cache/stats")
207
-
208
- if response.status_code != 200:
209
- logger.error(f"문서 목록 조회 API 오류: {response.status_code} - {response.text}")
210
- return jsonify({"error": f"문서 목록 조회 API 오류: {response.text}"}), response.status_code
211
-
212
- # 캐시 통계 정보 반환
213
- return jsonify(response.json())
214
-
215
- except requests.RequestException as e:
216
- logger.error(f"API 통신 오류: {str(e)}")
217
- return jsonify({"error": f"API 서버 연결 오류: {str(e)}"}), 503
218
-
219
- except Exception as e:
220
- logger.error(f"문서 목록 조회 중 오류: {str(e)}", exc_info=True)
221
- return jsonify({"error": f"처리 중 오류 발생: {str(e)}"}), 500
222
-
223
- @app.route('/api/upload', methods=['POST'])
224
- def api_upload_document():
225
- """문서 업로드 API"""
226
- # 파일 확인
227
- if 'document' not in request.files:
228
- return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
229
-
230
- doc_file = request.files['document']
231
-
232
- if doc_file.filename == '':
233
- return jsonify({"error": "문서 파일이 선택되지 않았습니다."}), 400
234
-
235
- if not allowed_doc_file(doc_file.filename):
236
- return jsonify({"error": "허용되지 않는 문서 파일 형식입니다."}), 400
237
-
238
- try:
239
- # 파일 임시 저장
240
- filename = secure_filename(doc_file.filename)
241
- temp_dir = tempfile.mkdtemp()
242
- temp_path = os.path.join(temp_dir, filename)
243
- doc_file.save(temp_path)
244
-
245
- logger.info(f"문서 파일 임시 저장: {temp_path}")
246
-
247
- # 파일을 멀티파트 폼으로 업로드
248
- with open(temp_path, 'rb') as f:
249
- files = {'document': (filename, f)}
250
- response = requests.post(f"{API_BASE_URL}/documents/upload", files=files)
251
-
252
- # 임시 파일 삭제
253
- os.unlink(temp_path)
254
- os.rmdir(temp_dir)
255
-
256
- if response.status_code not in (200, 201):
257
- logger.error(f"문서 업로드 API 오류: {response.status_code} - {response.text}")
258
- return jsonify({"error": f"문서 업로드 API 오류: {response.text}"}), response.status_code
259
-
260
- return jsonify(response.json())
261
-
262
- except requests.RequestException as e:
263
- logger.error(f"API 통신 오류: {str(e)}")
264
- return jsonify({"error": f"API 서버 연결 오류: {str(e)}"}), 503
265
-
266
- except Exception as e:
267
- logger.error(f"문서 업로드 중 오류: {str(e)}", exc_info=True)
268
- return jsonify({"error": f"처리 중 오류 발생: {str(e)}"}), 500
269
-
270
- # 정적 파일 서빙
271
- @app.route('/static/<path:path>')
272
- def send_static(path):
273
- return send_from_directory('static', path)
274
-
275
- if __name__ == '__main__':
276
- port = int(os.getenv('PORT', 5000))
277
- app.run(debug=True, host='0.0.0.0', port=port)
 
1
+ import streamlit as st
 
 
 
 
 
 
 
 
 
 
2
  import requests
3
+ import tempfile
4
+ import os
5
  from dotenv import load_dotenv
6
+ import json
7
 
8
+ # VITO STT 클래스 임포트
9
  from utils.vito_stt import VitoSTT
10
 
 
 
 
 
 
 
 
11
  # 환경 변수 로드
12
  load_dotenv()
13
 
14
+ # 페이지 구성
15
+ st.set_page_config(
16
+ page_title="RAG 챗봇",
17
+ page_icon="🤖",
18
+ layout="wide"
19
+ )
 
 
 
20
 
21
  # RAG API 설정
22
+ API_BASE_URL = os.getenv('API_BASE_URL', 'https://your-deployed-api-url.com')
 
 
 
 
 
 
23
 
24
  # VITO STT 클라이언트 초기화
25
  stt_client = VitoSTT()
26
 
27
+ # 세션 상태 초기화
28
+ if 'messages' not in st.session_state:
29
+ st.session_state.messages = []
30
+ if 'sources' not in st.session_state:
31
+ st.session_state.sources = []
32
 
33
+ # 사이드바 설정
34
+ with st.sidebar:
35
+ st.title("🤖 RAG 챗봇")
36
+ st.write("Retrieval-Augmented Generation 기반 챗봇 서비스")
37
+
38
+ st.subheader("⚙️ 설정")
39
+ retriever_type = st.selectbox(
40
+ "검색 엔진",
41
+ ["reranker", "vector"],
42
+ index=0
43
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ top_k = st.slider(
46
+ "참고 문서 (Top-K)",
47
+ min_value=1,
48
+ max_value=10,
49
+ value=3
50
+ )
51
 
52
+ temperature = st.slider(
53
+ "다양성 (Temperature)",
54
+ min_value=0.0,
55
+ max_value=1.0,
56
+ value=0.7,
57
+ step=0.1
58
+ )
59
 
60
+ st.divider()
 
 
61
 
62
+ st.subheader("📚 참고 문서")
63
+ if st.session_state.sources:
64
+ for i, source in enumerate(st.session_state.sources):
65
+ with st.expander(f"{i+1}. {source.get('metadata', {}).get('category', '일반')}"):
66
+ st.write(source.get('content', ''))
67
+ else:
68
+ st.info("참고 문서가 여기에 표시됩니다")
69
+
70
+ # 메인 영역
71
+ st.title("💬 RAG 챗봇")
72
+
73
+ # 저장된 대화 표시
74
+ for message in st.session_state.messages:
75
+ with st.chat_message(message["role"]):
76
+ st.write(message["content"])
77
+
78
+ # 사용자 입력
79
+ query = st.chat_input("질문을 입력하세요...")
80
+
81
+ # 음성 입력 옵션
82
+ audio_input = st.file_uploader("또는 음성으로 질문하기", type=["wav", "mp3", "ogg", "m4a"])
83
+
84
+ if query:
85
+ # 사용자 메시지 추가
86
+ st.session_state.messages.append({"role": "user", "content": query})
87
+ with st.chat_message("user"):
88
+ st.write(query)
89
 
90
+ # 챗봇 응답 영역
91
+ with st.chat_message("assistant"):
92
+ with st.spinner("답변 생성 중..."):
93
+ try:
94
+ # RAG API 요청
95
+ response = requests.post(
96
+ f"{API_BASE_URL}/rag",
97
+ json={
98
+ "query": query,
99
+ "retriever_type": retriever_type,
100
+ "top_k": top_k,
101
+ "temperature": temperature
102
+ },
103
+ timeout=30
104
+ )
105
+
106
+ if response.status_code == 200:
107
+ result = response.json()
108
+ answer = result.get("answer", "")
109
+ context_docs = result.get("context_docs", [])
110
+
111
+ # 응답 표시
112
+ st.markdown(answer)
113
+
114
+ # 세션 상태 업데이트
115
+ st.session_state.messages.append({"role": "assistant", "content": answer})
116
+ st.session_state.sources = context_docs
117
+ else:
118
+ st.error(f"API 오류: {response.status_code} - {response.text}")
119
+ except Exception as e:
120
+ st.error(f"오류 발생: {str(e)}")
121
+
122
+ # 음성 입력 처리
123
+ if audio_input:
124
+ with st.spinner("음성 처리 중..."):
125
  try:
126
+ # 임시 파일로 저장
127
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as tmp:
128
+ tmp.write(audio_input.getvalue())
129
+ tmp_path = tmp.name
 
 
 
 
130
 
131
+ # 파일 읽기
132
+ with open(tmp_path, 'rb') as f:
133
+ audio_bytes = f.read()
 
 
 
134
 
135
+ # 임시 파일 삭제
136
+ os.unlink(tmp_path)
 
137
 
138
+ # VITO STT 처리
139
+ stt_result = stt_client.transcribe_audio(audio_bytes)
140
 
141
+ if stt_result["success"]:
142
+ transcription = stt_result["text"]
143
+ if transcription:
144
+ st.success(f"인식된 텍스트: {transcription}")
145
+
146
+ # 사용자 메시지 추가
147
+ st.session_state.messages.append({"role": "user", "content": transcription})
148
+ with st.chat_message("user"):
149
+ st.write(transcription)
150
+
151
+ # 챗봇 응답 영역
152
+ with st.chat_message("assistant"):
153
+ with st.spinner("답변 생성 중..."):
154
+ try:
155
+ # RAG API 요청
156
+ response = requests.post(
157
+ f"{API_BASE_URL}/rag",
158
+ json={
159
+ "query": transcription,
160
+ "retriever_type": retriever_type,
161
+ "top_k": top_k,
162
+ "temperature": temperature
163
+ },
164
+ timeout=30
165
+ )
166
+
167
+ if response.status_code == 200:
168
+ result = response.json()
169
+ answer = result.get("answer", "")
170
+ context_docs = result.get("context_docs", [])
171
+
172
+ # 응답 표시
173
+ st.markdown(answer)
174
+
175
+ # 세션 상태 업데이트
176
+ st.session_state.messages.append({"role": "assistant", "content": answer})
177
+ st.session_state.sources = context_docs
178
+ else:
179
+ st.error(f"API 오류: {response.status_code} - {response.text}")
180
+ except Exception as e:
181
+ st.error(f"오류 발생: {str(e)}")
182
+ else:
183
+ st.warning("음성에서 텍스트를 인식하지 못했습니다.")
184
+ else:
185
+ st.error(f"음성 인식 오류: {stt_result.get('error', '알 수 없는 오류')}")
186
+
187
+ except Exception as e:
188
+ st.error(f"음성 처리 중 오류: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask==2.2.3
2
+ requests==2.28.2
3
+ python-dotenv==1.0.0
4
+ Werkzeug==2.2.3
5
+ streamlit==1.29.0
static/css/style.css ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 공통 스타일 */
2
+ body {
3
+ background-color: #f8f9fa;
4
+ min-height: 100vh;
5
+ display: flex;
6
+ flex-direction: column;
7
+ }
8
+
9
+ footer {
10
+ margin-top: auto;
11
+ }
12
+
13
+ /* 채팅 영역 스타일 */
14
+ .chat-messages {
15
+ max-height: 500px;
16
+ overflow-y: auto;
17
+ padding-right: 5px;
18
+ }
19
+
20
+ /* 사용자 및 봇 메시지 공통 스타일 */
21
+ .chat-message {
22
+ margin-bottom: 15px;
23
+ }
24
+
25
+ .message-avatar {
26
+ width: 40px;
27
+ height: 40px;
28
+ border-radius: 50%;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ margin-right: 15px;
33
+ flex-shrink: 0;
34
+ }
35
+
36
+ .message-content {
37
+ flex-grow: 1;
38
+ background-color: #ffffff;
39
+ border-radius: 12px;
40
+ padding: 12px 15px;
41
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
42
+ position: relative;
43
+ }
44
+
45
+ /* 사용자 메시지 스타일 */
46
+ .user-message .message-content {
47
+ background-color: #e9f5ff;
48
+ border: 1px solid #cce5ff;
49
+ }
50
+
51
+ /* 봇 메시지 스타일 */
52
+ .bot-message .message-content {
53
+ background-color: #ffffff;
54
+ border: 1px solid #e9ecef;
55
+ }
56
+
57
+ /* 메시지 텍스트 스타일 */
58
+ .message-text {
59
+ white-space: pre-wrap;
60
+ word-break: break-word;
61
+ }
62
+
63
+ .message-text code {
64
+ background-color: #f5f5f5;
65
+ padding: 2px 4px;
66
+ border-radius: 4px;
67
+ font-family: 'Courier New', monospace;
68
+ }
69
+
70
+ .message-text pre {
71
+ background-color: #f5f5f5;
72
+ padding: 10px;
73
+ border-radius: 5px;
74
+ margin: 10px 0;
75
+ overflow-x: auto;
76
+ }
77
+
78
+ .message-text blockquote {
79
+ border-left: 4px solid #ced4da;
80
+ padding-left: 15px;
81
+ color: #6c757d;
82
+ margin: 10px 0;
83
+ }
84
+
85
+ .message-metadata {
86
+ font-size: 0.85rem;
87
+ color: #6c757d;
88
+ }
89
+
90
+ /* 음성 입력 버튼 스타일 */
91
+ #micButton.recording {
92
+ background-color: #dc3545;
93
+ animation: pulse 1.5s infinite;
94
+ }
95
+
96
+ @keyframes pulse {
97
+ 0% {
98
+ opacity: 1;
99
+ }
100
+ 50% {
101
+ opacity: 0.7;
102
+ }
103
+ 100% {
104
+ opacity: 1;
105
+ }
106
+ }
107
+
108
+ /* 드롭존 스타일 */
109
+ .dropzone {
110
+ border: 2px dashed #ccc;
111
+ border-radius: 5px;
112
+ background-color: #f8f9fa;
113
+ min-height: 150px;
114
+ }
115
+
116
+ .dropzone:hover {
117
+ border-color: #198754;
118
+ }
119
+
120
+ .dropzone .dz-preview {
121
+ margin: 10px;
122
+ }
123
+
124
+ /* 소스 목록 스타일 */
125
+ .source-item {
126
+ border-left: 3px solid #28a745;
127
+ background-color: #f8f9fa;
128
+ }
129
+
130
+ .source-score {
131
+ width: 40px;
132
+ height: 40px;
133
+ border-radius: 50%;
134
+ background-color: #28a745;
135
+ color: white;
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ font-weight: bold;
140
+ }
141
+
142
+ /* 상자 애니메이션 효과 */
143
+ .fade-in {
144
+ animation: fadeIn 0.5s;
145
+ }
146
+
147
+ @keyframes fadeIn {
148
+ from { opacity: 0; }
149
+ to { opacity: 1; }
150
+ }
151
+
152
+ /* 스크롤바 스타일 */
153
+ ::-webkit-scrollbar {
154
+ width: 8px;
155
+ }
156
+
157
+ ::-webkit-scrollbar-track {
158
+ background: #f1f1f1;
159
+ border-radius: 10px;
160
+ }
161
+
162
+ ::-webkit-scrollbar-thumb {
163
+ background: #c1c1c1;
164
+ border-radius: 10px;
165
+ }
166
+
167
+ ::-webkit-scrollbar-thumb:hover {
168
+ background: #a1a1a1;
169
+ }
170
+
171
+ /* 모바일 반응형 스타일 */
172
+ @media (max-width: 768px) {
173
+ .message-avatar {
174
+ width: 35px;
175
+ height: 35px;
176
+ }
177
+
178
+ .message-content {
179
+ padding: 10px;
180
+ }
181
+ }
static/js/app.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 챗봇 클라이언트 - 공통 기능
3
+ */
4
+
5
+ document.addEventListener('DOMContentLoaded', function() {
6
+ // API 기본 URL
7
+ const API_BASE_URL = window.location.origin;
8
+
9
+ // 상태 확인 함수
10
+ async function checkApiStatus() {
11
+ try {
12
+ const response = await fetch(`${API_BASE_URL}/api/status`);
13
+ const data = await response.json();
14
+
15
+ if (data.ready) {
16
+ // API 서버 준비 완료
17
+ console.log('API 서버 준비 완료');
18
+
19
+ // 로딩 페이지에서 왔다면 홈으로 리디렉션
20
+ if (window.location.pathname === '/loading') {
21
+ window.location.href = '/';
22
+ }
23
+
24
+ return true;
25
+ } else {
26
+ // API 서버 준비 중
27
+ console.log('API 서버 초기화 중...');
28
+
29
+ // 로딩 페이지가 아니면 로딩 페이지로 리디렉션
30
+ if (window.location.pathname !== '/loading') {
31
+ window.location.href = '/loading';
32
+ }
33
+
34
+ return false;
35
+ }
36
+ } catch (err) {
37
+ console.error('API 서버 상태 확인 실패:', err);
38
+
39
+ // 연결 오류 시 처리
40
+ const statusDisplay = document.getElementById('apiStatusDisplay');
41
+ if (statusDisplay) {
42
+ statusDisplay.innerHTML = `
43
+ <div class="alert alert-danger">
44
+ <i class="fas fa-exclamation-triangle me-2"></i>
45
+ API 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요.
46
+ </div>
47
+ `;
48
+ }
49
+
50
+ return false;
51
+ }
52
+ }
53
+
54
+ // 페이지 로드 시 API 상태 확인
55
+ checkApiStatus();
56
+
57
+ // 주기적으로 API 상태 확인 (10초마다)
58
+ setInterval(checkApiStatus, 10000);
59
+ });
static/js/chat.js ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 챗봇 클라이언트 - 채팅 기능
3
+ * 텍스트 및 음성 대화 기능 구현
4
+ */
5
+
6
+ document.addEventListener('DOMContentLoaded', function() {
7
+ // DOM 요소
8
+ const chatMessages = document.getElementById('chatMessages');
9
+ const userInput = document.getElementById('userInput');
10
+ const sendButton = document.getElementById('sendButton');
11
+ const micButton = document.getElementById('micButton');
12
+ const clearChat = document.getElementById('clearChat');
13
+ const recordingAlert = document.getElementById('recordingAlert');
14
+ const processingAlert = document.getElementById('processingAlert');
15
+ const typingAlert = document.getElementById('typingAlert');
16
+ const sourceList = document.getElementById('sourceList');
17
+
18
+ // 설정 요소
19
+ const retrieverType = document.getElementById('retrieverType');
20
+ const topK = document.getElementById('topK');
21
+ const temperatureSlider = document.getElementById('temperature');
22
+ const temperatureValue = document.getElementById('temperatureValue');
23
+
24
+ // 음성 녹음 관련 변수
25
+ let mediaRecorder;
26
+ let audioChunks = [];
27
+ let isRecording = false;
28
+
29
+ // marked.js 설정 (마크다운 파서)
30
+ marked.setOptions({
31
+ renderer: new marked.Renderer(),
32
+ highlight: function(code, language) {
33
+ const validLang = hljs.getLanguage(language) ? language : 'plaintext';
34
+ return hljs.highlight(validLang, code).value;
35
+ },
36
+ gfm: true,
37
+ breaks: true
38
+ });
39
+
40
+ // 채팅 기록 로드
41
+ loadChatHistory();
42
+
43
+ // 이벤트 리스너 설정
44
+
45
+ // 텍스트 입력 이벤트
46
+ userInput.addEventListener('keypress', function(e) {
47
+ if (e.key === 'Enter' && !e.shiftKey) {
48
+ e.preventDefault();
49
+ sendMessage();
50
+ }
51
+ });
52
+
53
+ // 전송 버튼 클릭 이벤트
54
+ sendButton.addEventListener('click', sendMessage);
55
+
56
+ // 마이크 버튼 클릭 이벤트
57
+ micButton.addEventListener('click', toggleRecording);
58
+
59
+ // 녹음 알림 클릭 이벤트 (녹음 중지)
60
+ recordingAlert.addEventListener('click', stopRecording);
61
+
62
+ // 대화 지우기 버튼 클릭 이벤트
63
+ clearChat.addEventListener('click', function() {
64
+ if (confirm('정말 대화 내용을 모두 지우시겠습니까?')) {
65
+ chatMessages.innerHTML = '';
66
+ sourceList.innerHTML = '<div class="list-group-item text-center text-muted"><i>참고 문서가 여기에 표시됩니다</i></div>';
67
+ localStorage.removeItem('chatHistory');
68
+ }
69
+ });
70
+
71
+ // 온도 슬라이더 이벤트
72
+ temperatureSlider.addEventListener('input', function() {
73
+ temperatureValue.textContent = this.value;
74
+ });
75
+
76
+ /**
77
+ * 텍스트 메시지 전송 함수
78
+ */
79
+ function sendMessage() {
80
+ const message = userInput.value.trim();
81
+ if (message === '') return;
82
+
83
+ // 사용자 메시지 표시
84
+ addUserMessage(message);
85
+ userInput.value = '';
86
+
87
+ // 설정 가져오기
88
+ const retriever = retrieverType.value;
89
+ const k = parseInt(topK.value);
90
+ const temperature = parseFloat(temperatureSlider.value);
91
+
92
+ // 봇 응답 가져오기
93
+ getBotResponse(message, retriever, k, temperature);
94
+ }
95
+
96
+ /**
97
+ * 음성 녹음 토글 함수
98
+ */
99
+ async function toggleRecording() {
100
+ if (!isRecording) {
101
+ startRecording();
102
+ } else {
103
+ stopRecording();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 음성 녹음 시작 함수
109
+ */
110
+ async function startRecording() {
111
+ try {
112
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
113
+ mediaRecorder = new MediaRecorder(stream);
114
+ audioChunks = [];
115
+
116
+ mediaRecorder.addEventListener('dataavailable', event => {
117
+ audioChunks.push(event.data);
118
+ });
119
+
120
+ mediaRecorder.addEventListener('stop', async () => {
121
+ const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
122
+ await processAudio(audioBlob);
123
+ });
124
+
125
+ mediaRecorder.start();
126
+ isRecording = true;
127
+ micButton.classList.add('recording');
128
+ recordingAlert.classList.remove('d-none');
129
+
130
+ } catch (err) {
131
+ console.error('마이크 접근 오류:', err);
132
+ alert('마이크 접근에 실패했습니다. 마이크 권한을 확인해주세요.');
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 음성 녹음 중지 함수
138
+ */
139
+ function stopRecording() {
140
+ if (mediaRecorder && isRecording) {
141
+ mediaRecorder.stop();
142
+ isRecording = false;
143
+ micButton.classList.remove('recording');
144
+ recordingAlert.classList.add('d-none');
145
+ processingAlert.classList.remove('d-none');
146
+
147
+ // 스트림 트랙 중지
148
+ mediaRecorder.stream.getTracks().forEach(track => track.stop());
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 녹음된 오디오 처리 함수
154
+ * @param {Blob} audioBlob - 녹음된 오디오 데이터
155
+ */
156
+ async function processAudio(audioBlob) {
157
+ try {
158
+ const formData = new FormData();
159
+ formData.append('audio', audioBlob, 'recording.wav');
160
+
161
+ const response = await fetch('/api/voice', {
162
+ method: 'POST',
163
+ body: formData
164
+ });
165
+
166
+ processingAlert.classList.add('d-none');
167
+
168
+ if (!response.ok) {
169
+ const errorData = await response.json();
170
+ throw new Error(errorData.error || '음성 처리 중 오류가 발생했습니다.');
171
+ }
172
+
173
+ const data = await response.json();
174
+
175
+ // 인식된 텍스트가 있으면 사용자 메시지로 표시
176
+ if (data.transcription) {
177
+ addUserMessage(data.transcription);
178
+
179
+ // 봇 응답이 있으면 표시
180
+ if (data.answer) {
181
+ addBotMessage(data.answer, data.context_docs || []);
182
+ }
183
+ } else {
184
+ throw new Error('음성 인식에 실패했습니다.');
185
+ }
186
+
187
+ } catch (err) {
188
+ console.error('음성 처리 오류:', err);
189
+ processingAlert.classList.add('d-none');
190
+ alert('음성 처리 중 오류가 발생했습니다: ' + err.message);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * API로부터 봇 응답 가져오기
196
+ * @param {string} message - 사용자 메시지
197
+ * @param {string} retriever - 검색기 유형
198
+ * @param {number} k - Top-K 문서 수
199
+ * @param {number} temperature - 생성 다양성
200
+ */
201
+ async function getBotResponse(message, retriever, k, temperature) {
202
+ try {
203
+ typingAlert.classList.remove('d-none');
204
+ sourceList.innerHTML = '<div class="list-group-item text-center"><div class="spinner-border spinner-border-sm text-primary" role="status"></div><span class="ms-2">참고 문서 로딩중...</span></div>';
205
+
206
+ const response = await fetch('/api/chat', {
207
+ method: 'POST',
208
+ headers: {
209
+ 'Content-Type': 'application/json'
210
+ },
211
+ body: JSON.stringify({
212
+ query: message,
213
+ retriever_type: retriever,
214
+ top_k: k,
215
+ temperature: temperature
216
+ })
217
+ });
218
+
219
+ typingAlert.classList.add('d-none');
220
+
221
+ if (!response.ok) {
222
+ const errorData = await response.json();
223
+ throw new Error(errorData.error || '응답을 가져오는 중 오류가 발생했습니다.');
224
+ }
225
+
226
+ const data = await response.json();
227
+ addBotMessage(data.answer, data.context_docs || []);
228
+
229
+ } catch (err) {
230
+ console.error('API 요청 오류:', err);
231
+ typingAlert.classList.add('d-none');
232
+ alert('봇 응답을 가져오는 중 오류가 발생했습니다: ' + err.message);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 사용자 메시지 추가 함수
238
+ * @param {string} message - 사용자 메시지
239
+ */
240
+ function addUserMessage(message) {
241
+ const template = document.getElementById('userMessageTemplate');
242
+ const messageNode = template.content.cloneNode(true);
243
+
244
+ messageNode.querySelector('.message-text').textContent = message;
245
+ chatMessages.appendChild(messageNode);
246
+
247
+ // 스크롤 하단으로 이동
248
+ chatMessages.scrollTop = chatMessages.scrollHeight;
249
+
250
+ // 채팅 기록 저장
251
+ saveChatHistory();
252
+ }
253
+
254
+ /**
255
+ * 봇 메시지 추가 함수
256
+ * @param {string} message - 봇 메시지
257
+ * @param {Array} sources - 참고 문서 목록
258
+ */
259
+ function addBotMessage(message, sources) {
260
+ const template = document.getElementById('botMessageTemplate');
261
+ const messageNode = template.content.cloneNode(true);
262
+
263
+ // 마크다운 변환 및 코드 하이라이트 적용
264
+ const sanitizedHTML = DOMPurify.sanitize(marked.parse(message));
265
+ messageNode.querySelector('.message-text').innerHTML = sanitizedHTML;
266
+
267
+ // 코드 하이라이팅 적용
268
+ messageNode.querySelectorAll('pre code').forEach((block) => {
269
+ hljs.highlightBlock(block);
270
+ });
271
+
272
+ chatMessages.appendChild(messageNode);
273
+
274
+ // 스크롤 하단으로 이동
275
+ chatMessages.scrollTop = chatMessages.scrollHeight;
276
+
277
+ // 참고 문서 표시
278
+ updateSourceList(sources);
279
+
280
+ // 채팅 기록 저장
281
+ saveChatHistory();
282
+ }
283
+
284
+ /**
285
+ * 참고 문서 목록 업데이트 함수
286
+ * @param {Array} sources - 참고 문서 목록
287
+ */
288
+ function updateSourceList(sources) {
289
+ sourceList.innerHTML = '';
290
+
291
+ if (!sources || sources.length === 0) {
292
+ sourceList.innerHTML = '<div class="list-group-item text-center text-muted"><i>참고 문서가 없습니다</i></div>';
293
+ return;
294
+ }
295
+
296
+ sources.forEach((source, index) => {
297
+ const sourceItem = document.createElement('div');
298
+ sourceItem.className = 'list-group-item source-item';
299
+
300
+ const score = source.score || 0;
301
+ const scoreValue = Math.round(score * 100) / 100;
302
+ const scoreColor = getScoreColor(score);
303
+
304
+ sourceItem.innerHTML = `
305
+ <div class="d-flex align-items-center">
306
+ <div class="source-score me-3" style="background-color: ${scoreColor}">
307
+ ${scoreValue.toFixed(2)}
308
+ </div>
309
+ <div>
310
+ <h6 class="mb-0">${source.source || '문서 #' + (index + 1)}</h6>
311
+ <div class="small text-muted">관련성 점수: ${scoreValue.toFixed(2)}</div>
312
+ </div>
313
+ </div>
314
+ `;
315
+
316
+ sourceList.appendChild(sourceItem);
317
+ });
318
+ }
319
+
320
+ /**
321
+ * 점수 색상 계산 함수
322
+ * @param {number} score - 관련성 점수 (0-1)
323
+ * @returns {string} - 색상 코드
324
+ */
325
+ function getScoreColor(score) {
326
+ if (score >= 0.8) return '#198754'; // 높은 관련성 (초록색)
327
+ if (score >= 0.6) return '#0d6efd'; // 중간 관련성 (파란색)
328
+ if (score >= 0.4) return '#fd7e14'; // 낮은 관련성 (주황색)
329
+ return '#6c757d'; // 매우 낮은 관련성 (회색)
330
+ }
331
+
332
+ /**
333
+ * 채팅 기록 저장 함수
334
+ */
335
+ function saveChatHistory() {
336
+ const history = {
337
+ messages: [],
338
+ sources: []
339
+ };
340
+
341
+ // 메시지 저장
342
+ document.querySelectorAll('.chat-message').forEach(msg => {
343
+ const isUser = msg.classList.contains('user-message');
344
+ const text = msg.querySelector('.message-text').textContent || msg.querySelector('.message-text').innerHTML;
345
+
346
+ history.messages.push({
347
+ isUser: isUser,
348
+ content: text
349
+ });
350
+ });
351
+
352
+ // 참고 문서 저장
353
+ const sourcesHtml = sourceList.innerHTML;
354
+ history.sources = sourcesHtml;
355
+
356
+ // 로컬 스토리지에 저장
357
+ localStorage.setItem('chatHistory', JSON.stringify(history));
358
+ }
359
+
360
+ /**
361
+ * 채팅 기록 로드 함수
362
+ */
363
+ function loadChatHistory() {
364
+ const history = localStorage.getItem('chatHistory');
365
+ if (!history) return;
366
+
367
+ try {
368
+ const historyData = JSON.parse(history);
369
+
370
+ // 메시지 로드
371
+ historyData.messages.forEach(msg => {
372
+ if (msg.isUser) {
373
+ const template = document.getElementById('userMessageTemplate');
374
+ const messageNode = template.content.cloneNode(true);
375
+ messageNode.querySelector('.message-text').textContent = msg.content;
376
+ chatMessages.appendChild(messageNode);
377
+ } else {
378
+ const template = document.getElementById('botMessageTemplate');
379
+ const messageNode = template.content.cloneNode(true);
380
+
381
+ // 마크다운 변환 및 코드 하이라이트 적용
382
+ const sanitizedHTML = DOMPurify.sanitize(marked.parse(msg.content));
383
+ messageNode.querySelector('.message-text').innerHTML = sanitizedHTML;
384
+
385
+ // 코드 하이라이팅 적용
386
+ messageNode.querySelectorAll('pre code').forEach((block) => {
387
+ hljs.highlightBlock(block);
388
+ });
389
+
390
+ chatMessages.appendChild(messageNode);
391
+ }
392
+ });
393
+
394
+ // 참고 문서 로드
395
+ if (historyData.sources) {
396
+ sourceList.innerHTML = historyData.sources;
397
+ }
398
+
399
+ // 스크롤 하단으로 이동
400
+ chatMessages.scrollTop = chatMessages.scrollHeight;
401
+
402
+ } catch (err) {
403
+ console.error('채팅 기록 로드 실패:', err);
404
+ localStorage.removeItem('chatHistory');
405
+ }
406
+ }
407
+ });
static/js/knowledge.js ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 챗봇 클라이언트 - 지식베이스 관리 기능
3
+ * 문서 업로드 및 지식베이스 상태 관리
4
+ */
5
+
6
+ document.addEventListener('DOMContentLoaded', function() {
7
+ // DOM 요소
8
+ const uploadButton = document.getElementById('uploadButton');
9
+ const uploadSuccess = document.getElementById('uploadSuccess');
10
+ const uploadSuccessMessage = document.getElementById('uploadSuccessMessage');
11
+ const uploadError = document.getElementById('uploadError');
12
+ const uploadErrorMessage = document.getElementById('uploadErrorMessage');
13
+ const refreshStatus = document.getElementById('refreshStatus');
14
+ const databaseStats = document.getElementById('databaseStats');
15
+ const documentsContainer = document.getElementById('documentsContainer');
16
+
17
+ // Dropzone 설정
18
+ Dropzone.autoDiscover = false;
19
+
20
+ const documentUploadDropzone = new Dropzone("#documentUploadForm", {
21
+ url: "/api/upload",
22
+ maxFilesize: 10, // MB
23
+ acceptedFiles: ".txt,.md,.pdf,.docx,.csv",
24
+ addRemoveLinks: true,
25
+ dictDefaultMessage: "파일을 끌어다 놓거나 클릭하여 선택하세요",
26
+ dictRemoveFile: "제거",
27
+ dictCancelUpload: "업로드 취소",
28
+ dictUploadCanceled: "업로드 취소됨",
29
+ dictFileTooBig: "파일이 너무 큽니다 ({{filesize}}MB). 최대 파일 크기: {{maxFilesize}}MB.",
30
+ dictInvalidFileType: "이 형식의 파일은 업로드할 수 없습니다.",
31
+ autoProcessQueue: false,
32
+ maxFiles: 1
33
+ });
34
+
35
+ // 파일이 추가되면 업로드 버튼 활성화
36
+ documentUploadDropzone.on("addedfile", function(file) {
37
+ uploadButton.disabled = false;
38
+ hideAlerts();
39
+ });
40
+
41
+ // 파일이 제거되면 업로드 버튼 비활성화
42
+ documentUploadDropzone.on("removedfile", function(file) {
43
+ if (documentUploadDropzone.files.length === 0) {
44
+ uploadButton.disabled = true;
45
+ }
46
+ });
47
+
48
+ // 업로드 버튼 클릭 이벤트
49
+ uploadButton.addEventListener('click', function() {
50
+ if (documentUploadDropzone.files.length > 0) {
51
+ uploadButton.disabled = true;
52
+ uploadButton.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>업로드 중...';
53
+ documentUploadDropzone.processQueue();
54
+ }
55
+ });
56
+
57
+ // 업로드 성공 이벤트
58
+ documentUploadDropzone.on("success", function(file, response) {
59
+ uploadButton.innerHTML = '<i class="fas fa-upload me-2"></i>업로드';
60
+ uploadButton.disabled = true;
61
+
62
+ // 성공 메시지 표시
63
+ uploadSuccess.classList.remove('d-none');
64
+ uploadSuccessMessage.textContent = response.message || "문서가 성공적으로 업로드되었습니다.";
65
+
66
+ // 파일 제거
67
+ documentUploadDropzone.removeFile(file);
68
+
69
+ // 지식베이스 상태 새로고침
70
+ setTimeout(fetchDatabaseStats, 1000);
71
+ setTimeout(fetchDocuments, 1000);
72
+ });
73
+
74
+ // 업로드 오류 이벤트
75
+ documentUploadDropzone.on("error", function(file, errorMessage, xhr) {
76
+ uploadButton.innerHTML = '<i class="fas fa-upload me-2"></i>업로드';
77
+ uploadButton.disabled = false;
78
+
79
+ // 오류 메시지 표시
80
+ uploadError.classList.remove('d-none');
81
+
82
+ if (typeof errorMessage === 'object' && errorMessage.error) {
83
+ uploadErrorMessage.textContent = errorMessage.error;
84
+ } else if (typeof errorMessage === 'string') {
85
+ uploadErrorMessage.textContent = errorMessage;
86
+ } else {
87
+ uploadErrorMessage.textContent = "업로드 중 오류가 발생했습니다.";
88
+ }
89
+ });
90
+
91
+ // 새로고침 버튼 클릭 이벤트
92
+ refreshStatus.addEventListener('click', function() {
93
+ fetchDatabaseStats();
94
+ fetchDocuments();
95
+ });
96
+
97
+ /**
98
+ * 알림창 숨기기
99
+ */
100
+ function hideAlerts() {
101
+ uploadSuccess.classList.add('d-none');
102
+ uploadError.classList.add('d-none');
103
+ }
104
+
105
+ /**
106
+ * 지식베이스 상태 가져오기
107
+ */
108
+ async function fetchDatabaseStats() {
109
+ try {
110
+ databaseStats.innerHTML = `
111
+ <div class="d-flex justify-content-center align-items-center" style="height: 100px;">
112
+ <div class="spinner-border text-primary" role="status">
113
+ <span class="visually-hidden">Loading...</span>
114
+ </div>
115
+ </div>
116
+ `;
117
+
118
+ const response = await fetch('/api/documents');
119
+
120
+ if (!response.ok) {
121
+ throw new Error('API 요청 실패');
122
+ }
123
+
124
+ const data = await response.json();
125
+
126
+ if (data.enabled === false) {
127
+ databaseStats.innerHTML = `
128
+ <div class="alert alert-warning mb-0">
129
+ <i class="fas fa-exclamation-triangle me-2"></i>캐시가 활성화되지 않았습니다.
130
+ </div>
131
+ `;
132
+ return;
133
+ }
134
+
135
+ const stats = data.stats || {};
136
+
137
+ databaseStats.innerHTML = `
138
+ <div class="row text-center">
139
+ <div class="col-6 border-end">
140
+ <h3 class="mb-0">${stats.size || 0}</h3>
141
+ <p class="text-muted mb-0">캐시 항목</p>
142
+ </div>
143
+ <div class="col-6">
144
+ <h3 class="mb-0">${stats.max_size || 0}</h3>
145
+ <p class="text-muted mb-0">최대 항목 수</p>
146
+ </div>
147
+ </div>
148
+ <hr>
149
+ <div class="text-center">
150
+ <p class="mb-0">캐시 TTL: ${stats.ttl || 0}초</p>
151
+ <p class="mb-2">최대 항목 나이: ${(stats.oldest_item_age || 0).toFixed(1)}초</p>
152
+ </div>
153
+ <div class="d-grid">
154
+ <button class="btn btn-sm btn-outline-primary" id="clearCache">캐시 비우기</button>
155
+ </div>
156
+ `;
157
+
158
+ // 캐시 비우기 버튼 이벤트 리스너
159
+ document.getElementById('clearCache').addEventListener('click', clearCache);
160
+
161
+ } catch (err) {
162
+ console.error('지식베이스 상태 가져오기 실패:', err);
163
+ databaseStats.innerHTML = `
164
+ <div class="alert alert-danger mb-0">
165
+ <i class="fas fa-exclamation-circle me-2"></i>지식베이스 상태를 가져오는 중 오류가 발생했습니다.
166
+ </div>
167
+ `;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 문서 목록 가져오기
173
+ */
174
+ async function fetchDocuments() {
175
+ try {
176
+ documentsContainer.innerHTML = `
177
+ <div class="text-center p-4">
178
+ <div class="spinner-border text-primary" role="status">
179
+ <span class="visually-hidden">Loading...</span>
180
+ </div>
181
+ <p class="mt-2">문서 목록을 불러오는 중...</p>
182
+ </div>
183
+ `;
184
+
185
+ const response = await fetch('/api/documents');
186
+
187
+ if (!response.ok) {
188
+ throw new Error('API 요청 실패');
189
+ }
190
+
191
+ const data = await response.json();
192
+ const documents = data.documents || [];
193
+
194
+ if (documents.length === 0) {
195
+ documentsContainer.innerHTML = `
196
+ <div class="text-center p-4">
197
+ <i class="fas fa-file-alt fa-3x mb-3 text-muted"></i>
198
+ <p>지식베이스에 등록된 문서가 없습니다.</p>
199
+ <p class="text-muted small">왼쪽 패널에서 문서를 업로드하세요.</p>
200
+ </div>
201
+ `;
202
+ return;
203
+ }
204
+
205
+ // 문서 목록 생성
206
+ const documentsList = document.createElement('div');
207
+ documentsList.className = 'list-group list-group-flush';
208
+
209
+ documents.forEach(doc => {
210
+ const docItem = createDocumentItem(doc);
211
+ documentsList.appendChild(docItem);
212
+ });
213
+
214
+ documentsContainer.innerHTML = '';
215
+ documentsContainer.appendChild(documentsList);
216
+
217
+ } catch (err) {
218
+ console.error('문서 목록 가져오기 실패:', err);
219
+ documentsContainer.innerHTML = `
220
+ <div class="alert alert-danger m-3">
221
+ <i class="fas fa-exclamation-circle me-2"></i>문서 목록을 가져오는 중 오류가 발생했습니다.
222
+ </div>
223
+ `;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 문서 항목 생성
229
+ * @param {Object} doc - 문서 정보
230
+ * @returns {HTMLElement} - 문서 항목 요소
231
+ */
232
+ function createDocumentItem(doc) {
233
+ const template = document.getElementById('documentItemTemplate');
234
+ const docNode = template.content.cloneNode(true);
235
+
236
+ docNode.querySelector('.document-name').textContent = doc.filename || doc.source || 'Unnamed Document';
237
+ docNode.querySelector('.document-chunks').textContent = doc.chunks || 0;
238
+ docNode.querySelector('.document-type').textContent = doc.filetype || 'UNKNOWN';
239
+
240
+ return docNode.firstElementChild;
241
+ }
242
+
243
+ /**
244
+ * 캐시 비우기
245
+ */
246
+ async function clearCache() {
247
+ try {
248
+ if (!confirm('정말 캐시를 비우시겠습니까?')) {
249
+ return;
250
+ }
251
+
252
+ const response = await fetch('/api/cache/clear', {
253
+ method: 'POST'
254
+ });
255
+
256
+ if (!response.ok) {
257
+ throw new Error('API 요청 실패');
258
+ }
259
+
260
+ alert('캐시가 성공적으로 비워졌습니다.');
261
+ fetchDatabaseStats();
262
+
263
+ } catch (err) {
264
+ console.error('캐시 비우기 실패:', err);
265
+ alert('캐시 비우기 실패: ' + err.message);
266
+ }
267
+ }
268
+
269
+ // 초기 데이터 로드
270
+ fetchDatabaseStats();
271
+ fetchDocuments();
272
+ });
templates/chat.html ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 챗봇 - 채팅</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
12
+ <!-- Highlight.js for code styling -->
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
14
+ <!-- Markdown parser -->
15
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.2.4/marked.min.js"></script>
16
+ <!-- DOMPurify for sanitizing HTML -->
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.1/purify.min.js"></script>
18
+ </head>
19
+ <body>
20
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
21
+ <div class="container">
22
+ <a class="navbar-brand" href="/">
23
+ <i class="fas fa-robot me-2"></i>RAG 챗봇
24
+ </a>
25
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
26
+ <span class="navbar-toggler-icon"></span>
27
+ </button>
28
+ <div class="collapse navbar-collapse" id="navbarNav">
29
+ <ul class="navbar-nav ms-auto">
30
+ <li class="nav-item">
31
+ <a class="nav-link" href="/"><i class="fas fa-home me-1"></i>홈</a>
32
+ </li>
33
+ <li class="nav-item">
34
+ <a class="nav-link active" href="/chat"><i class="fas fa-comments me-1"></i>채팅</a>
35
+ </li>
36
+ <li class="nav-item">
37
+ <a class="nav-link" href="/knowledge"><i class="fas fa-database me-1"></i>지식베이스</a>
38
+ </li>
39
+ </ul>
40
+ </div>
41
+ </div>
42
+ </nav>
43
+
44
+ <div class="container mt-4">
45
+ <div class="row">
46
+ <!-- 채팅 영역 -->
47
+ <div class="col-md-8">
48
+ <div class="card shadow-sm">
49
+ <div class="card-header bg-primary text-white">
50
+ <div class="d-flex justify-content-between align-items-center">
51
+ <div>
52
+ <i class="fas fa-comments me-2"></i>채팅
53
+ </div>
54
+ <div>
55
+ <button id="clearChat" class="btn btn-sm btn-light">
56
+ <i class="fas fa-trash me-1"></i>대화 지우기
57
+ </button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ <div class="card-body">
62
+ <div id="chatMessages" class="chat-messages mb-3"></div>
63
+
64
+ <div class="alert alert-info d-none" id="recordingAlert">
65
+ <i class="fas fa-microphone-alt me-2"></i>음성 녹음 중... 클릭하여 중지
66
+ </div>
67
+
68
+ <div class="alert alert-warning d-none" id="processingAlert">
69
+ <div class="d-flex align-items-center">
70
+ <div class="spinner-border spinner-border-sm me-2" role="status"></div>
71
+ <div>음성을 처리 중입니다. 잠시만 기다려주세요...</div>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="alert alert-warning d-none" id="typingAlert">
76
+ <div class="d-flex align-items-center">
77
+ <div class="spinner-border spinner-border-sm me-2" role="status"></div>
78
+ <div>답변을 생성하고 있습니다...</div>
79
+ </div>
80
+ </div>
81
+
82
+ <div id="chatForm">
83
+ <div class="input-group">
84
+ <textarea id="userInput" class="form-control" placeholder="질문을 입력하세요..." rows="2"></textarea>
85
+ <button type="button" id="micButton" class="btn btn-danger">
86
+ <i class="fas fa-microphone"></i>
87
+ </button>
88
+ <button type="button" id="sendButton" class="btn btn-primary">
89
+ <i class="fas fa-paper-plane"></i>
90
+ </button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- 설정 및 정보 영역 -->
98
+ <div class="col-md-4">
99
+ <div class="card shadow-sm mb-4">
100
+ <div class="card-header bg-dark text-white">
101
+ <i class="fas fa-cog me-2"></i>설정
102
+ </div>
103
+ <div class="card-body">
104
+ <div class="mb-3">
105
+ <label for="retrieverType" class="form-label">검색 엔진</label>
106
+ <select id="retrieverType" class="form-select">
107
+ <option value="reranker" selected>재순위화 검색</option>
108
+ <option value="vector">벡터 검색</option>
109
+ </select>
110
+ </div>
111
+ <div class="mb-3">
112
+ <label for="topK" class="form-label">참고 문서 수 (Top-K)</label>
113
+ <input type="number" class="form-control" id="topK" min="1" max="10" value="3">
114
+ </div>
115
+ <div class="mb-3">
116
+ <label for="temperature" class="form-label">다양성 (Temperature)</label>
117
+ <input type="range" class="form-range" id="temperature" min="0" max="1" step="0.1" value="0.7">
118
+ <div class="d-flex justify-content-between">
119
+ <small>정확</small>
120
+ <small id="temperatureValue">0.7</small>
121
+ <small>창의적</small>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="card shadow-sm mb-4">
128
+ <div class="card-header bg-dark text-white">
129
+ <i class="fas fa-info-circle me-2"></i>정보
130
+ </div>
131
+ <div class="card-body">
132
+ <h5 class="card-title">사용 방법</h5>
133
+ <ul class="mb-0">
134
+ <li>텍스트를 입력하여 질문하거나</li>
135
+ <li>마이크 버튼을 클릭하여 음성으로 질문할 수 있습니다.</li>
136
+ <li>VITO STT로 음성이 텍스트로 변환됩니다.</li>
137
+ <li>문서를 첨부하려면 지식베이스 메뉴를 이용하세요.</li>
138
+ </ul>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="card shadow-sm">
143
+ <div class="card-header bg-success text-white">
144
+ <i class="fas fa-book me-2"></i>참고 문서
145
+ </div>
146
+ <div class="card-body p-0">
147
+ <div id="sourceList" class="list-group list-group-flush">
148
+ <div class="list-group-item text-center text-muted">
149
+ <i>참고 문서가 여기에 표시됩니다</i>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <footer class="bg-dark text-white text-center py-3 mt-5">
159
+ <div class="container">
160
+ <p class="mb-0">RAG 챗봇 클라이언트 &copy; 2025</p>
161
+ </div>
162
+ </footer>
163
+
164
+ <!-- 메시지 템플릿 -->
165
+ <template id="userMessageTemplate">
166
+ <div class="chat-message user-message mb-3">
167
+ <div class="d-flex">
168
+ <div class="message-avatar bg-primary text-white">
169
+ <i class="fas fa-user"></i>
170
+ </div>
171
+ <div class="message-content">
172
+ <div class="message-text"></div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </template>
177
+
178
+ <template id="botMessageTemplate">
179
+ <div class="chat-message bot-message mb-3">
180
+ <div class="d-flex">
181
+ <div class="message-avatar bg-success text-white">
182
+ <i class="fas fa-robot"></i>
183
+ </div>
184
+ <div class="message-content">
185
+ <div class="message-text"></div>
186
+ <div class="message-metadata mt-2 text-muted small">
187
+ <span class="message-sources"></span>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </template>
193
+
194
+ <!-- Bootstrap JS Bundle with Popper -->
195
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
196
+ <!-- Highlight.js for code highlighting -->
197
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
198
+ <!-- 채팅 로직 -->
199
+ <script src="/static/js/chat.js"></script>
200
+ </body>
201
+ </html>
templates/index.html ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 챗봇 - 홈</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
12
+ </head>
13
+ <body>
14
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
15
+ <div class="container">
16
+ <a class="navbar-brand" href="/">
17
+ <i class="fas fa-robot me-2"></i>RAG 챗봇
18
+ </a>
19
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
20
+ <span class="navbar-toggler-icon"></span>
21
+ </button>
22
+ <div class="collapse navbar-collapse" id="navbarNav">
23
+ <ul class="navbar-nav ms-auto">
24
+ <li class="nav-item">
25
+ <a class="nav-link active" href="/"><i class="fas fa-home me-1"></i>홈</a>
26
+ </li>
27
+ <li class="nav-item">
28
+ <a class="nav-link" href="/chat"><i class="fas fa-comments me-1"></i>채팅</a>
29
+ </li>
30
+ <li class="nav-item">
31
+ <a class="nav-link" href="/knowledge"><i class="fas fa-database me-1"></i>지식베이스</a>
32
+ </li>
33
+ </ul>
34
+ </div>
35
+ </div>
36
+ </nav>
37
+
38
+ <div class="container mt-5">
39
+ <div class="row justify-content-center">
40
+ <div class="col-md-8 text-center">
41
+ <h1 class="display-4 mb-4">RAG 챗봇</h1>
42
+ <p class="lead mb-5">Retrieval-Augmented Generation 기반 지능형 챗봇 서비스</p>
43
+
44
+ <div class="row mt-5">
45
+ <div class="col-md-4">
46
+ <div class="card mb-4 shadow-sm h-100">
47
+ <div class="card-body text-center py-4">
48
+ <i class="fas fa-comments fa-4x mb-3 text-primary"></i>
49
+ <h3 class="card-title">텍스트 채팅</h3>
50
+ <p class="card-text">텍스트로 질문을 입력하고 지식 기반 답변을 받으세요.</p>
51
+ <a href="/chat" class="btn btn-primary mt-2">채팅 시작</a>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="col-md-4">
57
+ <div class="card mb-4 shadow-sm h-100">
58
+ <div class="card-body text-center py-4">
59
+ <i class="fas fa-microphone fa-4x mb-3 text-danger"></i>
60
+ <h3 class="card-title">음성 채팅</h3>
61
+ <p class="card-text">VITO STT를 활용한 음성 질문으로 대화하세요.</p>
62
+ <a href="/chat" class="btn btn-danger mt-2">음성 채팅</a>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="col-md-4">
68
+ <div class="card mb-4 shadow-sm h-100">
69
+ <div class="card-body text-center py-4">
70
+ <i class="fas fa-database fa-4x mb-3 text-success"></i>
71
+ <h3 class="card-title">지식베이스</h3>
72
+ <p class="card-text">문서를 업로드하여 챗봇의 지식을 확장하세요.</p>
73
+ <a href="/knowledge" class="btn btn-success mt-2">지식베이스 관리</a>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="row mt-5">
80
+ <div class="col-md-12">
81
+ <div class="card shadow-sm">
82
+ <div class="card-body">
83
+ <h4 class="mb-3">RAG 기술에 대하여</h4>
84
+ <p>RAG(Retrieval-Augmented Generation)는 대규모 언어 모델(LLM)에 외부 지식을 제공하여 더 정확하고 최신 정보를 바탕으로 응답을 생성하는 기술입니다.</p>
85
+ <p>본 챗봇은 문서 검색과 LLM 생성을 결합하여 사용자 질문에 대한 맥락 기반 응답을 제공합니다.</p>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <footer class="bg-dark text-white text-center py-3 mt-5">
95
+ <div class="container">
96
+ <p class="mb-0">RAG 챗봇 클라이언�� &copy; 2025</p>
97
+ </div>
98
+ </footer>
99
+
100
+ <!-- Bootstrap JS Bundle with Popper -->
101
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
102
+ </body>
103
+ </html>
templates/knowledge.html ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 챗봇 - 지식베이스</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
12
+ <!-- Dropzone.js -->
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.9.3/min/dropzone.min.css">
14
+ </head>
15
+ <body>
16
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
17
+ <div class="container">
18
+ <a class="navbar-brand" href="/">
19
+ <i class="fas fa-robot me-2"></i>RAG 챗봇
20
+ </a>
21
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
22
+ <span class="navbar-toggler-icon"></span>
23
+ </button>
24
+ <div class="collapse navbar-collapse" id="navbarNav">
25
+ <ul class="navbar-nav ms-auto">
26
+ <li class="nav-item">
27
+ <a class="nav-link" href="/"><i class="fas fa-home me-1"></i>홈</a>
28
+ </li>
29
+ <li class="nav-item">
30
+ <a class="nav-link" href="/chat"><i class="fas fa-comments me-1"></i>채팅</a>
31
+ </li>
32
+ <li class="nav-item">
33
+ <a class="nav-link active" href="/knowledge"><i class="fas fa-database me-1"></i>지식베이스</a>
34
+ </li>
35
+ </ul>
36
+ </div>
37
+ </div>
38
+ </nav>
39
+
40
+ <div class="container mt-4">
41
+ <div class="row">
42
+ <!-- 문서 업로드 영역 -->
43
+ <div class="col-md-6">
44
+ <div class="card shadow-sm mb-4">
45
+ <div class="card-header bg-success text-white">
46
+ <i class="fas fa-upload me-2"></i>문서 업로드
47
+ </div>
48
+ <div class="card-body">
49
+ <p class="card-text">챗봇에 지식을 제공할 문서를 업로드하세요. 텍스트 기반 문서(TXT, MD, PDF, DOCX, CSV)를 지원합니다.</p>
50
+
51
+ <form id="documentUploadForm" class="dropzone mt-3">
52
+ <div class="dz-message needsclick">
53
+ <i class="fas fa-cloud-upload-alt fa-3x mb-3"></i>
54
+ <h4>여기에 파일을 끌어다 놓거나 클릭하여 선택하세요</h4>
55
+ <span class="text-muted">최대 10MB, 텍스트 기반 문서 파일만 허용</span>
56
+ </div>
57
+ </form>
58
+
59
+ <div class="alert alert-success mt-3 d-none" id="uploadSuccess">
60
+ <i class="fas fa-check-circle me-2"></i>
61
+ <span id="uploadSuccessMessage">문서가 성공적으로 업로드되었습니다.</span>
62
+ </div>
63
+
64
+ <div class="alert alert-danger mt-3 d-none" id="uploadError">
65
+ <i class="fas fa-exclamation-circle me-2"></i>
66
+ <span id="uploadErrorMessage">업로드 중 오류가 발생했습니다.</span>
67
+ </div>
68
+
69
+ <button id="uploadButton" class="btn btn-success mt-3" disabled>
70
+ <i class="fas fa-upload me-2"></i>업로드
71
+ </button>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="card shadow-sm">
76
+ <div class="card-header bg-dark text-white">
77
+ <i class="fas fa-info-circle me-2"></i>사용 안내
78
+ </div>
79
+ <div class="card-body">
80
+ <h5>지원되는 파일 형식</h5>
81
+ <ul>
82
+ <li>텍스트 파일 (.txt)</li>
83
+ <li>마크다운 파일 (.md)</li>
84
+ <li>PDF 문서 (.pdf)</li>
85
+ <li>MS Word 문서 (.docx)</li>
86
+ <li>CSV 데이터 (.csv)</li>
87
+ </ul>
88
+ <h5>작동 방식</h5>
89
+ <p>업로드된 문서는 적절한 크기로 분할되어 임베딩된 후 벡터 데이터베이스에 저장됩니다. 이 데이터는 사용자의 질문에 대한 관련 정보를 검색하는 데 사용됩니다.</p>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- 현재 지식베이스 상태 -->
95
+ <div class="col-md-6">
96
+ <div class="card shadow-sm mb-4">
97
+ <div class="card-header bg-primary text-white">
98
+ <div class="d-flex justify-content-between align-items-center">
99
+ <div>
100
+ <i class="fas fa-database me-2"></i>지식베이스 상태
101
+ </div>
102
+ <div>
103
+ <button id="refreshStatus" class="btn btn-sm btn-light">
104
+ <i class="fas fa-sync-alt me-1"></i>새로고침
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div class="card-body">
110
+ <div id="databaseStats">
111
+ <div class="d-flex justify-content-center align-items-center" style="height: 100px;">
112
+ <div class="spinner-border text-primary" role="status">
113
+ <span class="visually-hidden">Loading...</span>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <div class="card shadow-sm">
121
+ <div class="card-header bg-primary text-white">
122
+ <i class="fas fa-file-alt me-2"></i>문서 목록
123
+ </div>
124
+ <div class="card-body p-0">
125
+ <div id="documentsContainer">
126
+ <div class="text-center p-4">
127
+ <div class="spinner-border text-primary" role="status">
128
+ <span class="visually-hidden">Loading...</span>
129
+ </div>
130
+ <p class="mt-2">문서 목록을 불러오는 중...</p>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <footer class="bg-dark text-white text-center py-3 mt-5">
140
+ <div class="container">
141
+ <p class="mb-0">RAG 챗봇 클라이언트 &copy; 2025</p>
142
+ </div>
143
+ </footer>
144
+
145
+ <!-- 문서 항목 템플릿 -->
146
+ <template id="documentItemTemplate">
147
+ <div class="list-group-item">
148
+ <div class="d-flex justify-content-between align-items-center">
149
+ <div class="d-flex align-items-center">
150
+ <i class="fas fa-file-alt me-3 text-primary"></i>
151
+ <div>
152
+ <h6 class="mb-0 document-name">문서명</h6>
153
+ <div class="small text-muted">
154
+ 문서 청크: <span class="document-chunks">0</span> 개
155
+ </div>
156
+ </div>
157
+ </div>
158
+ <span class="badge bg-primary document-type">TXT</span>
159
+ </div>
160
+ </div>
161
+ </template>
162
+
163
+ <!-- Bootstrap JS Bundle with Popper -->
164
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
165
+ <!-- Dropzone.js -->
166
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.9.3/min/dropzone.min.js"></script>
167
+ <!-- 지식베이스 관리 스크립트 -->
168
+ <script src="/static/js/knowledge.js"></script>
169
+ </body>
170
+ </html>
templates/loading.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 챗봇 - 로딩 중</title>
7
+ <link rel="stylesheet" href="/static/css/style.css">
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
12
+ <style>
13
+ .loading-container {
14
+ height: 100vh;
15
+ display: flex;
16
+ flex-direction: column;
17
+ justify-content: center;
18
+ align-items: center;
19
+ }
20
+
21
+ .loader {
22
+ margin-bottom: 2rem;
23
+ }
24
+
25
+ .progress {
26
+ width: 300px;
27
+ }
28
+ </style>
29
+ <meta http-equiv="refresh" content="3;url=/" />
30
+ </head>
31
+ <body class="bg-light">
32
+ <div class="loading-container">
33
+ <div class="loader">
34
+ <div class="spinner-border text-primary" style="width: 5rem; height: 5rem;" role="status">
35
+ <span class="visually-hidden">Loading...</span>
36
+ </div>
37
+ </div>
38
+
39
+ <h3 class="mb-4 text-center">
40
+ <i class="fas fa-robot me-2"></i>RAG 챗봇을 초기화하는 중입니다
41
+ </h3>
42
+
43
+ <div class="progress mb-3">
44
+ <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
45
+ </div>
46
+
47
+ <p class="text-muted">잠시만 기다려주세요. 곧 자동으로 리디렉션됩니다.</p>
48
+ </div>
49
+ </body>
50
+ </html>
uploads/README.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # 업로드 디렉토리
2
+
3
+ 이 디렉토리는 업로드된 파일을 임시로 저장하는 용도로 사용됩니다.
4
+ 업로드된 파일은 처리 후 삭제됩니다.
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Utils 패키지
utils/vito_stt.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ VITO API를 사용한 음성 인식(STT) 모듈
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ import requests
9
+ import json
10
+ import time
11
+ from dotenv import load_dotenv
12
+
13
+ # 환경 변수 로드
14
+ load_dotenv()
15
+
16
+ # 로거 설정
17
+ logger = logging.getLogger("VitoSTT")
18
+ # 기본 로깅 레벨 설정 (핸들러가 없으면 출력이 안될 수 있으므로 기본 핸들러 추가 고려)
19
+ if not logger.hasHandlers():
20
+ handler = logging.StreamHandler()
21
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
22
+ handler.setFormatter(formatter)
23
+ logger.addHandler(handler)
24
+ logger.setLevel(logging.INFO) # 기본 레벨 INFO로 설정
25
+
26
+ class VitoSTT:
27
+ """VITO STT API 래퍼 클래스"""
28
+
29
+ def __init__(self):
30
+ """VITO STT 클래스 초기화"""
31
+ self.client_id = os.getenv("VITO_CLIENT_ID")
32
+ self.client_secret = os.getenv("VITO_CLIENT_SECRET")
33
+
34
+ if not self.client_id or not self.client_secret:
35
+ logger.warning("VITO API 인증 정보가 .env 파일에 설정되지 않았습니다.")
36
+ logger.warning("VITO_CLIENT_ID와 VITO_CLIENT_SECRET를 확인하세요.")
37
+ # 에러를 발생시키거나, 기능 사용 시점에 체크하도록 둘 수 있습니다.
38
+ # 여기서는 경고만 하고 넘어갑니다.
39
+ else:
40
+ logger.info("VITO STT API 클라이언트 ID/Secret 로드 완료.")
41
+
42
+ # API 엔드포인트
43
+ self.token_url = "https://openapi.vito.ai/v1/authenticate"
44
+ self.stt_url = "https://openapi.vito.ai/v1/transcribe"
45
+
46
+ # 액세스 토큰
47
+ self.access_token = None
48
+ self._token_expires_at = 0 # 토큰 만료 시간 추적 (선택적 개선)
49
+
50
+ def get_access_token(self):
51
+ """VITO API 액세스 토큰 획득"""
52
+ # 현재 시간을 가져와 토큰 만료 여부 확인 (선택적 개선)
53
+ now = time.time()
54
+ if self.access_token and now < self._token_expires_at:
55
+ logger.debug("기존 VITO API 토큰 사용")
56
+ return self.access_token
57
+
58
+ if not self.client_id or not self.client_secret:
59
+ logger.error("API 키가 설정되지 않아 토큰을 획득할 수 없습니다.")
60
+ raise ValueError("VITO API 인증 정보가 설정되지 않았습니다.")
61
+
62
+ logger.info("VITO API 액세스 토큰 요청 중...")
63
+ try:
64
+ response = requests.post(
65
+ self.token_url,
66
+ data={"client_id": self.client_id, "client_secret": self.client_secret},
67
+ timeout=10 # 타임아웃 설정
68
+ )
69
+ response.raise_for_status() # HTTP 오류 발생 시 예외 발생
70
+
71
+ result = response.json()
72
+ self.access_token = result.get("access_token")
73
+ expires_in = result.get("expires_in", 3600) # 만료 시간 (초), 기본값 1시간
74
+ self._token_expires_at = time.time() + expires_in - 60 # 60초 여유
75
+
76
+ if not self.access_token:
77
+ logger.error("VITO API 응답에서 토큰을 찾을 수 없습니다.")
78
+ raise ValueError("VITO API 토큰을 받아오지 못했습니다.")
79
+
80
+ logger.info("VITO API 액세스 토큰 획득 성공")
81
+ return self.access_token
82
+ except requests.exceptions.Timeout:
83
+ logger.error(f"VITO API 토큰 획득 시간 초과: {self.token_url}")
84
+ raise TimeoutError("VITO API 토큰 획득 시간 초과")
85
+ except requests.exceptions.RequestException as e:
86
+ logger.error(f"VITO API 토큰 획득 실패: {e}")
87
+ if hasattr(e, 'response') and e.response is not None:
88
+ logger.error(f"응답 코드: {e.response.status_code}, 내용: {e.response.text}")
89
+ raise ConnectionError(f"VITO API 토큰 획득 실패: {e}")
90
+
91
+
92
+ def transcribe_audio(self, audio_bytes, language="ko"):
93
+ """
94
+ 오디오 바이트 데이터를 텍스트로 변환
95
+
96
+ Args:
97
+ audio_bytes: 오디오 파일 바이트 데이터
98
+ language: 언어 코드 (기본값: 'ko')
99
+
100
+ Returns:
101
+ 인식된 텍스트 또는 오류 메시지를 포함한 딕셔너리
102
+ {'success': True, 'text': '인식된 텍스트'}
103
+ {'success': False, 'error': '오류 메시지', 'details': '상세 내용'}
104
+ """
105
+ if not self.client_id or not self.client_secret:
106
+ logger.error("API 키가 설정되지 않았습니다.")
107
+ return {"success": False, "error": "API 키가 설정되지 않았습니다."}
108
+
109
+ try:
110
+ # 토큰 획득 또는 갱신
111
+ # (선택적 개선: 만료 시간 체크 로직 추가 시 self._token_expires_at 사용)
112
+ if not self.access_token or time.time() >= self._token_expires_at:
113
+ logger.info("VITO API 토큰 획득/갱신 시도...")
114
+ self.get_access_token()
115
+
116
+ headers = {
117
+ "Authorization": f"Bearer {self.access_token}"
118
+ }
119
+
120
+ files = {
121
+ "file": ("audio_file", audio_bytes) # 파일명 튜플로 전달
122
+ }
123
+
124
+ # API 설정값 (필요에 따라 수정)
125
+ config = {
126
+ "use_multi_channel": False,
127
+ "use_itn": True, # Inverse Text Normalization (숫자, 날짜 등 변환)
128
+ "use_disfluency_filter": True, # 필러 (음, 아...) 제거
129
+ "use_profanity_filter": False, # 비속어 필터링
130
+ "language": language,
131
+ # "type": "audio" # type 파라미터는 VITO 문서상 필수 아님 (자동 감지)
132
+ }
133
+ data = {"config": json.dumps(config)}
134
+
135
+ logger.info(f"VITO STT API ({self.stt_url}) 요청 전송 중...")
136
+ response = requests.post(
137
+ self.stt_url,
138
+ headers=headers,
139
+ files=files,
140
+ data=data,
141
+ timeout=20 # 업로드 타임아웃
142
+ )
143
+ response.raise_for_status()
144
+
145
+ result = response.json()
146
+ job_id = result.get("id")
147
+
148
+ if not job_id:
149
+ logger.error("VITO API 작업 ID를 받아오지 못했습니다.")
150
+ return {"success": False, "error": "VITO API 작업 ID를 받아오지 못했습니다."}
151
+
152
+ logger.info(f"VITO STT 작업 ID: {job_id}, 결과 확인 시작...")
153
+
154
+ # 결과 확인 URL
155
+ transcript_url = f"{self.stt_url}/{job_id}"
156
+ max_tries = 15 # 최대 시도 횟수 증가
157
+ wait_time = 2 # 대기 시간 증가 (초)
158
+
159
+ for try_count in range(max_tries):
160
+ time.sleep(wait_time) # API 부하 감소 위해 대기
161
+ logger.debug(f"결과 확인 시도 ({try_count + 1}/{max_tries}) - URL: {transcript_url}")
162
+ get_response = requests.get(
163
+ transcript_url,
164
+ headers=headers,
165
+ timeout=10 # 결과 확인 타임아웃
166
+ )
167
+ get_response.raise_for_status()
168
+
169
+ result = get_response.json()
170
+ status = result.get("status")
171
+ logger.debug(f"현재 상태: {status}")
172
+
173
+ if status == "completed":
174
+ # 결과 추출 (utterances 구조 확인 필요)
175
+ utterances = result.get("results", {}).get("utterances", [])
176
+ if utterances:
177
+ # 전체 텍스트를 하나로 합침
178
+ transcript = " ".join([seg.get("msg", "") for seg in utterances if seg.get("msg")]).strip()
179
+ logger.info(f"VITO STT 인식 성공 (일부): {transcript[:50]}...")
180
+ return {
181
+ "success": True,
182
+ "text": transcript
183
+ # "raw_result": result # 필요시 전체 결과 반환
184
+ }
185
+ else:
186
+ logger.warning("VITO STT 완료되었으나 결과 utterances가 비어있습니다.")
187
+ return {"success": True, "text": ""} # 성공이지만 텍스트 없음
188
+
189
+ elif status == "failed":
190
+ error_msg = f"VITO API 변환 실패: {result.get('message', '알 수 없는 오류')}"
191
+ logger.error(error_msg)
192
+ return {"success": False, "error": error_msg, "details": result}
193
+
194
+ elif status == "transcribing":
195
+ logger.info(f"VITO API 처리 중... ({try_count + 1}/{max_tries})")
196
+ else: # registered, waiting 등 다른 상태
197
+ logger.info(f"VITO API 상태 '{status}', 대기 중... ({try_count + 1}/{max_tries})")
198
+
199
+
200
+ logger.error(f"VITO API 응답 타임아웃 ({max_tries * wait_time}초 초과)")
201
+ return {"success": False, "error": "VITO API 응답 타임아웃"}
202
+
203
+ except requests.exceptions.HTTPError as e:
204
+ # 토큰 만료 오류 처리 (401 Unauthorized)
205
+ if e.response.status_code == 401:
206
+ logger.warning("VITO API 토큰이 만료되었거나 유효하지 않습니다. 토큰 재발급 시도...")
207
+ self.access_token = None # 기존 토큰 무효화
208
+ try:
209
+ # 재귀 호출 대신, 토큰 재발급 후 다시 시도하는 로직 구성
210
+ self.get_access_token()
211
+ logger.info("새 토큰으로 재시도합니다.")
212
+ # 재시도는 이 함수를 다시 호출하는 대신, 호출하는 쪽에서 처리하는 것이 더 안전할 수 있음
213
+ return self.transcribe_audio(audio_bytes, language) # 재귀 호출 방식
214
+
215
+ except Exception as token_e:
216
+ logger.error(f"토큰 재획득 실패: {token_e}")
217
+ return {"success": False, "error": f"토큰 재획득 실패: {str(token_e)}"}
218
+
219
+ else:
220
+ # 401 외 다른 HTTP 오류
221
+ error_body = ""
222
+ try:
223
+ error_body = e.response.text
224
+ except Exception:
225
+ pass
226
+ logger.error(f"VITO API HTTP 오류: {e.response.status_code}, 응답: {error_body}")
227
+ return {
228
+ "success": False,
229
+ "error": f"API HTTP 오류: {e.response.status_code}",
230
+ "details": error_body
231
+ }
232
+
233
+ except requests.exceptions.Timeout:
234
+ logger.error("VITO API 요청 시간 초과")
235
+ return {"success": False, "error": "API 요청 시간 초과"}
236
+ except requests.exceptions.RequestException as e:
237
+ logger.error(f"VITO API 요청 중 네트워크 오류 발생: {str(e)}")
238
+ return {"success": False, "error": "API 요청 네트워크 오류", "details": str(e)}
239
+ except Exception as e:
240
+ logger.error(f"음성인식 처리 중 예상치 못한 오류 발생: {str(e)}", exc_info=True)
241
+ return {
242
+ "success": False,
243
+ "error": "음성인식 내부 처리 실패",
244
+ "details": str(e)
245
+ }
vito_stt.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ VITO API를 사용한 음성 인식(STT) 모듈
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ import requests
9
+ import json
10
+ import time # time import 추가
11
+ from dotenv import load_dotenv
12
+
13
+ # 환경 변수 로드
14
+ load_dotenv()
15
+
16
+ # 로거 설정 (app.py와 공유하거나 독립적으로 설정 가능)
17
+ # 여기서는 독립적인 로거를 사용합니다. 필요시 app.py의 로거를 사용하도록 수정할 수 있습니다.
18
+ logger = logging.getLogger("VitoSTT")
19
+ # 기본 로깅 레벨 설정 (핸들러가 없으면 출력이 안될 수 있으므로 기본 핸들러 추가 고려)
20
+ if not logger.hasHandlers():
21
+ handler = logging.StreamHandler()
22
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
23
+ handler.setFormatter(formatter)
24
+ logger.addHandler(handler)
25
+ logger.setLevel(logging.INFO) # 기본 레벨 INFO로 설정
26
+
27
+ class VitoSTT:
28
+ """VITO STT API 래퍼 클래스"""
29
+
30
+ def __init__(self):
31
+ """VITO STT 클래스 초기화"""
32
+ self.client_id = os.getenv("VITO_CLIENT_ID")
33
+ self.client_secret = os.getenv("VITO_CLIENT_SECRET")
34
+
35
+ if not self.client_id or not self.client_secret:
36
+ logger.warning("VITO API 인증 정보가 .env 파일에 설정되지 않았습니다.")
37
+ logger.warning("VITO_CLIENT_ID와 VITO_CLIENT_SECRET를 확인하세요.")
38
+ # 에러를 발생시키거나, 기능 사용 시점에 체크하도록 둘 수 있습니다.
39
+ # 여기서는 경고만 하고 넘어갑니다.
40
+ else:
41
+ logger.info("VITO STT API 클라이언트 ID/Secret 로드 완료.")
42
+
43
+ # API 엔드포인트
44
+ self.token_url = "https://openapi.vito.ai/v1/authenticate"
45
+ self.stt_url = "https://openapi.vito.ai/v1/transcribe"
46
+
47
+ # 액세스 토큰
48
+ self.access_token = None
49
+ self._token_expires_at = 0 # 토큰 만료 시간 추적 (선택적 개선)
50
+
51
+ def get_access_token(self):
52
+ """VITO API 액세스 토큰 획득"""
53
+ # 현재 시간을 가져와 토큰 만료 여부 확인 (선택적 개선)
54
+ # now = time.time()
55
+ # if self.access_token and now < self._token_expires_at:
56
+ # logger.debug("기존 VITO API 토큰 사용")
57
+ # return self.access_token
58
+
59
+ if not self.client_id or not self.client_secret:
60
+ logger.error("API 키가 설정되지 않아 토큰을 획득할 수 없습니다.")
61
+ raise ValueError("VITO API 인증 정보가 설정되지 않았습니다.")
62
+
63
+ logger.info("VITO API 액세스 토큰 요청 중...")
64
+ try:
65
+ response = requests.post(
66
+ self.token_url,
67
+ data={"client_id": self.client_id, "client_secret": self.client_secret},
68
+ timeout=10 # 타임아웃 설정
69
+ )
70
+ response.raise_for_status() # HTTP 오류 발생 시 예외 발생
71
+
72
+ result = response.json()
73
+ self.access_token = result.get("access_token")
74
+ expires_in = result.get("expires_in", 3600) # 만료 시간 (초), 기본값 1시간
75
+ self._token_expires_at = time.time() + expires_in - 60 # 60초 여유
76
+
77
+ if not self.access_token:
78
+ logger.error("VITO API 응답에서 토큰을 찾을 수 없습니다.")
79
+ raise ValueError("VITO API 토큰을 받아오지 못했습니다.")
80
+
81
+ logger.info("VITO API 액세스 토큰 획득 성공")
82
+ return self.access_token
83
+ except requests.exceptions.Timeout:
84
+ logger.error(f"VITO API 토큰 획득 시간 초과: {self.token_url}")
85
+ raise TimeoutError("VITO API 토큰 획득 시간 초과")
86
+ except requests.exceptions.RequestException as e:
87
+ logger.error(f"VITO API 토큰 획득 실패: {e}")
88
+ if hasattr(e, 'response') and e.response is not None:
89
+ logger.error(f"응답 코드: {e.response.status_code}, 내용: {e.response.text}")
90
+ raise ConnectionError(f"VITO API 토큰 획득 실패: {e}")
91
+
92
+
93
+ def transcribe_audio(self, audio_bytes, language="ko"):
94
+ """
95
+ 오디오 바이트 데이터를 텍스트로 변환
96
+
97
+ Args:
98
+ audio_bytes: 오디오 파일 바이트 데이터
99
+ language: 언어 코드 (기본값: 'ko')
100
+
101
+ Returns:
102
+ 인식된 텍스트 또는 오류 메시지를 포함한 딕셔너리
103
+ {'success': True, 'text': '인식된 텍스트'}
104
+ {'success': False, 'error': '오류 메시지', 'details': '상세 내용'}
105
+ """
106
+ if not self.client_id or not self.client_secret:
107
+ logger.error("API 키가 설정되지 않았습니다.")
108
+ return {"success": False, "error": "API 키가 설정되지 않았습니다."}
109
+
110
+ try:
111
+ # 토큰 획득 또는 갱신
112
+ # (선택적 개선: 만료 시간 체크 로직 추가 시 self._token_expires_at 사용)
113
+ if not self.access_token: # or time.time() >= self._token_expires_at:
114
+ logger.info("VITO API 토큰 획득/갱신 시도...")
115
+ self.get_access_token()
116
+
117
+ headers = {
118
+ "Authorization": f"Bearer {self.access_token}"
119
+ }
120
+
121
+ files = {
122
+ "file": ("audio_file", audio_bytes) # 파일명 튜플로 전달
123
+ }
124
+
125
+ # API 설정값 (필요에 따라 수정)
126
+ config = {
127
+ "use_multi_channel": False,
128
+ "use_itn": True, # Inverse Text Normalization (숫자, 날짜 등 변환)
129
+ "use_disfluency_filter": True, # 필러 (음, 아...) 제거
130
+ "use_profanity_filter": False, # 비속어 필터링
131
+ "language": language,
132
+ # "type": "audio" # type 파라미터는 VITO 문서상 필수 아님 (자동 감지)
133
+ }
134
+ data = {"config": json.dumps(config)}
135
+
136
+ logger.info(f"VITO STT API ({self.stt_url}) 요청 전송 중...")
137
+ response = requests.post(
138
+ self.stt_url,
139
+ headers=headers,
140
+ files=files,
141
+ data=data,
142
+ timeout=20 # 업로드 타임아웃
143
+ )
144
+ response.raise_for_status()
145
+
146
+ result = response.json()
147
+ job_id = result.get("id")
148
+
149
+ if not job_id:
150
+ logger.error("VITO API 작업 ID를 받아오지 못했습니다.")
151
+ return {"success": False, "error": "VITO API 작업 ID를 받아오지 못했습니다."}
152
+
153
+ logger.info(f"VITO STT 작업 ID: {job_id}, 결과 확인 시작...")
154
+
155
+ # 결과 확인 URL
156
+ transcript_url = f"{self.stt_url}/{job_id}"
157
+ max_tries = 15 # 최대 시도 횟수 증가
158
+ wait_time = 2 # 대기 시간 증가 (초)
159
+
160
+ for try_count in range(max_tries):
161
+ time.sleep(wait_time) # API 부하 감소 위해 대기
162
+ logger.debug(f"결과 확인 시도 ({try_count + 1}/{max_tries}) - URL: {transcript_url}")
163
+ get_response = requests.get(
164
+ transcript_url,
165
+ headers=headers,
166
+ timeout=10 # 결과 확인 타임아웃
167
+ )
168
+ get_response.raise_for_status()
169
+
170
+ result = get_response.json()
171
+ status = result.get("status")
172
+ logger.debug(f"현재 상태: {status}")
173
+
174
+ if status == "completed":
175
+ # 결과 추출 (utterances 구조 확인 필요)
176
+ utterances = result.get("results", {}).get("utterances", [])
177
+ if utterances:
178
+ # 전체 텍스트를 하나로 합침
179
+ transcript = " ".join([seg.get("msg", "") for seg in utterances if seg.get("msg")]).strip()
180
+ logger.info(f"VITO STT 인식 성공 (일부): {transcript[:50]}...")
181
+ return {
182
+ "success": True,
183
+ "text": transcript
184
+ # "raw_result": result # 필요시 전체 결과 반환
185
+ }
186
+ else:
187
+ logger.warning("VITO STT 완료되었으나 결과 utterances가 비어있습니다.")
188
+ return {"success": True, "text": ""} # 성공이지만 텍스트 없음
189
+
190
+ elif status == "failed":
191
+ error_msg = f"VITO API 변환 실패: {result.get('message', '알 수 없는 오류')}"
192
+ logger.error(error_msg)
193
+ return {"success": False, "error": error_msg, "details": result}
194
+
195
+ elif status == "transcribing":
196
+ logger.info(f"VITO API 처리 중... ({try_count + 1}/{max_tries})")
197
+ else: # registered, waiting 등 다른 상태
198
+ logger.info(f"VITO API 상태 '{status}', 대기 중... ({try_count + 1}/{max_tries})")
199
+
200
+
201
+ logger.error(f"VITO API 응답 타임아웃 ({max_tries * wait_time}초 초과)")
202
+ return {"success": False, "error": "VITO API 응답 타임아웃"}
203
+
204
+ except requests.exceptions.HTTPError as e:
205
+ # 토큰 만료 오류 처리 (401 Unauthorized)
206
+ if e.response.status_code == 401:
207
+ logger.warning("VITO API 토큰이 만료되었거나 유효하지 않습니다. 토큰 재발급 시도...")
208
+ self.access_token = None # 기존 토큰 무효화
209
+ try:
210
+ # 재귀 호출 대신, 토큰 재발급 후 다시 시도하는 로직 구성
211
+ self.get_access_token()
212
+ logger.info("새 토큰으로 재시도합니다.")
213
+ # 재시도는 이 함수를 다시 호출하는 대신, 호출하는 쪽에서 처리하는 것이 더 안전할 수 있음
214
+ # 여기서는 한 번 더 시도하는 로직 추가 (무한 루프 방지 필요)
215
+ # return self.transcribe_audio(audio_bytes, language) # 재귀 호출 방식
216
+ # --- 비재귀 방식 ---
217
+ headers["Authorization"] = f"Bearer {self.access_token}" # 헤더 업데이트
218
+ # POST 요청부터 다시 시작 (코드 중복 발생 가능성 있음)
219
+ # ... (POST 요청 및 결과 폴링 로직 반복) ...
220
+ # 간단하게는 그냥 실패 처리하고 상위에서 재시도 유도
221
+ return {"success": False, "error": "토큰 만료 후 재시도 필요", "details": "토큰 재발급 성공"}
222
+
223
+ except Exception as token_e:
224
+ logger.error(f"토큰 재획득 실패: {token_e}")
225
+ return {"success": False, "error": f"토큰 재획득 실패: {str(token_e)}"}
226
+
227
+ else:
228
+ # 401 외 다른 HTTP 오류
229
+ error_body = ""
230
+ try:
231
+ error_body = e.response.text
232
+ except Exception:
233
+ pass
234
+ logger.error(f"VITO API HTTP 오류: {e.response.status_code}, 응답: {error_body}")
235
+ return {
236
+ "success": False,
237
+ "error": f"API HTTP 오류: {e.response.status_code}",
238
+ "details": error_body
239
+ }
240
+
241
+ except requests.exceptions.Timeout:
242
+ logger.error("VITO API 요청 시간 초과")
243
+ return {"success": False, "error": "API 요청 시간 초과"}
244
+ except requests.exceptions.RequestException as e:
245
+ logger.error(f"VITO API 요청 중 네트워크 오류 발생: {str(e)}")
246
+ return {"success": False, "error": "API 요청 네트워크 오류", "details": str(e)}
247
+ except Exception as e:
248
+ logger.error(f"음성인식 처리 중 예상치 못한 오류 발생: {str(e)}", exc_info=True)
249
+ return {
250
+ "success": False,
251
+ "error": "음성인식 내부 처리 실패",
252
+ "details": str(e)
253
+ }