jeongsoo commited on
Commit
baa6a3e
·
1 Parent(s): 9acece8

Add application file

Browse files
Files changed (1) hide show
  1. 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
- from utils.vito_stt import VitoSTT
45
- from utils.llm_interface import LLMInterface
46
- from utils.document_processor import DocumentProcessor
47
- from retrieval.vector_retriever import VectorRetriever
48
- from retrieval.reranker import ReRanker
 
 
 
 
 
 
 
 
 
49
 
50
  # Flask 앱 초기화
51
  app = Flask(__name__)
52
 
53
- # 세션 설정 - 고정된 시크릿 키 사용
54
- app.secret_key = 'rag_chatbot_fixed_secret_key_12345' # 고정된 시크릿
55
-
56
- # 세션 설정 추가 - 허깅페이스 환경에 맞게 조정
57
- app.config['SESSION_COOKIE_SECURE'] = False # HTTP 에서도 쿠키 전송 가능
58
- app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript에서 쿠키 접근 방지
59
- app.config['SESSION_COOKIE_SAMESITE'] = None # 허깅페이스 프로시 관련 이슈 수정
60
- app.config['SESSION_COOKIE_DOMAIN'] = None # 모든 도메인에 쿠키 적용
61
- app.config['SESSION_COOKIE_PATH'] = '/' # 모든 경로에 쿠키 적용
 
 
 
 
 
62
  app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가
 
63
 
64
  # 최대 파일 크기 설정 (10MB)
65
  app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
