""" RAG 검색 챗봇 웹 애플리케이션 - API 라우트 정의 (TypeError 재수정) """ import os import json import logging import tempfile import requests import time # 앱 시작 시간 기록 위해 추가 import threading # threading.Event 사용 위해 추가 from flask import request, jsonify, render_template, send_from_directory, session, redirect, url_for from datetime import datetime from werkzeug.utils import secure_filename # 로거 가져오기 logger = logging.getLogger(__name__) # 앱 시작 시간 기록 (모듈 로드 시점) APP_START_TIME = time.time() # !! 중요: 함수 정의에서 app_ready_flag 대신 app_ready_event를 받도록 수정 !! def register_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor, base_retriever, app_ready_event, ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL): """Flask 애플리케이션에 기본 라우트 등록""" # 헬퍼 함수 (변경 없음) def allowed_audio_file(filename): """파일이 허용된 오디오 확장자를 가지는지 확인""" ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'} return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS def allowed_doc_file(filename): """파일이 허용된 문서 확장자를 가지는지 확인""" ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'} return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS # --- 로그인/로그아웃 라우트 --- @app.route('/login', methods=['GET', 'POST']) def login(): error = None next_url = request.args.get('next') 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}") valid_username = ADMIN_USERNAME valid_password = ADMIN_PASSWORD logger.info(f"검증용 사용자명: {valid_username}") if username == valid_username and password == valid_password: logger.info(f"로그인 성공: {username}") session.permanent = True session['logged_in'] = True session['username'] = username logger.info(f"세션 설정 완료: {session}") redirect_to = next_url or url_for('index') logger.info(f"리디렉션 대상: {redirect_to}") response = redirect(redirect_to) logger.debug(f"로그인 응답 헤더 (Set-Cookie 확인): {response.headers.getlist('Set-Cookie')}") return response else: logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치") error = '아이디 또는 비밀번호가 올바르지 않습니다.' else: # GET 요청 logger.info("로그인 페이지 GET 요청") if session.get('logged_in'): logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션") return redirect(url_for('index')) logger.info("---------- 로그인 페이지 렌더링 ----------") return render_template('login.html', error=error, next=next_url) @app.route('/logout') def logout(): """로그아웃 처리""" username = session.get('username', 'unknown') if session.pop('logged_in', None): session.pop('username', None) logger.info(f"사용자 {username} 로그아웃 처리 완료. 현재 세션: {session}") else: logger.warning("로그인되지 않은 상태에서 로그아웃 시도") logger.info("로그인 페이지로 리디렉션") response = redirect(url_for('login')) logger.debug(f"로그아웃 응답 헤더 (Set-Cookie 확인): {response.headers.getlist('Set-Cookie')}") return response # --- 메인 페이지 및 상태 확인 (app_ready_event 사용) --- @app.route('/') @login_required def index(): """메인 페이지""" # app_ready_event가 Event 객체인지 확인하고 상태 가져오기 is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False # 기본값 False time_elapsed = time.time() - APP_START_TIME if not is_ready: logger.info(f"앱이 아직 준비되지 않아 로딩 페이지 표시 (경과 시간: {time_elapsed:.1f}초)") # loading.html 템플릿이 있다고 가정 return render_template('loading.html') # 200 OK와 로딩 페이지 logger.info("메인 페이지 요청") # index.html 템플릿이 있다고 가정 return render_template('index.html') @app.route('/api/status') @login_required def app_status(): """앱 초기화 상태 확인 API""" is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False logger.info(f"앱 상태 확인 요청: {'Ready' if is_ready else 'Not Ready'}") return jsonify({"ready": is_ready}) # --- LLM API --- @app.route('/api/llm', methods=['GET', 'POST']) @login_required def llm_api(): """사용 가능한 LLM 목록 및 선택 API""" # is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False # LLM 목록 조회는 초기화 중에도 가능하도록 허용 if request.method == 'GET': logger.info("LLM 목록 요청") try: # 객체 및 속성 확인 강화 if llm_interface is None or not hasattr(llm_interface, 'get_current_llm_details') or not hasattr(llm_interface, 'SUPPORTED_LLMS'): logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 속성이 없습니다.") return jsonify({"error": "LLM 인터페이스 오류"}), 500 current_details = llm_interface.get_current_llm_details() supported_llms_dict = llm_interface.SUPPORTED_LLMS 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}", exc_info=True) return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500 elif request.method == 'POST': is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False if not is_ready: # LLM 변경은 앱 준비 완료 후 가능 return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503 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 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'): logger.error("LLM 인터페이스가 준비되지 않았거나 필요한 속성/메소드가 없습니다.") return jsonify({"error": "LLM 인터페이스 오류"}), 500 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 # --- Chat API --- @app.route('/api/chat', methods=['POST']) @login_required def chat(): """텍스트 기반 채봇 API""" try: # 앱이 준비되었는지 확인 is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False if not is_ready: logger.warning("앱이 아직 초기화 중입니다.") return jsonify({ "error": "앱 초기화 중...", "answer": "죄송합니다. 시스템이 아직 준비 중입니다.", "sources": [] }), 503 data = request.get_json() if not data or 'query' not in data: return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400 query = data['query'] logger.info(f"텍스트 쿼리 수신: {query[:100]}...") # 검색 엔진 처리 부분 수정 search_results = [] search_warning = None try: # retriever 상태 검증 if retriever is None: logger.warning("Retriever가 초기화되지 않았습니다.") search_warning = "검색 기능이 아직 준비되지 않았습니다." elif hasattr(retriever, 'is_mock') and retriever.is_mock: logger.info("Mock Retriever 사용 중 - 검색 결과 없음.") search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다." elif not hasattr(retriever, 'search'): logger.warning("Retriever에 search 메소드가 없습니다.") search_warning = "검색 기능이 현재 제한되어 있습니다." else: logger.info(f"검색 수행: {query[:50]}...") search_results = retriever.search(query, top_k=5, first_stage_k=6) if not search_results: logger.info("검색 결과가 없습니다.") else: logger.info(f"검색 결과: {len(search_results)}개 항목") except Exception as e: logger.error(f"검색 중 오류 발생: {str(e)}", exc_info=True) search_results = [] search_warning = f"검색 중 오류 발생: {str(e)}" # LLM 응답 생성 try: # DocumentProcessor 객체 및 메소드 확인 context = "" if search_results: if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'): logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.") else: context = DocumentProcessor.prepare_rag_context(search_results, field="text") logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)") # LLM 인터페이스 객체 및 메소드 확인 llm_id = data.get('llm_id', None) if not context: if search_warning: logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}") answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})" else: logger.info("컨텍스트 없이 기본 응답 생성") answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." else: if llm_interface is None or not hasattr(llm_interface, 'rag_generate'): logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.") answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다." else: # LLM 호출 전에 경고 메시지 추가 if search_warning: modified_query = f"{query}\n\n참고: {search_warning}" logger.info(f"경고 메시지와 함께 쿼리 생성: {modified_query[:100]}...") else: modified_query = query answer = llm_interface.rag_generate(modified_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 source_info = {} source_key = result.get("source") if not source_key and "metadata" in result and isinstance(result["metadata"], dict): source_key = result["metadata"].get("source") if source_key: source_info["name"] = os.path.basename(source_key) source_info["path"] = source_key else: source_info["name"] = "알 수 없는 소스" if "score" in result: source_info["score"] = result["score"] if "rerank_score" in result: source_info["rerank_score"] = result["rerank_score"] sources.append(source_info) return jsonify({ "answer": answer, "sources": sources, "search_warning": search_warning }) except Exception as e: logger.error(f"LLM 응답 생성 중 오류 발생: {str(e)}", exc_info=True) return jsonify({ "answer": f"죄송합니다. 응답 생성 중 오류가 발생했습니다: {str(e)}", "sources": [], "error": str(e) }) except Exception as e: logger.error(f"채팅 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True) return jsonify({ "error": f"예상치 못한 오류 발생: {str(e)}", "answer": "죄송합니다. 서버에서 오류가 발생했습니다.", "sources": [] }), 500 # --- Voice Chat API --- @app.route('/api/voice', methods=['POST']) @login_required def voice_chat(): """음성 챗 API 엔드포인트""" try: # 앱이 준비되었는지 확인 is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False if not is_ready: logger.warning("앱이 아직 초기화 중입니다.") return jsonify({"error": "앱 초기화 중...", "answer": "죄송합니다. 시스템이 아직 준비 중입니다."}), 503 # STT 클라이언트 확인 if stt_client is None or not hasattr(stt_client, 'transcribe_audio'): logger.error("음성 API 요청 시 STT 클라이언트가 준비되지 않음") return jsonify({"error": "음성 인식 서비스 준비 안됨"}), 503 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, suffix=os.path.splitext(audio_file.filename)[1]) as temp_audio: audio_file.save(temp_audio.name) logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}") # STT 수행 (바이트 전달 가정) with open(temp_audio.name, 'rb') as f_bytes: audio_bytes = f_bytes.read() stt_result = stt_client.transcribe_audio(audio_bytes, language="ko") # STT 결과 처리 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({ "transcription": "", "answer": "음성에서 텍스트를 인식하지 못했습니다.", "sources": [] }), 200 # 200 OK와 메시지 logger.info(f"음성인식 성공: {transcription[:50]}...") # --- RAG 및 LLM 호출 (Chat API와 동일 로직) --- # 검색 엔진 처리 부분 search_results = [] search_warning = None try: # retriever 상태 검증 if retriever is None: logger.warning("Retriever가 초기화되지 않았습니다.") search_warning = "검색 기능이 아직 준비되지 않았습니다." elif hasattr(retriever, 'is_mock') and retriever.is_mock: logger.info("Mock Retriever 사용 중 - 검색 결과 없음.") search_warning = "검색 인덱스가 아직 구축 중입니다. 기본 응답만 제공됩니다." elif not hasattr(retriever, 'search'): logger.warning("Retriever에 search 메소드가 없습니다.") search_warning = "검색 기능이 현재 제한되어 있습니다." else: logger.info(f"검색 수행: {transcription[:50]}...") search_results = retriever.search(transcription, top_k=5, first_stage_k=6) if not search_results: logger.info("검색 결과가 없습니다.") else: logger.info(f"검색 결과: {len(search_results)}개 항목") except Exception as e: logger.error(f"검색 중 오류 발생: {str(e)}", exc_info=True) search_results = [] search_warning = f"검색 중 오류 발생: {str(e)}" # LLM 응답 생성 context = "" if search_results: if DocumentProcessor is None or not hasattr(DocumentProcessor, 'prepare_rag_context'): logger.warning("DocumentProcessor가 준비되지 않았거나 prepare_rag_context 메소드가 없습니다.") else: context = DocumentProcessor.prepare_rag_context(search_results, field="text") logger.info(f"컨텍스트 준비 완료 (길이: {len(context) if context else 0}자)") # LLM 인터페이스 호출 llm_id = request.form.get('llm_id', None) # form 데이터에서 llm_id 가져오기 if not context: if search_warning: logger.info(f"컨텍스트 없음, 검색 경고: {search_warning}") answer = f"죄송합니다. 질문에 대한 답변을 찾을 수 없습니다. ({search_warning})" else: logger.info("컨텍스트 없이 기본 응답 생성") answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." else: if llm_interface is None or not hasattr(llm_interface, 'rag_generate'): logger.error("LLM 인터페이스가 준비되지 않았거나 rag_generate 메소드가 없습니다.") answer = "죄송합니다. 현재 LLM 서비스를 사용할 수 없습니다." else: # LLM 호출 전에 경고 메시지 추가 if search_warning: modified_query = f"{transcription}\n\n참고: {search_warning}" logger.info(f"경고 메시지와 함께 쿼리 생성: {modified_query[:100]}...") else: modified_query = transcription answer = llm_interface.rag_generate(modified_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 source_info = {} source_key = result.get("source") if not source_key and "metadata" in result and isinstance(result["metadata"], dict): source_key = result["metadata"].get("source") if source_key: source_info["name"] = os.path.basename(source_key) source_info["path"] = source_key else: source_info["name"] = "알 수 없는 소스" if "score" in result: source_info["score"] = result["score"] if "rerank_score" in result: source_info["rerank_score"] = result["rerank_score"] sources.append(source_info) # 최종 응답 response_data = { "transcription": transcription, "answer": answer, "sources": sources, "search_warning": search_warning } # LLM 정보 추가 (옵션) if hasattr(llm_interface, 'get_current_llm_details'): response_data["llm"] = llm_interface.get_current_llm_details() return jsonify(response_data) except Exception as e: logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True) return jsonify({ "error": "음성 처리 중 내부 오류 발생", "details": str(e), "answer": "죄송합니다. 오디오 처리 중 오류가 발생했습니다." }), 500 except Exception as e: logger.error(f"음성 API에서 예상치 못한 오류 발생: {str(e)}", exc_info=True) return jsonify({ "error": f"예상치 못한 오류 발생: {str(e)}", "answer": "죄송합니다. 서버에서 오류가 발생했습니다." }), 500 # --- Document Upload API --- @app.route('/api/upload', methods=['POST']) @login_required def upload_document(): """지식베이스 문서 업로드 API""" is_ready = app_ready_event.is_set() if isinstance(app_ready_event, threading.Event) else False if not is_ready: return jsonify({"error": "앱 초기화 중..."}), 503 # base_retriever 객체 및 필수 메소드 확인 if base_retriever is None or not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'): logger.error("문서 업로드 API 요청 시 base_retriever가 준비되지 않았거나 필수 메소드가 없습니다.") return jsonify({"error": "기본 검색기가 준비되지 않았습니다."}), 503 if 'document' not in request.files: return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400 doc_file = request.files['document'] if not doc_file or not doc_file.filename: return jsonify({"error": "선택된 파일이 없습니다."}), 400 # ALLOWED_DOC_EXTENSIONS를 함수 내에서 다시 정의하거나 전역 상수로 사용 ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'} if not allowed_doc_file(doc_file.filename): logger.warning(f"허용되지 않는 파일 형식: {doc_file.filename}") return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400 try: filename = secure_filename(doc_file.filename) # app.config 사용 확인 if 'DATA_FOLDER' not in app.config: logger.error("Flask app.config에 DATA_FOLDER가 설정되지 않았습니다.") return jsonify({"error": "서버 설정 오류 (DATA_FOLDER)"}), 500 data_folder = app.config['DATA_FOLDER'] os.makedirs(data_folder, exist_ok=True) filepath = os.path.join(data_folder, filename) doc_file.save(filepath) logger.info(f"문서 저장 완료: {filepath}") # DocumentProcessor 객체 및 메소드 확인 if DocumentProcessor is None or not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'): logger.error("DocumentProcessor가 준비되지 않았거나 필요한 메소드가 없습니다.") try: os.remove(filepath) # 저장된 파일 삭제 except OSError: pass return jsonify({"error": "문서 처리기 오류"}), 500 content = None file_ext = filename.rsplit('.', 1)[1].lower() metadata = {"source": filename, "filename": filename, "filetype": file_ext, "filepath": filepath} docs = [] # 파일 읽기 및 내용 추출 if file_ext in ['txt', 'md', 'csv']: 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 elif file_ext == 'pdf': logger.warning("PDF 처리는 구현되지 않았습니다.") # 여기에 PDF 텍스트 추출 로직 추가 (예: PyPDF2 사용) # content = extract_text_from_pdf(filepath) elif file_ext == 'docx': logger.warning("DOCX 처리는 구현되지 않았습니다.") # 여기에 DOCX 텍스트 추출 로직 추가 (예: python-docx 사용) # content = extract_text_from_docx(filepath) # 문서 분할/처리 if content is not None: # 내용이 성공적으로 읽혔거나 추출되었을 때만 if file_ext == 'csv': logger.info(f"CSV 파일 처리 시작: {filename}") docs = DocumentProcessor.csv_to_documents(content, metadata) elif file_ext in ['txt', 'md'] or (file_ext in ['pdf', 'docx'] and content): # 텍스트 기반 또는 추출된 내용 logger.info(f"텍스트 기반 문서 처리 시작: {filename}") # text_to_documents 함수가 청크 분할 등을 수행한다고 가정 docs = DocumentProcessor.text_to_documents( content, metadata=metadata, chunk_size=512, chunk_overlap=50 # 설정값 사용 ) # 검색기에 추가 및 저장 if docs: logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...") base_retriever.add_documents(docs) logger.info(f"검색기 상태를 저장합니다...") # app.config 사용 확인 if 'INDEX_PATH' not in app.config: logger.error("Flask app.config에 INDEX_PATH가 설정되지 않았습니다.") return jsonify({"error": "서버 설정 오류 (INDEX_PATH)"}), 500 index_path = app.config['INDEX_PATH'] # 인덱스 저장 경로가 폴더인지 파일인지 확인 필요 (VectorRetriever.save 구현에 따라 다름) # 여기서는 index_path가 디렉토리라고 가정하고 부모 디렉토리 생성 os.makedirs(os.path.dirname(index_path), exist_ok=True) try: base_retriever.save(index_path) logger.info("인덱스 저장 완료") # TODO: 재순위화 검색기(retriever) 업데이트 로직 필요 시 추가 # 예: if 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}", exc_info=True) # 저장 실패 시 추가된 문서 롤백 고려? return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500 else: logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.") # 파일은 저장되었으므로 warning 반환 return jsonify({ "warning": True, # 'success' 대신 'warning' 사용 "message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없거나 지원되지 않는 형식입니다." }) except Exception as e: logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True) # 오류 발생 시 저장된 파일 삭제 if 'filepath' in locals() and os.path.exists(filepath): try: os.remove(filepath) except OSError as e_del: logger.error(f"업로드 실패 후 파일 삭제 오류: {e_del}") return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500 # --- Document List API --- @app.route('/api/documents', methods=['GET']) @login_required def list_documents(): """지식베이스 문서 목록 API""" logger.info("문서 목록 API 요청 시작") # base_retriever 상태 확인 if base_retriever is None: logger.warning("문서 API 요청 시 base_retriever가 None입니다.") return jsonify({"documents": [], "total_documents": 0, "total_chunks": 0}) elif not hasattr(base_retriever, 'documents'): logger.warning("문서 API 요청 시 base_retriever에 'documents' 속성이 없습니다.") return jsonify({"documents": [], "total_documents": 0, "total_chunks": 0}) # 로깅 추가 logger.info(f"base_retriever 객체 타입: {type(base_retriever)}") logger.info(f"base_retriever.documents 존재 여부: {hasattr(base_retriever, 'documents')}") doc_list_attr = getattr(base_retriever, 'documents', None) # 안전하게 속성 가져오기 logger.info(f"base_retriever.documents 타입: {type(doc_list_attr)}") logger.info(f"base_retriever.documents 길이: {len(doc_list_attr) if isinstance(doc_list_attr, list) else 'N/A'}") try: sources = {} total_chunks = 0 doc_list = doc_list_attr # 위에서 가져온 속성 사용 # doc_list가 리스트인지 확인 if not isinstance(doc_list, list): logger.error(f"base_retriever.documents가 리스트가 아님: {type(doc_list)}") return jsonify({"error": "내부 데이터 구조 오류"}), 500 logger.info(f"총 {len(doc_list)}개 문서 청크에서 소스 목록 생성 중...") for i, doc in enumerate(doc_list): # 각 청크가 딕셔너리 형태인지 확인 (Langchain Document 객체도 딕셔너리처럼 동작 가능) if not hasattr(doc, 'get'): # 딕셔너리 또는 유사 객체인지 확인 logger.warning(f"청크 {i}가 딕셔너리 타입이 아님: {type(doc)}") continue # 소스 정보 추출 (metadata 우선) source = "unknown" metadata = doc.get("metadata") if isinstance(metadata, dict): source = metadata.get("source", "unknown") # metadata에 없으면 doc 자체에서 찾기 (하위 호환성) if source == "unknown": source = doc.get("source", "unknown") if source != "unknown": if source in sources: sources[source]["chunks"] += 1 else: # filename, filetype 추출 (metadata 우선) filename = metadata.get("filename", source) if isinstance(metadata, dict) else source filetype = metadata.get("filetype", "unknown") if isinstance(metadata, dict) else "unknown" # metadata에 없으면 doc 자체에서 찾기 if filename == source and doc.get("filename"): filename = doc["filename"] if filetype == "unknown" and doc.get("filetype"): filetype = doc["filetype"] sources[source] = { "filename": filename, "chunks": 1, "filetype": filetype } total_chunks += 1 else: # 소스 정보가 없는 청크 로깅 (너무 많으면 주석 처리) logger.warning(f"청크 {i}에서 소스 정보를 찾을 수 없음: {str(doc)[:200]}...") # 내용 일부 로깅 # 최종 목록 생성 및 정렬 documents = [{"source": src, **info} for src, info in sources.items()] documents.sort(key=lambda x: x.get("filename", ""), reverse=False) # 파일명 기준 오름차순 정렬 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) # 503 대신 500 반환 return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500