Add application file
Browse files- .env.example +7 -0
- Dockerfile +13 -0
- README.md +12 -0
- app.py +163 -252
- requirements.txt +5 -0
- static/css/style.css +181 -0
- static/js/app.js +59 -0
- static/js/chat.js +407 -0
- static/js/knowledge.js +272 -0
- templates/chat.html +201 -0
- templates/index.html +103 -0
- templates/knowledge.html +170 -0
- templates/loading.html +50 -0
- uploads/README.md +4 -0
- utils/__init__.py +1 -0
- utils/vito_stt.py +245 -0
- vito_stt.py +253 -0
.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 |
-
|
| 14 |
-
|
| 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 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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', '
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
logger.error("오디오 파일명이 비어있음")
|
| 128 |
-
return jsonify({"error": "오디오 파일이 선택되지 않았습니다."}), 400
|
| 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 |
try:
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
"retriever_type": "reranker",
|
| 172 |
-
"top_k": 3
|
| 173 |
-
}
|
| 174 |
-
)
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"transcription": transcription,
|
| 180 |
-
"error": f"RAG API 오류: {response.text}"
|
| 181 |
-
}), response.status_code
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
result["transcription"] = transcription
|
| 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 |
-
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 챗봇 클라이언트 © 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 챗봇 클라이언�� © 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 챗봇 클라이언트 © 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 |
+
}
|