66
- app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
67
- app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data')
68
- app.config['INDEX_PATH'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data', 'index')
 
 
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
- # LLM 클라이언트 초기화 - OpenAI와 DeepSeek 모두 지원
82
- llm_interface = LLMInterface(default_llm="openai")
 
 
 
 
 
 
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
- logger.info(f"현재 세션 케이: {request.cookies.get('session', 'None')}")
102
-
103
- # 수동 쿠키 확인
104
- has_manual_cookie = False
105
- if 'session_data' in request.cookies:
106
- try:
107
- cookie_data = json.loads(request.cookies.get('session_data'))
108
- logger.info(f"수동 쿠키 데이터: {cookie_data}")
109
- has_manual_cookie = cookie_data.get('logged_in', False)
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
- if not base_retriever.documents and os.path.exists(data_path):
 
153
  logger.info(f"{data_path}에서 문서를 로드합니다...")
154
- docs = DocumentProcessor.load_documents_from_directory(
155
- data_path,
156
- extensions=[".txt", ".md", ".csv"],
157
- recursive=True
158
- )
159
-
160
- if docs:
161
- logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
162
- base_retriever.add_documents(docs)
163
-
164
- # 인덱스 저장
165
- logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
166
- try:
167
- base_retriever.save(index_path)
168
- logger.info("인덱스 저장 완료")
169
- except Exception as e:
170
- logger.error(f"인덱스 저장 중 오류 발생: {e}")
171
-
 
 
172
  # 재순위화 검색기 초기화
173
  logger.info("재순위화 검색기를 초기화합니다...")
174
-
175
- # 자체 구현된 재순위화 함수 - 간단한 TF-IDF 기반 점수 재계산
176
- def custom_rerank_fn(query, results):
177
- """간단한 자체 구현 재순위화 함수"""
178
- # 쿼리 단어 분석
179
- query_terms = set(query.lower().split())
180
-
181
- # 결과 재점수화
182
- for result in results:
183
- if "text" in result:
184
- text = result["text"].lower()
185
-
186
- # 간단한 TF 기반 점수 계산
187
- term_freq = sum(1 for term in query_terms if term in text)
188
-
189
- # 길이 정규화
190
- normalized_score = term_freq / (len(text.split()) + 1) * 10
191
-
192
- # 기존 임베딩 점수와 새 점수 결합
193
- result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
194
- else:
195
- # 텍스트가 없는 경우 원래 점수 유지
196
- result["rerank_score"] = result.get("score", 0)
197
-
198
- # 재점수화된 결과 정렬
199
- results.sort(key=lambda x: x.get("rerank_score", 0), reverse=True)
200
- return results
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
- retriever = init_retriever()
 
 
 
 
 
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
- logger.info("-------------- 로그인 페이지 접속 --------------")
 
236
  logger.info(f"Method: {request.method}")
237
-
238
- # 경로 로그 추가
239
- logger.info(f"Request Path: {request.path}")
240
- logger.info(f"Request Host: {request.host}")
241
- logger.info(f"Request URL: {request.url}")
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
- # .env에서 가져오기
260
- valid_username = os.environ.get('ADMIN_USERNAME') or os.getenv('ADMIN_USERNAME')
261
- valid_password = os.environ.get('ADMIN_PASSWORD') or os.getenv('ADMIN_PASSWORD')
262
-
263
- # 환경변수 디버깅
264
- logger.info("\n[Environment Variables]")
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.info(f"세션 설정 현재 세션 상태: {session}")
283
-
284
- # 영구 세션으로 설정
285
- session.permanent = True
286
  session['logged_in'] = True
287
  session['username'] = username
288
-
289
- # 세션 설정 후 로깅
290
- logger.info(f"세션 설정 세션 상태: {session}")
291
  logger.info("세션 설정 완료, 리디렉션 시도")
292
-
293
- # 세션 쿠키 설정 확인
294
- response = redirect(url_for('index'))
295
- logger.info(f"Response Headers: {response.headers}")
 
 
 
 
 
296
  return response
297
  else:
298
  logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
299
- # 어떤 부분이 일치하지 않는지 상세 로깅
300
- if username != valid_username:
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
- logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
321
-
322
  if 'logged_in' in session:
323
- logger.info(f"사용자 {session.get('username', 'unknown')} 로그아웃 처리 시작")
324
- # 세션에서 정보 삭제
325
  session.pop('logged_in', None)
326
  session.pop('username', None)
327
- logger.info("세션 정보 삭제 완료")
 
328
  else:
329
  logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
330
-
331
  logger.info("로그인 페이지로 리디렉션")
332
- return redirect(url_for('login'))
 
 
 
333
 
334
  @app.route('/')
335
  @login_required
336
  def index():
337
  """메인 페이지"""
338
  if not app_ready:
339
- return render_template('loading.html')
 
 
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, app_ready
353
-
354
- # 앱 준비 상태 확인
355
  if not app_ready:
356
  return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
357
-
358
  if request.method == 'GET':
359
- # 지원되는 LLM 목록 및 현재 LLM 정보 반환
360
- return jsonify({
361
- "supported_llms": [{
362
- "name": name,
363
- "id": id,
364
- "current": id == llm_interface.current_llm
365
- } for name, id in llm_interface.SUPPORTED_LLMS.items()],
366
- "current_llm": llm_interface.get_current_llm_details()
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
- if llm_id not in llm_interface.llm_clients:
376
- return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
377
-
378
- # 현재 LLM 변경
379
- success = llm_interface.set_llm(llm_id)
380
- if success:
381
- logger.info(f"LLM이 '{llm_id}'로 변경되었습니다.")
382
- return jsonify({
383
- "success": True,
384
- "message": f"LLM이 '{llm_interface.get_current_llm_name()}'로 변경되었습니다.",
385
- "current_llm": llm_interface.get_current_llm_details()
386
- })
387
- else:
388
- return jsonify({"error": "LLM 변경 중 오류가 발생했습니다."}), 500
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  @app.route('/api/chat', methods=['POST'])
391
  @login_required
392
  def chat():
393
  """텍스트 기반 챗봇 API"""
394
- global retriever, app_ready
395
-
396
- # 준비 상태 확인
397
- if not app_ready:
398
- return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
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
- search_results = retriever.search(query, top_k=5, first_stage_k=6)
410
-
411
- # 검색 결과에서 컨텍스트 추출
 
 
 
 
412
  context = DocumentProcessor.prepare_rag_context(search_results, field="text")
413
-
414
  if not context:
415
- logger.warning("검색 결과가 없습니다.")
416
- return jsonify({
417
- "answer": "죄송합니다. 관련 정보를 찾을 수 없습니다.",
418
- "sources": []
419
- })
420
-
421
  # LLM에 질의
422
- llm_id = data.get('llm_id', None) # 클라이언트에서 LLM 선택이 제공되면 사용
423
- answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
424
-
425
- # 소스 정보 추출
 
 
 
 
 
 
 
 
 
426
  sources = []
427
- for result in search_results:
428
- if "source" in result:
429
- source_info = {
430
- "source": result.get("source", "Unknown"),
431
- "score": result.get("rerank_score", result.get("score", 0))
432
- }
433
-
434
- # CSV 파일인 경우 첫 번째 컨텐츠 데이터를 추출하여 표시
435
- if "text" in result and "filetype" in result and result["filetype"] == "csv":
436
- # 디버깅 로그 추가
437
- logger.info(f"CSV 파일 처리: {result['source']}")
438
- logger.info(f"CSV 내용 처음 부분: {result['text'][:100]}...")
439
-
440
- # 첫 번째 라인이나 내용에서 컬럼 값 추출 시도
441
- try:
442
- # 텍스트의 처음 부분을 추출
443
- text_lines = result["text"].strip().split('\n')
444
- logger.info(f"CSV 라인 개수: {len(text_lines)}")
445
-
446
- if len(text_lines) > 0:
447
- first_line = text_lines[0].strip()
448
- logger.info(f"CSV 줄: {first_line}")
449
-
450
- if ',' in first_line: # CSV 형식이면
451
- first_columns = first_line.split(',')
452
- logger.info(f"CSV 컬럼 개수: {len(first_columns)}")
453
-
454
- first_column = first_columns[0].strip()
455
- logger.info(f"CSV 첫 번째 컬럼 값: '{first_column}'")
456
- source_info["id"] = first_column
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 응답 구조: {json.dumps(response_data, ensure_ascii=False, indent=2)[:500]}...")
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
- 음성 API 엔드포인트: 오디오 파일을 받아 텍스트로 변환하고, 질문에 대한 응답과 소스를 반환
486
-
487
- Returns:
488
- JSON 응답:
489
- - transcription: 인식된 텍스트
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
- with audio_file.stream as f:
508
- audio_bytes = f.read()
509
-
510
- # 음성인식 (VitoSTT)
511
- stt = VitoSTT()
512
- stt_result = stt.transcribe_audio(audio_bytes, language="ko")
513
-
514
- if not stt_result["success"]:
515
- logger.error(f"음성인식 실패: {stt_result['error']}")
 
 
 
 
 
 
 
 
 
 
 
516
  return jsonify({
517
- "error": stt_result["error"],
518
- "details": stt_result.get("details", "")
519
  }), 500
520
-
521
- transcription = stt_result["text"]
522
  if not transcription:
523
  logger.warning("음성인식 결과가 비어있습니다.")
524
- return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다."}), 400
525
-
526
  logger.info(f"음성인식 성공: {transcription[:50]}...")
527
-
528
- # 검색기 호출: 인식된 텍스트를 쿼리로 사용
529
- sources = retriever.search(transcription, top_k=5, first_stage_k=6)
530
- if not sources:
531
- logger.warning("검색된 소스가 없습니다.")
532
- sources = []
533
-
534
- # 소스 문서 내용을 컨텍스트로 준비
535
- context = DocumentProcessor.prepare_rag_context(sources, field="text")
536
- logger.info(f"검색된 소스 수: {len(sources)}")
537
-
538
- # LLM 호출: 질문과 컨텍스트를 바탕으로 응답 생성
539
- llm_id = request.form.get('llm_id', None) # 클라이언트에서 LLM 선택이 제공되면 사용
540
- answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
541
-
 
 
 
 
 
 
542
  # 소스 정보 추출
543
  enhanced_sources = []
544
- for doc in sources:
545
- if "source" in doc:
546
- source_info = {
547
- "source": doc.get("source", "Unknown"),
548
- "score": doc.get("rerank_score", doc.get("score", 0))
549
- }
550
-
551
- # CSV 파일인 경우 첫 번째 컨텐츠 데이터를 추출하여 표시
552
- if "text" in doc and "filetype" in doc and doc["filetype"] == "csv":
553
- # 디버깅 로그 추가
554
- logger.info(f"[음성챗] CSV 파일 처리: {doc['source']}")
555
- logger.info(f"[음성챗] CSV 내용 처음 부분: {doc['text'][:100]}...")
556
-
557
- # 번째 라인이나 내용에서 컬럼 값 추출 시도
558
- try:
559
- # 텍스트의 처음 부분을 추출
560
- text_lines = doc["text"].strip().split('\n')
561
- logger.info(f"[음성챗] CSV 라인 개수: {len(text_lines)}")
562
-
563
- if len(text_lines) > 0:
564
- first_line = text_lines[0].strip()
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"음성 챗 처리 중 오류 발생: {str(e)}", exc_info=True)
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, app_ready
607
-
608
- # 준비 상태 확인
609
- if not app_ready:
610
- return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
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
- logger.info(f"문서가 저장되었습니다: {filepath}")
637
-
638
- # 문서 처리
639
  try:
640
- # 먼저 UTF-8 시도
 
 
 
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
- metadata = {
652
- "source": filename,
653
- "filename": filename,
654
- "filetype": filename.rsplit('.', 1)[1].lower(),
655
- "filepath": filepath
656
- }
657
-
658
- # 파일 형식에 따라 다른 처리 적용
659
- file_ext = filename.rsplit('.', 1)[1].lower()
660
-
661
- # CSV 파일은 행 단위로 처리
662
- if file_ext == 'csv':
663
- logger.info(f"CSV 파일 업로드 감지, 행 단위로 분할 처리: {filename}")
664
- docs = DocumentProcessor.csv_to_documents(content, metadata)
665
- else:
666
- # 일반 텍스트 문서 처리
667
- docs = DocumentProcessor.text_to_documents(
668
- content,
669
- metadata=metadata,
670
- chunk_size=512,
671
- chunk_overlap=50
672
- )
673
-
674
- if docs:
675
- logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
676
- base_retriever.add_documents(docs)
677
-
678
- # 인덱스 저장
679
- logger.info(f"검색기 상태를 저장합니다...")
680
- index_path = app.config['INDEX_PATH']
681
- try:
682
- base_retriever.save(index_path)
683
- logger.info("인덱스 저장 완료")
684
- except Exception as e:
685
- logger.error(f"인덱스 저장 중 오류 발생: {e}")
686
- return jsonify({"error": f"인덱스 저장 중 오류: {str(e)}"}), 500
687
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  return jsonify({
689
  "success": True,
690
- "message": f"파일 '{filename}' 성공적으로 업로드되고 {len(docs)}개 청크가 추가되었습니다."
691
  })
692
- else:
693
- logger.warning(f"파일 '{filename}'에서 처리할 문서가 없습니다.")
694
- return jsonify({
695
- "warning": True,
696
- "message": f"파일 '{filename}' 저장되었지만 처리할 내용이 없습니다."
697
- })
698
-
699
- except Exception as e:
700
- logger.error(f"문서 '{filename}' 처리 오류 발생: {e}", exc_info=True)
701
- return jsonify({"error": f"문서 처리 중 오류: {str(e)}"}), 500
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, retriever, app_ready
712
-
713
- # 준비 상태 확인
714
- if not app_ready:
715
- return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
716
-
717
  try:
718
- # 문서 소스 목록 생성
719
  sources = {}
720
-
721
- if base_retriever and base_retriever.documents:
722
- for doc in base_retriever.documents:
723
- source = doc.get("source", "unknown")
724
- if source in sources:
725
- sources[source]["chunks"] += 1
726
- else:
727
- sources[source] = {
728
- "filename": doc.get("filename", source),
729
- "chunks": 1,
730
- "filetype": doc.get("filetype", "unknown")
731
- }
732
-
733
- # 목록 형식으로 변환
734
- documents = []
735
- for source, info in sources.items():
736
- documents.append({
737
- "source": source,
738
- "filename": info["filename"],
739
- "chunks": info["chunks"],
740
- "filetype": info["filetype"]
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
- if session.modified:
782
- logger.info("\n[After Request] 세션이 수정되었습니다.")
783
- logger.info(f"현재 세션 내용: {session}")
784
-
785
- # 응답 헤더 로깅
786
- logger.info("\n[Response Headers]")
787
- for header, value in response.headers:
788
- logger.info(f" {header}: {value}")
789
-
790
- # 쿠키 설정
791
  if 'Set-Cookie' in response.headers:
792
- logger.info(f"Set-Cookie 헤더 있음: {response.headers['Set-Cookie']}")
793
- else:
794
- # 로그인 세션 쿠키가 없으면 세션 값을 확인
795
- if 'logged_in' in session and request.path != '/login':
796
- logger.info("세션에 logged_in이 있지만 쿠키가 설정되지 않았습니다.")
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 환경 변수를 사용