""" RAG 검색 챗봇 웹 애플리케이션 (세션 설정 수정 적용 및 중복 라우트 등록 방지) """ import os import json import logging import tempfile import threading import datetime from flask import Flask, request, jsonify, render_template, send_from_directory, session, redirect, url_for from flask_cors import CORS from werkzeug.utils import secure_filename from dotenv import load_dotenv from functools import wraps # 로거 설정 logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.DEBUG # INFO에서 DEBUG로 변경하여 더 상세한 로그 확인 ) logger = logging.getLogger(__name__) # 환경 변수 로드 load_dotenv() # 환경 변수 로드 상태 확인 및 로깅 ADMIN_USERNAME = os.getenv('ADMIN_USERNAME') ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD') # 장치 서버 URL 환경 변수 추가 DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050') logger.info(f"장치 서버 URL: {DEVICE_SERVER_URL}") logger.info(f"==== 환경 변수 로드 상태 ====") logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}") # 비밀번호는 로드 여부만 기록 (보안) logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}") # 환경 변수가 없으면 기본값 설정 (개발용, 배포 시 환경 변수 설정 권장) if not ADMIN_USERNAME: ADMIN_USERNAME = 'admin' logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.") if not ADMIN_PASSWORD: ADMIN_PASSWORD = 'rag12345' logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.") class MockComponent: pass # --- 로컬 모듈 임포트 --- # 실제 경로에 맞게 utils, retrieval 폴더가 존재해야 합니다. try: from utils.vito_stt import VitoSTT from utils.llm_interface import LLMInterface from utils.document_processor import DocumentProcessor from retrieval.vector_retriever import VectorRetriever from retrieval.reranker import ReRanker # 장치 라우트 등록 함수 임포트 from app.app_device_routes import register_device_routes except ImportError as e: logger.error(f"로컬 모듈 임포트 실패: {e}. utils, retrieval, app 패키지가 올바른 경로에 있는지 확인하세요.") # 개발/테스트를 위해 임시 클래스/함수 정의 (실제 사용 시 제거) VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent def register_device_routes(*args, **kwargs): logger.warning("Mock register_device_routes 함수 호출됨.") pass # --- 로컬 모듈 임포트 끝 --- # Flask 앱 초기화 app = Flask(__name__) # CORS 설정 - 모든 도메인에서의 요청 허용 CORS(app, supports_credentials=True) # 세션 설정 - 고정된 시크릿 키 사용 (실제 배포 시 환경 변수 등으로 관리 권장) app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345') # 환경 변수 우선 사용 # --- 세션 쿠키 설정 수정 (허깅페이스 환경 고려) --- # 허깅페이스 스페이스는 일반적으로 HTTPS로 서비스되므로 Secure=True 설정 app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript에서 쿠키 접근 방지 (보안 강화) # SameSite='Lax'가 대부분의 경우에 더 안전하고 호환성이 좋음. # 만약 앱이 다른 도메인의 iframe 내에서 실행되어야 한다면 'None'으로 설정해야 함. # (단, 'None'으로 설정 시 반드시 Secure=True여야 함) # 로그 분석 결과 iframe 환경으로 확인되어 'None'으로 변경 app.config['SESSION_COOKIE_SAMESITE'] = 'None' # <--- 이렇게 변경합니다. app.config['SESSION_COOKIE_DOMAIN'] = None # 특정 도메인 제한 없음 app.config['SESSION_COOKIE_PATH'] = '/' # 앱 전체 경로에 쿠키 적용 app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가 # --- 세션 쿠키 설정 끝 --- # 최대 파일 크기 설정 (10MB) app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 애플리케이션 파일 기준 상대 경로 설정 APP_ROOT = os.path.dirname(os.path.abspath(__file__)) app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads') app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data') app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index') # 필요한 폴더 생성 os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['DATA_FOLDER'], exist_ok=True) os.makedirs(app.config['INDEX_PATH'], exist_ok=True) # 허용되는 오디오/문서 파일 확장자 ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'} ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'} # --- 전역 객체 초기화 --- try: llm_interface = LLMInterface(default_llm="openai") stt_client = VitoSTT() except NameError: logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.") llm_interface = MockComponent() stt_client = MockComponent() base_retriever = None retriever = None app_ready = False # 앱 초기화 상태 플래그 DEVICE_ROUTES_REGISTERED = False # 장치 라우트 등록 상태 플래그 # --- 전역 객체 초기화 끝 --- # --- 인증 데코레이터 (수정됨) --- def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------") logger.info(f"현재 플라스크 세션 객체: {session}") logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}") # 브라우저가 보낸 실제 쿠키 확인 (디버깅용) logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}") # API 요청이고 클라이언트에서 오는 경우 인증 무시 (임시 조치) # ---> 주의: 이 부분은 보안 검토 후 실제 환경에서는 제거하거나 더 안전한 방식으로 변경해야 할 수 있습니다. if request.path.startswith('/api/device/'): logger.info(f"장치 API 요청: {request.path} - 인증 제외 (주의: 임시 조치)") return f(*args, **kwargs) # Flask 세션에 'logged_in' 키가 있는지 직접 확인 if 'logged_in' not in session: logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.") # 수동 쿠키 확인 로직 제거됨 return redirect(url_for('login', next=request.url)) # 로그인 후 원래 페이지로 돌아가도록 next 파라미터 추가 logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근") return f(*args, **kwargs) return decorated_function # --- 인증 데코레이터 끝 --- # --- 오류 핸들러 추가 --- @app.errorhandler(404) def not_found(e): # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답 if request.path.startswith('/api/'): return jsonify({"success": False, "error": "요청한 API 엔드포인트를 찾을 수 없습니다."}), 404 # 일반 웹 페이지 요청인 경우 HTML 응답 return "페이지를 찾을 수 없습니다.", 404 @app.errorhandler(500) def internal_error(e): # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답 if request.path.startswith('/api/'): return jsonify({"success": False, "error": "서버 내부 오류가 발생했습니다."}), 500 # 일반 웹 페이지 요청인 경우 HTML 응답 return "서버 오류가 발생했습니다.", 500 # --- 오류 핸들러 끝 --- # --- 장치 관련 라우트 등록 (수정됨: 중복 방지) --- # 전역 플래그를 사용하여 한 번만 등록되도록 함 if not DEVICE_ROUTES_REGISTERED: try: # 임포트된 register_device_routes 함수 사용 # 인증 데코레이터(login_required)와 서버 URL 전달 register_device_routes(app, login_required, DEVICE_SERVER_URL) DEVICE_ROUTES_REGISTERED = True # 등록 성공 시 플래그 설정 logger.info("장치 관련 라우트 등록 완료") except NameError: logger.error("register_device_routes 함수를 찾을 수 없습니다. app.app_device_routes 모듈 확인 필요.") except Exception as e: logger.error(f"장치 관련 라우트 등록 실패: {e}", exc_info=True) else: logger.info("장치 관련 라우트가 이미 등록되어 있어 건너<0xEB>뜁니다.") # --- 장치 관련 라우트 등록 끝 --- # --- 헬퍼 함수 --- def allowed_audio_file(filename): """파일이 허용된 오디오 확장자를 가지는지 확인""" return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS def allowed_doc_file(filename): """파일이 허용된 문서 확장자를 가지는지 확인""" return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS # --- 헬퍼 함수 끝 --- # init_retriever 함수 내부에 로깅 추가 예시 # --- 검색기 초기화 관련 함수 --- def init_retriever(): """검색기 객체 초기화 또는 로드""" global base_retriever, retriever index_path = app.config['INDEX_PATH'] data_path = app.config['DATA_FOLDER'] # data_path 정의 확인 logger.info("--- init_retriever 시작 ---") # 1. 기본 검색기 로드 또는 초기화 if os.path.exists(os.path.join(index_path, "documents.json")): try: logger.info(f"인덱스 로드 시도: {index_path}") base_retriever = VectorRetriever.load(index_path) logger.info(f"인덱스 로드 성공. 문서 {len(getattr(base_retriever, 'documents', []))}개") except Exception as e: logger.error(f"인덱스 로드 실패: {e}", exc_info=True) logger.info("새 VectorRetriever 초기화 시도...") try: base_retriever = VectorRetriever() logger.info("새 VectorRetriever 초기화 성공.") except Exception as e_init: logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True) base_retriever = None else: logger.info("인덱스 파일 없음. 새 VectorRetriever 초기화 시도...") try: base_retriever = VectorRetriever() logger.info("새 VectorRetriever 초기화 성공.") except Exception as e_init: logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True) base_retriever = None if base_retriever is None: logger.error("base_retriever 초기화/로드에 실패하여 init_retriever 중단.") return None # 2. 데이터 폴더 문서 로드 (기본 검색기가 비어있을 때) needs_loading = (not hasattr(base_retriever, 'documents') or not getattr(base_retriever, 'documents', None)) # None 체크 추가 if needs_loading and os.path.exists(data_path): logger.info(f"기본 검색기가 비어있어 {data_path}에서 문서 로드 시도...") try: docs = DocumentProcessor.load_documents_from_directory( directory=data_path, extensions=[".txt", ".md", ".csv"], recursive=True ) logger.info(f"{len(docs)}개 문서 로드 성공.") if docs and hasattr(base_retriever, 'add_documents'): logger.info("검색기에 문서 추가 시도...") base_retriever.add_documents(docs) logger.info("문서 추가 완료.") if hasattr(base_retriever, 'save'): logger.info(f"검색기 상태 저장 시도: {index_path}") try: base_retriever.save(index_path) logger.info("인덱스 저장 완료.") except Exception as e_save: logger.error(f"인덱스 저장 실패: {e_save}", exc_info=True) except Exception as e_load_add: logger.error(f"DATA_FOLDER 문서 로드/추가 중 오류: {e_load_add}", exc_info=True) # 3. 재순위화 검색기 초기화 logger.info("재순위화 검색기 초기화 시도...") try: def custom_rerank_fn(query, results): query_terms = set(query.lower().split()) for result in results: if isinstance(result, dict) and "text" in result: text = result["text"].lower() term_freq = sum(1 for term in query_terms if term in text) normalized_score = term_freq / (len(text.split()) + 1) * 10 result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3 elif isinstance(result, dict): result["rerank_score"] = result.get("score", 0) results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True) return results # ReRanker 클래스 사용 retriever = ReRanker( base_retriever=base_retriever, rerank_fn=custom_rerank_fn, rerank_field="text" ) logger.info("재순위화 검색기 초기화 완료.") except Exception as e_rerank: logger.error(f"재순위화 검색기 초기화 실패: {e_rerank}", exc_info=True) logger.warning("재순위화 실패, 기본 검색기를 retriever로 사용합니다.") retriever = base_retriever # fallback logger.info("--- init_retriever 종료 ---") return retriever def background_init(): """백그라운드에서 검색기 초기화 수행""" global app_ready, retriever, base_retriever, llm_interface, stt_client temp_app_ready = False # 임시 상태 플래그 try: logger.info("백그라운드 초기화 시작...") # 1. LLM, STT 인터페이스 초기화 (필요 시) if llm_interface is None or isinstance(llm_interface, MockComponent): if 'LLMInterface' in globals() and LLMInterface != MockComponent: llm_interface = LLMInterface(default_llm="openai") logger.info("LLM 인터페이스 초기화 완료.") else: logger.warning("LLMInterface 클래스 없음. Mock 사용.") llm_interface = MockComponent() # Mock 객체 보장 if stt_client is None or isinstance(stt_client, MockComponent): if 'VitoSTT' in globals() and VitoSTT != MockComponent: stt_client = VitoSTT() logger.info("STT 클라이언트 초기화 완료.") else: logger.warning("VitoSTT 클래스 없음. Mock 사용.") stt_client = MockComponent() # Mock 객체 보장 # 2. 검색기 초기화 if 'VectorRetriever' in globals() and VectorRetriever != MockComponent: logger.info("실제 검색기 초기화 시도...") retriever = init_retriever() if hasattr(retriever, 'base_retriever') and base_retriever is None: base_retriever = retriever.base_retriever elif base_retriever is None: logger.warning("init_retriever 후 base_retriever가 설정되지 않음. 확인 필요.") if isinstance(retriever, VectorRetriever): base_retriever = retriever if retriever is not None and base_retriever is not None: logger.info("검색기 (Retriever, Base Retriever) 초기화 성공") temp_app_ready = True else: logger.error("검색기 초기화 후에도 retriever 또는 base_retriever가 None입니다.") if base_retriever is None: base_retriever = MockComponent() if retriever is None: retriever = MockComponent() if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: [] if not hasattr(base_retriever, 'documents'): base_retriever.documents = [] temp_app_ready = True else: logger.warning("VectorRetriever 클래스 없음. Mock 검색기 사용.") base_retriever = MockComponent() retriever = MockComponent() if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: [] if not hasattr(base_retriever, 'documents'): base_retriever.documents = [] temp_app_ready = True logger.info(f"백그라운드 초기화 완료. 최종 상태: {'Ready' if temp_app_ready else 'Not Ready (Error during init)'}") except Exception as e: logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True) if base_retriever is None: base_retriever = MockComponent() if retriever is None: retriever = MockComponent() if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: [] if not hasattr(base_retriever, 'documents'): base_retriever.documents = [] temp_app_ready = True logger.warning("초기화 중 오류가 발생했지만 Mock 객체로 대체 후 앱 사용 가능 상태로 설정.") finally: # 최종적으로 app_ready 상태 업데이트 app_ready = temp_app_ready # 장치 라우트 등록 호출은 여기서 제거됨 (메인 레벨에서 처리) # 백그라운드 스레드 시작 부분은 그대로 유지 init_thread = threading.Thread(target=background_init) init_thread.daemon = True init_thread.start() # --- Flask 라우트 정의 --- @app.route('/login', methods=['GET', 'POST']) def login(): error = None next_url = request.args.get('next') # 리디렉션할 URL 가져오기 logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------") logger.info(f"Method: {request.method}") if request.method == 'POST': logger.info("로그인 시도 받음") username = request.form.get('username', '') password = request.form.get('password', '') logger.info(f"입력된 사용자명: {username}") logger.info(f"비밀번호 입력 여부: {len(password) > 0}") valid_username = ADMIN_USERNAME valid_password = ADMIN_PASSWORD logger.info(f"검증용 사용자명: {valid_username}") logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}") if username == valid_username and password == valid_password: logger.info(f"로그인 성공: {username}") logger.debug(f"세션 설정 전: {session}") session.permanent = True session['logged_in'] = True session['username'] = username session.modified = True logger.info(f"세션 설정 후: {session}") logger.info("세션 설정 완료, 리디렉션 시도") redirect_to = next_url or url_for('index') logger.info(f"리디렉션 대상: {redirect_to}") response = redirect(redirect_to) return response else: logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치") if username != valid_username: logger.warning("사용자명 불일치") if password != valid_password: logger.warning("비밀번호 불일치") error = '아이디 또는 비밀번호가 올바르지 않습니다.' else: logger.info("로그인 페이지 GET 요청") if 'logged_in' in session: logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션") return redirect(url_for('index')) logger.info("---------- 로그인 페이지 렌더링 ----------") return render_template('login.html', error=error, next=next_url) @app.route('/logout') def logout(): logger.info("-------------- 로그아웃 요청 --------------") logger.info(f"로그아웃 전 세션 상태: {session}") if 'logged_in' in session: username = session.get('username', 'unknown') logger.info(f"사용자 {username} 로그아웃 처리 시작") session.pop('logged_in', None) session.pop('username', None) session.modified = True logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}") else: logger.warning("로그인되지 않은 상태에서 로그아웃 시도") logger.info("로그인 페이지로 리디렉션") response = redirect(url_for('login')) return response @app.route('/') @login_required def index(): """메인 페이지""" global app_ready current_time = datetime.datetime.now() try: start_time = datetime.datetime.fromtimestamp(os.path.getmtime(__file__)) time_diff = (current_time - start_time).total_seconds() if not app_ready and time_diff > 30: logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.") app_ready = True except FileNotFoundError: logger.warning("__file__ 경로를 찾을 수 없어 시간 비교 로직을 건너<0xEB>뜁니다.") if not app_ready: # 기본 타임아웃 대신 간단한 로직 추가 가능 logger.warning("앱 준비 상태 확인 (시간 비교 불가)") # 필요시 다른 준비 상태 확인 로직 추가 pass # 임시로 통과 if not app_ready: logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시") return render_template('loading.html'), 503 logger.info("메인 페이지 요청") return render_template('index.html') @app.route('/api/status') @login_required def app_status(): """앱 초기화 상태 확인 API""" logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}") return jsonify({"ready": app_ready}) @app.route('/api/llm', methods=['GET', 'POST']) @login_required def llm_api(): """사용 가능한 LLM 목록 및 선택 API""" global llm_interface if not app_ready: return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503 if request.method == 'GET': logger.info("LLM 목록 요청") try: current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"} supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {} supported_list = [{ "name": name, "id": id, "current": id == current_details.get("id") } for name, id in supported_llms_dict.items()] return jsonify({ "supported_llms": supported_list, "current_llm": current_details }) except Exception as e: logger.error(f"LLM 정보 조회 오류: {e}") return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500 elif request.method == 'POST': data = request.get_json() if not data or 'llm_id' not in data: return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400 llm_id = data['llm_id'] logger.info(f"LLM 변경 요청: {llm_id}") try: if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'): raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음") if llm_id not in llm_interface.llm_clients: return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400 success = llm_interface.set_llm(llm_id) if success: new_details = llm_interface.get_current_llm_details() logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.") return jsonify({ "success": True, "message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.", "current_llm": new_details }) else: logger.error(f"LLM 변경 실패 (ID: {llm_id})") return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500 except Exception as e: logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True) return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500 @app.route('/api/chat', methods=['POST']) @login_required def chat(): """텍스트 기반 챗봇 API""" global retriever if not app_ready or retriever is None: return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503 try: data = request.get_json() if not data or 'query' not in data: return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400 query = data['query'] logger.info(f"텍스트 쿼리 수신: {query[:100]}...") if not hasattr(retriever, 'search'): raise NotImplementedError("Retriever에 search 메소드가 없습니다.") search_results = retriever.search(query, top_k=5, first_stage_k=6) if not hasattr(DocumentProcessor, 'prepare_rag_context'): raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.") context = DocumentProcessor.prepare_rag_context(search_results, field="text") if not context: logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.") pass llm_id = data.get('llm_id', None) if not hasattr(llm_interface, 'rag_generate'): raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.") if not context: answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." logger.info("컨텍스트 없이 기본 응답 생성") else: answer = llm_interface.rag_generate(query, context, llm_id=llm_id) logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})") sources = [] if search_results: for result in search_results: if not isinstance(result, dict): logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}") continue if "source" in result: source_info = { "source": result.get("source", "Unknown"), "score": result.get("rerank_score", result.get("score", 0)) } if "text" in result and result.get("filetype") == "csv": try: text_lines = result["text"].strip().split('\n') if text_lines: first_line = text_lines[0].strip() if ',' in first_line: first_column = first_line.split(',')[0].strip() source_info["id"] = first_column logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}") except Exception as e: logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}") sources.append(source_info) response_data = { "answer": answer, "sources": sources, "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {} } return jsonify(response_data) except Exception as e: logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True) return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500 @app.route('/api/voice', methods=['POST']) @login_required def voice_chat(): """음성 챗 API 엔드포인트""" global retriever, stt_client if not app_ready: logger.warning("앱 초기화가 완료되지 않았지만 음성 API 요청 처리 시도") if retriever is None: logger.error("retriever가 아직 초기화되지 않았습니다") return jsonify({ "transcription": "(음성을 텍스트로 변환했지만 검색 엔진이 아직 준비되지 않았습니다)", "answer": "죄송합니다. 검색 엔진이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요.", "sources": [] }) if stt_client is None: return jsonify({ "transcription": "(음성 인식 기능이 준비 중입니다)", "answer": "죄송합니다. 현재 음성 인식 서비스가 초기화 중입니다. 잠시 후 다시 시도해주세요.", "sources": [] }) logger.info("음성 챗 요청 수신") if 'audio' not in request.files: logger.error("오디오 파일이 제공되지 않음") return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400 audio_file = request.files['audio'] logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})") try: with tempfile.NamedTemporaryFile(delete=True) as temp_audio: audio_file.save(temp_audio.name) logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}") if not hasattr(stt_client, 'transcribe_audio'): raise NotImplementedError("STT 클라이언트에 transcribe_audio 메소드가 없습니다.") with open(temp_audio.name, 'rb') as f_bytes: audio_bytes = f_bytes.read() stt_result = stt_client.transcribe_audio(audio_bytes, language="ko") if not isinstance(stt_result, dict) or not stt_result.get("success"): error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류" logger.error(f"음성인식 실패: {error_msg}") return jsonify({ "error": "음성인식 실패", "details": error_msg }), 500 transcription = stt_result.get("text", "") if not transcription: logger.warning("음성인식 결과가 비어있습니다.") return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다.", "transcription": ""}), 400 logger.info(f"음성인식 성공: {transcription[:50]}...") if retriever is None: logger.error("STT 성공 후 검색 시도 중 retriever가 None임") return jsonify({ "transcription": transcription, "answer": "음성을 인식했지만, 현재 검색 시스템이 준비되지 않았습니다. 잠시 후 다시 시도해주세요.", "sources": [] }) search_results = retriever.search(transcription, top_k=5, first_stage_k=6) context = DocumentProcessor.prepare_rag_context(search_results, field="text") if not context: logger.warning("음성 쿼리에 대한 검색 결과 없음.") pass llm_id = request.form.get('llm_id', None) if not context: answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." logger.info("컨텍스트 없이 기본 응답 생성") else: answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id) logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})") enhanced_sources = [] if search_results: for doc in search_results: if not isinstance(doc, dict): continue if "source" in doc: source_info = { "source": doc.get("source", "Unknown"), "score": doc.get("rerank_score", doc.get("score", 0)) } if "text" in doc and doc.get("filetype") == "csv": try: text_lines = doc["text"].strip().split('\n') if text_lines: first_line = text_lines[0].strip() if ',' in first_line: first_column = first_line.split(',')[0].strip() source_info["id"] = first_column except Exception as e: logger.warning(f"[음성챗] CSV 소스 ID 추출 실패 ({doc.get('source')}): {e}") enhanced_sources.append(source_info) response_data = { "transcription": transcription, "answer": answer, "sources": enhanced_sources, "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {} } return jsonify(response_data) except Exception as e: logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True) return jsonify({ "error": "음성 처리 중 내부 오류 발생", "details": str(e) }), 500 @app.route('/api/upload', methods=['POST']) @login_required def upload_document(): """지식베이스 문서 업로드 API""" global base_retriever, retriever if not app_ready or base_retriever is None: return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503 if 'document' not in request.files: return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400 doc_file = request.files['document'] if doc_file.filename == '': return jsonify({"error": "선택된 파일이 없습니다."}), 400 if not allowed_doc_file(doc_file.filename): logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}") return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400 try: filename = secure_filename(doc_file.filename) filepath = os.path.join(app.config['DATA_FOLDER'], filename) doc_file.save(filepath) logger.info(f"문서 저장 완료: {filepath}") try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read() except UnicodeDecodeError: logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}") try: with open(filepath, 'r', encoding='cp949') as f: content = f.read() except Exception as e_cp949: logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}") return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400 except Exception as e_read: logger.error(f"파일 읽기 오류 ({filename}): {e_read}") return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500 metadata = { "source": filename, "filename": filename, "filetype": filename.rsplit('.', 1)[1].lower(), "filepath": filepath } file_ext = metadata["filetype"] docs = [] if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'): raise NotImplementedError("DocumentProcessor에 필요한 메소드 없음") if file_ext == 'csv': logger.info(f"CSV 파일 처리 시작: {filename}") docs = DocumentProcessor.csv_to_documents(content, metadata) else: logger.info(f"일반 텍스트 문서 처리 시작: {filename}") if file_ext in ['pdf', 'docx']: logger.warning(f".{file_ext} 파일 처리는 현재 구현되지 않았습니다. 텍스트 추출 로직 추가 필요.") content = "" if content: docs = DocumentProcessor.text_to_documents( content, metadata=metadata, chunk_size=512, chunk_overlap=50 ) if docs: if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'): raise NotImplementedError("기본 검색기에 add_documents 또는 save 메소드 없음") logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...") base_retriever.add_documents(docs) logger.info(f"검색기 상태를 저장합니다...") index_path = app.config['INDEX_PATH'] try: base_retriever.save(index_path) logger.info("인덱스 저장 완료") # 재순위화 검색기 업데이트 로직 필요 시 추가 # 예: if retriever != base_retriever and hasattr(retriever, 'update_base_retriever'): retriever.update_base_retriever(base_retriever) return jsonify({ "success": True, "message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)." }) except Exception as e_save: logger.error(f"인덱스 저장 중 오류 발생: {e_save}") return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500 else: logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.") return jsonify({ "warning": True, "message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없습니다." }) except Exception as e: logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True) return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500 @app.route('/api/documents', methods=['GET']) @login_required def list_documents(): """지식베이스 문서 목록 API""" global base_retriever if not app_ready or base_retriever is None: return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503 try: sources = {} total_chunks = 0 if hasattr(base_retriever, 'documents') and base_retriever.documents: logger.info(f"총 {len(base_retriever.documents)}개 문서 청크에서 소스 목록 생성 중...") for doc in base_retriever.documents: if not isinstance(doc, dict): continue source = doc.get("source", "unknown") if source == "unknown" and "metadata" in doc and isinstance(doc["metadata"], dict): source = doc["metadata"].get("source", "unknown") if source != "unknown": if source in sources: sources[source]["chunks"] += 1 else: filename = doc.get("filename", source) filetype = doc.get("filetype", "unknown") if "metadata" in doc and isinstance(doc["metadata"], dict): filename = doc["metadata"].get("filename", filename) filetype = doc["metadata"].get("filetype", filetype) sources[source] = { "filename": filename, "chunks": 1, "filetype": filetype } total_chunks += 1 else: logger.info("검색기에 문서가 없거나 documents 속성을 찾을 수 없습니다.") documents = [{"source": src, **info} for src, info in sources.items()] documents.sort(key=lambda x: x["chunks"], reverse=True) logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크") return jsonify({ "documents": documents, "total_documents": len(documents), "total_chunks": total_chunks }) except Exception as e: logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True) return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500 # 정적 파일 서빙 @app.route('/static/') def send_static(path): return send_from_directory('static', path) # --- 요청 처리 훅 --- @app.after_request def after_request_func(response): """모든 응답에 대해 후처리 수행""" # logger.debug(f"[After Request] 응답 헤더: {response.headers}") # 디버깅 시 Set-Cookie 확인 return response # 앱 실행 (로컬 테스트용) if __name__ == '__main__': logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).") # 디버그 모드는 개발 중에만 True로 설정하고, 실제 배포 시에는 False로 설정해야 합니다. # host='0.0.0.0' 은 모든 네트워크 인터페이스에서 접속 가능하게 합니다. port = int(os.environ.get("PORT", 7860)) logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.") # debug=True 사용 시 werkzeug reloader가 활성화되어 코드가 변경될 때 서버가 재시작될 수 있으며, # 이 과정에서 전역 초기화 코드가 다시 실행될 수 있습니다. # DEVICE_ROUTES_REGISTERED 플래그가 이를 방지합니다. app.run(debug=True, host='0.0.0.0', port=port)