jeongsoo commited on
Commit
2a02f66
·
1 Parent(s): 4c352df

RAG 리트리버 초기화 실패 문제 해결

Browse files
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
- from app.app import app
 
 
 
 
 
10
 
11
  if __name__ == '__main__':
12
  port = int(os.environ.get("PORT", 7860))
13
- app.run(debug=False, host='0.0.0.0', port=port)
 
 
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
- from datetime import datetime, timedelta
9
- from flask import Flask, send_from_directory
 
 
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', 'http://localhost:5050')
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
- class MockComponent: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}. utils retrieval 패키지가 올바른 경로에 있는지 확인하세요.")
 
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.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
78
- app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
 
 
 
 
 
 
 
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
- try:
87
- llm_interface = LLMInterface(default_llm="openai")
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 = False # 앱 초기화 상태 플래그
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
- initialize_app()
234
-
235
-
236
- # --- 앱 실행 (직접 실행 시) ---
237
- if __name__ == '__main__':
238
- logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
239
- port = int(os.environ.get("PORT", 7860))
240
- logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
241
- app.run(debug=True, host='0.0.0.0', port=port)
 
 
 
 
 
 
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
- // 직접 접근 설정: 문자열이면 현재 호스트의 5050 포트를 사용
24
- let DEVICE_SERVER_URL = ''; // 서버에서 전달된 URL이 있으면 초기화 시 설정됨
25
 
26
  /**
27
  * 장치 서버 API 경로 생성 함수
@@ -43,34 +46,97 @@ function getDeviceApiUrl(endpoint) {
43
  */
44
  function initDeviceServerSettings() {
45
  console.log("장치 서버 설정 초기화 시작");
46
- // 서버 URL 설정 (웹 서버로부터 설정 불러오기)
47
- fetch('/api/device/settings')
48
- .then(response => {
49
- if (!response.ok) {
50
- throw new Error('설정을 가져올 수 없습니다');
51
- }
52
- return response.json();
53
- })
54
- .then(data => {
55
- if (data.server_url) {
56
- console.log(`장치 서버 URL 설정됨: ${data.server_url}`);
57
- DEVICE_SERVER_URL = data.server_url;
58
- } else {
59
- // 설정 없음 - 자동 생성 (현재 호스트 + 포트 5050)
60
- const currentHost = window.location.hostname;
61
- const protocol = window.location.protocol;
62
- DEVICE_SERVER_URL = `${protocol}//${currentHost}:5051`;
63
- console.log(`장치 서버 URL 자동 설정: ${DEVICE_SERVER_URL}`);
64
- }
65
- })
66
- .catch(error => {
67
- console.error('장치 서버 설정 초기화 오류:', error);
68
- // 기본값으로 설정 (현재 호스트 + 포트 5051)
69
- const currentHost = window.location.hostname;
70
- const protocol = window.location.protocol;
71
- DEVICE_SERVER_URL = `${protocol}//${currentHost}:5051`;
72
- console.log(`장치 서버 URL 기본값 설정: ${DEVICE_SERVER_URL}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  }
75
 
76
  // 페이지 로드 시 초기화
@@ -89,22 +155,57 @@ document.addEventListener('DOMContentLoaded', () => {
89
  }
90
 
91
  // 장치 상태 확인 버튼 이벤트 리스너
92
- checkDeviceStatusButton.addEventListener('click', () => {
93
- console.log("장치 상태 확인 버튼 클릭");
94
- checkDeviceStatus();
95
- });
 
 
96
 
97
  // 장치 목록 새로고침 버튼 이벤트 리스너
98
- refreshDevicesButton.addEventListener('click', () => {
99
- console.log("장치 목록 새로고침 버튼 클릭");
100
- loadDevices();
101
- });
 
 
102
 
103
  // 프로그램 목록 로드 버튼 이벤트 리스너
104
- loadProgramsButton.addEventListener('click', () => {
105
- console.log("프로그램 목록 로드 버튼 클릭");
106
- loadPrograms();
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.classList.remove('hidden');
252
- noDevicesMessage.textContent = handleError(error);
 
 
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.classList.remove('hidden');
383
- noProgramsMessage.textContent = handleError(error);
 
 
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
- # RAG5_2_ChooseLLM 프로젝트 계획서
2
 
3
  ## 프로젝트 개요
4
- 이 프로젝트는 RAG(Retrieval-Augmented Generation) 기반 검색 챗봇으로, 지식베이스에서 관련 정보를 검색하여 사용자 질의에 답변합니다. 주요 특징으로는 OpenAI와 DeepSeek 등 다양한 LLM을 선택하여 사용할 수 있는 기능과 장치 관리 기능을 제공합니다.
5
-
6
- ## 주요 기능
7
- 1. 지식베이스 관리
8
- - 문서 업로드 (.txt, .md, .csv 등)
9
- - 문서 청크 분할 벡터 인덱싱
10
- - 지식베이스 문서 목록 조회
11
-
12
- 2. LLM 선택
13
- - OpenAI, DeepSeek 다양한 LLM 선택 가능
14
- - LLM 변경 실시간 전환
15
- - 현재 사용 중인 LLM 정보 표시
16
-
17
- 3. 챗봇 기능
18
- - 텍스트 기반 질의응답
19
- - 음성 인식 질의응답 (VITO STT 활용)
20
- - 검색 결과 정보 출처 표시
21
-
22
- 4. 장치 관리 기능
23
- - 로컬 PC와 통신하는 기능
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
- RAG5_2_ChooseLLM/
104
- ├── app/
105
- │ ├── app.py # 메인 Flask 애플리케이션
106
- │ ├── app_routes.py # 기본 라우트 정의
107
- │ ├── app_device_routes.py # 장치 관리 관련 라우트
108
- │ ├── static/
109
- │ │ ├── css/
110
- │ │ │ ├── style.css # 기본 스타일시트
111
- │ │ │ └── device-style.css # 장치 관리 스타일시트
112
- │ │ └── js/
113
- │ │ ├── app.js # 메인 JavaScript
114
- │ │ └── app-device.js # 장치 관리 JavaScript
115
- │ └── templates/
116
- │ ├── index.html # 메인 페이지 템플릿 (수정됨)
117
- │ └── login.html # 로그인 페이지 템플릿
118
- ├── data/ # 업로드된 문서 저장
119
- ├── docs/ # 프로젝트 문서
120
- ├── retrieval/ # 검색 관련 모듈
121
- └── utils/ # 유틸리티 모듈
122
- ```
123
-
124
- ## 환경 변수 설정
125
- - `ADMIN_USERNAME`: 관리자 사용자명 (기본값: admin)
126
- - `ADMIN_PASSWORD`: 관리자 비밀번호 (기본값: rag12345)
127
- - `DEVICE_SERVER_URL`: 장치 관리 서버 URL (기본값: http://localhost:5050)
128
- - `OPENAI_API_KEY`: OpenAI API
129
- - `DEEPSEEK_API_KEY`: DeepSeek API 키
130
- - `VITO_API_KEY`: VITO STT API 키
131
-
132
- ## 참고 사항
133
- - 장치 관리 서버는 별도로 실행되어야 (포트 5050)
134
- - OpenAI, DeepSeek 및 VITO API 키는 .env 파일에 설정해야 함
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
+ 이러한 개선 사항은 시스템의 장기적인 유지보수와 확장에 큰 도움이 될 것입니다.