Spaces:
No application file
No application file
RAG 리트리버 초기화 실패 문제 해결
Browse files- README.md +59 -0
- app.py +16 -2
- app/__init__.py +17 -1
- app/app.py +0 -365
- app/app_device_routes.py +0 -220
- app/app_main.py +138 -0
- app/app_part2.py +0 -209
- app/app_part3.py +0 -163
- app/app_revised.py +83 -168
- app/app_routes.py +0 -774
- app/app_routes_improved.py +156 -0
- app/background_init.py +113 -0
- app/chat_routes.py +223 -0
- app/init_retriever.py +0 -149
- app/init_retriever_improved.py +218 -0
- app/static/css/device-style.css +196 -0
- app/static/js/app-device.js +180 -75
- app/templates/error.html +88 -0
- app/templates/index.html +26 -0
- app/voice_routes.py +203 -0
- app_gradio.py +0 -206
- docs/project_plan.md +74 -135
- docs/technical_improvements.md +167 -0
README.md
CHANGED
@@ -10,4 +10,63 @@ pinned: false
|
|
10 |
license: mit
|
11 |
---
|
12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
10 |
license: mit
|
11 |
---
|
12 |
|
13 |
+
# RAG6 AgenticAI - 개선된 RAG 검색 챗봇
|
14 |
+
|
15 |
+
RAG(Retrieval-Augmented Generation) 기반의 문서 검색 및 질의응답 챗봇 시스템입니다.
|
16 |
+
|
17 |
+
## 주요 기능
|
18 |
+
|
19 |
+
- 텍스트 기반 챗봇 (RAG)
|
20 |
+
- 음성 인식 기반 대화
|
21 |
+
- 문서 파일 업로드 및 인덱싱
|
22 |
+
- 다양한 LLM 모델 지원
|
23 |
+
- 관리자 인증 기능
|
24 |
+
|
25 |
+
## 시스템 아키텍처
|
26 |
+
|
27 |
+
이 시스템은 다음과 같은 주요 구성 요소로 이루어져 있습니다:
|
28 |
+
|
29 |
+
1. **Flask 웹 서버**: 프론트엔드 및 API 제공
|
30 |
+
2. **VectorRetriever**: 문서 인덱싱 및 벡터 검색
|
31 |
+
3. **ReRanker**: 검색 결과 재순위화
|
32 |
+
4. **LLM 인터페이스**: 다양한 LLM API 연결
|
33 |
+
5. **STT(Speech-to-Text)**: 음성 인식 지원
|
34 |
+
|
35 |
+
## 개선된 기능
|
36 |
+
|
37 |
+
이번 버전에서는 다음과 같은 개선 사항이 적용되었습니다:
|
38 |
+
|
39 |
+
- **안정성 향상**: 리트리버 초기화 실패 문제 해결
|
40 |
+
- **오류 처리 강화**: 초기화 실패 원인 추적 및 명확한 피드백 제공
|
41 |
+
- **모듈화 개선**: 코드 분할 및 기능별 모듈화로 유지보수성 향상
|
42 |
+
- **상태 관리**: 앱 준비 상태와 초기화 성공 여부 분리 관리
|
43 |
+
- **사용자 경험**: 오류 발생 시에도 앱 접근성 유지와 명확한 피드백
|
44 |
+
|
45 |
+
## 설치 및 실행
|
46 |
+
|
47 |
+
```bash
|
48 |
+
# 가상환경 생성 및 활성화
|
49 |
+
python -m venv venv
|
50 |
+
source venv/bin/activate # 또는 Windows: venv\Scripts\activate
|
51 |
+
|
52 |
+
# 의존성 설치
|
53 |
+
pip install -r requirements.txt
|
54 |
+
|
55 |
+
# 앱 실행
|
56 |
+
python app.py
|
57 |
+
```
|
58 |
+
|
59 |
+
## 환경 변수
|
60 |
+
|
61 |
+
필요한 환경 변수는 다음과 같습니다:
|
62 |
+
|
63 |
+
- `ADMIN_USERNAME`: 관리자 사용자명 (기본값: "admin")
|
64 |
+
- `ADMIN_PASSWORD`: 관리자 비밀번호 (기본값: "rag12345")
|
65 |
+
- `FLASK_SECRET_KEY`: Flask 세션 암호화 키
|
66 |
+
- `PORT`: 서버 포트 (기본값: 7860)
|
67 |
+
|
68 |
+
## 라이센스
|
69 |
+
|
70 |
+
MIT License
|
71 |
+
|
72 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
CHANGED
@@ -4,10 +4,24 @@ RAG 검색 챗봇 메인 실행 파일
|
|
4 |
|
5 |
# os 모듈 임포트
|
6 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
|
8 |
# 앱 모듈에서 Flask 앱 가져오기
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
if __name__ == '__main__':
|
12 |
port = int(os.environ.get("PORT", 7860))
|
13 |
-
|
|
|
|
4 |
|
5 |
# os 모듈 임포트
|
6 |
import os
|
7 |
+
import logging
|
8 |
+
|
9 |
+
# 로깅 설정
|
10 |
+
logging.basicConfig(
|
11 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
12 |
+
level=logging.DEBUG
|
13 |
+
)
|
14 |
+
logger = logging.getLogger(__name__)
|
15 |
|
16 |
# 앱 모듈에서 Flask 앱 가져오기
|
17 |
+
try:
|
18 |
+
from app.app_main import app
|
19 |
+
logger.info("Flask 앱을 성공적으로 로드했습니다.")
|
20 |
+
except ImportError as e:
|
21 |
+
logger.critical(f"앱 임포트 실패: {e}")
|
22 |
+
raise
|
23 |
|
24 |
if __name__ == '__main__':
|
25 |
port = int(os.environ.get("PORT", 7860))
|
26 |
+
logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
|
27 |
+
app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)
|
app/__init__.py
CHANGED
@@ -1 +1,17 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 애플리케이션 패키지
|
3 |
+
"""
|
4 |
+
|
5 |
+
# 이 파일은 app 디렉토리를 파이썬 패키지로 만듭니다.
|
6 |
+
# 주요 모듈:
|
7 |
+
# - app_revised.py: 앱 초기화 및 설정
|
8 |
+
# - app_main.py: 앱 메인 로직 및 서버 실행
|
9 |
+
# - background_init.py: 백그라운드 초기화 관리
|
10 |
+
# - init_retriever_improved.py: 개선된 검색기 초기화 로직
|
11 |
+
# - app_routes_improved.py: 메인 라우트 등록
|
12 |
+
# - chat_routes.py: 챗봇 API 엔드포인트
|
13 |
+
# - document_routes.py: 문서 관리 API 엔드포인트
|
14 |
+
# - voice_routes.py: 음성 처리 API 엔드포인트
|
15 |
+
|
16 |
+
# 버전 정보
|
17 |
+
__version__ = '1.1.0'
|
app/app.py
DELETED
@@ -1,365 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
RAG 검색 챗봇 웹 애플리케이션 (세션 설정 수정 적용 및 TypeError 해결)
|
3 |
-
"""
|
4 |
-
|
5 |
-
import os
|
6 |
-
import json
|
7 |
-
import logging
|
8 |
-
import tempfile
|
9 |
-
import threading
|
10 |
-
import datetime
|
11 |
-
import time # 추가
|
12 |
-
from flask import Flask, request, jsonify, render_template, send_from_directory, session, redirect, url_for
|
13 |
-
from werkzeug.utils import secure_filename
|
14 |
-
from dotenv import load_dotenv
|
15 |
-
from functools import wraps
|
16 |
-
|
17 |
-
# 로거 설정
|
18 |
-
logging.basicConfig(
|
19 |
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
20 |
-
level=logging.DEBUG
|
21 |
-
)
|
22 |
-
logger = logging.getLogger(__name__)
|
23 |
-
|
24 |
-
# 환경 변수 로드
|
25 |
-
load_dotenv()
|
26 |
-
|
27 |
-
# 환경 변수 로드 상태 확인 및 로깅
|
28 |
-
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
|
29 |
-
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
30 |
-
DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', '')
|
31 |
-
|
32 |
-
logger.info(f"==== 환경 변수 로드 상태 ====")
|
33 |
-
logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
|
34 |
-
logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
|
35 |
-
logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL or '설정되지 않음 (프론트엔드에서 자동 설정)'}")
|
36 |
-
|
37 |
-
# 환경 변수가 없으면 기본값 설정
|
38 |
-
if not ADMIN_USERNAME:
|
39 |
-
ADMIN_USERNAME = 'admin'
|
40 |
-
logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
|
41 |
-
|
42 |
-
if not ADMIN_PASSWORD:
|
43 |
-
ADMIN_PASSWORD = 'rag12345'
|
44 |
-
logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
|
45 |
-
|
46 |
-
# --- 로컬 모듈 임포트 ---
|
47 |
-
# MockComponent 정의 (임포트 실패 시 대체)
|
48 |
-
class MockComponent:
|
49 |
-
def __init__(self):
|
50 |
-
self.is_mock = True
|
51 |
-
|
52 |
-
def search(self, query, top_k=5, first_stage_k=None):
|
53 |
-
"""빈 검색 결과를 반환합니다."""
|
54 |
-
logger.warning(f"MockComponent.search 호출됨 (쿼리: {query[:30]}...)")
|
55 |
-
return []
|
56 |
-
|
57 |
-
def __getattr__(self, name):
|
58 |
-
# Mock 객체의 어떤 속성이나 메소드 호출 시 경고 로그 출력 및 기본값 반환
|
59 |
-
logger.warning(f"MockComponent에서 '{name}' 접근 시도됨 (실제 모듈 로드 실패)")
|
60 |
-
# 메소드 호출 시에는 아무것도 안 하는 함수 반환
|
61 |
-
if name in ['add_documents', 'save', 'transcribe_audio', 'rag_generate', 'set_llm', 'get_current_llm_details', 'prepare_rag_context', 'csv_to_documents', 'text_to_documents', 'load_documents_from_directory']:
|
62 |
-
return lambda *args, **kwargs: logger.warning(f"Mocked method '{name}' called") or None
|
63 |
-
# 속성 접근 시에는 None 반환
|
64 |
-
return None
|
65 |
-
|
66 |
-
try:
|
67 |
-
from utils.vito_stt import VitoSTT
|
68 |
-
from utils.llm_interface import LLMInterface
|
69 |
-
from utils.document_processor import DocumentProcessor
|
70 |
-
from retrieval.vector_retriever import VectorRetriever
|
71 |
-
from retrieval.reranker import ReRanker
|
72 |
-
from app.app_routes import register_routes
|
73 |
-
from app.app_device_routes import register_device_routes
|
74 |
-
MODULE_LOAD_SUCCESS = True
|
75 |
-
except ImportError as e:
|
76 |
-
logger.error(f"로컬 모듈 임포트 실패: {e}. Mock 객체를 사용합니다.")
|
77 |
-
VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
|
78 |
-
# register_routes, register_device_routes는 임포트 실패 시 정의되지 않으므로 아래에서 처리
|
79 |
-
MODULE_LOAD_SUCCESS = False
|
80 |
-
# 임시로 빈 함수 정의 (앱 실행은 되도록)
|
81 |
-
def register_routes(*args, **kwargs): logger.error("register_routes 임포트 실패")
|
82 |
-
def register_device_routes(*args, **kwargs): logger.error("register_device_routes 임포트 실패")
|
83 |
-
# --- 로컬 모듈 임포트 끝 ---
|
84 |
-
|
85 |
-
|
86 |
-
# Flask 앱 초기화
|
87 |
-
app = Flask(__name__)
|
88 |
-
|
89 |
-
# 세션 설정
|
90 |
-
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345')
|
91 |
-
app.config['SESSION_COOKIE_SECURE'] = True
|
92 |
-
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
93 |
-
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
94 |
-
app.config['SESSION_COOKIE_DOMAIN'] = None
|
95 |
-
app.config['SESSION_COOKIE_PATH'] = '/'
|
96 |
-
app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1)
|
97 |
-
|
98 |
-
# 최대 파일 크기 설정 (10MB)
|
99 |
-
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
100 |
-
# 애플리케이션 파일 기준 상대 경로 설정
|
101 |
-
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
|
102 |
-
# static 폴더 경로 수정 (app 폴더 내부의 static)
|
103 |
-
app.config['STATIC_FOLDER'] = os.path.join(APP_ROOT, 'static')
|
104 |
-
app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
|
105 |
-
# data 및 index 경로는 app 폴더 외부로 설정 (프로젝트 루트 기준)
|
106 |
-
app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(APP_ROOT), 'data')
|
107 |
-
app.config['INDEX_PATH'] = os.path.join(os.path.dirname(APP_ROOT), 'data', 'index')
|
108 |
-
|
109 |
-
# 필요한 폴더 생성
|
110 |
-
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
111 |
-
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
112 |
-
os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
|
113 |
-
|
114 |
-
# 허용되는 오디오/문서 파일 확장자
|
115 |
-
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
116 |
-
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
117 |
-
|
118 |
-
# --- 전역 객체 초기화 ---
|
119 |
-
llm_interface = None
|
120 |
-
stt_client = None
|
121 |
-
base_retriever = None
|
122 |
-
retriever = None
|
123 |
-
# app_ready 플래그 대신 threading.Event 사용
|
124 |
-
app_ready_event = threading.Event() # 초기 상태: False (set() 호출 전까지)
|
125 |
-
|
126 |
-
# --- 전역 객체 초기화 (try-except로 감싸기) ---
|
127 |
-
try:
|
128 |
-
if MODULE_LOAD_SUCCESS: # 모듈 로드 성공 시에만 실제 초기화 시도
|
129 |
-
llm_interface = LLMInterface(default_llm="openai")
|
130 |
-
stt_client = VitoSTT()
|
131 |
-
else: # 실패 시 Mock 객체 사용
|
132 |
-
llm_interface = LLMInterface() # MockComponent
|
133 |
-
stt_client = VitoSTT() # MockComponent
|
134 |
-
except Exception as e:
|
135 |
-
logger.error(f"LLM 또는 STT 인터페이스 초기화 중 오류 발생: {e}", exc_info=True)
|
136 |
-
llm_interface = MockComponent() # 오류 시 Mock 객체 할당
|
137 |
-
stt_client = MockComponent() # 오류 시 Mock 객체 할당
|
138 |
-
|
139 |
-
# --- 인증 데코레이터 ---
|
140 |
-
def login_required(f):
|
141 |
-
@wraps(f)
|
142 |
-
def decorated_function(*args, **kwargs):
|
143 |
-
logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
|
144 |
-
logger.debug(f"현재 플라스크 세션 객체: {session}") # DEBUG 레벨로 변경
|
145 |
-
logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
|
146 |
-
logger.debug(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}") # DEBUG 레벨로 변경
|
147 |
-
|
148 |
-
if not session.get('logged_in'): # .get() 사용하는 것이 더 안전
|
149 |
-
logger.warning(f"세션에 'logged_in' 없음 또는 False. 로그인 페이지로 리디렉션.")
|
150 |
-
return redirect(url_for('login', next=request.url))
|
151 |
-
|
152 |
-
logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
|
153 |
-
return f(*args, **kwargs)
|
154 |
-
return decorated_function
|
155 |
-
# --- 인증 데코레이터 끝 ---
|
156 |
-
|
157 |
-
|
158 |
-
# --- 헬퍼 함수 (app_routes.py에도 있지만 여기서도 필요할 수 있음) ---
|
159 |
-
def allowed_audio_file(filename):
|
160 |
-
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
161 |
-
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
162 |
-
|
163 |
-
def allowed_doc_file(filename):
|
164 |
-
"""파일이 허용된 문서 확장자를 가지는지 확인"""
|
165 |
-
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
166 |
-
# --- 헬퍼 함수 끝 ---
|
167 |
-
|
168 |
-
|
169 |
-
# --- 검색기 초기화 관련 함수 ---
|
170 |
-
def init_retriever():
|
171 |
-
"""검색기 객체 초기화 또는 로드"""
|
172 |
-
global base_retriever, retriever
|
173 |
-
|
174 |
-
# 모듈 로드 실패 시 Mock 객체 반환
|
175 |
-
if not MODULE_LOAD_SUCCESS:
|
176 |
-
logger.warning("필수 모듈 로드 실패로 Mock 검색기 반환")
|
177 |
-
base_retriever = VectorRetriever() # MockComponent
|
178 |
-
retriever = ReRanker() # MockComponent
|
179 |
-
return retriever
|
180 |
-
|
181 |
-
index_path = app.config['INDEX_PATH']
|
182 |
-
data_path = app.config['DATA_FOLDER']
|
183 |
-
logger.info("--- init_retriever 시작 ---")
|
184 |
-
|
185 |
-
# 1. 기본 검색기 로드 또는 초기화
|
186 |
-
try:
|
187 |
-
if os.path.exists(os.path.join(index_path, "documents.json")): # 저장 방식에 따라 확인 파일 변경 필요
|
188 |
-
logger.info(f"인덱스 로드 시도: {index_path}")
|
189 |
-
base_retriever = VectorRetriever.load(index_path)
|
190 |
-
logger.info(f"인덱스 로드 성공. 문서 {len(getattr(base_retriever, 'documents', []))}개")
|
191 |
-
else:
|
192 |
-
logger.info("인덱스 파일 없음. 새 VectorRetriever 초기화 시도...")
|
193 |
-
base_retriever = VectorRetriever()
|
194 |
-
logger.info("새 VectorRetriever 초기화 성공.")
|
195 |
-
except Exception as e:
|
196 |
-
logger.error(f"기본 검색기 초기화/로드 실패: {e}", exc_info=True)
|
197 |
-
base_retriever = MockComponent() # 실패 시 Mock 사용
|
198 |
-
retriever = MockComponent()
|
199 |
-
logger.info("Mock 검색기를 대체로 사용합니다.")
|
200 |
-
return retriever # 초기화 실패해도 Mock 검색기 반환 (None 대신)
|
201 |
-
|
202 |
-
# 2. 데이터 폴더 문서 로드 (기본 검색기가 비어있을 때)
|
203 |
-
needs_loading = not hasattr(base_retriever, 'documents') or not getattr(base_retriever, 'documents', [])
|
204 |
-
if needs_loading and os.path.exists(data_path):
|
205 |
-
logger.info(f"기본 검색기가 비어있어 {data_path}에서 문서 로드 시도...")
|
206 |
-
try:
|
207 |
-
# DocumentProcessor.load_documents_from_directory 호출 확인
|
208 |
-
if hasattr(DocumentProcessor, 'load_documents_from_directory'):
|
209 |
-
docs = DocumentProcessor.load_documents_from_directory(
|
210 |
-
directory=data_path,
|
211 |
-
extensions=[".txt", ".md", ".csv"],
|
212 |
-
recursive=True
|
213 |
-
)
|
214 |
-
logger.info(f"{len(docs)}개 문서 로드 성공.")
|
215 |
-
if docs and hasattr(base_retriever, 'add_documents'):
|
216 |
-
logger.info("검색기에 문서 추가 시도...")
|
217 |
-
base_retriever.add_documents(docs)
|
218 |
-
logger.info("문서 추가 완료.")
|
219 |
-
if hasattr(base_retriever, 'save'):
|
220 |
-
logger.info(f"검색기 상태 저장 시도: {index_path}")
|
221 |
-
try:
|
222 |
-
base_retriever.save(index_path)
|
223 |
-
logger.info("인덱스 저장 완료.")
|
224 |
-
except Exception as e_save:
|
225 |
-
logger.error(f"인덱스 저장 실패: {e_save}", exc_info=True)
|
226 |
-
else:
|
227 |
-
logger.warning("DocumentProcessor에 load_documents_from_directory 메소드가 없습니다.")
|
228 |
-
except Exception as e_load_add:
|
229 |
-
logger.error(f"DATA_FOLDER 문서 로드/추가 중 오류: {e_load_add}", exc_info=True)
|
230 |
-
|
231 |
-
# 3. 재순위화 검색기 초기화
|
232 |
-
logger.info("재순위화 검색기 초기화 시도...")
|
233 |
-
try:
|
234 |
-
# custom_rerank_fn 정의
|
235 |
-
def custom_rerank_fn(query, results):
|
236 |
-
# 이 함수는 실제 재순위화 로직에 맞게 구현 필요
|
237 |
-
# 예시: 단순히 score 기준으로 정렬
|
238 |
-
results.sort(key=lambda x: x.get("score", 0) if isinstance(x, dict) else 0, reverse=True)
|
239 |
-
return results
|
240 |
-
|
241 |
-
# ReRanker 클래스 사용
|
242 |
-
retriever = ReRanker(
|
243 |
-
base_retriever=base_retriever,
|
244 |
-
rerank_fn=custom_rerank_fn,
|
245 |
-
rerank_field="text" # 재순위화에 사용할 필드 (필요시)
|
246 |
-
)
|
247 |
-
logger.info("재순위화 검색기 초기화 완료.")
|
248 |
-
except Exception as e_rerank:
|
249 |
-
logger.error(f"재순위화 검색기 초기화 실패: {e_rerank}", exc_info=True)
|
250 |
-
logger.warning("재순위화 실패, 기본 검색기를 retriever로 사용합니다.")
|
251 |
-
retriever = base_retriever # fallback
|
252 |
-
|
253 |
-
logger.info("--- init_retriever 종료 ---")
|
254 |
-
return retriever
|
255 |
-
|
256 |
-
# --- 백그라운드 초기화 ---
|
257 |
-
def background_init():
|
258 |
-
"""백그라운드에서 검색기 및 기타 컴포넌트 초기화 수행"""
|
259 |
-
global retriever, base_retriever, llm_interface, stt_client, app_ready_event
|
260 |
-
|
261 |
-
logger.info("백그라운드 초기화 시작...")
|
262 |
-
start_init_time = time.time()
|
263 |
-
|
264 |
-
try:
|
265 |
-
# 1. LLM, STT 인터페이스 재확인 (이미 초기화 시도됨)
|
266 |
-
if llm_interface is None or isinstance(llm_interface, MockComponent):
|
267 |
-
logger.warning("LLM 인터페이스가 초기화되지 않았거나 Mock 객체입니다.")
|
268 |
-
# 필요시 여기서 다시 초기화 시도 가능
|
269 |
-
if stt_client is None or isinstance(stt_client, MockComponent):
|
270 |
-
logger.warning("STT 클라이언트가 초기화되지 않았거나 Mock 객체입니다.")
|
271 |
-
|
272 |
-
# 2. 검색기 초기화
|
273 |
-
logger.info("검색기 초기화 시도 (background)...")
|
274 |
-
retriever = init_retriever() # init_retriever가 base_retriever도 설정
|
275 |
-
|
276 |
-
# 성공 여부 확인 - retriever가 None이 아니면 성공으로 간주
|
277 |
-
if retriever is not None:
|
278 |
-
if not isinstance(retriever, MockComponent):
|
279 |
-
logger.info("검색기 초기화 성공 (background)")
|
280 |
-
else:
|
281 |
-
logger.warning("Mock 검색기를 사용합니다. 일부 기능이 제한될 수 있습니다.")
|
282 |
-
# 중요: 검색기가 Mock 객체이더라도 app_ready_event.set()을 호출하여 로딩 상태를 종료합니다.
|
283 |
-
app_ready_event.set()
|
284 |
-
logger.info("app_ready_event가 True로 설정됨.")
|
285 |
-
else:
|
286 |
-
logger.error("검색기 초기화 실패 (background)")
|
287 |
-
# 중요 수정: 실패 시에도 app_ready_event를 설정하여 로딩 페이지에서 벗어날 수 있도록 함
|
288 |
-
# 이렇게 하면 앱 자체는 실행되어 사용 가능한 상태가 됨
|
289 |
-
app_ready_event.set()
|
290 |
-
logger.warning("검색기 초기화 실패했지만 app_ready_event를 True로 설정하여 앱을 사용 가능한 상태로 만듭니다.")
|
291 |
-
|
292 |
-
except Exception as e:
|
293 |
-
logger.error(f"백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
|
294 |
-
# 오류 발생 시에도 Mock 객체 할당 및 상태 설정 고려
|
295 |
-
if base_retriever is None: base_retriever = MockComponent()
|
296 |
-
if retriever is None: retriever = MockComponent()
|
297 |
-
# 중요 수정: 오류 발생 시에도 app_ready_event를 설정하여 로딩 페이지에서 벗어날 수 있도록 함
|
298 |
-
app_ready_event.set()
|
299 |
-
logger.warning("초기화 오류가 발생했지만 app_ready_event를 True로 설정하여 앱을 사용 가능한 상태로 만듭니다.")
|
300 |
-
|
301 |
-
finally:
|
302 |
-
end_init_time = time.time()
|
303 |
-
logger.info(f"백그라운드 초기화 완료. 소요 시간: {end_init_time - start_init_time:.2f}초")
|
304 |
-
logger.info(f"최종 앱 준비 상태 (app_ready_event.is_set()): {app_ready_event.is_set()}")
|
305 |
-
|
306 |
-
# 백그라운드 스레드 시작
|
307 |
-
init_thread = threading.Thread(target=background_init)
|
308 |
-
init_thread.daemon = True
|
309 |
-
init_thread.start()
|
310 |
-
|
311 |
-
# --- 라우트 등록 ---
|
312 |
-
try:
|
313 |
-
# 기본 RAG 챗봇 라우트 등록
|
314 |
-
register_routes(
|
315 |
-
app=app,
|
316 |
-
login_required=login_required,
|
317 |
-
llm_interface=llm_interface,
|
318 |
-
retriever=retriever,
|
319 |
-
stt_client=stt_client,
|
320 |
-
DocumentProcessor=DocumentProcessor,
|
321 |
-
base_retriever=base_retriever,
|
322 |
-
app_ready_event=app_ready_event,
|
323 |
-
ADMIN_USERNAME=ADMIN_USERNAME,
|
324 |
-
ADMIN_PASSWORD=ADMIN_PASSWORD,
|
325 |
-
DEVICE_SERVER_URL=DEVICE_SERVER_URL
|
326 |
-
)
|
327 |
-
logger.info("기본 챗봇 라우트 등록 완료")
|
328 |
-
|
329 |
-
# 장치 관리 라우트 등록
|
330 |
-
register_device_routes(
|
331 |
-
app=app,
|
332 |
-
login_required=login_required,
|
333 |
-
DEVICE_SERVER_URL=DEVICE_SERVER_URL
|
334 |
-
)
|
335 |
-
logger.info("장치 관리 라우트 등록 완료")
|
336 |
-
except Exception as e:
|
337 |
-
# 라우트 등록 실패는 심각한 문제이므로 Critical 레벨 사용 고려
|
338 |
-
logger.critical(f"라우트 등록 중 치명적 오류 발생: {e}", exc_info=True)
|
339 |
-
# 앱 실행을 중단하거나 최소한의 오류 페이지를 제공하는 로직 추가 가능
|
340 |
-
|
341 |
-
# --- 정적 파일 서빙 ---
|
342 |
-
# STATIC_FOLDER 설정을 사용하여 static 폴더 경로 지정
|
343 |
-
@app.route('/static/<path:path>')
|
344 |
-
def send_static(path):
|
345 |
-
static_folder = app.config.get('STATIC_FOLDER', 'static') # 설정값 또는 기본값 사용
|
346 |
-
# logger.debug(f"Serving static file: {path} from {static_folder}") # 디버깅 시 주석 해제
|
347 |
-
return send_from_directory(static_folder, path)
|
348 |
-
|
349 |
-
|
350 |
-
# --- 요청 처리 훅 ---
|
351 |
-
@app.after_request
|
352 |
-
def after_request_func(response):
|
353 |
-
"""모든 응답에 대해 후처리 수행 (예: 보안 헤더 추가)"""
|
354 |
-
# 예시: response.headers['X-Content-Type-Options'] = 'nosniff'
|
355 |
-
return response
|
356 |
-
|
357 |
-
# 앱 실행 (로컬 테스트용)
|
358 |
-
if __name__ == '__main__':
|
359 |
-
logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
|
360 |
-
# port 번호는 환경 변수 또는 기본값을 사용합니다.
|
361 |
-
port = int(os.environ.get("PORT", 7860)) # 기본 포트 7860 사용
|
362 |
-
logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
|
363 |
-
# debug=True는 개발 중에만 사용하고, 배포 시에는 False로 변경하거나 제거
|
364 |
-
# use_reloader=False 추가하여 자동 리로드 비활성화 (백그라운드 스레드 문제 방지)
|
365 |
-
app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/app_device_routes.py
DELETED
@@ -1,220 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
RAG 검색 챗봇 웹 애플리케이션 - 장치 관리 API 라우트 정의
|
3 |
-
"""
|
4 |
-
|
5 |
-
import os
|
6 |
-
import logging
|
7 |
-
import requests
|
8 |
-
import json
|
9 |
-
from flask import request, jsonify
|
10 |
-
|
11 |
-
# 로거 가져오기
|
12 |
-
logger = logging.getLogger(__name__)
|
13 |
-
|
14 |
-
def register_device_routes(app, login_required, DEVICE_SERVER_URL):
|
15 |
-
"""Flask 애플리케이션에 장치 관리 관련 라우트 등록"""
|
16 |
-
|
17 |
-
@app.route('/api/device/settings', methods=['GET'])
|
18 |
-
@login_required
|
19 |
-
def get_device_settings():
|
20 |
-
"""장치 관리 서버 설정 API - 프론트엔드에서 사용"""
|
21 |
-
logger.info("장치 관리 서버 설정 요청")
|
22 |
-
|
23 |
-
return jsonify({
|
24 |
-
"server_url": DEVICE_SERVER_URL
|
25 |
-
})
|
26 |
-
|
27 |
-
@app.route('/api/device/status', methods=['GET'])
|
28 |
-
@login_required
|
29 |
-
def device_status():
|
30 |
-
"""장치 관리 서버 상태 확인 API"""
|
31 |
-
logger.info("장치 관리 서버 상태 확인 요청")
|
32 |
-
|
33 |
-
# 직접 접속 모드를 사용하는 경우 프록시 처리
|
34 |
-
if not DEVICE_SERVER_URL:
|
35 |
-
logger.info("직접 접속 모드 사용 중 - 프론트엔드에서 처리")
|
36 |
-
return jsonify({
|
37 |
-
"info": "직접 접속 모드 사용 중. 프론트엔드에서 장치 서버에 직접 연결합니다."
|
38 |
-
})
|
39 |
-
|
40 |
-
try:
|
41 |
-
# 장치 관리 서버 상태 확인
|
42 |
-
response = requests.get(f"{DEVICE_SERVER_URL}/api/status", timeout=5)
|
43 |
-
|
44 |
-
if response.status_code == 200:
|
45 |
-
data = response.json()
|
46 |
-
logger.info(f"장치 관리 서버 상태: {data.get('status', 'unknown')}")
|
47 |
-
return jsonify({
|
48 |
-
"success": True,
|
49 |
-
"server_status": data.get("status", "unknown")
|
50 |
-
})
|
51 |
-
else:
|
52 |
-
logger.warning(f"장치 관리 서버 응답 코드: {response.status_code}")
|
53 |
-
return jsonify({
|
54 |
-
"success": False,
|
55 |
-
"error": f"장치 관리 서버가 비정상 응답 코드를 반환했습니다: {response.status_code}"
|
56 |
-
}), 502
|
57 |
-
|
58 |
-
except requests.exceptions.Timeout:
|
59 |
-
logger.error("장치 관리 서버 연결 시간 초과")
|
60 |
-
return jsonify({
|
61 |
-
"success": False,
|
62 |
-
"error": "장치 관리 서버 연결 시간이 초과되었습니다."
|
63 |
-
}), 504
|
64 |
-
|
65 |
-
except requests.exceptions.ConnectionError:
|
66 |
-
logger.error("장치 관리 서버 연결 실패")
|
67 |
-
return jsonify({
|
68 |
-
"success": False,
|
69 |
-
"error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
|
70 |
-
}), 503
|
71 |
-
|
72 |
-
except Exception as e:
|
73 |
-
logger.error(f"장치 관리 서버 상태 확인 중 오류 발생: {e}")
|
74 |
-
return jsonify({
|
75 |
-
"success": False,
|
76 |
-
"error": f"장치 관리 서버 상태 확인 중 오류 발생: {str(e)}"
|
77 |
-
}), 500
|
78 |
-
|
79 |
-
|
80 |
-
@app.route('/api/device/list', methods=['GET'])
|
81 |
-
@login_required
|
82 |
-
def device_list():
|
83 |
-
"""장치 목록 조회 API"""
|
84 |
-
logger.info("장치 목록 조회 요청")
|
85 |
-
|
86 |
-
try:
|
87 |
-
# 장치 목록 조회
|
88 |
-
response = requests.get(f"{DEVICE_SERVER_URL}/api/devices", timeout=5)
|
89 |
-
|
90 |
-
if response.status_code == 200:
|
91 |
-
data = response.json()
|
92 |
-
devices = data.get("devices", [])
|
93 |
-
logger.info(f"장치 목록 조회 성공: {len(devices)}개 장치")
|
94 |
-
return jsonify({
|
95 |
-
"success": True,
|
96 |
-
"devices": devices
|
97 |
-
})
|
98 |
-
else:
|
99 |
-
logger.warning(f"장치 목록 조회 실패: {response.status_code}")
|
100 |
-
return jsonify({
|
101 |
-
"success": False,
|
102 |
-
"error": f"장치 목록 조회 실패: {response.status_code}"
|
103 |
-
}), 502
|
104 |
-
|
105 |
-
except requests.exceptions.Timeout:
|
106 |
-
logger.error("장치 목록 조회 시간 초과")
|
107 |
-
return jsonify({
|
108 |
-
"success": False,
|
109 |
-
"error": "장치 목록 조회 시간이 초과되었습니다."
|
110 |
-
}), 504
|
111 |
-
|
112 |
-
except requests.exceptions.ConnectionError:
|
113 |
-
logger.error("장치 관리 서버 연결 실패")
|
114 |
-
return jsonify({
|
115 |
-
"success": False,
|
116 |
-
"error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
|
117 |
-
}), 503
|
118 |
-
|
119 |
-
except Exception as e:
|
120 |
-
logger.error(f"장치 목록 조회 중 오류 발생: {e}")
|
121 |
-
return jsonify({
|
122 |
-
"success": False,
|
123 |
-
"error": f"장치 목록 조회 중 오류 발생: {str(e)}"
|
124 |
-
}), 500
|
125 |
-
|
126 |
-
|
127 |
-
@app.route('/api/device/programs', methods=['GET'])
|
128 |
-
@login_required
|
129 |
-
def device_programs():
|
130 |
-
"""실행 가능한 프로그램 목록 조회 API"""
|
131 |
-
logger.info("프로그램 목록 조회 요청")
|
132 |
-
|
133 |
-
try:
|
134 |
-
# 프로그램 목록 조회
|
135 |
-
response = requests.get(f"{DEVICE_SERVER_URL}/api/programs", timeout=5)
|
136 |
-
|
137 |
-
if response.status_code == 200:
|
138 |
-
data = response.json()
|
139 |
-
programs = data.get("programs", [])
|
140 |
-
logger.info(f"프로그램 목록 조회 성공: {len(programs)}개 프로그램")
|
141 |
-
return jsonify({
|
142 |
-
"success": True,
|
143 |
-
"programs": programs
|
144 |
-
})
|
145 |
-
else:
|
146 |
-
logger.warning(f"프로그램 목록 조회 실패: {response.status_code}")
|
147 |
-
return jsonify({
|
148 |
-
"success": False,
|
149 |
-
"error": f"프로그램 목록 조회 실패: {response.status_code}"
|
150 |
-
}), 502
|
151 |
-
|
152 |
-
except requests.exceptions.Timeout:
|
153 |
-
logger.error("프로그램 목록 조회 시간 초과")
|
154 |
-
return jsonify({
|
155 |
-
"success": False,
|
156 |
-
"error": "프로그램 목록 조회 시간이 초과되었습니다."
|
157 |
-
}), 504
|
158 |
-
|
159 |
-
except requests.exceptions.ConnectionError:
|
160 |
-
logger.error("장치 관리 서버 연결 실패")
|
161 |
-
return jsonify({
|
162 |
-
"success": False,
|
163 |
-
"error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
|
164 |
-
}), 503
|
165 |
-
|
166 |
-
except Exception as e:
|
167 |
-
logger.error(f"프로그램 목록 조회 중 오류 발생: {e}")
|
168 |
-
return jsonify({
|
169 |
-
"success": False,
|
170 |
-
"error": f"프로그램 목록 조회 중 오류 발생: {str(e)}"
|
171 |
-
}), 500
|
172 |
-
|
173 |
-
|
174 |
-
@app.route('/api/device/programs/<program_id>/execute', methods=['POST'])
|
175 |
-
@login_required
|
176 |
-
def execute_program(program_id):
|
177 |
-
"""프로그램 실행 API"""
|
178 |
-
logger.info(f"프로그램 실행 요청: {program_id}")
|
179 |
-
|
180 |
-
try:
|
181 |
-
# 프로그램 실행
|
182 |
-
response = requests.post(
|
183 |
-
f"{DEVICE_SERVER_URL}/api/programs/{program_id}/execute",
|
184 |
-
json={},
|
185 |
-
timeout=10 # 프로그램 실행에는 더 긴 시간 부여
|
186 |
-
)
|
187 |
-
|
188 |
-
if response.status_code == 200:
|
189 |
-
data = response.json()
|
190 |
-
success = data.get("success", False)
|
191 |
-
message = data.get("message", "")
|
192 |
-
logger.info(f"프로그램 실행 응답: {success}, {message}")
|
193 |
-
return jsonify(data)
|
194 |
-
else:
|
195 |
-
logger.warning(f"프로그램 실행 실패: {response.status_code}")
|
196 |
-
return jsonify({
|
197 |
-
"success": False,
|
198 |
-
"error": f"프로그램 실행 요청 실패: {response.status_code}"
|
199 |
-
}), 502
|
200 |
-
|
201 |
-
except requests.exceptions.Timeout:
|
202 |
-
logger.error("프로그램 실행 요청 시간 초과")
|
203 |
-
return jsonify({
|
204 |
-
"success": False,
|
205 |
-
"error": "프로그램 실행 요청 시간이 초과되었습니다."
|
206 |
-
}), 504
|
207 |
-
|
208 |
-
except requests.exceptions.ConnectionError:
|
209 |
-
logger.error("장치 관리 서버 연결 실패")
|
210 |
-
return jsonify({
|
211 |
-
"success": False,
|
212 |
-
"error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
|
213 |
-
}), 503
|
214 |
-
|
215 |
-
except Exception as e:
|
216 |
-
logger.error(f"프로그램 실행 중 오류 발생: {e}")
|
217 |
-
return jsonify({
|
218 |
-
"success": False,
|
219 |
-
"error": f"프로그램 실행 중 오류 발생: {str(e)}"
|
220 |
-
}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/app_main.py
ADDED
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 웹 애플리케이션 - 메인 파일
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import json
|
7 |
+
import logging
|
8 |
+
import threading
|
9 |
+
import datetime
|
10 |
+
from flask import Flask, request, jsonify, render_template, send_from_directory, session, redirect, url_for
|
11 |
+
from werkzeug.utils import secure_filename
|
12 |
+
from dotenv import load_dotenv
|
13 |
+
from functools import wraps
|
14 |
+
|
15 |
+
# 로거 설정
|
16 |
+
logging.basicConfig(
|
17 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
18 |
+
level=logging.DEBUG
|
19 |
+
)
|
20 |
+
logger = logging.getLogger(__name__)
|
21 |
+
|
22 |
+
# 앱 초기화 관련 모듈 임포트
|
23 |
+
from app.app_revised import (app, app_ready_event, init_success_event,
|
24 |
+
llm_interface, stt_client, base_retriever, retriever,
|
25 |
+
MockComponent, initialization_error, login_required,
|
26 |
+
ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL,
|
27 |
+
DocumentProcessor)
|
28 |
+
|
29 |
+
# 백그라운드 초기화 함수 임포트
|
30 |
+
from app.background_init import background_init
|
31 |
+
|
32 |
+
# 백그라운드 초기화 스레드 함수
|
33 |
+
def init_thread_func():
|
34 |
+
global base_retriever, retriever, initialization_error
|
35 |
+
|
36 |
+
# 백그라운드 초기화 함수 호출
|
37 |
+
updated_base_retriever, updated_retriever, updated_error = background_init(
|
38 |
+
app, llm_interface, stt_client, base_retriever, retriever,
|
39 |
+
app_ready_event, init_success_event, MockComponent, initialization_error
|
40 |
+
)
|
41 |
+
|
42 |
+
# 전역 변수 업데이트
|
43 |
+
base_retriever = updated_base_retriever
|
44 |
+
retriever = updated_retriever
|
45 |
+
|
46 |
+
# 오류 메시지가 있으면 업데이트
|
47 |
+
if updated_error:
|
48 |
+
initialization_error = updated_error
|
49 |
+
logger.warning(f"초기화 오류 메시지 업데이트: {initialization_error}")
|
50 |
+
|
51 |
+
# 백그라운드 초기화 스레드 시작
|
52 |
+
init_thread = threading.Thread(target=init_thread_func)
|
53 |
+
init_thread.daemon = True
|
54 |
+
init_thread.start()
|
55 |
+
|
56 |
+
# 모듈 임포트
|
57 |
+
try:
|
58 |
+
from app.app_routes_improved import register_routes
|
59 |
+
logger.info("라우트 등록 모듈 임포트 성공")
|
60 |
+
except ImportError as e:
|
61 |
+
logger.critical(f"라우트 등록 모듈 임포트 실패: {e}", exc_info=True)
|
62 |
+
|
63 |
+
# 기본 라우트 정의 (오류 시)
|
64 |
+
@app.route('/')
|
65 |
+
def error_index():
|
66 |
+
return render_template('error.html',
|
67 |
+
error="앱 초기화 중 심각한 오류가 발생했습니다. 관리자에게 문의하세요.",
|
68 |
+
details=f"라우트 모듈 임포트 실패: {str(e)}")
|
69 |
+
|
70 |
+
# --- 라우트 등록 ---
|
71 |
+
try:
|
72 |
+
# app_routes_improved.py의 register_routes 함수 호출
|
73 |
+
register_routes(
|
74 |
+
app=app,
|
75 |
+
login_required=login_required,
|
76 |
+
llm_interface=llm_interface,
|
77 |
+
retriever=retriever,
|
78 |
+
stt_client=stt_client,
|
79 |
+
DocumentProcessor=DocumentProcessor,
|
80 |
+
base_retriever=base_retriever,
|
81 |
+
app_ready_event=app_ready_event,
|
82 |
+
init_success_event=init_success_event,
|
83 |
+
initialization_error=initialization_error,
|
84 |
+
ADMIN_USERNAME=ADMIN_USERNAME,
|
85 |
+
ADMIN_PASSWORD=ADMIN_PASSWORD,
|
86 |
+
DEVICE_SERVER_URL=DEVICE_SERVER_URL
|
87 |
+
)
|
88 |
+
logger.info("모든 라우트 등록 완료")
|
89 |
+
except Exception as e:
|
90 |
+
# 라우트 등록 실패는 심각한 문제이므로 Critical 레벨 사용
|
91 |
+
logger.critical(f"라우트 등록 중 치명적 오류 발생: {e}", exc_info=True)
|
92 |
+
|
93 |
+
# 최소한의 오류 페이지를 제공하는 기본 라우트 등록
|
94 |
+
@app.route('/')
|
95 |
+
def error_index():
|
96 |
+
return render_template('error.html',
|
97 |
+
error="앱 초기화 중 심각한 오류가 발생했습니다. 관리자에게 문의하세요.",
|
98 |
+
details=str(e))
|
99 |
+
|
100 |
+
# --- 추가 시스템 상태 API ---
|
101 |
+
@app.route('/api/system/status')
|
102 |
+
def system_status():
|
103 |
+
"""시스템 상태 정보를 반환"""
|
104 |
+
status = {
|
105 |
+
"app_ready": app_ready_event.is_set(),
|
106 |
+
"init_success": init_success_event.is_set(),
|
107 |
+
"retriever_type": type(retriever).__name__ if retriever else "None",
|
108 |
+
"base_retriever_type": type(base_retriever).__name__ if base_retriever else "None",
|
109 |
+
"has_error": initialization_error is not None,
|
110 |
+
"error_message": initialization_error if initialization_error else None,
|
111 |
+
"document_count": len(getattr(base_retriever, 'documents', []))
|
112 |
+
if hasattr(base_retriever, 'documents') else 0,
|
113 |
+
"server_time": datetime.datetime.now().isoformat(),
|
114 |
+
}
|
115 |
+
return jsonify(status)
|
116 |
+
|
117 |
+
# --- 정적 파일 서빙 ---
|
118 |
+
@app.route('/static/<path:path>')
|
119 |
+
def send_static(path):
|
120 |
+
static_folder = app.config.get('STATIC_FOLDER', 'static')
|
121 |
+
return send_from_directory(static_folder, path)
|
122 |
+
|
123 |
+
# --- 요청 처리 훅 ---
|
124 |
+
@app.after_request
|
125 |
+
def after_request_func(response):
|
126 |
+
"""모든 응답에 대해 후처리 수행 (예: 보안 헤더 추가)"""
|
127 |
+
# 보안 헤더 설정 예시
|
128 |
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
129 |
+
return response
|
130 |
+
|
131 |
+
# 앱 실행 (로컬 테스트용)
|
132 |
+
if __name__ == '__main__':
|
133 |
+
logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
|
134 |
+
port = int(os.environ.get("PORT", 7860)) # 기본 포트 7860 사용
|
135 |
+
logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
|
136 |
+
# debug=True는 개발 중에만 사용하고, 배포 시에는 False로 변경하거나 제거
|
137 |
+
# use_reloader=False 추가하여 자동 리로드 비활성화 (백그라운드 스레드 문제 방지)
|
138 |
+
app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)
|
app/app_part2.py
DELETED
@@ -1,209 +0,0 @@
|
|
1 |
-
# --- 임베딩 관련 헬퍼 함수 ---
|
2 |
-
def save_embeddings(base_retriever, file_path):
|
3 |
-
"""임베딩 데이터를 압축하여 파일에 저장"""
|
4 |
-
try:
|
5 |
-
# 저장 디렉토리가 없으면 생성
|
6 |
-
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
7 |
-
|
8 |
-
# 타임스탬프 추가
|
9 |
-
save_data = {
|
10 |
-
'timestamp': datetime.now().isoformat(),
|
11 |
-
'retriever': base_retriever
|
12 |
-
}
|
13 |
-
|
14 |
-
# 압축하여 저장 (용량 줄이기)
|
15 |
-
with gzip.open(file_path, 'wb') as f:
|
16 |
-
pickle.dump(save_data, f)
|
17 |
-
|
18 |
-
logger.info(f"임베딩 데이터를 {file_path}에 압축하여 저장했습니다.")
|
19 |
-
return True
|
20 |
-
except Exception as e:
|
21 |
-
logger.error(f"임베딩 저장 중 오류 발생: {e}")
|
22 |
-
return False
|
23 |
-
|
24 |
-
def load_embeddings(file_path, max_age_days=30):
|
25 |
-
"""저장된 임베딩 데이터를 파일에서 로드"""
|
26 |
-
try:
|
27 |
-
if not os.path.exists(file_path):
|
28 |
-
logger.info(f"저장된 임베딩 파일({file_path})이 없습니다.")
|
29 |
-
return None
|
30 |
-
|
31 |
-
# 압축 파일 로드
|
32 |
-
with gzip.open(file_path, 'rb') as f:
|
33 |
-
data = pickle.load(f)
|
34 |
-
|
35 |
-
# 타임스탬프 확인 (너무 오래된 데이터는 사용하지 않음)
|
36 |
-
saved_time = datetime.fromisoformat(data['timestamp'])
|
37 |
-
age = (datetime.now() - saved_time).days
|
38 |
-
|
39 |
-
if age > max_age_days:
|
40 |
-
logger.info(f"저장된 임베딩이 {age}일로 너무 오래되었습니다. 새로 생성합니다.")
|
41 |
-
return None
|
42 |
-
|
43 |
-
logger.info(f"{file_path}에서 임베딩 데이터를 로드했습니다. (생성일: {saved_time})")
|
44 |
-
return data['retriever']
|
45 |
-
except Exception as e:
|
46 |
-
logger.error(f"임베딩 로드 중 오류 발생: {e}")
|
47 |
-
return None
|
48 |
-
|
49 |
-
def init_retriever():
|
50 |
-
"""검색기 객체 초기화 또는 로드"""
|
51 |
-
global base_retriever, retriever
|
52 |
-
|
53 |
-
# 임베딩 캐시 파일 경로
|
54 |
-
cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
|
55 |
-
|
56 |
-
# 먼저 저장된 임베딩 데이터 로드 시도
|
57 |
-
cached_retriever = load_embeddings(cache_path)
|
58 |
-
|
59 |
-
if cached_retriever:
|
60 |
-
logger.info("캐시된 임베딩 데이터를 성공적으로 로드했습니다.")
|
61 |
-
base_retriever = cached_retriever
|
62 |
-
else:
|
63 |
-
# 캐시된 데이터가 없으면 기존 방식으로 초기화
|
64 |
-
index_path = app.config['INDEX_PATH']
|
65 |
-
|
66 |
-
# VectorRetriever 로드 또는 초기화
|
67 |
-
if os.path.exists(os.path.join(index_path, "documents.json")):
|
68 |
-
try:
|
69 |
-
logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
|
70 |
-
base_retriever = VectorRetriever.load(index_path)
|
71 |
-
logger.info(f"{len(base_retriever.documents) if hasattr(base_retriever, 'documents') else 0}개 문서가 로드되었습니다.")
|
72 |
-
except Exception as e:
|
73 |
-
logger.error(f"인덱스 로드 중 오류 발생: {e}. 새 검색기를 초기화합니다.")
|
74 |
-
base_retriever = VectorRetriever()
|
75 |
-
else:
|
76 |
-
logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
|
77 |
-
base_retriever = VectorRetriever()
|
78 |
-
|
79 |
-
# 데이터 폴더의 문서 로드
|
80 |
-
data_path = app.config['DATA_FOLDER']
|
81 |
-
if (not hasattr(base_retriever, 'documents') or not base_retriever.documents) and os.path.exists(data_path):
|
82 |
-
logger.info(f"{data_path}에서 문서를 로드합니다...")
|
83 |
-
try:
|
84 |
-
docs = DocumentProcessor.load_documents_from_directory(
|
85 |
-
data_path,
|
86 |
-
extensions=[".txt", ".md", ".csv"],
|
87 |
-
recursive=True
|
88 |
-
)
|
89 |
-
if docs and hasattr(base_retriever, 'add_documents'):
|
90 |
-
logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
|
91 |
-
base_retriever.add_documents(docs)
|
92 |
-
|
93 |
-
if hasattr(base_retriever, 'save'):
|
94 |
-
logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
|
95 |
-
try:
|
96 |
-
base_retriever.save(index_path)
|
97 |
-
logger.info("인덱스 저장 완료")
|
98 |
-
|
99 |
-
# 새로 생성된 검색기 캐싱
|
100 |
-
if hasattr(base_retriever, 'documents') and base_retriever.documents:
|
101 |
-
save_embeddings(base_retriever, cache_path)
|
102 |
-
logger.info(f"검색기를 캐시 파일 {cache_path}에 저장 완료")
|
103 |
-
except Exception as e:
|
104 |
-
logger.error(f"인덱스 저장 중 오류 발생: {e}")
|
105 |
-
except Exception as e:
|
106 |
-
logger.error(f"DATA_FOLDER에서 문서 로드 중 오류: {e}")
|
107 |
-
|
108 |
-
# 재순위화 검색기 초기화
|
109 |
-
logger.info("재순위화 검색기를 초기화합니다...")
|
110 |
-
try:
|
111 |
-
# 자체 구현된 재순위화 함수
|
112 |
-
def custom_rerank_fn(query, results):
|
113 |
-
query_terms = set(query.lower().split())
|
114 |
-
for result in results:
|
115 |
-
if isinstance(result, dict) and "text" in result:
|
116 |
-
text = result["text"].lower()
|
117 |
-
term_freq = sum(1 for term in query_terms if term in text)
|
118 |
-
normalized_score = term_freq / (len(text.split()) + 1) * 10
|
119 |
-
result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
|
120 |
-
elif isinstance(result, dict):
|
121 |
-
result["rerank_score"] = result.get("score", 0)
|
122 |
-
results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
|
123 |
-
return results
|
124 |
-
|
125 |
-
# ReRanker 클래스 사용
|
126 |
-
retriever = ReRanker(
|
127 |
-
base_retriever=base_retriever,
|
128 |
-
rerank_fn=custom_rerank_fn,
|
129 |
-
rerank_field="text"
|
130 |
-
)
|
131 |
-
logger.info("재순위화 검색기 초기화 완료")
|
132 |
-
except Exception as e:
|
133 |
-
logger.error(f"재순위화 검색기 초기화 실패: {e}")
|
134 |
-
retriever = base_retriever # 실패 시 기본 검색기 사용
|
135 |
-
|
136 |
-
return retriever
|
137 |
-
|
138 |
-
def background_init():
|
139 |
-
"""백그라운드에서 검색기 초기화 수행"""
|
140 |
-
global app_ready, retriever, base_retriever
|
141 |
-
|
142 |
-
# 즉시 앱 사용 가능 상태로 설정
|
143 |
-
app_ready = True
|
144 |
-
logger.info("앱을 즉시 사용 가능 상태로 설정 (app_ready=True)")
|
145 |
-
|
146 |
-
try:
|
147 |
-
# 기본 검색기 초기화 (보험)
|
148 |
-
if base_retriever is None:
|
149 |
-
base_retriever = MockComponent()
|
150 |
-
if hasattr(base_retriever, 'documents'):
|
151 |
-
base_retriever.documents = []
|
152 |
-
|
153 |
-
# 임시 retriever 설정
|
154 |
-
if retriever is None:
|
155 |
-
retriever = MockComponent()
|
156 |
-
if not hasattr(retriever, 'search'):
|
157 |
-
retriever.search = lambda query, **kwargs: []
|
158 |
-
|
159 |
-
# 캐시된 임베딩 로드 시도
|
160 |
-
cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
|
161 |
-
cached_retriever = load_embeddings(cache_path)
|
162 |
-
|
163 |
-
if cached_retriever:
|
164 |
-
# 캐시된 데이터가 있으면 바로 사용
|
165 |
-
base_retriever = cached_retriever
|
166 |
-
|
167 |
-
# 간단한 재순위화 함수
|
168 |
-
def simple_rerank(query, results):
|
169 |
-
if results:
|
170 |
-
for result in results:
|
171 |
-
if isinstance(result, dict):
|
172 |
-
result["rerank_score"] = result.get("score", 0)
|
173 |
-
results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
|
174 |
-
return results
|
175 |
-
|
176 |
-
# 재순위화 검색기 초기화
|
177 |
-
retriever = ReRanker(
|
178 |
-
base_retriever=base_retriever,
|
179 |
-
rerank_fn=simple_rerank,
|
180 |
-
rerank_field="text"
|
181 |
-
)
|
182 |
-
|
183 |
-
logger.info("캐시된 임베딩으로 검색기 초기화 완료 (빠른 시작)")
|
184 |
-
else:
|
185 |
-
# 캐시된 데이터가 없으면 전체 초기화 진행
|
186 |
-
logger.info("캐시된 임베딩이 없어 전체 초기화 시작")
|
187 |
-
retriever = init_retriever()
|
188 |
-
logger.info("전체 초기화 완료")
|
189 |
-
|
190 |
-
logger.info("앱 초기화 완료 (모든 컴포넌트 준비됨)")
|
191 |
-
except Exception as e:
|
192 |
-
logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
|
193 |
-
# 초기화 실패 시 기본 객체 생성
|
194 |
-
if base_retriever is None:
|
195 |
-
base_retriever = MockComponent()
|
196 |
-
if hasattr(base_retriever, 'documents'):
|
197 |
-
base_retriever.documents = []
|
198 |
-
if retriever is None:
|
199 |
-
retriever = MockComponent()
|
200 |
-
if not hasattr(retriever, 'search'):
|
201 |
-
retriever.search = lambda query, **kwargs: []
|
202 |
-
|
203 |
-
logger.warning("초기화 중 오류가 있지만 앱은 계속 사용 가능합니다.")
|
204 |
-
|
205 |
-
# 백그라운드 스레드 시작
|
206 |
-
init_thread = threading.Thread(target=background_init)
|
207 |
-
init_thread.daemon = True
|
208 |
-
init_thread.start()
|
209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/app_part3.py
DELETED
@@ -1,163 +0,0 @@
|
|
1 |
-
# --- Flask 라우트 정의 ---
|
2 |
-
|
3 |
-
@app.route('/login', methods=['GET', 'POST'])
|
4 |
-
def login():
|
5 |
-
error = None
|
6 |
-
next_url = request.args.get('next')
|
7 |
-
logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
|
8 |
-
logger.info(f"Method: {request.method}")
|
9 |
-
|
10 |
-
if request.method == 'POST':
|
11 |
-
logger.info("로그인 시도 받음")
|
12 |
-
username = request.form.get('username', '')
|
13 |
-
password = request.form.get('password', '')
|
14 |
-
logger.info(f"입력된 사용자명: {username}")
|
15 |
-
logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
|
16 |
-
|
17 |
-
# 환경 변수 또는 기본값과 비교
|
18 |
-
valid_username = ADMIN_USERNAME
|
19 |
-
valid_password = ADMIN_PASSWORD
|
20 |
-
logger.info(f"검증용 사용자명: {valid_username}")
|
21 |
-
logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
|
22 |
-
|
23 |
-
if username == valid_username and password == valid_password:
|
24 |
-
logger.info(f"로그인 성공: {username}")
|
25 |
-
# 세션 설정 전 현재 세션 상태 로깅
|
26 |
-
logger.debug(f"세션 설정 전: {session}")
|
27 |
-
|
28 |
-
# 세션에 로그인 정보 저장
|
29 |
-
session.permanent = True
|
30 |
-
session['logged_in'] = True
|
31 |
-
session['username'] = username
|
32 |
-
session.modified = True
|
33 |
-
|
34 |
-
logger.info(f"세션 설정 후: {session}")
|
35 |
-
logger.info("세션 설정 완료, 리디렉션 시도")
|
36 |
-
|
37 |
-
# 로그인 성공 후 리디렉션
|
38 |
-
redirect_to = next_url or url_for('index')
|
39 |
-
logger.info(f"리디렉션 대상: {redirect_to}")
|
40 |
-
response = redirect(redirect_to)
|
41 |
-
return response
|
42 |
-
else:
|
43 |
-
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
44 |
-
if username != valid_username: logger.warning("사용자명 불일치")
|
45 |
-
if password != valid_password: logger.warning("비밀번호 불일치")
|
46 |
-
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
47 |
-
else:
|
48 |
-
logger.info("로그인 페이지 GET 요청")
|
49 |
-
if 'logged_in' in session:
|
50 |
-
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
51 |
-
return redirect(url_for('index'))
|
52 |
-
|
53 |
-
logger.info("---------- 로그인 페이지 렌더링 ----------")
|
54 |
-
return render_template('login.html', error=error, next=next_url)
|
55 |
-
|
56 |
-
|
57 |
-
@app.route('/logout')
|
58 |
-
def logout():
|
59 |
-
logger.info("-------------- 로그아웃 요청 --------------")
|
60 |
-
logger.info(f"로그아웃 전 세션 상태: {session}")
|
61 |
-
|
62 |
-
if 'logged_in' in session:
|
63 |
-
username = session.get('username', 'unknown')
|
64 |
-
logger.info(f"사용자 {username} 로그아웃 처리 시작")
|
65 |
-
session.pop('logged_in', None)
|
66 |
-
session.pop('username', None)
|
67 |
-
session.modified = True
|
68 |
-
logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
|
69 |
-
else:
|
70 |
-
logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
|
71 |
-
|
72 |
-
logger.info("로그인 페이지로 리디렉션")
|
73 |
-
response = redirect(url_for('login'))
|
74 |
-
return response
|
75 |
-
|
76 |
-
|
77 |
-
@app.route('/')
|
78 |
-
@login_required
|
79 |
-
def index():
|
80 |
-
"""메인 페이지"""
|
81 |
-
global app_ready
|
82 |
-
|
83 |
-
# 앱 준비 상태 확인 - 30초 이상 지났으면 강제로 ready 상태로 변경
|
84 |
-
current_time = datetime.now()
|
85 |
-
start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
|
86 |
-
time_diff = (current_time - start_time).total_seconds()
|
87 |
-
|
88 |
-
if not app_ready and time_diff > 30:
|
89 |
-
logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
|
90 |
-
app_ready = True
|
91 |
-
|
92 |
-
if not app_ready:
|
93 |
-
logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
|
94 |
-
return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
|
95 |
-
|
96 |
-
logger.info("메인 페이지 요청")
|
97 |
-
return render_template('index.html')
|
98 |
-
|
99 |
-
|
100 |
-
@app.route('/api/status')
|
101 |
-
@login_required
|
102 |
-
def app_status():
|
103 |
-
"""앱 초기화 상태 확인 API"""
|
104 |
-
logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
|
105 |
-
return jsonify({"ready": app_ready})
|
106 |
-
|
107 |
-
|
108 |
-
@app.route('/api/llm', methods=['GET', 'POST'])
|
109 |
-
@login_required
|
110 |
-
def llm_api():
|
111 |
-
"""사용 가능한 LLM 목록 및 선택 API"""
|
112 |
-
global llm_interface
|
113 |
-
|
114 |
-
if not app_ready:
|
115 |
-
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
116 |
-
|
117 |
-
if request.method == 'GET':
|
118 |
-
logger.info("LLM 목록 요청")
|
119 |
-
try:
|
120 |
-
current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
|
121 |
-
supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
|
122 |
-
supported_list = [{
|
123 |
-
"name": name, "id": id, "current": id == current_details.get("id")
|
124 |
-
} for name, id in supported_llms_dict.items()]
|
125 |
-
|
126 |
-
return jsonify({
|
127 |
-
"supported_llms": supported_list,
|
128 |
-
"current_llm": current_details
|
129 |
-
})
|
130 |
-
except Exception as e:
|
131 |
-
logger.error(f"LLM 정보 조회 오류: {e}")
|
132 |
-
return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
|
133 |
-
|
134 |
-
elif request.method == 'POST':
|
135 |
-
data = request.get_json()
|
136 |
-
if not data or 'llm_id' not in data:
|
137 |
-
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
138 |
-
|
139 |
-
llm_id = data['llm_id']
|
140 |
-
logger.info(f"LLM 변경 요청: {llm_id}")
|
141 |
-
|
142 |
-
try:
|
143 |
-
if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
|
144 |
-
raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
|
145 |
-
|
146 |
-
if llm_id not in llm_interface.llm_clients:
|
147 |
-
return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
|
148 |
-
|
149 |
-
success = llm_interface.set_llm(llm_id)
|
150 |
-
if success:
|
151 |
-
new_details = llm_interface.get_current_llm_details()
|
152 |
-
logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
|
153 |
-
return jsonify({
|
154 |
-
"success": True,
|
155 |
-
"message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
|
156 |
-
"current_llm": new_details
|
157 |
-
})
|
158 |
-
else:
|
159 |
-
logger.error(f"LLM 변경 실패 (ID: {llm_id})")
|
160 |
-
return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
|
161 |
-
except Exception as e:
|
162 |
-
logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
|
163 |
-
return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/app_revised.py
CHANGED
@@ -1,12 +1,16 @@
|
|
1 |
"""
|
2 |
-
RAG 검색 챗봇 웹 애플리케이션 (
|
3 |
"""
|
4 |
|
5 |
import os
|
|
|
6 |
import logging
|
|
|
7 |
import threading
|
8 |
-
|
9 |
-
|
|
|
|
|
10 |
from dotenv import load_dotenv
|
11 |
from functools import wraps
|
12 |
|
@@ -23,12 +27,12 @@ load_dotenv()
|
|
23 |
# 환경 변수 로드 상태 확인 및 로깅
|
24 |
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
|
25 |
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
26 |
-
DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', '
|
27 |
|
28 |
logger.info(f"==== 환경 변수 로드 상태 ====")
|
29 |
logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
|
30 |
logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
|
31 |
-
logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL}")
|
32 |
|
33 |
# 환경 변수가 없으면 기본값 설정
|
34 |
if not ADMIN_USERNAME:
|
@@ -39,203 +43,114 @@ if not ADMIN_PASSWORD:
|
|
39 |
ADMIN_PASSWORD = 'rag12345'
|
40 |
logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
|
41 |
|
42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
|
44 |
-
# --- 로컬 모듈 임포트 ---
|
45 |
try:
|
46 |
from utils.vito_stt import VitoSTT
|
47 |
from utils.llm_interface import LLMInterface
|
48 |
from utils.document_processor import DocumentProcessor
|
49 |
from retrieval.vector_retriever import VectorRetriever
|
50 |
from retrieval.reranker import ReRanker
|
|
|
|
|
|
|
|
|
51 |
except ImportError as e:
|
52 |
-
logger.error(f"로컬 모듈 임포트 실패: {e}.
|
|
|
53 |
VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
# --- 로컬 모듈 임포트 끝 ---
|
55 |
|
56 |
-
|
57 |
# Flask 앱 초기화
|
58 |
app = Flask(__name__)
|
59 |
|
60 |
# 세션 설정
|
61 |
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345')
|
62 |
-
|
63 |
-
# --- 세션 쿠키 설정 ---
|
64 |
app.config['SESSION_COOKIE_SECURE'] = True
|
65 |
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
66 |
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
67 |
app.config['SESSION_COOKIE_DOMAIN'] = None
|
68 |
app.config['SESSION_COOKIE_PATH'] = '/'
|
69 |
-
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=1)
|
70 |
-
# --- 세션 쿠키 설정 끝 ---
|
71 |
|
72 |
# 최대 파일 크기 설정 (10MB)
|
73 |
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
74 |
# 애플리케이션 파일 기준 상대 경로 설정
|
75 |
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
76 |
app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
|
77 |
-
app
|
78 |
-
app.config['
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
|
80 |
# 필요한 폴더 생성
|
81 |
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
82 |
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
83 |
os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
|
84 |
|
85 |
-
#
|
86 |
-
|
87 |
-
|
88 |
-
stt_client = VitoSTT()
|
89 |
-
except NameError:
|
90 |
-
logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
|
91 |
-
llm_interface = MockComponent()
|
92 |
-
stt_client = MockComponent()
|
93 |
|
|
|
|
|
|
|
94 |
base_retriever = None
|
95 |
retriever = None
|
96 |
-
app_ready
|
97 |
-
#
|
98 |
-
|
99 |
-
|
100 |
-
# --- 인증 데코레이터 ---
|
101 |
-
def login_required(f):
|
102 |
-
@wraps(f)
|
103 |
-
def decorated_function(*args, **kwargs):
|
104 |
-
from flask import request, session, redirect, url_for
|
105 |
-
|
106 |
-
logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
|
107 |
-
logger.info(f"현재 플라스크 세션 객체: {session}")
|
108 |
-
logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
|
109 |
-
logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
|
110 |
-
|
111 |
-
# Flask 세션에 'logged_in' 키가 있는지 직접 확인
|
112 |
-
if 'logged_in' not in session:
|
113 |
-
logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
|
114 |
-
return redirect(url_for('login', next=request.url))
|
115 |
-
|
116 |
-
logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
|
117 |
-
return f(*args, **kwargs)
|
118 |
-
return decorated_function
|
119 |
-
# --- 인증 데코레이터 끝 ---
|
120 |
-
|
121 |
-
|
122 |
-
# --- 정적 파일 서빙 ---
|
123 |
-
@app.route('/static/<path:path>')
|
124 |
-
def send_static(path):
|
125 |
-
return send_from_directory('static', path)
|
126 |
-
|
127 |
-
|
128 |
-
# --- 백그라운드 초기화 함수 ---
|
129 |
-
def background_init():
|
130 |
-
"""백그라운드에서 검색기 초기화 수행"""
|
131 |
-
global app_ready, retriever, base_retriever
|
132 |
-
|
133 |
-
# 즉시 앱 사용 가능 상태로 설정
|
134 |
-
app_ready = True
|
135 |
-
logger.info("앱을 즉시 사용 가능 상태로 설정 (app_ready=True)")
|
136 |
-
|
137 |
-
try:
|
138 |
-
from app.init_retriever import init_retriever
|
139 |
-
|
140 |
-
# 기본 검색기 초기화 (보험)
|
141 |
-
if base_retriever is None:
|
142 |
-
base_retriever = MockComponent()
|
143 |
-
if hasattr(base_retriever, 'documents'):
|
144 |
-
base_retriever.documents = []
|
145 |
-
|
146 |
-
# 임시 retriever 설정
|
147 |
-
if retriever is None:
|
148 |
-
retriever = MockComponent()
|
149 |
-
if not hasattr(retriever, 'search'):
|
150 |
-
retriever.search = lambda query, **kwargs: []
|
151 |
-
|
152 |
-
# 임베딩 캐시 파일 경로
|
153 |
-
cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
|
154 |
-
|
155 |
-
# 캐시된 임베딩 로드 시도
|
156 |
-
try:
|
157 |
-
from app.init_retriever import load_embeddings
|
158 |
-
cached_retriever = load_embeddings(cache_path)
|
159 |
-
|
160 |
-
if cached_retriever:
|
161 |
-
# 캐시된 데이터가 있으면 바로 사용
|
162 |
-
base_retriever = cached_retriever
|
163 |
-
|
164 |
-
# 재순위화 검색기 초기화
|
165 |
-
retriever = ReRanker(
|
166 |
-
base_retriever=base_retriever,
|
167 |
-
rerank_fn=lambda query, results: results,
|
168 |
-
rerank_field="text"
|
169 |
-
)
|
170 |
-
|
171 |
-
logger.info("캐시된 임베딩으로 검색기 초기화 완료 (빠른 시작)")
|
172 |
-
else:
|
173 |
-
# 캐시된 데이터가 없으면 전체 초기화 진행
|
174 |
-
logger.info("캐시된 임베딩이 없어 전체 초기화 시작")
|
175 |
-
retriever = init_retriever(app, base_retriever, retriever, ReRanker)
|
176 |
-
logger.info("전체 초기화 완료")
|
177 |
-
except ImportError:
|
178 |
-
logger.warning("임베딩 캐시 모듈을 찾을 수 없습니다. 전체 초기화를 진행합니다.")
|
179 |
-
retriever = init_retriever(app, base_retriever, retriever, ReRanker)
|
180 |
-
|
181 |
-
logger.info("앱 초기화 완료 (모든 컴포넌트 준비됨)")
|
182 |
-
except Exception as e:
|
183 |
-
logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
|
184 |
-
# 초기화 실패 시 기본 객체 생성
|
185 |
-
if base_retriever is None:
|
186 |
-
base_retriever = MockComponent()
|
187 |
-
if hasattr(base_retriever, 'documents'):
|
188 |
-
base_retriever.documents = []
|
189 |
-
if retriever is None:
|
190 |
-
retriever = MockComponent()
|
191 |
-
if not hasattr(retriever, 'search'):
|
192 |
-
retriever.search = lambda query, **kwargs: []
|
193 |
-
|
194 |
-
logger.warning("초기화 중 오류가 있지만 앱은 계속 사용 가능합니다.")
|
195 |
-
|
196 |
-
|
197 |
-
# --- 라우트 등록 ---
|
198 |
-
def register_all_routes():
|
199 |
-
try:
|
200 |
-
# 기본 라우트 등록
|
201 |
-
from app.app_routes import register_routes
|
202 |
-
register_routes(
|
203 |
-
app, login_required, llm_interface, retriever, stt_client,
|
204 |
-
DocumentProcessor, base_retriever, app_ready,
|
205 |
-
ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL
|
206 |
-
)
|
207 |
-
|
208 |
-
# 장치 관리 라우트 등록
|
209 |
-
from app.app_device_routes import register_device_routes
|
210 |
-
register_device_routes(app, login_required, DEVICE_SERVER_URL)
|
211 |
-
|
212 |
-
logger.info("모든 라우트 등록 완료")
|
213 |
-
except ImportError as e:
|
214 |
-
logger.error(f"라우트 모듈 임포트 실패: {e}")
|
215 |
-
except Exception as e:
|
216 |
-
logger.error(f"라우트 등록 중 오류 발생: {e}", exc_info=True)
|
217 |
-
|
218 |
-
|
219 |
-
# --- 앱 초기화 및 실행 ---
|
220 |
-
def initialize_app():
|
221 |
-
# 백그라운드 초기화 스레드 시작
|
222 |
-
init_thread = threading.Thread(target=background_init)
|
223 |
-
init_thread.daemon = True
|
224 |
-
init_thread.start()
|
225 |
-
|
226 |
-
# 라우트 등록
|
227 |
-
register_all_routes()
|
228 |
-
|
229 |
-
logger.info("앱 초기화 완료")
|
230 |
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
1 |
"""
|
2 |
+
RAG 검색 챗봇 웹 애플리케이션 (초기화 로직 개선)
|
3 |
"""
|
4 |
|
5 |
import os
|
6 |
+
import json
|
7 |
import logging
|
8 |
+
import tempfile
|
9 |
import threading
|
10 |
+
import datetime
|
11 |
+
import time
|
12 |
+
from flask import Flask, request, jsonify, render_template, send_from_directory, session, redirect, url_for
|
13 |
+
from werkzeug.utils import secure_filename
|
14 |
from dotenv import load_dotenv
|
15 |
from functools import wraps
|
16 |
|
|
|
27 |
# 환경 변수 로드 상태 확인 및 로깅
|
28 |
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
|
29 |
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
30 |
+
DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', '')
|
31 |
|
32 |
logger.info(f"==== 환경 변수 로드 상태 ====")
|
33 |
logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
|
34 |
logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
|
35 |
+
logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL or '설정되지 않음 (프론트엔드에서 자동 설정)'}")
|
36 |
|
37 |
# 환경 변수가 없으면 기본값 설정
|
38 |
if not ADMIN_USERNAME:
|
|
|
43 |
ADMIN_PASSWORD = 'rag12345'
|
44 |
logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
|
45 |
|
46 |
+
# --- 로컬 모듈 임포트 시작 ---
|
47 |
+
# MockComponent 정의 (임포트 실패 시 대체)
|
48 |
+
class MockComponent:
|
49 |
+
def __init__(self):
|
50 |
+
self.is_mock = True
|
51 |
+
logger.warning("MockComponent 인스턴스 생성됨")
|
52 |
+
|
53 |
+
def search(self, query, top_k=5, first_stage_k=None):
|
54 |
+
"""빈 검색 결과를 반환합니다."""
|
55 |
+
logger.warning(f"MockComponent.search 호출됨 (쿼리: {query[:30]}...)")
|
56 |
+
return []
|
57 |
+
|
58 |
+
def __getattr__(self, name):
|
59 |
+
# Mock 객체의 어떤 속성이나 메소드 호출 시 경고 로그 출력 및 기본값 반환
|
60 |
+
logger.warning(f"MockComponent에서 '{name}' 접근 시도됨 (실제 모듈 로드 실패)")
|
61 |
+
# 메소드 호출 시에는 아무것도 안 하는 함수 반환
|
62 |
+
if name in ['add_documents', 'save', 'transcribe_audio', 'rag_generate', 'set_llm',
|
63 |
+
'get_current_llm_details', 'prepare_rag_context', 'csv_to_documents',
|
64 |
+
'text_to_documents', 'load_documents_from_directory']:
|
65 |
+
return lambda *args, **kwargs: logger.warning(f"Mocked method '{name}' called") or None
|
66 |
+
# 속성 접근 시에는 None 반환
|
67 |
+
return None
|
68 |
+
|
69 |
+
# 초기화 상태 및 에러 정보를 저장할 전역 변수
|
70 |
+
initialization_error = None
|
71 |
|
|
|
72 |
try:
|
73 |
from utils.vito_stt import VitoSTT
|
74 |
from utils.llm_interface import LLMInterface
|
75 |
from utils.document_processor import DocumentProcessor
|
76 |
from retrieval.vector_retriever import VectorRetriever
|
77 |
from retrieval.reranker import ReRanker
|
78 |
+
from app.app_routes import register_routes
|
79 |
+
from app.app_device_routes import register_device_routes
|
80 |
+
MODULE_LOAD_SUCCESS = True
|
81 |
+
logger.info("모든 필수 모듈을 성공적으로 임포트했습니다.")
|
82 |
except ImportError as e:
|
83 |
+
logger.error(f"로컬 모듈 임포트 실패: {e}. Mock 객체를 사용합니다.")
|
84 |
+
initialization_error = f"모듈 임포트 실패: {str(e)}"
|
85 |
VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
|
86 |
+
MODULE_LOAD_SUCCESS = False
|
87 |
+
# 임시로 빈 함수 정의 (앱 실행은 되도록)
|
88 |
+
def register_routes(*args, **kwargs):
|
89 |
+
logger.error("register_routes 임포트 실패")
|
90 |
+
def register_device_routes(*args, **kwargs):
|
91 |
+
logger.error("register_device_routes 임포트 실패")
|
92 |
# --- 로컬 모듈 임포트 끝 ---
|
93 |
|
|
|
94 |
# Flask 앱 초기화
|
95 |
app = Flask(__name__)
|
96 |
|
97 |
# 세션 설정
|
98 |
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345')
|
|
|
|
|
99 |
app.config['SESSION_COOKIE_SECURE'] = True
|
100 |
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
101 |
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
102 |
app.config['SESSION_COOKIE_DOMAIN'] = None
|
103 |
app.config['SESSION_COOKIE_PATH'] = '/'
|
104 |
+
app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1)
|
|
|
105 |
|
106 |
# 최대 파일 크기 설정 (10MB)
|
107 |
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
108 |
# 애플리케이션 파일 기준 상대 경로 설정
|
109 |
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
|
110 |
+
# static 폴더 경로 설정 (app 폴더 내부의 static)
|
111 |
+
app.config['STATIC_FOLDER'] = os.path.join(APP_ROOT, 'static')
|
112 |
app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
|
113 |
+
# data 및 index 경로는 app 폴더 외부로 설정 (프로젝트 루트 기준)
|
114 |
+
app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(APP_ROOT), 'data')
|
115 |
+
app.config['INDEX_PATH'] = os.path.join(os.path.dirname(APP_ROOT), 'data', 'index')
|
116 |
+
|
117 |
+
# 로그에 설정값 출력
|
118 |
+
logger.info(f"애플리케이션 경로: {APP_ROOT}")
|
119 |
+
logger.info(f"Static 폴더 경로: {app.config['STATIC_FOLDER']}")
|
120 |
+
logger.info(f"데이터 폴더 경로: {app.config['DATA_FOLDER']}")
|
121 |
+
logger.info(f"인덱스 경로: {app.config['INDEX_PATH']}")
|
122 |
|
123 |
# 필요한 폴더 생성
|
124 |
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
125 |
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
126 |
os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
|
127 |
|
128 |
+
# 허용되는 오디오/문서 파일 확장자
|
129 |
+
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
130 |
+
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
|
|
|
|
|
|
|
|
|
|
131 |
|
132 |
+
# --- 전역 객체 초기화 ---
|
133 |
+
llm_interface = None
|
134 |
+
stt_client = None
|
135 |
base_retriever = None
|
136 |
retriever = None
|
137 |
+
# app_ready 플래그 대신 threading.Event 사용
|
138 |
+
app_ready_event = threading.Event() # 초기 상태: False (set() 호출 전까지)
|
139 |
+
init_success_event = threading.Event() # 초기화 성공 여부를 추적하는 이벤트
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
140 |
|
141 |
+
# --- 전역 객체 초기화 (try-except로 감싸기) ---
|
142 |
+
try:
|
143 |
+
if MODULE_LOAD_SUCCESS: # 모듈 로드 성공 시에만 실제 초기화 시도
|
144 |
+
logger.info("LLM 및 STT 인터페이스 초기화 시도...")
|
145 |
+
llm_interface = LLMInterface(default_llm="openai")
|
146 |
+
stt_client = VitoSTT()
|
147 |
+
logger.info("LLM 및 STT 인터페이스 초기화 성공")
|
148 |
+
else: # 실패 시 Mock 객체 사용
|
149 |
+
logger.warning("모듈 로드 실패로 LLM 및 STT를 Mock 객체로 초기화")
|
150 |
+
llm_interface = MockComponent()
|
151 |
+
stt_client = MockComponent()
|
152 |
+
except Exception as e:
|
153 |
+
logger.error(f"LLM 또는 STT 인터페이스 초기화 중 오류 발생: {e}", exc_info=True)
|
154 |
+
initialization_error = f"인터페이스 초기화 실패: {str(e)}"
|
155 |
+
llm_interface = MockComponent() # 오류 시 Mock 객체 할당
|
156 |
+
stt_client = MockComponent() # 오류 시 Mock 객체 할당
|
app/app_routes.py
DELETED
@@ -1,774 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
RAG 검색 챗봇 웹 애플리케이션 - API 라우트 정의 (TypeError 재수정)
|
3 |
-
"""
|
4 |
-
|
5 |
-
import os
|
6 |
-
import json
|
7 |
-
import logging
|
8 |
-
import tempfile
|
9 |
-
import requests
|
10 |
-
import time # 앱 시작 시간 기록 위해 추가
|
11 |
-
import threading # threading.Event 사용 위해 추가
|
12 |
-
from flask import request, jsonify, render_template, send_from_directory, session, redirect, url_for
|
13 |
-
from datetime import datetime
|
14 |
-
from werkzeug.utils import secure_filename
|
15 |
-
|
16 |
-
# 로거 가져오기
|
17 |
-
logger = logging.getLogger(__name__)
|
18 |
-
|
19 |
-
# 앱 시작 시간 기록 (모듈 로드 시점)
|
20 |
-
APP_START_TIME = time.time()
|
21 |
-
|
22 |
-
# !! 중요: 함수 정의에서 app_ready_flag 대신 app_ready_event를 받도록 수정 !!
|
23 |
-
def register_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor, base_retriever, app_ready_event, ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL):
|
24 |
-
"""Flask 애플리케이션에 기본 라우트 등록"""
|
25 |
-
|
26 |
-
# 헬퍼 함수 (변경 없음)
|
27 |
-
def allowed_audio_file(filename):
|
28 |
-
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
29 |
-
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
30 |
-
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
31 |
-
|
32 |
-
def allowed_doc_file(filename):
|
33 |
-
"""파일이 허용된 문서 확장자를 가지는지 확인"""
|
34 |
-
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
35 |
-
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
36 |
-
|
37 |
-
# --- 로그인/로그아웃 라우트 ---
|
38 |
-
@app.route('/login', methods=['GET', 'POST'])
|
39 |
-
def login():
|
40 |
-
error = None
|
41 |
-
next_url = request.args.get('next')
|
42 |
-
logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
|
43 |
-
logger.info(f"Method: {request.method}")
|
44 |
-
|
45 |
-
if request.method == 'POST':
|
46 |
-
logger.info("로그인 시도 받음")
|
47 |
-
username = request.form.get('username', '')
|
48 |
-
password = request.form.get('password', '')
|
49 |
-
logger.info(f"입력된 사용자명: {username}")
|
50 |
-
|
51 |
-
valid_username = ADMIN_USERNAME
|
52 |
-
valid_password = ADMIN_PASSWORD
|
53 |
-
logger.info(f"검증용 사용자명: {valid_username}")
|
54 |
-
|
55 |
-
if username == valid_username and password == valid_password:
|
56 |
-
logger.info(f"로그인 성공: {username}")
|
57 |
-
session.permanent = True
|
58 |
-
session['logged_in'] = True
|
59 |
-
session['username'] = username
|
60 |
-
logger.info(f"세션 설정 완료: {session}")
|
61 |
-
redirect_to = next_url or url_for('index')
|
62 |
-
logger.info(f"리디렉션 대상: {redirect_to}")
|
63 |
-
response = redirect(redirect_to)
|
64 |
-
logger.debug(f"로그인 응답 헤더 (Set-Cookie 확인): {response.headers.getlist('Set-Cookie')}")
|
65 |
-
return response
|
66 |
-
else:
|
67 |
-
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
68 |
-
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
69 |
-
else: # GET 요청
|
70 |
-
logger.info("로그인 페이지 GET 요청")
|
71 |
-
if session.get('logged_in'):
|
72 |
-
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
73 |
-
return redirect(url_for('index'))
|
74 |
-
|
75 |
-
logger.info("---------- 로그인 페이지 렌더링 ----------")
|
76 |
-
return render_template('login.html', error=error, next=next_url)
|
77 |
-
|
78 |
-
|
79 |
-
@app.route('/logout')
|
80 |
-
def logout():
|
81 |
-
"""로그아웃 처리"""
|
82 |
-
username = session.get('username', 'unknown')
|
83 |
-
if session.pop('logged_in', None):
|
84 |
-
session.pop('username', None)
|
85 |
-
logger.info(f"사용자 {username} 로그아웃 처리 완료. 현재 세션: {session}")
|
86 |
-
else:
|
87 |
-
logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
|
88 |
-
|
89 |
-
logger.info("로그인 페이지로 리디렉션")
|
90 |
-
response = redirect(url_for('login'))
|
91 |
-
logger.debug(f"로그아웃 응답 헤더 (Set-Cookie 확인): {response.headers.getlist('Set-Cookie')}")
|
92 |
-
return response
|
93 |
-
|
94 |
-
# --- 메인 페이지 및 상태 확인 (app_ready_event 사용) ---
|
95 |
-
@app.route('/')
|
96 |
-
@login_required
|
97 |
-
def index():
|
98 |
-
"""메인 페이지"""
|
99 |
-
# app_ready_event가 Event 객체인지 확인하고 상태 가져오기
|
100 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False # 기본값 False
|
101 |
-
|
102 |
-
time_elapsed = time.time() - APP_START_TIME
|
103 |
-
|
104 |
-
if not is_ready:
|
105 |
-
logger.info(f"앱이 아직 준비되지 않아 로딩 페이지 표시 (경과 시간: {time_elapsed:.1f}초)")
|
106 |
-
# loading.html 템플릿이 있다고 가정
|
107 |
-
return render_template('loading.html') # 200 OK와 로딩 페이지
|
108 |
-
|
109 |
-
logger.info("메인 페이지 요청")
|
110 |
-
# index.html 템플���이 있다고 가정
|
111 |
-
return render_template('index.html')
|
112 |
-
|
113 |
-
|
114 |
-
@app.route('/api/status')
|
115 |
-
@login_required
|
116 |
-
def app_status():
|
117 |
-
"""앱 초기화 상태 확인 API"""
|
118 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
119 |
-
logger.info(f"앱 상태 확인 요청: {'Ready' if is_ready else 'Not Ready'}")
|
120 |
-
return jsonify({"ready": is_ready})
|
121 |
-
|
122 |
-
# --- LLM API ---
|
123 |
-
@app.route('/api/llm', methods=['GET', 'POST'])
|
124 |
-
@login_required
|
125 |
-
def llm_api():
|
126 |
-
"""사용 가능한 LLM 목록 및 선택 API"""
|
127 |
-
# is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
128 |
-
# LLM 목록 조회는 초기화 중에도 가능하도록 허용
|
129 |
-
|
130 |
-
if request.method == 'GET':
|
131 |
-
logger.info("LLM 목록 요청")
|
132 |
-
try:
|
133 |
-
# 객체 및 속성 확인 강화
|
134 |
-
if llm_interface is None or not hasattr(llm_interface, 'get_current_llm_details') or not hasattr(llm_interface, 'SUPPORTED_LLMS'):
|
135 |
-
logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 속성이 없습니다.")
|
136 |
-
return jsonify({"error": "LLM 인터페이스 오류"}), 500
|
137 |
-
|
138 |
-
current_details = llm_interface.get_current_llm_details()
|
139 |
-
supported_llms_dict = llm_interface.SUPPORTED_LLMS
|
140 |
-
supported_list = [{
|
141 |
-
"name": name, "id": id, "current": id == current_details.get("id")
|
142 |
-
} for name, id in supported_llms_dict.items()]
|
143 |
-
|
144 |
-
return jsonify({
|
145 |
-
"supported_llms": supported_list,
|
146 |
-
"current_llm": current_details
|
147 |
-
})
|
148 |
-
except Exception as e:
|
149 |
-
logger.error(f"LLM 정보 조회 오류: {e}", exc_info=True)
|
150 |
-
return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
|
151 |
-
|
152 |
-
elif request.method == 'POST':
|
153 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
154 |
-
if not is_ready: # LLM 변경은 앱 준비 완료 후 가능
|
155 |
-
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
156 |
-
|
157 |
-
data = request.get_json()
|
158 |
-
if not data or 'llm_id' not in data:
|
159 |
-
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
160 |
-
|
161 |
-
llm_id = data['llm_id']
|
162 |
-
logger.info(f"LLM 변경 요청: {llm_id}")
|
163 |
-
|
164 |
-
try:
|
165 |
-
# 객체 및 속성/메소드 확인 강화
|
166 |
-
if llm_interface is None or not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients') or not hasattr(llm_interface, 'get_current_llm_details'):
|
167 |
-
logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 속성/메소드가 없습니다.")
|
168 |
-
return jsonify({"error": "LLM 인터페이스 오류"}), 500
|
169 |
-
|
170 |
-
if llm_id not in llm_interface.llm_clients:
|
171 |
-
return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
|
172 |
-
|
173 |
-
success = llm_interface.set_llm(llm_id)
|
174 |
-
if success:
|
175 |
-
new_details = llm_interface.get_current_llm_details()
|
176 |
-
logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
|
177 |
-
return jsonify({
|
178 |
-
"success": True,
|
179 |
-
"message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
|
180 |
-
"current_llm": new_details
|
181 |
-
})
|
182 |
-
else:
|
183 |
-
logger.error(f"LLM 변경 실패 (ID: {llm_id})")
|
184 |
-
return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
|
185 |
-
except Exception as e:
|
186 |
-
logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
|
187 |
-
return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
|
188 |
-
|
189 |
-
# --- Chat API ---
|
190 |
-
@app.route('/api/chat', methods=['POST'])
|
191 |
-
@login_required
|
192 |
-
def chat():
|
193 |
-
"""텍스트 기반 채봇 API"""
|
194 |
-
# 함수 내에서는 전역 retriever 변수를 직접 사용하고 수정하지 않음
|
195 |
-
# nonlocal 키워드를 사용하여 외부 스코프의 retriever 변수를 참조
|
196 |
-
nonlocal retriever
|
197 |
-
|
198 |
-
try:
|
199 |
-
# 앱이 준비되었는지 확인
|
200 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
201 |
-
if not is_ready:
|
202 |
-
logger.warning("앱이 아직 초기화 중입니다.")
|
203 |
-
return jsonify({
|
204 |
-
"error": "앱 초기화 중...",
|
205 |
-
"answer": "죄송합니다. 시스템이 아직 준비 중입니다.",
|
206 |
-
"sources": []
|
207 |
-
}), 200 # 503 대신 200으로 변���하여 앱이 정상 응답하도록 함
|
208 |
-
|
209 |
-
data = request.get_json()
|
210 |
-
if not data or 'query' not in data:
|
211 |
-
return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
|
212 |
-
|
213 |
-
query = data['query']
|
214 |
-
logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
|
215 |
-
|
216 |
-
# 검색 엔진 처리 부분 수정
|
217 |
-
search_results = []
|
218 |
-
search_warning = None
|
219 |
-
try:
|
220 |
-
# retriever 상태 검증 - 여기서 retriever 변수를 수정하지 않도록 주의
|
221 |
-
if retriever is None:
|
222 |
-
logger.warning("Retriever가 초기화되지 않았습니다.")
|
223 |
-
# retriever가 None인 경우 경고만 기록하고 수정하지 않음
|
224 |
-
search_warning = "검색 기능이 아직 준비되지 않았습니다."
|
225 |
-
elif hasattr(retriever, 'is_mock') and retriever.is_mock:
|
226 |
-
logger.info("Mock Retriever 사용 중 - 검색 결과 없음.")
|
227 |
-
# 상태 기록만 하고 수정하지 않음
|
228 |
-
search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다."
|
229 |
-
elif not hasattr(retriever, 'search'):
|
230 |
-
logger.warning("Retriever에 search 메소드가 없습니다.")
|
231 |
-
# 상태 기록만 하고 수정하지 않음
|
232 |
-
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
233 |
-
else:
|
234 |
-
try:
|
235 |
-
logger.info(f"검색 수행: {query[:50]}...")
|
236 |
-
# 오류 시 빈 결과를 반환하는 추가 try-except 블록
|
237 |
-
# retriever가 유효하고 search 메소드가 있는 경우에만 검색 실행
|
238 |
-
if retriever is not None and hasattr(retriever, 'search'):
|
239 |
-
try:
|
240 |
-
search_results = retriever.search(query, top_k=5, first_stage_k=6)
|
241 |
-
except Exception as search_err:
|
242 |
-
logger.error(f"retriever.search() 호출 중 오류 발생: {search_err}", exc_info=True)
|
243 |
-
search_results = []
|
244 |
-
search_warning = f"검색 중 오류 발생: {str(search_err)}"
|
245 |
-
else:
|
246 |
-
logger.warning("검색을 실행할 수 없습니다: retriever가 None이거나 search 메소드가 없습니다.")
|
247 |
-
search_results = []
|
248 |
-
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
249 |
-
# except Exception as search_err:
|
250 |
-
# logger.error(f"retriever.search() 호출 중 오류 발생: {search_err}", exc_info=True)
|
251 |
-
# search_results = []
|
252 |
-
# search_warning = f"검색 중 오류 발생: {str(search_err)}"
|
253 |
-
|
254 |
-
if not search_results:
|
255 |
-
logger.info("검색 결과가 없습니다.")
|
256 |
-
else:
|
257 |
-
logger.info(f"검색 결과: {len(search_results)}개 항목")
|
258 |
-
except Exception as e:
|
259 |
-
logger.error(f"검색 처리 중 예상치 못한 오류: {e}", exc_info=True)
|
260 |
-
search_results = []
|
261 |
-
search_warning = f"검색 처리 중 오류 발생: {str(e)}"
|
262 |
-
except Exception as e:
|
263 |
-
logger.error(f"검색 중 오류 발생: {str(e)}", exc_info=True)
|
264 |
-
search_results = []
|
265 |
-
search_warning = f"검색 중 오류 발생: {str(e)}"
|
266 |
-
|
267 |
-
# LLM 응답 생성
|
268 |
-
try:
|
269 |
-
# DocumentProcessor 객체 및 메소드 확인
|
270 |
-
context = ""
|
271 |
-
if search_results:
|
272 |
-
if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
273 |
-
logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.")
|
274 |
-
else:
|
275 |
-
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
276 |
-
logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)")
|
277 |
-
|
278 |
-
# LLM 인터페이스 객체 및 메소드 확인
|
279 |
-
llm_id = data.get('llm_id', None)
|
280 |
-
|
281 |
-
if not context:
|
282 |
-
if search_warning:
|
283 |
-
logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}")
|
284 |
-
answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})"
|
285 |
-
else:
|
286 |
-
logger.info("컨텍스트 없이 기본 응답 생성")
|
287 |
-
answer = "죄송���니다. 관련 정보를 찾을 수 없습니다."
|
288 |
-
else:
|
289 |
-
if llm_interface is None or not hasattr(llm_interface, 'rag_generate'):
|
290 |
-
logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.")
|
291 |
-
answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다."
|
292 |
-
else:
|
293 |
-
# LLM 호출 시 검색 경고 처리 추가
|
294 |
-
if search_warning:
|
295 |
-
# 경고 메시지를 쿼리에 추가하는 대신 내부적으로 처리 (콘텐츠만 전달)
|
296 |
-
logger.info(f"검색 경고 있음: {search_warning}")
|
297 |
-
# 원래 쿼리만 사용
|
298 |
-
modified_query = query
|
299 |
-
else:
|
300 |
-
modified_query = query
|
301 |
-
|
302 |
-
try:
|
303 |
-
answer = llm_interface.rag_generate(modified_query, context, llm_id=llm_id)
|
304 |
-
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
305 |
-
|
306 |
-
# 검색 경고가 있을 경우, 응답 앞에 경고 메시지 추가 없이 그대로 반환
|
307 |
-
except Exception as llm_err:
|
308 |
-
logger.error(f"LLM 호출 중 오류: {llm_err}", exc_info=True)
|
309 |
-
answer = f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(llm_err)}"
|
310 |
-
|
311 |
-
# 소스 정보 추출
|
312 |
-
sources = []
|
313 |
-
if search_results:
|
314 |
-
for result in search_results:
|
315 |
-
if not isinstance(result, dict):
|
316 |
-
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
317 |
-
continue
|
318 |
-
source_info = {}
|
319 |
-
source_key = result.get("source")
|
320 |
-
if not source_key and "metadata" in result and isinstance(result["metadata"], dict):
|
321 |
-
source_key = result["metadata"].get("source")
|
322 |
-
|
323 |
-
if source_key:
|
324 |
-
source_info["name"] = os.path.basename(source_key)
|
325 |
-
source_info["path"] = source_key
|
326 |
-
else:
|
327 |
-
source_info["name"] = "알 수 없는 소스"
|
328 |
-
|
329 |
-
if "score" in result:
|
330 |
-
source_info["score"] = result["score"]
|
331 |
-
if "rerank_score" in result:
|
332 |
-
source_info["rerank_score"] = result["rerank_score"]
|
333 |
-
|
334 |
-
sources.append(source_info)
|
335 |
-
|
336 |
-
return jsonify({
|
337 |
-
"answer": answer,
|
338 |
-
"sources": sources,
|
339 |
-
"search_warning": search_warning
|
340 |
-
})
|
341 |
-
|
342 |
-
except Exception as e:
|
343 |
-
logger.error(f"LLM 응답 생성 중 오류 발생: {str(e)}", exc_info=True)
|
344 |
-
return jsonify({
|
345 |
-
"answer": f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(e)}",
|
346 |
-
"sources": [],
|
347 |
-
"error": str(e)
|
348 |
-
})
|
349 |
-
|
350 |
-
except Exception as e:
|
351 |
-
logger.error(f"채팅 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True)
|
352 |
-
return jsonify({
|
353 |
-
"error": f"예상치 못한 오류 발생: {str(e)}",
|
354 |
-
"answer": "죄송합니다. 서버에서 오류가 발생했습니다.",
|
355 |
-
"sources": []
|
356 |
-
}), 500
|
357 |
-
|
358 |
-
# --- Voice Chat API ---
|
359 |
-
@app.route('/api/voice', methods=['POST'])
|
360 |
-
@login_required
|
361 |
-
def voice_chat():
|
362 |
-
"""음성 챗 API 엔드포인트"""
|
363 |
-
# 함수 내에서는 전역 retriever 변수를 직접 사용하고 수정하지 않음
|
364 |
-
nonlocal retriever
|
365 |
-
|
366 |
-
try:
|
367 |
-
# 앱이 준비되었는지 확인
|
368 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
369 |
-
if not is_ready:
|
370 |
-
logger.warning("앱이 아직 초기화 중입니다.")
|
371 |
-
return jsonify({"error": "앱 초기화 중...", "answer": "죄송합니다. 시스템이 아직 준비 중입니다."}), 200 # 503 대신 200으로 변경
|
372 |
-
|
373 |
-
# STT 클라이언트 확인
|
374 |
-
if stt_client is None or not hasattr(stt_client, 'transcribe_audio'):
|
375 |
-
logger.error("음성 API 요청 시 STT 클라이언트가 준비되지 않음")
|
376 |
-
return jsonify({"error": "음성 인식 서비스 준비 안됨"}), 503
|
377 |
-
|
378 |
-
logger.info("음성 챗 요청 수신")
|
379 |
-
|
380 |
-
if 'audio' not in request.files:
|
381 |
-
logger.error("오디오 파일이 제공되지 않음")
|
382 |
-
return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
|
383 |
-
|
384 |
-
audio_file = request.files['audio']
|
385 |
-
logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
|
386 |
-
|
387 |
-
try:
|
388 |
-
# 오디오 파일 임시 저장 및 처리
|
389 |
-
with tempfile.NamedTemporaryFile(delete=True, suffix=os.path.splitext(audio_file.filename)[1]) as temp_audio:
|
390 |
-
audio_file.save(temp_audio.name)
|
391 |
-
logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
|
392 |
-
# STT 수행 (바이트 전달 가정)
|
393 |
-
with open(temp_audio.name, 'rb') as f_bytes:
|
394 |
-
audio_bytes = f_bytes.read()
|
395 |
-
stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
|
396 |
-
|
397 |
-
# STT 결과 처리
|
398 |
-
if not isinstance(stt_result, dict) or not stt_result.get("success"):
|
399 |
-
error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
|
400 |
-
logger.error(f"음성인식 실패: {error_msg}")
|
401 |
-
return jsonify({"error": "음성인식 실패", "details": error_msg}), 500
|
402 |
-
|
403 |
-
transcription = stt_result.get("text", "")
|
404 |
-
if not transcription:
|
405 |
-
logger.warning("음성인식 결과가 비어있습니다.")
|
406 |
-
return jsonify({
|
407 |
-
"transcription": "",
|
408 |
-
"answer": "음성에서 텍스트를 인식하지 못했습니다.",
|
409 |
-
"sources": []
|
410 |
-
}), 200 # 200 OK와 메시지
|
411 |
-
|
412 |
-
logger.info(f"음성인식 성공: {transcription[:50]}...")
|
413 |
-
|
414 |
-
# --- RAG 및 LLM 호출 (Chat API와 동일 로직) ---
|
415 |
-
# 검색 엔진 처리 부분
|
416 |
-
search_results = []
|
417 |
-
search_warning = None
|
418 |
-
try:
|
419 |
-
# retriever 상태 검증 - 여기서 retriever 변수를 수정하지 않도록 주의
|
420 |
-
if retriever is None:
|
421 |
-
logger.warning("Retriever가 초기화되지 않았습니다.")
|
422 |
-
# 상태 기록만 하고 수정하지 않음
|
423 |
-
search_warning = "검색 기능이 아직 준비되지 않았습니다."
|
424 |
-
elif hasattr(retriever, 'is_mock') and retriever.is_mock:
|
425 |
-
logger.info("Mock Retriever 사용 중 - 검색 결과 없음.")
|
426 |
-
# 상태 기록만 하고 수정하지 않음
|
427 |
-
search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다."
|
428 |
-
elif not hasattr(retriever, 'search'):
|
429 |
-
logger.warning("Retriever에 search 메소드가 없습니다.")
|
430 |
-
# 상태 기록만 하고 수정하지 않음
|
431 |
-
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
432 |
-
else:
|
433 |
-
try:
|
434 |
-
logger.info(f"검색 수행: {transcription[:50]}...")
|
435 |
-
# retriever가 유효하고 search 메소드가 있는 경우에만 검색 실행
|
436 |
-
if retriever is not None and hasattr(retriever, 'search'):
|
437 |
-
try:
|
438 |
-
search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
|
439 |
-
except Exception as search_err:
|
440 |
-
logger.error(f"retriever.search() 호출 중 오류 발생: {search_err}", exc_info=True)
|
441 |
-
search_results = []
|
442 |
-
search_warning = f"검색 중 오류 발생: {str(search_err)}"
|
443 |
-
else:
|
444 |
-
logger.warning("검색을 실행할 수 없습니다: retriever가 None이거나 search 메소드가 없습니다.")
|
445 |
-
search_results = []
|
446 |
-
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
447 |
-
# except Exception as search_err:
|
448 |
-
# logger.error(f"retriever.search() 호출 중 오류 발생: {search_err}", exc_info=True)
|
449 |
-
# search_results = []
|
450 |
-
# search_warning = f"검색 중 오류 발생: {str(search_err)}"
|
451 |
-
|
452 |
-
if not search_results:
|
453 |
-
logger.info("검색 결과가 없습니다.")
|
454 |
-
else:
|
455 |
-
logger.info(f"검색 결과: {len(search_results)}개 항목")
|
456 |
-
except Exception as e:
|
457 |
-
logger.error(f"검색 처리 중 예상치 못한 오류: {e}", exc_info=True)
|
458 |
-
search_results = []
|
459 |
-
search_warning = f"검색 처리 중 오류 발생: {str(e)}"
|
460 |
-
except Exception as e:
|
461 |
-
logger.error(f"검색 엔진 접근 중 오류 발생: {str(e)}", exc_info=True)
|
462 |
-
search_results = []
|
463 |
-
search_warning = f"검색 엔진 접근 중 오류 발생: {str(e)}"
|
464 |
-
|
465 |
-
# LLM 응답 생성
|
466 |
-
context = ""
|
467 |
-
if search_results:
|
468 |
-
if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
469 |
-
logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.")
|
470 |
-
else:
|
471 |
-
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
472 |
-
logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)")
|
473 |
-
|
474 |
-
# LLM 인터페이스 호출
|
475 |
-
llm_id = request.form.get('llm_id', None) # form 데이터에서 llm_id 가져오기
|
476 |
-
|
477 |
-
if not context:
|
478 |
-
if search_warning:
|
479 |
-
logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}")
|
480 |
-
answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})"
|
481 |
-
else:
|
482 |
-
logger.info("컨텍스트 없이 기본 응답 생성")
|
483 |
-
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
484 |
-
else:
|
485 |
-
if llm_interface is None or not hasattr(llm_interface, 'rag_generate'):
|
486 |
-
logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.")
|
487 |
-
answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다."
|
488 |
-
else:
|
489 |
-
# LLM 호출 시 검색 경고 처리 추가
|
490 |
-
if search_warning:
|
491 |
-
logger.info(f"검색 경고 있음: {search_warning}")
|
492 |
-
# 원래 쿼리만 사용
|
493 |
-
modified_query = transcription
|
494 |
-
else:
|
495 |
-
modified_query = transcription
|
496 |
-
|
497 |
-
try:
|
498 |
-
answer = llm_interface.rag_generate(modified_query, context, llm_id=llm_id)
|
499 |
-
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
500 |
-
except Exception as llm_err:
|
501 |
-
logger.error(f"LLM 호출 중 오류: {llm_err}", exc_info=True)
|
502 |
-
answer = f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(llm_err)}"
|
503 |
-
|
504 |
-
# 소스 정보 추출
|
505 |
-
sources = []
|
506 |
-
if search_results:
|
507 |
-
for result in search_results:
|
508 |
-
if not isinstance(result, dict):
|
509 |
-
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
510 |
-
continue
|
511 |
-
source_info = {}
|
512 |
-
source_key = result.get("source")
|
513 |
-
if not source_key and "metadata" in result and isinstance(result["metadata"], dict):
|
514 |
-
source_key = result["metadata"].get("source")
|
515 |
-
|
516 |
-
if source_key:
|
517 |
-
source_info["name"] = os.path.basename(source_key)
|
518 |
-
source_info["path"] = source_key
|
519 |
-
else:
|
520 |
-
source_info["name"] = "알 수 없는 소스"
|
521 |
-
|
522 |
-
if "score" in result:
|
523 |
-
source_info["score"] = result["score"]
|
524 |
-
if "rerank_score" in result:
|
525 |
-
source_info["rerank_score"] = result["rerank_score"]
|
526 |
-
|
527 |
-
sources.append(source_info)
|
528 |
-
|
529 |
-
# 최종 응답
|
530 |
-
response_data = {
|
531 |
-
"transcription": transcription,
|
532 |
-
"answer": answer,
|
533 |
-
"sources": sources,
|
534 |
-
"search_warning": search_warning
|
535 |
-
}
|
536 |
-
|
537 |
-
# LLM 정보 추가 (옵션)
|
538 |
-
if hasattr(llm_interface, 'get_current_llm_details'):
|
539 |
-
response_data["llm"] = llm_interface.get_current_llm_details()
|
540 |
-
|
541 |
-
return jsonify(response_data)
|
542 |
-
|
543 |
-
except Exception as e:
|
544 |
-
logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
|
545 |
-
return jsonify({
|
546 |
-
"error": "음성 처리 중 내부 오류 발생",
|
547 |
-
"details": str(e),
|
548 |
-
"answer": "죄송합니다. 오디오 처리 중 오류가 발생했습니다."
|
549 |
-
}), 500
|
550 |
-
|
551 |
-
except Exception as e:
|
552 |
-
logger.error(f"음성 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True)
|
553 |
-
return jsonify({
|
554 |
-
"error": f"예상치 못한 오류 발생: {str(e)}",
|
555 |
-
"answer": "죄송합니다. 서버에서 오류가 발생했습니다."
|
556 |
-
}), 500
|
557 |
-
|
558 |
-
# --- Document Upload API ---
|
559 |
-
@app.route('/api/upload', methods=['POST'])
|
560 |
-
@login_required
|
561 |
-
def upload_document():
|
562 |
-
"""지식베이스 문서 업로드 API"""
|
563 |
-
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
564 |
-
if not is_ready:
|
565 |
-
return jsonify({"error": "앱 초기화 중..."}), 503
|
566 |
-
|
567 |
-
# base_retriever 객체 및 필수 메소드 확인
|
568 |
-
if base_retriever is None or not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
|
569 |
-
logger.error("문서 업로드 API 요청 시 base_retriever가 준비되지 않았거나 필수 메소드가 없습니다.")
|
570 |
-
return jsonify({"error": "기본 검색기가 준비되지 않았습니다."}), 503
|
571 |
-
|
572 |
-
if 'document' not in request.files:
|
573 |
-
return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
|
574 |
-
|
575 |
-
doc_file = request.files['document']
|
576 |
-
if not doc_file or not doc_file.filename:
|
577 |
-
return jsonify({"error": "선택된 파일이 없습니다."}), 400
|
578 |
-
|
579 |
-
# ALLOWED_DOC_EXTENSIONS를 함수 내에서 다시 정의하거나 전역 상수로 사용
|
580 |
-
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
581 |
-
if not allowed_doc_file(doc_file.filename):
|
582 |
-
logger.warning(f"허용되지 않는 파일 형식: {doc_file.filename}")
|
583 |
-
return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400
|
584 |
-
|
585 |
-
try:
|
586 |
-
filename = secure_filename(doc_file.filename)
|
587 |
-
# app.config 사용 확인
|
588 |
-
if 'DATA_FOLDER' not in app.config:
|
589 |
-
logger.error("Flask app.config에 DATA_FOLDER가 설정되지 않았습니다.")
|
590 |
-
return jsonify({"error": "서버 설정 오류 (DATA_FOLDER)"}), 500
|
591 |
-
data_folder = app.config['DATA_FOLDER']
|
592 |
-
os.makedirs(data_folder, exist_ok=True)
|
593 |
-
filepath = os.path.join(data_folder, filename)
|
594 |
-
|
595 |
-
doc_file.save(filepath)
|
596 |
-
logger.info(f"문서 저장 완료: {filepath}")
|
597 |
-
|
598 |
-
# DocumentProcessor 객체 및 메소드 확인
|
599 |
-
if DocumentProcessor is None or not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
|
600 |
-
logger.error("DocumentProcessor가 준비되지 않았거나 필요한 메소드가 없습니다.")
|
601 |
-
try: os.remove(filepath) # 저장된 파일 삭제
|
602 |
-
except OSError: pass
|
603 |
-
return jsonify({"error": "문서 처리기 오류"}), 500
|
604 |
-
|
605 |
-
content = None
|
606 |
-
file_ext = filename.rsplit('.', 1)[1].lower()
|
607 |
-
metadata = {"source": filename, "filename": filename, "filetype": file_ext, "filepath": filepath}
|
608 |
-
docs = []
|
609 |
-
|
610 |
-
# 파일 읽기 및 내용 추출
|
611 |
-
if file_ext in ['txt', 'md', 'csv']:
|
612 |
-
try:
|
613 |
-
with open(filepath, 'r', encoding='utf-8') as f:
|
614 |
-
content = f.read()
|
615 |
-
except UnicodeDecodeError:
|
616 |
-
logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
|
617 |
-
try:
|
618 |
-
with open(filepath, 'r', encoding='cp949') as f:
|
619 |
-
content = f.read()
|
620 |
-
except Exception as e_cp949:
|
621 |
-
logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}")
|
622 |
-
return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400
|
623 |
-
except Exception as e_read:
|
624 |
-
logger.error(f"파일 읽기 오류 ({filename}): {e_read}")
|
625 |
-
return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500
|
626 |
-
elif file_ext == 'pdf':
|
627 |
-
logger.warning("PDF 처리는 구현되지 않았습니다.")
|
628 |
-
# 여기에 PDF 텍스트 추출 로직 추가 (예: PyPDF2 사용)
|
629 |
-
# content = extract_text_from_pdf(filepath)
|
630 |
-
elif file_ext == 'docx':
|
631 |
-
logger.warning("DOCX 처리는 구현되지 않았습니다.")
|
632 |
-
# 여기에 DOCX 텍스트 추출 로직 추가 (예: python-docx 사용)
|
633 |
-
# content = extract_text_from_docx(filepath)
|
634 |
-
|
635 |
-
# 문서 분할/처리
|
636 |
-
if content is not None: # 내용이 성공적으로 읽혔거나 추출되었을 때만
|
637 |
-
if file_ext == 'csv':
|
638 |
-
logger.info(f"CSV 파일 처리 시작: {filename}")
|
639 |
-
docs = DocumentProcessor.csv_to_documents(content, metadata)
|
640 |
-
elif file_ext in ['txt', 'md'] or (file_ext in ['pdf', 'docx'] and content): # 텍스트 기반 또는 추출된 내용
|
641 |
-
logger.info(f"텍스트 기반 문서 처리 시작: {filename}")
|
642 |
-
# text_to_documents 함수가 청크 분할 등을 수행한다고 가정
|
643 |
-
docs = DocumentProcessor.text_to_documents(
|
644 |
-
content, metadata=metadata,
|
645 |
-
chunk_size=512, chunk_overlap=50 # 설정값 사용
|
646 |
-
)
|
647 |
-
|
648 |
-
# 검색기에 추가 및 저장
|
649 |
-
if docs:
|
650 |
-
logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
|
651 |
-
base_retriever.add_documents(docs)
|
652 |
-
|
653 |
-
logger.info(f"검색기 상태를 저장합니다...")
|
654 |
-
# app.config 사용 확인
|
655 |
-
if 'INDEX_PATH' not in app.config:
|
656 |
-
logger.error("Flask app.config에 INDEX_PATH가 설정되지 않았습니다.")
|
657 |
-
return jsonify({"error": "서버 설정 오류 (INDEX_PATH)"}), 500
|
658 |
-
index_path = app.config['INDEX_PATH']
|
659 |
-
# 인덱스 저장 경로가 폴더인지 파일인지 확인 필요 (VectorRetriever.save 구현에 따라 다름)
|
660 |
-
# 여기서는 index_path가 디렉토리라고 가정하고 부모 디렉토리 생성
|
661 |
-
os.makedirs(os.path.dirname(index_path), exist_ok=True)
|
662 |
-
try:
|
663 |
-
base_retriever.save(index_path)
|
664 |
-
logger.info("인덱스 저장 완료")
|
665 |
-
# TODO: 재순위화 검색기(retriever) 업데이트 로직 필요 시 추가
|
666 |
-
# 예: if retriever and hasattr(retriever, 'update_base_retriever'): retriever.update_base_retriever(base_retriever)
|
667 |
-
return jsonify({
|
668 |
-
"success": True,
|
669 |
-
"message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)."
|
670 |
-
})
|
671 |
-
except Exception as e_save:
|
672 |
-
logger.error(f"인덱스 저장 중 오류 발생: {e_save}", exc_info=True)
|
673 |
-
# 저장 실패 시 추가된 문서 롤백 고려?
|
674 |
-
return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500
|
675 |
-
else:
|
676 |
-
logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.")
|
677 |
-
# 파일은 저장되었으므로 warning 반환
|
678 |
-
return jsonify({
|
679 |
-
"warning": True, # 'success' 대신 'warning' 사용
|
680 |
-
"message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없거나 지원되지 않는 형식입니다."
|
681 |
-
})
|
682 |
-
|
683 |
-
except Exception as e:
|
684 |
-
logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True)
|
685 |
-
# 오류 발생 시 저장된 파일 삭제
|
686 |
-
if 'filepath' in locals() and os.path.exists(filepath):
|
687 |
-
try: os.remove(filepath)
|
688 |
-
except OSError as e_del: logger.error(f"업로드 실패 후 파일 삭제 오류: {e_del}")
|
689 |
-
return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
|
690 |
-
|
691 |
-
# --- Document List API ---
|
692 |
-
@app.route('/api/documents', methods=['GET'])
|
693 |
-
@login_required
|
694 |
-
def list_documents():
|
695 |
-
"""지식베이스 문서 목록 API"""
|
696 |
-
logger.info("문서 목록 API 요청 시작")
|
697 |
-
|
698 |
-
# base_retriever 상태 확인
|
699 |
-
if base_retriever is None:
|
700 |
-
logger.warning("문서 API 요청 시 base_retriever가 None입니다.")
|
701 |
-
return jsonify({"documents": [], "total_documents": 0, "total_chunks": 0})
|
702 |
-
elif not hasattr(base_retriever, 'documents'):
|
703 |
-
logger.warning("문서 API 요청 시 base_retriever에 'documents' 속성이 없습니다.")
|
704 |
-
return jsonify({"documents": [], "total_documents": 0, "total_chunks": 0})
|
705 |
-
|
706 |
-
# 로깅 추가
|
707 |
-
logger.info(f"base_retriever 객체 타입: {type(base_retriever)}")
|
708 |
-
logger.info(f"base_retriever.documents 존재 여부: {hasattr(base_retriever, 'documents')}")
|
709 |
-
doc_list_attr = getattr(base_retriever, 'documents', None) # 안전하게 속성 가져오기
|
710 |
-
logger.info(f"base_retriever.documents 타입: {type(doc_list_attr)}")
|
711 |
-
logger.info(f"base_retriever.documents 길이: {len(doc_list_attr) if isinstance(doc_list_attr, list) else 'N/A'}")
|
712 |
-
|
713 |
-
try:
|
714 |
-
sources = {}
|
715 |
-
total_chunks = 0
|
716 |
-
doc_list = doc_list_attr # 위에서 가져온 속성 사용
|
717 |
-
|
718 |
-
# doc_list가 리스트인지 확인
|
719 |
-
if not isinstance(doc_list, list):
|
720 |
-
logger.error(f"base_retriever.documents가 리스트가 아님: {type(doc_list)}")
|
721 |
-
return jsonify({"error": "내부 데이터 구조 오류"}), 500
|
722 |
-
|
723 |
-
logger.info(f"총 {len(doc_list)}개 문서 청크에서 소스 목록 생성 중...")
|
724 |
-
for i, doc in enumerate(doc_list):
|
725 |
-
# 각 청크가 딕셔너리 형태인지 확인 (Langchain Document 객체도 딕셔너리처럼 동작 가능)
|
726 |
-
if not hasattr(doc, 'get'): # 딕셔너리 또는 유사 객체인지 확인
|
727 |
-
logger.warning(f"청크 {i}가 딕셔너리 타입이 아님: {type(doc)}")
|
728 |
-
continue
|
729 |
-
|
730 |
-
# 소스 정보 추출 (metadata 우선)
|
731 |
-
source = "unknown"
|
732 |
-
metadata = doc.get("metadata")
|
733 |
-
if isinstance(metadata, dict):
|
734 |
-
source = metadata.get("source", "unknown")
|
735 |
-
# metadata에 없으면 doc 자체에서 찾기 (하위 호환성)
|
736 |
-
if source == "unknown":
|
737 |
-
source = doc.get("source", "unknown")
|
738 |
-
|
739 |
-
if source != "unknown":
|
740 |
-
if source in sources:
|
741 |
-
sources[source]["chunks"] += 1
|
742 |
-
else:
|
743 |
-
# filename, filetype 추출 (metadata 우선)
|
744 |
-
filename = metadata.get("filename", source) if isinstance(metadata, dict) else source
|
745 |
-
filetype = metadata.get("filetype", "unknown") if isinstance(metadata, dict) else "unknown"
|
746 |
-
# metadata에 없으면 doc 자체에서 찾기
|
747 |
-
if filename == source and doc.get("filename"): filename = doc["filename"]
|
748 |
-
if filetype == "unknown" and doc.get("filetype"): filetype = doc["filetype"]
|
749 |
-
|
750 |
-
sources[source] = {
|
751 |
-
"filename": filename,
|
752 |
-
"chunks": 1,
|
753 |
-
"filetype": filetype
|
754 |
-
}
|
755 |
-
total_chunks += 1
|
756 |
-
else:
|
757 |
-
# 소스 정보가 없는 청크 로깅 (너무 많으면 주석 처리)
|
758 |
-
logger.warning(f"청크 {i}에서 소스 정보를 찾을 수 없음: {str(doc)[:200]}...") # 내용 일부 로깅
|
759 |
-
|
760 |
-
# 최종 목록 생성 및 정렬
|
761 |
-
documents = [{"source": src, **info} for src, info in sources.items()]
|
762 |
-
documents.sort(key=lambda x: x.get("filename", ""), reverse=False) # 파일명 기준 오름차순 정렬
|
763 |
-
|
764 |
-
logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크")
|
765 |
-
return jsonify({
|
766 |
-
"documents": documents,
|
767 |
-
"total_documents": len(documents),
|
768 |
-
"total_chunks": total_chunks
|
769 |
-
})
|
770 |
-
|
771 |
-
except Exception as e:
|
772 |
-
logger.error(f"문서 목록 조회 중 심각한 오류 발생: {e}", exc_info=True)
|
773 |
-
# 503 대신 500 반환
|
774 |
-
return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/app_routes_improved.py
ADDED
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 웹 애플리케이션 - 개선된 API 라우트 정의
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import json
|
7 |
+
import logging
|
8 |
+
import tempfile
|
9 |
+
import threading
|
10 |
+
import time
|
11 |
+
from flask import request, jsonify, render_template, send_from_directory, session, redirect, url_for
|
12 |
+
from datetime import datetime
|
13 |
+
from werkzeug.utils import secure_filename
|
14 |
+
|
15 |
+
# 로거 가져오기
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
|
18 |
+
# 앱 시작 시간 기록
|
19 |
+
APP_START_TIME = time.time()
|
20 |
+
|
21 |
+
def register_routes(app, login_required, llm_interface, retriever, stt_client,
|
22 |
+
DocumentProcessor, base_retriever, app_ready_event,
|
23 |
+
init_success_event, initialization_error, ADMIN_USERNAME,
|
24 |
+
ADMIN_PASSWORD, DEVICE_SERVER_URL):
|
25 |
+
"""
|
26 |
+
Flask 애플리케이션에 기본 라우트 등록 (개선된 버전)
|
27 |
+
"""
|
28 |
+
# 헬퍼 함수
|
29 |
+
def allowed_audio_file(filename):
|
30 |
+
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
31 |
+
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
32 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
33 |
+
|
34 |
+
def allowed_doc_file(filename):
|
35 |
+
"""파일이 허용된 문서 확장자를 가지는지 확인"""
|
36 |
+
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
37 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
38 |
+
|
39 |
+
# --- 로그인/로그아웃 라우트 ---
|
40 |
+
@app.route('/login', methods=['GET', 'POST'])
|
41 |
+
def login():
|
42 |
+
error = None
|
43 |
+
next_url = request.args.get('next')
|
44 |
+
logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
|
45 |
+
|
46 |
+
if request.method == 'POST':
|
47 |
+
logger.info("로그인 시도 받음")
|
48 |
+
username = request.form.get('username', '')
|
49 |
+
password = request.form.get('password', '')
|
50 |
+
|
51 |
+
if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
|
52 |
+
logger.info(f"로그인 성공: {username}")
|
53 |
+
session.permanent = True
|
54 |
+
session['logged_in'] = True
|
55 |
+
session['username'] = username
|
56 |
+
redirect_to = next_url or url_for('index')
|
57 |
+
return redirect(redirect_to)
|
58 |
+
else:
|
59 |
+
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
60 |
+
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
61 |
+
else: # GET 요청
|
62 |
+
if session.get('logged_in'):
|
63 |
+
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
64 |
+
return redirect(url_for('index'))
|
65 |
+
|
66 |
+
return render_template('login.html', error=error, next=next_url)
|
67 |
+
|
68 |
+
@app.route('/logout')
|
69 |
+
def logout():
|
70 |
+
"""로그아웃 처리"""
|
71 |
+
username = session.get('username', 'unknown')
|
72 |
+
if session.pop('logged_in', None):
|
73 |
+
session.pop('username', None)
|
74 |
+
|
75 |
+
return redirect(url_for('login'))
|
76 |
+
|
77 |
+
# --- 메인 페이지 및 상태 확인 ---
|
78 |
+
@app.route('/')
|
79 |
+
@login_required
|
80 |
+
def index():
|
81 |
+
"""메인 페이지"""
|
82 |
+
# 앱이 준비되었는지 확인
|
83 |
+
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
84 |
+
|
85 |
+
if not is_ready:
|
86 |
+
logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
|
87 |
+
return render_template('loading.html')
|
88 |
+
|
89 |
+
# 초기화는 완료되었지만 오류가 발생한 경우 오류 페이지 표시
|
90 |
+
is_success = init_success_event.is_set() if isinstance(init_success_event, threading.Event) else False
|
91 |
+
if not is_success and initialization_error:
|
92 |
+
logger.warning(f"초기화 실패로 오류 페이지 표시. 오류: {initialization_error}")
|
93 |
+
return render_template('error.html',
|
94 |
+
error="RAG 검색기 초기화 중 오류가 발생했습니다.",
|
95 |
+
details=initialization_error)
|
96 |
+
|
97 |
+
return render_template('index.html')
|
98 |
+
|
99 |
+
@app.route('/api/status')
|
100 |
+
@login_required
|
101 |
+
def app_status():
|
102 |
+
"""앱 초기화 상태 확인 API (개선됨)"""
|
103 |
+
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
104 |
+
is_success = init_success_event.is_set() if isinstance(init_success_event, threading.Event) else False
|
105 |
+
|
106 |
+
# 검색기 상태 확인
|
107 |
+
retriever_type = type(retriever).__name__ if retriever else "None"
|
108 |
+
is_mock_retriever = hasattr(retriever, 'is_mock') and retriever.is_mock if retriever else False
|
109 |
+
|
110 |
+
# 데이터 수 확인
|
111 |
+
doc_count = 0
|
112 |
+
if base_retriever and hasattr(base_retriever, 'documents'):
|
113 |
+
doc_count = len(base_retriever.documents)
|
114 |
+
|
115 |
+
status_data = {
|
116 |
+
"ready": is_ready,
|
117 |
+
"success": is_success,
|
118 |
+
"error": initialization_error if initialization_error else None,
|
119 |
+
"retriever_type": retriever_type,
|
120 |
+
"is_mock_retriever": is_mock_retriever,
|
121 |
+
"document_count": doc_count,
|
122 |
+
"uptime_seconds": time.time() - APP_START_TIME
|
123 |
+
}
|
124 |
+
|
125 |
+
return jsonify(status_data)
|
126 |
+
|
127 |
+
# --- 나머지 API 엔드포인트들 ---
|
128 |
+
# 기존의 app_routes_after.py에서 가져온 엔드포인트들로 여기에 통합
|
129 |
+
# (chat, voice_chat, document 관련 API 등)
|
130 |
+
|
131 |
+
# LLM API와 Chat API는 chat_routes.py 파일로 분리
|
132 |
+
# Document Upload/List API는 document_routes.py 파일로 분리
|
133 |
+
# Voice API는 voice_routes.py 파일로 분리
|
134 |
+
|
135 |
+
# 기본 라우트만 여기서 처리하고 나머지는 분리된 파일에서 임포트
|
136 |
+
from app.chat_routes import register_chat_routes
|
137 |
+
from app.document_routes import register_document_routes
|
138 |
+
from app.voice_routes import register_voice_routes
|
139 |
+
|
140 |
+
# 각 서브모듈의 라우트 등록
|
141 |
+
register_chat_routes(app, login_required, llm_interface, retriever, DocumentProcessor,
|
142 |
+
app_ready_event, init_success_event, initialization_error)
|
143 |
+
|
144 |
+
register_document_routes(app, login_required, base_retriever, retriever, DocumentProcessor,
|
145 |
+
app_ready_event, init_success_event, initialization_error)
|
146 |
+
|
147 |
+
register_voice_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor,
|
148 |
+
app_ready_event, init_success_event, initialization_error)
|
149 |
+
|
150 |
+
# --- 정적 파일 서빙 ---
|
151 |
+
@app.route('/static/<path:path>')
|
152 |
+
def send_static(path):
|
153 |
+
static_folder = app.config.get('STATIC_FOLDER', 'static')
|
154 |
+
return send_from_directory(static_folder, path)
|
155 |
+
|
156 |
+
return app # 설정이 완료된 앱 반환
|
app/background_init.py
ADDED
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 - 백그라운드 초기화 모듈
|
3 |
+
"""
|
4 |
+
|
5 |
+
import time
|
6 |
+
import logging
|
7 |
+
|
8 |
+
# 로거 가져오기
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
def background_init(app, llm_interface, stt_client, base_retriever, retriever,
|
12 |
+
app_ready_event, init_success_event, MockComponent, initialization_error=None):
|
13 |
+
"""
|
14 |
+
백그라운드에서 검색기 및 기타 컴포넌트 초기화 수행
|
15 |
+
|
16 |
+
Args:
|
17 |
+
app: Flask 앱 객체
|
18 |
+
llm_interface: LLM 인터페이스 참조
|
19 |
+
stt_client: STT 클라이언트 참조
|
20 |
+
base_retriever: 기본 검색기 참조
|
21 |
+
retriever: 재순위화 검색기 참조
|
22 |
+
app_ready_event: 앱 준비 완료 이벤트 (threading.Event)
|
23 |
+
init_success_event: 초기화 성공 이벤트 (threading.Event)
|
24 |
+
MockComponent: Mock 클래스
|
25 |
+
initialization_error: 초기화 오류 정보 저장용 변수
|
26 |
+
"""
|
27 |
+
from app.init_retriever_improved import init_retriever
|
28 |
+
|
29 |
+
logger.info("백그라운드 초기화 시작...")
|
30 |
+
start_init_time = time.time()
|
31 |
+
|
32 |
+
# 전역 변수 참조 - 이 함수 내에서 업데이트된 값을 호출자에게 반환하기 위해 사용
|
33 |
+
# nonlocal 키워드는 함수 외부에서는 사용할 수 없으므로 리턴 값으로 처리
|
34 |
+
updated_base_retriever = base_retriever
|
35 |
+
updated_retriever = retriever
|
36 |
+
error_message = None if initialization_error is None else initialization_error
|
37 |
+
|
38 |
+
try:
|
39 |
+
# 1. LLM, STT 인터페이스 재확인 및 초기화
|
40 |
+
if llm_interface is None or hasattr(llm_interface, 'is_mock') and llm_interface.is_mock:
|
41 |
+
logger.warning("LLM 인터페이스가 초기화되지 않았거나 Mock 객체입니다.")
|
42 |
+
# 필요 시 여기서 다시 초기화 시도 가능
|
43 |
+
|
44 |
+
if stt_client is None or hasattr(stt_client, 'is_mock') and stt_client.is_mock:
|
45 |
+
logger.warning("STT 클라이언트가 초기화되지 않았거나 Mock 객체입니다.")
|
46 |
+
# 필요 시 여기서 다시 초기화 시도 가능
|
47 |
+
|
48 |
+
# 2. 검색기 초기화 (개선된 버전 사용)
|
49 |
+
logger.info("개선된 검색기 초기화 함수 호출...")
|
50 |
+
base_retriever_new, retriever_new, success, error_msg = init_retriever(
|
51 |
+
app=app,
|
52 |
+
base_retriever=updated_base_retriever,
|
53 |
+
retriever=updated_retriever,
|
54 |
+
MockComponent=MockComponent,
|
55 |
+
initialization_error=error_message
|
56 |
+
)
|
57 |
+
|
58 |
+
# 지역 변수에 업데이트된 값 저장
|
59 |
+
updated_base_retriever = base_retriever_new
|
60 |
+
updated_retriever = retriever_new
|
61 |
+
|
62 |
+
# 초기화 결과에 따른 처리
|
63 |
+
if success:
|
64 |
+
logger.info("검색기 초기화 성공")
|
65 |
+
init_success_event.set() # 성공 이벤트 설정
|
66 |
+
|
67 |
+
# 검색기가 Mock이 아닌지 추가 확인
|
68 |
+
if not isinstance(updated_retriever, MockComponent) and not isinstance(updated_base_retriever, MockComponent):
|
69 |
+
logger.info("실제 검색기(non-Mock) 초기화 완료")
|
70 |
+
else:
|
71 |
+
# Mock인 경우 경고 로그 남기고 오류 메시지 설정
|
72 |
+
logger.warning("Mock 검색기를 사용합니다. 일부 기능이 제한될 수 있습니다.")
|
73 |
+
error_message = "검색기 초기화는 완료되었으나 Mock 객체를 사용 중입니다."
|
74 |
+
else:
|
75 |
+
logger.error(f"검색기 초기화 실패: {error_msg}")
|
76 |
+
error_message = error_msg
|
77 |
+
|
78 |
+
# 중요: 성공 여부와 관계없이 app_ready_event 설정
|
79 |
+
# 이렇게 하면 앱은 어떤 상태이든 로딩 화면에서 벗어남
|
80 |
+
app_ready_event.set()
|
81 |
+
logger.info("app_ready_event가 설정됨 (앱이 준비 완료 상태로 전환)")
|
82 |
+
|
83 |
+
except Exception as e:
|
84 |
+
logger.error(f"백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
|
85 |
+
|
86 |
+
# 오류 발생 시 Mock 객체 할당
|
87 |
+
if updated_base_retriever is None:
|
88 |
+
updated_base_retriever = MockComponent()
|
89 |
+
|
90 |
+
if updated_retriever is None:
|
91 |
+
updated_retriever = MockComponent()
|
92 |
+
|
93 |
+
# 오류 메시지 설정
|
94 |
+
error_message = f"백그라운드 초기화 중 오류: {str(e)}"
|
95 |
+
|
96 |
+
# 앱은 여전히 사용 가능하도록 ready 이벤트 설정
|
97 |
+
app_ready_event.set()
|
98 |
+
logger.warning("초기화 오류가 발생했지만 app_ready_event를 설정하여 앱을 사용 가능 상태로 만듭니다.")
|
99 |
+
|
100 |
+
finally:
|
101 |
+
# 초기화 완료 시간 기록
|
102 |
+
end_init_time = time.time()
|
103 |
+
logger.info(f"백그라운드 초기화 완료. 소요 시간: {end_init_time - start_init_time:.2f}초")
|
104 |
+
|
105 |
+
# 최종 상태 기록
|
106 |
+
logger.info(f"앱 준비 상태: {app_ready_event.is_set()}")
|
107 |
+
logger.info(f"초기화 성공 상태: {init_success_event.is_set()}")
|
108 |
+
|
109 |
+
if error_message:
|
110 |
+
logger.error(f"초기화 오류 메시지: {error_message}")
|
111 |
+
|
112 |
+
# 업데이트된 객체와 오류 메시지 반환
|
113 |
+
return updated_base_retriever, updated_retriever, error_message
|
app/chat_routes.py
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 - 채팅 관련 API 라우트
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import logging
|
7 |
+
import threading
|
8 |
+
from flask import request, jsonify
|
9 |
+
|
10 |
+
# 로거 가져오기
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
def register_chat_routes(app, login_required, llm_interface, retriever, DocumentProcessor,
|
14 |
+
app_ready_event, init_success_event, initialization_error):
|
15 |
+
"""
|
16 |
+
채팅 관련 API 라우트 등록
|
17 |
+
"""
|
18 |
+
|
19 |
+
# --- LLM API ---
|
20 |
+
@app.route('/api/llm', methods=['GET', 'POST'])
|
21 |
+
@login_required
|
22 |
+
def llm_api():
|
23 |
+
"""사용 가능한 LLM 목록 및 선택 API"""
|
24 |
+
if request.method == 'GET':
|
25 |
+
logger.info("LLM 목록 요청")
|
26 |
+
try:
|
27 |
+
# 객체 확인
|
28 |
+
if llm_interface is None or not hasattr(llm_interface, 'get_current_llm_details') or not hasattr(llm_interface, 'SUPPORTED_LLMS'):
|
29 |
+
logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 속성이 없습니다.")
|
30 |
+
return jsonify({"error": "LLM 인터페이스 오류"}), 500
|
31 |
+
|
32 |
+
current_details = llm_interface.get_current_llm_details()
|
33 |
+
supported_llms_dict = llm_interface.SUPPORTED_LLMS
|
34 |
+
supported_list = [{
|
35 |
+
"name": name, "id": id, "current": id == current_details.get("id")
|
36 |
+
} for name, id in supported_llms_dict.items()]
|
37 |
+
|
38 |
+
return jsonify({
|
39 |
+
"supported_llms": supported_list,
|
40 |
+
"current_llm": current_details
|
41 |
+
})
|
42 |
+
except Exception as e:
|
43 |
+
logger.error(f"LLM 정보 조회 오류: {e}", exc_info=True)
|
44 |
+
return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
|
45 |
+
|
46 |
+
elif request.method == 'POST':
|
47 |
+
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
48 |
+
if not is_ready:
|
49 |
+
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
50 |
+
|
51 |
+
data = request.get_json()
|
52 |
+
if not data or 'llm_id' not in data:
|
53 |
+
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
54 |
+
|
55 |
+
llm_id = data['llm_id']
|
56 |
+
logger.info(f"LLM 변경 요청: {llm_id}")
|
57 |
+
|
58 |
+
try:
|
59 |
+
if llm_interface is None or not hasattr(llm_interface, 'set_llm'):
|
60 |
+
logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 메소드가 없습니다.")
|
61 |
+
return jsonify({"error": "LLM 인터페이스 오류"}), 500
|
62 |
+
|
63 |
+
if hasattr(llm_interface, 'llm_clients') and llm_id not in llm_interface.llm_clients:
|
64 |
+
return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
|
65 |
+
|
66 |
+
success = llm_interface.set_llm(llm_id)
|
67 |
+
if success:
|
68 |
+
new_details = llm_interface.get_current_llm_details()
|
69 |
+
logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
|
70 |
+
return jsonify({
|
71 |
+
"success": True,
|
72 |
+
"message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
|
73 |
+
"current_llm": new_details
|
74 |
+
})
|
75 |
+
else:
|
76 |
+
logger.error(f"LLM 변경 실패 (ID: {llm_id})")
|
77 |
+
return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
|
80 |
+
return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
|
81 |
+
|
82 |
+
# --- Chat API ---
|
83 |
+
@app.route('/api/chat', methods=['POST'])
|
84 |
+
@login_required
|
85 |
+
def chat():
|
86 |
+
"""텍스트 기반 챗봇 API"""
|
87 |
+
try:
|
88 |
+
# 앱이 준비되었는지 확인
|
89 |
+
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
90 |
+
if not is_ready:
|
91 |
+
logger.warning("앱이 아직 초기화 중입니다.")
|
92 |
+
return jsonify({
|
93 |
+
"error": "앱 초기화 중...",
|
94 |
+
"answer": "죄송합니다. 시스템이 아직 준비 중입니다.",
|
95 |
+
"sources": []
|
96 |
+
}), 200 # 사용자에게 알림 메시지
|
97 |
+
|
98 |
+
# 초기화 성공 여부 확인
|
99 |
+
is_success = init_success_event.is_set() if isinstance(init_success_event, threading.Event) else False
|
100 |
+
if not is_success and initialization_error:
|
101 |
+
logger.warning(f"초기화 실패 상태에서 API 요청: {initialization_error}")
|
102 |
+
return jsonify({
|
103 |
+
"error": "검색기 초기화 실패",
|
104 |
+
"answer": f"죄송합니다. 검색 시스템 초기화에 실패했습니다. 관리자에게 문의하세요. (오류: {initialization_error})",
|
105 |
+
"sources": []
|
106 |
+
}), 200
|
107 |
+
|
108 |
+
data = request.get_json()
|
109 |
+
if not data or 'query' not in data:
|
110 |
+
return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
|
111 |
+
|
112 |
+
query = data['query']
|
113 |
+
logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
|
114 |
+
|
115 |
+
# 검색 처리
|
116 |
+
search_results = []
|
117 |
+
search_warning = None
|
118 |
+
|
119 |
+
# 검색기 상태 확인 및 검색 수행
|
120 |
+
if retriever is None:
|
121 |
+
logger.warning("Retriever가 초기화되지 않았습니다.")
|
122 |
+
search_warning = "검색 기능이 아직 준비되지 않았습니다."
|
123 |
+
elif hasattr(retriever, 'is_mock') and retriever.is_mock:
|
124 |
+
logger.info("Mock Retriever 사용 중 - 검색 결과 없음.")
|
125 |
+
search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다."
|
126 |
+
elif not hasattr(retriever, 'search'):
|
127 |
+
logger.warning("Retriever에 search 메소드가 없습니다.")
|
128 |
+
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
129 |
+
else:
|
130 |
+
try:
|
131 |
+
logger.info(f"검색 수행: {query[:50]}...")
|
132 |
+
search_results = retriever.search(query, top_k=5, first_stage_k=6)
|
133 |
+
|
134 |
+
if not search_results:
|
135 |
+
logger.info("검색 결과가 없습니다.")
|
136 |
+
else:
|
137 |
+
logger.info(f"검색 결과: {len(search_results)}개 항목")
|
138 |
+
except Exception as e:
|
139 |
+
logger.error(f"검색 처리 중 오류: {e}", exc_info=True)
|
140 |
+
search_results = []
|
141 |
+
search_warning = f"검색 중 오류 발생: {str(e)}"
|
142 |
+
|
143 |
+
# LLM 응답 생성
|
144 |
+
try:
|
145 |
+
# 컨텍스트 준비
|
146 |
+
context = ""
|
147 |
+
if search_results:
|
148 |
+
if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
149 |
+
logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.")
|
150 |
+
else:
|
151 |
+
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
152 |
+
logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)")
|
153 |
+
|
154 |
+
# LLM 인터페이스 사용
|
155 |
+
llm_id = data.get('llm_id', None)
|
156 |
+
|
157 |
+
if not context:
|
158 |
+
if search_warning:
|
159 |
+
logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}")
|
160 |
+
answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})"
|
161 |
+
else:
|
162 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
163 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
164 |
+
else:
|
165 |
+
if llm_interface is None or not hasattr(llm_interface, 'rag_generate'):
|
166 |
+
logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.")
|
167 |
+
answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다."
|
168 |
+
else:
|
169 |
+
try:
|
170 |
+
# LLM을 통한 응답 생성
|
171 |
+
answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
|
172 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
173 |
+
except Exception as llm_err:
|
174 |
+
logger.error(f"LLM 호출 중 오류: {llm_err}", exc_info=True)
|
175 |
+
answer = f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(llm_err)}"
|
176 |
+
|
177 |
+
# 소스 정보 추출
|
178 |
+
sources = []
|
179 |
+
if search_results:
|
180 |
+
for result in search_results:
|
181 |
+
if not isinstance(result, dict):
|
182 |
+
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
183 |
+
continue
|
184 |
+
|
185 |
+
source_info = {}
|
186 |
+
source_key = result.get("source")
|
187 |
+
if not source_key and "metadata" in result and isinstance(result["metadata"], dict):
|
188 |
+
source_key = result["metadata"].get("source")
|
189 |
+
|
190 |
+
if source_key:
|
191 |
+
source_info["name"] = os.path.basename(source_key)
|
192 |
+
source_info["path"] = source_key
|
193 |
+
else:
|
194 |
+
source_info["name"] = "알 수 없는 소스"
|
195 |
+
|
196 |
+
if "score" in result:
|
197 |
+
source_info["score"] = result["score"]
|
198 |
+
if "rerank_score" in result:
|
199 |
+
source_info["rerank_score"] = result["rerank_score"]
|
200 |
+
|
201 |
+
sources.append(source_info)
|
202 |
+
|
203 |
+
return jsonify({
|
204 |
+
"answer": answer,
|
205 |
+
"sources": sources,
|
206 |
+
"search_warning": search_warning
|
207 |
+
})
|
208 |
+
|
209 |
+
except Exception as e:
|
210 |
+
logger.error(f"LLM 응답 생성 중 오류 발생: {str(e)}", exc_info=True)
|
211 |
+
return jsonify({
|
212 |
+
"answer": f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(e)}",
|
213 |
+
"sources": [],
|
214 |
+
"error": str(e)
|
215 |
+
})
|
216 |
+
|
217 |
+
except Exception as e:
|
218 |
+
logger.error(f"채팅 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True)
|
219 |
+
return jsonify({
|
220 |
+
"error": f"예상치 못한 오류 발생: {str(e)}",
|
221 |
+
"answer": "죄송합니다. 서버에서 오류가 발생했습니다.",
|
222 |
+
"sources": []
|
223 |
+
}), 500
|
app/init_retriever.py
DELETED
@@ -1,149 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
RAG 검색 챗봇 - 검색기 초기화 모듈
|
3 |
-
"""
|
4 |
-
|
5 |
-
import os
|
6 |
-
import logging
|
7 |
-
import pickle
|
8 |
-
import gzip
|
9 |
-
from datetime import datetime
|
10 |
-
|
11 |
-
# 로거 가져오기
|
12 |
-
logger = logging.getLogger(__name__)
|
13 |
-
|
14 |
-
def save_embeddings(base_retriever, file_path):
|
15 |
-
"""임베딩 데이터를 압축하여 파일에 저장"""
|
16 |
-
try:
|
17 |
-
# 저장 디렉토리가 없으면 생성
|
18 |
-
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
19 |
-
|
20 |
-
# 타임스탬프 추가
|
21 |
-
save_data = {
|
22 |
-
'timestamp': datetime.now().isoformat(),
|
23 |
-
'retriever': base_retriever
|
24 |
-
}
|
25 |
-
|
26 |
-
# 압축하여 저장 (용량 줄이기)
|
27 |
-
with gzip.open(file_path, 'wb') as f:
|
28 |
-
pickle.dump(save_data, f)
|
29 |
-
|
30 |
-
logger.info(f"임베딩 데이터를 {file_path}에 압축하여 저장했습니다.")
|
31 |
-
return True
|
32 |
-
except Exception as e:
|
33 |
-
logger.error(f"임베딩 저장 중 오류 발생: {e}")
|
34 |
-
return False
|
35 |
-
|
36 |
-
def load_embeddings(file_path, max_age_days=30):
|
37 |
-
"""저장된 임베딩 데이터를 파일에서 로드"""
|
38 |
-
try:
|
39 |
-
if not os.path.exists(file_path):
|
40 |
-
logger.info(f"저장된 임베딩 파일({file_path})이 없습니다.")
|
41 |
-
return None
|
42 |
-
|
43 |
-
# 압축 파일 로드
|
44 |
-
with gzip.open(file_path, 'rb') as f:
|
45 |
-
data = pickle.load(f)
|
46 |
-
|
47 |
-
# 타임스탬프 확인 (너무 오래된 데이터는 사용하지 않음)
|
48 |
-
saved_time = datetime.fromisoformat(data['timestamp'])
|
49 |
-
age = (datetime.now() - saved_time).days
|
50 |
-
|
51 |
-
if age > max_age_days:
|
52 |
-
logger.info(f"저장된 임베딩이 {age}일로 너무 오래되었습니다. 새로 생성합니다.")
|
53 |
-
return None
|
54 |
-
|
55 |
-
logger.info(f"{file_path}에서 임베딩 데이터를 로드했습니다. (생성일: {saved_time})")
|
56 |
-
return data['retriever']
|
57 |
-
except Exception as e:
|
58 |
-
logger.error(f"임베딩 로드 중 오류 발생: {e}")
|
59 |
-
return None
|
60 |
-
|
61 |
-
def init_retriever(app, base_retriever, retriever, ReRanker):
|
62 |
-
"""검색기 객체 초기화 또는 로드"""
|
63 |
-
from utils.document_processor import DocumentProcessor
|
64 |
-
from retrieval.vector_retriever import VectorRetriever
|
65 |
-
|
66 |
-
# 임베딩 캐시 파일 경로
|
67 |
-
cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
|
68 |
-
|
69 |
-
# 먼저 저장된 임베딩 데이터 로드 시도
|
70 |
-
cached_retriever = load_embeddings(cache_path)
|
71 |
-
|
72 |
-
if cached_retriever:
|
73 |
-
logger.info("캐시된 임베딩 데이터를 성공적으로 로드했습니다.")
|
74 |
-
base_retriever = cached_retriever
|
75 |
-
else:
|
76 |
-
# 캐시된 데이터가 없으면 기존 방식으로 초기화
|
77 |
-
index_path = app.config['INDEX_PATH']
|
78 |
-
|
79 |
-
# VectorRetriever 로드 또는 초기화
|
80 |
-
if os.path.exists(os.path.join(index_path, "documents.json")):
|
81 |
-
try:
|
82 |
-
logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
|
83 |
-
base_retriever = VectorRetriever.load(index_path)
|
84 |
-
logger.info(f"{len(base_retriever.documents) if hasattr(base_retriever, 'documents') else 0}개 문서가 로드되었습니다.")
|
85 |
-
except Exception as e:
|
86 |
-
logger.error(f"인덱스 로드 중 오류 발생: {e}. 새 검색기를 초기화합니다.")
|
87 |
-
base_retriever = VectorRetriever()
|
88 |
-
else:
|
89 |
-
logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
|
90 |
-
base_retriever = VectorRetriever()
|
91 |
-
|
92 |
-
# 데이터 폴더의 문서 로드
|
93 |
-
data_path = app.config['DATA_FOLDER']
|
94 |
-
if (not hasattr(base_retriever, 'documents') or not base_retriever.documents) and os.path.exists(data_path):
|
95 |
-
logger.info(f"{data_path}에서 문서를 로드합니다...")
|
96 |
-
try:
|
97 |
-
docs = DocumentProcessor.load_documents_from_directory(
|
98 |
-
data_path,
|
99 |
-
extensions=[".txt", ".md", ".csv"],
|
100 |
-
recursive=True
|
101 |
-
)
|
102 |
-
if docs and hasattr(base_retriever, 'add_documents'):
|
103 |
-
logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
|
104 |
-
base_retriever.add_documents(docs)
|
105 |
-
|
106 |
-
if hasattr(base_retriever, 'save'):
|
107 |
-
logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
|
108 |
-
try:
|
109 |
-
base_retriever.save(index_path)
|
110 |
-
logger.info("인덱스 저장 완료")
|
111 |
-
|
112 |
-
# 새로 생성된 검색기 캐싱
|
113 |
-
if hasattr(base_retriever, 'documents') and base_retriever.documents:
|
114 |
-
save_embeddings(base_retriever, cache_path)
|
115 |
-
logger.info(f"검색기를 캐시 파일 {cache_path}에 저장 완료")
|
116 |
-
except Exception as e:
|
117 |
-
logger.error(f"인덱스 저장 중 오류 발생: {e}")
|
118 |
-
except Exception as e:
|
119 |
-
logger.error(f"DATA_FOLDER에서 문서 로드 중 오류: {e}")
|
120 |
-
|
121 |
-
# 재순위화 검색기 초기화
|
122 |
-
logger.info("재순위화 검색기를 초기화합니다...")
|
123 |
-
try:
|
124 |
-
# 자체 구현된 재순위화 함수
|
125 |
-
def custom_rerank_fn(query, results):
|
126 |
-
query_terms = set(query.lower().split())
|
127 |
-
for result in results:
|
128 |
-
if isinstance(result, dict) and "text" in result:
|
129 |
-
text = result["text"].lower()
|
130 |
-
term_freq = sum(1 for term in query_terms if term in text)
|
131 |
-
normalized_score = term_freq / (len(text.split()) + 1) * 10
|
132 |
-
result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
|
133 |
-
elif isinstance(result, dict):
|
134 |
-
result["rerank_score"] = result.get("score", 0)
|
135 |
-
results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
|
136 |
-
return results
|
137 |
-
|
138 |
-
# ReRanker 클래스 사용
|
139 |
-
retriever = ReRanker(
|
140 |
-
base_retriever=base_retriever,
|
141 |
-
rerank_fn=custom_rerank_fn,
|
142 |
-
rerank_field="text"
|
143 |
-
)
|
144 |
-
logger.info("재순위화 검색기 초기화 완료")
|
145 |
-
except Exception as e:
|
146 |
-
logger.error(f"재순위화 검색기 초기화 실패: {e}")
|
147 |
-
retriever = base_retriever # 실패 시 기본 검색기 사용
|
148 |
-
|
149 |
-
return retriever
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/init_retriever_improved.py
ADDED
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 - 개선된 검색기 초기화 모듈
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import logging
|
7 |
+
import pickle
|
8 |
+
import gzip
|
9 |
+
from datetime import datetime
|
10 |
+
|
11 |
+
# 로거 가져오기
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
def init_retriever(app, base_retriever=None, retriever=None, MockComponent=None, initialization_error=None):
|
15 |
+
"""
|
16 |
+
검색기 객체 초기화 또는 로드 - 개선된 버전
|
17 |
+
|
18 |
+
Args:
|
19 |
+
app: Flask 앱 객체 (설정값 가져오기용)
|
20 |
+
base_retriever: 기존 기본 검색기 객체 (None인 경우 새로 생성)
|
21 |
+
retriever: 기존 재순위화 검색기 객체 (None인 경우 새로 생성)
|
22 |
+
MockComponent: 모듈 로드 실패 시 사용할 Mock 클래스
|
23 |
+
initialization_error: 초기화 오류 정보를 저장할 변수 참조
|
24 |
+
|
25 |
+
Returns:
|
26 |
+
tuple: (base_retriever, retriever, success, error_message)
|
27 |
+
"""
|
28 |
+
try:
|
29 |
+
# 필요한 모듈 임포트
|
30 |
+
from utils.document_processor import DocumentProcessor
|
31 |
+
from retrieval.vector_retriever import VectorRetriever
|
32 |
+
from retrieval.reranker import ReRanker
|
33 |
+
|
34 |
+
index_path = app.config['INDEX_PATH']
|
35 |
+
data_path = app.config['DATA_FOLDER']
|
36 |
+
|
37 |
+
logger.info("=== 검색기 초기화 시작 ===")
|
38 |
+
logger.info(f"인덱스 경로: {index_path}")
|
39 |
+
logger.info(f"데이터 경로: {data_path}")
|
40 |
+
|
41 |
+
# 경로 접근 가능 여부 확인
|
42 |
+
if not os.path.exists(index_path):
|
43 |
+
logger.warning(f"인덱스 경로({index_path})가 존재하지 않습니다. 생성을 시도합니다.")
|
44 |
+
try:
|
45 |
+
os.makedirs(index_path, exist_ok=True)
|
46 |
+
logger.info(f"인덱스 경로({index_path}) 생성 성공")
|
47 |
+
except Exception as e:
|
48 |
+
error_msg = f"인덱스 디렉토리 생성 실패: {str(e)}"
|
49 |
+
logger.error(error_msg)
|
50 |
+
return None, None, False, error_msg
|
51 |
+
|
52 |
+
if not os.path.exists(data_path):
|
53 |
+
logger.warning(f"데이터 경로({data_path})가 존재하지 않습니다. 생성을 시도합니다.")
|
54 |
+
try:
|
55 |
+
os.makedirs(data_path, exist_ok=True)
|
56 |
+
logger.info(f"데이터 경로({data_path}) 생성 성공")
|
57 |
+
except Exception as e:
|
58 |
+
error_msg = f"데이터 디렉토리 생성 실패: {str(e)}"
|
59 |
+
logger.error(error_msg)
|
60 |
+
return None, None, False, error_msg
|
61 |
+
|
62 |
+
# 1. 기본 검색기(VectorRetriever) 로드 또는 초기화
|
63 |
+
logger.info("기본 검색기(VectorRetriever) 초기화/로드 시도...")
|
64 |
+
|
65 |
+
# 저장된 인덱스 파일 확인
|
66 |
+
docs_json_path = os.path.join(index_path, "documents.json")
|
67 |
+
index_exists = os.path.exists(docs_json_path)
|
68 |
+
logger.info(f"인덱스 파일 존재 여부: {index_exists} (경로: {docs_json_path})")
|
69 |
+
|
70 |
+
# 기존 인덱스 로드 또는 새 검색기 초기화
|
71 |
+
if base_retriever is None: # 기존 검색기가 없는 경우에만 시도
|
72 |
+
try:
|
73 |
+
if index_exists:
|
74 |
+
logger.info(f"기존 인덱스 파일에서 검색기 로드 시도...")
|
75 |
+
base_retriever = VectorRetriever.load(index_path)
|
76 |
+
docs_count = len(getattr(base_retriever, 'documents', []))
|
77 |
+
logger.info(f"인덱스 로드 성공. 문서 {docs_count}개 로드됨")
|
78 |
+
else:
|
79 |
+
logger.info("인덱스 파일이 없어 새 VectorRetriever 초기화...")
|
80 |
+
base_retriever = VectorRetriever()
|
81 |
+
logger.info("새 VectorRetriever 초기화 성공")
|
82 |
+
except Exception as e:
|
83 |
+
error_msg = f"VectorRetriever 초기화/로드 실패: {str(e)}"
|
84 |
+
logger.error(error_msg, exc_info=True)
|
85 |
+
# 실패 시 MockComponent 반환을 준비하되, 바로 반환하지 않고 계속 진행
|
86 |
+
if MockComponent:
|
87 |
+
base_retriever = MockComponent()
|
88 |
+
logger.warning("MockComponent를 base_retriever로 사용합니다.")
|
89 |
+
else:
|
90 |
+
logger.info(f"기존 검색기 사용 (base_retriever가 이미 초기화됨)")
|
91 |
+
|
92 |
+
# 2. 데이터 폴더 문서 로드 (기본 검색기가 비어있을 때)
|
93 |
+
if not isinstance(base_retriever, MockComponent): # Mock이 아닌 경우에만 시도
|
94 |
+
needs_loading = not hasattr(base_retriever, 'documents') or not getattr(base_retriever, 'documents', [])
|
95 |
+
|
96 |
+
if needs_loading and os.path.exists(data_path):
|
97 |
+
logger.info(f"검색기가 비어있어 {data_path}에서 문서 로드 시도...")
|
98 |
+
try:
|
99 |
+
# DocumentProcessor 메소드 존재 여부 확인
|
100 |
+
if hasattr(DocumentProcessor, 'load_documents_from_directory'):
|
101 |
+
docs = DocumentProcessor.load_documents_from_directory(
|
102 |
+
directory=data_path,
|
103 |
+
extensions=[".txt", ".md", ".csv"],
|
104 |
+
recursive=True
|
105 |
+
)
|
106 |
+
|
107 |
+
if docs:
|
108 |
+
logger.info(f"{len(docs)}개 문서 로드 성공")
|
109 |
+
|
110 |
+
# 검색기에 문서 추가
|
111 |
+
if hasattr(base_retriever, 'add_documents'):
|
112 |
+
logger.info("검색기에 문서 추가 시도...")
|
113 |
+
base_retriever.add_documents(docs)
|
114 |
+
logger.info("문서 추가 완료")
|
115 |
+
|
116 |
+
# 인덱스 저장
|
117 |
+
if hasattr(base_retriever, 'save'):
|
118 |
+
logger.info(f"검색기 상태 저장 시도: {index_path}")
|
119 |
+
try:
|
120 |
+
base_retriever.save(index_path)
|
121 |
+
logger.info("인덱스 저장 완료")
|
122 |
+
except Exception as e_save:
|
123 |
+
logger.error(f"인덱스 저장 실패: {e_save}", exc_info=True)
|
124 |
+
# 저장 실패는 치명적 오류가 아니므로 계속 진행
|
125 |
+
else:
|
126 |
+
logger.warning("base_retriever에 add_documents 메소드가 없습니다")
|
127 |
+
else:
|
128 |
+
logger.info(f"{data_path}에서 로드할 문서가 없습니다")
|
129 |
+
else:
|
130 |
+
logger.warning("DocumentProcessor에 load_documents_from_directory 메소드가 없습니다")
|
131 |
+
except Exception as e_load:
|
132 |
+
logger.error(f"문서 로드/추가 중 오류: {e_load}", exc_info=True)
|
133 |
+
# 문서 로드 실패는 치명적 오류가 아니므로 계속 진행
|
134 |
+
|
135 |
+
# 3. 재순위화 검색기(ReRanker) 초기화
|
136 |
+
logger.info("재순위화 검색기(ReRanker) 초기화 시도...")
|
137 |
+
|
138 |
+
if retriever is None: # 기존 retriever가 없는 경우에만 시도
|
139 |
+
try:
|
140 |
+
# custom_rerank_fn 함수 정의
|
141 |
+
def custom_rerank_fn(query, results):
|
142 |
+
"""쿼리 용어 빈도에 기반한 재순위화 함수"""
|
143 |
+
logger.debug(f"재순위화 함수 호출됨: 쿼리='{query}', 결과 수={len(results)}")
|
144 |
+
|
145 |
+
# 빈 결과 처리
|
146 |
+
if not results:
|
147 |
+
logger.debug("재순위화할 결과가 없습니다")
|
148 |
+
return results
|
149 |
+
|
150 |
+
query_terms = set(query.lower().split())
|
151 |
+
|
152 |
+
for result in results:
|
153 |
+
if isinstance(result, dict) and "text" in result:
|
154 |
+
text = result["text"].lower()
|
155 |
+
# 쿼리 용어의 문서 내 빈도 계산
|
156 |
+
term_freq = sum(1 for term in query_terms if term in text)
|
157 |
+
# 정규화된 점수 계산 (텍스트 길이에 따라 조정)
|
158 |
+
normalized_score = term_freq / (len(text.split()) + 1) * 10
|
159 |
+
# 최종 점수는 원래 점수의 가중합으로 계산
|
160 |
+
result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
|
161 |
+
elif isinstance(result, dict):
|
162 |
+
# text 필드가 없는 경우 원래 점수 사용
|
163 |
+
result["rerank_score"] = result.get("score", 0)
|
164 |
+
|
165 |
+
# 재순위화 점수 기준으로 정렬
|
166 |
+
results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
|
167 |
+
logger.debug(f"재순위화 완료: 상위 점수={results[0].get('rerank_score', 0) if results else 'N/A'}")
|
168 |
+
return results
|
169 |
+
|
170 |
+
# base_retriever가 유효한지 확인
|
171 |
+
if base_retriever is None or isinstance(base_retriever, MockComponent):
|
172 |
+
raise ValueError("유효한 base_retriever가 없어 ReRanker를 초기화할 수 없습니다")
|
173 |
+
|
174 |
+
# ReRanker 초기화
|
175 |
+
retriever = ReRanker(
|
176 |
+
base_retriever=base_retriever,
|
177 |
+
rerank_fn=custom_rerank_fn,
|
178 |
+
rerank_field="text"
|
179 |
+
)
|
180 |
+
logger.info("재순위화 검색기(ReRanker) 초기화 성공")
|
181 |
+
except Exception as e_rerank:
|
182 |
+
logger.error(f"재순위화 검색기 초기화 실패: {e_rerank}", exc_info=True)
|
183 |
+
|
184 |
+
# 실패 시 기본 검색기로 fallback
|
185 |
+
if not isinstance(base_retriever, MockComponent):
|
186 |
+
logger.warning("fallback: 재순위화 실패, 기본 검색기를 retriever로 사용")
|
187 |
+
retriever = base_retriever
|
188 |
+
else:
|
189 |
+
if MockComponent:
|
190 |
+
logger.warning("MockComponent를 retriever로 사용합니다")
|
191 |
+
retriever = MockComponent()
|
192 |
+
else:
|
193 |
+
logger.info(f"기존 retriever 사용 (이미 초기화됨)")
|
194 |
+
|
195 |
+
# 최종 상태 확인
|
196 |
+
if retriever is None or isinstance(retriever, MockComponent):
|
197 |
+
if base_retriever is None or isinstance(base_retriever, MockComponent):
|
198 |
+
error_msg = "검색기 초기화 실패: base_retriever와 retriever 모두 초기화 실패"
|
199 |
+
logger.error(error_msg)
|
200 |
+
return base_retriever, retriever, False, error_msg
|
201 |
+
else:
|
202 |
+
# base_retriever가 유효하면 retriever로 설정
|
203 |
+
retriever = base_retriever
|
204 |
+
logger.warning("retriever 초기화 실패, base_retriever를 retriever로 사용")
|
205 |
+
|
206 |
+
logger.info("=== 검색기 초기화 성공적으로 완료 ===")
|
207 |
+
return base_retriever, retriever, True, None
|
208 |
+
|
209 |
+
except Exception as e:
|
210 |
+
error_msg = f"검색기 초기화 중 예상치 못한 오류 발생: {str(e)}"
|
211 |
+
logger.error(error_msg, exc_info=True)
|
212 |
+
|
213 |
+
# 완전히 실패한 경우 MockComponent 반환
|
214 |
+
if MockComponent:
|
215 |
+
base_retriever = MockComponent()
|
216 |
+
retriever = MockComponent()
|
217 |
+
|
218 |
+
return base_retriever, retriever, False, error_msg
|
app/static/css/device-style.css
CHANGED
@@ -11,6 +11,128 @@
|
|
11 |
padding: 20px;
|
12 |
}
|
13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
/* 서버 상태 표시 */
|
15 |
.server-status {
|
16 |
padding: 12px 15px;
|
@@ -293,6 +415,64 @@
|
|
293 |
margin-right: 5px;
|
294 |
}
|
295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
296 |
/* 반응형 */
|
297 |
@media (max-width: 768px) {
|
298 |
.device-details {
|
@@ -303,4 +483,20 @@
|
|
303 |
flex-direction: column;
|
304 |
gap: 10px;
|
305 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
306 |
}
|
|
|
11 |
padding: 20px;
|
12 |
}
|
13 |
|
14 |
+
/* 서버 설정 섹션 스타일 */
|
15 |
+
.server-settings-section {
|
16 |
+
margin-bottom: 25px;
|
17 |
+
padding-bottom: 15px;
|
18 |
+
border-bottom: 1px solid var(--border-color);
|
19 |
+
}
|
20 |
+
|
21 |
+
.server-settings-section h2 {
|
22 |
+
display: flex;
|
23 |
+
justify-content: space-between;
|
24 |
+
align-items: center;
|
25 |
+
margin-bottom: 15px;
|
26 |
+
color: var(--primary-color);
|
27 |
+
}
|
28 |
+
|
29 |
+
.toggle-button {
|
30 |
+
background: none;
|
31 |
+
border: none;
|
32 |
+
color: var(--primary-color);
|
33 |
+
cursor: pointer;
|
34 |
+
font-size: 18px;
|
35 |
+
padding: 5px;
|
36 |
+
transition: transform 0.3s ease;
|
37 |
+
}
|
38 |
+
|
39 |
+
.toggle-button:hover {
|
40 |
+
transform: rotate(90deg);
|
41 |
+
}
|
42 |
+
|
43 |
+
.server-settings-panel {
|
44 |
+
background-color: #f8f9fa;
|
45 |
+
border-radius: 6px;
|
46 |
+
padding: 15px;
|
47 |
+
margin-bottom: 15px;
|
48 |
+
border: 1px solid var(--border-color);
|
49 |
+
transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease;
|
50 |
+
}
|
51 |
+
|
52 |
+
.server-settings-panel.closed {
|
53 |
+
max-height: 0;
|
54 |
+
padding: 0 15px;
|
55 |
+
opacity: 0;
|
56 |
+
overflow: hidden;
|
57 |
+
margin: 0;
|
58 |
+
}
|
59 |
+
|
60 |
+
.settings-row {
|
61 |
+
margin-bottom: 12px;
|
62 |
+
}
|
63 |
+
|
64 |
+
.settings-row label {
|
65 |
+
display: block;
|
66 |
+
margin-bottom: 5px;
|
67 |
+
font-weight: 500;
|
68 |
+
font-size: 14px;
|
69 |
+
}
|
70 |
+
|
71 |
+
.input-with-button {
|
72 |
+
display: flex;
|
73 |
+
gap: 8px;
|
74 |
+
}
|
75 |
+
|
76 |
+
#serverUrlInput {
|
77 |
+
flex-grow: 1;
|
78 |
+
padding: 8px 12px;
|
79 |
+
border: 1px solid var(--border-color);
|
80 |
+
border-radius: 4px;
|
81 |
+
font-size: 14px;
|
82 |
+
}
|
83 |
+
|
84 |
+
.save-button {
|
85 |
+
background-color: var(--primary-color);
|
86 |
+
color: white;
|
87 |
+
border: none;
|
88 |
+
border-radius: 4px;
|
89 |
+
padding: 8px 15px;
|
90 |
+
cursor: pointer;
|
91 |
+
font-size: 14px;
|
92 |
+
transition: background-color 0.2s;
|
93 |
+
}
|
94 |
+
|
95 |
+
.save-button:hover {
|
96 |
+
background-color: var(--primary-dark);
|
97 |
+
}
|
98 |
+
|
99 |
+
.connection-status {
|
100 |
+
margin-top: 10px;
|
101 |
+
padding: 8px;
|
102 |
+
border-radius: 4px;
|
103 |
+
font-size: 14px;
|
104 |
+
font-weight: 500;
|
105 |
+
}
|
106 |
+
|
107 |
+
.connection-status.connected {
|
108 |
+
color: var(--success-color);
|
109 |
+
background-color: rgba(16, 185, 129, 0.1);
|
110 |
+
}
|
111 |
+
|
112 |
+
.connection-status.error {
|
113 |
+
color: var(--error-color);
|
114 |
+
background-color: rgba(239, 68, 68, 0.1);
|
115 |
+
}
|
116 |
+
|
117 |
+
.connection-status.pending {
|
118 |
+
color: var(--secondary-color);
|
119 |
+
background-color: rgba(245, 158, 11, 0.1);
|
120 |
+
}
|
121 |
+
|
122 |
+
.server-hint {
|
123 |
+
margin-top: 10px;
|
124 |
+
font-size: 13px;
|
125 |
+
color: var(--light-text);
|
126 |
+
display: flex;
|
127 |
+
align-items: flex-start;
|
128 |
+
gap: 5px;
|
129 |
+
}
|
130 |
+
|
131 |
+
.server-hint i {
|
132 |
+
margin-top: 3px;
|
133 |
+
color: var(--secondary-color);
|
134 |
+
}
|
135 |
+
|
136 |
/* 서버 상태 표시 */
|
137 |
.server-status {
|
138 |
padding: 12px 15px;
|
|
|
415 |
margin-right: 5px;
|
416 |
}
|
417 |
|
418 |
+
/* 알림 메시지 */
|
419 |
+
.notification {
|
420 |
+
position: fixed;
|
421 |
+
bottom: 20px;
|
422 |
+
right: 20px;
|
423 |
+
background-color: white;
|
424 |
+
border-radius: 6px;
|
425 |
+
padding: 15px 20px 15px 15px;
|
426 |
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
427 |
+
min-width: 250px;
|
428 |
+
max-width: 350px;
|
429 |
+
z-index: 1000;
|
430 |
+
animation: slideIn 0.3s ease-out;
|
431 |
+
border-left: 4px solid var(--primary-color);
|
432 |
+
}
|
433 |
+
|
434 |
+
.notification.success {
|
435 |
+
border-left-color: var(--success-color);
|
436 |
+
}
|
437 |
+
|
438 |
+
.notification.error {
|
439 |
+
border-left-color: var(--error-color);
|
440 |
+
}
|
441 |
+
|
442 |
+
.notification.info {
|
443 |
+
border-left-color: var(--primary-color);
|
444 |
+
}
|
445 |
+
|
446 |
+
.notification.warning {
|
447 |
+
border-left-color: var(--secondary-color);
|
448 |
+
}
|
449 |
+
|
450 |
+
.notification-close {
|
451 |
+
position: absolute;
|
452 |
+
top: 5px;
|
453 |
+
right: 5px;
|
454 |
+
background: none;
|
455 |
+
border: none;
|
456 |
+
font-size: 16px;
|
457 |
+
cursor: pointer;
|
458 |
+
color: #888;
|
459 |
+
}
|
460 |
+
|
461 |
+
.notification-close:hover {
|
462 |
+
color: #333;
|
463 |
+
}
|
464 |
+
|
465 |
+
@keyframes slideIn {
|
466 |
+
from {
|
467 |
+
transform: translateX(100%);
|
468 |
+
opacity: 0;
|
469 |
+
}
|
470 |
+
to {
|
471 |
+
transform: translateX(0);
|
472 |
+
opacity: 1;
|
473 |
+
}
|
474 |
+
}
|
475 |
+
|
476 |
/* 반응형 */
|
477 |
@media (max-width: 768px) {
|
478 |
.device-details {
|
|
|
483 |
flex-direction: column;
|
484 |
gap: 10px;
|
485 |
}
|
486 |
+
|
487 |
+
.server-settings-panel {
|
488 |
+
padding: 10px;
|
489 |
+
}
|
490 |
+
|
491 |
+
.input-with-button {
|
492 |
+
flex-direction: column;
|
493 |
+
}
|
494 |
+
|
495 |
+
#serverUrlInput {
|
496 |
+
width: 100%;
|
497 |
+
}
|
498 |
+
|
499 |
+
.save-button {
|
500 |
+
width: 100%;
|
501 |
+
}
|
502 |
}
|
app/static/js/app-device.js
CHANGED
@@ -18,10 +18,13 @@ const loadProgramsButton = document.getElementById('loadProgramsButton');
|
|
18 |
const programsLoading = document.getElementById('programsLoading');
|
19 |
const programsList = document.getElementById('programsList');
|
20 |
const noProgramsMessage = document.getElementById('noProgramsMessage');
|
|
|
|
|
|
|
21 |
|
22 |
// 장치 서버 URL 설정
|
23 |
-
//
|
24 |
-
let DEVICE_SERVER_URL = ''
|
25 |
|
26 |
/**
|
27 |
* 장치 서버 API 경로 생성 함수
|
@@ -43,34 +46,97 @@ function getDeviceApiUrl(endpoint) {
|
|
43 |
*/
|
44 |
function initDeviceServerSettings() {
|
45 |
console.log("장치 서버 설정 초기화 시작");
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
}
|
75 |
|
76 |
// 페이지 로드 시 초기화
|
@@ -89,22 +155,57 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
89 |
}
|
90 |
|
91 |
// 장치 상태 확인 버튼 이벤트 리스너
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
|
|
|
|
96 |
|
97 |
// 장치 목록 새로고침 버튼 이벤트 리스너
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
|
|
|
|
102 |
|
103 |
// 프로그램 목록 로드 버튼 이벤트 리스너
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
108 |
});
|
109 |
|
110 |
/**
|
@@ -148,8 +249,8 @@ async function checkDeviceStatus() {
|
|
148 |
console.log("장치 상태 확인 중...");
|
149 |
|
150 |
// UI 업데이트
|
151 |
-
deviceStatusResult.classList.add('hidden');
|
152 |
-
deviceStatusLoading.classList.remove('hidden');
|
153 |
|
154 |
try {
|
155 |
// 타임아웃 설정을 위한 컨트롤러
|
@@ -172,30 +273,30 @@ async function checkDeviceStatus() {
|
|
172 |
console.log("장치 상태 응답:", data);
|
173 |
|
174 |
// UI 업데이트
|
175 |
-
deviceStatusLoading.classList.add('hidden');
|
176 |
-
deviceStatusResult.classList.remove('hidden');
|
177 |
|
178 |
-
if (data.status === "online") {
|
179 |
// 온라인 상태인 경우
|
180 |
-
statusIcon.innerHTML = '<i class="fas fa-circle online"></i>';
|
181 |
-
statusText.textContent = `서버 상태: ${data.status || '정상'}`;
|
182 |
|
183 |
// 자동으로 장치 목록 로드
|
184 |
loadDevices();
|
185 |
} else {
|
186 |
// 오프라인 또는 오류 상태인 경우
|
187 |
-
statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
188 |
-
statusText.textContent = `서버 오류: ${data.error || '알 수 없는 오류'}`;
|
189 |
}
|
190 |
} catch (error) {
|
191 |
console.error("장치 상태 확인 오류:", error);
|
192 |
|
193 |
// UI 업데이트
|
194 |
-
deviceStatusLoading.classList.add('hidden');
|
195 |
-
deviceStatusResult.classList.remove('hidden');
|
196 |
|
197 |
-
statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
198 |
-
statusText.textContent = handleError(error);
|
199 |
}
|
200 |
}
|
201 |
|
@@ -206,9 +307,9 @@ async function loadDevices() {
|
|
206 |
console.log("장치 목록 로드 중...");
|
207 |
|
208 |
// UI 업데이트
|
209 |
-
deviceList.innerHTML = '';
|
210 |
-
noDevicesMessage.classList.add('hidden');
|
211 |
-
devicesLoading.classList.remove('hidden');
|
212 |
|
213 |
try {
|
214 |
// 타임아웃 설정을 위한 컨트롤러
|
@@ -231,25 +332,27 @@ async function loadDevices() {
|
|
231 |
console.log("장치 목록 응답:", data);
|
232 |
|
233 |
// UI 업데이트
|
234 |
-
devicesLoading.classList.add('hidden');
|
235 |
|
236 |
if (data.devices && data.devices.length > 0) {
|
237 |
// 장치 목록 표시
|
238 |
data.devices.forEach(device => {
|
239 |
const deviceElement = createDeviceItem(device);
|
240 |
-
deviceList.appendChild(deviceElement);
|
241 |
});
|
242 |
} else {
|
243 |
// 장치 없음 메시지 표시
|
244 |
-
noDevicesMessage.classList.remove('hidden');
|
245 |
}
|
246 |
} catch (error) {
|
247 |
console.error("장치 목록 로드 오류:", error);
|
248 |
|
249 |
// UI 업데이트
|
250 |
-
devicesLoading.classList.add('hidden');
|
251 |
-
noDevicesMessage
|
252 |
-
|
|
|
|
|
253 |
}
|
254 |
}
|
255 |
|
@@ -263,7 +366,7 @@ function createDeviceItem(device) {
|
|
263 |
deviceItem.classList.add('device-item');
|
264 |
|
265 |
// 상태에 따른 클래스 추가
|
266 |
-
if (device.status === 'online' || device.status === '온라인') {
|
267 |
deviceItem.classList.add('online');
|
268 |
} else if (device.status === 'offline' || device.status === '오프라인') {
|
269 |
deviceItem.classList.add('offline');
|
@@ -283,7 +386,7 @@ function createDeviceItem(device) {
|
|
283 |
deviceStatusBadge.classList.add('device-status-badge');
|
284 |
|
285 |
// 상태에 따른 배지 설정
|
286 |
-
if (device.status === 'online' || device.status === '온라인') {
|
287 |
deviceStatusBadge.classList.add('online');
|
288 |
deviceStatusBadge.textContent = '온라인';
|
289 |
} else if (device.status === 'offline' || device.status === '오프라인') {
|
@@ -337,9 +440,9 @@ async function loadPrograms() {
|
|
337 |
console.log("프로그램 목록 로드 중...");
|
338 |
|
339 |
// UI 업데이트
|
340 |
-
programsList.innerHTML = '';
|
341 |
-
noProgramsMessage.classList.add('hidden');
|
342 |
-
programsLoading.classList.remove('hidden');
|
343 |
|
344 |
try {
|
345 |
// 타임아웃 설정을 위한 컨트롤러
|
@@ -362,25 +465,27 @@ async function loadPrograms() {
|
|
362 |
console.log("프로그램 목록 응답:", data);
|
363 |
|
364 |
// UI 업데이트
|
365 |
-
programsLoading.classList.add('hidden');
|
366 |
|
367 |
if (data.programs && data.programs.length > 0) {
|
368 |
// 프로그램 목록 표시
|
369 |
data.programs.forEach(program => {
|
370 |
const programElement = createProgramItem(program);
|
371 |
-
programsList.appendChild(programElement);
|
372 |
});
|
373 |
} else {
|
374 |
// 프로그램 없음 메시지 표시
|
375 |
-
noProgramsMessage.classList.remove('hidden');
|
376 |
}
|
377 |
} catch (error) {
|
378 |
console.error("프로그램 목록 로드 오류:", error);
|
379 |
|
380 |
// UI 업데이트
|
381 |
-
programsLoading.classList.add('hidden');
|
382 |
-
noProgramsMessage
|
383 |
-
|
|
|
|
|
384 |
}
|
385 |
}
|
386 |
|
@@ -478,7 +583,7 @@ async function executeProgram(programId, programName) {
|
|
478 |
/**
|
479 |
* 알림 표시 함수
|
480 |
* @param {string} message - 알림 메시지
|
481 |
-
* @param {string} type - 알림 유형 ('success', 'error', 'warning')
|
482 |
*/
|
483 |
function showNotification(message, type = 'info') {
|
484 |
// 기존 알림이 있으면 제거
|
|
|
18 |
const programsLoading = document.getElementById('programsLoading');
|
19 |
const programsList = document.getElementById('programsList');
|
20 |
const noProgramsMessage = document.getElementById('noProgramsMessage');
|
21 |
+
const serverUrlInput = document.getElementById('serverUrlInput'); // 서버 URL 입력
|
22 |
+
const saveServerUrlButton = document.getElementById('saveServerUrlButton'); // 서버 URL 저장 버튼
|
23 |
+
const connectionStatus = document.getElementById('connectionStatus'); // 연결 상태 표시
|
24 |
|
25 |
// 장치 서버 URL 설정
|
26 |
+
// 로컬스토리지에서 서버 URL 로드 또는 기본값 사용
|
27 |
+
let DEVICE_SERVER_URL = localStorage.getItem('device_server_url') || '';
|
28 |
|
29 |
/**
|
30 |
* 장치 서버 API 경로 생성 함수
|
|
|
46 |
*/
|
47 |
function initDeviceServerSettings() {
|
48 |
console.log("장치 서버 설정 초기화 시작");
|
49 |
+
|
50 |
+
// 로컬스토리지에서 URL 로드
|
51 |
+
const savedUrl = localStorage.getItem('device_server_url');
|
52 |
+
|
53 |
+
if (savedUrl) {
|
54 |
+
// 저장된 URL이 있으면 사용
|
55 |
+
console.log(`저장된 장치 서버 URL 로드: ${savedUrl}`);
|
56 |
+
DEVICE_SERVER_URL = savedUrl;
|
57 |
+
if (serverUrlInput) {
|
58 |
+
serverUrlInput.value = savedUrl;
|
59 |
+
}
|
60 |
+
} else {
|
61 |
+
// 설정 없음 - 자동 생성 (현재 호스트 + 포트 8000)
|
62 |
+
const currentHost = window.location.hostname;
|
63 |
+
const protocol = window.location.protocol;
|
64 |
+
const defaultUrl = `${protocol}//${currentHost}:8000`;
|
65 |
+
DEVICE_SERVER_URL = defaultUrl;
|
66 |
+
console.log(`장치 서버 URL 자동 설정: ${DEVICE_SERVER_URL}`);
|
67 |
+
|
68 |
+
if (serverUrlInput) {
|
69 |
+
serverUrlInput.value = defaultUrl;
|
70 |
+
}
|
71 |
+
|
72 |
+
// 자동 생성된 URL 저장
|
73 |
+
localStorage.setItem('device_server_url', defaultUrl);
|
74 |
+
}
|
75 |
+
|
76 |
+
// 서버 연결 상태 초기 확인
|
77 |
+
updateConnectionStatus();
|
78 |
+
}
|
79 |
+
|
80 |
+
/**
|
81 |
+
* 서버 URL 저장 함수
|
82 |
+
*/
|
83 |
+
function saveServerUrl() {
|
84 |
+
if (!serverUrlInput) return;
|
85 |
+
|
86 |
+
const newUrl = serverUrlInput.value.trim();
|
87 |
+
|
88 |
+
if (!newUrl) {
|
89 |
+
showNotification('서버 URL을 입력해주세요.', 'error');
|
90 |
+
return;
|
91 |
+
}
|
92 |
+
|
93 |
+
try {
|
94 |
+
// URL 유효성 검사
|
95 |
+
new URL(newUrl);
|
96 |
+
|
97 |
+
// 로컬스토리지에 저장
|
98 |
+
localStorage.setItem('device_server_url', newUrl);
|
99 |
+
DEVICE_SERVER_URL = newUrl;
|
100 |
+
console.log(`장치 서버 URL 변경됨: ${newUrl}`);
|
101 |
+
|
102 |
+
// 알림 표시
|
103 |
+
showNotification('서버 URL이 저장되었습니다.', 'success');
|
104 |
+
|
105 |
+
// 연결 상태 업데이트
|
106 |
+
updateConnectionStatus();
|
107 |
+
} catch (error) {
|
108 |
+
console.error(`URL 유효성 검사 실패: ${error}`);
|
109 |
+
showNotification('유효한 URL 형식이 아닙니다.', 'error');
|
110 |
+
}
|
111 |
+
}
|
112 |
+
|
113 |
+
/**
|
114 |
+
* 서버 연결 상태 업데이트 함수
|
115 |
+
*/
|
116 |
+
async function updateConnectionStatus() {
|
117 |
+
if (!connectionStatus) return;
|
118 |
+
|
119 |
+
connectionStatus.textContent = '연결 상태: 확인 중...';
|
120 |
+
connectionStatus.className = 'connection-status pending';
|
121 |
+
|
122 |
+
try {
|
123 |
+
const response = await fetch(getDeviceApiUrl('/api/status'), {
|
124 |
+
signal: AbortSignal.timeout(3000) // 3초 타임아웃
|
125 |
});
|
126 |
+
|
127 |
+
if (response.ok) {
|
128 |
+
const data = await response.json();
|
129 |
+
connectionStatus.textContent = '연결 상태: 연결됨 ✓';
|
130 |
+
connectionStatus.className = 'connection-status connected';
|
131 |
+
} else {
|
132 |
+
connectionStatus.textContent = '연결 상태: 연결 실패 (응답 오류) ✗';
|
133 |
+
connectionStatus.className = 'connection-status error';
|
134 |
+
}
|
135 |
+
} catch (error) {
|
136 |
+
console.error(`서버 연결 확인 실패: ${error}`);
|
137 |
+
connectionStatus.textContent = '연결 상태: 연결 실패 ✗';
|
138 |
+
connectionStatus.className = 'connection-status error';
|
139 |
+
}
|
140 |
}
|
141 |
|
142 |
// 페이지 로드 시 초기화
|
|
|
155 |
}
|
156 |
|
157 |
// 장치 상태 확인 버튼 이벤트 리스너
|
158 |
+
if (checkDeviceStatusButton) {
|
159 |
+
checkDeviceStatusButton.addEventListener('click', () => {
|
160 |
+
console.log("장치 상태 확인 버튼 클릭");
|
161 |
+
checkDeviceStatus();
|
162 |
+
});
|
163 |
+
}
|
164 |
|
165 |
// 장치 목록 새로고침 버튼 이벤트 리스너
|
166 |
+
if (refreshDevicesButton) {
|
167 |
+
refreshDevicesButton.addEventListener('click', () => {
|
168 |
+
console.log("장치 목록 새로고침 버튼 클릭");
|
169 |
+
loadDevices();
|
170 |
+
});
|
171 |
+
}
|
172 |
|
173 |
// 프로그램 목록 로드 버튼 이벤트 리스너
|
174 |
+
if (loadProgramsButton) {
|
175 |
+
loadProgramsButton.addEventListener('click', () => {
|
176 |
+
console.log("프로그램 목록 로드 버튼 클릭");
|
177 |
+
loadPrograms();
|
178 |
+
});
|
179 |
+
}
|
180 |
+
|
181 |
+
// 서버 URL 저장 버튼 이벤트 리스너
|
182 |
+
if (saveServerUrlButton) {
|
183 |
+
saveServerUrlButton.addEventListener('click', () => {
|
184 |
+
console.log("서버 URL 저장 버튼 클릭");
|
185 |
+
saveServerUrl();
|
186 |
+
});
|
187 |
+
}
|
188 |
+
|
189 |
+
// 서버 URL 입력 필드 엔터 키 이벤트 리스너
|
190 |
+
if (serverUrlInput) {
|
191 |
+
serverUrlInput.addEventListener('keypress', (e) => {
|
192 |
+
if (e.key === 'Enter') {
|
193 |
+
console.log("서버 URL 입력 필드 엔터 키 입력");
|
194 |
+
saveServerUrl();
|
195 |
+
}
|
196 |
+
});
|
197 |
+
}
|
198 |
+
|
199 |
+
// 설정 패널 토글 이벤트 리스너 (id가 'toggleSettings'인 요소가 있다면)
|
200 |
+
const toggleSettings = document.getElementById('toggleSettings');
|
201 |
+
if (toggleSettings) {
|
202 |
+
toggleSettings.addEventListener('click', () => {
|
203 |
+
const settingsPanel = document.getElementById('serverSettingsPanel');
|
204 |
+
if (settingsPanel) {
|
205 |
+
settingsPanel.classList.toggle('open');
|
206 |
+
}
|
207 |
+
});
|
208 |
+
}
|
209 |
});
|
210 |
|
211 |
/**
|
|
|
249 |
console.log("장치 상태 확인 중...");
|
250 |
|
251 |
// UI 업데이트
|
252 |
+
if (deviceStatusResult) deviceStatusResult.classList.add('hidden');
|
253 |
+
if (deviceStatusLoading) deviceStatusLoading.classList.remove('hidden');
|
254 |
|
255 |
try {
|
256 |
// 타임아웃 설정을 위한 컨트롤러
|
|
|
273 |
console.log("장치 상태 응답:", data);
|
274 |
|
275 |
// UI 업데이트
|
276 |
+
if (deviceStatusLoading) deviceStatusLoading.classList.add('hidden');
|
277 |
+
if (deviceStatusResult) deviceStatusResult.classList.remove('hidden');
|
278 |
|
279 |
+
if (data.status === "online" || data.status === "running") {
|
280 |
// 온라인 상태인 경우
|
281 |
+
if (statusIcon) statusIcon.innerHTML = '<i class="fas fa-circle online"></i>';
|
282 |
+
if (statusText) statusText.textContent = `서버 상태: ${data.status || '정상'}`;
|
283 |
|
284 |
// 자동으로 장치 목록 로드
|
285 |
loadDevices();
|
286 |
} else {
|
287 |
// 오프라인 또는 오류 상태인 경우
|
288 |
+
if (statusIcon) statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
289 |
+
if (statusText) statusText.textContent = `서버 오류: ${data.error || '알 수 없는 오류'}`;
|
290 |
}
|
291 |
} catch (error) {
|
292 |
console.error("장치 상태 확인 오류:", error);
|
293 |
|
294 |
// UI 업데이트
|
295 |
+
if (deviceStatusLoading) deviceStatusLoading.classList.add('hidden');
|
296 |
+
if (deviceStatusResult) deviceStatusResult.classList.remove('hidden');
|
297 |
|
298 |
+
if (statusIcon) statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
299 |
+
if (statusText) statusText.textContent = handleError(error);
|
300 |
}
|
301 |
}
|
302 |
|
|
|
307 |
console.log("장치 목록 로드 중...");
|
308 |
|
309 |
// UI 업데이트
|
310 |
+
if (deviceList) deviceList.innerHTML = '';
|
311 |
+
if (noDevicesMessage) noDevicesMessage.classList.add('hidden');
|
312 |
+
if (devicesLoading) devicesLoading.classList.remove('hidden');
|
313 |
|
314 |
try {
|
315 |
// 타임아웃 설정을 위한 컨트롤러
|
|
|
332 |
console.log("장치 목록 응답:", data);
|
333 |
|
334 |
// UI 업데이트
|
335 |
+
if (devicesLoading) devicesLoading.classList.add('hidden');
|
336 |
|
337 |
if (data.devices && data.devices.length > 0) {
|
338 |
// 장치 목록 표시
|
339 |
data.devices.forEach(device => {
|
340 |
const deviceElement = createDeviceItem(device);
|
341 |
+
if (deviceList) deviceList.appendChild(deviceElement);
|
342 |
});
|
343 |
} else {
|
344 |
// 장치 없음 메시지 표시
|
345 |
+
if (noDevicesMessage) noDevicesMessage.classList.remove('hidden');
|
346 |
}
|
347 |
} catch (error) {
|
348 |
console.error("장치 목록 로드 오류:", error);
|
349 |
|
350 |
// UI 업데이트
|
351 |
+
if (devicesLoading) devicesLoading.classList.add('hidden');
|
352 |
+
if (noDevicesMessage) {
|
353 |
+
noDevicesMessage.classList.remove('hidden');
|
354 |
+
noDevicesMessage.textContent = handleError(error);
|
355 |
+
}
|
356 |
}
|
357 |
}
|
358 |
|
|
|
366 |
deviceItem.classList.add('device-item');
|
367 |
|
368 |
// 상태에 따른 클래스 추가
|
369 |
+
if (device.status === 'online' || device.status === '온라인' || device.status === 'Connected') {
|
370 |
deviceItem.classList.add('online');
|
371 |
} else if (device.status === 'offline' || device.status === '오프라인') {
|
372 |
deviceItem.classList.add('offline');
|
|
|
386 |
deviceStatusBadge.classList.add('device-status-badge');
|
387 |
|
388 |
// 상태에 따른 배지 설정
|
389 |
+
if (device.status === 'online' || device.status === '온라인' || device.status === 'Connected') {
|
390 |
deviceStatusBadge.classList.add('online');
|
391 |
deviceStatusBadge.textContent = '온라인';
|
392 |
} else if (device.status === 'offline' || device.status === '오프라인') {
|
|
|
440 |
console.log("프로그램 목록 로드 중...");
|
441 |
|
442 |
// UI 업데이트
|
443 |
+
if (programsList) programsList.innerHTML = '';
|
444 |
+
if (noProgramsMessage) noProgramsMessage.classList.add('hidden');
|
445 |
+
if (programsLoading) programsLoading.classList.remove('hidden');
|
446 |
|
447 |
try {
|
448 |
// 타임아웃 설정을 위한 컨트롤러
|
|
|
465 |
console.log("프로그램 목록 응답:", data);
|
466 |
|
467 |
// UI 업데이트
|
468 |
+
if (programsLoading) programsLoading.classList.add('hidden');
|
469 |
|
470 |
if (data.programs && data.programs.length > 0) {
|
471 |
// 프로그램 목록 표시
|
472 |
data.programs.forEach(program => {
|
473 |
const programElement = createProgramItem(program);
|
474 |
+
if (programsList) programsList.appendChild(programElement);
|
475 |
});
|
476 |
} else {
|
477 |
// 프로그램 없음 메시지 표시
|
478 |
+
if (noProgramsMessage) noProgramsMessage.classList.remove('hidden');
|
479 |
}
|
480 |
} catch (error) {
|
481 |
console.error("프로그램 목록 로드 오류:", error);
|
482 |
|
483 |
// UI 업데이트
|
484 |
+
if (programsLoading) programsLoading.classList.add('hidden');
|
485 |
+
if (noProgramsMessage) {
|
486 |
+
noProgramsMessage.classList.remove('hidden');
|
487 |
+
noProgramsMessage.textContent = handleError(error);
|
488 |
+
}
|
489 |
}
|
490 |
}
|
491 |
|
|
|
583 |
/**
|
584 |
* 알림 표시 함수
|
585 |
* @param {string} message - 알림 메시지
|
586 |
+
* @param {string} type - 알림 유형 ('success', 'error', 'warning', 'info')
|
587 |
*/
|
588 |
function showNotification(message, type = 'info') {
|
589 |
// 기존 알림이 있으면 제거
|
app/templates/error.html
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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/styles.css">
|
8 |
+
<style>
|
9 |
+
.error-container {
|
10 |
+
max-width: 800px;
|
11 |
+
margin: 100px auto;
|
12 |
+
padding: 30px;
|
13 |
+
background-color: #fff;
|
14 |
+
border-radius: 10px;
|
15 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
16 |
+
text-align: center;
|
17 |
+
}
|
18 |
+
|
19 |
+
.error-icon {
|
20 |
+
font-size: 64px;
|
21 |
+
color: #e74c3c;
|
22 |
+
margin-bottom: 20px;
|
23 |
+
}
|
24 |
+
|
25 |
+
.error-title {
|
26 |
+
font-size: 24px;
|
27 |
+
font-weight: bold;
|
28 |
+
margin-bottom: 15px;
|
29 |
+
color: #e74c3c;
|
30 |
+
}
|
31 |
+
|
32 |
+
.error-message {
|
33 |
+
font-size: 18px;
|
34 |
+
margin-bottom: 25px;
|
35 |
+
color: #555;
|
36 |
+
}
|
37 |
+
|
38 |
+
.error-details {
|
39 |
+
background-color: #f8f9fa;
|
40 |
+
padding: 15px;
|
41 |
+
border-radius: 5px;
|
42 |
+
margin-bottom: 25px;
|
43 |
+
text-align: left;
|
44 |
+
font-family: monospace;
|
45 |
+
white-space: pre-wrap;
|
46 |
+
font-size: 14px;
|
47 |
+
}
|
48 |
+
|
49 |
+
.home-button {
|
50 |
+
display: inline-block;
|
51 |
+
padding: 10px 20px;
|
52 |
+
background-color: #3498db;
|
53 |
+
color: white;
|
54 |
+
text-decoration: none;
|
55 |
+
border-radius: 5px;
|
56 |
+
font-weight: bold;
|
57 |
+
transition: background-color 0.3s;
|
58 |
+
}
|
59 |
+
|
60 |
+
.home-button:hover {
|
61 |
+
background-color: #2980b9;
|
62 |
+
}
|
63 |
+
</style>
|
64 |
+
</head>
|
65 |
+
<body>
|
66 |
+
<div class="error-container">
|
67 |
+
<div class="error-icon">⚠️</div>
|
68 |
+
<div class="error-title">오류가 발생했습니다</div>
|
69 |
+
<div class="error-message">{{ error }}</div>
|
70 |
+
|
71 |
+
{% if details %}
|
72 |
+
<div class="error-details">{{ details }}</div>
|
73 |
+
{% endif %}
|
74 |
+
|
75 |
+
<div class="error-actions">
|
76 |
+
<a href="/" class="home-button">홈으로 돌아가기</a>
|
77 |
+
</div>
|
78 |
+
</div>
|
79 |
+
|
80 |
+
<script>
|
81 |
+
// 오류 발생 로그 출력
|
82 |
+
console.error("페이지 오류:", "{{ error }}");
|
83 |
+
{% if details %}
|
84 |
+
console.error("오류 상세:", "{{ details }}");
|
85 |
+
{% endif %}
|
86 |
+
</script>
|
87 |
+
</body>
|
88 |
+
</html>
|
app/templates/index.html
CHANGED
@@ -6,6 +6,7 @@
|
|
6 |
<title>RAG 검색 챗봇</title>
|
7 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
8 |
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
|
9 |
</head>
|
10 |
<body>
|
11 |
<div class="container">
|
@@ -124,6 +125,31 @@
|
|
124 |
<!-- 장치관리 탭 -->
|
125 |
<section id="deviceSection" class="tab-content">
|
126 |
<div class="device-container">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
<div class="device-status-section">
|
128 |
<h2>장치 상태</h2>
|
129 |
<button id="checkDeviceStatusButton" class="refresh-button">
|
|
|
6 |
<title>RAG 검색 챗봇</title>
|
7 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
8 |
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
9 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/device-style.css') }}">
|
10 |
</head>
|
11 |
<body>
|
12 |
<div class="container">
|
|
|
125 |
<!-- 장치관리 탭 -->
|
126 |
<section id="deviceSection" class="tab-content">
|
127 |
<div class="device-container">
|
128 |
+
<!-- 서버 설정 섹션 (새로 추가) -->
|
129 |
+
<div class="server-settings-section">
|
130 |
+
<h2>
|
131 |
+
서버 설정
|
132 |
+
<button id="toggleSettings" class="toggle-button" title="설정 토글">
|
133 |
+
<i class="fas fa-cog"></i>
|
134 |
+
</button>
|
135 |
+
</h2>
|
136 |
+
|
137 |
+
<div id="serverSettingsPanel" class="server-settings-panel">
|
138 |
+
<div class="settings-row">
|
139 |
+
<label for="serverUrlInput">서버 URL:</label>
|
140 |
+
<div class="input-with-button">
|
141 |
+
<input type="text" id="serverUrlInput" placeholder="http://localhost:8000 또는 ngrok URL">
|
142 |
+
<button id="saveServerUrlButton" class="save-button">저장</button>
|
143 |
+
</div>
|
144 |
+
</div>
|
145 |
+
<div id="connectionStatus" class="connection-status">연결 상태: 확인 중...</div>
|
146 |
+
<div class="server-hint">
|
147 |
+
<i class="fas fa-info-circle"></i>
|
148 |
+
<span>ngrok 터널링을 사용하는 경우 URL을 입력하세요. (예: https://xxxx-xxx-xxx.ngrok.io)</span>
|
149 |
+
</div>
|
150 |
+
</div>
|
151 |
+
</div>
|
152 |
+
|
153 |
<div class="device-status-section">
|
154 |
<h2>장치 상태</h2>
|
155 |
<button id="checkDeviceStatusButton" class="refresh-button">
|
app/voice_routes.py
ADDED
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
RAG 검색 챗봇 - 음성 관련 API 라우트
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import logging
|
7 |
+
import tempfile
|
8 |
+
import threading
|
9 |
+
from flask import request, jsonify
|
10 |
+
|
11 |
+
# 로거 가져오기
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
def register_voice_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor,
|
15 |
+
app_ready_event, init_success_event, initialization_error):
|
16 |
+
"""
|
17 |
+
음성 관련 API 라우트 등록
|
18 |
+
"""
|
19 |
+
|
20 |
+
def allowed_audio_file(filename):
|
21 |
+
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
22 |
+
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
23 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
24 |
+
|
25 |
+
# --- Voice Chat API ---
|
26 |
+
@app.route('/api/voice', methods=['POST'])
|
27 |
+
@login_required
|
28 |
+
def voice_chat():
|
29 |
+
"""음성 챗 API 엔드포인트"""
|
30 |
+
try:
|
31 |
+
# 앱이 준비되었는지 확인
|
32 |
+
is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False
|
33 |
+
if not is_ready:
|
34 |
+
logger.warning("앱이 아직 초기화 중입니다.")
|
35 |
+
return jsonify({
|
36 |
+
"error": "앱 초기화 중...",
|
37 |
+
"answer": "죄송합니다. 시스템이 아직 준비 중입니다."
|
38 |
+
}), 200
|
39 |
+
|
40 |
+
# 초기화 성공 여부 확인
|
41 |
+
is_success = init_success_event.is_set() if isinstance(init_success_event, threading.Event) else False
|
42 |
+
if not is_success and initialization_error:
|
43 |
+
logger.warning(f"초기화 실패 상태에서 음성 API 요청: {initialization_error}")
|
44 |
+
return jsonify({
|
45 |
+
"error": "검색기 초기화 실패",
|
46 |
+
"answer": f"죄송합니다. 검색 시스템이 초기화되지 않았습니다. (오류: {initialization_error})"
|
47 |
+
}), 200
|
48 |
+
|
49 |
+
# STT 클라이언트 확인
|
50 |
+
if stt_client is None or not hasattr(stt_client, 'transcribe_audio'):
|
51 |
+
logger.error("음성 API 요청 시 STT 클라이언트가 준비되지 않음")
|
52 |
+
return jsonify({"error": "음성 인식 서비스 준비 안됨"}), 503
|
53 |
+
|
54 |
+
logger.info("음성 챗 요청 수신")
|
55 |
+
|
56 |
+
if 'audio' not in request.files:
|
57 |
+
logger.error("오디오 파일이 제공되지 않음")
|
58 |
+
return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
|
59 |
+
|
60 |
+
audio_file = request.files['audio']
|
61 |
+
logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
|
62 |
+
|
63 |
+
try:
|
64 |
+
# 오디오 파일 임시 저장 및 처리
|
65 |
+
with tempfile.NamedTemporaryFile(delete=True, suffix=os.path.splitext(audio_file.filename)[1]) as temp_audio:
|
66 |
+
audio_file.save(temp_audio.name)
|
67 |
+
logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
|
68 |
+
# STT 수행 (바이트 전달 가정)
|
69 |
+
with open(temp_audio.name, 'rb') as f_bytes:
|
70 |
+
audio_bytes = f_bytes.read()
|
71 |
+
stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
|
72 |
+
|
73 |
+
# STT 결과 처리
|
74 |
+
if not isinstance(stt_result, dict) or not stt_result.get("success"):
|
75 |
+
error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
|
76 |
+
logger.error(f"음성인식 실패: {error_msg}")
|
77 |
+
return jsonify({"error": "음성인식 실패", "details": error_msg}), 500
|
78 |
+
|
79 |
+
transcription = stt_result.get("text", "")
|
80 |
+
if not transcription:
|
81 |
+
logger.warning("음성인식 결과가 비어있습니다.")
|
82 |
+
return jsonify({
|
83 |
+
"transcription": "",
|
84 |
+
"answer": "음성에서 텍스트를 인식하지 못했습니다.",
|
85 |
+
"sources": []
|
86 |
+
}), 200
|
87 |
+
|
88 |
+
logger.info(f"음성인식 성공: {transcription[:50]}...")
|
89 |
+
|
90 |
+
# 검색 수행
|
91 |
+
search_results = []
|
92 |
+
search_warning = None
|
93 |
+
|
94 |
+
# 검색기 상태 확인 및 검색 수행
|
95 |
+
if retriever is None:
|
96 |
+
logger.warning("Retriever가 초기화되지 않았습니다.")
|
97 |
+
search_warning = "검색 기능이 아직 준비되지 않았습니다."
|
98 |
+
elif hasattr(retriever, 'is_mock') and retriever.is_mock:
|
99 |
+
logger.info("Mock Retriever 사용 중 - 검색 결과 없음.")
|
100 |
+
search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다."
|
101 |
+
elif not hasattr(retriever, 'search'):
|
102 |
+
logger.warning("Retriever에 search 메소드가 없습니다.")
|
103 |
+
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
104 |
+
else:
|
105 |
+
try:
|
106 |
+
logger.info(f"검색 수행: {transcription[:50]}...")
|
107 |
+
search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
|
108 |
+
|
109 |
+
if not search_results:
|
110 |
+
logger.info("검색 결과가 없습니다.")
|
111 |
+
else:
|
112 |
+
logger.info(f"검색 결과: {len(search_results)}개 항목")
|
113 |
+
except Exception as e:
|
114 |
+
logger.error(f"검색 처리 중 오류: {e}", exc_info=True)
|
115 |
+
search_results = []
|
116 |
+
search_warning = f"검색 중 오류 발생: {str(e)}"
|
117 |
+
|
118 |
+
# 컨텍스트 준비
|
119 |
+
context = ""
|
120 |
+
if search_results:
|
121 |
+
if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
122 |
+
logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.")
|
123 |
+
else:
|
124 |
+
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
125 |
+
logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)")
|
126 |
+
|
127 |
+
# LLM 인터페이스 호출
|
128 |
+
llm_id = request.form.get('llm_id', None)
|
129 |
+
|
130 |
+
if not context:
|
131 |
+
if search_warning:
|
132 |
+
logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}")
|
133 |
+
answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})"
|
134 |
+
else:
|
135 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
136 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
137 |
+
else:
|
138 |
+
if llm_interface is None or not hasattr(llm_interface, 'rag_generate'):
|
139 |
+
logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.")
|
140 |
+
answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다."
|
141 |
+
else:
|
142 |
+
try:
|
143 |
+
# LLM을 통한 응답 생성
|
144 |
+
answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
|
145 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
146 |
+
except Exception as llm_err:
|
147 |
+
logger.error(f"LLM 호출 중 오류: {llm_err}", exc_info=True)
|
148 |
+
answer = f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(llm_err)}"
|
149 |
+
|
150 |
+
# 소스 정보 추출
|
151 |
+
sources = []
|
152 |
+
if search_results:
|
153 |
+
for result in search_results:
|
154 |
+
if not isinstance(result, dict):
|
155 |
+
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
156 |
+
continue
|
157 |
+
|
158 |
+
source_info = {}
|
159 |
+
source_key = result.get("source")
|
160 |
+
if not source_key and "metadata" in result and isinstance(result["metadata"], dict):
|
161 |
+
source_key = result["metadata"].get("source")
|
162 |
+
|
163 |
+
if source_key:
|
164 |
+
source_info["name"] = os.path.basename(source_key)
|
165 |
+
source_info["path"] = source_key
|
166 |
+
else:
|
167 |
+
source_info["name"] = "알 수 없는 소스"
|
168 |
+
|
169 |
+
if "score" in result:
|
170 |
+
source_info["score"] = result["score"]
|
171 |
+
if "rerank_score" in result:
|
172 |
+
source_info["rerank_score"] = result["rerank_score"]
|
173 |
+
|
174 |
+
sources.append(source_info)
|
175 |
+
|
176 |
+
# 최종 응답
|
177 |
+
response_data = {
|
178 |
+
"transcription": transcription,
|
179 |
+
"answer": answer,
|
180 |
+
"sources": sources,
|
181 |
+
"search_warning": search_warning
|
182 |
+
}
|
183 |
+
|
184 |
+
# LLM 정보 추가 (옵션)
|
185 |
+
if hasattr(llm_interface, 'get_current_llm_details'):
|
186 |
+
response_data["llm"] = llm_interface.get_current_llm_details()
|
187 |
+
|
188 |
+
return jsonify(response_data)
|
189 |
+
|
190 |
+
except Exception as e:
|
191 |
+
logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
|
192 |
+
return jsonify({
|
193 |
+
"error": "음성 처리 중 내부 오류 발생",
|
194 |
+
"details": str(e),
|
195 |
+
"answer": "죄송합니다. 오디오 처리 중 오류가 발생했습니다."
|
196 |
+
}), 500
|
197 |
+
|
198 |
+
except Exception as e:
|
199 |
+
logger.error(f"음성 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True)
|
200 |
+
return jsonify({
|
201 |
+
"error": f"예상치 못한 오류 발생: {str(e)}",
|
202 |
+
"answer": "죄송합니다. 서버에서 오류가 발생했습니다."
|
203 |
+
}), 500
|
app_gradio.py
DELETED
@@ -1,206 +0,0 @@
|
|
1 |
-
import os
|
2 |
-
import gradio as gr
|
3 |
-
import json
|
4 |
-
import logging
|
5 |
-
from app.app import app as flask_app # Flask 앱 가져오기
|
6 |
-
from flask import json as flask_json
|
7 |
-
from retrying import retry
|
8 |
-
|
9 |
-
# 로거 설정
|
10 |
-
logging.basicConfig(
|
11 |
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
12 |
-
level=logging.DEBUG # INFO에서 DEBUG로 변경하여 더 상세한 로그 확인
|
13 |
-
)
|
14 |
-
logger = logging.getLogger(__name__)
|
15 |
-
|
16 |
-
# Flask 테스트 클라이언트 초기화
|
17 |
-
@retry(tries=5, delay=1, backoff=2)
|
18 |
-
def init_flask_client():
|
19 |
-
"""백그라운드 실행 Flask 서버에 연결하는 클라이언트 초기화"""
|
20 |
-
try:
|
21 |
-
test_client = flask_app.test_client()
|
22 |
-
logger.info("Flask 테스트 클라이언트 초기화 성공")
|
23 |
-
return test_client
|
24 |
-
except Exception as e:
|
25 |
-
logger.error(f"Flask 테스트 클라이언트 초기화 실패: {e}")
|
26 |
-
raise e
|
27 |
-
|
28 |
-
# 소스 정보 포맷팅 헤루퍼 함수
|
29 |
-
def format_source_info(sources, prefix=""):
|
30 |
-
"""검색 결과 소스 정보를 형식화
|
31 |
-
|
32 |
-
Args:
|
33 |
-
sources: 소스 정보 리스트
|
34 |
-
prefix: 로그 메시지 접두사 (기본값: "")
|
35 |
-
|
36 |
-
Returns:
|
37 |
-
형식화된 소스 정보 문자열
|
38 |
-
"""
|
39 |
-
source_info = ""
|
40 |
-
if sources and len(sources) > 0:
|
41 |
-
logger.debug(f"{prefix}소스 정보 포맷팅: {len(sources)}개 소스: {json.dumps(sources, ensure_ascii=False, indent=2)}")
|
42 |
-
|
43 |
-
source_list = []
|
44 |
-
for src in sources:
|
45 |
-
source_text = src["source"]
|
46 |
-
# id 필드가 있으면 함께 표시
|
47 |
-
if "id" in src:
|
48 |
-
logger.debug(f"{prefix}ID 필드 발견: {src['id']}")
|
49 |
-
source_text += f" (ID: {src['id']})"
|
50 |
-
source_list.append(source_text)
|
51 |
-
|
52 |
-
if source_list:
|
53 |
-
source_info = "\n\n참조 소스:\n" + "\n".join(source_list)
|
54 |
-
logger.debug(f"{prefix}최종 소스 정보 형식: {source_info}")
|
55 |
-
|
56 |
-
return source_info
|
57 |
-
|
58 |
-
# Flask 테스트 클라이언트 초기화
|
59 |
-
flask_client = init_flask_client()
|
60 |
-
|
61 |
-
# Gradio 버전 확인 메시지
|
62 |
-
logger.info(f"Gradio 버전: {gr.__version__}")
|
63 |
-
|
64 |
-
# Gradio 인터페이스 생성
|
65 |
-
with gr.Blocks(title="RAG 검색 챗봇 with 음성인식") as demo:
|
66 |
-
gr.HTML("""
|
67 |
-
<div style="text-align: center; max-width: 800px; margin: 0 auto;">
|
68 |
-
<h1>RAG 검색 챗봇 with 음성인식</h1>
|
69 |
-
<p>텍스트 또는 음성으로 질문을 입력하세요.</p>
|
70 |
-
</div>
|
71 |
-
""")
|
72 |
-
|
73 |
-
with gr.Tab("텍스트 챗"):
|
74 |
-
text_input = gr.Textbox(label="질문 입력", placeholder="여기에 질문을 입력하세요...")
|
75 |
-
text_output = gr.Textbox(label="응답", interactive=False)
|
76 |
-
text_button = gr.Button("질문 제출")
|
77 |
-
|
78 |
-
with gr.Tab("음성 챗"):
|
79 |
-
with gr.Row():
|
80 |
-
# 녹음 UI 개선: 마이크로폰으로 소스 지정 및 음파 표시 활성화
|
81 |
-
audio_input = gr.Audio(
|
82 |
-
label="음성 입력",
|
83 |
-
type="filepath",
|
84 |
-
sources=["microphone"],
|
85 |
-
show_label=True,
|
86 |
-
waveform_options={"show_controls": True, "normalize": True},
|
87 |
-
interactive=True
|
88 |
-
)
|
89 |
-
|
90 |
-
audio_transcription = gr.Textbox(label="인식된 텍스트", interactive=False)
|
91 |
-
audio_output = gr.Textbox(label="응답", interactive=False)
|
92 |
-
gr.Markdown("""
|
93 |
-
<div style="text-align: center; margin: 10px 0;">
|
94 |
-
<p>녹음 정지 버튼을 누르면 자동으로 음성이 전송됩니다.</p>
|
95 |
-
</div>
|
96 |
-
""")
|
97 |
-
|
98 |
-
with gr.Tab("문서 업로드"):
|
99 |
-
doc_input = gr.File(label="문서 업로드", file_types=[".txt", ".md", ".pdf", ".docx", ".csv"])
|
100 |
-
doc_output = gr.Textbox(label="업로드 결과", interactive=False)
|
101 |
-
doc_button = gr.Button("문서 업로드")
|
102 |
-
|
103 |
-
# 텍스트 챗 기능
|
104 |
-
def handle_text_chat(query):
|
105 |
-
if not query:
|
106 |
-
return "질문을 입력하세요."
|
107 |
-
try:
|
108 |
-
logger.info("텍스트 챗 요청: /api/chat")
|
109 |
-
response = flask_client.post("/api/chat", json={"query": query})
|
110 |
-
data = flask_json.loads(response.data)
|
111 |
-
|
112 |
-
# 디버깅을 위한 API 응답 로그
|
113 |
-
logger.info(f"API 응답 구조: {json.dumps(data, ensure_ascii=False, indent=2)[:500]}...")
|
114 |
-
|
115 |
-
if "error" in data:
|
116 |
-
logger.error(f"텍스트 챗 오류: {data['error']}")
|
117 |
-
return data["error"]
|
118 |
-
|
119 |
-
# 소스 정보 추출 및 포맷팅
|
120 |
-
source_info = ""
|
121 |
-
if "sources" in data and data["sources"]:
|
122 |
-
source_info = format_source_info(data["sources"])
|
123 |
-
|
124 |
-
# 응답과 소스 정보를 함께 반환
|
125 |
-
return data["answer"] + source_info
|
126 |
-
except Exception as e:
|
127 |
-
logger.error(f"텍스트 챗 처리 실패: {str(e)}")
|
128 |
-
return f"처리 중 오류 발생: {str(e)}"
|
129 |
-
|
130 |
-
# 음성 챗 기능
|
131 |
-
def handle_voice_chat(audio_file):
|
132 |
-
if not audio_file:
|
133 |
-
return "", "음성을 업로드하세요."
|
134 |
-
try:
|
135 |
-
logger.info("음성 챗 요청: /api/voice")
|
136 |
-
with open(audio_file, "rb") as f:
|
137 |
-
# Flask 테스트 클라이언트는 files 직접 지원 안 하므로, 데이터를 읽어 전달
|
138 |
-
response = flask_client.post(
|
139 |
-
"/api/voice",
|
140 |
-
data={"audio": (f, "audio_file")}
|
141 |
-
)
|
142 |
-
data = flask_json.loads(response.data)
|
143 |
-
|
144 |
-
# 디버깅을 위한 API 응답 로그
|
145 |
-
logger.info(f"[음성챗] API 응답 구조: {json.dumps(data, ensure_ascii=False, indent=2)[:500]}...")
|
146 |
-
|
147 |
-
if "error" in data:
|
148 |
-
logger.error(f"음성 챗 오류: {data['error']}")
|
149 |
-
return "", data["error"]
|
150 |
-
|
151 |
-
# 소스 정보 추출 및 포맷팅
|
152 |
-
source_info = ""
|
153 |
-
if "sources" in data and data["sources"]:
|
154 |
-
source_info = format_source_info(data["sources"], prefix="[음성챗] ")
|
155 |
-
|
156 |
-
# 인식된 텍스트와 소스 정보가 포함된 응답 반환
|
157 |
-
return data["transcription"], data["answer"] + source_info
|
158 |
-
except Exception as e:
|
159 |
-
logger.error(f"음성 챗 처리 실패: {str(e)}")
|
160 |
-
return "", f"처리 중 오류 발생: {str(e)}"
|
161 |
-
|
162 |
-
# 문서 업로드 기능
|
163 |
-
def handle_doc_upload(doc_file):
|
164 |
-
if not doc_file:
|
165 |
-
return "문서를 업로드하세요."
|
166 |
-
try:
|
167 |
-
logger.info(f"문서 업로드 요청: /api/upload, 파일명: {doc_file.name}")
|
168 |
-
file_extension = os.path.splitext(doc_file.name)[1].lower()
|
169 |
-
logger.info(f"파일 확장자: {file_extension}")
|
170 |
-
|
171 |
-
with open(doc_file, "rb") as f:
|
172 |
-
response = flask_client.post(
|
173 |
-
"/api/upload",
|
174 |
-
data={"document": (f, doc_file.name)}
|
175 |
-
)
|
176 |
-
data = flask_json.loads(response.data)
|
177 |
-
if "error" in data:
|
178 |
-
logger.error(f"문서 업로드 오류: {data['error']}")
|
179 |
-
return data["error"]
|
180 |
-
return data["message"]
|
181 |
-
except Exception as e:
|
182 |
-
logger.error(f"문서 업로드 처리 실패: {str(e)}")
|
183 |
-
return f"처리 중 오류 발생: {str(e)}"
|
184 |
-
|
185 |
-
# 이벤트 핸들러 연결
|
186 |
-
text_button.click(
|
187 |
-
fn=handle_text_chat,
|
188 |
-
inputs=text_input,
|
189 |
-
outputs=text_output
|
190 |
-
)
|
191 |
-
|
192 |
-
# 음성 입력 값이 변경될 때 자동으로 전송
|
193 |
-
audio_input.change(
|
194 |
-
fn=handle_voice_chat,
|
195 |
-
inputs=audio_input,
|
196 |
-
outputs=[audio_transcription, audio_output]
|
197 |
-
)
|
198 |
-
|
199 |
-
doc_button.click(
|
200 |
-
fn=handle_doc_upload,
|
201 |
-
inputs=doc_input,
|
202 |
-
outputs=doc_output
|
203 |
-
)
|
204 |
-
|
205 |
-
if __name__ == "__main__":
|
206 |
-
demo.launch(server_port=7860)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/project_plan.md
CHANGED
@@ -1,139 +1,78 @@
|
|
1 |
-
#
|
2 |
|
3 |
## 프로젝트 개요
|
4 |
-
이 프로젝트는 RAG(Retrieval-Augmented Generation)
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
-
|
15 |
-
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
-
|
20 |
-
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
-
|
25 |
-
-
|
26 |
-
|
27 |
-
## 기술 스택
|
28 |
-
- **백엔드:** Flask (Python)
|
29 |
-
- **프론트엔드:** HTML, CSS, JavaScript
|
30 |
-
- **LLM 통합:** OpenAI API, DeepSeek API
|
31 |
-
- **검색 기능:** 벡터 검색, 재순위화
|
32 |
-
- **STT 서비스:** VITO API
|
33 |
-
- **통신 프로토콜:** RESTful API
|
34 |
-
|
35 |
-
## 완료된 작업
|
36 |
-
- [X] 프로젝트 기본 구조 설정
|
37 |
-
- [X] Flask 웹 서버 구현
|
38 |
-
- [X] 다중 LLM 인터페이스 구현 (OpenAI, DeepSeek)
|
39 |
-
- [X] 사용자 인증 기능 구현
|
40 |
-
- [X] 벡터 검색 및 재순위화 기능 구현
|
41 |
-
- [X] 문서 처리 및 관리 기능 구현
|
42 |
-
- [X] 웹 인터페이스 구현
|
43 |
-
- [X] 장치 관리 기능 통합
|
44 |
-
- [X] 장치 관리 UI 구현
|
45 |
-
- [X] 장치관리 탭 클릭 이벤트 버그 수정
|
46 |
-
|
47 |
-
## 진행해야 할 작업
|
48 |
-
- [ ] 문서관리&장치관리 탭 로딩지속 현상 해결결
|
49 |
-
- [ ] LLM 선택 UI 개선
|
50 |
-
- 드롭다운에 아이콘 추가
|
51 |
-
- 각 LLM별 상세 모델 정보 표시
|
52 |
-
- [ ] 응답 내 소스 링크 클릭 시 원문 표시 기능
|
53 |
-
- [ ] 응답 속도 개선
|
54 |
-
- [ ] 오류 처리 강화
|
55 |
-
- [ ] 테스트 케이스 작성 및 유닛 테스트 구현
|
56 |
-
- [ ] 사용자 가이드 문서 작성
|
57 |
-
|
58 |
-
## 통합 구현 내용
|
59 |
-
|
60 |
-
### 1. 웹앱 통합
|
61 |
-
- 단일 웹 애플리케이션에 RAG 검색 챗봇과 장치 관리 기능 통합
|
62 |
-
- 탭 기반 인터페이스로 기능 분리 (대화, 문서관리, 장치관리)
|
63 |
-
- Flask를 통한 중앙 집중식 서버 관리
|
64 |
-
|
65 |
-
### 2. 장치 관리 기능
|
66 |
-
- 장치 상태 확인 기능
|
67 |
-
- 연결된 장치 목록 조회
|
68 |
-
- 실행 가능한 프로그램 목록 조회 및 실행
|
69 |
-
- 서버와의 RESTful API 통신
|
70 |
-
|
71 |
-
### 3. 프론트엔드 구현
|
72 |
-
- 직관적인 사용자 인터페이스
|
73 |
-
- 실시간 상태 업데이트
|
74 |
-
- 에러 처리 및 사용자 피드백 제공
|
75 |
-
- 반응형 디자인 적용
|
76 |
-
|
77 |
-
## 최근 버그 수정
|
78 |
-
|
79 |
-
### 장치관리 탭 작동 문제 해결
|
80 |
-
**문제 상황:** UI에서 장치관리 버튼이 전혀 작동하지 않는 문제 발생
|
81 |
-
|
82 |
-
**원인 분석:**
|
83 |
-
1. HTML 문제:
|
84 |
-
- `device-style.css` 파일이 HTML에 링크되어 있지 않음
|
85 |
-
2. JavaScript 문제:
|
86 |
-
- `app.js`에서 deviceTab 이벤트 리스너가 제대로 등록되지 않음
|
87 |
-
- `app-device.js`와 `app.js` 간의 함수 공유에 문제 발생
|
88 |
-
|
89 |
-
**해결 방법:**
|
90 |
-
1. HTML 수정:
|
91 |
-
- `device-style.css` 파일을 HTML의 head 섹션에 링크 추가
|
92 |
-
- 장치관리 탭을 위한 직접적인 이벤트 핸들러를 HTML 내부 스크립트로 추가
|
93 |
-
2. 이벤트 핸들링 개선:
|
94 |
-
- 장치관리 탭에 직접 onclick 이벤트 핸들러 추가하여 기존 문제 우회
|
95 |
-
- 디버깅용 콘솔 로그 추가로 문제 추적 가능하게 함
|
96 |
-
|
97 |
-
**수정 파일:**
|
98 |
-
- `app/templates/index.html`: CSS 링크 및 직접 이벤트 핸들러 추가
|
99 |
-
- `docs/project_plan.md`: 문제 해결 과정 문서화
|
100 |
|
101 |
## 파일 구조
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
-
|
127 |
-
-
|
128 |
-
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
-
|
134 |
-
-
|
135 |
-
-
|
136 |
-
-
|
137 |
-
|
138 |
-
|
139 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# RAG 챗봇 프로젝트 계획서
|
2 |
|
3 |
## 프로젝트 개요
|
4 |
+
이 프로젝트는 RAG(Retrieval-Augmented Generation) 기반의 검색 챗봇을 개발하는 것입니다.
|
5 |
+
현재 리트리버 초기화 실패 문제가 발생하고 있으며, 이를 해결하는 것이 주요 목표입니다.
|
6 |
+
|
7 |
+
## 문제 상황
|
8 |
+
- RAG 리트리버 초기화 과정에서 실패가 발생하고 있음
|
9 |
+
- app.py 파일의 백그라운드 초기화 로직에 문제가 있음
|
10 |
+
- 초기화 실패 시에도 앱이 '준비 완료' 상태로 전환되어 API 호출 시점에 오류 발생
|
11 |
+
|
12 |
+
## 해결 방안
|
13 |
+
1. `init_retriever` 함수 디버깅 및 개선
|
14 |
+
- 상세 로깅 추가하여 실패 지점 파악
|
15 |
+
- 오류 처리 로직 강화
|
16 |
+
- 모든 잠재적 오류 상황에 대한 처리 추가
|
17 |
+
|
18 |
+
2. 초기화 상태 관리 개선
|
19 |
+
- 초기화 상태를 세분화하여 '준비 완료'와 '초기화 성공'을 별도로 관리
|
20 |
+
- 초기화 실패 시 사용자에게 명확한 오류 메시지 제공
|
21 |
+
- Mock 컴포넌트 강화 및 오류 처리 개선
|
22 |
+
|
23 |
+
3. 앱 구조 정리
|
24 |
+
- 파일 크기 제한(10KB)을 고려한 코드 분할
|
25 |
+
- 기능별 모듈화로 유지보수성 향상
|
26 |
+
- 명확한 파일 구조 설계
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
|
28 |
## 파일 구조
|
29 |
+
- `app.py`: 메인 진입점
|
30 |
+
- `app/app_main.py`: 메인 앱 로직
|
31 |
+
- `app/app_revised.py`: 개선된 앱 초기화 로직
|
32 |
+
- `app/init_retriever_improved.py`: 개선된 리트리버 초기화 로직
|
33 |
+
- `app/background_init.py`: 백그라운드 초기화 관리
|
34 |
+
- `app/app_routes_improved.py`: 개선된 라우트 등록 로직 (핵심 라우트만 포함)
|
35 |
+
- `app/chat_routes.py`: 챗봇 관련 API 엔드포인트
|
36 |
+
- `app/document_routes.py`: 문서 관리 관련 API 엔드포인트
|
37 |
+
- `app/voice_routes.py`: 음성 관련 API 엔드포인트
|
38 |
+
- `app/templates/error.html`: 오류 표시 템플릿
|
39 |
+
|
40 |
+
## 핵심 개선 사항
|
41 |
+
|
42 |
+
### 1. 초기화 과정 개선
|
43 |
+
- `app_ready_event`와 `init_success_event` 분리하여 앱 로딩과 초기화 성공 여부를 독립적으로 관리
|
44 |
+
- 초기화 실패 시에도 앱은 로딩되지만, 실패 상태를 명확히 사용자에게 표시
|
45 |
+
- `initialization_error` 변수를 통해 오류 정보 추적 및 표시
|
46 |
+
|
47 |
+
### 2. 오류 처리 강화
|
48 |
+
- 초기화 실패 원인 세분화 (모듈 로드, 파일 접근, 인덱스 로드 등)
|
49 |
+
- Mock 컴포넌트 확장으로 실패 시에도 최소한의 기능 유지
|
50 |
+
- 모든 주요 작업에 상세 로깅 추가로 디버깅 용이성 향상
|
51 |
+
|
52 |
+
### 3. 사용자 경험 개선
|
53 |
+
- 오류 발생 시 명확한 피드백 제공
|
54 |
+
- API 상태 코드 조정으로 유효한 응답 유지
|
55 |
+
- 시스템 상태 모니터링 API 확장
|
56 |
+
|
57 |
+
## 작업 상황
|
58 |
+
### 완료된 작업
|
59 |
+
- 프로젝트 초기 분석
|
60 |
+
- 문제 원인 파악 (app.py의 백그라운드 초기화 로직)
|
61 |
+
- 프로젝트 계획 수립
|
62 |
+
- 코드베이스 분석 및 구조 이해
|
63 |
+
- 개선된 init_retriever 함수 구현
|
64 |
+
- 백그라운드 초기화 로직 재구성
|
65 |
+
- API 라우트 로직 개선 및 모듈화
|
66 |
+
- 오류 처리 및 상태 관리 강화
|
67 |
+
- 초기화 실패 시 사용자 피드백 개선
|
68 |
+
- 모듈 구조 최적화 및 코드 분할
|
69 |
+
|
70 |
+
### 진행 중인 작업
|
71 |
+
- 추가 오류 상황 처리 개선
|
72 |
+
- 로깅 강화
|
73 |
+
|
74 |
+
### 해야 할 작업
|
75 |
+
- 실제 환경에서 검증
|
76 |
+
- 성능 테스트
|
77 |
+
- 추가 문서화
|
78 |
+
- UI 측 오류 처리 개선 (클라이언트 JavaScript)
|
docs/technical_improvements.md
ADDED
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# RAG 챗봇 - 기술적 개선 사항 문서
|
2 |
+
|
3 |
+
## 개요
|
4 |
+
이 문서는 RAG 챗봇 시스템의 리트리버 초기화 실패 문제를 해결하기 위한 기술적 개선 사항을 설명합니다.
|
5 |
+
|
6 |
+
## 핵심 문제 및 해결책
|
7 |
+
|
8 |
+
### 문제 1: 리트리버 초기화 실패
|
9 |
+
**문제**: 백그라운드 스레드에서 리트리버 초기화가 실패해도 앱이 '준비 완료' 상태로 전환됨
|
10 |
+
|
11 |
+
**해결책**:
|
12 |
+
1. 상태 관리 세분화:
|
13 |
+
- `app_ready_event`: 앱 UI 로딩 완료 상태
|
14 |
+
- `init_success_event`: 리트리버 초기화 성공 여부
|
15 |
+
- `initialization_error`: 초기화 실패 시 오류 메시지 저장
|
16 |
+
|
17 |
+
2. 오류 처리 개선:
|
18 |
+
```python
|
19 |
+
# 이전 코드
|
20 |
+
if retriever is None:
|
21 |
+
logger.error("검색기 초기화 실패")
|
22 |
+
app_ready_event.set() # 실패해도 앱은 '준비 완료'로 설정
|
23 |
+
|
24 |
+
# 개선된 코드
|
25 |
+
if retriever is None:
|
26 |
+
logger.error("검색기 초기화 실패")
|
27 |
+
initialization_error = "검색기 초기화 실패"
|
28 |
+
app_ready_event.set() # 앱 로딩은 완료
|
29 |
+
# init_success_event는 설정하지 않음 (초기화 실패)
|
30 |
+
```
|
31 |
+
|
32 |
+
### 문제 2: 불충분한 오류 정보
|
33 |
+
**문제**: 초기화 실패 시 원인에 대한 정보가 불충분하여 디버깅이 어려움
|
34 |
+
|
35 |
+
**해결책**:
|
36 |
+
1. 상세 로깅 추가:
|
37 |
+
- 각 초기화 단계별 로그 기록
|
38 |
+
- 오류 발생 지점 명확히 식별
|
39 |
+
|
40 |
+
2. 세부 오류 추적:
|
41 |
+
```python
|
42 |
+
try:
|
43 |
+
# 초기화 코드
|
44 |
+
except Exception as e:
|
45 |
+
error_msg = f"검색기 초기화 중 오류: {str(e)}"
|
46 |
+
logger.error(error_msg, exc_info=True)
|
47 |
+
return None, None, False, error_msg # 오류 메시지 반환
|
48 |
+
```
|
49 |
+
|
50 |
+
### 문제 3: 파일 크기 및 코드 유지보수성
|
51 |
+
**문제**: 단일 파일에 모든 코드가 있어 크기 제한(10KB) 초과 및 유지보수 어려움
|
52 |
+
|
53 |
+
**해결책**:
|
54 |
+
1. 모듈화된 구조:
|
55 |
+
- `app_main.py`: 메인 앱 로직
|
56 |
+
- `background_init.py`: 백그라운드 초기화 처리
|
57 |
+
- `init_retriever_improved.py`: 리트리버 초기화 로직
|
58 |
+
- `app_routes_improved.py`: 기본 라우트 등록
|
59 |
+
- 기능별 모듈: `chat_routes.py`, `document_routes.py`, `voice_routes.py`
|
60 |
+
|
61 |
+
2. 명확한 책임 분리:
|
62 |
+
- 각 모듈은 단일 책임 원칙 준수
|
63 |
+
- 의존성 명확히 문서화
|
64 |
+
|
65 |
+
## 기술적 개선의 핵심 포인트
|
66 |
+
|
67 |
+
### 1. 강화된 오류 복원력
|
68 |
+
- Mock 컴포넌트 구현:
|
69 |
+
```python
|
70 |
+
class MockComponent:
|
71 |
+
def __init__(self):
|
72 |
+
self.is_mock = True
|
73 |
+
logger.warning("MockComponent 인스턴스 생성됨")
|
74 |
+
|
75 |
+
def search(self, query, **kwargs):
|
76 |
+
logger.warning(f"MockComponent.search 호출됨: {query[:30]}...")
|
77 |
+
return []
|
78 |
+
|
79 |
+
def __getattr__(self, name):
|
80 |
+
logger.warning(f"MockComponent에서 '{name}' 접근 시도")
|
81 |
+
if name in ['add_documents', 'save', 'rag_generate', 'prepare_rag_context']:
|
82 |
+
return lambda *args, **kwargs: None
|
83 |
+
return None
|
84 |
+
```
|
85 |
+
|
86 |
+
- 컴포넌트 존재 및 속성 확인 강화:
|
87 |
+
```python
|
88 |
+
# 안전한 검색 수행
|
89 |
+
if retriever is None:
|
90 |
+
search_warning = "검색기가 초기화되지 않았습니다."
|
91 |
+
elif hasattr(retriever, 'is_mock') and retriever.is_mock:
|
92 |
+
search_warning = "검색 인덱스가 아직 구축 중입니다."
|
93 |
+
elif not hasattr(retriever, 'search'):
|
94 |
+
search_warning = "검색 기능이 현재 제한되어 있습니다."
|
95 |
+
else:
|
96 |
+
try:
|
97 |
+
search_results = retriever.search(query, top_k=5)
|
98 |
+
except Exception as e:
|
99 |
+
search_warning = f"검색 중 오류 발생: {str(e)}"
|
100 |
+
```
|
101 |
+
|
102 |
+
### 2. 사용자 경험 개선
|
103 |
+
- 초기화 실패 시에도 앱 접근성 유지
|
104 |
+
- API 응답에 명확한 오류 메시지 포함:
|
105 |
+
```python
|
106 |
+
if not is_success and initialization_error:
|
107 |
+
return jsonify({
|
108 |
+
"error": "검색기 초기화 실패",
|
109 |
+
"answer": f"죄송합니다. 검색 시스템 초기화에 실패했습니다. (오류: {initialization_error})"
|
110 |
+
})
|
111 |
+
```
|
112 |
+
|
113 |
+
- 오류 페이지 템플릿 추가:
|
114 |
+
```html
|
115 |
+
<div class="error-container">
|
116 |
+
<div class="error-title">오류가 발생했습니다</div>
|
117 |
+
<div class="error-message">{{ error }}</div>
|
118 |
+
{% if details %}
|
119 |
+
<div class="error-details">{{ details }}</div>
|
120 |
+
{% endif %}
|
121 |
+
</div>
|
122 |
+
```
|
123 |
+
|
124 |
+
### 3. 시스템 상태 모니터링 강화
|
125 |
+
- 상세 상태 정보 API 추가:
|
126 |
+
```python
|
127 |
+
@app.route('/api/system/status')
|
128 |
+
def system_status():
|
129 |
+
status = {
|
130 |
+
"app_ready": app_ready_event.is_set(),
|
131 |
+
"init_success": init_success_event.is_set(),
|
132 |
+
"retriever_type": type(retriever).__name__,
|
133 |
+
"is_mock_retriever": hasattr(retriever, 'is_mock') and retriever.is_mock,
|
134 |
+
"error_message": initialization_error,
|
135 |
+
"document_count": len(getattr(base_retriever, 'documents', [])),
|
136 |
+
"server_time": datetime.datetime.now().isoformat()
|
137 |
+
}
|
138 |
+
return jsonify(status)
|
139 |
+
```
|
140 |
+
|
141 |
+
## 데이터 ��름 개선
|
142 |
+
|
143 |
+
### 이전 데이터 흐름
|
144 |
+
1. Flask 앱 초기화
|
145 |
+
2. 백그라운드 스레드에서 retriever 초기화
|
146 |
+
3. 초기화 성공/실패와 관계없이 app_ready 설정
|
147 |
+
4. API 호출 시점에 retriever 객체 존재 확인 없이 사용 시도
|
148 |
+
5. 실패 시 사용자에게 "정보를 찾을 수 없습니다" 같은 모호한 메시지 표시
|
149 |
+
|
150 |
+
### 개선된 데이터 흐름
|
151 |
+
1. Flask 앱 초기화
|
152 |
+
2. 백그라운드 스레드에서 retriever 초기화 시도
|
153 |
+
3. 초기화 성공 여부 별도 관리 (init_success_event)
|
154 |
+
4. 초기화 실패 시 상세 오류 정보 저장 (initialization_error)
|
155 |
+
5. API 호출 시 초기화 성공 여부 및 오류 정보 확인
|
156 |
+
6. 사용자에게 구체적인 문제 상황과 해결 방안 안내
|
157 |
+
|
158 |
+
## 결론
|
159 |
+
이번 개선 작업은 RAG 챗봇 시스템의 안정성과 유지보수성을 크게 향상시켰습니다. 특히 다음과 같은 이점을 얻었습니다:
|
160 |
+
|
161 |
+
1. **안정성 향상**: 초기화 실패 상황에서도 최소한의 기능 유지
|
162 |
+
2. **디버깅 용이성**: 상세한 오류 로깅과 추적 지원
|
163 |
+
3. **코드 가독성**: 모듈화된 구조로 유지보수 용이
|
164 |
+
4. **사용자 경험**: 오류 상황에서도 명확한 피드백 제공
|
165 |
+
5. **확장성**: 새로운 기능 추가가 용이해진 모듈식 구조
|
166 |
+
|
167 |
+
이러한 개선 사항은 시스템의 장기적인 유지보수와 확장에 큰 도움이 될 것입니다.
|