Spaces:
Sleeping
Sleeping
Add application file
Browse files- app/app.py +560 -539
app/app.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
"""
|
2 |
-
RAG 검색 챗봇 웹 애플리케이션
|
3 |
"""
|
4 |
|
5 |
import os
|
@@ -29,9 +29,10 @@ ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
|
|
29 |
|
30 |
logger.info(f"==== 환경 변수 로드 상태 ====")
|
31 |
logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
|
|
|
32 |
logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
|
33 |
|
34 |
-
# 환경 변수가 없으면 기본값 설정
|
35 |
if not ADMIN_USERNAME:
|
36 |
ADMIN_USERNAME = 'admin'
|
37 |
logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
|
@@ -40,85 +41,97 @@ if not ADMIN_PASSWORD:
|
|
40 |
ADMIN_PASSWORD = 'rag12345'
|
41 |
logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
|
42 |
|
43 |
-
# 로컬 모듈 임포트
|
44 |
-
|
45 |
-
|
46 |
-
from utils.
|
47 |
-
from
|
48 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
|
50 |
# Flask 앱 초기화
|
51 |
app = Flask(__name__)
|
52 |
|
53 |
-
# 세션 설정 - 고정된 시크릿 키 사용
|
54 |
-
app.secret_key = 'rag_chatbot_fixed_secret_key_12345'
|
55 |
-
|
56 |
-
# 세션 설정
|
57 |
-
|
58 |
-
app.config['
|
59 |
-
app.config['
|
60 |
-
|
61 |
-
|
|
|
|
|
|
|
|
|
|
|
62 |
app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가
|
|
|
63 |
|
64 |
# 최대 파일 크기 설정 (10MB)
|
65 |
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
66 |
-
|
67 |
-
|
68 |
-
app.config['
|
|
|
|
|
69 |
|
70 |
-
#
|
71 |
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
72 |
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
73 |
os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
|
74 |
|
75 |
-
# 허용되는
|
76 |
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
77 |
-
|
78 |
-
# 허용되는 문서 파일 확장자
|
79 |
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
80 |
|
81 |
-
#
|
82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
|
84 |
-
# VITO STT 클라이언트 초기화
|
85 |
-
stt_client = VitoSTT()
|
86 |
-
|
87 |
-
# 전역 검색기 객체와 재순위화 검색기 객체
|
88 |
base_retriever = None
|
89 |
retriever = None
|
|
|
|
|
90 |
|
91 |
-
# 앱 초기화 상태
|
92 |
-
app_ready = False
|
93 |
|
94 |
-
#
|
95 |
def login_required(f):
|
96 |
@wraps(f)
|
97 |
def decorated_function(*args, **kwargs):
|
98 |
logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
|
99 |
-
logger.info(f"현재 세션 객체: {session}")
|
100 |
logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
if '
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
except:
|
111 |
-
pass
|
112 |
-
|
113 |
-
# 세션 또는 수동 쿠키 중 하나라도 있으면 인증 성공
|
114 |
-
if 'logged_in' not in session and not has_manual_cookie:
|
115 |
-
logger.warning(f"비로그인 상태에서 {request.path} 접근 시도, 로그인 페이지로 리디렉션")
|
116 |
-
return redirect(url_for('login'))
|
117 |
-
|
118 |
logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
|
119 |
return f(*args, **kwargs)
|
120 |
return decorated_function
|
|
|
|
|
121 |
|
|
|
122 |
def allowed_audio_file(filename):
|
123 |
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
124 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
@@ -126,678 +139,686 @@ def allowed_audio_file(filename):
|
|
126 |
def allowed_doc_file(filename):
|
127 |
"""파일이 허용된 문서 확장자를 가지는지 확인"""
|
128 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
|
|
|
|
129 |
|
|
|
130 |
def init_retriever():
|
131 |
"""검색기 객체 초기화 또는 로드"""
|
132 |
global base_retriever, retriever
|
133 |
-
|
134 |
index_path = app.config['INDEX_PATH']
|
135 |
-
|
136 |
-
#
|
137 |
-
if os.path.exists(os.path.join(index_path, "documents.json")):
|
138 |
try:
|
139 |
logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
|
140 |
base_retriever = VectorRetriever.load(index_path)
|
141 |
-
logger.info(f"{len(base_retriever.documents)}개 문서가 로드되었습니다.")
|
142 |
except Exception as e:
|
143 |
-
logger.error(f"인덱스 로드 중 오류 발생: {e}")
|
144 |
-
logger.info("새 검색기를 초기화합니다...")
|
145 |
base_retriever = VectorRetriever()
|
146 |
else:
|
147 |
logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
|
148 |
base_retriever = VectorRetriever()
|
149 |
-
|
150 |
-
# 데이터 폴더의 문서 로드
|
151 |
data_path = app.config['DATA_FOLDER']
|
152 |
-
|
|
|
153 |
logger.info(f"{data_path}에서 문서를 로드합니다...")
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
|
|
|
|
172 |
# 재순위화 검색기 초기화
|
173 |
logger.info("재순위화 검색기를 초기화합니다...")
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
#
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
# 재순위화 검색기 객체 생성 (CrossEncoder 대신 사용자 정의 함수 사용)
|
203 |
-
retriever = ReRanker(
|
204 |
-
base_retriever=base_retriever,
|
205 |
-
rerank_fn=custom_rerank_fn,
|
206 |
-
rerank_field="text"
|
207 |
-
)
|
208 |
-
|
209 |
-
logger.info("재순위화 검색기 초기화 완료")
|
210 |
-
|
211 |
return retriever
|
212 |
|
213 |
-
# 비동기 초기화 함수
|
214 |
def background_init():
|
215 |
"""백그라운드에서 검색기 초기화 수행"""
|
216 |
global app_ready, retriever
|
217 |
try:
|
218 |
logger.info("백그라운드 초기화 시작")
|
219 |
-
|
|
|
|
|
|
|
|
|
|
|
220 |
app_ready = True
|
221 |
-
logger.info("앱 초기화 완료")
|
222 |
except Exception as e:
|
223 |
-
logger.error(f"앱 초기화 중 오류 발생: {e}", exc_info=True)
|
224 |
-
app_ready = False
|
225 |
|
226 |
# 백그라운드 스레드에서 초기화 시작
|
227 |
init_thread = threading.Thread(target=background_init)
|
228 |
-
init_thread.daemon = True
|
229 |
init_thread.start()
|
|
|
|
|
|
|
|
|
230 |
|
231 |
-
# 로그인 페이지
|
232 |
@app.route('/login', methods=['GET', 'POST'])
|
233 |
def login():
|
234 |
error = None
|
235 |
-
|
|
|
236 |
logger.info(f"Method: {request.method}")
|
237 |
-
|
238 |
-
#
|
239 |
-
logger.
|
240 |
-
|
241 |
-
logger.
|
242 |
-
|
243 |
-
# 모든 헤더 로그
|
244 |
-
logger.info("Request Headers:")
|
245 |
-
for header, value in request.headers.items():
|
246 |
-
logger.info(f" {header}: {value}")
|
247 |
-
|
248 |
if request.method == 'POST':
|
249 |
logger.info("로그인 시도 받음")
|
250 |
-
|
251 |
-
# 입력받은 자격증명 로깅
|
252 |
username = request.form.get('username', '')
|
253 |
password = request.form.get('password', '')
|
254 |
logger.info(f"입력된 사용자명: {username}")
|
255 |
-
# 비밀번호는 일부 검출 후 로깅
|
256 |
logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
|
257 |
-
|
258 |
-
#
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
logger.info(f"ADMIN_USERNAME from os.environ: {os.environ.get('ADMIN_USERNAME')}")
|
266 |
-
logger.info(f"ADMIN_PASSWORD from os.environ: {os.environ.get('ADMIN_PASSWORD') is not None}")
|
267 |
-
logger.info(f"ADMIN_USERNAME from os.getenv: {os.getenv('ADMIN_USERNAME')}")
|
268 |
-
logger.info(f"ADMIN_PASSWORD from os.getenv: {os.getenv('ADMIN_PASSWORD') is not None}")
|
269 |
-
|
270 |
-
logger.info(f"환경변수에서 가져온 사용자명: {valid_username if valid_username else '정의되지 않음'}")
|
271 |
-
logger.info(f"환경변수에서 비밀번호 가져온 여부: {len(valid_password) > 0 if valid_password else False}")
|
272 |
-
|
273 |
-
# 허깅페이스에서 환경변수가 로드되지 않을 경우 기본값 사용
|
274 |
-
if not valid_username or not valid_password:
|
275 |
-
logger.warning("환경변수에서 사용자 자격증명을 찾을 수 없어 기본값 사용")
|
276 |
-
valid_username = "admin"
|
277 |
-
valid_password = "rag12345"
|
278 |
-
|
279 |
if username == valid_username and password == valid_password:
|
280 |
logger.info(f"로그인 성공: {username}")
|
281 |
-
# 세션
|
282 |
-
logger.
|
283 |
-
|
284 |
-
#
|
285 |
-
session.permanent = True
|
286 |
session['logged_in'] = True
|
287 |
session['username'] = username
|
288 |
-
|
289 |
-
|
290 |
-
logger.info(f"세션 설정
|
291 |
logger.info("세션 설정 완료, 리디렉션 시도")
|
292 |
-
|
293 |
-
#
|
294 |
-
|
295 |
-
|
|
|
|
|
|
|
|
|
|
|
296 |
return response
|
297 |
else:
|
298 |
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
299 |
-
|
300 |
-
if
|
301 |
-
logger.warning("사용자명 불일치")
|
302 |
-
if password != valid_password:
|
303 |
-
logger.warning("비밀번호 불일치")
|
304 |
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
305 |
-
else:
|
306 |
logger.info("로그인 페이지 GET 요청")
|
307 |
-
# 세션 상태 확인
|
308 |
if 'logged_in' in session:
|
309 |
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
310 |
return redirect(url_for('index'))
|
311 |
-
|
312 |
logger.info("---------- 로그인 페이지 렌더링 ----------")
|
313 |
-
return render_template('login.html', error=error)
|
|
|
314 |
|
315 |
-
# 로그아웃 라우트
|
316 |
@app.route('/logout')
|
317 |
def logout():
|
318 |
logger.info("-------------- 로그아웃 요청 --------------")
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
if 'logged_in' in session:
|
323 |
-
|
324 |
-
|
325 |
session.pop('logged_in', None)
|
326 |
session.pop('username', None)
|
327 |
-
|
|
|
328 |
else:
|
329 |
logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
|
330 |
-
|
331 |
logger.info("로그인 페이지로 리디렉션")
|
332 |
-
|
|
|
|
|
|
|
333 |
|
334 |
@app.route('/')
|
335 |
@login_required
|
336 |
def index():
|
337 |
"""메인 페이지"""
|
338 |
if not app_ready:
|
339 |
-
|
|
|
|
|
340 |
return render_template('index.html')
|
341 |
|
|
|
342 |
@app.route('/api/status')
|
343 |
@login_required
|
344 |
def app_status():
|
345 |
"""앱 초기화 상태 확인 API"""
|
|
|
346 |
return jsonify({"ready": app_ready})
|
347 |
|
|
|
348 |
@app.route('/api/llm', methods=['GET', 'POST'])
|
349 |
@login_required
|
350 |
def llm_api():
|
351 |
"""사용 가능한 LLM 목록 및 선택 API"""
|
352 |
-
global llm_interface
|
353 |
-
|
354 |
-
# 앱 준비 상태 확인
|
355 |
if not app_ready:
|
356 |
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
357 |
-
|
358 |
if request.method == 'GET':
|
359 |
-
|
360 |
-
|
361 |
-
"
|
362 |
-
|
363 |
-
|
364 |
-
"current": id ==
|
365 |
-
} for name, id in
|
366 |
-
|
367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
368 |
elif request.method == 'POST':
|
369 |
-
# LLM 선택 변경
|
370 |
data = request.get_json()
|
371 |
if not data or 'llm_id' not in data:
|
372 |
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
373 |
-
|
374 |
llm_id = data['llm_id']
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
389 |
|
390 |
@app.route('/api/chat', methods=['POST'])
|
391 |
@login_required
|
392 |
def chat():
|
393 |
"""텍스트 기반 챗봇 API"""
|
394 |
-
global retriever
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
try:
|
401 |
data = request.get_json()
|
402 |
if not data or 'query' not in data:
|
403 |
return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
|
404 |
-
|
405 |
query = data['query']
|
406 |
-
logger.info(f"쿼리 수신: {query}")
|
407 |
-
|
408 |
-
# RAG 검색 수행
|
409 |
-
|
410 |
-
|
411 |
-
|
|
|
|
|
|
|
|
|
412 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
413 |
-
|
414 |
if not context:
|
415 |
-
logger.warning("검색 결과가
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
# LLM에 질의
|
422 |
-
llm_id = data.get('llm_id', None)
|
423 |
-
|
424 |
-
|
425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
426 |
sources = []
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
logger.info(f"source_info에 id 추가: {source_info}")
|
458 |
-
else:
|
459 |
-
logger.warning(f"CSV 파일이지만 코마가 없음: {first_line}")
|
460 |
-
else:
|
461 |
-
logger.warning(f"CSV 파일이지만 라인이 없음: {result['source']}")
|
462 |
-
except Exception as e:
|
463 |
-
logger.warning(f"CSV 첫 번째 컬럼 추출 실패: {e}")
|
464 |
-
|
465 |
-
sources.append(source_info)
|
466 |
-
|
467 |
-
# 최종 응답 구조 로깅
|
468 |
response_data = {
|
469 |
"answer": answer,
|
470 |
"sources": sources,
|
471 |
-
"llm": llm_interface.get_current_llm_details()
|
472 |
}
|
473 |
-
logger.debug(f"최종 API
|
474 |
-
|
475 |
return jsonify(response_data)
|
476 |
-
|
477 |
except Exception as e:
|
478 |
logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
|
479 |
return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
|
480 |
|
|
|
481 |
@app.route('/api/voice', methods=['POST'])
|
482 |
@login_required
|
483 |
def voice_chat():
|
484 |
-
"""
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
- answer: LLM에서 생성한 응답
|
491 |
-
- sources: 검색된 문서 소스 (리스트)
|
492 |
-
- error: 오류 발생 시 오류 메시지
|
493 |
-
- details: 오류 상세 정보 (선택적)
|
494 |
-
"""
|
495 |
logger.info("음성 챗 요청 수신")
|
496 |
-
|
497 |
-
# 오디오 파일 확인
|
498 |
if 'audio' not in request.files:
|
499 |
logger.error("오디오 파일이 제공되지 않음")
|
500 |
return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
|
501 |
-
|
502 |
audio_file = request.files['audio']
|
503 |
-
logger.info(f"수신된 파일: {audio_file.filename}")
|
504 |
-
|
505 |
try:
|
506 |
-
# 오디오 파일
|
507 |
-
|
508 |
-
|
509 |
-
|
510 |
-
|
511 |
-
|
512 |
-
|
513 |
-
|
514 |
-
|
515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
516 |
return jsonify({
|
517 |
-
"error":
|
518 |
-
"details":
|
519 |
}), 500
|
520 |
-
|
521 |
-
transcription = stt_result
|
522 |
if not transcription:
|
523 |
logger.warning("음성인식 결과가 비어있습니다.")
|
524 |
-
return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다."}), 400
|
525 |
-
|
526 |
logger.info(f"음성인식 성공: {transcription[:50]}...")
|
527 |
-
|
528 |
-
#
|
529 |
-
|
530 |
-
|
531 |
-
|
532 |
-
|
533 |
-
|
534 |
-
|
535 |
-
|
536 |
-
|
537 |
-
|
538 |
-
# LLM
|
539 |
-
llm_id = request.form.get('llm_id', None)
|
540 |
-
|
541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
542 |
# 소스 정보 추출
|
543 |
enhanced_sources = []
|
544 |
-
|
545 |
-
|
546 |
-
|
547 |
-
|
548 |
-
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
|
563 |
-
|
564 |
-
|
565 |
-
logger.info(f"[음성챗] CSV 첫 줄: {first_line}")
|
566 |
-
|
567 |
-
if ',' in first_line: # CSV 형식이면
|
568 |
-
first_columns = first_line.split(',')
|
569 |
-
logger.info(f"[음성챗] CSV 컬럼 개수: {len(first_columns)}")
|
570 |
-
|
571 |
-
first_column = first_columns[0].strip()
|
572 |
-
logger.info(f"[음성챗] CSV 첫 번째 컬럼 값: '{first_column}'")
|
573 |
-
source_info["id"] = first_column
|
574 |
-
logger.info(f"[음성챗] source_info에 id 추가: {source_info}")
|
575 |
-
else:
|
576 |
-
logger.warning(f"[음성챗] CSV 파일이지만 코마가 없음: {first_line}")
|
577 |
-
else:
|
578 |
-
logger.warning(f"[음성챗] CSV 파일이지만 라인이 없음: {doc['source']}")
|
579 |
-
except Exception as e:
|
580 |
-
logger.warning(f"[음성챗] CSV 첫 번째 컬럼 추출 실패: {e}")
|
581 |
-
|
582 |
-
enhanced_sources.append(source_info)
|
583 |
-
|
584 |
-
# 최종 응답 구조 로깅
|
585 |
response_data = {
|
586 |
"transcription": transcription,
|
587 |
"answer": answer,
|
588 |
"sources": enhanced_sources,
|
589 |
-
"llm": llm_interface.get_current_llm_details()
|
590 |
}
|
591 |
-
logger.debug(f"[음성챗] 최종 API 응답 구조: {json.dumps(response_data, ensure_ascii=False, indent=2)[:500]}...")
|
592 |
-
|
593 |
return jsonify(response_data)
|
594 |
-
|
595 |
except Exception as e:
|
596 |
-
logger.error(f"음성 챗 처리 중 오류 발생: {
|
597 |
return jsonify({
|
598 |
"error": "음성 처리 중 내부 오류 발생",
|
599 |
"details": str(e)
|
600 |
}), 500
|
601 |
|
|
|
602 |
@app.route('/api/upload', methods=['POST'])
|
603 |
@login_required
|
604 |
def upload_document():
|
605 |
"""지식베이스 문서 업로드 API"""
|
606 |
-
global base_retriever, retriever
|
607 |
-
|
608 |
-
|
609 |
-
|
610 |
-
|
611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
612 |
try:
|
613 |
-
# 파일이 요청에 포함되어 있는지 확인
|
614 |
-
if 'document' not in request.files:
|
615 |
-
return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
|
616 |
-
|
617 |
-
doc_file = request.files['document']
|
618 |
-
logger.info(f"받은 파일명: {doc_file.filename}")
|
619 |
-
|
620 |
-
# 파일명이 비어있는지 확인
|
621 |
-
if doc_file.filename == '':
|
622 |
-
return jsonify({"error": "선택된 파일이 없습니다."}), 400
|
623 |
-
|
624 |
-
# 파일 형식 확인
|
625 |
-
if not allowed_doc_file(doc_file.filename):
|
626 |
-
logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}")
|
627 |
-
return jsonify({"error": "허용되지 않는 파일 형식입니다. 현재 허용된 파일 형식: {}".format(', '.join(ALLOWED_DOC_EXTENSIONS))}), 400
|
628 |
-
|
629 |
-
# 파일명 보안 처리
|
630 |
filename = secure_filename(doc_file.filename)
|
631 |
-
|
632 |
-
# 데이터 폴더에 저장
|
633 |
filepath = os.path.join(app.config['DATA_FOLDER'], filename)
|
634 |
doc_file.save(filepath)
|
635 |
-
|
636 |
-
|
637 |
-
|
638 |
-
# 문서 처리
|
639 |
try:
|
640 |
-
|
|
|
|
|
|
|
641 |
try:
|
642 |
-
with open(filepath, 'r', encoding='utf-8') as f:
|
643 |
-
content = f.read()
|
644 |
-
except UnicodeDecodeError:
|
645 |
-
# UTF-8로 실패하면 CP949(한국어 Windows 기본 인코딩)로 시도
|
646 |
-
logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
|
647 |
with open(filepath, 'r', encoding='cp949') as f:
|
648 |
content = f.read()
|
649 |
-
|
650 |
-
|
651 |
-
|
652 |
-
|
653 |
-
|
654 |
-
|
655 |
-
|
656 |
-
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
-
|
663 |
-
|
664 |
-
|
665 |
-
|
666 |
-
|
667 |
-
|
668 |
-
|
669 |
-
|
670 |
-
|
671 |
-
|
672 |
-
|
673 |
-
|
674 |
-
|
675 |
-
|
676 |
-
|
677 |
-
|
678 |
-
|
679 |
-
|
680 |
-
|
681 |
-
|
682 |
-
|
683 |
-
|
684 |
-
|
685 |
-
|
686 |
-
|
687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
688 |
return jsonify({
|
689 |
"success": True,
|
690 |
-
"message": f"파일 '{filename}'
|
691 |
})
|
692 |
-
|
693 |
-
logger.
|
694 |
-
return jsonify({
|
695 |
-
|
696 |
-
|
697 |
-
|
698 |
-
|
699 |
-
|
700 |
-
|
701 |
-
|
702 |
-
|
703 |
except Exception as e:
|
704 |
-
logger.error(f"파일 업로드 중 오류 발생: {e}", exc_info=True)
|
705 |
return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
|
706 |
|
|
|
707 |
@app.route('/api/documents', methods=['GET'])
|
708 |
@login_required
|
709 |
def list_documents():
|
710 |
"""지식베이스 문서 목록 API"""
|
711 |
-
global base_retriever
|
712 |
-
|
713 |
-
|
714 |
-
|
715 |
-
|
716 |
-
|
717 |
try:
|
718 |
-
# 문서 소스 목록 생성
|
719 |
sources = {}
|
720 |
-
|
721 |
-
|
722 |
-
|
723 |
-
|
724 |
-
|
725 |
-
|
726 |
-
|
727 |
-
|
728 |
-
|
729 |
-
|
730 |
-
|
731 |
-
|
732 |
-
|
733 |
-
|
734 |
-
|
735 |
-
|
736 |
-
|
737 |
-
|
738 |
-
|
739 |
-
|
740 |
-
|
741 |
-
|
742 |
-
|
743 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
744 |
documents.sort(key=lambda x: x["chunks"], reverse=True)
|
745 |
-
|
|
|
746 |
return jsonify({
|
747 |
"documents": documents,
|
748 |
"total_documents": len(documents),
|
749 |
-
"total_chunks": sum(doc["chunks"] for doc in documents)
|
750 |
})
|
751 |
-
|
752 |
except Exception as e:
|
753 |
logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True)
|
754 |
return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
|
755 |
|
|
|
756 |
# 정적 파일 서빙
|
757 |
@app.route('/static/<path:path>')
|
758 |
def send_static(path):
|
759 |
return send_from_directory('static', path)
|
760 |
|
761 |
-
# 세션 쿠키 처리 확인 및 수정
|
762 |
-
@app.before_request
|
763 |
-
def process_cookies():
|
764 |
-
# 수동 쿠키 처리 확인
|
765 |
-
if 'session_data' in request.cookies and 'logged_in' not in session:
|
766 |
-
try:
|
767 |
-
cookie_data = json.loads(request.cookies.get('session_data'))
|
768 |
-
logger.info(f"\n[Before Request] 수동 쿠키 값 발견: {cookie_data}")
|
769 |
-
if cookie_data.get('logged_in'):
|
770 |
-
# 세션 재구성
|
771 |
-
session['logged_in'] = True
|
772 |
-
session['username'] = cookie_data.get('username')
|
773 |
-
logger.info(f"\n[Before Request] 수동 쿠키에서 세션 복원: {session}")
|
774 |
-
except Exception as e:
|
775 |
-
logger.error(f"\n[Before Request] 쿠키 처리 오류: {e}")
|
776 |
|
777 |
-
#
|
|
|
|
|
|
|
|
|
778 |
@app.after_request
|
779 |
def after_request_func(response):
|
780 |
-
|
781 |
-
|
782 |
-
|
783 |
-
|
784 |
-
|
785 |
-
# 응답 헤더 로깅
|
786 |
-
logger.
|
787 |
-
for header, value in response.headers:
|
788 |
-
|
789 |
-
|
790 |
-
#
|
791 |
if 'Set-Cookie' in response.headers:
|
792 |
-
logger.
|
793 |
-
|
794 |
-
|
795 |
-
|
796 |
-
|
797 |
-
|
798 |
-
# 허깅페이스 프록시 관련 헤더 처리
|
799 |
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
800 |
response.headers['Pragma'] = 'no-cache'
|
801 |
response.headers['Expires'] = '0'
|
802 |
-
|
803 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
"""
|
2 |
+
RAG 검색 챗봇 웹 애플리케이션 (세션 설정 수정 적용)
|
3 |
"""
|
4 |
|
5 |
import os
|
|
|
29 |
|
30 |
logger.info(f"==== 환경 변수 로드 상태 ====")
|
31 |
logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
|
32 |
+
# 비밀번호는 로드 여부만 기록 (보안)
|
33 |
logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
|
34 |
|
35 |
+
# 환경 변수가 없으면 기본값 설정 (개발용, 배포 시 환경 변수 설정 권장)
|
36 |
if not ADMIN_USERNAME:
|
37 |
ADMIN_USERNAME = 'admin'
|
38 |
logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
|
|
|
41 |
ADMIN_PASSWORD = 'rag12345'
|
42 |
logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
|
43 |
|
44 |
+
# --- 로컬 모듈 임포트 ---
|
45 |
+
# 실제 경로에 맞게 utils, retrieval 폴더가 존재해야 합니다.
|
46 |
+
try:
|
47 |
+
from utils.vito_stt import VitoSTT
|
48 |
+
from utils.llm_interface import LLMInterface
|
49 |
+
from utils.document_processor import DocumentProcessor
|
50 |
+
from retrieval.vector_retriever import VectorRetriever
|
51 |
+
from retrieval.reranker import ReRanker
|
52 |
+
except ImportError as e:
|
53 |
+
logger.error(f"로컬 모듈 임포트 실패: {e}. utils 및 retrieval 패키지가 올바른 경로에 있는지 확인하세요.")
|
54 |
+
# 개발/테스트를 위해 임시 클래스 정의 (실제 사용 시 제거)
|
55 |
+
class MockComponent: pass
|
56 |
+
VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
|
57 |
+
# --- 로컬 모듈 임포트 끝 ---
|
58 |
+
|
59 |
|
60 |
# Flask 앱 초기화
|
61 |
app = Flask(__name__)
|
62 |
|
63 |
+
# 세션 설정 - 고정된 시크릿 키 사용 (실제 배포 시 환경 변수 등으로 관리 권장)
|
64 |
+
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345') # 환경 변수 우선 사용
|
65 |
+
|
66 |
+
# --- 세션 쿠키 설정 수정 (허깅페이스 환경 고려) ---
|
67 |
+
# 허깅페이스 스페이스는 일반적으로 HTTPS로 서비스되므로 Secure=True 설정
|
68 |
+
app.config['SESSION_COOKIE_SECURE'] = True
|
69 |
+
app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript에서 쿠키 접근 방지 (보안 강화)
|
70 |
+
# SameSite='Lax'가 대부분의 경우에 더 안전하고 호환성이 좋음.
|
71 |
+
# 만약 앱이 다른 도메인의 iframe 내에서 실행되어야 한다면 'None'으로 설정해야 함.
|
72 |
+
# (단, 'None'으로 설정 시 반드시 Secure=True여야 함)
|
73 |
+
# 로그 분석 결과 iframe 환경으로 확인되어 'None'으로 변경
|
74 |
+
app.config['SESSION_COOKIE_SAMESITE'] = 'None' # <--- 이렇게 변경합니다.
|
75 |
+
app.config['SESSION_COOKIE_DOMAIN'] = None # 특정 도메인 제한 없음
|
76 |
+
app.config['SESSION_COOKIE_PATH'] = '/' # 앱 전체 경로에 쿠키 적용
|
77 |
app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가
|
78 |
+
# --- 세션 쿠키 설정 끝 ---
|
79 |
|
80 |
# 최대 파일 크기 설정 (10MB)
|
81 |
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
82 |
+
# 애플리케이션 파일 기준 상대 경로 설정
|
83 |
+
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
|
84 |
+
app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
|
85 |
+
app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
|
86 |
+
app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
|
87 |
|
88 |
+
# 필요한 폴더 생성
|
89 |
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
90 |
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
|
91 |
os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
|
92 |
|
93 |
+
# 허용되는 오디오/문서 파일 확장자
|
94 |
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
|
|
|
|
95 |
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
96 |
|
97 |
+
# --- 전역 객체 초기화 ---
|
98 |
+
try:
|
99 |
+
llm_interface = LLMInterface(default_llm="openai")
|
100 |
+
stt_client = VitoSTT()
|
101 |
+
except NameError:
|
102 |
+
logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
|
103 |
+
llm_interface = MockComponent()
|
104 |
+
stt_client = MockComponent()
|
105 |
|
|
|
|
|
|
|
|
|
106 |
base_retriever = None
|
107 |
retriever = None
|
108 |
+
app_ready = False # 앱 초기화 상태 플래그
|
109 |
+
# --- 전역 객체 초기화 끝 ---
|
110 |
|
|
|
|
|
111 |
|
112 |
+
# --- 인증 데코레이터 (수정됨) ---
|
113 |
def login_required(f):
|
114 |
@wraps(f)
|
115 |
def decorated_function(*args, **kwargs):
|
116 |
logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
|
117 |
+
logger.info(f"현재 플라스크 세션 객체: {session}")
|
118 |
logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
|
119 |
+
# 브라우저가 보낸 실제 쿠키 확인 (디버깅용)
|
120 |
+
logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
|
121 |
+
|
122 |
+
# Flask 세션에 'logged_in' 키가 있는지 직접 확인
|
123 |
+
if 'logged_in' not in session:
|
124 |
+
logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
|
125 |
+
# 수동 쿠키 확인 로직 제거됨
|
126 |
+
return redirect(url_for('login', next=request.url)) # 로그인 후 원래 페이지로 돌아가도록 next 파라미터 추가
|
127 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
|
129 |
return f(*args, **kwargs)
|
130 |
return decorated_function
|
131 |
+
# --- 인증 데코레이터 끝 ---
|
132 |
+
|
133 |
|
134 |
+
# --- 헬퍼 함수 ---
|
135 |
def allowed_audio_file(filename):
|
136 |
"""파일이 허용된 오디오 확장자를 가지는지 확인"""
|
137 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
|
|
139 |
def allowed_doc_file(filename):
|
140 |
"""파일이 허용된 문서 확장자를 가지는지 확인"""
|
141 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
142 |
+
# --- 헬퍼 함수 끝 ---
|
143 |
+
|
144 |
|
145 |
+
# --- 검색기 초기화 관련 함수 ---
|
146 |
def init_retriever():
|
147 |
"""검색기 객체 초기화 또는 로드"""
|
148 |
global base_retriever, retriever
|
149 |
+
|
150 |
index_path = app.config['INDEX_PATH']
|
151 |
+
|
152 |
+
# VectorRetriever 로드 또는 초기화 (실제 클래스 사용 가정)
|
153 |
+
if os.path.exists(os.path.join(index_path, "documents.json")): # 간단한 존재 확인 예시
|
154 |
try:
|
155 |
logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
|
156 |
base_retriever = VectorRetriever.load(index_path)
|
157 |
+
logger.info(f"{len(base_retriever.documents) if hasattr(base_retriever, 'documents') else 0}개 문서가 로드되었습니다.")
|
158 |
except Exception as e:
|
159 |
+
logger.error(f"인덱스 로드 중 오류 발생: {e}. 새 검색기를 초기화합니다.")
|
|
|
160 |
base_retriever = VectorRetriever()
|
161 |
else:
|
162 |
logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
|
163 |
base_retriever = VectorRetriever()
|
164 |
+
|
165 |
+
# 데이터 폴더의 문서 로드 (예시: base_retriever가 비어있을 때)
|
166 |
data_path = app.config['DATA_FOLDER']
|
167 |
+
# base_retriever.documents 와 같은 속성이 실제 클래스에 있다고 가정
|
168 |
+
if (not hasattr(base_retriever, 'documents') or not base_retriever.documents) and os.path.exists(data_path):
|
169 |
logger.info(f"{data_path}에서 문서를 로드합니다...")
|
170 |
+
try:
|
171 |
+
docs = DocumentProcessor.load_documents_from_directory(
|
172 |
+
data_path,
|
173 |
+
extensions=[".txt", ".md", ".csv"], # .pdf, .docx 등은 별도 처리 필요
|
174 |
+
recursive=True
|
175 |
+
)
|
176 |
+
if docs and hasattr(base_retriever, 'add_documents'):
|
177 |
+
logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
|
178 |
+
base_retriever.add_documents(docs)
|
179 |
+
|
180 |
+
if hasattr(base_retriever, 'save'):
|
181 |
+
logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
|
182 |
+
try:
|
183 |
+
base_retriever.save(index_path)
|
184 |
+
logger.info("인덱스 저장 완료")
|
185 |
+
except Exception as e:
|
186 |
+
logger.error(f"인덱스 저장 중 오류 발생: {e}")
|
187 |
+
except Exception as e:
|
188 |
+
logger.error(f"DATA_FOLDER에서 문서 로드 중 오류: {e}")
|
189 |
+
|
190 |
# 재순위화 검색기 초기화
|
191 |
logger.info("재순위화 검색기를 초기화합니다...")
|
192 |
+
try:
|
193 |
+
# 자체 구현된 재순위화 함수
|
194 |
+
def custom_rerank_fn(query, results):
|
195 |
+
query_terms = set(query.lower().split())
|
196 |
+
for result in results:
|
197 |
+
if isinstance(result, dict) and "text" in result:
|
198 |
+
text = result["text"].lower()
|
199 |
+
term_freq = sum(1 for term in query_terms if term in text)
|
200 |
+
normalized_score = term_freq / (len(text.split()) + 1) * 10
|
201 |
+
result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
|
202 |
+
elif isinstance(result, dict):
|
203 |
+
result["rerank_score"] = result.get("score", 0)
|
204 |
+
# 결과 형식이 다를 경우 처리 필요
|
205 |
+
results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
|
206 |
+
return results
|
207 |
+
|
208 |
+
# ReRanker 클래스 사용
|
209 |
+
retriever = ReRanker(
|
210 |
+
base_retriever=base_retriever,
|
211 |
+
rerank_fn=custom_rerank_fn, # 또는 실제 CrossEncoder 모델 사용
|
212 |
+
rerank_field="text" # 재순위화에 사용할 텍스트 필드 지정
|
213 |
+
)
|
214 |
+
logger.info("재순위화 검색기 초기화 완료")
|
215 |
+
except Exception as e:
|
216 |
+
logger.error(f"재순위화 검색기 초기화 실패: {e}")
|
217 |
+
retriever = base_retriever # 실패 시 기본 검색기 사용
|
218 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
219 |
return retriever
|
220 |
|
|
|
221 |
def background_init():
|
222 |
"""백그라운드에서 검색기 초기화 수행"""
|
223 |
global app_ready, retriever
|
224 |
try:
|
225 |
logger.info("백그라운드 초기화 시작")
|
226 |
+
# init_retriever() 호출 시 실제 클래스가 임포트되었다고 가정
|
227 |
+
if 'VectorRetriever' in globals() and VectorRetriever != MockComponent:
|
228 |
+
retriever = init_retriever()
|
229 |
+
else:
|
230 |
+
logger.warning("Retriever 관련 클래스가 없어 초기화를 건너<0xEB><0x87>니다.")
|
231 |
+
# retriever = None # 또는 기본 Mock 객체 할당
|
232 |
app_ready = True
|
233 |
+
logger.info("앱 초기화 완료 (app_ready=True)")
|
234 |
except Exception as e:
|
235 |
+
logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
|
236 |
+
app_ready = False # 오류 발생 시 준비 안됨 상태 유지
|
237 |
|
238 |
# 백그라운드 스레드에서 초기화 시작
|
239 |
init_thread = threading.Thread(target=background_init)
|
240 |
+
init_thread.daemon = True # 메인 스레드 종료 시 함께 종료
|
241 |
init_thread.start()
|
242 |
+
# --- 검색기 초기화 관련 함수 끝 ---
|
243 |
+
|
244 |
+
|
245 |
+
# --- Flask 라우트 정의 ---
|
246 |
|
|
|
247 |
@app.route('/login', methods=['GET', 'POST'])
|
248 |
def login():
|
249 |
error = None
|
250 |
+
next_url = request.args.get('next') # 리디렉션할 URL 가져오기
|
251 |
+
logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
|
252 |
logger.info(f"Method: {request.method}")
|
253 |
+
|
254 |
+
# 헤더 로깅 (디버깅용)
|
255 |
+
# logger.debug("Request Headers:")
|
256 |
+
# for header, value in request.headers.items():
|
257 |
+
# logger.debug(f" {header}: {value}")
|
258 |
+
|
|
|
|
|
|
|
|
|
|
|
259 |
if request.method == 'POST':
|
260 |
logger.info("로그인 시도 받음")
|
|
|
|
|
261 |
username = request.form.get('username', '')
|
262 |
password = request.form.get('password', '')
|
263 |
logger.info(f"입력된 사용자명: {username}")
|
|
|
264 |
logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
|
265 |
+
|
266 |
+
# 환경 변수 또는 기본값과 비교
|
267 |
+
valid_username = ADMIN_USERNAME
|
268 |
+
valid_password = ADMIN_PASSWORD
|
269 |
+
logger.info(f"검증용 사용자명: {valid_username}")
|
270 |
+
logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
|
271 |
+
|
272 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
273 |
if username == valid_username and password == valid_password:
|
274 |
logger.info(f"로그인 성공: {username}")
|
275 |
+
# 세션 설정 전 현재 세션 상태 로깅
|
276 |
+
logger.debug(f"세션 설정 전: {session}")
|
277 |
+
|
278 |
+
# 세션에 로그인 정보 저장
|
279 |
+
session.permanent = True # PERMANENT_SESSION_LIFETIME 설정 사용
|
280 |
session['logged_in'] = True
|
281 |
session['username'] = username
|
282 |
+
session.modified = True # 세션이 변경되었음을 명시 (필수는 아닐 수 있음)
|
283 |
+
|
284 |
+
logger.info(f"세션 설정 후: {session}")
|
285 |
logger.info("세션 설정 완료, 리디렉션 시도")
|
286 |
+
|
287 |
+
# 로그인 성공 후 리디렉션
|
288 |
+
# 'next' 파라미터가 있으면 해당 URL로, 없으면 메인 페이지로
|
289 |
+
redirect_to = next_url or url_for('index')
|
290 |
+
logger.info(f"리디렉션 대상: {redirect_to}")
|
291 |
+
response = redirect(redirect_to)
|
292 |
+
|
293 |
+
# 응답 헤더 로깅 (Set-Cookie 확인용)
|
294 |
+
# logger.debug(f"로그인 성공 응답 헤더: {response.headers}")
|
295 |
return response
|
296 |
else:
|
297 |
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
298 |
+
if username != valid_username: logger.warning("사용자명 불일치")
|
299 |
+
if password != valid_password: logger.warning("비밀번호 불일치")
|
|
|
|
|
|
|
300 |
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
301 |
+
else: # GET 요청
|
302 |
logger.info("로그인 페이지 GET 요청")
|
|
|
303 |
if 'logged_in' in session:
|
304 |
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
305 |
return redirect(url_for('index'))
|
306 |
+
|
307 |
logger.info("---------- 로그인 페이지 렌더링 ----------")
|
308 |
+
return render_template('login.html', error=error, next=next_url)
|
309 |
+
|
310 |
|
|
|
311 |
@app.route('/logout')
|
312 |
def logout():
|
313 |
logger.info("-------------- 로그아웃 요청 --------------")
|
314 |
+
logger.info(f"로그아웃 전 세션 상태: {session}")
|
315 |
+
|
|
|
316 |
if 'logged_in' in session:
|
317 |
+
username = session.get('username', 'unknown')
|
318 |
+
logger.info(f"사용자 {username} 로그아웃 처리 시작")
|
319 |
session.pop('logged_in', None)
|
320 |
session.pop('username', None)
|
321 |
+
session.modified = True # 세션 변경 명시
|
322 |
+
logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
|
323 |
else:
|
324 |
logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
|
325 |
+
|
326 |
logger.info("로그인 페이지로 리디렉션")
|
327 |
+
response = redirect(url_for('login'))
|
328 |
+
# logger.debug(f"로그아웃 응답 헤더: {response.headers}") # 쿠키 삭제 확인용
|
329 |
+
return response
|
330 |
+
|
331 |
|
332 |
@app.route('/')
|
333 |
@login_required
|
334 |
def index():
|
335 |
"""메인 페이지"""
|
336 |
if not app_ready:
|
337 |
+
logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
|
338 |
+
return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
|
339 |
+
logger.info("메인 페이지 요청")
|
340 |
return render_template('index.html')
|
341 |
|
342 |
+
|
343 |
@app.route('/api/status')
|
344 |
@login_required
|
345 |
def app_status():
|
346 |
"""앱 초기화 상태 확인 API"""
|
347 |
+
logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
|
348 |
return jsonify({"ready": app_ready})
|
349 |
|
350 |
+
|
351 |
@app.route('/api/llm', methods=['GET', 'POST'])
|
352 |
@login_required
|
353 |
def llm_api():
|
354 |
"""사용 가능한 LLM 목록 및 선택 API"""
|
355 |
+
global llm_interface
|
356 |
+
|
|
|
357 |
if not app_ready:
|
358 |
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
359 |
+
|
360 |
if request.method == 'GET':
|
361 |
+
logger.info("LLM 목록 요청")
|
362 |
+
try:
|
363 |
+
current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
|
364 |
+
supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
|
365 |
+
supported_list = [{
|
366 |
+
"name": name, "id": id, "current": id == current_details.get("id")
|
367 |
+
} for name, id in supported_llms_dict.items()]
|
368 |
+
|
369 |
+
return jsonify({
|
370 |
+
"supported_llms": supported_list,
|
371 |
+
"current_llm": current_details
|
372 |
+
})
|
373 |
+
except Exception as e:
|
374 |
+
logger.error(f"LLM 정보 조회 오류: {e}")
|
375 |
+
return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
|
376 |
+
|
377 |
elif request.method == 'POST':
|
|
|
378 |
data = request.get_json()
|
379 |
if not data or 'llm_id' not in data:
|
380 |
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
381 |
+
|
382 |
llm_id = data['llm_id']
|
383 |
+
logger.info(f"LLM 변경 요청: {llm_id}")
|
384 |
+
|
385 |
+
try:
|
386 |
+
if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
|
387 |
+
raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
|
388 |
+
|
389 |
+
if llm_id not in llm_interface.llm_clients:
|
390 |
+
return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
|
391 |
+
|
392 |
+
success = llm_interface.set_llm(llm_id)
|
393 |
+
if success:
|
394 |
+
new_details = llm_interface.get_current_llm_details()
|
395 |
+
logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
|
396 |
+
return jsonify({
|
397 |
+
"success": True,
|
398 |
+
"message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
|
399 |
+
"current_llm": new_details
|
400 |
+
})
|
401 |
+
else:
|
402 |
+
# set_llm 이 False를 반환하는 경우 (가능성은 낮지만)
|
403 |
+
logger.error(f"LLM 변경 실패 (ID: {llm_id})")
|
404 |
+
return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
|
405 |
+
except Exception as e:
|
406 |
+
logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
|
407 |
+
return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
|
408 |
+
|
409 |
|
410 |
@app.route('/api/chat', methods=['POST'])
|
411 |
@login_required
|
412 |
def chat():
|
413 |
"""텍스트 기반 챗봇 API"""
|
414 |
+
global retriever
|
415 |
+
|
416 |
+
if not app_ready or retriever is None:
|
417 |
+
return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
418 |
+
|
|
|
419 |
try:
|
420 |
data = request.get_json()
|
421 |
if not data or 'query' not in data:
|
422 |
return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
|
423 |
+
|
424 |
query = data['query']
|
425 |
+
logger.info(f"텍스트 쿼리 수신: {query[:100]}...") # 너무 긴 쿼리 로그는 잘라서 표시
|
426 |
+
|
427 |
+
# RAG 검색 수행
|
428 |
+
if not hasattr(retriever, 'search'):
|
429 |
+
raise NotImplementedError("Retriever에 search 메소드가 없습니다.")
|
430 |
+
search_results = retriever.search(query, top_k=5, first_stage_k=6) # 재순위화 고려
|
431 |
+
|
432 |
+
# 컨텍스트 준비
|
433 |
+
if not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
434 |
+
raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.")
|
435 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
436 |
+
|
437 |
if not context:
|
438 |
+
logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.")
|
439 |
+
# LLM 호출 없이 기본 응답 반환 또는 검색 결과 없음을 알리는 응답 생성
|
440 |
+
# answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." (아래 LLM 호출 로직에서 처리)
|
441 |
+
pass
|
442 |
+
|
|
|
443 |
# LLM에 질의
|
444 |
+
llm_id = data.get('llm_id', None) # 클라이언트에서 특정 LLM 지정 가능
|
445 |
+
if not hasattr(llm_interface, 'rag_generate'):
|
446 |
+
raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.")
|
447 |
+
|
448 |
+
if not context:
|
449 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
450 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
451 |
+
else:
|
452 |
+
answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
|
453 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
454 |
+
|
455 |
+
|
456 |
+
# 소스 정보 추출 (CSV ID 추출 로직 포함)
|
457 |
sources = []
|
458 |
+
if search_results: # 검색 결과가 있을 때만 소스 처리
|
459 |
+
for result in search_results:
|
460 |
+
# 결과가 딕셔너리 형태인지 확인
|
461 |
+
if not isinstance(result, dict):
|
462 |
+
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
463 |
+
continue
|
464 |
+
|
465 |
+
if "source" in result:
|
466 |
+
source_info = {
|
467 |
+
"source": result.get("source", "Unknown"),
|
468 |
+
# 재순위화 점수가 있으면 사용, 없으면 원래 점수 사용
|
469 |
+
"score": result.get("rerank_score", result.get("score", 0))
|
470 |
+
}
|
471 |
+
|
472 |
+
# CSV 파일 특정 처리
|
473 |
+
if "text" in result and result.get("filetype") == "csv":
|
474 |
+
try:
|
475 |
+
text_lines = result["text"].strip().split('\n')
|
476 |
+
if text_lines:
|
477 |
+
first_line = text_lines[0].strip()
|
478 |
+
if ',' in first_line:
|
479 |
+
first_column = first_line.split(',')[0].strip()
|
480 |
+
source_info["id"] = first_column # 예: CSV의 첫 컬럼 값을 ID로 추가
|
481 |
+
logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}")
|
482 |
+
except Exception as e:
|
483 |
+
logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}")
|
484 |
+
|
485 |
+
sources.append(source_info)
|
486 |
+
|
487 |
+
# 최종 응답
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
488 |
response_data = {
|
489 |
"answer": answer,
|
490 |
"sources": sources,
|
491 |
+
"llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
|
492 |
}
|
493 |
+
# logger.debug(f"최종 API 응답: {response_data}") # 너무 길 수 있으므로 필요한 경우에만 활성화
|
|
|
494 |
return jsonify(response_data)
|
495 |
+
|
496 |
except Exception as e:
|
497 |
logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
|
498 |
return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
|
499 |
|
500 |
+
|
501 |
@app.route('/api/voice', methods=['POST'])
|
502 |
@login_required
|
503 |
def voice_chat():
|
504 |
+
"""음성 챗 API 엔드포인트"""
|
505 |
+
global retriever, stt_client
|
506 |
+
|
507 |
+
if not app_ready or retriever is None or stt_client is None:
|
508 |
+
return jsonify({"error": "앱/검색기/STT가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
509 |
+
|
|
|
|
|
|
|
|
|
|
|
510 |
logger.info("음성 챗 요청 수신")
|
|
|
|
|
511 |
if 'audio' not in request.files:
|
512 |
logger.error("오디오 파일이 제공되지 않음")
|
513 |
return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
|
514 |
+
|
515 |
audio_file = request.files['audio']
|
516 |
+
logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
|
517 |
+
|
518 |
try:
|
519 |
+
# 오디오 파일 처리
|
520 |
+
# 임시 파일 사용 고려 (메모리 부담 줄이기 위해)
|
521 |
+
with tempfile.NamedTemporaryFile(delete=True) as temp_audio:
|
522 |
+
audio_file.save(temp_audio.name)
|
523 |
+
logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
|
524 |
+
# VitoSTT.transcribe_audio 가 파일 경로 또는 바이트를 받을 수 있도록 구현되어야 함
|
525 |
+
# 여기서는 파일 경로를 사용한다고 가정
|
526 |
+
if not hasattr(stt_client, 'transcribe_audio'):
|
527 |
+
raise NotImplementedError("STT 클라이언트에 transcribe_audio 메소드가 없습니다.")
|
528 |
+
|
529 |
+
# 파일 경로로 전달 시
|
530 |
+
# stt_result = stt_client.transcribe_audio(temp_audio.name, language="ko")
|
531 |
+
# 바이트로 전달 시
|
532 |
+
with open(temp_audio.name, 'rb') as f_bytes:
|
533 |
+
audio_bytes = f_bytes.read()
|
534 |
+
stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
|
535 |
+
|
536 |
+
|
537 |
+
if not isinstance(stt_result, dict) or not stt_result.get("success"):
|
538 |
+
error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
|
539 |
+
logger.error(f"음성인식 실패: {error_msg}")
|
540 |
return jsonify({
|
541 |
+
"error": "음성인식 실패",
|
542 |
+
"details": error_msg
|
543 |
}), 500
|
544 |
+
|
545 |
+
transcription = stt_result.get("text", "")
|
546 |
if not transcription:
|
547 |
logger.warning("음성인식 결과가 비어있습니다.")
|
548 |
+
return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다.", "transcription": ""}), 400
|
549 |
+
|
550 |
logger.info(f"음성인식 성공: {transcription[:50]}...")
|
551 |
+
|
552 |
+
# --- 이후 로직은 /api/chat과 거의 동일 ---
|
553 |
+
# RAG 검색 수행
|
554 |
+
search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
|
555 |
+
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
556 |
+
|
557 |
+
if not context:
|
558 |
+
logger.warning("음성 쿼리에 대한 검색 결과 없음.")
|
559 |
+
# answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." (아래 LLM 호출 로직에서 처리)
|
560 |
+
pass
|
561 |
+
|
562 |
+
# LLM 호출
|
563 |
+
llm_id = request.form.get('llm_id', None) # 음성 요청은 form 데이터로 LLM ID 받을 수 있음
|
564 |
+
if not context:
|
565 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
566 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
567 |
+
else:
|
568 |
+
answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
|
569 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
570 |
+
|
571 |
+
|
572 |
# 소스 정보 추출
|
573 |
enhanced_sources = []
|
574 |
+
if search_results:
|
575 |
+
for doc in search_results:
|
576 |
+
if not isinstance(doc, dict): continue # 형식 체크
|
577 |
+
if "source" in doc:
|
578 |
+
source_info = {
|
579 |
+
"source": doc.get("source", "Unknown"),
|
580 |
+
"score": doc.get("rerank_score", doc.get("score", 0))
|
581 |
+
}
|
582 |
+
if "text" in doc and doc.get("filetype") == "csv":
|
583 |
+
try:
|
584 |
+
text_lines = doc["text"].strip().split('\n')
|
585 |
+
if text_lines:
|
586 |
+
first_line = text_lines[0].strip()
|
587 |
+
if ',' in first_line:
|
588 |
+
first_column = first_line.split(',')[0].strip()
|
589 |
+
source_info["id"] = first_column
|
590 |
+
except Exception as e:
|
591 |
+
logger.warning(f"[음성챗] CSV 소스 ID 추출 실패 ({doc.get('source')}): {e}")
|
592 |
+
enhanced_sources.append(source_info)
|
593 |
+
|
594 |
+
# 최종 응답
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
595 |
response_data = {
|
596 |
"transcription": transcription,
|
597 |
"answer": answer,
|
598 |
"sources": enhanced_sources,
|
599 |
+
"llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
|
600 |
}
|
|
|
|
|
601 |
return jsonify(response_data)
|
602 |
+
|
603 |
except Exception as e:
|
604 |
+
logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
|
605 |
return jsonify({
|
606 |
"error": "음성 처리 중 내부 오류 발생",
|
607 |
"details": str(e)
|
608 |
}), 500
|
609 |
|
610 |
+
|
611 |
@app.route('/api/upload', methods=['POST'])
|
612 |
@login_required
|
613 |
def upload_document():
|
614 |
"""지식베이스 문서 업로드 API"""
|
615 |
+
global base_retriever, retriever
|
616 |
+
|
617 |
+
if not app_ready or base_retriever is None:
|
618 |
+
return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
|
619 |
+
|
620 |
+
if 'document' not in request.files:
|
621 |
+
return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
|
622 |
+
|
623 |
+
doc_file = request.files['document']
|
624 |
+
if doc_file.filename == '':
|
625 |
+
return jsonify({"error": "선택된 파일이 없습니다."}), 400
|
626 |
+
|
627 |
+
if not allowed_doc_file(doc_file.filename):
|
628 |
+
logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}")
|
629 |
+
return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400
|
630 |
+
|
631 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
632 |
filename = secure_filename(doc_file.filename)
|
|
|
|
|
633 |
filepath = os.path.join(app.config['DATA_FOLDER'], filename)
|
634 |
doc_file.save(filepath)
|
635 |
+
logger.info(f"문서 저장 완료: {filepath}")
|
636 |
+
|
637 |
+
# 문서 처리 (인코딩 처리 포함)
|
|
|
638 |
try:
|
639 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
640 |
+
content = f.read()
|
641 |
+
except UnicodeDecodeError:
|
642 |
+
logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
|
643 |
try:
|
|
|
|
|
|
|
|
|
|
|
644 |
with open(filepath, 'r', encoding='cp949') as f:
|
645 |
content = f.read()
|
646 |
+
except Exception as e_cp949:
|
647 |
+
logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}")
|
648 |
+
return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400
|
649 |
+
except Exception as e_read:
|
650 |
+
logger.error(f"파일 읽기 오류 ({filename}): {e_read}")
|
651 |
+
return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500
|
652 |
+
|
653 |
+
|
654 |
+
# 메타데이터 및 문서 분할/처리
|
655 |
+
metadata = {
|
656 |
+
"source": filename, "filename": filename,
|
657 |
+
"filetype": filename.rsplit('.', 1)[1].lower(),
|
658 |
+
"filepath": filepath
|
659 |
+
}
|
660 |
+
file_ext = metadata["filetype"]
|
661 |
+
docs = []
|
662 |
+
|
663 |
+
if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
|
664 |
+
raise NotImplementedError("DocumentProcessor에 필요한 메소드 없음")
|
665 |
+
|
666 |
+
if file_ext == 'csv':
|
667 |
+
logger.info(f"CSV 파일 처리 시작: {filename}")
|
668 |
+
docs = DocumentProcessor.csv_to_documents(content, metadata) # 행 단위 처리 가정
|
669 |
+
else: # 기타 텍스트 기반 문서
|
670 |
+
logger.info(f"일반 텍스트 문서 처리 시작: {filename}")
|
671 |
+
# PDF, DOCX 등은 별도 라이브러리(pypdf, python-docx) 필요
|
672 |
+
if file_ext in ['pdf', 'docx']:
|
673 |
+
logger.warning(f".{file_ext} 파일 처리는 현재 구현되지 않았습니다. 텍스트 추출 로직 추가 필요.")
|
674 |
+
# 여기에 pdf/docx 텍스트 추출 로직 추가
|
675 |
+
# 예: content = extract_text_from_pdf(filepath)
|
676 |
+
# content = extract_text_from_docx(filepath)
|
677 |
+
# 임시로 비워둠
|
678 |
+
content = ""
|
679 |
+
|
680 |
+
if content: # 텍스트 내용이 있을 때만 처리
|
681 |
+
docs = DocumentProcessor.text_to_documents(
|
682 |
+
content, metadata=metadata,
|
683 |
+
chunk_size=512, chunk_overlap=50
|
684 |
+
)
|
685 |
+
|
686 |
+
# 검색기에 문서 추가 및 인덱스 저장
|
687 |
+
if docs:
|
688 |
+
if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
|
689 |
+
raise NotImplementedError("기본 검색기에 add_documents 또는 save 메소드 없음")
|
690 |
+
|
691 |
+
logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
|
692 |
+
base_retriever.add_documents(docs)
|
693 |
+
|
694 |
+
# 인덱스 저장 (업로드마다 저장 - 비효율적일 수 있음)
|
695 |
+
logger.info(f"검색기 상태를 저장합니다...")
|
696 |
+
index_path = app.config['INDEX_PATH']
|
697 |
+
try:
|
698 |
+
base_retriever.save(index_path)
|
699 |
+
logger.info("인덱스 저장 완료")
|
700 |
+
# 재순위화 검색기도 업데이트 필요 시 로직 추가
|
701 |
+
# 예: retriever.update_base_retriever(base_retriever)
|
702 |
return jsonify({
|
703 |
"success": True,
|
704 |
+
"message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)."
|
705 |
})
|
706 |
+
except Exception as e_save:
|
707 |
+
logger.error(f"인덱스 저장 중 오류 발생: {e_save}")
|
708 |
+
return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500
|
709 |
+
else:
|
710 |
+
logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.")
|
711 |
+
# 파일은 저장되었으므로 성공으로 간주할지 결정 필요
|
712 |
+
return jsonify({
|
713 |
+
"warning": True,
|
714 |
+
"message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없습니다."
|
715 |
+
})
|
716 |
+
|
717 |
except Exception as e:
|
718 |
+
logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True)
|
719 |
return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
|
720 |
|
721 |
+
|
722 |
@app.route('/api/documents', methods=['GET'])
|
723 |
@login_required
|
724 |
def list_documents():
|
725 |
"""지식베이스 문서 목록 API"""
|
726 |
+
global base_retriever
|
727 |
+
|
728 |
+
if not app_ready or base_retriever is None:
|
729 |
+
return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
|
730 |
+
|
|
|
731 |
try:
|
|
|
732 |
sources = {}
|
733 |
+
total_chunks = 0
|
734 |
+
# base_retriever.documents 와 같은 속성이 실제 클래스에 있다고 가정
|
735 |
+
if hasattr(base_retriever, 'documents') and base_retriever.documents:
|
736 |
+
logger.info(f"총 {len(base_retriever.documents)}개 문서 청크에서 소스 목록 생성 중...")
|
737 |
+
for doc in base_retriever.documents:
|
738 |
+
# 문서 청크가 딕셔너리 형태라고 가정
|
739 |
+
if not isinstance(doc, dict): continue
|
740 |
+
|
741 |
+
source = doc.get("source", "unknown") # 메타데이터에서 source 가져오기
|
742 |
+
if source == "unknown" and "metadata" in doc and isinstance(doc["metadata"], dict):
|
743 |
+
source = doc["metadata"].get("source", "unknown") # Langchain Document 구조 고려
|
744 |
+
|
745 |
+
if source != "unknown":
|
746 |
+
if source in sources:
|
747 |
+
sources[source]["chunks"] += 1
|
748 |
+
else:
|
749 |
+
# 메타데이터에서 추가 정보 가져오기
|
750 |
+
filename = doc.get("filename", source)
|
751 |
+
filetype = doc.get("filetype", "unknown")
|
752 |
+
if "metadata" in doc and isinstance(doc["metadata"], dict):
|
753 |
+
filename = doc["metadata"].get("filename", filename)
|
754 |
+
filetype = doc["metadata"].get("filetype", filetype)
|
755 |
+
|
756 |
+
sources[source] = {
|
757 |
+
"filename": filename,
|
758 |
+
"chunks": 1,
|
759 |
+
"filetype": filetype
|
760 |
+
}
|
761 |
+
total_chunks += 1
|
762 |
+
else:
|
763 |
+
logger.info("검색기에 문서가 없거나 documents 속성을 찾을 수 없습니다.")
|
764 |
+
|
765 |
+
# 목록 형식 변환 및 정렬
|
766 |
+
documents = [{"source": src, **info} for src, info in sources.items()]
|
767 |
documents.sort(key=lambda x: x["chunks"], reverse=True)
|
768 |
+
|
769 |
+
logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크")
|
770 |
return jsonify({
|
771 |
"documents": documents,
|
772 |
"total_documents": len(documents),
|
773 |
+
"total_chunks": total_chunks # sum(doc["chunks"] for doc in documents) 와 동일
|
774 |
})
|
775 |
+
|
776 |
except Exception as e:
|
777 |
logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True)
|
778 |
return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
|
779 |
|
780 |
+
|
781 |
# 정적 파일 서빙
|
782 |
@app.route('/static/<path:path>')
|
783 |
def send_static(path):
|
784 |
return send_from_directory('static', path)
|
785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
786 |
|
787 |
+
# --- 요청 처리 훅 ---
|
788 |
+
|
789 |
+
# @app.before_request - 제거됨 (수동 쿠키 처리 로직 삭제)
|
790 |
+
# def process_cookies(): ...
|
791 |
+
|
792 |
@app.after_request
|
793 |
def after_request_func(response):
|
794 |
+
"""모든 응답에 대해 후처리 수행"""
|
795 |
+
# 세션이 수정되었는지 확인 후 로깅 (디버깅용)
|
796 |
+
# if session.modified: # session.modified 는 항상 정확하지 않을 수 있음
|
797 |
+
# logger.debug(f"[After Request] 세션 수정 감지. 현재 세션: {session}")
|
798 |
+
|
799 |
+
# 응답 헤더 로깅 (디버깅용)
|
800 |
+
# logger.debug("[Response Headers]")
|
801 |
+
# for header, value in response.headers:
|
802 |
+
# logger.debug(f" {header}: {value}")
|
803 |
+
|
804 |
+
# Set-Cookie 헤더 확인 (디버깅용)
|
805 |
if 'Set-Cookie' in response.headers:
|
806 |
+
logger.debug(f"응답에 Set-Cookie 헤더 포함: {response.headers['Set-Cookie']}")
|
807 |
+
# elif 'logged_in' in session and request.path != '/login': # 로그인 상태인데 쿠키가 안나가는 경우
|
808 |
+
# logger.warning("세션에 logged_in=True 이지만 응답에 Set-Cookie 헤더가 없습니다.")
|
809 |
+
|
810 |
+
# 허깅페이스 프록시 등 캐싱 방지 헤더 설정 (필요시)
|
|
|
|
|
811 |
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
812 |
response.headers['Pragma'] = 'no-cache'
|
813 |
response.headers['Expires'] = '0'
|
814 |
+
|
815 |
+
return response
|
816 |
+
|
817 |
+
# --- 요청 처리 훅 끝 ---
|
818 |
+
|
819 |
+
# 앱 실행 (로컬 테스트용)
|
820 |
+
if __name__ == '__main__':
|
821 |
+
logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
|
822 |
+
# host='0.0.0.0' 으로 설정하면 외부에서 접근 가능
|
823 |
+
app.run(debug=True, host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))
|
824 |
+
# Hugging Face Spaces는 보통 PORT 환경 변수를 사용
|