jeongsoo commited on
Commit
d93e680
·
1 Parent(s): b4e504a
.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ venv/
25
+ ENV/
26
+
27
+ # IDE
28
+ .idea/
29
+ .vscode/
30
+ *.swp
31
+ *.swo
32
+
33
+ # 프로젝트 특화
34
+ .env
35
+ *.log
36
+ data/index/
37
+ data/*.json
38
+ app/uploads/
Procfile ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <<<<<<< HEAD
2
+ web: gunicorn -b 0.0.0.0:$PORT app:app
3
+ =======
4
+ web: gunicorn app:app
5
+ >>>>>>> 342cb1bea06d143718684d40b8f294967c3bbcae
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # RAG 검색 챗봇 패키지
app.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 메인 실행 파일
3
+ """
4
+
5
+ # os 모듈 임포트
6
+ import os
7
+
8
+ # 앱 모듈에서 Flask 앱 가져오기
9
+ from app.app import app
10
+
11
+ if __name__ == '__main__':
12
+ port = int(os.environ.get("PORT", 7860))
13
+ app.run(debug=False, host='0.0.0.0', port=port)
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # 애플리케이션 패키지
app/app.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 웹 애플리케이션 (세션 설정 수정 적용)
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import tempfile
9
+ import threading
10
+ import datetime
11
+ from flask import Flask, request, jsonify, render_template, send_from_directory, session, redirect, url_for
12
+ from werkzeug.utils import secure_filename
13
+ from dotenv import load_dotenv
14
+ from functools import wraps
15
+
16
+ # 로거 설정
17
+ logging.basicConfig(
18
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
19
+ level=logging.DEBUG # INFO에서 DEBUG로 변경하여 더 상세한 로그 확인
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # 환경 변수 로드
24
+ load_dotenv()
25
+
26
+ # 환경 변수 로드 상태 확인 및 로깅
27
+ ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
28
+ ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
29
+ DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050')
30
+
31
+ logger.info(f"==== 환경 변수 로드 상태 ====")
32
+ logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
33
+ # 비밀번호는 로드 여부만 기록 (보안)
34
+ logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
35
+ logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL}")
36
+
37
+ # 환경 변수가 없으면 기본값 설정 (개발용, 배포 시 환경 변수 설정 권장)
38
+ if not ADMIN_USERNAME:
39
+ ADMIN_USERNAME = 'admin'
40
+ logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
41
+
42
+ if not ADMIN_PASSWORD:
43
+ ADMIN_PASSWORD = 'rag12345'
44
+ logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
45
+ class MockComponent: pass
46
+
47
+ # --- 로컬 모듈 임포트 ---
48
+ # 실제 경로에 맞게 utils, retrieval 폴더가 존재해야 합니다.
49
+ try:
50
+ from utils.vito_stt import VitoSTT
51
+ from utils.llm_interface import LLMInterface
52
+ from utils.document_processor import DocumentProcessor
53
+ from retrieval.vector_retriever import VectorRetriever
54
+ from retrieval.reranker import ReRanker
55
+ # 라우트 정의 파일 임포트
56
+ from app.app_routes import register_routes
57
+ from app.app_device_routes import register_device_routes
58
+ except ImportError as e:
59
+ logger.error(f"로컬 모듈 임포트 실패: {e}. utils 및 retrieval 패키지가 올바른 경로에 있는지 확인하세요.")
60
+ # 개발/테스트를 위해 임시 클래스 정의 (실제 사용 시 제거)
61
+
62
+ VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
63
+ # --- 로컬 모듈 임포트 끝 ---
64
+
65
+
66
+ # Flask 앱 초기화
67
+ app = Flask(__name__)
68
+
69
+ # 세션 설정 - 고정된 시크릿 키 사용 (실제 배포 시 환경 변수 등으로 관리 권장)
70
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345') # 환경 변수 우선 사용
71
+
72
+ # --- 세션 쿠키 설정 수정 (허깅페이스 환경 고려) ---
73
+ # 허깅페이스 스페이스는 일반적으로 HTTPS로 서비스되므로 Secure=True 설정
74
+ app.config['SESSION_COOKIE_SECURE'] = True
75
+ app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript에서 쿠키 접근 방지 (보안 강화)
76
+ # SameSite='Lax'가 대부분의 경우에 더 안전하고 호환성이 좋음.
77
+ # 만약 앱이 다른 도메인의 iframe 내에서 실행되어야 한다면 'None'으로 설정해야 함.
78
+ # (단, 'None'으로 설정 시 반드시 Secure=True여야 함)
79
+ # 로그 분석 결과 iframe 환경으로 확인되어 'None'으로 변경
80
+ app.config['SESSION_COOKIE_SAMESITE'] = 'None' # <--- 이렇게 변경합니다.
81
+ app.config['SESSION_COOKIE_DOMAIN'] = None # 특정 도메인 제한 없음
82
+ app.config['SESSION_COOKIE_PATH'] = '/' # 앱 전체 경로에 쿠키 적용
83
+ app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가
84
+ # --- 세션 쿠키 설정 끝 ---
85
+
86
+ # 최대 파일 크기 설정 (10MB)
87
+ app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
88
+ # 애플리케이션 파일 기준 상대 경로 설정
89
+ APP_ROOT = os.path.dirname(os.path.abspath(__file__))
90
+ app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
91
+ app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
92
+ app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
93
+
94
+ # 필요한 폴더 생성
95
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
96
+ os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
97
+ os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
98
+
99
+ # 허용되는 오디오/문서 파일 확장자
100
+ ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
101
+ ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
102
+
103
+ # --- 전역 객체 초기화 ---
104
+ try:
105
+ llm_interface = LLMInterface(default_llm="openai")
106
+ stt_client = VitoSTT()
107
+ except NameError:
108
+ logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
109
+ llm_interface = MockComponent()
110
+ stt_client = MockComponent()
111
+
112
+ base_retriever = None
113
+ retriever = None
114
+ app_ready = False # 앱 초기화 상태 플래그
115
+ # --- 전역 객체 초기화 끝 ---
116
+
117
+
118
+ # --- 인증 데코레이터 (수정됨) ---
119
+ def login_required(f):
120
+ @wraps(f)
121
+ def decorated_function(*args, **kwargs):
122
+ logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
123
+ logger.info(f"현재 플라스크 세션 객체: {session}")
124
+ logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
125
+ # 브라우저가 보낸 실제 쿠키 확인 (디버깅용)
126
+ logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
127
+
128
+ # Flask 세션에 'logged_in' 키가 있는지 직접 확인
129
+ if 'logged_in' not in session:
130
+ logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
131
+ # 수동 쿠키 확인 로직 제거됨
132
+ return redirect(url_for('login', next=request.url)) # 로그인 후 원래 페이지로 돌아가도록 next 파라미터 추가
133
+
134
+ logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
135
+ return f(*args, **kwargs)
136
+ return decorated_function
137
+ # --- 인증 데코레이터 끝 ---
138
+
139
+
140
+ # --- 헬퍼 함수 ---
141
+ def allowed_audio_file(filename):
142
+ """파일이 허용된 오디오 확장자를 가지는지 확인"""
143
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
144
+
145
+ def allowed_doc_file(filename):
146
+ """파일이 허용된 문서 확장자를 가지는지 확인"""
147
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
148
+ # --- 헬퍼 함수 끝 ---
149
+
150
+
151
+ # init_retriever 함수 내부에 로깅 추가 예시
152
+ # --- 검색기 초기화 관련 함수 ---
153
+ def init_retriever():
154
+ """검색기 객체 초기화 또는 로드"""
155
+ global base_retriever, retriever
156
+
157
+ index_path = app.config['INDEX_PATH']
158
+ data_path = app.config['DATA_FOLDER'] # data_path 정의 확인
159
+ logger.info("--- init_retriever 시작 ---")
160
+
161
+ # 1. 기본 검색기 로드 또는 초기화
162
+ # ... (VectorRetriever 로드 또는 초기화 로직은 이전과 동일하게 유지) ...
163
+ # VectorRetriever 초기화/로드 실패 시 base_retriever = None 및 return None 처리 포함
164
+ if os.path.exists(os.path.join(index_path, "documents.json")):
165
+ try:
166
+ logger.info(f"인덱스 로드 시도: {index_path}")
167
+ base_retriever = VectorRetriever.load(index_path)
168
+ logger.info(f"인덱스 로드 성공. 문서 {len(getattr(base_retriever, 'documents', []))}개")
169
+ except Exception as e:
170
+ logger.error(f"인덱스 로드 실패: {e}", exc_info=True)
171
+ logger.info("새 VectorRetriever 초기화 시도...")
172
+ try:
173
+ base_retriever = VectorRetriever()
174
+ logger.info("새 VectorRetriever 초기화 성공.")
175
+ except Exception as e_init:
176
+ logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True)
177
+ base_retriever = None
178
+ else:
179
+ logger.info("인덱스 파일 없음. 새 VectorRetriever 초기화 시도...")
180
+ try:
181
+ base_retriever = VectorRetriever()
182
+ logger.info("새 VectorRetriever 초기화 성공.")
183
+ except Exception as e_init:
184
+ logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True)
185
+ base_retriever = None
186
+
187
+ if base_retriever is None:
188
+ logger.error("base_retriever 초기화/로드에 실패하여 init_retriever 중단.")
189
+ return None
190
+
191
+ # 2. 데이터 폴더 문서 로드 (기본 검색기가 비어있을 때)
192
+ needs_loading = (not hasattr(base_retriever, 'documents') or not getattr(base_retriever, 'documents', None)) # None 체크 추가
193
+ if needs_loading and os.path.exists(data_path):
194
+ logger.info(f"기본 검색기가 비어있어 {data_path}에서 문서 로드 시도...")
195
+ try:
196
+ # ================== 수정된 부분 1 시작 ==================
197
+ # DocumentProcessor.load_documents_from_directory 호출 시 올바른 인자 전달
198
+ docs = DocumentProcessor.load_documents_from_directory(
199
+ directory=data_path, # <-- 경로 변수 사용
200
+ extensions=[".txt", ".md", ".csv"], # <-- 필요한 확장자 전달
201
+ recursive=True # <-- 재귀 탐색 여부 전달
202
+ )
203
+ # ================== 수정된 부분 1 끝 ====================
204
+ logger.info(f"{len(docs)}개 문서 로드 성공.")
205
+ if docs and hasattr(base_retriever, 'add_documents'):
206
+ logger.info("검색기에 문서 추가 시도...")
207
+ base_retriever.add_documents(docs)
208
+ logger.info("문서 추가 완료.")
209
+
210
+ if hasattr(base_retriever, 'save'):
211
+ logger.info(f"검색기 상태 저장 시도: {index_path}")
212
+ try:
213
+ base_retriever.save(index_path)
214
+ logger.info("인덱스 저장 완료.")
215
+ except Exception as e_save:
216
+ logger.error(f"인덱스 저장 실패: {e_save}", exc_info=True)
217
+ except Exception as e_load_add:
218
+ # load_documents_from_directory 자체에서 오류가 날 수도 있음 (권한 등)
219
+ logger.error(f"DATA_FOLDER 문서 로드/추가 중 오류: {e_load_add}", exc_info=True)
220
+
221
+ # 3. 재순위화 검색기 초기화
222
+ logger.info("재순위화 검색기 초기화 시도...")
223
+ try:
224
+ # ================== 수정된 부분 2 시작 ==================
225
+ # custom_rerank_fn 함수를 ReRanker 초기화 전에 정의
226
+ def custom_rerank_fn(query, results):
227
+ query_terms = set(query.lower().split())
228
+ for result in results:
229
+ if isinstance(result, dict) and "text" in result:
230
+ text = result["text"].lower()
231
+ term_freq = sum(1 for term in query_terms if term in text)
232
+ normalized_score = term_freq / (len(text.split()) + 1) * 10
233
+ result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
234
+ elif isinstance(result, dict):
235
+ result["rerank_score"] = result.get("score", 0)
236
+ results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
237
+ return results
238
+ # ================== 수정된 부분 2 끝 ====================
239
+
240
+ # ReRanker 클래스 사용
241
+ retriever = ReRanker(
242
+ base_retriever=base_retriever,
243
+ rerank_fn=custom_rerank_fn, # 이제 함수가 정의되었으므로 사용 가능
244
+ rerank_field="text"
245
+ )
246
+ logger.info("재순위화 검색기 초기화 완료.")
247
+ except Exception as e_rerank:
248
+ logger.error(f"재순위화 검색기 초기화 실패: {e_rerank}", exc_info=True)
249
+ logger.warning("재순위화 실패, 기본 검색기를 retriever로 사용합니다.")
250
+ retriever = base_retriever # fallback
251
+
252
+ logger.info("--- init_retriever 종료 ---")
253
+ return retriever
254
+
255
+ def background_init():
256
+ """백그라운드에서 검색기 초기화 수행"""
257
+ global app_ready, retriever, base_retriever, llm_interface, stt_client
258
+
259
+ temp_app_ready = False # 임시 상태 플래그
260
+ try:
261
+ logger.info("백그라운드 초기화 시작...")
262
+
263
+ # 1. LLM, STT 인터페이스 초기화 (필요 시)
264
+ if llm_interface is None or isinstance(llm_interface, MockComponent):
265
+ if 'LLMInterface' in globals() and LLMInterface != MockComponent:
266
+ llm_interface = LLMInterface(default_llm="openai")
267
+ logger.info("LLM 인터페이스 초기화 완료.")
268
+ else:
269
+ logger.warning("LLMInterface 클래스 없음. Mock 사용.")
270
+ llm_interface = MockComponent() # Mock 객체 보장
271
+ if stt_client is None or isinstance(stt_client, MockComponent):
272
+ if 'VitoSTT' in globals() and VitoSTT != MockComponent:
273
+ stt_client = VitoSTT()
274
+ logger.info("STT 클라이언트 초기화 완료.")
275
+ else:
276
+ logger.warning("VitoSTT 클래스 없음. Mock 사용.")
277
+ stt_client = MockComponent() # Mock 객체 보장
278
+
279
+
280
+ # 2. 검색기 초기화
281
+ if 'VectorRetriever' in globals() and VectorRetriever != MockComponent:
282
+ logger.info("실제 검색기 초기화 시도...")
283
+ # init_retriever가 base_retriever와 retriever를 모두 설정한다고 가정
284
+ retriever = init_retriever()
285
+ # init_retriever 내부에서 base_retriever가 설정되지 않았다면 여기서 설정
286
+ if hasattr(retriever, 'base_retriever') and base_retriever is None:
287
+ base_retriever = retriever.base_retriever
288
+ elif base_retriever is None:
289
+ # retriever가 base_retriever를 포함하지 않는 경우 또는 ReRanker가 아닌 경우
290
+ # init_retriever에서 base_retriever를 직접 설정하도록 하거나, 여기서 별도 로직 필요
291
+ # 예시: base_retriever = VectorRetriever.load(...) 또는 VectorRetriever()
292
+ logger.warning("init_retriever 후 base_retriever가 설정되지 않음. 확인 필요.")
293
+ # 임시로 retriever 자체를 base_retriever로 설정 (동일 객체일 경우)
294
+ if isinstance(retriever, VectorRetriever):
295
+ base_retriever = retriever
296
+
297
+ # 성공적으로 초기화 되었는지 확인 (None이 아닌지)
298
+ if retriever is not None and base_retriever is not None:
299
+ logger.info("검색기 (Retriever, Base Retriever) 초기화 성공")
300
+ temp_app_ready = True # 초기화 성공 시에만 True 설정
301
+ else:
302
+ logger.error("검색기 초기화 후에도 retriever 또는 base_retriever가 None입니다.")
303
+ # 실패 시 Mock 객체 할당 (최소한의 동작 보장)
304
+ if base_retriever is None: base_retriever = MockComponent()
305
+ if retriever is None: retriever = MockComponent()
306
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
307
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
308
+ # temp_app_ready = False 또는 True (정책에 따라 결정)
309
+ temp_app_ready = True # 일단 앱은 실행되도록 설정
310
+
311
+ else:
312
+ logger.warning("VectorRetriever 클래스 없음. Mock 검색기 사용.")
313
+ base_retriever = MockComponent()
314
+ retriever = MockComponent()
315
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
316
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
317
+ temp_app_ready = True # Mock이라도 준비는 된 것으로 간주
318
+
319
+ logger.info(f"백그라운드 초기화 완료. 최종 상태: {'Ready' if temp_app_ready else 'Not Ready (Error during init)'}")
320
+
321
+ except Exception as e:
322
+ logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
323
+ # 오류 발생 시에도 Mock 객체 할당 시도
324
+ if base_retriever is None: base_retriever = MockComponent()
325
+ if retriever is None: retriever = MockComponent()
326
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
327
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
328
+ temp_app_ready = True # 오류 발생해도 앱은 응답하도록 설정 (정책에 따라 False 가능)
329
+ logger.warning("초기화 중 오류가 발생했지만 Mock 객체로 대체 후 앱 사용 가능 상태로 설정.")
330
+
331
+ finally:
332
+ # 최종적으로 app_ready 상태 업데이트
333
+ app_ready = temp_app_ready
334
+
335
+ # 백그라운드 스레드 시작 부분은 그대로 유지
336
+ init_thread = threading.Thread(target=background_init)
337
+ init_thread.daemon = True
338
+ init_thread.start()
339
+
340
+ # 라우트 등록
341
+ try:
342
+ # 기본 RAG 챗봇 라우트 등록
343
+ register_routes(
344
+ app=app,
345
+ login_required=login_required,
346
+ llm_interface=llm_interface,
347
+ retriever=retriever,
348
+ stt_client=stt_client,
349
+ DocumentProcessor=DocumentProcessor,
350
+ base_retriever=base_retriever,
351
+ app_ready=app_ready,
352
+ ADMIN_USERNAME=ADMIN_USERNAME,
353
+ ADMIN_PASSWORD=ADMIN_PASSWORD,
354
+ DEVICE_SERVER_URL=DEVICE_SERVER_URL
355
+ )
356
+ logger.info("기본 챗봇 라우트 등록 완료")
357
+
358
+ # 장치 관리 라우트 등록
359
+ register_device_routes(
360
+ app=app,
361
+ login_required=login_required,
362
+ DEVICE_SERVER_URL=DEVICE_SERVER_URL
363
+ )
364
+ logger.info("장치 관리 라우트 등록 완료")
365
+ except Exception as e:
366
+ logger.error(f"라우트 등록 중 오류 발생: {e}", exc_info=True)
367
+
368
+
369
+ # --- 정적 파일 서빙 ---
370
+ @app.route('/static/<path:path>')
371
+ def send_static(path):
372
+ return send_from_directory('static', path)
373
+
374
+
375
+ # --- 요청 처리 훅 ---
376
+ @app.after_request
377
+ def after_request_func(response):
378
+ """모든 응답에 대해 후처리 수행"""
379
+ return response
380
+
381
+ # 앱 실행 (로컬 테스트용)
382
+ if __name__ == '__main__':
383
+ logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
384
+ # 디버그 모드는 실제 배포 시 False로 설정해야 합니다.
385
+ # port 번호는 환경 변수 또는 기본값을 사용합니다.
386
+ port = int(os.environ.get("PORT", 7860))
387
+ logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
388
+ app.run(debug=True, host='0.0.0.0', port=port)
app/app_device_routes.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 웹 애플리케이션 - 장치 관리 API 라우트 정의
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ import requests
8
+ import json
9
+ from flask import request, jsonify
10
+
11
+ # 로거 가져오기
12
+ logger = logging.getLogger(__name__)
13
+
14
+ def register_device_routes(app, login_required, DEVICE_SERVER_URL):
15
+ """Flask 애플리케이션에 장치 관리 관련 라우트 등록"""
16
+
17
+ @app.route('/api/device/status', methods=['GET'])
18
+ @login_required
19
+ def device_status():
20
+ """장치 관리 서버 상태 확인 API"""
21
+ logger.info("장치 관리 서버 상태 확인 요청")
22
+
23
+ try:
24
+ # 장치 관리 서버 상태 확인
25
+ response = requests.get(f"{DEVICE_SERVER_URL}/api/status", timeout=5)
26
+
27
+ if response.status_code == 200:
28
+ data = response.json()
29
+ logger.info(f"장치 관리 서버 상태: {data.get('status', 'unknown')}")
30
+ return jsonify({
31
+ "success": True,
32
+ "server_status": data.get("status", "unknown")
33
+ })
34
+ else:
35
+ logger.warning(f"장치 관리 서버 응답 코드: {response.status_code}")
36
+ return jsonify({
37
+ "success": False,
38
+ "error": f"장치 관리 서버가 비정상 응답 코드를 반환했습니다: {response.status_code}"
39
+ }), 502
40
+
41
+ except requests.exceptions.Timeout:
42
+ logger.error("장치 관리 서버 연결 시간 초과")
43
+ return jsonify({
44
+ "success": False,
45
+ "error": "장치 관리 서버 연결 시간이 초과되었습니다."
46
+ }), 504
47
+
48
+ except requests.exceptions.ConnectionError:
49
+ logger.error("장치 관리 서버 연결 실패")
50
+ return jsonify({
51
+ "success": False,
52
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
53
+ }), 503
54
+
55
+ except Exception as e:
56
+ logger.error(f"장치 관리 서버 상태 확인 중 오류 발생: {e}")
57
+ return jsonify({
58
+ "success": False,
59
+ "error": f"장치 관리 서버 상태 확인 중 오류 발생: {str(e)}"
60
+ }), 500
61
+
62
+
63
+ @app.route('/api/device/list', methods=['GET'])
64
+ @login_required
65
+ def device_list():
66
+ """장치 목록 조회 API"""
67
+ logger.info("장치 목록 조회 요청")
68
+
69
+ try:
70
+ # 장치 목록 조회
71
+ response = requests.get(f"{DEVICE_SERVER_URL}/api/devices", timeout=5)
72
+
73
+ if response.status_code == 200:
74
+ data = response.json()
75
+ devices = data.get("devices", [])
76
+ logger.info(f"장치 목록 조회 성공: {len(devices)}개 장치")
77
+ return jsonify({
78
+ "success": True,
79
+ "devices": devices
80
+ })
81
+ else:
82
+ logger.warning(f"장치 목록 조회 실패: {response.status_code}")
83
+ return jsonify({
84
+ "success": False,
85
+ "error": f"장치 목록 조회 실패: {response.status_code}"
86
+ }), 502
87
+
88
+ except requests.exceptions.Timeout:
89
+ logger.error("장치 목록 조회 시간 초과")
90
+ return jsonify({
91
+ "success": False,
92
+ "error": "장치 목록 조회 시간이 초과되었습니다."
93
+ }), 504
94
+
95
+ except requests.exceptions.ConnectionError:
96
+ logger.error("장치 관리 서버 연결 실패")
97
+ return jsonify({
98
+ "success": False,
99
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
100
+ }), 503
101
+
102
+ except Exception as e:
103
+ logger.error(f"장치 목록 조회 중 오류 발생: {e}")
104
+ return jsonify({
105
+ "success": False,
106
+ "error": f"장치 목록 조회 중 오류 발생: {str(e)}"
107
+ }), 500
108
+
109
+
110
+ @app.route('/api/device/programs', methods=['GET'])
111
+ @login_required
112
+ def device_programs():
113
+ """실행 가능한 프로그램 목록 조회 API"""
114
+ logger.info("프로그램 목록 조회 요청")
115
+
116
+ try:
117
+ # 프로그램 목록 조회
118
+ response = requests.get(f"{DEVICE_SERVER_URL}/api/programs", timeout=5)
119
+
120
+ if response.status_code == 200:
121
+ data = response.json()
122
+ programs = data.get("programs", [])
123
+ logger.info(f"프로그램 목록 조회 성공: {len(programs)}개 프로그램")
124
+ return jsonify({
125
+ "success": True,
126
+ "programs": programs
127
+ })
128
+ else:
129
+ logger.warning(f"프로그램 목록 조회 실패: {response.status_code}")
130
+ return jsonify({
131
+ "success": False,
132
+ "error": f"프로그램 목록 조회 실패: {response.status_code}"
133
+ }), 502
134
+
135
+ except requests.exceptions.Timeout:
136
+ logger.error("프로그램 목록 조회 시간 초과")
137
+ return jsonify({
138
+ "success": False,
139
+ "error": "프로그램 목록 조회 시간이 초과되었습니다."
140
+ }), 504
141
+
142
+ except requests.exceptions.ConnectionError:
143
+ logger.error("장치 관리 서버 연결 실패")
144
+ return jsonify({
145
+ "success": False,
146
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
147
+ }), 503
148
+
149
+ except Exception as e:
150
+ logger.error(f"프로그램 목록 조회 중 오류 발생: {e}")
151
+ return jsonify({
152
+ "success": False,
153
+ "error": f"프로그램 목록 조회 중 오류 발생: {str(e)}"
154
+ }), 500
155
+
156
+
157
+ @app.route('/api/device/programs/<program_id>/execute', methods=['POST'])
158
+ @login_required
159
+ def execute_program(program_id):
160
+ """프로그램 실행 API"""
161
+ logger.info(f"프로그램 실행 요청: {program_id}")
162
+
163
+ try:
164
+ # 프로그램 실행
165
+ response = requests.post(
166
+ f"{DEVICE_SERVER_URL}/api/programs/{program_id}/execute",
167
+ json={},
168
+ timeout=10 # 프로그램 실행에는 더 긴 시간 부여
169
+ )
170
+
171
+ if response.status_code == 200:
172
+ data = response.json()
173
+ success = data.get("success", False)
174
+ message = data.get("message", "")
175
+ logger.info(f"프로그램 실행 응답: {success}, {message}")
176
+ return jsonify(data)
177
+ else:
178
+ logger.warning(f"프로그램 실행 실패: {response.status_code}")
179
+ return jsonify({
180
+ "success": False,
181
+ "error": f"프로그램 실행 요청 실패: {response.status_code}"
182
+ }), 502
183
+
184
+ except requests.exceptions.Timeout:
185
+ logger.error("프로그램 실행 요청 시간 초과")
186
+ return jsonify({
187
+ "success": False,
188
+ "error": "프로그램 실행 요청 시간이 초과되었습니다."
189
+ }), 504
190
+
191
+ except requests.exceptions.ConnectionError:
192
+ logger.error("장치 관리 서버 연결 실패")
193
+ return jsonify({
194
+ "success": False,
195
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
196
+ }), 503
197
+
198
+ except Exception as e:
199
+ logger.error(f"프로그램 실행 중 오류 발생: {e}")
200
+ return jsonify({
201
+ "success": False,
202
+ "error": f"프로그램 실행 중 오류 발생: {str(e)}"
203
+ }), 500
app/app_part2.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- 임베딩 관련 헬퍼 함수 ---
2
+ def save_embeddings(base_retriever, file_path):
3
+ """임베딩 데이터를 압축하여 파일에 저장"""
4
+ try:
5
+ # 저장 디렉토리가 없으면 생성
6
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
7
+
8
+ # 타임스탬프 추가
9
+ save_data = {
10
+ 'timestamp': datetime.now().isoformat(),
11
+ 'retriever': base_retriever
12
+ }
13
+
14
+ # 압축하여 저장 (용량 줄이기)
15
+ with gzip.open(file_path, 'wb') as f:
16
+ pickle.dump(save_data, f)
17
+
18
+ logger.info(f"임베딩 데이터를 {file_path}에 압축하여 저장했습니다.")
19
+ return True
20
+ except Exception as e:
21
+ logger.error(f"임베딩 저장 중 오류 발생: {e}")
22
+ return False
23
+
24
+ def load_embeddings(file_path, max_age_days=30):
25
+ """저장된 임베딩 데이터를 파일에서 로드"""
26
+ try:
27
+ if not os.path.exists(file_path):
28
+ logger.info(f"저장된 임베딩 파일({file_path})이 없습니다.")
29
+ return None
30
+
31
+ # 압축 파일 로드
32
+ with gzip.open(file_path, 'rb') as f:
33
+ data = pickle.load(f)
34
+
35
+ # 타임스탬프 확인 (너무 오래된 데이터는 사용하지 않음)
36
+ saved_time = datetime.fromisoformat(data['timestamp'])
37
+ age = (datetime.now() - saved_time).days
38
+
39
+ if age > max_age_days:
40
+ logger.info(f"저장된 임베딩이 {age}일로 너무 오래되었습니다. 새로 생성합니다.")
41
+ return None
42
+
43
+ logger.info(f"{file_path}에서 임베딩 데이터를 로드했습니다. (생성일: {saved_time})")
44
+ return data['retriever']
45
+ except Exception as e:
46
+ logger.error(f"임베딩 로드 중 오류 발생: {e}")
47
+ return None
48
+
49
+ def init_retriever():
50
+ """검색기 객체 초기화 또는 로드"""
51
+ global base_retriever, retriever
52
+
53
+ # 임베딩 캐시 파일 경로
54
+ cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
55
+
56
+ # 먼저 저장된 임베딩 데이터 로드 시도
57
+ cached_retriever = load_embeddings(cache_path)
58
+
59
+ if cached_retriever:
60
+ logger.info("캐시된 임베딩 데이터를 성공적으로 로드했습니다.")
61
+ base_retriever = cached_retriever
62
+ else:
63
+ # 캐시된 데이터가 없으면 기존 방식으로 초기화
64
+ index_path = app.config['INDEX_PATH']
65
+
66
+ # VectorRetriever 로드 또는 초기화
67
+ if os.path.exists(os.path.join(index_path, "documents.json")):
68
+ try:
69
+ logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
70
+ base_retriever = VectorRetriever.load(index_path)
71
+ logger.info(f"{len(base_retriever.documents) if hasattr(base_retriever, 'documents') else 0}개 문서가 로드되었습니다.")
72
+ except Exception as e:
73
+ logger.error(f"인덱스 로드 중 오류 발생: {e}. 새 검색기를 초기화합니다.")
74
+ base_retriever = VectorRetriever()
75
+ else:
76
+ logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
77
+ base_retriever = VectorRetriever()
78
+
79
+ # 데이터 폴더의 문서 로드
80
+ data_path = app.config['DATA_FOLDER']
81
+ if (not hasattr(base_retriever, 'documents') or not base_retriever.documents) and os.path.exists(data_path):
82
+ logger.info(f"{data_path}에서 문서를 로드합니다...")
83
+ try:
84
+ docs = DocumentProcessor.load_documents_from_directory(
85
+ data_path,
86
+ extensions=[".txt", ".md", ".csv"],
87
+ recursive=True
88
+ )
89
+ if docs and hasattr(base_retriever, 'add_documents'):
90
+ logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
91
+ base_retriever.add_documents(docs)
92
+
93
+ if hasattr(base_retriever, 'save'):
94
+ logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
95
+ try:
96
+ base_retriever.save(index_path)
97
+ logger.info("인덱스 저장 완료")
98
+
99
+ # 새로 생성된 검색기 캐싱
100
+ if hasattr(base_retriever, 'documents') and base_retriever.documents:
101
+ save_embeddings(base_retriever, cache_path)
102
+ logger.info(f"검색기를 캐시 파일 {cache_path}에 저장 완료")
103
+ except Exception as e:
104
+ logger.error(f"인덱스 저장 중 오류 발생: {e}")
105
+ except Exception as e:
106
+ logger.error(f"DATA_FOLDER에서 문서 로드 중 오류: {e}")
107
+
108
+ # 재순위화 검색기 초기화
109
+ logger.info("재순위화 검색기를 초기화합니다...")
110
+ try:
111
+ # 자체 구현된 재순위화 함수
112
+ def custom_rerank_fn(query, results):
113
+ query_terms = set(query.lower().split())
114
+ for result in results:
115
+ if isinstance(result, dict) and "text" in result:
116
+ text = result["text"].lower()
117
+ term_freq = sum(1 for term in query_terms if term in text)
118
+ normalized_score = term_freq / (len(text.split()) + 1) * 10
119
+ result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
120
+ elif isinstance(result, dict):
121
+ result["rerank_score"] = result.get("score", 0)
122
+ results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
123
+ return results
124
+
125
+ # ReRanker 클래스 사용
126
+ retriever = ReRanker(
127
+ base_retriever=base_retriever,
128
+ rerank_fn=custom_rerank_fn,
129
+ rerank_field="text"
130
+ )
131
+ logger.info("재순위화 검색기 초기화 완료")
132
+ except Exception as e:
133
+ logger.error(f"재순위화 검색기 초기화 실패: {e}")
134
+ retriever = base_retriever # 실패 시 기본 검색기 사용
135
+
136
+ return retriever
137
+
138
+ def background_init():
139
+ """백그라운드에서 검색기 초기화 수행"""
140
+ global app_ready, retriever, base_retriever
141
+
142
+ # 즉시 앱 사용 가능 상태로 설정
143
+ app_ready = True
144
+ logger.info("앱을 즉시 사용 가능 상태로 설정 (app_ready=True)")
145
+
146
+ try:
147
+ # 기본 검색기 초기화 (보험)
148
+ if base_retriever is None:
149
+ base_retriever = MockComponent()
150
+ if hasattr(base_retriever, 'documents'):
151
+ base_retriever.documents = []
152
+
153
+ # 임시 retriever 설정
154
+ if retriever is None:
155
+ retriever = MockComponent()
156
+ if not hasattr(retriever, 'search'):
157
+ retriever.search = lambda query, **kwargs: []
158
+
159
+ # 캐시된 임베딩 로드 시도
160
+ cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
161
+ cached_retriever = load_embeddings(cache_path)
162
+
163
+ if cached_retriever:
164
+ # 캐시된 데이터가 있으면 바로 사용
165
+ base_retriever = cached_retriever
166
+
167
+ # 간단한 재순위화 함수
168
+ def simple_rerank(query, results):
169
+ if results:
170
+ for result in results:
171
+ if isinstance(result, dict):
172
+ result["rerank_score"] = result.get("score", 0)
173
+ results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
174
+ return results
175
+
176
+ # 재순위화 검색기 초기화
177
+ retriever = ReRanker(
178
+ base_retriever=base_retriever,
179
+ rerank_fn=simple_rerank,
180
+ rerank_field="text"
181
+ )
182
+
183
+ logger.info("캐시된 임베딩으로 검색기 초기화 완료 (빠른 시작)")
184
+ else:
185
+ # 캐시된 데이터가 없으면 전체 초기화 진행
186
+ logger.info("캐시된 임베딩이 없어 전체 초기화 시작")
187
+ retriever = init_retriever()
188
+ logger.info("전체 초기화 완료")
189
+
190
+ logger.info("앱 초기화 완료 (모든 컴포넌트 준비됨)")
191
+ except Exception as e:
192
+ logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
193
+ # 초기화 실패 시 기본 객체 생성
194
+ if base_retriever is None:
195
+ base_retriever = MockComponent()
196
+ if hasattr(base_retriever, 'documents'):
197
+ base_retriever.documents = []
198
+ if retriever is None:
199
+ retriever = MockComponent()
200
+ if not hasattr(retriever, 'search'):
201
+ retriever.search = lambda query, **kwargs: []
202
+
203
+ logger.warning("초기화 중 오류가 있지만 앱은 계속 사용 가능합니다.")
204
+
205
+ # 백그라운드 스레드 시작
206
+ init_thread = threading.Thread(target=background_init)
207
+ init_thread.daemon = True
208
+ init_thread.start()
209
+
app/app_part3.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Flask 라우트 정의 ---
2
+
3
+ @app.route('/login', methods=['GET', 'POST'])
4
+ def login():
5
+ error = None
6
+ next_url = request.args.get('next')
7
+ logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
8
+ logger.info(f"Method: {request.method}")
9
+
10
+ if request.method == 'POST':
11
+ logger.info("로그인 시도 받음")
12
+ username = request.form.get('username', '')
13
+ password = request.form.get('password', '')
14
+ logger.info(f"입력된 사용자명: {username}")
15
+ logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
16
+
17
+ # 환경 변수 또는 기본값과 비교
18
+ valid_username = ADMIN_USERNAME
19
+ valid_password = ADMIN_PASSWORD
20
+ logger.info(f"검증용 사용자명: {valid_username}")
21
+ logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
22
+
23
+ if username == valid_username and password == valid_password:
24
+ logger.info(f"로그인 성공: {username}")
25
+ # 세션 설정 전 현재 세션 상태 로깅
26
+ logger.debug(f"세션 설정 전: {session}")
27
+
28
+ # 세션에 로그인 정보 저장
29
+ session.permanent = True
30
+ session['logged_in'] = True
31
+ session['username'] = username
32
+ session.modified = True
33
+
34
+ logger.info(f"세션 설정 후: {session}")
35
+ logger.info("세션 설정 완료, 리디렉션 시도")
36
+
37
+ # 로그인 성공 후 리디렉션
38
+ redirect_to = next_url or url_for('index')
39
+ logger.info(f"리디렉션 대상: {redirect_to}")
40
+ response = redirect(redirect_to)
41
+ return response
42
+ else:
43
+ logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
44
+ if username != valid_username: logger.warning("사용자명 불일치")
45
+ if password != valid_password: logger.warning("비밀번호 불일치")
46
+ error = '아이디 또는 비밀번호가 올바르지 않습니다.'
47
+ else:
48
+ logger.info("로그인 페이지 GET 요청")
49
+ if 'logged_in' in session:
50
+ logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
51
+ return redirect(url_for('index'))
52
+
53
+ logger.info("---------- 로그인 페이지 렌더링 ----------")
54
+ return render_template('login.html', error=error, next=next_url)
55
+
56
+
57
+ @app.route('/logout')
58
+ def logout():
59
+ logger.info("-------------- 로그아웃 요청 --------------")
60
+ logger.info(f"로그아웃 전 세션 상태: {session}")
61
+
62
+ if 'logged_in' in session:
63
+ username = session.get('username', 'unknown')
64
+ logger.info(f"사용자 {username} 로그아웃 처리 시작")
65
+ session.pop('logged_in', None)
66
+ session.pop('username', None)
67
+ session.modified = True
68
+ logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
69
+ else:
70
+ logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
71
+
72
+ logger.info("로그인 페이지로 리디렉션")
73
+ response = redirect(url_for('login'))
74
+ return response
75
+
76
+
77
+ @app.route('/')
78
+ @login_required
79
+ def index():
80
+ """메인 페이지"""
81
+ global app_ready
82
+
83
+ # 앱 준비 상태 확인 - 30초 이상 지났으면 강제로 ready 상태로 변경
84
+ current_time = datetime.now()
85
+ start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
86
+ time_diff = (current_time - start_time).total_seconds()
87
+
88
+ if not app_ready and time_diff > 30:
89
+ logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
90
+ app_ready = True
91
+
92
+ if not app_ready:
93
+ logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
94
+ return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
95
+
96
+ logger.info("메인 페이지 요청")
97
+ return render_template('index.html')
98
+
99
+
100
+ @app.route('/api/status')
101
+ @login_required
102
+ def app_status():
103
+ """앱 초기화 상태 확인 API"""
104
+ logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
105
+ return jsonify({"ready": app_ready})
106
+
107
+
108
+ @app.route('/api/llm', methods=['GET', 'POST'])
109
+ @login_required
110
+ def llm_api():
111
+ """사용 가능한 LLM 목록 및 선택 API"""
112
+ global llm_interface
113
+
114
+ if not app_ready:
115
+ return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
116
+
117
+ if request.method == 'GET':
118
+ logger.info("LLM 목록 요청")
119
+ try:
120
+ current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
121
+ supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
122
+ supported_list = [{
123
+ "name": name, "id": id, "current": id == current_details.get("id")
124
+ } for name, id in supported_llms_dict.items()]
125
+
126
+ return jsonify({
127
+ "supported_llms": supported_list,
128
+ "current_llm": current_details
129
+ })
130
+ except Exception as e:
131
+ logger.error(f"LLM 정보 조회 오류: {e}")
132
+ return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
133
+
134
+ elif request.method == 'POST':
135
+ data = request.get_json()
136
+ if not data or 'llm_id' not in data:
137
+ return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
138
+
139
+ llm_id = data['llm_id']
140
+ logger.info(f"LLM 변경 요청: {llm_id}")
141
+
142
+ try:
143
+ if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
144
+ raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
145
+
146
+ if llm_id not in llm_interface.llm_clients:
147
+ return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
148
+
149
+ success = llm_interface.set_llm(llm_id)
150
+ if success:
151
+ new_details = llm_interface.get_current_llm_details()
152
+ logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
153
+ return jsonify({
154
+ "success": True,
155
+ "message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
156
+ "current_llm": new_details
157
+ })
158
+ else:
159
+ logger.error(f"LLM 변경 실패 (ID: {llm_id})")
160
+ return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
161
+ except Exception as e:
162
+ logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
163
+ return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
app/app_revised.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 웹 애플리케이션 (장치 관리 기능 통합)
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ import threading
8
+ from datetime import datetime, timedelta
9
+ from flask import Flask, send_from_directory
10
+ from dotenv import load_dotenv
11
+ from functools import wraps
12
+
13
+ # 로거 설정
14
+ logging.basicConfig(
15
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
16
+ level=logging.DEBUG
17
+ )
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # 환경 변수 로드
21
+ load_dotenv()
22
+
23
+ # 환경 변수 로드 상태 확인 및 로깅
24
+ ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
25
+ ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
26
+ DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050')
27
+
28
+ logger.info(f"==== 환경 변수 로드 상태 ====")
29
+ logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
30
+ logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
31
+ logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL}")
32
+
33
+ # 환경 변수가 없으면 기본값 설정
34
+ if not ADMIN_USERNAME:
35
+ ADMIN_USERNAME = 'admin'
36
+ logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
37
+
38
+ if not ADMIN_PASSWORD:
39
+ ADMIN_PASSWORD = 'rag12345'
40
+ logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
41
+
42
+ class MockComponent: pass
43
+
44
+ # --- 로컬 모듈 임포트 ---
45
+ try:
46
+ from utils.vito_stt import VitoSTT
47
+ from utils.llm_interface import LLMInterface
48
+ from utils.document_processor import DocumentProcessor
49
+ from retrieval.vector_retriever import VectorRetriever
50
+ from retrieval.reranker import ReRanker
51
+ except ImportError as e:
52
+ logger.error(f"로컬 모듈 임포트 실패: {e}. utils 및 retrieval 패키지가 올바른 경로에 있는지 확인하세요.")
53
+ VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
54
+ # --- 로컬 모듈 임포트 끝 ---
55
+
56
+
57
+ # Flask 앱 초기화
58
+ app = Flask(__name__)
59
+
60
+ # 세션 설정
61
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345')
62
+
63
+ # --- 세션 쿠키 설정 ---
64
+ app.config['SESSION_COOKIE_SECURE'] = True
65
+ app.config['SESSION_COOKIE_HTTPONLY'] = True
66
+ app.config['SESSION_COOKIE_SAMESITE'] = 'None'
67
+ app.config['SESSION_COOKIE_DOMAIN'] = None
68
+ app.config['SESSION_COOKIE_PATH'] = '/'
69
+ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=1)
70
+ # --- 세션 쿠키 설정 끝 ---
71
+
72
+ # 최대 파일 크기 설정 (10MB)
73
+ app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
74
+ # 애플리케이션 파일 기준 상대 경로 설정
75
+ APP_ROOT = os.path.dirname(os.path.abspath(__file__))
76
+ app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
77
+ app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
78
+ app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
79
+
80
+ # 필요한 폴더 생성
81
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
82
+ os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
83
+ os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
84
+
85
+ # --- 전역 객체 초기화 ---
86
+ try:
87
+ llm_interface = LLMInterface(default_llm="openai")
88
+ stt_client = VitoSTT()
89
+ except NameError:
90
+ logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
91
+ llm_interface = MockComponent()
92
+ stt_client = MockComponent()
93
+
94
+ base_retriever = None
95
+ retriever = None
96
+ app_ready = False # 앱 초기화 상태 플래그
97
+ # --- 전역 객체 초기화 끝 ---
98
+
99
+
100
+ # --- 인증 데코레이터 ---
101
+ def login_required(f):
102
+ @wraps(f)
103
+ def decorated_function(*args, **kwargs):
104
+ from flask import request, session, redirect, url_for
105
+
106
+ logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
107
+ logger.info(f"현재 플라스크 세션 객체: {session}")
108
+ logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
109
+ logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
110
+
111
+ # Flask 세션에 'logged_in' 키가 있는지 직접 확인
112
+ if 'logged_in' not in session:
113
+ logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
114
+ return redirect(url_for('login', next=request.url))
115
+
116
+ logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
117
+ return f(*args, **kwargs)
118
+ return decorated_function
119
+ # --- 인증 데코레이터 끝 ---
120
+
121
+
122
+ # --- 정적 파일 서빙 ---
123
+ @app.route('/static/<path:path>')
124
+ def send_static(path):
125
+ return send_from_directory('static', path)
126
+
127
+
128
+ # --- 백그라운드 초기화 함수 ---
129
+ def background_init():
130
+ """백그라운드에서 검색기 초기화 수행"""
131
+ global app_ready, retriever, base_retriever
132
+
133
+ # 즉시 앱 사용 가능 상태로 설정
134
+ app_ready = True
135
+ logger.info("앱을 즉시 사용 가능 상태로 설정 (app_ready=True)")
136
+
137
+ try:
138
+ from app.init_retriever import init_retriever
139
+
140
+ # 기본 검색기 초기화 (보험)
141
+ if base_retriever is None:
142
+ base_retriever = MockComponent()
143
+ if hasattr(base_retriever, 'documents'):
144
+ base_retriever.documents = []
145
+
146
+ # 임시 retriever 설정
147
+ if retriever is None:
148
+ retriever = MockComponent()
149
+ if not hasattr(retriever, 'search'):
150
+ retriever.search = lambda query, **kwargs: []
151
+
152
+ # 임베딩 캐시 파일 경로
153
+ cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
154
+
155
+ # 캐시된 임베딩 로드 시도
156
+ try:
157
+ from app.init_retriever import load_embeddings
158
+ cached_retriever = load_embeddings(cache_path)
159
+
160
+ if cached_retriever:
161
+ # 캐시된 데이터가 있으면 바로 사용
162
+ base_retriever = cached_retriever
163
+
164
+ # 재순위화 검색기 초기화
165
+ retriever = ReRanker(
166
+ base_retriever=base_retriever,
167
+ rerank_fn=lambda query, results: results,
168
+ rerank_field="text"
169
+ )
170
+
171
+ logger.info("캐시된 임베딩으로 검색기 초기화 완료 (빠른 시작)")
172
+ else:
173
+ # 캐시된 데이터가 없으면 전체 초기화 진행
174
+ logger.info("캐시된 임베딩이 없어 전체 초기화 시작")
175
+ retriever = init_retriever(app, base_retriever, retriever, ReRanker)
176
+ logger.info("전체 초기화 완료")
177
+ except ImportError:
178
+ logger.warning("임베딩 캐시 모듈을 찾을 수 없습니다. 전체 초기화를 진행합니다.")
179
+ retriever = init_retriever(app, base_retriever, retriever, ReRanker)
180
+
181
+ logger.info("앱 초기화 완료 (모든 컴포넌트 준비됨)")
182
+ except Exception as e:
183
+ logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
184
+ # 초기화 실패 시 기본 객체 생성
185
+ if base_retriever is None:
186
+ base_retriever = MockComponent()
187
+ if hasattr(base_retriever, 'documents'):
188
+ base_retriever.documents = []
189
+ if retriever is None:
190
+ retriever = MockComponent()
191
+ if not hasattr(retriever, 'search'):
192
+ retriever.search = lambda query, **kwargs: []
193
+
194
+ logger.warning("초기화 중 오류가 있지만 앱은 계속 사용 가능합니다.")
195
+
196
+
197
+ # --- 라우트 등록 ---
198
+ def register_all_routes():
199
+ try:
200
+ # 기본 라우트 등록
201
+ from app.app_routes import register_routes
202
+ register_routes(
203
+ app, login_required, llm_interface, retriever, stt_client,
204
+ DocumentProcessor, base_retriever, app_ready,
205
+ ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL
206
+ )
207
+
208
+ # 장치 관리 라우트 등록
209
+ from app.app_device_routes import register_device_routes
210
+ register_device_routes(app, login_required, DEVICE_SERVER_URL)
211
+
212
+ logger.info("모든 라우트 등록 완료")
213
+ except ImportError as e:
214
+ logger.error(f"라우트 모듈 임포트 실패: {e}")
215
+ except Exception as e:
216
+ logger.error(f"라우트 등록 중 오류 발생: {e}", exc_info=True)
217
+
218
+
219
+ # --- 앱 초기화 및 실행 ---
220
+ def initialize_app():
221
+ # 백그라운드 초기화 스레드 시작
222
+ init_thread = threading.Thread(target=background_init)
223
+ init_thread.daemon = True
224
+ init_thread.start()
225
+
226
+ # 라우트 등록
227
+ register_all_routes()
228
+
229
+ logger.info("앱 초기화 완료")
230
+
231
+
232
+ # 앱 초기화 실행
233
+ initialize_app()
234
+
235
+
236
+ # --- 앱 실행 (직접 실행 시) ---
237
+ if __name__ == '__main__':
238
+ logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
239
+ port = int(os.environ.get("PORT", 7860))
240
+ logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
241
+ app.run(debug=True, host='0.0.0.0', port=port)
app/app_routes.py ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 웹 애플리케이션 - API 라우트 정의
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import tempfile
9
+ import requests
10
+ from flask import request, jsonify, render_template, send_from_directory, session, redirect, url_for
11
+ from datetime import datetime
12
+ from werkzeug.utils import secure_filename
13
+
14
+ # 로거 가져오기
15
+ logger = logging.getLogger(__name__)
16
+
17
+ def register_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor, base_retriever, app_ready, ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL):
18
+ """Flask 애플리케이션에 기본 라우트 등록"""
19
+
20
+ # 헬퍼 함수
21
+ def allowed_audio_file(filename):
22
+ """파일이 허용된 오디오 확장자를 가지는지 확인"""
23
+ ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
24
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
25
+
26
+ def allowed_doc_file(filename):
27
+ """파일이 허용된 문서 확장자를 가지는지 확인"""
28
+ ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
29
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
30
+
31
+ @app.route('/login', methods=['GET', 'POST'])
32
+ def login():
33
+ error = None
34
+ next_url = request.args.get('next')
35
+ logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
36
+ logger.info(f"Method: {request.method}")
37
+
38
+ if request.method == 'POST':
39
+ logger.info("로그인 시도 받음")
40
+ username = request.form.get('username', '')
41
+ password = request.form.get('password', '')
42
+ logger.info(f"입력된 사용자명: {username}")
43
+ logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
44
+
45
+ # 환경 변수 또는 기본값과 비교
46
+ valid_username = ADMIN_USERNAME
47
+ valid_password = ADMIN_PASSWORD
48
+ logger.info(f"검증용 사용자명: {valid_username}")
49
+ logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
50
+
51
+ if username == valid_username and password == valid_password:
52
+ logger.info(f"로그인 성공: {username}")
53
+ # 세션 설정 전 현재 세션 상태 로깅
54
+ logger.debug(f"세션 설정 전: {session}")
55
+
56
+ # 세션에 로그인 정보 저장
57
+ session.permanent = True
58
+ session['logged_in'] = True
59
+ session['username'] = username
60
+ session.modified = True
61
+
62
+ logger.info(f"세션 설정 후: {session}")
63
+ logger.info("세션 설정 완료, 리디렉션 시도")
64
+
65
+ # 로그인 성공 후 리디렉션
66
+ redirect_to = next_url or url_for('index')
67
+ logger.info(f"리디렉션 대상: {redirect_to}")
68
+ response = redirect(redirect_to)
69
+ return response
70
+ else:
71
+ logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
72
+ if username != valid_username: logger.warning("사용자명 불일치")
73
+ if password != valid_password: logger.warning("비밀번호 불일치")
74
+ error = '아이디 또는 비밀번호가 올바르지 않습니다.'
75
+ else:
76
+ logger.info("로그인 페이지 GET 요청")
77
+ if 'logged_in' in session:
78
+ logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
79
+ return redirect(url_for('index'))
80
+
81
+ logger.info("---------- 로그인 페이지 렌더링 ----------")
82
+ return render_template('login.html', error=error, next=next_url)
83
+
84
+
85
+ @app.route('/logout')
86
+ def logout():
87
+ logger.info("-------------- 로그아웃 요청 --------------")
88
+ logger.info(f"로그아웃 전 세션 상태: {session}")
89
+
90
+ if 'logged_in' in session:
91
+ username = session.get('username', 'unknown')
92
+ logger.info(f"사용자 {username} 로그아웃 처리 시작")
93
+ session.pop('logged_in', None)
94
+ session.pop('username', None)
95
+ session.modified = True
96
+ logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
97
+ else:
98
+ logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
99
+
100
+ logger.info("로그인 페이지로 리디렉션")
101
+ response = redirect(url_for('login'))
102
+ return response
103
+
104
+
105
+ @app.route('/')
106
+ @login_required
107
+ def index():
108
+ """메인 페이지"""
109
+ nonlocal app_ready
110
+
111
+ # 앱 준비 상태 확인 - 30초 이상 지났으면 강제로 ready 상태로 변경
112
+ current_time = datetime.now()
113
+ start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
114
+ time_diff = (current_time - start_time).total_seconds()
115
+
116
+ if not app_ready and time_diff > 30:
117
+ logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
118
+ app_ready = True
119
+
120
+ if not app_ready:
121
+ logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
122
+ return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
123
+
124
+ logger.info("메인 페이지 요청")
125
+ return render_template('index.html')
126
+
127
+
128
+ @app.route('/api/status')
129
+ @login_required
130
+ def app_status():
131
+ """앱 초기화 상태 확인 API"""
132
+ logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
133
+ return jsonify({"ready": app_ready})
134
+
135
+
136
+ @app.route('/api/llm', methods=['GET', 'POST'])
137
+ @login_required
138
+ def llm_api():
139
+ """사용 가능한 LLM 목록 및 선택 API"""
140
+ if not app_ready:
141
+ return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
142
+
143
+ if request.method == 'GET':
144
+ logger.info("LLM 목록 요청")
145
+ try:
146
+ current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
147
+ supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
148
+ supported_list = [{
149
+ "name": name, "id": id, "current": id == current_details.get("id")
150
+ } for name, id in supported_llms_dict.items()]
151
+
152
+ return jsonify({
153
+ "supported_llms": supported_list,
154
+ "current_llm": current_details
155
+ })
156
+ except Exception as e:
157
+ logger.error(f"LLM 정보 조회 오류: {e}")
158
+ return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
159
+
160
+ elif request.method == 'POST':
161
+ data = request.get_json()
162
+ if not data or 'llm_id' not in data:
163
+ return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
164
+
165
+ llm_id = data['llm_id']
166
+ logger.info(f"LLM 변경 요청: {llm_id}")
167
+
168
+ try:
169
+ if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
170
+ raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
171
+
172
+ if llm_id not in llm_interface.llm_clients:
173
+ return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
174
+
175
+ success = llm_interface.set_llm(llm_id)
176
+ if success:
177
+ new_details = llm_interface.get_current_llm_details()
178
+ logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
179
+ return jsonify({
180
+ "success": True,
181
+ "message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
182
+ "current_llm": new_details
183
+ })
184
+ else:
185
+ logger.error(f"LLM 변경 실패 (ID: {llm_id})")
186
+ return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
187
+ except Exception as e:
188
+ logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
189
+ return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
190
+
191
+
192
+ @app.route('/api/chat', methods=['POST'])
193
+ @login_required
194
+ def chat():
195
+ """텍스트 기반 챗봇 API"""
196
+ if not app_ready or retriever is None:
197
+ return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
198
+
199
+ try:
200
+ data = request.get_json()
201
+ if not data or 'query' not in data:
202
+ return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
203
+
204
+ query = data['query']
205
+ logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
206
+
207
+ # RAG 검색 수행
208
+ if not hasattr(retriever, 'search'):
209
+ raise NotImplementedError("Retriever에 search 메소드가 없습니다.")
210
+ search_results = retriever.search(query, top_k=5, first_stage_k=6)
211
+
212
+ # 컨텍스트 준비
213
+ if not hasattr(DocumentProcessor, 'prepare_rag_context'):
214
+ raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.")
215
+ context = DocumentProcessor.prepare_rag_context(search_results, field="text")
216
+
217
+ if not context:
218
+ logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.")
219
+
220
+ # LLM에 질의
221
+ llm_id = data.get('llm_id', None)
222
+ if not hasattr(llm_interface, 'rag_generate'):
223
+ raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.")
224
+
225
+ if not context:
226
+ answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
227
+ logger.info("컨텍스트 없이 기본 응답 생성")
228
+ else:
229
+ answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
230
+ logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
231
+
232
+ # 소스 정보 추출 (CSV ID 추출 로직 포함)
233
+ sources = []
234
+ if search_results:
235
+ for result in search_results:
236
+ if not isinstance(result, dict):
237
+ logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
238
+ continue
239
+
240
+ if "source" in result:
241
+ source_info = {
242
+ "source": result.get("source", "Unknown"),
243
+ "score": result.get("rerank_score", result.get("score", 0))
244
+ }
245
+
246
+ # CSV 파일 특정 처리
247
+ if "text" in result and result.get("filetype") == "csv":
248
+ try:
249
+ text_lines = result["text"].strip().split('\n')
250
+ if text_lines:
251
+ first_line = text_lines[0].strip()
252
+ if ',' in first_line:
253
+ first_column = first_line.split(',')[0].strip()
254
+ source_info["id"] = first_column
255
+ logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}")
256
+ except Exception as e:
257
+ logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}")
258
+
259
+ sources.append(source_info)
260
+
261
+ # 최종 응답
262
+ response_data = {
263
+ "answer": answer,
264
+ "sources": sources,
265
+ "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
266
+ }
267
+ return jsonify(response_data)
268
+
269
+ except Exception as e:
270
+ logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
271
+ return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
272
+
273
+
274
+ @app.route('/api/voice', methods=['POST'])
275
+ @login_required
276
+ def voice_chat():
277
+ """음성 챗 API 엔드포인트"""
278
+ if not app_ready:
279
+ logger.warning("앱 초기화가 완료되지 않았지만 음성 API 요청 처리 시도")
280
+ # 여기서 바로 리턴하지 않고 계속 진행
281
+ # 사전 검사: retriever와 stt_client가 제대로 초기화되었는지 확인
282
+
283
+ if retriever is None:
284
+ logger.error("retriever가 아직 초기화되지 않았습니다")
285
+ return jsonify({
286
+ "transcription": "(음성을 텍스트로 변환했지만 검색 엔진이 아직 준비되지 않았습니다)",
287
+ "answer": "죄송합니다. 검색 엔진이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요.",
288
+ "sources": []
289
+ })
290
+ # 또는 필수 컴포넌트가 없을 때만 특별 응답 반환
291
+ if stt_client is None:
292
+ return jsonify({
293
+ "transcription": "(음성 인식 기능이 준비 중입니다)",
294
+ "answer": "죄송합니다. 현재 음성 인식 서비스가 초기화 중입니다. 잠시 후 다시 시도해주세요.",
295
+ "sources": []
296
+ })
297
+
298
+ logger.info("음성 챗 요청 수신")
299
+
300
+ if 'audio' not in request.files:
301
+ logger.error("오디오 파일이 제공되지 않음")
302
+ return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
303
+
304
+ audio_file = request.files['audio']
305
+ logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
306
+
307
+ try:
308
+ # 오디오 파일 처리
309
+ # 임시 파일 사용 고려 (메모리 부담 줄이기 위해)
310
+ with tempfile.NamedTemporaryFile(delete=True) as temp_audio:
311
+ audio_file.save(temp_audio.name)
312
+ logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
313
+ # VitoSTT.transcribe_audio 가 파일 경로 또는 바이트를 받을 수 있도록 구현되어야 함
314
+ # 여기서는 파일 경로를 사용한다고 가정
315
+ if not hasattr(stt_client, 'transcribe_audio'):
316
+ raise NotImplementedError("STT 클라이언트에 transcribe_audio 메소드가 없습니다.")
317
+
318
+ # 파일 경로로 전달 시
319
+ # stt_result = stt_client.transcribe_audio(temp_audio.name, language="ko")
320
+ # 바이트로 전달 시
321
+ with open(temp_audio.name, 'rb') as f_bytes:
322
+ audio_bytes = f_bytes.read()
323
+ stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
324
+
325
+
326
+ if not isinstance(stt_result, dict) or not stt_result.get("success"):
327
+ error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
328
+ logger.error(f"음성인식 실패: {error_msg}")
329
+ return jsonify({
330
+ "error": "음성인식 실패",
331
+ "details": error_msg
332
+ }), 500
333
+
334
+ transcription = stt_result.get("text", "")
335
+ if not transcription:
336
+ logger.warning("음성인식 결과가 비어있습니다.")
337
+ return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다.", "transcription": ""}), 400
338
+
339
+ logger.info(f"음성인식 성공: {transcription[:50]}...")
340
+ if retriever is None:
341
+ logger.error("STT 성공 후 검색 시도 중 retriever가 None임")
342
+ return jsonify({
343
+ "transcription": transcription,
344
+ "answer": "음성을 인식했지만, 현재 검색 시스템이 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
345
+ "sources": []
346
+ })
347
+ # --- 이후 로직은 /api/chat과 거의 동일 ---
348
+ # RAG 검색 수행
349
+ search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
350
+ context = DocumentProcessor.prepare_rag_context(search_results, field="text")
351
+
352
+ if not context:
353
+ logger.warning("음성 쿼리에 대한 검색 결과 없음.")
354
+ # answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." (아래 LLM 호출 로직에서 처리)
355
+ pass
356
+
357
+ # LLM 호출
358
+ llm_id = request.form.get('llm_id', None) # 음성 요청은 form 데이터로 LLM ID 받을 수 있음
359
+ if not context:
360
+ answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
361
+ logger.info("컨텍스트 없이 기본 응답 생성")
362
+ else:
363
+ answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
364
+ logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
365
+
366
+
367
+ # 소스 정보 추출
368
+ enhanced_sources = []
369
+ if search_results:
370
+ for doc in search_results:
371
+ if not isinstance(doc, dict): continue # A
372
+ if "source" in doc:
373
+ source_info = {
374
+ "source": doc.get("source", "Unknown"),
375
+ "score": doc.get("rerank_score", doc.get("score", 0))
376
+ }
377
+ if "text" in doc and doc.get("filetype") == "csv":
378
+ try:
379
+ text_lines = doc["text"].strip().split('\n')
380
+ if text_lines:
381
+ first_line = text_lines[0].strip()
382
+ if ',' in first_line:
383
+ first_column = first_line.split(',')[0].strip()
384
+ source_info["id"] = first_column
385
+ except Exception as e:
386
+ logger.warning(f"[음성챗] CSV 소스 ID 추출 실패 ({doc.get('source')}): {e}")
387
+ enhanced_sources.append(source_info)
388
+
389
+ # 최종 응답
390
+ response_data = {
391
+ "transcription": transcription,
392
+ "answer": answer,
393
+ "sources": enhanced_sources,
394
+ "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
395
+ }
396
+ return jsonify(response_data)
397
+
398
+ except Exception as e:
399
+ logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
400
+ return jsonify({
401
+ "error": "음성 처리 중 내부 오류 발생",
402
+ "details": str(e)
403
+ }), 500
404
+
405
+
406
+ @app.route('/api/upload', methods=['POST'])
407
+ @login_required
408
+ def upload_document():
409
+ """지식베이스 문서 업로드 API"""
410
+ if not app_ready or base_retriever is None:
411
+ return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
412
+
413
+ if 'document' not in request.files:
414
+ return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
415
+
416
+ doc_file = request.files['document']
417
+ if doc_file.filename == '':
418
+ return jsonify({"error": "선택된 파일이 없습니다."}), 400
419
+
420
+ if not allowed_doc_file(doc_file.filename):
421
+ logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}")
422
+ return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400
423
+
424
+ try:
425
+ filename = secure_filename(doc_file.filename)
426
+ filepath = os.path.join(app.config['DATA_FOLDER'], filename)
427
+ doc_file.save(filepath)
428
+ logger.info(f"문서 저장 완료: {filepath}")
429
+
430
+ # 문서 처리 (인코딩 처리 포함)
431
+ try:
432
+ with open(filepath, 'r', encoding='utf-8') as f:
433
+ content = f.read()
434
+ except UnicodeDecodeError:
435
+ logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
436
+ try:
437
+ with open(filepath, 'r', encoding='cp949') as f:
438
+ content = f.read()
439
+ except Exception as e_cp949:
440
+ logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}")
441
+ return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400
442
+ except Exception as e_read:
443
+ logger.error(f"파일 읽기 오류 ({filename}): {e_read}")
444
+ return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500
445
+
446
+
447
+ # 메타데이터 및 문서 분할/처리
448
+ metadata = {
449
+ "source": filename, "filename": filename,
450
+ "filetype": filename.rsplit('.', 1)[1].lower(),
451
+ "filepath": filepath
452
+ }
453
+ file_ext = metadata["filetype"]
454
+ docs = []
455
+
456
+ if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
457
+ raise NotImplementedError("DocumentProcessor에 필요한 메소드 없음")
458
+
459
+ if file_ext == 'csv':
460
+ logger.info(f"CSV 파일 처리 시작: {filename}")
461
+ docs = DocumentProcessor.csv_to_documents(content, metadata) # 행 단위 처리 가정
462
+ else: # 기타 텍스트 기반 문서
463
+ logger.info(f"일반 텍스트 문서 처리 시작: {filename}")
464
+ # PDF, DOCX 등은 별도 라이브러리(pypdf, python-docx) 필요
465
+ if file_ext in ['pdf', 'docx']:
466
+ logger.warning(f".{file_ext} 파일 처리는 현재 구현되지 않았습니다. 텍스트 추출 로직 추가 필요.")
467
+ # 여기에 pdf/docx 텍스트 추출 로직 추가
468
+ # 예: content = extract_text_from_pdf(filepath)
469
+ # content = extract_text_from_docx(filepath)
470
+ # 임시로 비워둠
471
+ content = ""
472
+
473
+ if content: # 텍스트 내용이 있을 때만 처리
474
+ docs = DocumentProcessor.text_to_documents(
475
+ content, metadata=metadata,
476
+ chunk_size=512, chunk_overlap=50
477
+ )
478
+
479
+ # 검색기에 문서 추가 및 인덱스 저장
480
+ if docs:
481
+ if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
482
+ raise NotImplementedError("기본 검색기에 add_documents 또는 save 메소드 없음")
483
+
484
+ logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
485
+ base_retriever.add_documents(docs)
486
+
487
+ # 인덱스 저장 (업로드마다 저장 - 비효율적일 수 있음)
488
+ logger.info(f"검색기 상태를 저장합니다...")
489
+ index_path = app.config['INDEX_PATH']
490
+ try:
491
+ base_retriever.save(index_path)
492
+ logger.info("인덱스 저장 완료")
493
+ # 재순위화 검색기도 업데이트 필요 시 로직 추가
494
+ # 예: retriever.update_base_retriever(base_retriever)
495
+ return jsonify({
496
+ "success": True,
497
+ "message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)."
498
+ })
499
+ except Exception as e_save:
500
+ logger.error(f"인덱스 저장 중 오류 발생: {e_save}")
501
+ return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500
502
+ else:
503
+ logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.")
504
+ # 파일은 저장되었으므로 성공으로 간주할지 결정 필요
505
+ return jsonify({
506
+ "warning": True,
507
+ "message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없습니다."
508
+ })
509
+
510
+ except Exception as e:
511
+ logger.error(f"파일 업로드 또는 ��리 중 오류 발생: {e}", exc_info=True)
512
+ return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
513
+
514
+
515
+ @app.route('/api/documents', methods=['GET'])
516
+ @login_required
517
+ def list_documents():
518
+ """지식베이스 문서 목록 API"""
519
+ if not app_ready or base_retriever is None:
520
+ return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
521
+
522
+ try:
523
+ sources = {}
524
+ total_chunks = 0
525
+ # base_retriever.documents 와 같은 속성이 실제 클래스에 있다고 가정
526
+ if hasattr(base_retriever, 'documents') and base_retriever.documents:
527
+ logger.info(f"총 {len(base_retriever.documents)}개 문서 청크에서 소스 목록 생성 중...")
528
+ for doc in base_retriever.documents:
529
+ # 문서 청크가 딕셔너리 형태라고 가정
530
+ if not isinstance(doc, dict): continue
531
+
532
+ source = doc.get("source", "unknown") # 메타데이터에서 source 가져오기
533
+ if source == "unknown" and "metadata" in doc and isinstance(doc["metadata"], dict):
534
+ source = doc["metadata"].get("source", "unknown") # Langchain Document 구조 고려
535
+
536
+ if source != "unknown":
537
+ if source in sources:
538
+ sources[source]["chunks"] += 1
539
+ else:
540
+ # 메타데이터에서 추가 정보 가져오기
541
+ filename = doc.get("filename", source)
542
+ filetype = doc.get("filetype", "unknown")
543
+ if "metadata" in doc and isinstance(doc["metadata"], dict):
544
+ filename = doc["metadata"].get("filename", filename)
545
+ filetype = doc["metadata"].get("filetype", filetype)
546
+
547
+ sources[source] = {
548
+ "filename": filename,
549
+ "chunks": 1,
550
+ "filetype": filetype
551
+ }
552
+ total_chunks += 1
553
+ else:
554
+ logger.info("검색기에 문서가 없거나 documents 속성을 찾을 수 없습니다.")
555
+
556
+ # 목록 형식 변환 및 정렬
557
+ documents = [{"source": src, **info} for src, info in sources.items()]
558
+ documents.sort(key=lambda x: x["chunks"], reverse=True)
559
+
560
+ logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크")
561
+ return jsonify({
562
+ "documents": documents,
563
+ "total_documents": len(documents),
564
+ "total_chunks": total_chunks
565
+ })
566
+
567
+ except Exception as e:
568
+ logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True)
569
+ return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
app/init_retriever.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 - 검색기 초기화 모듈
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ import pickle
8
+ import gzip
9
+ from datetime import datetime
10
+
11
+ # 로거 가져오기
12
+ logger = logging.getLogger(__name__)
13
+
14
+ def save_embeddings(base_retriever, file_path):
15
+ """임베딩 데이터를 압축하여 파일에 저장"""
16
+ try:
17
+ # 저장 디렉토리가 없으면 생성
18
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
19
+
20
+ # 타임스탬프 추가
21
+ save_data = {
22
+ 'timestamp': datetime.now().isoformat(),
23
+ 'retriever': base_retriever
24
+ }
25
+
26
+ # 압축하여 저장 (용량 줄이기)
27
+ with gzip.open(file_path, 'wb') as f:
28
+ pickle.dump(save_data, f)
29
+
30
+ logger.info(f"임베딩 데이터를 {file_path}에 압축하여 저장했습니다.")
31
+ return True
32
+ except Exception as e:
33
+ logger.error(f"임베딩 저장 중 오류 발생: {e}")
34
+ return False
35
+
36
+ def load_embeddings(file_path, max_age_days=30):
37
+ """저장된 임베딩 데이터를 파일에서 로드"""
38
+ try:
39
+ if not os.path.exists(file_path):
40
+ logger.info(f"저장된 임베딩 파일({file_path})이 없습니다.")
41
+ return None
42
+
43
+ # 압축 파일 로드
44
+ with gzip.open(file_path, 'rb') as f:
45
+ data = pickle.load(f)
46
+
47
+ # 타임스탬프 확인 (너무 오래된 데이터는 사용하지 않음)
48
+ saved_time = datetime.fromisoformat(data['timestamp'])
49
+ age = (datetime.now() - saved_time).days
50
+
51
+ if age > max_age_days:
52
+ logger.info(f"저장된 임베딩이 {age}일로 너무 오래되었습니다. 새로 생성합니다.")
53
+ return None
54
+
55
+ logger.info(f"{file_path}에서 임베딩 데이터를 로드했습니다. (생성일: {saved_time})")
56
+ return data['retriever']
57
+ except Exception as e:
58
+ logger.error(f"임베딩 로드 중 오류 발생: {e}")
59
+ return None
60
+
61
+ def init_retriever(app, base_retriever, retriever, ReRanker):
62
+ """검색기 객체 초기화 또는 로드"""
63
+ from utils.document_processor import DocumentProcessor
64
+ from retrieval.vector_retriever import VectorRetriever
65
+
66
+ # 임베딩 캐시 파일 경로
67
+ cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
68
+
69
+ # 먼저 저장된 임베딩 데이터 로드 시도
70
+ cached_retriever = load_embeddings(cache_path)
71
+
72
+ if cached_retriever:
73
+ logger.info("캐시된 임베딩 데이터를 성공적으로 로드했습니다.")
74
+ base_retriever = cached_retriever
75
+ else:
76
+ # 캐시된 데이터가 없으면 기존 방식으로 초기화
77
+ index_path = app.config['INDEX_PATH']
78
+
79
+ # VectorRetriever 로드 또는 초기화
80
+ if os.path.exists(os.path.join(index_path, "documents.json")):
81
+ try:
82
+ logger.info(f"기존 벡터 인덱스를 '{index_path}'에서 로드합니다...")
83
+ base_retriever = VectorRetriever.load(index_path)
84
+ logger.info(f"{len(base_retriever.documents) if hasattr(base_retriever, 'documents') else 0}개 문서가 로드되었습니다.")
85
+ except Exception as e:
86
+ logger.error(f"인덱스 로드 중 오류 발생: {e}. 새 검색기를 초기화합니다.")
87
+ base_retriever = VectorRetriever()
88
+ else:
89
+ logger.info("기존 인덱스를 찾을 수 없어 새 검색기를 초기화합니다...")
90
+ base_retriever = VectorRetriever()
91
+
92
+ # 데이터 폴더의 문서 로드
93
+ data_path = app.config['DATA_FOLDER']
94
+ if (not hasattr(base_retriever, 'documents') or not base_retriever.documents) and os.path.exists(data_path):
95
+ logger.info(f"{data_path}에서 문서를 로드합니다...")
96
+ try:
97
+ docs = DocumentProcessor.load_documents_from_directory(
98
+ data_path,
99
+ extensions=[".txt", ".md", ".csv"],
100
+ recursive=True
101
+ )
102
+ if docs and hasattr(base_retriever, 'add_documents'):
103
+ logger.info(f"{len(docs)}개 문서를 검색기에 추가합니다...")
104
+ base_retriever.add_documents(docs)
105
+
106
+ if hasattr(base_retriever, 'save'):
107
+ logger.info(f"검색기 상태를 '{index_path}'에 저장합니다...")
108
+ try:
109
+ base_retriever.save(index_path)
110
+ logger.info("인덱스 저장 완료")
111
+
112
+ # 새로 생성된 검색기 캐싱
113
+ if hasattr(base_retriever, 'documents') and base_retriever.documents:
114
+ save_embeddings(base_retriever, cache_path)
115
+ logger.info(f"검색기를 캐시 파일 {cache_path}에 저장 완료")
116
+ except Exception as e:
117
+ logger.error(f"인덱스 저장 중 오류 발생: {e}")
118
+ except Exception as e:
119
+ logger.error(f"DATA_FOLDER에서 문서 로드 중 오류: {e}")
120
+
121
+ # 재순위화 검색기 초기화
122
+ logger.info("재순위화 검색기를 초기화합니다...")
123
+ try:
124
+ # 자체 구현된 재순위화 함수
125
+ def custom_rerank_fn(query, results):
126
+ query_terms = set(query.lower().split())
127
+ for result in results:
128
+ if isinstance(result, dict) and "text" in result:
129
+ text = result["text"].lower()
130
+ term_freq = sum(1 for term in query_terms if term in text)
131
+ normalized_score = term_freq / (len(text.split()) + 1) * 10
132
+ result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
133
+ elif isinstance(result, dict):
134
+ result["rerank_score"] = result.get("score", 0)
135
+ results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
136
+ return results
137
+
138
+ # ReRanker 클래스 사용
139
+ retriever = ReRanker(
140
+ base_retriever=base_retriever,
141
+ rerank_fn=custom_rerank_fn,
142
+ rerank_field="text"
143
+ )
144
+ logger.info("재순위화 검색기 초기화 완료")
145
+ except Exception as e:
146
+ logger.error(f"재순위화 검색기 초기화 실패: {e}")
147
+ retriever = base_retriever # 실패 시 기본 검색기 사용
148
+
149
+ return retriever
app/static/css/device-style.css ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 장치 관리 전용 CSS 스타일
3
+ */
4
+
5
+ /* 장치 관리 섹션 */
6
+ .device-container {
7
+ background-color: var(--card-bg);
8
+ border-radius: 8px;
9
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
10
+ overflow: hidden;
11
+ padding: 20px;
12
+ }
13
+
14
+ /* 서버 상태 표시 */
15
+ .server-status {
16
+ padding: 12px 15px;
17
+ border-radius: 6px;
18
+ margin-bottom: 15px;
19
+ display: flex;
20
+ align-items: center;
21
+ }
22
+
23
+ .server-status i {
24
+ margin-right: 10px;
25
+ font-size: 18px;
26
+ }
27
+
28
+ .server-status.success {
29
+ background-color: rgba(16, 185, 129, 0.1);
30
+ color: var(--success-color);
31
+ border-left: 4px solid var(--success-color);
32
+ }
33
+
34
+ .server-status.error {
35
+ background-color: rgba(239, 68, 68, 0.1);
36
+ color: var(--error-color);
37
+ border-left: 4px solid var(--error-color);
38
+ }
39
+
40
+ .server-status.warning {
41
+ background-color: rgba(245, 158, 11, 0.1);
42
+ color: var(--secondary-color);
43
+ border-left: 4px solid var(--secondary-color);
44
+ }
45
+
46
+ /* 서버 시작 안내 */
47
+ .server-guide {
48
+ background-color: #f8f9fa;
49
+ border-radius: 6px;
50
+ padding: 15px;
51
+ margin: 15px 0;
52
+ font-size: 14px;
53
+ }
54
+
55
+ .server-guide code {
56
+ background-color: #e9ecef;
57
+ padding: 2px 6px;
58
+ border-radius: 4px;
59
+ font-family: monospace;
60
+ }
61
+
62
+ .server-guide ol {
63
+ margin-left: 20px;
64
+ margin-top: 10px;
65
+ margin-bottom: 0;
66
+ }
67
+
68
+ .server-guide li {
69
+ margin-bottom: 5px;
70
+ }
71
+
72
+ /* 재시도 버튼 */
73
+ .retry-button {
74
+ background-color: var(--primary-color);
75
+ color: white;
76
+ border: none;
77
+ border-radius: 4px;
78
+ padding: 8px 15px;
79
+ margin-top: 10px;
80
+ cursor: pointer;
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ font-size: 14px;
85
+ transition: var(--transition);
86
+ }
87
+
88
+ .retry-button:hover {
89
+ background-color: var(--primary-dark);
90
+ }
91
+
92
+ .retry-button i {
93
+ margin-right: 5px;
94
+ }
95
+
96
+ /* 장치 목록 */
97
+ .device-section, .programs-section {
98
+ margin-top: 25px;
99
+ }
100
+
101
+ .device-section h2, .programs-section h2 {
102
+ margin-bottom: 15px;
103
+ color: var(--primary-color);
104
+ display: flex;
105
+ justify-content: space-between;
106
+ align-items: center;
107
+ }
108
+
109
+ .device-count {
110
+ font-size: 14px;
111
+ color: var(--light-text);
112
+ margin-bottom: 10px;
113
+ }
114
+
115
+ .device-item {
116
+ background-color: #f8f9fa;
117
+ border-radius: 8px;
118
+ padding: 15px;
119
+ margin-bottom: 10px;
120
+ border: 1px solid var(--border-color);
121
+ transition: var(--transition);
122
+ }
123
+
124
+ .device-item:hover {
125
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08);
126
+ }
127
+
128
+ .device-item h3 {
129
+ margin-bottom: 10px;
130
+ color: var(--primary-color);
131
+ font-size: 16px;
132
+ }
133
+
134
+ .device-details {
135
+ display: grid;
136
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
137
+ gap: 10px;
138
+ font-size: 14px;
139
+ }
140
+
141
+ .device-details p {
142
+ margin: 0;
143
+ }
144
+
145
+ .status-connected, .status-online {
146
+ color: var(--success-color);
147
+ font-weight: 500;
148
+ }
149
+
150
+ .status-disconnected, .status-offline {
151
+ color: var(--error-color);
152
+ font-weight: 500;
153
+ }
154
+
155
+ .status-idle {
156
+ color: var(--secondary-color);
157
+ font-weight: 500;
158
+ }
159
+
160
+ .no-devices, .no-programs {
161
+ padding: 15px;
162
+ text-align: center;
163
+ background-color: #f8f9fa;
164
+ border-radius: 8px;
165
+ color: var(--light-text);
166
+ border: 1px dashed var(--border-color);
167
+ }
168
+
169
+ .no-devices i, .no-programs i {
170
+ margin-right: 5px;
171
+ }
172
+
173
+ /* 로딩 표시 */
174
+ .loading-device, .loading-device-list, .loading-programs {
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ justify-content: center;
179
+ padding: 20px;
180
+ text-align: center;
181
+ }
182
+
183
+ .spinner.small {
184
+ width: 20px;
185
+ height: 20px;
186
+ margin-right: 8px;
187
+ }
188
+
189
+ /* 프로그램 목록 섹션 */
190
+ .programs-container {
191
+ background-color: #f8f9fa;
192
+ border-radius: 8px;
193
+ padding: 15px;
194
+ margin-top: 20px;
195
+ border: 1px solid var(--border-color);
196
+ }
197
+
198
+ .program-item {
199
+ background-color: white;
200
+ border-radius: 6px;
201
+ padding: 15px;
202
+ margin-bottom: 10px;
203
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
204
+ position: relative;
205
+ }
206
+
207
+ .program-item h3 {
208
+ margin-bottom: 8px;
209
+ color: var(--primary-color);
210
+ font-size: 16px;
211
+ }
212
+
213
+ .program-description {
214
+ font-size: 14px;
215
+ color: var(--light-text);
216
+ margin-bottom: 15px;
217
+ }
218
+
219
+ .execute-btn {
220
+ background-color: var(--primary-color);
221
+ color: white;
222
+ border: none;
223
+ border-radius: 4px;
224
+ padding: 8px 15px;
225
+ cursor: pointer;
226
+ display: flex;
227
+ align-items: center;
228
+ font-size: 14px;
229
+ transition: var(--transition);
230
+ }
231
+
232
+ .execute-btn:hover {
233
+ background-color: var(--primary-dark);
234
+ }
235
+
236
+ .execute-btn i {
237
+ margin-right: 5px;
238
+ }
239
+
240
+ .execute-loading, .execute-success, .execute-error {
241
+ display: flex;
242
+ align-items: center;
243
+ padding: 5px 10px;
244
+ border-radius: 4px;
245
+ margin-top: 10px;
246
+ font-size: 14px;
247
+ }
248
+
249
+ .execute-loading {
250
+ background-color: #f1f5f9;
251
+ color: var(--light-text);
252
+ }
253
+
254
+ .execute-success {
255
+ background-color: rgba(16, 185, 129, 0.1);
256
+ color: var(--success-color);
257
+ }
258
+
259
+ .execute-error {
260
+ background-color: rgba(239, 68, 68, 0.1);
261
+ color: var(--error-color);
262
+ }
263
+
264
+ .execute-loading span, .execute-success span, .execute-error span {
265
+ margin-left: 5px;
266
+ }
267
+
268
+ /* 장치 관리 섹션 상단 버튼 */
269
+ .device-toolbar {
270
+ display: flex;
271
+ justify-content: space-between;
272
+ margin-bottom: 15px;
273
+ }
274
+
275
+ .load-programs-btn, .refresh-device-btn {
276
+ padding: 8px 15px;
277
+ background-color: var(--primary-color);
278
+ color: white;
279
+ border: none;
280
+ border-radius: 4px;
281
+ cursor: pointer;
282
+ display: flex;
283
+ align-items: center;
284
+ font-size: 14px;
285
+ transition: var(--transition);
286
+ }
287
+
288
+ .load-programs-btn:hover, .refresh-device-btn:hover {
289
+ background-color: var(--primary-dark);
290
+ }
291
+
292
+ .load-programs-btn i, .refresh-device-btn i {
293
+ margin-right: 5px;
294
+ }
295
+
296
+ /* 반응형 */
297
+ @media (max-width: 768px) {
298
+ .device-details {
299
+ grid-template-columns: 1fr;
300
+ }
301
+
302
+ .device-toolbar {
303
+ flex-direction: column;
304
+ gap: 10px;
305
+ }
306
+ }
app/static/css/style.css ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 기본 스타일 */
2
+ :root {
3
+ --primary-color: #4a6da7;
4
+ --primary-dark: #345089;
5
+ --secondary-color: #f59e0b;
6
+ --text-color: #333;
7
+ --light-text: #666;
8
+ --bg-color: #f8f9fa;
9
+ --card-bg: #fff;
10
+ --border-color: #e0e0e0;
11
+ --error-color: #ef4444;
12
+ --success-color: #10b981;
13
+ --warning-color: #f59e0b;
14
+ --hover-color: #f1f5f9;
15
+ --transition: all 0.3s ease;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ margin: 0;
21
+ padding: 0;
22
+ }
23
+
24
+ body {
25
+ font-family: 'Pretendard', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
26
+ line-height: 1.6;
27
+ color: var(--text-color);
28
+ background-color: var(--bg-color);
29
+ margin: 0;
30
+ }
31
+
32
+ .container {
33
+ max-width: 1000px;
34
+ margin: 0 auto;
35
+ padding: 20px;
36
+ min-height: 100vh;
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ /* 헤더 스타일 */
42
+ header {
43
+ text-align: center;
44
+ margin-bottom: 20px;
45
+ }
46
+
47
+ header h1 {
48
+ color: var(--primary-color);
49
+ margin-bottom: 15px;
50
+ }
51
+
52
+ .header-actions {
53
+ display: flex;
54
+ justify-content: space-between;
55
+ align-items: center;
56
+ margin-bottom: 15px;
57
+ flex-wrap: wrap;
58
+ }
59
+
60
+ .llm-selector {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 10px;
64
+ }
65
+
66
+ .llm-selector label {
67
+ font-weight: 600;
68
+ color: var(--primary-color);
69
+ }
70
+
71
+ .user-info {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ }
76
+
77
+ .logout-button {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 5px;
81
+ color: var(--error-color);
82
+ text-decoration: none;
83
+ font-size: 14px;
84
+ }
85
+
86
+ .logout-button:hover {
87
+ text-decoration: underline;
88
+ }
89
+
90
+ #llmSelect {
91
+ padding: 8px 12px;
92
+ border: 1px solid var(--border-color);
93
+ border-radius: 4px;
94
+ background-color: white;
95
+ font-size: 14px;
96
+ outline: none;
97
+ transition: var(--transition);
98
+ }
99
+
100
+ #llmSelect:focus {
101
+ border-color: var(--primary-color);
102
+ }
103
+
104
+ .tabs {
105
+ display: flex;
106
+ justify-content: center;
107
+ margin-bottom: 20px;
108
+ }
109
+
110
+ .tab {
111
+ padding: 10px 20px;
112
+ background-color: var(--card-bg);
113
+ border: 1px solid var(--border-color);
114
+ border-radius: 4px;
115
+ margin: 0 5px;
116
+ cursor: pointer;
117
+ transition: var(--transition);
118
+ }
119
+
120
+ .tab:hover {
121
+ background-color: var(--hover-color);
122
+ }
123
+
124
+ .tab.active {
125
+ background-color: var(--primary-color);
126
+ color: white;
127
+ border-color: var(--primary-color);
128
+ }
129
+
130
+ /* 메인 컨텐츠 */
131
+ main {
132
+ flex-grow: 1;
133
+ }
134
+
135
+ .tab-content {
136
+ display: none;
137
+ }
138
+
139
+ .tab-content.active {
140
+ display: block;
141
+ }
142
+
143
+ /* 채팅 섹션 */
144
+ .chat-container {
145
+ background-color: var(--card-bg);
146
+ border-radius: 8px;
147
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
148
+ overflow: hidden;
149
+ height: 70vh;
150
+ display: flex;
151
+ flex-direction: column;
152
+ }
153
+
154
+ .chat-messages {
155
+ flex-grow: 1;
156
+ overflow-y: auto;
157
+ padding: 20px;
158
+ }
159
+
160
+ .message {
161
+ margin-bottom: 20px;
162
+ display: flex;
163
+ align-items: flex-start;
164
+ }
165
+
166
+ .message.user {
167
+ justify-content: flex-end;
168
+ }
169
+
170
+ .message-content {
171
+ padding: 12px 16px;
172
+ border-radius: 12px;
173
+ max-width: 80%;
174
+ }
175
+
176
+ .message.system .message-content {
177
+ background-color: #f0f7ff;
178
+ color: var(--primary-dark);
179
+ }
180
+
181
+ .message.user .message-content {
182
+ background-color: var(--primary-color);
183
+ color: white;
184
+ border-top-right-radius: 4px;
185
+ }
186
+
187
+ .message.bot .message-content {
188
+ background-color: #f1f5f9;
189
+ color: var(--text-color);
190
+ border-top-left-radius: 4px;
191
+ }
192
+
193
+ .message p {
194
+ margin-bottom: 8px;
195
+ }
196
+
197
+ .message p:last-child {
198
+ margin-bottom: 0;
199
+ }
200
+
201
+ .message .sources {
202
+ font-size: 0.85em;
203
+ color: var(--light-text);
204
+ margin-top: 5px;
205
+ }
206
+
207
+ .message .source-item {
208
+ margin-right: 10px;
209
+ }
210
+
211
+ .message .transcription {
212
+ font-style: italic;
213
+ opacity: 0.8;
214
+ font-size: 0.9em;
215
+ margin-bottom: 8px;
216
+ }
217
+
218
+ .chat-input-container {
219
+ display: flex;
220
+ padding: 15px;
221
+ border-top: 1px solid var(--border-color);
222
+ background-color: var(--card-bg);
223
+ }
224
+
225
+ #userInput {
226
+ flex-grow: 1;
227
+ border: 1px solid var(--border-color);
228
+ border-radius: 20px;
229
+ padding: 10px 15px;
230
+ font-size: 16px;
231
+ resize: none;
232
+ outline: none;
233
+ transition: var(--transition);
234
+ }
235
+
236
+ #userInput:focus {
237
+ border-color: var(--primary-color);
238
+ }
239
+
240
+ .mic-button, .send-button, .stop-recording-button {
241
+ border: none;
242
+ background-color: transparent;
243
+ color: var(--primary-color);
244
+ font-size: 20px;
245
+ margin-left: 10px;
246
+ cursor: pointer;
247
+ transition: var(--transition);
248
+ width: 40px;
249
+ height: 40px;
250
+ border-radius: 50%;
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: center;
254
+ }
255
+
256
+ .mic-button:hover, .send-button:hover, .stop-recording-button:hover {
257
+ background-color: var(--hover-color);
258
+ }
259
+
260
+ .stop-recording-button {
261
+ background-color: var(--error-color);
262
+ color: white;
263
+ }
264
+
265
+ .stop-recording-button:hover {
266
+ background-color: #dc2626; /* 더 어두운 빨간색 */
267
+ }
268
+
269
+ .recording-status {
270
+ display: flex;
271
+ align-items: center;
272
+ padding: 10px 15px;
273
+ background-color: rgba(239, 68, 68, 0.1);
274
+ border-top: 1px solid var(--border-color);
275
+ color: var(--error-color);
276
+ }
277
+
278
+ .recording-indicator {
279
+ position: relative;
280
+ width: 12px;
281
+ height: 12px;
282
+ margin-right: 10px;
283
+ }
284
+
285
+ .recording-pulse {
286
+ position: absolute;
287
+ width: 100%;
288
+ height: 100%;
289
+ background-color: var(--error-color);
290
+ border-radius: 50%;
291
+ animation: pulse 1.5s infinite;
292
+ }
293
+
294
+ @keyframes pulse {
295
+ 0% {
296
+ transform: scale(0.8);
297
+ opacity: 1;
298
+ }
299
+ 70% {
300
+ transform: scale(1.5);
301
+ opacity: 0;
302
+ }
303
+ 100% {
304
+ transform: scale(0.8);
305
+ opacity: 0;
306
+ }
307
+ }
308
+
309
+ .hidden {
310
+ display: none;
311
+ }
312
+
313
+ /* 문서 관리 섹션 */
314
+ .docs-container {
315
+ background-color: var(--card-bg);
316
+ border-radius: 8px;
317
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
318
+ overflow: hidden;
319
+ padding: 20px;
320
+ }
321
+
322
+ .upload-section {
323
+ margin-bottom: 30px;
324
+ padding-bottom: 20px;
325
+ border-bottom: 1px solid var(--border-color);
326
+ }
327
+
328
+ .upload-section h2, .docs-list-section h2 {
329
+ margin-bottom: 15px;
330
+ color: var(--primary-color);
331
+ }
332
+
333
+ .file-upload {
334
+ display: flex;
335
+ align-items: center;
336
+ margin-bottom: 20px;
337
+ }
338
+
339
+ .file-upload input[type="file"] {
340
+ display: none;
341
+ }
342
+
343
+ .file-upload label {
344
+ padding: 10px 20px;
345
+ background-color: var(--primary-color);
346
+ color: white;
347
+ border-radius: 4px;
348
+ cursor: pointer;
349
+ transition: var(--transition);
350
+ }
351
+
352
+ .file-upload label:hover {
353
+ background-color: var(--primary-dark);
354
+ }
355
+
356
+ #fileName {
357
+ margin-left: 15px;
358
+ color: var(--light-text);
359
+ }
360
+
361
+ .upload-button {
362
+ padding: 10px 20px;
363
+ background-color: var(--secondary-color);
364
+ color: white;
365
+ border: none;
366
+ border-radius: 4px;
367
+ cursor: pointer;
368
+ transition: var(--transition);
369
+ display: flex;
370
+ align-items: center;
371
+ }
372
+
373
+ .upload-button i {
374
+ margin-right: 8px;
375
+ }
376
+
377
+ .upload-button:hover {
378
+ background-color: #d97706; /* 더 어두운 주황색 */
379
+ }
380
+
381
+ .upload-status {
382
+ margin-top: 15px;
383
+ padding: 10px 15px;
384
+ border-radius: 4px;
385
+ }
386
+
387
+ .upload-status.success {
388
+ background-color: rgba(16, 185, 129, 0.1);
389
+ color: var(--success-color);
390
+ }
391
+
392
+ .upload-status.error {
393
+ background-color: rgba(239, 68, 68, 0.1);
394
+ color: var(--error-color);
395
+ }
396
+
397
+ .docs-list-section {
398
+ position: relative;
399
+ }
400
+
401
+ .refresh-button {
402
+ position: absolute;
403
+ top: 0;
404
+ right: 0;
405
+ padding: 5px 10px;
406
+ background-color: transparent;
407
+ color: var(--primary-color);
408
+ border: 1px solid var(--primary-color);
409
+ border-radius: 4px;
410
+ cursor: pointer;
411
+ transition: var(--transition);
412
+ display: flex;
413
+ align-items: center;
414
+ }
415
+
416
+ .refresh-button i {
417
+ margin-right: 5px;
418
+ }
419
+
420
+ .refresh-button:hover {
421
+ background-color: var(--hover-color);
422
+ }
423
+
424
+ .docs-list {
425
+ width: 100%;
426
+ border-collapse: collapse;
427
+ margin-top: 20px;
428
+ }
429
+
430
+ .docs-list th, .docs-list td {
431
+ padding: 12px 15px;
432
+ text-align: left;
433
+ border-bottom: 1px solid var(--border-color);
434
+ }
435
+
436
+ .docs-list th {
437
+ background-color: #f1f5f9;
438
+ font-weight: 600;
439
+ }
440
+
441
+ .docs-list tr:hover {
442
+ background-color: var(--hover-color);
443
+ }
444
+
445
+ .loading-indicator {
446
+ display: flex;
447
+ flex-direction: column;
448
+ align-items: center;
449
+ justify-content: center;
450
+ padding: 30px;
451
+ }
452
+
453
+ .spinner {
454
+ width: 40px;
455
+ height: 40px;
456
+ border: 4px solid #f3f3f3;
457
+ border-top: 4px solid var(--primary-color);
458
+ border-radius: 50%;
459
+ animation: spin 1s linear infinite;
460
+ margin-bottom: 15px;
461
+ }
462
+
463
+ @keyframes spin {
464
+ 0% { transform: rotate(0deg); }
465
+ 100% { transform: rotate(360deg); }
466
+ }
467
+
468
+ .no-docs-message {
469
+ text-align: center;
470
+ padding: 30px;
471
+ color: var(--light-text);
472
+ }
473
+
474
+ /* 장치 관리 섹션 */
475
+ .device-container {
476
+ background-color: var(--card-bg);
477
+ border-radius: 8px;
478
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
479
+ overflow: hidden;
480
+ padding: 20px;
481
+ }
482
+
483
+ .device-status-section, .device-list-section, .programs-section {
484
+ margin-bottom: 30px;
485
+ padding-bottom: 20px;
486
+ border-bottom: 1px solid var(--border-color);
487
+ position: relative;
488
+ }
489
+
490
+ .programs-section {
491
+ border-bottom: none;
492
+ }
493
+
494
+ .device-status-section h2, .device-list-section h2, .programs-section h2 {
495
+ margin-bottom: 15px;
496
+ color: var(--primary-color);
497
+ }
498
+
499
+ .device-status-info {
500
+ background-color: #f8f9fa;
501
+ border-radius: 8px;
502
+ padding: 15px;
503
+ margin-top: 15px;
504
+ }
505
+
506
+ .status-indicator {
507
+ display: flex;
508
+ align-items: center;
509
+ gap: 10px;
510
+ font-size: 16px;
511
+ }
512
+
513
+ .status-indicator i {
514
+ font-size: 20px;
515
+ }
516
+
517
+ .status-indicator .fa-circle.online {
518
+ color: var(--success-color);
519
+ }
520
+
521
+ .status-indicator .fa-circle.offline {
522
+ color: var(--error-color);
523
+ }
524
+
525
+ .status-indicator .fa-circle.warning {
526
+ color: var(--warning-color);
527
+ }
528
+
529
+ .status-indicator .fa-question-circle {
530
+ color: var(--light-text);
531
+ }
532
+
533
+ .device-list-container, .programs-container {
534
+ margin-top: 15px;
535
+ }
536
+
537
+ .device-list, .programs-list {
538
+ display: flex;
539
+ flex-direction: column;
540
+ gap: 10px;
541
+ }
542
+
543
+ .device-item, .program-item {
544
+ background-color: #f8f9fa;
545
+ border-radius: 8px;
546
+ padding: 15px;
547
+ border-left: 4px solid var(--primary-color);
548
+ }
549
+
550
+ .device-item.online {
551
+ border-left-color: var(--success-color);
552
+ }
553
+
554
+ .device-item.offline {
555
+ border-left-color: var(--error-color);
556
+ }
557
+
558
+ .device-item.warning {
559
+ border-left-color: var(--warning-color);
560
+ }
561
+
562
+ .device-item-header, .program-item-header {
563
+ display: flex;
564
+ justify-content: space-between;
565
+ align-items: center;
566
+ margin-bottom: 8px;
567
+ }
568
+
569
+ .device-name, .program-name {
570
+ font-weight: 600;
571
+ font-size: 16px;
572
+ }
573
+
574
+ .device-status-badge {
575
+ padding: 4px 8px;
576
+ border-radius: 12px;
577
+ font-size: 12px;
578
+ font-weight: 500;
579
+ }
580
+
581
+ .device-status-badge.online {
582
+ background-color: rgba(16, 185, 129, 0.1);
583
+ color: var(--success-color);
584
+ }
585
+
586
+ .device-status-badge.offline {
587
+ background-color: rgba(239, 68, 68, 0.1);
588
+ color: var(--error-color);
589
+ }
590
+
591
+ .device-status-badge.warning {
592
+ background-color: rgba(245, 158, 11, 0.1);
593
+ color: var(--warning-color);
594
+ }
595
+
596
+ .device-info, .program-info {
597
+ color: var(--light-text);
598
+ font-size: 14px;
599
+ }
600
+
601
+ .device-details, .program-description {
602
+ margin-top: 5px;
603
+ font-size: 14px;
604
+ }
605
+
606
+ .no-devices-message, .no-programs-message {
607
+ text-align: center;
608
+ padding: 30px;
609
+ color: var(--light-text);
610
+ }
611
+
612
+ .execute-btn {
613
+ background-color: var(--primary-color);
614
+ color: white;
615
+ border: none;
616
+ border-radius: 4px;
617
+ padding: 8px 16px;
618
+ cursor: pointer;
619
+ transition: var(--transition);
620
+ margin-top: 10px;
621
+ }
622
+
623
+ .execute-btn:hover {
624
+ background-color: var(--primary-dark);
625
+ }
626
+
627
+ .error-message {
628
+ color: var(--error-color);
629
+ background-color: rgba(239, 68, 68, 0.1);
630
+ padding: 10px;
631
+ border-radius: 4px;
632
+ margin-top: 10px;
633
+ }
634
+
635
+ .success-message {
636
+ color: var(--success-color);
637
+ background-color: rgba(16, 185, 129, 0.1);
638
+ padding: 10px;
639
+ border-radius: 4px;
640
+ margin-top: 10px;
641
+ }
642
+
643
+ /* 푸터 */
644
+ footer {
645
+ text-align: center;
646
+ margin-top: 30px;
647
+ padding-top: 20px;
648
+ border-top: 1px solid var(--border-color);
649
+ color: var(--light-text);
650
+ font-size: 0.9em;
651
+ display: flex;
652
+ justify-content: space-between;
653
+ align-items: center;
654
+ flex-wrap: wrap;
655
+ }
656
+
657
+ footer p {
658
+ margin-bottom: 5px;
659
+ }
660
+
661
+ .current-llm {
662
+ color: var(--primary-color);
663
+ font-weight: 500;
664
+ }
665
+
666
+ #currentLLMInfo {
667
+ font-weight: 600;
668
+ }
669
+
670
+ /* 반응형 스타일 */
671
+ @media (max-width: 768px) {
672
+ .container {
673
+ padding: 10px;
674
+ }
675
+
676
+ .header-actions {
677
+ flex-direction: column;
678
+ gap: 10px;
679
+ }
680
+
681
+ .chat-container {
682
+ height: 65vh;
683
+ }
684
+
685
+ .message-content {
686
+ max-width: 90%;
687
+ }
688
+
689
+ .file-upload {
690
+ flex-direction: column;
691
+ align-items: flex-start;
692
+ }
693
+
694
+ #fileName {
695
+ margin-left: 0;
696
+ margin-top: 10px;
697
+ }
698
+
699
+ .refresh-button {
700
+ position: static;
701
+ margin-top: 10px;
702
+ margin-bottom: 10px;
703
+ }
704
+
705
+ footer {
706
+ flex-direction: column;
707
+ text-align: center;
708
+ }
709
+
710
+ .current-llm {
711
+ margin-top: 10px;
712
+ }
713
+
714
+ .llm-selector {
715
+ flex-direction: column;
716
+ gap: 5px;
717
+ }
718
+
719
+ .tabs {
720
+ flex-wrap: wrap;
721
+ }
722
+
723
+ .tab {
724
+ margin-bottom: 5px;
725
+ }
726
+ }
app/static/js/app-core.js ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 UI 코어 JavaScript
3
+ */
4
+
5
+ // 전역 변수
6
+ let currentLLM = 'openai';
7
+ let supportedLLMs = [];
8
+
9
+ // DOM 변수 미리 선언
10
+ let chatTab, docsTab, deviceTab, chatSection, docsSection, deviceSection;
11
+ let chatMessages, userInput, sendButton;
12
+ let micButton, stopRecordingButton, recordingStatus;
13
+ let llmSelect, currentLLMInfo;
14
+
15
+ // 녹음 관련 변수
16
+ let mediaRecorder = null;
17
+ let audioChunks = [];
18
+ let isRecording = false;
19
+
20
+ /**
21
+ * 앱 초기화 상태 확인 함수
22
+ */
23
+ async function checkAppStatus() {
24
+ try {
25
+ const response = await fetch('/api/status');
26
+ if (!response.ok) {
27
+ return false;
28
+ }
29
+ const data = await response.json();
30
+ return data.ready;
31
+ } catch (error) {
32
+ console.error('상태 확인 실패:', error);
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * DOM 요소 초기화 함수
39
+ */
40
+ function initDomElements() {
41
+ console.log('DOM 요소 초기화 중...');
42
+
43
+ // 탭 관련 요소
44
+ chatTab = document.getElementById('chatTab');
45
+ docsTab = document.getElementById('docsTab');
46
+ deviceTab = document.getElementById('deviceTab');
47
+ chatSection = document.getElementById('chatSection');
48
+ docsSection = document.getElementById('docsSection');
49
+ deviceSection = document.getElementById('deviceSection');
50
+
51
+ // 채팅 관련 요소
52
+ chatMessages = document.getElementById('chatMessages');
53
+ userInput = document.getElementById('userInput');
54
+ sendButton = document.getElementById('sendButton');
55
+
56
+ // 음성 녹음 관련 요소
57
+ micButton = document.getElementById('micButton');
58
+ stopRecordingButton = document.getElementById('stopRecordingButton');
59
+ recordingStatus = document.getElementById('recordingStatus');
60
+
61
+ // LLM 관련 요소
62
+ llmSelect = document.getElementById('llmSelect');
63
+ currentLLMInfo = document.getElementById('currentLLMInfo');
64
+
65
+ console.log('DOM 요소 초기화 완료');
66
+ }
67
+
68
+ /**
69
+ * 이벤트 리스너 초기화 함수
70
+ */
71
+ function initEventListeners() {
72
+ console.log('이벤트 리스너 초기화 중...');
73
+
74
+ // 탭 전환 이벤트 리스너
75
+ chatTab.addEventListener('click', () => {
76
+ switchTab('chat');
77
+ });
78
+
79
+ docsTab.addEventListener('click', () => {
80
+ switchTab('docs');
81
+ loadDocuments();
82
+ });
83
+
84
+ deviceTab.addEventListener('click', () => {
85
+ switchTab('device');
86
+ loadDeviceStatus();
87
+ });
88
+
89
+ // LLM 선택 이벤트 리스너
90
+ llmSelect.addEventListener('change', (event) => {
91
+ changeLLM(event.target.value);
92
+ });
93
+
94
+ // 메시지 전송 이벤트 리스너
95
+ sendButton.addEventListener('click', sendMessage);
96
+ userInput.addEventListener('keydown', (event) => {
97
+ if (event.key === 'Enter' && !event.shiftKey) {
98
+ event.preventDefault();
99
+ sendMessage();
100
+ }
101
+ });
102
+
103
+ // 음성 인식 이벤트 리스너
104
+ micButton.addEventListener('click', startRecording);
105
+ stopRecordingButton.addEventListener('click', stopRecording);
106
+
107
+ // 자동 입력 필드 크기 조정
108
+ userInput.addEventListener('input', adjustTextareaHeight);
109
+
110
+ console.log('이벤트 리스너 초기화 완료');
111
+ }
112
+
113
+ /**
114
+ * 탭 전환 함수
115
+ * @param {string} tabName - 활성화할 탭 이름 ('chat', 'docs', 또는 'device')
116
+ */
117
+ function switchTab(tabName) {
118
+ console.log(`탭 전환: ${tabName}`);
119
+
120
+ // 모든 탭을 비활성화
121
+ [chatTab, docsTab, deviceTab].forEach(tab => tab.classList.remove('active'));
122
+ [chatSection, docsSection, deviceSection].forEach(section => section.classList.remove('active'));
123
+
124
+ // 선택한 탭 활성화
125
+ if (tabName === 'chat') {
126
+ chatTab.classList.add('active');
127
+ chatSection.classList.add('active');
128
+ } else if (tabName === 'docs') {
129
+ docsTab.classList.add('active');
130
+ docsSection.classList.add('active');
131
+ } else if (tabName === 'device') {
132
+ deviceTab.classList.add('active');
133
+ deviceSection.classList.add('active');
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 시스템 알림 메시지 추가
139
+ * @param {string} message - 알림 메시지
140
+ */
141
+ function addSystemNotification(message) {
142
+ console.log(`시스템 알림 추가: ${message}`);
143
+
144
+ const messageDiv = document.createElement('div');
145
+ messageDiv.classList.add('message', 'system');
146
+
147
+ const contentDiv = document.createElement('div');
148
+ contentDiv.classList.add('message-content');
149
+
150
+ const messageP = document.createElement('p');
151
+ messageP.innerHTML = `<i class="fas fa-info-circle"></i> ${message}`;
152
+ contentDiv.appendChild(messageP);
153
+
154
+ messageDiv.appendChild(contentDiv);
155
+ chatMessages.appendChild(messageDiv);
156
+
157
+ // 스크롤을 가장 아래로 이동
158
+ chatMessages.scrollTop = chatMessages.scrollHeight;
159
+ }
160
+
161
+ /**
162
+ * 메시지 추가 함수
163
+ * @param {string} text - 메시지 내용
164
+ * @param {string} sender - 메시지 발신��� ('user' 또는 'bot' 또는 'system')
165
+ * @param {string|null} transcription - 음성 인식 텍스트 (선택 사항)
166
+ * @param {Array|null} sources - 소스 정보 배열 (선택 사항)
167
+ */
168
+ function addMessage(text, sender, transcription = null, sources = null) {
169
+ console.log(`메시지 추가: ${sender}`);
170
+
171
+ const messageDiv = document.createElement('div');
172
+ messageDiv.classList.add('message', sender);
173
+
174
+ const contentDiv = document.createElement('div');
175
+ contentDiv.classList.add('message-content');
176
+
177
+ // 음성 인식 텍스트 추가 (있는 경우)
178
+ if (transcription && sender === 'bot') {
179
+ const transcriptionP = document.createElement('p');
180
+ transcriptionP.classList.add('transcription');
181
+ transcriptionP.textContent = `"${transcription}"`;
182
+ contentDiv.appendChild(transcriptionP);
183
+ }
184
+
185
+ // 메시지 텍스트 추가
186
+ const textP = document.createElement('p');
187
+ textP.textContent = text;
188
+ contentDiv.appendChild(textP);
189
+
190
+ // 소스 정보 추가 (있는 경우)
191
+ if (sources && sources.length > 0 && sender === 'bot') {
192
+ const sourcesDiv = document.createElement('div');
193
+ sourcesDiv.classList.add('sources');
194
+
195
+ const sourcesTitle = document.createElement('strong');
196
+ sourcesTitle.textContent = '출처: ';
197
+ sourcesDiv.appendChild(sourcesTitle);
198
+
199
+ sources.forEach((source, index) => {
200
+ if (index < 3) { // 최대 3개까지만 표시
201
+ const sourceSpan = document.createElement('span');
202
+ sourceSpan.classList.add('source-item');
203
+ sourceSpan.textContent = source.source;
204
+ sourcesDiv.appendChild(sourceSpan);
205
+ }
206
+ });
207
+
208
+ contentDiv.appendChild(sourcesDiv);
209
+ }
210
+
211
+ messageDiv.appendChild(contentDiv);
212
+ chatMessages.appendChild(messageDiv);
213
+
214
+ // 스크롤을 가장 아래로 이동
215
+ chatMessages.scrollTop = chatMessages.scrollHeight;
216
+ }
217
+
218
+ /**
219
+ * 로딩 메시지 추가 함수
220
+ * @returns {string} 로딩 메시지 ID
221
+ */
222
+ function addLoadingMessage() {
223
+ console.log('로딩 메시지 추가');
224
+
225
+ const id = 'loading-' + Date.now();
226
+ const messageDiv = document.createElement('div');
227
+ messageDiv.classList.add('message', 'bot');
228
+ messageDiv.id = id;
229
+
230
+ const contentDiv = document.createElement('div');
231
+ contentDiv.classList.add('message-content');
232
+
233
+ const loadingP = document.createElement('p');
234
+ loadingP.innerHTML = '<div class="spinner" style="width: 20px; height: 20px; display: inline-block; margin-right: 10px;"></div> 생각 중...';
235
+ contentDiv.appendChild(loadingP);
236
+
237
+ messageDiv.appendChild(contentDiv);
238
+ chatMessages.appendChild(messageDiv);
239
+
240
+ // 스크롤을 가장 아래로 이동
241
+ chatMessages.scrollTop = chatMessages.scrollHeight;
242
+
243
+ return id;
244
+ }
245
+
246
+ /**
247
+ * 로딩 메시지 제거 함수
248
+ * @param {string} id - 로딩 메시지 ID
249
+ */
250
+ function removeLoadingMessage(id) {
251
+ console.log(`로딩 메시지 제거: ${id}`);
252
+
253
+ const loadingMessage = document.getElementById(id);
254
+ if (loadingMessage) {
255
+ loadingMessage.remove();
256
+ }
257
+ }
258
+
259
+ /**
260
+ * 오류 메시지 추가 함수
261
+ * @param {string} errorText - 오류 메시지 내용
262
+ */
263
+ function addErrorMessage(errorText) {
264
+ console.log(`오류 메시지 추가: ${errorText}`);
265
+
266
+ const messageDiv = document.createElement('div');
267
+ messageDiv.classList.add('message', 'system');
268
+
269
+ const contentDiv = document.createElement('div');
270
+ contentDiv.classList.add('message-content');
271
+ contentDiv.style.backgroundColor = 'rgba(239, 68, 68, 0.1)';
272
+ contentDiv.style.color = 'var(--error-color)';
273
+
274
+ const errorP = document.createElement('p');
275
+ errorP.innerHTML = `<i class="fas fa-exclamation-circle"></i> ${errorText}`;
276
+ contentDiv.appendChild(errorP);
277
+
278
+ messageDiv.appendChild(contentDiv);
279
+ chatMessages.appendChild(messageDiv);
280
+
281
+ // 스크롤을 가장 아래로 이동
282
+ chatMessages.scrollTop = chatMessages.scrollHeight;
283
+ }
284
+
285
+ /**
286
+ * textarea 높이 자동 조정 함수
287
+ */
288
+ function adjustTextareaHeight() {
289
+ userInput.style.height = 'auto';
290
+ userInput.style.height = Math.min(userInput.scrollHeight, 100) + 'px';
291
+ }
292
+
293
+ // 앱 초기화 (페이지 로드 시)
294
+ document.addEventListener('DOMContentLoaded', function() {
295
+ console.log('페이지 로드 완료, 앱 초기화 시작');
296
+
297
+ // DOM 요소 초기화
298
+ initDomElements();
299
+
300
+ // 이벤트 리스너 초기화
301
+ initEventListeners();
302
+
303
+ // 앱 상태 확인 (로딩 페이지가 아닌 경우에만)
304
+ if (window.location.pathname === '/' && !document.getElementById('app-loading-indicator')) {
305
+ // 앱 상태 주기적으로 확인
306
+ const statusInterval = setInterval(async () => {
307
+ const isReady = await checkAppStatus();
308
+ if (isReady) {
309
+ clearInterval(statusInterval);
310
+ console.log('앱이 준비되었습니다.');
311
+
312
+ // 앱이 준비되면 LLM 목록 로드
313
+ loadLLMs();
314
+
315
+ // 활성 탭 확인 및 데이터 로드
316
+ if (docsSection.classList.contains('active')) {
317
+ loadDocuments();
318
+ } else if (deviceSection.classList.contains('active')) {
319
+ loadDeviceStatus();
320
+ }
321
+ }
322
+ }, 5000);
323
+ }
324
+
325
+ console.log('앱 초기화 완료');
326
+ });
app/static/js/app-device.js ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 장치 관리 JavaScript
3
+ */
4
+
5
+ // DOM 요소
6
+ const deviceTab = document.getElementById('deviceTab');
7
+ const deviceSection = document.getElementById('deviceSection');
8
+ const checkDeviceStatusButton = document.getElementById('checkDeviceStatusButton');
9
+ const deviceStatusLoading = document.getElementById('deviceStatusLoading');
10
+ const deviceStatusResult = document.getElementById('deviceStatusResult');
11
+ const statusIcon = document.getElementById('statusIcon');
12
+ const statusText = document.getElementById('statusText');
13
+ const refreshDevicesButton = document.getElementById('refreshDevicesButton');
14
+ const deviceList = document.getElementById('deviceList');
15
+ const devicesLoading = document.getElementById('devicesLoading');
16
+ const noDevicesMessage = document.getElementById('noDevicesMessage');
17
+ const loadProgramsButton = document.getElementById('loadProgramsButton');
18
+ const programsLoading = document.getElementById('programsLoading');
19
+ const programsList = document.getElementById('programsList');
20
+ const noProgramsMessage = document.getElementById('noProgramsMessage');
21
+
22
+ // 페이지 로드 시 초기화
23
+ document.addEventListener('DOMContentLoaded', () => {
24
+ console.log("장치 관리 모듈 초기화");
25
+
26
+ // 탭 전환 이벤트 리스너 추가
27
+ deviceTab.addEventListener('click', () => {
28
+ console.log("장치 관리 탭 클릭");
29
+ switchTab('device');
30
+ checkDeviceStatus(); // 탭 전환 시 자동으로 상태 확인
31
+ });
32
+
33
+ // 장치 상태 확인 버튼 이벤트 리스너
34
+ checkDeviceStatusButton.addEventListener('click', () => {
35
+ console.log("장치 상태 확인 버튼 클릭");
36
+ checkDeviceStatus();
37
+ });
38
+
39
+ // 장치 목록 새로고침 버튼 이벤트 리스너
40
+ refreshDevicesButton.addEventListener('click', () => {
41
+ console.log("장치 목록 새로고침 버튼 클릭");
42
+ loadDevices();
43
+ });
44
+
45
+ // 프로그램 목록 로드 버튼 이벤트 리스너
46
+ loadProgramsButton.addEventListener('click', () => {
47
+ console.log("프로그램 목록 로드 버튼 클릭");
48
+ loadPrograms();
49
+ });
50
+ });
51
+
52
+ /**
53
+ * 탭 전환 함수 (기존 app.js의 함수 확장)
54
+ * @param {string} tabName - 활성화할 탭 이름 ('chat', 'docs', 'device')
55
+ */
56
+ function switchTab(tabName) {
57
+ // app.js에 이미 정의된 함수를 참조
58
+ if (typeof window.switchTab === 'function') {
59
+ // 기존 switchTab 함수 호출
60
+ window.switchTab(tabName);
61
+ return;
62
+ }
63
+
64
+ // 기존 함수가 없는 경우를 대비한 기본 구현
65
+ const chatTab = document.getElementById('chatTab');
66
+ const docsTab = document.getElementById('docsTab');
67
+ const chatSection = document.getElementById('chatSection');
68
+ const docsSection = document.getElementById('docsSection');
69
+
70
+ if (tabName === 'chat') {
71
+ chatTab.classList.add('active');
72
+ docsTab.classList.remove('active');
73
+ deviceTab.classList.remove('active');
74
+ chatSection.classList.add('active');
75
+ docsSection.classList.remove('active');
76
+ deviceSection.classList.remove('active');
77
+ } else if (tabName === 'docs') {
78
+ chatTab.classList.remove('active');
79
+ docsTab.classList.add('active');
80
+ deviceTab.classList.remove('active');
81
+ chatSection.classList.remove('active');
82
+ docsSection.classList.add('active');
83
+ deviceSection.classList.remove('active');
84
+ } else if (tabName === 'device') {
85
+ chatTab.classList.remove('active');
86
+ docsTab.classList.remove('active');
87
+ deviceTab.classList.add('active');
88
+ chatSection.classList.remove('active');
89
+ docsSection.classList.remove('active');
90
+ deviceSection.classList.add('active');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 에러 처리 헬퍼 함수
96
+ * @param {Error} error - 발생한 오류
97
+ * @returns {string} - 사용자에게 표시할 오류 메시지
98
+ */
99
+ function handleError(error) {
100
+ console.error("오류 발생:", error);
101
+
102
+ if (error.name === 'AbortError') {
103
+ return '요청 시간이 초과되었습니다. 서버가 응답하지 않습니다.';
104
+ }
105
+
106
+ if (error.message && (error.message.includes('NetworkError') || error.message.includes('Failed to fetch'))) {
107
+ return '네트워크 오류가 발생했습니다. 서버에 연결할 수 없습니다.';
108
+ }
109
+
110
+ return `오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`;
111
+ }
112
+
113
+ /**
114
+ * HTML 이스케이프 함수 (XSS 방지)
115
+ * @param {string} unsafe - 이스케이프 전 문자열
116
+ * @returns {string} - 이스케이프 후 문자열
117
+ */
118
+ function escapeHtml(unsafe) {
119
+ if (typeof unsafe !== 'string') return unsafe;
120
+ return unsafe
121
+ .replace(/&/g, "&amp;")
122
+ .replace(/</g, "&lt;")
123
+ .replace(/>/g, "&gt;")
124
+ .replace(/"/g, "&quot;")
125
+ .replace(/'/g, "&#039;");
126
+ }
127
+
128
+ /**
129
+ * 장치 관리 서버 상태 확인 함수
130
+ */
131
+ async function checkDeviceStatus() {
132
+ console.log("장치 상태 확인 중...");
133
+
134
+ // UI 업데이트
135
+ deviceStatusResult.classList.add('hidden');
136
+ deviceStatusLoading.classList.remove('hidden');
137
+
138
+ try {
139
+ // 타임아웃 설정을 위한 컨트롤러
140
+ const controller = new AbortController();
141
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
142
+
143
+ // API 요청
144
+ const response = await fetch('/api/device/status', {
145
+ signal: controller.signal
146
+ });
147
+
148
+ clearTimeout(timeoutId); // 타임아웃 해제
149
+
150
+ // 응답 처리
151
+ if (!response.ok) {
152
+ throw new Error(`HTTP 오류: ${response.status}`);
153
+ }
154
+
155
+ const data = await response.json();
156
+ console.log("장치 상태 응답:", data);
157
+
158
+ // UI 업데이트
159
+ deviceStatusLoading.classList.add('hidden');
160
+ deviceStatusResult.classList.remove('hidden');
161
+
162
+ if (data.success) {
163
+ // 온라인 상태인 경우
164
+ statusIcon.innerHTML = '<i class="fas fa-circle online"></i>';
165
+ statusText.textContent = `서버 상태: ${data.server_status || '정상'}`;
166
+
167
+ // 자동으로 장치 목록 로드
168
+ loadDevices();
169
+ } else {
170
+ // 오프라인 또는 오류 상태인 경우
171
+ statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
172
+ statusText.textContent = `서버 오류: ${data.error || '알 수 없는 오류'}`;
173
+ }
174
+ } catch (error) {
175
+ console.error("장치 상태 확인 오류:", error);
176
+
177
+ // UI 업데이트
178
+ deviceStatusLoading.classList.add('hidden');
179
+ deviceStatusResult.classList.remove('hidden');
180
+
181
+ statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
182
+ statusText.textContent = handleError(error);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 장치 목록 로드 함수
188
+ */
189
+ async function loadDevices() {
190
+ console.log("장치 목록 로드 중...");
191
+
192
+ // UI 업데이트
193
+ deviceList.innerHTML = '';
194
+ noDevicesMessage.classList.add('hidden');
195
+ devicesLoading.classList.remove('hidden');
196
+
197
+ try {
198
+ // 타임아웃 설정을 위한 컨트롤러
199
+ const controller = new AbortController();
200
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
201
+
202
+ // API 요청
203
+ const response = await fetch('/api/device/list', {
204
+ signal: controller.signal
205
+ });
206
+
207
+ clearTimeout(timeoutId); // 타임아웃 해제
208
+
209
+ // 응답 처리
210
+ if (!response.ok) {
211
+ throw new Error(`HTTP 오류: ${response.status}`);
212
+ }
213
+
214
+ const data = await response.json();
215
+ console.log("장치 목록 응답:", data);
216
+
217
+ // UI 업데이트
218
+ devicesLoading.classList.add('hidden');
219
+
220
+ if (data.success && data.devices && data.devices.length > 0) {
221
+ // 장치가 있는 경우
222
+ data.devices.forEach(device => {
223
+ const deviceItem = createDeviceItem(device);
224
+ deviceList.appendChild(deviceItem);
225
+ });
226
+ } else {
227
+ // 장치가 없는 경우
228
+ noDevicesMessage.classList.remove('hidden');
229
+
230
+ if (data.error) {
231
+ noDevicesMessage.querySelector('p').textContent = `오류: ${data.error}`;
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.error("장치 목록 로드 오류:", error);
236
+
237
+ // UI 업데이트
238
+ devicesLoading.classList.add('hidden');
239
+ noDevicesMessage.classList.remove('hidden');
240
+ noDevicesMessage.querySelector('p').textContent = handleError(error);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 장치 아이템 생성 함수
246
+ * @param {Object} device - 장치 정보 객체
247
+ * @returns {HTMLElement} - 장치 아이템 DOM 요소
248
+ */
249
+ function createDeviceItem(device) {
250
+ const deviceItem = document.createElement('div');
251
+ deviceItem.classList.add('device-item');
252
+
253
+ // 상태에 따른 클래스 추가
254
+ if (device.status === 'online' || device.status === '온라인') {
255
+ deviceItem.classList.add('online');
256
+ } else if (device.status === 'offline' || device.status === '오프라인') {
257
+ deviceItem.classList.add('offline');
258
+ } else if (device.status === 'warning' || device.status === '경고') {
259
+ deviceItem.classList.add('warning');
260
+ }
261
+
262
+ // 장치 헤더 (이름 및 상태)
263
+ const deviceHeader = document.createElement('div');
264
+ deviceHeader.classList.add('device-item-header');
265
+
266
+ const deviceName = document.createElement('div');
267
+ deviceName.classList.add('device-name');
268
+ deviceName.textContent = device.name || '알 수 없는 장치';
269
+
270
+ const deviceStatusBadge = document.createElement('div');
271
+ deviceStatusBadge.classList.add('device-status-badge');
272
+
273
+ // 상태에 따른 배지 설정
274
+ if (device.status === 'online' || device.status === '온라인') {
275
+ deviceStatusBadge.classList.add('online');
276
+ deviceStatusBadge.textContent = '온라인';
277
+ } else if (device.status === 'offline' || device.status === '오프라인') {
278
+ deviceStatusBadge.classList.add('offline');
279
+ deviceStatusBadge.textContent = '오프라인';
280
+ } else if (device.status === 'warning' || device.status === '경고') {
281
+ deviceStatusBadge.classList.add('warning');
282
+ deviceStatusBadge.textContent = '경고';
283
+ } else {
284
+ deviceStatusBadge.textContent = device.status || '알 수 없음';
285
+ }
286
+
287
+ deviceHeader.appendChild(deviceName);
288
+ deviceHeader.appendChild(deviceStatusBadge);
289
+
290
+ // 장치 정보
291
+ const deviceInfo = document.createElement('div');
292
+ deviceInfo.classList.add('device-info');
293
+ deviceInfo.textContent = `유형: ${device.type || '알 수 없음'}`;
294
+
295
+ // 장치 세부 정보
296
+ const deviceDetails = document.createElement('div');
297
+ deviceDetails.classList.add('device-details');
298
+
299
+ // 추가 정보가 있는 경우 표시
300
+ if (device.ip) {
301
+ deviceDetails.textContent += `IP: ${device.ip}`;
302
+ }
303
+ if (device.mac) {
304
+ deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') + `MAC: ${device.mac}`;
305
+ }
306
+ if (device.lastSeen) {
307
+ deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') + `마지막 활동: ${device.lastSeen}`;
308
+ }
309
+
310
+ // 아이템에 요소 추가
311
+ deviceItem.appendChild(deviceHeader);
312
+ deviceItem.appendChild(deviceInfo);
313
+
314
+ if (deviceDetails.textContent) {
315
+ deviceItem.appendChild(deviceDetails);
316
+ }
317
+
318
+ return deviceItem;
319
+ }
320
+
321
+ /**
322
+ * 프로그램 목록 로드 함수
323
+ */
324
+ async function loadPrograms() {
325
+ console.log("프로그램 목록 로드 중...");
326
+
327
+ // UI 업데이트
328
+ programsList.innerHTML = '';
329
+ noProgramsMessage.classList.add('hidden');
330
+ programsLoading.classList.remove('hidden');
331
+
332
+ try {
333
+ // 타임아웃 설정을 위한 컨트롤러
334
+ const controller = new AbortController();
335
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
336
+
337
+ // API 요청
338
+ const response = await fetch('/api/device/programs', {
339
+ signal: controller.signal
340
+ });
341
+
342
+ clearTimeout(timeoutId); // 타임아웃 해제
343
+
344
+ // 응답 처리
345
+ if (!response.ok) {
346
+ throw new Error(`HTTP 오류: ${response.status}`);
347
+ }
348
+
349
+ const data = await response.json();
350
+ console.log("프로그램 목록 응답:", data);
351
+
352
+ // UI 업데이트
353
+ programsLoading.classList.add('hidden');
354
+
355
+ if (data.success && data.programs && data.programs.length > 0) {
356
+ // 프로그램이 있는 경우
357
+ data.programs.forEach(program => {
358
+ const programItem = createProgramItem(program);
359
+ programsList.appendChild(programItem);
360
+ });
361
+ noProgramsMessage.classList.add('hidden');
362
+ } else {
363
+ // 프로그램이 없는 경우
364
+ noProgramsMessage.classList.remove('hidden');
365
+
366
+ if (data.error) {
367
+ noProgramsMessage.querySelector('p').textContent = `오류: ${data.error}`;
368
+ }
369
+ }
370
+ } catch (error) {
371
+ console.error("프로그램 목록 로드 오류:", error);
372
+
373
+ // UI 업데이트
374
+ programsLoading.classList.add('hidden');
375
+ noProgramsMessage.classList.remove('hidden');
376
+ noProgramsMessage.querySelector('p').textContent = handleError(error);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 프로그램 아이템 생성 함수
382
+ * @param {Object} program - 프로그램 정보 객체
383
+ * @returns {HTMLElement} - 프로그램 아이템 DOM 요소
384
+ */
385
+ function createProgramItem(program) {
386
+ const programItem = document.createElement('div');
387
+ programItem.classList.add('program-item');
388
+
389
+ // 프로그램 헤더 (이름)
390
+ const programHeader = document.createElement('div');
391
+ programHeader.classList.add('program-item-header');
392
+
393
+ const programName = document.createElement('div');
394
+ programName.classList.add('program-name');
395
+ programName.textContent = program.name || '알 수 없는 프로그램';
396
+
397
+ programHeader.appendChild(programName);
398
+
399
+ // 프로그램 설명
400
+ if (program.description) {
401
+ const programDescription = document.createElement('div');
402
+ programDescription.classList.add('program-description');
403
+ programDescription.textContent = program.description;
404
+ programItem.appendChild(programDescription);
405
+ }
406
+
407
+ // 실행 버튼
408
+ const executeButton = document.createElement('button');
409
+ executeButton.classList.add('execute-btn');
410
+ executeButton.textContent = '실행';
411
+ executeButton.addEventListener('click', () => {
412
+ executeProgram(program.id, program.name);
413
+ });
414
+
415
+ // 아이템에 요소 추가
416
+ programItem.appendChild(programHeader);
417
+ programItem.appendChild(executeButton);
418
+
419
+ return programItem;
420
+ }
421
+
422
+ /**
423
+ * 프로그램 실행 함수
424
+ * @param {string} programId - 프로그램 ID
425
+ * @param {string} programName - 프로그램 이름
426
+ */
427
+ async function executeProgram(programId, programName) {
428
+ console.log(`프로그램 실행: ${programName} (ID: ${programId})`);
429
+
430
+ // 실행 확인
431
+ if (!confirm(`'${programName}' 프로그램을 실행하시겠습니까?`)) {
432
+ return;
433
+ }
434
+
435
+ try {
436
+ // 타임아웃 설정을 위한 컨트롤러
437
+ const controller = new AbortController();
438
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10초 타임아웃 (실행에는 더 긴 시간 부여)
439
+
440
+ // API 요청
441
+ const response = await fetch(`/api/device/programs/${programId}/execute`, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/json'
445
+ },
446
+ body: JSON.stringify({}),
447
+ signal: controller.signal
448
+ });
449
+
450
+ clearTimeout(timeoutId); // 타임아웃 해제
451
+
452
+ // 응답 처리
453
+ if (!response.ok) {
454
+ const errorData = await response.json();
455
+ throw new Error(errorData.error || `HTTP 오류: ${response.status}`);
456
+ }
457
+
458
+ const data = await response.json();
459
+ console.log("프로그램 실행 응답:", data);
460
+
461
+ // 성공 메시지
462
+ if (data.success) {
463
+ showNotification(`'${programName}' 실행이 요청되었습니다.`, 'success');
464
+ } else {
465
+ showNotification(`'${programName}' 실행 요청 실패: ${data.error || '알 수 없는 오류'}`, 'error');
466
+ }
467
+ } catch (error) {
468
+ console.error(`프로그램 실행 오류 (${programName}):`, error);
469
+ showNotification(`'${programName}' 실행 중 오류 발생: ${handleError(error)}`, 'error');
470
+ }
471
+ }
472
+
473
+ /**
474
+ * 알림 표시 함수
475
+ * @param {string} message - 알림 메시지
476
+ * @param {string} type - 알림 유형 ('success', 'error', 'warning')
477
+ */
478
+ function showNotification(message, type = 'info') {
479
+ // 기존 알림이 있으면 제거
480
+ const existingNotification = document.querySelector('.notification');
481
+ if (existingNotification) {
482
+ existingNotification.remove();
483
+ }
484
+
485
+ // 새 알림 생성
486
+ const notification = document.createElement('div');
487
+ notification.classList.add('notification', type);
488
+ notification.textContent = message;
489
+
490
+ // 알림 닫기 버튼
491
+ const closeButton = document.createElement('button');
492
+ closeButton.classList.add('notification-close');
493
+ closeButton.innerHTML = '&times;';
494
+ closeButton.addEventListener('click', () => {
495
+ notification.remove();
496
+ });
497
+
498
+ notification.appendChild(closeButton);
499
+
500
+ // 문서에 알림 추가
501
+ document.body.appendChild(notification);
502
+
503
+ // 일정 시간 후 자동으로 사라지도록 설정
504
+ setTimeout(() => {
505
+ if (document.body.contains(notification)) {
506
+ notification.remove();
507
+ }
508
+ }, 5000); // 5초 후 사라짐
509
+ }
510
+
511
+ // 앱 전역에서 switchTab 함수가 사용 가능하도록 export
512
+ window.deviceSwitchTab = switchTab;
app/static/js/app-docs.js ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 문서 관리 JavaScript
3
+ */
4
+
5
+ // DOM 요소 미리 선언
6
+ let uploadForm, documentFile, fileName, uploadButton, uploadStatus;
7
+ let refreshDocsButton, docsList, docsLoading, noDocsMessage;
8
+
9
+ /**
10
+ * 문서 관리 DOM 요소 초기화
11
+ */
12
+ function initDocsElements() {
13
+ console.log('문서 관리 DOM 요소 초기화 중...');
14
+
15
+ uploadForm = document.getElementById('uploadForm');
16
+ documentFile = document.getElementById('documentFile');
17
+ fileName = document.getElementById('fileName');
18
+ uploadButton = document.getElementById('uploadButton');
19
+ uploadStatus = document.getElementById('uploadStatus');
20
+ refreshDocsButton = document.getElementById('refreshDocsButton');
21
+ docsList = document.getElementById('docsList');
22
+ docsLoading = document.getElementById('docsLoading');
23
+ noDocsMessage = document.getElementById('noDocsMessage');
24
+
25
+ console.log('문서 관리 DOM 요소 초기화 완료');
26
+
27
+ // 문서 관리 이벤트 리스너 초기화
28
+ initDocsEventListeners();
29
+ }
30
+
31
+ /**
32
+ * 문서 관리 이벤트 리스너 초기화
33
+ */
34
+ function initDocsEventListeners() {
35
+ console.log('문서 관리 이벤트 리스너 초기화 중...');
36
+
37
+ // 문서 업로드 이벤트 리스너
38
+ documentFile.addEventListener('change', (event) => {
39
+ if (event.target.files.length > 0) {
40
+ fileName.textContent = event.target.files[0].name;
41
+ console.log(`파일 선택됨: ${event.target.files[0].name}`);
42
+ } else {
43
+ fileName.textContent = '선택된 파일 없음';
44
+ console.log('파일 선택 취소됨');
45
+ }
46
+ });
47
+
48
+ uploadForm.addEventListener('submit', (event) => {
49
+ event.preventDefault();
50
+ console.log('업로드 폼 제출됨');
51
+ uploadDocument();
52
+ });
53
+
54
+ // 문서 목록 새로고침 이벤트 리스너
55
+ refreshDocsButton.addEventListener('click', () => {
56
+ console.log('문서 목록 새로고침 요청');
57
+ loadDocuments();
58
+ });
59
+
60
+ console.log('문서 관리 이벤트 리스너 초기화 완료');
61
+ }
62
+
63
+ /**
64
+ * 문서 업로드 함수
65
+ */
66
+ async function uploadDocument() {
67
+ if (documentFile.files.length === 0) {
68
+ console.log('업로드할 파일이 선택되지 않음');
69
+ alert('파일을 선택해 주세요.');
70
+ return;
71
+ }
72
+
73
+ console.log(`파일 업로드 시작: ${documentFile.files[0].name}`);
74
+
75
+ // UI 업데이트
76
+ uploadStatus.classList.remove('hidden');
77
+ uploadStatus.className = 'upload-status';
78
+ uploadStatus.innerHTML = '<div class="spinner"></div><p>업로드 중...</p>';
79
+ uploadButton.disabled = true;
80
+
81
+ try {
82
+ const formData = new FormData();
83
+ formData.append('document', documentFile.files[0]);
84
+
85
+ console.log('문서 업로드 API 요청 전송');
86
+ // API 요청
87
+ const response = await fetch('/api/upload', {
88
+ method: 'POST',
89
+ body: formData
90
+ });
91
+
92
+ const data = await response.json();
93
+ console.log('문서 업로드 API 응답 수신');
94
+
95
+ // 응답 처리
96
+ if (data.error) {
97
+ console.error('업로드 오류:', data.error);
98
+ uploadStatus.className = 'upload-status error';
99
+ uploadStatus.textContent = `오류: ${data.error}`;
100
+ } else if (data.warning) {
101
+ console.warn('업로드 경고:', data.message);
102
+ uploadStatus.className = 'upload-status warning';
103
+ uploadStatus.textContent = data.message;
104
+ } else {
105
+ console.log('업로드 성공:', data.message);
106
+ uploadStatus.className = 'upload-status success';
107
+ uploadStatus.textContent = data.message;
108
+
109
+ // 문서 목록 새로고침
110
+ loadDocuments();
111
+
112
+ // 입력 필드 초기화
113
+ documentFile.value = '';
114
+ fileName.textContent = '선택된 파일 없음';
115
+ }
116
+ } catch (error) {
117
+ console.error('업로드 처리 중 오류:', error);
118
+ uploadStatus.className = 'upload-status error';
119
+ uploadStatus.textContent = '업로드 중 오류가 발생했습니다. 다시 시도해 주세요.';
120
+ } finally {
121
+ uploadButton.disabled = false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * 문서 목록 로드 함수
127
+ */
128
+ async function loadDocuments() {
129
+ console.log('문서 목록 로드 시작');
130
+
131
+ // UI 업데이트
132
+ docsList.querySelector('tbody').innerHTML = '';
133
+ docsLoading.classList.remove('hidden');
134
+ noDocsMessage.classList.add('hidden');
135
+
136
+ try {
137
+ console.log('문서 목록 API 요청 전송');
138
+ // API 요청
139
+ const response = await fetch('/api/documents');
140
+
141
+ if (!response.ok) {
142
+ throw new Error(`HTTP error! status: ${response.status}`);
143
+ }
144
+
145
+ const data = await response.json();
146
+ console.log(`문서 목록 API 응답 수신: ${data.documents ? data.documents.length : 0}개 문서`);
147
+
148
+ // 응답 처리
149
+ docsLoading.classList.add('hidden');
150
+
151
+ if (!data.documents || data.documents.length === 0) {
152
+ console.log('로드된 문서가 없음');
153
+ noDocsMessage.classList.remove('hidden');
154
+ return;
155
+ }
156
+
157
+ // 문서 목록 업데이트
158
+ const tbody = docsList.querySelector('tbody');
159
+ data.documents.forEach(doc => {
160
+ console.log(`문서 표시: ${doc.filename || doc.source}`);
161
+
162
+ const row = document.createElement('tr');
163
+
164
+ const fileNameCell = document.createElement('td');
165
+ fileNameCell.textContent = doc.filename || doc.source;
166
+ row.appendChild(fileNameCell);
167
+
168
+ const chunksCell = document.createElement('td');
169
+ chunksCell.textContent = doc.chunks;
170
+ row.appendChild(chunksCell);
171
+
172
+ const typeCell = document.createElement('td');
173
+ typeCell.textContent = doc.filetype || '-';
174
+ row.appendChild(typeCell);
175
+
176
+ tbody.appendChild(row);
177
+ });
178
+
179
+ console.log('문서 목록 업데이트 완료');
180
+ } catch (error) {
181
+ console.error('문서 목록 로드 오류:', error);
182
+ docsLoading.classList.add('hidden');
183
+ noDocsMessage.classList.remove('hidden');
184
+ noDocsMessage.querySelector('p').textContent = '문서 목록을 불러오는 중 오류가 발생했습니다.';
185
+ }
186
+ }
187
+
188
+ // 페이지 로드 시 모듈 초기화
189
+ document.addEventListener('DOMContentLoaded', function() {
190
+ console.log('문서 관리 모듈 초기화');
191
+
192
+ // 비동기적으로 초기화 (DOM 요소가 준비된 후)
193
+ setTimeout(() => {
194
+ initDocsElements();
195
+ }, 100);
196
+ });
app/static/js/app-llm.js ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 LLM 관련 JavaScript
3
+ */
4
+
5
+ /**
6
+ * LLM 목록 로드 함수
7
+ */
8
+ async function loadLLMs() {
9
+ try {
10
+ console.log('LLM 목록 로드 시작');
11
+
12
+ // API 요청
13
+ const response = await fetch('/api/llm');
14
+
15
+ if (!response.ok) {
16
+ throw new Error(`HTTP error! status: ${response.status}`);
17
+ }
18
+
19
+ const data = await response.json();
20
+ supportedLLMs = data.supported_llms;
21
+ currentLLM = data.current_llm.id;
22
+
23
+ console.log(`로드된 LLM 수: ${supportedLLMs.length}, 현재 LLM: ${currentLLM}`);
24
+
25
+ // LLM 선택 드롭다운 업데이트
26
+ llmSelect.innerHTML = '';
27
+ supportedLLMs.forEach(llm => {
28
+ const option = document.createElement('option');
29
+ option.value = llm.id;
30
+ option.textContent = llm.name;
31
+ option.selected = llm.current;
32
+ llmSelect.appendChild(option);
33
+ });
34
+
35
+ // 현재 LLM 표시
36
+ updateCurrentLLMInfo(data.current_llm);
37
+ console.log('LLM 목록 로드 완료');
38
+ } catch (error) {
39
+ console.error('LLM 목록 로드 실패:', error);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * LLM 변경 함수
45
+ * @param {string} llmId - 변경할 LLM ID
46
+ */
47
+ async function changeLLM(llmId) {
48
+ try {
49
+ console.log(`LLM 변경 시작: ${llmId}`);
50
+
51
+ // API 요청
52
+ const response = await fetch('/api/llm', {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json'
56
+ },
57
+ body: JSON.stringify({ llm_id: llmId })
58
+ });
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`HTTP error! status: ${response.status}`);
62
+ }
63
+
64
+ const data = await response.json();
65
+
66
+ if (data.success) {
67
+ currentLLM = llmId;
68
+ updateCurrentLLMInfo(data.current_llm);
69
+ console.log(`LLM 변경 성공: ${data.current_llm.name}`);
70
+
71
+ // 시스템 메시지 추가
72
+ const systemMessage = `LLM이 ${data.current_llm.name}(으)로 변경되었습니다. 모델: ${data.current_llm.model}`;
73
+ addSystemNotification(systemMessage);
74
+ } else if (data.error) {
75
+ console.error('LLM 변경 오류:', data.error);
76
+ alert(`LLM 변경 오류: ${data.error}`);
77
+ }
78
+ } catch (error) {
79
+ console.error('LLM 변경 실패:', error);
80
+ alert('LLM 변경 중 오류가 발생했습니다.');
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 현재 LLM 정보 표시 업데이트
86
+ * @param {Object} llmInfo - LLM 정보 객체
87
+ */
88
+ function updateCurrentLLMInfo(llmInfo) {
89
+ console.log(`현재 LLM 정보 업데이트: ${llmInfo.name} (${llmInfo.model})`);
90
+
91
+ if (currentLLMInfo) {
92
+ currentLLMInfo.textContent = `${llmInfo.name} (${llmInfo.model})`;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 채팅 메시지 전송 함수
98
+ */
99
+ async function sendMessage() {
100
+ const message = userInput.value.trim();
101
+ if (!message) return;
102
+
103
+ console.log(`메시지 전송: "${message}"`);
104
+
105
+ // UI 업데이트
106
+ addMessage(message, 'user');
107
+ userInput.value = '';
108
+ adjustTextareaHeight();
109
+
110
+ // 로딩 메시지 추가
111
+ const loadingMessageId = addLoadingMessage();
112
+
113
+ try {
114
+ console.log('채팅 API 요청 시작');
115
+
116
+ // API 요청
117
+ const response = await fetch('/api/chat', {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json'
121
+ },
122
+ body: JSON.stringify({
123
+ query: message,
124
+ llm_id: currentLLM // 현재 선택된 LLM 전송
125
+ })
126
+ });
127
+
128
+ if (!response.ok) {
129
+ throw new Error(`HTTP error! status: ${response.status}`);
130
+ }
131
+
132
+ const data = await response.json();
133
+ console.log('채팅 API 응답 수신 완료');
134
+
135
+ // 로딩 메시지 제거
136
+ removeLoadingMessage(loadingMessageId);
137
+
138
+ // 응답 표시
139
+ if (data.error) {
140
+ console.error('채팅 응답 오류:', data.error);
141
+ addErrorMessage(data.error);
142
+ } else {
143
+ // LLM 정보 업데이트
144
+ if (data.llm) {
145
+ updateCurrentLLMInfo(data.llm);
146
+ }
147
+ console.log('봇 응답 표시');
148
+ addMessage(data.answer, 'bot', null, data.sources);
149
+ }
150
+ } catch (error) {
151
+ console.error('채팅 요청 오류:', error);
152
+ removeLoadingMessage(loadingMessageId);
153
+ addErrorMessage('오류가 발생했습니다. 다시 시도해 주세요.');
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 음성 녹음 시작 함수
159
+ */
160
+ async function startRecording() {
161
+ if (isRecording) return;
162
+
163
+ console.log('음성 녹음 시작 요청');
164
+
165
+ try {
166
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
167
+ isRecording = true;
168
+ audioChunks = [];
169
+
170
+ mediaRecorder = new MediaRecorder(stream);
171
+
172
+ mediaRecorder.addEventListener('dataavailable', (event) => {
173
+ if (event.data.size > 0) audioChunks.push(event.data);
174
+ console.log('오디오 데이터 청크 수신됨');
175
+ });
176
+
177
+ mediaRecorder.addEventListener('stop', sendAudioMessage);
178
+
179
+ // 녹음 시작
180
+ mediaRecorder.start();
181
+ console.log('MediaRecorder 시작됨');
182
+
183
+ // UI 업데이트
184
+ micButton.style.display = 'none';
185
+ recordingStatus.classList.remove('hidden');
186
+ } catch (error) {
187
+ console.error('음성 녹음 권한을 얻을 수 없습니다:', error);
188
+ alert('마이크 접근 권한이 필요합니다.');
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 음성 녹음 중지 함수
194
+ */
195
+ function stopRecording() {
196
+ if (!isRecording || !mediaRecorder) return;
197
+
198
+ console.log('음성 녹음 중지 요청');
199
+
200
+ mediaRecorder.stop();
201
+ isRecording = false;
202
+
203
+ // UI 업데이트
204
+ micButton.style.display = 'flex';
205
+ recordingStatus.classList.add('hidden');
206
+
207
+ console.log('MediaRecorder 중지됨');
208
+ }
209
+
210
+ /**
211
+ * 녹음된 오디오 메시지 전송 함수
212
+ */
213
+ async function sendAudioMessage() {
214
+ if (audioChunks.length === 0) return;
215
+
216
+ console.log(`오디오 메시지 전송 준비, ${audioChunks.length}개 청크`);
217
+
218
+ // 오디오 Blob 생성
219
+ const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
220
+
221
+ // 로딩 메시지 추가
222
+ const loadingMessageId = addLoadingMessage();
223
+
224
+ try {
225
+ // FormData에 오디오 추가
226
+ const formData = new FormData();
227
+ formData.append('audio', audioBlob, 'recording.wav');
228
+ // 현재 선택된 LLM 추가
229
+ formData.append('llm_id', currentLLM);
230
+
231
+ console.log('음성 API 요청 시작');
232
+ // API 요청
233
+ const response = await fetch('/api/voice', {
234
+ method: 'POST',
235
+ body: formData
236
+ });
237
+
238
+ if (!response.ok) {
239
+ throw new Error(`HTTP error! status: ${response.status}`);
240
+ }
241
+
242
+ const data = await response.json();
243
+ console.log('음성 API 응답 수신 완료');
244
+
245
+ // 로딩 메시지 제거
246
+ removeLoadingMessage(loadingMessageId);
247
+
248
+ // 응답 표시
249
+ if (data.error) {
250
+ console.error('음성 응답 오류:', data.error);
251
+ addErrorMessage(data.error);
252
+ } else {
253
+ // LLM 정보 업데이트
254
+ if (data.llm) {
255
+ updateCurrentLLMInfo(data.llm);
256
+ }
257
+
258
+ // 사용자 메시지(음성 텍스트) 추가
259
+ if (data.transcription) {
260
+ console.log(`음성 인식 결과: "${data.transcription}"`);
261
+ addMessage(data.transcription, 'user');
262
+ }
263
+
264
+ // 봇 응답 추가
265
+ console.log('봇 응답 표시');
266
+ addMessage(data.answer, 'bot', data.transcription, data.sources);
267
+ }
268
+ } catch (error) {
269
+ console.error('음성 요청 오류:', error);
270
+ removeLoadingMessage(loadingMessageId);
271
+ addErrorMessage('오디오 처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
272
+ }
273
+ }
app/static/js/app.js ADDED
@@ -0,0 +1,652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 UI JavaScript
3
+ */
4
+
5
+ // DOM 요소
6
+ const chatTab = document.getElementById('chatTab');
7
+ const docsTab = document.getElementById('docsTab');
8
+ const deviceTab = document.getElementById('deviceTab');
9
+ const chatSection = document.getElementById('chatSection');
10
+ const docsSection = document.getElementById('docsSection');
11
+ const deviceSection = document.getElementById('deviceSection');
12
+ const chatMessages = document.getElementById('chatMessages');
13
+ const userInput = document.getElementById('userInput');
14
+ const sendButton = document.getElementById('sendButton');
15
+ const micButton = document.getElementById('micButton');
16
+ const stopRecordingButton = document.getElementById('stopRecordingButton');
17
+ const recordingStatus = document.getElementById('recordingStatus');
18
+ const uploadForm = document.getElementById('uploadForm');
19
+ const documentFile = document.getElementById('documentFile');
20
+ const fileName = document.getElementById('fileName');
21
+ const uploadButton = document.getElementById('uploadButton');
22
+ const uploadStatus = document.getElementById('uploadStatus');
23
+ const refreshDocsButton = document.getElementById('refreshDocsButton');
24
+ const docsList = document.getElementById('docsList');
25
+ const docsLoading = document.getElementById('docsLoading');
26
+ const noDocsMessage = document.getElementById('noDocsMessage');
27
+ const llmSelect = document.getElementById('llmSelect');
28
+ const currentLLMInfo = document.getElementById('currentLLMInfo');
29
+
30
+ // LLM 관련 변수
31
+ let currentLLM = 'openai';
32
+ let supportedLLMs = [];
33
+
34
+ // 녹음 관련 변수
35
+ let mediaRecorder = null;
36
+ let audioChunks = [];
37
+ let isRecording = false;
38
+
39
+ // 앱 초기화 상태 확인 함수
40
+ async function checkAppStatus() {
41
+ try {
42
+ const response = await fetch('/api/status');
43
+ if (!response.ok) {
44
+ return false;
45
+ }
46
+ const data = await response.json();
47
+ return data.ready;
48
+ } catch (error) {
49
+ console.error('Status check failed:', error);
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * LLM 목록 로드 함수
56
+ */
57
+ async function loadLLMs() {
58
+ try {
59
+ // API 요청
60
+ const response = await fetch('/api/llm');
61
+
62
+ if (!response.ok) {
63
+ throw new Error(`HTTP error! status: ${response.status}`);
64
+ }
65
+
66
+ const data = await response.json();
67
+ supportedLLMs = data.supported_llms;
68
+ currentLLM = data.current_llm.id;
69
+
70
+ // LLM 선택 드롭다운 업데이트
71
+ llmSelect.innerHTML = '';
72
+ supportedLLMs.forEach(llm => {
73
+ const option = document.createElement('option');
74
+ option.value = llm.id;
75
+ option.textContent = llm.name;
76
+ option.selected = llm.current;
77
+ llmSelect.appendChild(option);
78
+ });
79
+
80
+ // 현재 LLM 표시
81
+ updateCurrentLLMInfo(data.current_llm);
82
+ } catch (error) {
83
+ console.error('LLM 목록 로드 실패:', error);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * LLM 변경 함수
89
+ * @param {string} llmId - 변경할 LLM ID
90
+ */
91
+ async function changeLLM(llmId) {
92
+ try {
93
+ // API 요청
94
+ const response = await fetch('/api/llm', {
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json'
98
+ },
99
+ body: JSON.stringify({ llm_id: llmId })
100
+ });
101
+
102
+ if (!response.ok) {
103
+ throw new Error(`HTTP error! status: ${response.status}`);
104
+ }
105
+
106
+ const data = await response.json();
107
+
108
+ if (data.success) {
109
+ currentLLM = llmId;
110
+ updateCurrentLLMInfo(data.current_llm);
111
+ console.log(`LLM이 ${data.current_llm.name}(으)로 변경되었습니다.`);
112
+
113
+ // 시스템 메시지 추가
114
+ const systemMessage = `LLM이 ${data.current_llm.name}(으)로 변경되었습니다. 모델: ${data.current_llm.model}`;
115
+ addSystemNotification(systemMessage);
116
+ } else if (data.error) {
117
+ console.error('LLM 변경 오류:', data.error);
118
+ alert(`LLM 변경 오류: ${data.error}`);
119
+ }
120
+ } catch (error) {
121
+ console.error('LLM 변경 실패:', error);
122
+ alert('LLM 변경 중 오류가 발생했습니다.');
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 현재 LLM 정보 표시 업데이트
128
+ * @param {Object} llmInfo - LLM 정보 객체
129
+ */
130
+ function updateCurrentLLMInfo(llmInfo) {
131
+ if (currentLLMInfo) {
132
+ currentLLMInfo.textContent = `${llmInfo.name} (${llmInfo.model})`;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 시스템 알림 메시지 추가
138
+ * @param {string} message - 알림 메시지
139
+ */
140
+ function addSystemNotification(message) {
141
+ const messageDiv = document.createElement('div');
142
+ messageDiv.classList.add('message', 'system');
143
+
144
+ const contentDiv = document.createElement('div');
145
+ contentDiv.classList.add('message-content');
146
+
147
+ const messageP = document.createElement('p');
148
+ messageP.innerHTML = `<i class="fas fa-info-circle"></i> ${message}`;
149
+ contentDiv.appendChild(messageP);
150
+
151
+ messageDiv.appendChild(contentDiv);
152
+ chatMessages.appendChild(messageDiv);
153
+
154
+ // 스크롤을 가장 아래로 이동
155
+ chatMessages.scrollTop = chatMessages.scrollHeight;
156
+ }
157
+
158
+ // 페이지 로드 시 초기화
159
+ document.addEventListener('DOMContentLoaded', () => {
160
+ // 앱 상태 확인 (로딩 페이지가 아닌 경우에만)
161
+ if (window.location.pathname === '/' && !document.getElementById('app-loading-indicator')) {
162
+ // 앱 상태 주기적으로 확인
163
+ const statusInterval = setInterval(async () => {
164
+ const isReady = await checkAppStatus();
165
+ if (isReady) {
166
+ clearInterval(statusInterval);
167
+ console.log('앱이 준비되었습니다.');
168
+
169
+ // 앱이 준비되면 LLM 목록 로드
170
+ loadLLMs();
171
+ }
172
+ }, 5000);
173
+ }
174
+
175
+ // 탭 전환 이벤트 리스너
176
+ chatTab.addEventListener('click', () => {
177
+ switchTab('chat');
178
+ });
179
+
180
+ docsTab.addEventListener('click', () => {
181
+ switchTab('docs');
182
+ loadDocuments();
183
+ });
184
+
185
+ // LLM 선택 이벤트 리스너
186
+ llmSelect.addEventListener('change', (event) => {
187
+ changeLLM(event.target.value);
188
+ });
189
+
190
+ // 메시지 전송 이벤트 리스너
191
+ sendButton.addEventListener('click', sendMessage);
192
+ userInput.addEventListener('keydown', (event) => {
193
+ if (event.key === 'Enter' && !event.shiftKey) {
194
+ event.preventDefault();
195
+ sendMessage();
196
+ }
197
+ });
198
+
199
+ // 음성 인식 이벤트 리스너
200
+ micButton.addEventListener('click', startRecording);
201
+ stopRecordingButton.addEventListener('click', stopRecording);
202
+
203
+ // 문서 업로드 이벤트 리스너
204
+ documentFile.addEventListener('change', (event) => {
205
+ if (event.target.files.length > 0) {
206
+ fileName.textContent = event.target.files[0].name;
207
+ } else {
208
+ fileName.textContent = '선택된 파일 없음';
209
+ }
210
+ });
211
+
212
+ uploadForm.addEventListener('submit', (event) => {
213
+ event.preventDefault();
214
+ uploadDocument();
215
+ });
216
+
217
+ // 문서 목록 새로고침 이벤트 리스너
218
+ refreshDocsButton.addEventListener('click', loadDocuments);
219
+
220
+ // 자동 입력 필드 크기 조정
221
+ userInput.addEventListener('input', adjustTextareaHeight);
222
+
223
+ // 초기 문서 목록 로드
224
+ if (docsSection.classList.contains('active')) {
225
+ loadDocuments();
226
+ }
227
+ });
228
+
229
+ /**
230
+ * 탭 전환 함수
231
+ * @param {string} tabName - 활성화할 탭 이름 ('chat', 'docs', 'device')
232
+ */
233
+ function switchTab(tabName) {
234
+ if (tabName === 'chat') {
235
+ chatTab.classList.add('active');
236
+ docsTab.classList.remove('active');
237
+ deviceTab.classList.remove('active');
238
+ chatSection.classList.add('active');
239
+ docsSection.classList.remove('active');
240
+ deviceSection.classList.remove('active');
241
+ } else if (tabName === 'docs') {
242
+ chatTab.classList.remove('active');
243
+ docsTab.classList.add('active');
244
+ deviceTab.classList.remove('active');
245
+ chatSection.classList.remove('active');
246
+ docsSection.classList.add('active');
247
+ deviceSection.classList.remove('active');
248
+ } else if (tabName === 'device') {
249
+ chatTab.classList.remove('active');
250
+ docsTab.classList.remove('active');
251
+ deviceTab.classList.add('active');
252
+ chatSection.classList.remove('active');
253
+ docsSection.classList.remove('active');
254
+ deviceSection.classList.add('active');
255
+ }
256
+ }
257
+
258
+ /**
259
+ * 채팅 메시지 전송 함수
260
+ */
261
+ async function sendMessage() {
262
+ const message = userInput.value.trim();
263
+ if (!message) return;
264
+
265
+ // UI 업데이트
266
+ addMessage(message, 'user');
267
+ userInput.value = '';
268
+ adjustTextareaHeight();
269
+
270
+ // 로딩 메시지 추가
271
+ const loadingMessageId = addLoadingMessage();
272
+
273
+ try {
274
+ // API 요청
275
+ const response = await fetch('/api/chat', {
276
+ method: 'POST',
277
+ headers: {
278
+ 'Content-Type': 'application/json'
279
+ },
280
+ body: JSON.stringify({
281
+ query: message,
282
+ llm_id: currentLLM // 현재 선택된 LLM 전송
283
+ })
284
+ });
285
+
286
+ if (!response.ok) {
287
+ throw new Error(`HTTP error! status: ${response.status}`);
288
+ }
289
+
290
+ const data = await response.json();
291
+
292
+ // 로딩 메시지 제거
293
+ removeLoadingMessage(loadingMessageId);
294
+
295
+ // 응답 표시
296
+ if (data.error) {
297
+ addErrorMessage(data.error);
298
+ } else {
299
+ // LLM 정보 업데이트
300
+ if (data.llm) {
301
+ updateCurrentLLMInfo(data.llm);
302
+ }
303
+ addMessage(data.answer, 'bot', null, data.sources);
304
+ }
305
+ } catch (error) {
306
+ console.error('Error:', error);
307
+ removeLoadingMessage(loadingMessageId);
308
+ addErrorMessage('오류가 발생했습니다. 다시 시도해 주세요.');
309
+ }
310
+ }
311
+
312
+ /**
313
+ * 음성 녹음 시작 함수
314
+ */
315
+ async function startRecording() {
316
+ if (isRecording) return;
317
+
318
+ try {
319
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
320
+ isRecording = true;
321
+ audioChunks = [];
322
+
323
+ mediaRecorder = new MediaRecorder(stream);
324
+
325
+ mediaRecorder.addEventListener('dataavailable', (event) => {
326
+ if (event.data.size > 0) audioChunks.push(event.data);
327
+ });
328
+
329
+ mediaRecorder.addEventListener('stop', sendAudioMessage);
330
+
331
+ // 녹음 시작
332
+ mediaRecorder.start();
333
+
334
+ // UI 업데이트
335
+ micButton.style.display = 'none';
336
+ recordingStatus.classList.remove('hidden');
337
+
338
+ console.log('녹음 시작됨');
339
+ } catch (error) {
340
+ console.error('음성 녹음 권한을 얻을 수 없습니다:', error);
341
+ alert('마이크 접근 권한이 필요합니다.');
342
+ }
343
+ }
344
+
345
+ /**
346
+ * 음성 녹음 중지 함수
347
+ */
348
+ function stopRecording() {
349
+ if (!isRecording || !mediaRecorder) return;
350
+
351
+ mediaRecorder.stop();
352
+ isRecording = false;
353
+
354
+ // UI 업데이트
355
+ micButton.style.display = 'flex';
356
+ recordingStatus.classList.add('hidden');
357
+
358
+ console.log('녹음 중지됨');
359
+ }
360
+
361
+ /**
362
+ * 녹음된 오디오 메시지 전송 함수
363
+ */
364
+ async function sendAudioMessage() {
365
+ if (audioChunks.length === 0) return;
366
+
367
+ // 오디오 Blob 생성
368
+ const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
369
+
370
+ // 로딩 메시지 추가
371
+ const loadingMessageId = addLoadingMessage();
372
+
373
+ try {
374
+ // FormData에 오디오 추가
375
+ const formData = new FormData();
376
+ formData.append('audio', audioBlob, 'recording.wav');
377
+ // 현재 선택된 LLM 추가
378
+ formData.append('llm_id', currentLLM);
379
+
380
+ // API 요청
381
+ const response = await fetch('/api/voice', {
382
+ method: 'POST',
383
+ body: formData
384
+ });
385
+
386
+ if (!response.ok) {
387
+ throw new Error(`HTTP error! status: ${response.status}`);
388
+ }
389
+
390
+ const data = await response.json();
391
+
392
+ // 로딩 메시지 제거
393
+ removeLoadingMessage(loadingMessageId);
394
+
395
+ // 응답 표시
396
+ if (data.error) {
397
+ addErrorMessage(data.error);
398
+ } else {
399
+ // LLM 정보 업데이트
400
+ if (data.llm) {
401
+ updateCurrentLLMInfo(data.llm);
402
+ }
403
+
404
+ // 사용자 메시지(음성 텍스트) 추가
405
+ if (data.transcription) {
406
+ addMessage(data.transcription, 'user');
407
+ }
408
+
409
+ // 봇 응답 추가
410
+ addMessage(data.answer, 'bot', data.transcription, data.sources);
411
+ }
412
+ } catch (error) {
413
+ console.error('Error:', error);
414
+ removeLoadingMessage(loadingMessageId);
415
+ addErrorMessage('오디오 처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
416
+ }
417
+ }
418
+
419
+ /**
420
+ * 문서 업로드 함수
421
+ */
422
+ async function uploadDocument() {
423
+ if (documentFile.files.length === 0) {
424
+ alert('파일을 선택해 주세요.');
425
+ return;
426
+ }
427
+
428
+ // UI 업데이트
429
+ uploadStatus.classList.remove('hidden');
430
+ uploadStatus.className = 'upload-status';
431
+ uploadStatus.innerHTML = '<div class="spinner"></div><p>업로드 중...</p>';
432
+ uploadButton.disabled = true;
433
+
434
+ try {
435
+ const formData = new FormData();
436
+ formData.append('document', documentFile.files[0]);
437
+
438
+ // API 요청
439
+ const response = await fetch('/api/upload', {
440
+ method: 'POST',
441
+ body: formData
442
+ });
443
+
444
+ const data = await response.json();
445
+
446
+ // 응답 처리
447
+ if (data.error) {
448
+ uploadStatus.className = 'upload-status error';
449
+ uploadStatus.textContent = `오류: ${data.error}`;
450
+ } else if (data.warning) {
451
+ uploadStatus.className = 'upload-status warning';
452
+ uploadStatus.textContent = data.message;
453
+ } else {
454
+ uploadStatus.className = 'upload-status success';
455
+ uploadStatus.textContent = data.message;
456
+
457
+ // 문서 목록 새로고침
458
+ loadDocuments();
459
+
460
+ // 입력 필드 초기화
461
+ documentFile.value = '';
462
+ fileName.textContent = '선택된 파일 없음';
463
+ }
464
+ } catch (error) {
465
+ console.error('Error:', error);
466
+ uploadStatus.className = 'upload-status error';
467
+ uploadStatus.textContent = '업로드 중 오류가 발생했습니다. 다시 시도해 주세요.';
468
+ } finally {
469
+ uploadButton.disabled = false;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * 문서 목록 로드 함수
475
+ */
476
+ async function loadDocuments() {
477
+ // UI 업데이트
478
+ docsList.querySelector('tbody').innerHTML = '';
479
+ docsLoading.classList.remove('hidden');
480
+ noDocsMessage.classList.add('hidden');
481
+
482
+ try {
483
+ // API 요청
484
+ const response = await fetch('/api/documents');
485
+
486
+ if (!response.ok) {
487
+ throw new Error(`HTTP error! status: ${response.status}`);
488
+ }
489
+
490
+ const data = await response.json();
491
+
492
+ // 응답 처리
493
+ docsLoading.classList.add('hidden');
494
+
495
+ if (!data.documents || data.documents.length === 0) {
496
+ noDocsMessage.classList.remove('hidden');
497
+ return;
498
+ }
499
+
500
+ // 문서 목록 업데이트
501
+ const tbody = docsList.querySelector('tbody');
502
+ data.documents.forEach(doc => {
503
+ const row = document.createElement('tr');
504
+
505
+ const fileNameCell = document.createElement('td');
506
+ fileNameCell.textContent = doc.filename || doc.source;
507
+ row.appendChild(fileNameCell);
508
+
509
+ const chunksCell = document.createElement('td');
510
+ chunksCell.textContent = doc.chunks;
511
+ row.appendChild(chunksCell);
512
+
513
+ const typeCell = document.createElement('td');
514
+ typeCell.textContent = doc.filetype || '-';
515
+ row.appendChild(typeCell);
516
+
517
+ tbody.appendChild(row);
518
+ });
519
+ } catch (error) {
520
+ console.error('Error:', error);
521
+ docsLoading.classList.add('hidden');
522
+ noDocsMessage.classList.remove('hidden');
523
+ noDocsMessage.querySelector('p').textContent = '문서 목록을 불러오는 중 오류가 발생했습니다.';
524
+ }
525
+ }
526
+
527
+ /**
528
+ * 메시지 추가 함수
529
+ * @param {string} text - 메시지 내용
530
+ * @param {string} sender - 메시지 발신자 ('user' 또는 'bot' 또는 'system')
531
+ * @param {string|null} transcription - 음성 인식 텍스트 (선택 사항)
532
+ * @param {Array|null} sources - 소스 정보 배열 (선택 사항)
533
+ */
534
+ function addMessage(text, sender, transcription = null, sources = null) {
535
+ const messageDiv = document.createElement('div');
536
+ messageDiv.classList.add('message', sender);
537
+
538
+ const contentDiv = document.createElement('div');
539
+ contentDiv.classList.add('message-content');
540
+
541
+ // 음성 인식 텍스트 추가 (있는 경우)
542
+ if (transcription && sender === 'bot') {
543
+ const transcriptionP = document.createElement('p');
544
+ transcriptionP.classList.add('transcription');
545
+ transcriptionP.textContent = `"${transcription}"`;
546
+ contentDiv.appendChild(transcriptionP);
547
+ }
548
+
549
+ // 메시지 텍스트 추가
550
+ const textP = document.createElement('p');
551
+ textP.textContent = text;
552
+ contentDiv.appendChild(textP);
553
+
554
+ // 소스 정보 추가 (있는 경우)
555
+ if (sources && sources.length > 0 && sender === 'bot') {
556
+ const sourcesDiv = document.createElement('div');
557
+ sourcesDiv.classList.add('sources');
558
+
559
+ const sourcesTitle = document.createElement('strong');
560
+ sourcesTitle.textContent = '출처: ';
561
+ sourcesDiv.appendChild(sourcesTitle);
562
+
563
+ sources.forEach((source, index) => {
564
+ if (index < 3) { // 최대 3개까지만 표시
565
+ const sourceSpan = document.createElement('span');
566
+ sourceSpan.classList.add('source-item');
567
+ sourceSpan.textContent = source.source;
568
+ sourcesDiv.appendChild(sourceSpan);
569
+ }
570
+ });
571
+
572
+ contentDiv.appendChild(sourcesDiv);
573
+ }
574
+
575
+ messageDiv.appendChild(contentDiv);
576
+ chatMessages.appendChild(messageDiv);
577
+
578
+ // 스크롤을 가장 아래로 이동
579
+ chatMessages.scrollTop = chatMessages.scrollHeight;
580
+ }
581
+
582
+ /**
583
+ * 로딩 메시지 추가 함수
584
+ * @returns {string} 로딩 메시지 ID
585
+ */
586
+ function addLoadingMessage() {
587
+ const id = 'loading-' + Date.now();
588
+ const messageDiv = document.createElement('div');
589
+ messageDiv.classList.add('message', 'bot');
590
+ messageDiv.id = id;
591
+
592
+ const contentDiv = document.createElement('div');
593
+ contentDiv.classList.add('message-content');
594
+
595
+ const loadingP = document.createElement('p');
596
+ loadingP.innerHTML = '<div class="spinner" style="width: 20px; height: 20px; display: inline-block; margin-right: 10px;"></div> 생각 중...';
597
+ contentDiv.appendChild(loadingP);
598
+
599
+ messageDiv.appendChild(contentDiv);
600
+ chatMessages.appendChild(messageDiv);
601
+
602
+ // 스크롤을 가장 아래로 이동
603
+ chatMessages.scrollTop = chatMessages.scrollHeight;
604
+
605
+ return id;
606
+ }
607
+
608
+ /**
609
+ * 로딩 메시지 제거 함수
610
+ * @param {string} id - 로딩 메시지 ID
611
+ */
612
+ function removeLoadingMessage(id) {
613
+ const loadingMessage = document.getElementById(id);
614
+ if (loadingMessage) {
615
+ loadingMessage.remove();
616
+ }
617
+ }
618
+
619
+ /**
620
+ * 오류 메시지 추가 함수
621
+ * @param {string} errorText - 오류 메시지 내용
622
+ */
623
+ function addErrorMessage(errorText) {
624
+ const messageDiv = document.createElement('div');
625
+ messageDiv.classList.add('message', 'system');
626
+
627
+ const contentDiv = document.createElement('div');
628
+ contentDiv.classList.add('message-content');
629
+ contentDiv.style.backgroundColor = 'rgba(239, 68, 68, 0.1)';
630
+ contentDiv.style.color = 'var(--error-color)';
631
+
632
+ const errorP = document.createElement('p');
633
+ errorP.innerHTML = `<i class="fas fa-exclamation-circle"></i> ${errorText}`;
634
+ contentDiv.appendChild(errorP);
635
+
636
+ messageDiv.appendChild(contentDiv);
637
+ chatMessages.appendChild(messageDiv);
638
+
639
+ // 스크롤을 가장 아래로 이동
640
+ chatMessages.scrollTop = chatMessages.scrollHeight;
641
+ }
642
+
643
+ /**
644
+ * textarea 높이 자동 조정 함수
645
+ */
646
+ function adjustTextareaHeight() {
647
+ userInput.style.height = 'auto';
648
+ userInput.style.height = Math.min(userInput.scrollHeight, 100) + 'px';
649
+ }
650
+
651
+ // 앱 전역에서 switchTab 함수가 사용 가능하도록 export
652
+ window.switchTab = switchTab;
app/templates/index.html ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 검색 챗봇</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
9
+ </head>
10
+ <body>
11
+ <div class="container">
12
+ <header>
13
+ <h1>RAG 검색 챗봇</h1>
14
+ <div class="header-actions">
15
+ <div class="llm-selector">
16
+ <label for="llmSelect">LLM 선택:</label>
17
+ <select id="llmSelect">
18
+ <!-- 옵션은 JavaScript에서 동적으로 로드됩니다 -->
19
+ </select>
20
+ </div>
21
+ <div class="user-info">
22
+ <span>사용자: {% if session.username %}{{ session.username }}{% else %}손님{% endif %}</span>
23
+ <a href="{{ url_for('logout') }}" class="logout-button">
24
+ <i class="fas fa-sign-out-alt"></i> 로그아웃
25
+ </a>
26
+ </div>
27
+ </div>
28
+ <div class="tabs">
29
+ <button id="chatTab" class="tab active">대화</button>
30
+ <button id="docsTab" class="tab">문서관리</button>
31
+ <button id="deviceTab" class="tab">장치관리</button>
32
+ </div>
33
+ </header>
34
+
35
+ <main>
36
+ <!-- 대화 탭 -->
37
+ <section id="chatSection" class="tab-content active">
38
+ <div class="chat-container">
39
+ <div class="chat-messages" id="chatMessages">
40
+ <div class="message system">
41
+ <div class="message-content">
42
+ <p>안녕하세요! 지식베이스에 대해 궁금한 점을 물어보세요. 음성으로 질문하시려면 마이크 버튼을 누르세요.</p>
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="chat-input-container">
48
+ <textarea id="userInput" placeholder="메시지를 입력하세요..." rows="1"></textarea>
49
+ <button id="micButton" class="mic-button">
50
+ <i class="fas fa-microphone"></i>
51
+ </button>
52
+ <button id="sendButton" class="send-button">
53
+ <i class="fas fa-paper-plane"></i>
54
+ </button>
55
+ </div>
56
+
57
+ <div id="recordingStatus" class="recording-status hidden">
58
+ <div class="recording-indicator">
59
+ <div class="recording-pulse"></div>
60
+ </div>
61
+ <span>녹음 중...</span>
62
+ <button id="stopRecordingButton" class="stop-recording-button">
63
+ <i class="fas fa-stop"></i>
64
+ </button>
65
+ </div>
66
+ </div>
67
+ </section>
68
+
69
+ <!-- 문서관리 탭 -->
70
+ <section id="docsSection" class="tab-content">
71
+ <div class="docs-container">
72
+ <div class="upload-section">
73
+ <h2>문서 업로드</h2>
74
+ <p>지식베이스에 추가할 문서를 업로드하세요. (지원 형식: .txt, .md, .csv)</p>
75
+
76
+ <form id="uploadForm" enctype="multipart/form-data">
77
+ <div class="file-upload">
78
+ <input type="file" id="documentFile" name="document" accept=".txt,.md,.csv">
79
+ <label for="documentFile">파일 선택</label>
80
+ <span id="fileName">선택된 파일 없음</span>
81
+ </div>
82
+
83
+ <button type="submit" id="uploadButton" class="upload-button">
84
+ <i class="fas fa-upload"></i> 업로드
85
+ </button>
86
+ </form>
87
+
88
+ <div id="uploadStatus" class="upload-status hidden"></div>
89
+ </div>
90
+
91
+ <div class="docs-list-section">
92
+ <h2>문서 목록</h2>
93
+ <button id="refreshDocsButton" class="refresh-button">
94
+ <i class="fas fa-sync-alt"></i> 새로고침
95
+ </button>
96
+
97
+ <div class="docs-list-container">
98
+ <table id="docsList" class="docs-list">
99
+ <thead>
100
+ <tr>
101
+ <th>파일명</th>
102
+ <th>청크 수</th>
103
+ <th>유형</th>
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ <!-- 문서 목록이 여기에 동적으로 추가됩니다 -->
108
+ </tbody>
109
+ </table>
110
+
111
+ <div id="docsLoading" class="loading-indicator">
112
+ <div class="spinner"></div>
113
+ <p>문서 로딩 중...</p>
114
+ </div>
115
+
116
+ <div id="noDocsMessage" class="no-docs-message hidden">
117
+ <p>지식베이스에 등록된 문서가 없습니다. 문서를 업로드해 주세요.</p>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </section>
123
+
124
+ <!-- 장치관리 탭 -->
125
+ <section id="deviceSection" class="tab-content">
126
+ <div class="device-container">
127
+ <div class="device-status-section">
128
+ <h2>장치 상태</h2>
129
+ <button id="checkDeviceStatusButton" class="refresh-button">
130
+ <i class="fas fa-sync-alt"></i> 상태 확인
131
+ </button>
132
+
133
+ <div class="device-status-info" id="deviceStatusInfo">
134
+ <div id="deviceStatusLoading" class="loading-indicator hidden">
135
+ <div class="spinner"></div>
136
+ <p>장치 상태 확인 중...</p>
137
+ </div>
138
+ <div id="deviceStatusResult">
139
+ <div class="status-indicator">
140
+ <span id="statusIcon"><i class="fas fa-question-circle"></i></span>
141
+ <span id="statusText">상태 정보가 없습니다</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="device-list-section">
148
+ <h2>연결된 장치</h2>
149
+ <button id="refreshDevicesButton" class="refresh-button">
150
+ <i class="fas fa-sync-alt"></i> 새로고침
151
+ </button>
152
+
153
+ <div class="device-list-container">
154
+ <div id="deviceList" class="device-list">
155
+ <!-- 장치 목록이 여기에 동적으로 추가됩니다 -->
156
+ </div>
157
+
158
+ <div id="devicesLoading" class="loading-indicator hidden">
159
+ <div class="spinner"></div>
160
+ <p>장치 목록 로딩 중...</p>
161
+ </div>
162
+
163
+ <div id="noDevicesMessage" class="no-devices-message">
164
+ <p>연결된 장치가 없습니다.</p>
165
+ </div>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="programs-section">
170
+ <h2>프로그램 실행</h2>
171
+ <button id="loadProgramsButton" class="refresh-button">
172
+ <i class="fas fa-list"></i> 프로그램 목록 가져오기
173
+ </button>
174
+
175
+ <div class="programs-container" id="programsContainer">
176
+ <div id="programsLoading" class="loading-indicator hidden">
177
+ <div class="spinner"></div>
178
+ <p>프로그램 목록 로딩 중...</p>
179
+ </div>
180
+
181
+ <div id="programsList" class="programs-list">
182
+ <!-- 프로그램 목록이 여기에 동적으로 추가됩니다 -->
183
+ </div>
184
+
185
+ <div id="noProgramsMessage" class="no-programs-message">
186
+ <p>실행 가능한 프로그램이 없습니다.</p>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </section>
192
+ </main>
193
+
194
+ <footer>
195
+ <p>© 2025 RAG 검색 챗봇 | OpenAI/DeepSeek LLM & VITO STT 활용</p>
196
+ <div class="current-llm">
197
+ <span>Current LLM: </span>
198
+ <span id="currentLLMInfo">-</span>
199
+ </div>
200
+ </footer>
201
+ </div>
202
+
203
+ <script src="{{ url_for('static', filename='js/app.js') }}"></script>
204
+ <script src="{{ url_for('static', filename='js/app-device.js') }}"></script>
205
+ </body>
206
+ </html>
app/templates/loading.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="refresh" content="5"> <!-- 5초마다 새로고침 -->
7
+ <title>RAG 검색 챗봇 - 초기화 중</title>
8
+ <style>
9
+ body {
10
+ font-family: 'Pretendard', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
11
+ line-height: 1.6;
12
+ color: #333;
13
+ background-color: #f8f9fa;
14
+ text-align: center;
15
+ padding-top: 100px;
16
+ margin: 0;
17
+ }
18
+ .container {
19
+ max-width: 800px;
20
+ margin: 0 auto;
21
+ padding: 20px;
22
+ }
23
+ h1 {
24
+ color: #4a6da7;
25
+ margin-bottom: 30px;
26
+ }
27
+ .loader {
28
+ border: 16px solid #f3f3f3;
29
+ border-top: 16px solid #4a6da7;
30
+ border-radius: 50%;
31
+ width: 80px;
32
+ height: 80px;
33
+ animation: spin 2s linear infinite;
34
+ margin: 0 auto 30px;
35
+ }
36
+ @keyframes spin {
37
+ 0% { transform: rotate(0deg); }
38
+ 100% { transform: rotate(360deg); }
39
+ }
40
+ p {
41
+ font-size: 18px;
42
+ margin-bottom: 20px;
43
+ }
44
+ .info {
45
+ background-color: #e7f0ff;
46
+ border-radius: 8px;
47
+ padding: 20px;
48
+ margin-top: 30px;
49
+ text-align: left;
50
+ }
51
+ .info h2 {
52
+ color: #4a6da7;
53
+ margin-top: 0;
54
+ }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="container">
59
+ <h1>RAG 검색 챗봇 초기화 중...</h1>
60
+ <div class="loader"></div>
61
+ <p>첫 실행 시 데이터 준비에 시간이 소요됩니다. 잠시만 기다려주세요.</p>
62
+ <p>페이지는 5초마다 자동으로 새로고침됩니다.</p>
63
+
64
+ <div class="info">
65
+ <h2>초기화 작업</h2>
66
+ <ul>
67
+ <li>벡터 인덱스 생성 중...</li>
68
+ <li>문서 처리 및 임베딩 생성 중...</li>
69
+ <li>검색 엔진 준비 중...</li>
70
+ </ul>
71
+ <p>이 과정은 최대 1-2분이 소요될 수 있습니다.</p>
72
+ </div>
73
+ </div>
74
+ </body>
75
+ </html>
app/templates/login.html ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RAG 검색 챗봇 - 로그인</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
9
+ <style>
10
+ .login-container {
11
+ max-width: 400px;
12
+ margin: 0 auto;
13
+ padding: 30px;
14
+ background-color: var(--card-bg);
15
+ border-radius: 10px;
16
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
17
+ }
18
+
19
+ .login-header {
20
+ text-align: center;
21
+ margin-bottom: 30px;
22
+ }
23
+
24
+ .login-form {
25
+ display: flex;
26
+ flex-direction: column;
27
+ }
28
+
29
+ .form-group {
30
+ margin-bottom: 20px;
31
+ }
32
+
33
+ .form-group label {
34
+ display: block;
35
+ margin-bottom: 8px;
36
+ font-weight: 600;
37
+ color: var(--primary-color);
38
+ }
39
+
40
+ .form-group input {
41
+ width: 100%;
42
+ padding: 12px;
43
+ border: 1px solid var(--border-color);
44
+ border-radius: 4px;
45
+ font-size: 16px;
46
+ transition: var(--transition);
47
+ }
48
+
49
+ .form-group input:focus {
50
+ border-color: var(--primary-color);
51
+ outline: none;
52
+ }
53
+
54
+ .login-button {
55
+ padding: 12px;
56
+ background-color: var(--primary-color);
57
+ color: white;
58
+ border: none;
59
+ border-radius: 4px;
60
+ font-size: 16px;
61
+ cursor: pointer;
62
+ transition: var(--transition);
63
+ }
64
+
65
+ .login-button:hover {
66
+ background-color: var(--primary-dark);
67
+ }
68
+
69
+ .error-message {
70
+ color: var(--error-color);
71
+ margin: 15px 0;
72
+ text-align: center;
73
+ font-size: 14px;
74
+ }
75
+
76
+ .login-footer {
77
+ text-align: center;
78
+ margin-top: 20px;
79
+ font-size: 0.9em;
80
+ color: var(--light-text);
81
+ }
82
+
83
+ @media (max-width: 480px) {
84
+ .login-container {
85
+ padding: 20px;
86
+ }
87
+ }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="container">
92
+ <main>
93
+ <div class="login-container">
94
+ <div class="login-header">
95
+ <h1>RAG 검색 챗봇</h1>
96
+ <p>계정 로그인 필요</p>
97
+ </div>
98
+
99
+ {% if error %}
100
+ <div class="error-message">
101
+ <i class="fas fa-exclamation-circle"></i> {{ error }}
102
+ </div>
103
+ {% endif %}
104
+
105
+ <form class="login-form" method="post" action="{{ url_for('login') }}">
106
+ <div class="form-group">
107
+ <label for="username">아이디</label>
108
+ <input type="text" id="username" name="username" required>
109
+ </div>
110
+
111
+ <div class="form-group">
112
+ <label for="password">비밀번호</label>
113
+ <input type="password" id="password" name="password" required>
114
+ </div>
115
+
116
+ <button type="submit" class="login-button">로그인</button>
117
+ </form>
118
+
119
+ <div class="login-footer">
120
+ <p>© 2025 RAG 검색 챗봇</p>
121
+ </div>
122
+ </div>
123
+ </main>
124
+ </div>
125
+ </body>
126
+ </html>
app_gradio.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import gradio as gr
3
+ import json
4
+ import logging
5
+ from app.app import app as flask_app # Flask 앱 가져오기
6
+ from flask import json as flask_json
7
+ from retrying import retry
8
+
9
+ # 로거 설정
10
+ logging.basicConfig(
11
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
12
+ level=logging.DEBUG # INFO에서 DEBUG로 변경하여 더 상세한 로그 확인
13
+ )
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Flask 테스트 클라이언트 초기화
17
+ @retry(tries=5, delay=1, backoff=2)
18
+ def init_flask_client():
19
+ """백그라운드 실행 Flask 서버에 연결하는 클라이언트 초기화"""
20
+ try:
21
+ test_client = flask_app.test_client()
22
+ logger.info("Flask 테스트 클라이언트 초기화 성공")
23
+ return test_client
24
+ except Exception as e:
25
+ logger.error(f"Flask 테스트 클라이언트 초기화 실패: {e}")
26
+ raise e
27
+
28
+ # 소스 정보 포맷팅 헤루퍼 함수
29
+ def format_source_info(sources, prefix=""):
30
+ """검색 결과 소스 정보를 형식화
31
+
32
+ Args:
33
+ sources: 소스 정보 리스트
34
+ prefix: 로그 메시지 접두사 (기본값: "")
35
+
36
+ Returns:
37
+ 형식화된 소스 정보 문자열
38
+ """
39
+ source_info = ""
40
+ if sources and len(sources) > 0:
41
+ logger.debug(f"{prefix}소스 정보 포맷팅: {len(sources)}개 소스: {json.dumps(sources, ensure_ascii=False, indent=2)}")
42
+
43
+ source_list = []
44
+ for src in sources:
45
+ source_text = src["source"]
46
+ # id 필드가 있으면 함께 표시
47
+ if "id" in src:
48
+ logger.debug(f"{prefix}ID 필드 발견: {src['id']}")
49
+ source_text += f" (ID: {src['id']})"
50
+ source_list.append(source_text)
51
+
52
+ if source_list:
53
+ source_info = "\n\n참조 소스:\n" + "\n".join(source_list)
54
+ logger.debug(f"{prefix}최종 소스 정보 형식: {source_info}")
55
+
56
+ return source_info
57
+
58
+ # Flask 테스트 클라이언트 초기화
59
+ flask_client = init_flask_client()
60
+
61
+ # Gradio 버전 확인 메시지
62
+ logger.info(f"Gradio 버전: {gr.__version__}")
63
+
64
+ # Gradio 인터페이스 생성
65
+ with gr.Blocks(title="RAG 검색 챗봇 with 음성인식") as demo:
66
+ gr.HTML("""
67
+ <div style="text-align: center; max-width: 800px; margin: 0 auto;">
68
+ <h1>RAG 검색 챗봇 with 음성인식</h1>
69
+ <p>텍스트 또는 음성으로 질문을 입력하세요.</p>
70
+ </div>
71
+ """)
72
+
73
+ with gr.Tab("텍스트 챗"):
74
+ text_input = gr.Textbox(label="질문 입력", placeholder="여기에 질문을 입력하세요...")
75
+ text_output = gr.Textbox(label="응답", interactive=False)
76
+ text_button = gr.Button("질문 제출")
77
+
78
+ with gr.Tab("음성 챗"):
79
+ with gr.Row():
80
+ # 녹음 UI 개선: 마이크로폰으로 소스 지정 및 음파 표시 활성화
81
+ audio_input = gr.Audio(
82
+ label="음성 입력",
83
+ type="filepath",
84
+ sources=["microphone"],
85
+ show_label=True,
86
+ waveform_options={"show_controls": True, "normalize": True},
87
+ interactive=True
88
+ )
89
+
90
+ audio_transcription = gr.Textbox(label="인식된 텍스트", interactive=False)
91
+ audio_output = gr.Textbox(label="응답", interactive=False)
92
+ gr.Markdown("""
93
+ <div style="text-align: center; margin: 10px 0;">
94
+ <p>녹음 정지 버튼을 누르면 자동으로 음성이 전송됩니다.</p>
95
+ </div>
96
+ """)
97
+
98
+ with gr.Tab("문서 업로드"):
99
+ doc_input = gr.File(label="문서 업로드", file_types=[".txt", ".md", ".pdf", ".docx", ".csv"])
100
+ doc_output = gr.Textbox(label="업로드 결과", interactive=False)
101
+ doc_button = gr.Button("문서 업로드")
102
+
103
+ # 텍스트 챗 기능
104
+ def handle_text_chat(query):
105
+ if not query:
106
+ return "질문을 입력하세요."
107
+ try:
108
+ logger.info("텍스트 챗 요청: /api/chat")
109
+ response = flask_client.post("/api/chat", json={"query": query})
110
+ data = flask_json.loads(response.data)
111
+
112
+ # 디버깅을 위한 API 응답 로그
113
+ logger.info(f"API 응답 구조: {json.dumps(data, ensure_ascii=False, indent=2)[:500]}...")
114
+
115
+ if "error" in data:
116
+ logger.error(f"텍스트 챗 오류: {data['error']}")
117
+ return data["error"]
118
+
119
+ # 소스 정보 추출 및 포맷팅
120
+ source_info = ""
121
+ if "sources" in data and data["sources"]:
122
+ source_info = format_source_info(data["sources"])
123
+
124
+ # 응답과 소스 정보를 함께 반환
125
+ return data["answer"] + source_info
126
+ except Exception as e:
127
+ logger.error(f"텍스트 챗 처리 실패: {str(e)}")
128
+ return f"처리 중 오류 발생: {str(e)}"
129
+
130
+ # 음성 챗 기능
131
+ def handle_voice_chat(audio_file):
132
+ if not audio_file:
133
+ return "", "음성을 업로드하세요."
134
+ try:
135
+ logger.info("음성 챗 요청: /api/voice")
136
+ with open(audio_file, "rb") as f:
137
+ # Flask 테스트 클라이언트는 files 직접 지원 안 하므로, 데이터를 읽어 전달
138
+ response = flask_client.post(
139
+ "/api/voice",
140
+ data={"audio": (f, "audio_file")}
141
+ )
142
+ data = flask_json.loads(response.data)
143
+
144
+ # 디버깅을 위한 API 응답 로그
145
+ logger.info(f"[음성챗] API 응답 구조: {json.dumps(data, ensure_ascii=False, indent=2)[:500]}...")
146
+
147
+ if "error" in data:
148
+ logger.error(f"음성 챗 오류: {data['error']}")
149
+ return "", data["error"]
150
+
151
+ # 소스 정보 추출 및 포맷팅
152
+ source_info = ""
153
+ if "sources" in data and data["sources"]:
154
+ source_info = format_source_info(data["sources"], prefix="[음성챗] ")
155
+
156
+ # 인식된 텍스트와 소스 정보가 포함된 응답 반환
157
+ return data["transcription"], data["answer"] + source_info
158
+ except Exception as e:
159
+ logger.error(f"음성 챗 처리 실패: {str(e)}")
160
+ return "", f"처리 중 오류 발생: {str(e)}"
161
+
162
+ # 문서 업로드 기능
163
+ def handle_doc_upload(doc_file):
164
+ if not doc_file:
165
+ return "문서를 업로드하세요."
166
+ try:
167
+ logger.info(f"문서 업로드 요청: /api/upload, 파일명: {doc_file.name}")
168
+ file_extension = os.path.splitext(doc_file.name)[1].lower()
169
+ logger.info(f"파일 확장자: {file_extension}")
170
+
171
+ with open(doc_file, "rb") as f:
172
+ response = flask_client.post(
173
+ "/api/upload",
174
+ data={"document": (f, doc_file.name)}
175
+ )
176
+ data = flask_json.loads(response.data)
177
+ if "error" in data:
178
+ logger.error(f"문서 업로드 오류: {data['error']}")
179
+ return data["error"]
180
+ return data["message"]
181
+ except Exception as e:
182
+ logger.error(f"문서 업로드 처리 실패: {str(e)}")
183
+ return f"처리 중 오류 발생: {str(e)}"
184
+
185
+ # 이벤트 핸들러 연결
186
+ text_button.click(
187
+ fn=handle_text_chat,
188
+ inputs=text_input,
189
+ outputs=text_output
190
+ )
191
+
192
+ # 음성 입력 값이 변경될 때 자동으로 전송
193
+ audio_input.change(
194
+ fn=handle_voice_chat,
195
+ inputs=audio_input,
196
+ outputs=[audio_transcription, audio_output]
197
+ )
198
+
199
+ doc_button.click(
200
+ fn=handle_doc_upload,
201
+ inputs=doc_input,
202
+ outputs=doc_output
203
+ )
204
+
205
+ if __name__ == "__main__":
206
+ demo.launch(server_port=7860)
data/DatasetForRag.csv ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ID,���� ����,���� (Question),���� (Answer),���� ����/�ƶ� (Reference/Context)
2
+ IT001,��� Ȯ��,������ 16 Pro Max ���� ȭ�� ũ��� �� ��ġ�ΰ���?,������ 16 Pro Max�� 6.9��ġ Super Retina XDR ���÷��̸� ž���߽��ϴ�.,"(2024-09-15) Apple's latest flagship, the iPhone 16 Pro Max, boasts an expansive 6.9-inch Super Retina XDR display, offering an immersive viewing experience with ProMotion technology and always-on capability."
3
+ IT002,��� Ȯ��,�Z ������ S25 Ultra�� ���� ī�޶� ȭ�Ҵ� ���ΰ���?,�Z ������ S25 Ultra�� 2�� ȭ��(200MP)�� ���� ī�޶� ������ ž���� ������ ����˴ϴ�. (����: ��� �� �����̹Ƿ� ���� ����),"(2025-01-10) Leaks suggest the upcoming Samsung Galaxy S25 Ultra will retain a 200-megapixel primary camera sensor, likely with further improvements in sensor technology and image processing for enhanced low-light performance and detail capture."
4
+ IT003,���� ����,����Ʈ���� '�ֻ���(Refresh Rate)'�̶� �����ΰ���?,"�ֻ����� ���÷��̰� 1�ʿ� ȭ���� �� ���̳� ���ΰ�ħ�ϴ����� ��Ÿ���� ��ġ�Դϴ�. ������ �츣��(Hz)�� ����ϸ�, ���� �ֻ���(��: 120Hz)�� ��ũ���̳� ȭ�� ��ȯ �� �� �ε巯�� �������� �����ݴϴ�.","Refresh rate, measured in Hertz (Hz), refers to how many times per second the display updates the image shown on the screen. A standard display might have a 60Hz refresh rate, while high-end smartphones often feature 90Hz, 120Hz, or even higher rates for smoother motion clarity, particularly noticeable in fast-paced games or while scrolling through content."
5
+ IT004,�� �м�,������ 16�� ������ S25�� �ֿ� �������� �����ΰ���?,"�ü��(iOS vs Android), ������ ö��, ī�޶� �ý����� Ư¡(����, �� ���� ��), ���°� ������, �׸��� Ĩ�� ���� ��� �ֿ� ���̰� �ֽ��ϴ�. ������� ��ȣ���� ��� ȯ�濡 ���� ������� �޶��� �� �ֽ��ϴ�.","Key differences between the iPhone 16 and Galaxy S25 lie in their core operating systems (Apple's iOS vs. Google's Android), distinct design languages, camera philosophies (e.g., natural tones vs. vibrant processing, zoom capabilities), ecosystem integration (iMessage/AirDrop vs. Google/Samsung services), and the underlying chipset performance (A-series vs. Snapdragon/Exynos)."
6
+ IT005,���� �ذ�,����Ʈ�� ���͸��� �ʹ� ���� ��ƿ�. ��� �ذ��� �� �ֳ���?,"ȭ�� ��� ����, ������� �ʴ� �� ����, ��׶��� �� Ȱ�� ����, ��ġ ���� ���� ����, ���� ��� Ȱ��ȭ, ���ʿ��� �˸� ���� ���� �õ��� �� �� �ֽ��ϴ�. ���͸� ��ü�� ����ȭ ���ɼ��� �����ؾ� �մϴ�.","To address rapid battery drain on your smartphone, try reducing screen brightness, closing apps you aren't actively using, limiting background app refresh and location services for non-essential apps, enabling power-saving or low-power modes, disabling unnecessary notifications, and checking battery usage statistics to identify power-hungry apps. If the issue persists, battery degradation might be the cause."
7
+ IT006,��� ���,�ȵ���̵� ����Ʈ������ ��ũ������ ��� �ﳪ��?,"�Ϲ������� ���� ��ư�� ���� �ٿ� ��ư�� ���ÿ� ª�� ������ ��ũ������ �Կ��˴ϴ�. ��� �����糪 �𵨿� ���� �ճ��� ȭ�� ����, �� �հ������� ȭ�� ������ �� �ٸ� ����� ���� ���� �ֽ��ϴ�.","Taking a screenshot on most Android phones typically involves pressing and holding the power button and the volume down button simultaneously for a moment. Some manufacturers offer alternative methods, such as palm swipe gestures (Samsung) or three-finger swipes (various brands). The captured screenshot is usually saved to the gallery or a dedicated screenshots folder."
8
+ IT007,��� Ȯ��,���� �ȼ� 8a�� ���� ��õǾ�����?,���� �ȼ� 8a�� 2024�� 5���� ���� ��ǥ �� ��õǾ����ϴ�.,"(2024-05-07) Google officially unveiled the Pixel 8a during its Google I/O event keynote in May 2024, making it available for purchase shortly thereafter. It serves as the more affordable alternative within the Pixel 8 lineup."
9
+ IT008,���� ����,����Ʈ���� 'IP ���'�� ������ �ǹ��ϳ���? (��: IP68),"IP ����� 'Ingress Protection'�� ���ڷ�, ����� ����(ù ��° ����) �� ���(�� ��° ����) ������ ��Ÿ���ϴ�. IP68�� �ְ� ������ ����(6)�� ���� ���� �Ͽ����� ���(8, ���� 1.5m ���ɿ��� 30��)�� �ǹ��մϴ�.","The IP rating (Ingress Protection rating) classifies the level of protection an enclosure provides against intrusion from solid objects (like dust; indicated by the first digit) and liquids (water; indicated by the second digit). For example, an IP68 rating means the device is dust-tight (6) and protected against continuous immersion in water under conditions specified by the manufacturer (8, often up to 1.5 meters for 30 minutes)."
10
+ IT009,���� �߷�,�ֽ� ����Ʈ���� AI ����� ���� ǰ�� ��� ��� �⿩�ϳ���?,"AI�� ��� �ν�, �ǻ�ü �ĺ�, ������ ����, ���� ������ �ռ�(HDR ��), �ι� ����� �ɵ� ȿ�� ����ȭ, ������ ȯ�濡���� ��� �� ������ ���� �� �پ��� ������� �����Ͽ� ���� ���� ������� ǰ���� ����ŵ�ϴ�.","AI algorithms in modern smartphones significantly enhance photo quality through various computational photography techniques. These include scene recognition for automatic setting optimization, subject detection for focus and exposure, advanced noise reduction, multi-frame synthesis for improved dynamic range (HDR) and low-light performance, sophisticated depth mapping for portrait mode bokeh, and semantic segmentation for targeted adjustments."
11
+ IT010,�� �м�,OLED ���÷��̿� LCD ���÷����� ������� �����ΰ���?,"OLED�� ��ü �߱� ���ڸ� ����Ͽ� �Ϻ��� ������ ǥ��, ���� ���Ϻ�, ���� ���� �ӵ��� ����������, ���� ���� ���ɼ��� ��������� ���� ������ �����Դϴ�. LCD�� ���� ������ ���� ��� ǥ���� ������ ������, ���Ϻ� ���� ������ ǥ���� �Ѱ谡 �ֽ��ϴ�.","OLED (Organic Light-Emitting Diode) displays offer advantages like perfect blacks (pixels can turn off completely), infinite contrast ratios, wider viewing angles, and faster response times. Downsides can include potential burn-in over time and higher manufacturing costs. LCD (Liquid Crystal Display) panels are typically brighter, less prone to burn-in, and cheaper to produce, but they suffer from lower contrast ratios ('black' is dark grey due to the backlight) and potential backlight bleeding."
12
+ IT011,��� Ȯ��,�ֽ� �����е� ���� �𵨿� ž��� Ĩ�� �̸��� �����ΰ���?,�ֽ� �����е� ���� (M4 Ĩ ž�� ��)���� Apple M4 Ĩ�� ž��Ǿ����ϴ�.,"(2024-05-07) Apple introduced the new iPad Pro lineup featuring the powerful, next-generation Apple M4 chip, delivering significant performance gains and enabling features like the Tandem OLED display and enhanced AI capabilities."
13
+ IT012,���� ����,5G ��Ʈ��ũ�� �ֿ� Ư¡�� �����ΰ���?,"5G�� �ʰ���(eMBB), ��������(URLLC), �ʿ���(mMTC)�̶�� �� ���� �ֿ� Ư¡�� �����ϴ�. �̸� ���� �� ���� ������ �ӵ�, �ǽð��� ����� ���伺, �׸��� ������ ��⸦ ���ÿ� �����ϴ� ���� ���������ϴ�.","The key characteristics of 5G networks are Enhanced Mobile Broadband (eMBB) for significantly faster data speeds, Ultra-Reliable Low-Latency Communication (URLLC) for near-instantaneous responsiveness crucial for applications like autonomous driving and remote surgery, and Massive Machine-Type Communications (mMTC) enabling connectivity for a vast number of IoT devices simultaneously."
14
+ IT013,���� �ذ�,����Ʈ�� ȭ���� ���ڱ� ���߰� ������ �����. ��� �ؾ� �ϳ���?,"��κ��� ���, ���� ��ư�� ���(10-30��) ���� ���� ������� �õ��ϸ� �ذ�� �� �ֽ��ϴ�. �ȵ���̵��� ��� '���� ��ư + ���� �ٿ� ��ư'�� ���ÿ� ��� ������ ���յ� ���˴ϴ�. ��⺰ ���� ����� ����� Ȯ���غ�����.","If your smartphone screen freezes and becomes unresponsive, the first troubleshooting step is usually a forced restart. For most iPhones, quickly press and release volume up, then volume down, then press and hold the side button. For many Android devices, press and hold the power button for 10-30 seconds, or press and hold the power button and volume down button together until the device restarts. Consult your device's manual for specific instructions."
15
+ IT014,��� ���,���������� AirDrop(������) ����� ����ϴ� ����� �����ΰ���?,"���� ���͸� ���ų� ���� �ۿ��� AirDrop�� Ȱ��ȭ�ϰ� '���� ��', '����ó��', '��� ���' �� ���� �ɼ��� �����մϴ�. ������ ������ ���� ���� ��Ʈ���� AirDrop �������� ���ϰ� ������ ����̳� ��⸦ �����մϴ�.","To use AirDrop on an iPhone, ensure Wi-Fi and Bluetooth are turned on. Open Control Center or Settings > General > AirDrop, and choose your receiving preference ('Receiving Off', 'Contacts Only', or 'Everyone'). To share a file, tap the Share icon, select AirDrop, and then tap the user or device you want to share with. The recipient will receive a notification to accept or decline the file."
16
+ IT015,���� �߷�,������ ����Ʈ�� ���� �� �����ؾ� �� ������� �����ϱ��?,"�������δ� ��ȭ���� �޴��ϱ� ���ϴٴ� ��, ��Ƽ�½�ŷ�� ���̼� ���� �ֽ��ϴ�. �������δ� �Ϲ� ����Ʈ�� ��� ���� ����, ������(Ư�� ������ ���� ���÷���)�� ���� ���, ��������� �β��� ���ſ� �� ���� �ֽ��ϴ�.","When considering purchasing a foldable smartphone, weigh the advantages, such as having a large tablet-like screen in a portable form factor and enhanced multitasking capabilities. However, also consider the disadvantages: higher price compared to traditional smartphones, potential concerns about long-term durability (especially the hinge mechanism and the flexible inner display), increased thickness and weight, and sometimes compromises in other specs (like battery size or camera modules) to accommodate the folding design."
17
+ IT016,��� Ȯ��,������ 14 Ultra ���� ī�޶� ���� ������ ��� �dz���?,"������ 14 Ultra�� ����ī(Leica)�� ������ ���� ī�޶� �ý����� ���߰� ������, 1��ġ ������ ž���� ���� ī�޶�, �ʱ��� ī�޶�, �׸��� �� ���� ���� ī�޶�(3.2��, 5��)�� �����Ǿ� �ֽ��ϴ�.","(2024-02-22) The Xiaomi 14 Ultra features a sophisticated quad-camera system co-engineered with Leica. It includes a main camera with a large 1-inch LYT-900 sensor, an ultra-wide camera, a 3.2x telephoto camera with a floating lens element, and a 5x periscope telephoto camera, offering versatile focal lengths."
18
+ IT017,���� ����,����Ʈ���� 'SoC(System on Chip)'�� �����ΰ���?,"SoC�� ����Ʈ���� �γ� ������ �ϴ� �ٽ� ��ǰ����, �߾�ó����ġ(CPU), �׷���ó����ġ(GPU), ��� ��(5G, LTE ��), AI ó�� ��ġ(NPU), �޸� ��Ʈ�ѷ� �� �پ��� ����� ȸ�θ� �ϳ��� Ĩ�� ������ ���Դϴ�.","A System on Chip (SoC) is an integrated circuit that combines multiple essential computing components onto a single silicon chip. In a smartphone, the SoC typically includes the Central Processing Unit (CPU), Graphics Processing Unit (GPU), memory controllers, communication modems (like 5G/LTE), Neural Processing Unit (NPU) for AI tasks, Image Signal Processor (ISP) for camera functions, and other necessary hardware accelerators, acting as the device's 'brain'."
19
+ IT018,�� �м�,���� ������ ���� ���� �� � ����� �� ��������? �Ϲ������� �� �׷�����?,�Ϲ������� ���� ������ ���� �������� �� �����ϴ�. �̴� ���� ������ ���� �ս��� ���� �� ���� ������ ���������� ������ �� �ֱ� �����Դϴ�. ���� ������ ������ ���� �������� �����ϴ� �������� ������ �ս��� �߻��Ͽ� ���� ȿ���� �������� ������ �ֽ��ϴ�.,"Wired charging is generally faster than wireless charging because it offers a more direct and efficient transfer of electrical power with less energy loss. Wireless charging relies on electromagnetic induction between coils in the charger and the device, which inherently involves some energy loss as heat, limiting the maximum achievable charging speed and efficiency compared to a direct cable connection that can handle higher wattage more stably."
20
+ IT019,���� �ذ�,��������(Wi-Fi)�� ����ƴٰ� �����µ� ���ͳ��� �� �ſ�. ��� �ؾ� �ϳ���?,1. ������(�����) ����� 2. ����Ʈ�� ����� 3. ����Ʈ���� Wi-Fi �������� �ش� ��Ʈ��ũ '�ر�' �� �翬�� 4. �ٸ� ��⿡���� ������ ������ �߻��ϴ��� Ȯ�� (������/���ͳ� ȸ�� ���� Ȯ��) 5. DNS ���� ���� ���� �õ��� �� �� �ֽ��ϴ�.,"If your phone shows it's connected to Wi-Fi but has no internet access, try these steps: 1. Restart your Wi-Fi router/modem. 2. Restart your smartphone. 3. Forget the Wi-Fi network on your phone (Settings > Wi-Fi > select network > Forget) and then reconnect. 4. Check if other devices on the same network have internet access (this helps isolate if the issue is with your phone or the network/ISP). 5. Try changing DNS settings on your phone (e.g., to Google DNS 8.8.8.8)."
21
+ IT020,��� ���,������ ����Ʈ������ '�Z ����(Samsung Pay)' ���� �� ������ �˷��ּ���.,"�Z ����(�� �Z ����) ���� ���� �Z �������� �α����� ��, �ȳ��� ���� ���� ī��(�ſ�/üũ)�� ī�޶� ��ĵ �Ǵ� ���� �Է����� ����մϴ�. ���� �ÿ��� ȭ�� �ϴܿ��� ���� ����÷� ī�带 �����ϰ� ����/��й�ȣ ���� �� ���� �ܸ��⿡ ����Ʈ�� �޸��� ������ ��� �˴ϴ�.","To set up and use Samsung Pay (now part of Samsung Wallet) on a Galaxy phone: 1. Open the Samsung Wallet app and sign in with your Samsung account. 2. Follow the prompts to add a payment card by scanning it with the camera or entering details manually. 3. To pay, swipe up from the bottom of the screen (even when locked), select the desired card, authenticate with your fingerprint, PIN, or iris scan, and then tap the back of your phone near the payment terminal (NFC or MST)."
22
+ IT021,���� �߷�,"����Ʈ�� ���� ����� ��ġ ���� ���� ������ �䱸�ϴ� ������ �����̸�, ��� �� � ���� �����ؾ� �ϳ���?","���� ��ġ ������ �䱸�ϴ� ������ ����/������̼�, �ֺ� ��� �˻�, ���� ���� ����, ������ ���� ǥ��, Ȱ�� ����(� ��) �� �پ��մϴ�. ��� �ÿ��� '�� ��� �߿��� ���' �ɼ��� �����ϰ�, ���ʿ��� �ۿ��� ������ �ο����� ������, �ֱ������� ���� ������ �����Ͽ� ���� ���� ���� ������ �ּ�ȭ�ؾ� �մϴ�.","Apps request location access for various functions: navigation (maps), finding nearby places, providing localized weather forecasts, delivering location-based ads or content, tracking workouts, geotagging photos, etc. When granting permission, it's crucial to: 1. Prefer the 'Allow only while using the app' option if available. 2. Deny permission to apps that don't genuinely need location data for their core function. 3. Regularly review app permissions in settings to minimize privacy risks and unnecessary data exposure."
23
+ IT022,��� Ȯ��,���� ��ġ �ø��� 9�� �ֿ� �ǰ� ���� ����� �����ΰ���?,"������(ECG) ����, ���� ��� ��ȭ�� ����, �Ѿ��� ����, �浹 ����, �ո� �µ� ������ ���� ���� �ֱ� ����, �ɹڼ� ����͸�(��/�� �ɹڼ� �˸� ����) ���� �ֿ� �ǰ� ���� ����Դϴ�.","Key health-related features of the Apple Watch Series 9 include the ECG app for taking electrocardiograms, blood oxygen saturation (SpO2) measurement, fall detection, crash detection, wrist temperature sensing for retrospective ovulation estimates and cycle tracking, and continuous heart rate monitoring with notifications for high/low heart rates and irregular rhythms."
24
+ IT023,���� ����,NFC(Near Field Communication)' ����� ����Ʈ������ ��� Ȱ��dz���?,"NFC�� ����� �Ÿ�(�� 10cm �̳�)���� ��� �� ���� ������ ����� �����ϰ� �ϴ� ����Դϴ�. ����Ʈ�������� ����� ����(�Z����, ��������, �������� ��), ����ī�� ���, ������ ����(Android Beam ��), ����Ʈ ��� ���� �� ���� � Ȱ��˴ϴ�.","NFC (Near Field Communication) is a short-range wireless technology enabling communication between devices within close proximity (typically around 4 inches or 10 cm). On smartphones, it's used for various applications, including contactless mobile payments (like Google Pay, Apple Pay, Samsung Pay), transit passes, quick data transfer between phones (e.g., Android Beam - now deprecated but NFC still used for sharing), pairing with accessories (like headphones or speakers), and reading NFC tags for information or automation tasks."
25
+ IT024,�� �м�,"����Ʈ�� �ü�� ������Ʈ�� �� �߿��ϸ�, ������Ʈ ���ķ� ������ ���� �����ΰ���?","OS ������Ʈ�� ���ο� ��� �߰�, ���� ����, ���� ����� ��ġ ���� �����ϹǷ� �߿��մϴ�. ������Ʈ ������ �߿��� �����͸� ����ϰ�, ����� ���� ������ ���͸� �ܷ��� Ȯ���ϸ�, �������� Wi-Fi ȯ�濡�� �����ϴ� ���� �����ϴ�. ������Ʈ �Ŀ��� ��Ȥ ���͸� �Ҹ� ������ �� ȣȯ�� ������ �߻��� �� �����Ƿ� ��ȭ�� ������ �ʿ䰡 �ֽ��ϴ�.","Smartphone OS updates are crucial as they often include new features, performance enhancements, bug fixes, and critical security patches to protect against vulnerabilities. Before updating, it's vital to back up important data, ensure sufficient storage space and battery level (or connect to power), and use a stable Wi-Fi connection. After updating, monitor for any changes, such as altered battery life or app compatibility issues, which can sometimes occur initially."
26
+ IT025,���� �ذ�,����Ʈ�� �߿��� ���� �� ��� ��ó�ؾ� �ϳ���?,"����� ����/�� ��� �ߴ�, ���� �� ��� ����, ���籤�� ���� ���ϱ�, ����Ʈ�� ���̽� ��� ����, ���ʿ��� ���(GPS, �������� ��) ����, ��� ����� ���� �õ��� �� �� �ֽ��ϴ�. �߿��� ������������ ���ӵǸ� ���� ���� ������ �ʿ��� �� �ֽ��ϴ�.","If your smartphone overheats, try the following: Stop using demanding apps or games, avoid using the phone while it's charging, keep it out of direct sunlight, temporarily remove the case, turn off unnecessary features like GPS and Bluetooth, and restart the device. If excessive heating persists or seems abnormal, contact customer support or visit a service center, as it could indicate a hardware issue."
27
+ IT026,���� ����,����Ʈ���� 'eSIM(�̽�)'�̶� �����ΰ���? ��� ����ϳ���?,"eSIM�� 'embedded SIM'�� ���ڷ�, �������� SIM ī�� ���� ��� ���ο� ����� Ĩ�� ���� ��Ż� ������ �ٿ�ε��Ͽ� ����ϴ� ������ SIM�Դϴ�. ��Ż� ���̳� QR �ڵ� ��ĵ ���� ���� �����Ͽ� ����� �� �ֽ��ϴ�.","An eSIM (embedded Subscriber Identity Module) is a digital SIM that allows users to activate a cellular plan from a carrier without having to use a physical nano-SIM card. The information is downloaded and programmed directly onto a dedicated chip inside the device. Activation is typically done through a carrier's app, a QR code, or manual entry of activation details provided by the carrier."
28
+ IT027,��� Ȯ��,UWB(�ʱ��뿪) ����� ����Ʈ������ �ַ� � �뵵�� ���dz���?,"UWB ����� �ſ� ������ �Ÿ� �� ���� ������ �����Ͽ�, ����Ʈ�������� �ַ� ������ Ű(�ڵ��� �� ���� ��), �нǹ� ������(��: �����±�, ����Ʈ�±�)�� ���� Ž�� ���, ��� �� ���� ���� �� ���� �ν� � Ȱ��˴ϴ�.","Ultra-Wideband (UWB) technology enables highly accurate spatial awareness and precise distance measurement between devices. In smartphones, it's primarily used for features like secure digital car keys, precision finding of nearby tracker tags (e.g., Apple AirTag, Samsung SmartTag+), directional file sharing (like enhancing AirDrop), and seamless smart home device interactions."
29
+ IT028,�� �м�,������ �۷��� ���ͽ� 2�� ������ �۷��� �Ƹ��� �������� �����ΰ���?,"������ �۷��� ���ͽ� 2�� ���� ��� ��ȣ�� ������ �� ��ȭ�����Դϴ�. �ݸ�, ������ S24 Ultra � ����� ������ �۷��� �ƸӴ� �پ ��ũ��ġ ���׼��� �Բ� ȭ�� �ݻ����� ũ�� ���� �������� ���� ���� Ư¡�Դϴ�.","Corning's Gorilla Glass Victus 2 primarily focuses on improved drop protection, particularly on rough surfaces like concrete. Gorilla Glass Armor, introduced on the Galaxy S24 Ultra, maintains strong scratch resistance while significantly reducing screen reflections (by up to 75%), enhancing outdoor visibility and perceived clarity."
30
+ IT029,���� ����,����Ʈ�� ���� ��� �� 'GaN(��ȭ����)' ������� �����̸� ������ �����ΰ���?,"GaN ������� ���� �Ǹ��� ��� ��ȭ�����̶�� �ż��縦 ����Ͽ� ���� �������Դϴ�. GaN ����� ���� ȿ���� ���� ������ ���� ����� �� ���� ũ��� ���� �� ������, �߿��� ���ٴ� ������ �ֽ��ϴ�. �̸� ���� �� �۰� ������鼭�� ����� ������ ���������ϴ�.","GaN (Gallium Nitride) chargers utilize Gallium Nitride semiconductor material instead of traditional silicon. GaN is more power-efficient, allowing manufacturers to build smaller, lighter chargers that can deliver the same or higher power output compared to silicon-based ones. They also tend to generate less heat, contributing to their compact size and efficiency."
31
+ IT030,��� Ȯ��,�Z ������ ����Ʈ���� ������ �� �Ϲ������� � ���� ����Ʈ���� ������Ʈ ���� �Ⱓ�� �� �䰡��? (2025�� 4�� ����),"2025�� 4�� ��������, �÷��׽� ���� ��� �Z�� ����(�ȼ�)�� �ִ� 7�Ⱓ�� OS �� ���� ������Ʈ�� ����ϸ� ���� �� ���� �Ⱓ�� �����ϴ� ������ �ֽ��ϴ�. ����(������) ���� �������� ������Ʈ�� ����������, �������� ���� �Ⱓ�� �������� �ʴ� ���Դϴ�.","(As of April 2025) For flagship devices, Samsung and Google (with its Pixel line) currently tend to offer the longest official software support durations, promising up to 7 years of both OS upgrades and security updates for recent models. Apple has a strong track record of providing long-term iOS updates for iPhones, often exceeding 5-6 years, but typically doesn't state a specific guaranteed timeframe upfront."
32
+ IT031,���� �ذ�,�������� �̾����� ����Ʈ���� ������� �ʰų� �ڲ� ���ܿ�. �ذ� �����?,"1. �̾����� ����Ʈ�� ��� ����� 2. ����Ʈ�� �������� �������� �ش� �̾��� ��� ��� ���� �� ���� 3. �̾��� ���� ���� Ȯ�� 4. �ֺ� ���� ���� Ȯ��(�ٸ� �������� ���, Wi-Fi ������ ��) 5. ����Ʈ�� �� �̾��� �߿��� �ֽ� ������Ʈ Ȯ�� ���� �õ��� �� �� �ֽ��ϴ�.","If your Bluetooth earbuds won't connect or keep disconnecting: 1. Restart both the earbuds and your smartphone. 2. Unpair/forget the earbuds in your phone's Bluetooth settings and then re-pair them. 3. Ensure the earbuds are sufficiently charged. 4. Check for interference from other Bluetooth devices, Wi-Fi routers, or microwave ovens. 5. Make sure both your phone's OS and the earbuds' firmware are up-to-date."
33
+ IT032,��� ���,�ȵ���̵� ����Ʈ������ Ȩ ȭ���� �ٸ� ��ó(Launcher) ������ �ٲٷ��� ��� �ϳ���?,"���� �÷��� ������ ���ϴ� ��ó ��(��: Nova Launcher, Microsoft Launcher ��)�� ��ġ�մϴ�. ��ġ ��, ����Ʈ�� ���� �޴��� '���ø����̼�' �Ǵ� 'Ȩ ȭ��' ���� �������� '�⺻ Ȩ ��' �Ǵ� '��ó' ������ ã�� ��ġ�� ��ó ������ �����ϸ� �˴ϴ�.","To change the home screen launcher on an Android phone: 1. Download a launcher app (e.g., Nova Launcher, Microsoft Launcher, Niagara Launcher) from the Google Play Store. 2. After installation, go to your phone's Settings app. 3. Navigate to 'Apps', 'Default apps', or 'Home screen' settings (the exact path varies by manufacturer). 4. Find the 'Default home app' or 'Launcher' setting and select the newly installed launcher you wish to use."
34
+ IT033,�� �м�,"����Ʈ�� ���� ��, �÷��׽� �� ��� �߱ޱ�(�̵巹����)�� �����ص� ������ ���� �����ΰ���?","����� ���� �÷��̳� ������ ������ ī�޶� ������ �ʼ��� �ƴϸ�, �ַ� �� ����, SNS, ������ ��û, �⺻���� �� ��� �� �ϻ����� �뵵�� ����Ʈ���� ����Ѵٸ� ���� ��� ������ ���� �߱ޱ� �𵨷ε� ����� ���������� ��� ������ ���� �� �ֽ��ϴ�. ������ �������� ��쿡�� ���� ����Դϴ�.","Choosing a mid-range smartphone instead of a flagship is often suitable when: 1. You don't require top-tier gaming performance or professional-level camera capabilities. 2. Your primary usage involves everyday tasks like web Browse, social media, video streaming, messaging, and running standard apps. 3. Budget is a significant consideration, as mid-range phones offer good value for money. 4. You prioritize battery life, as some mid-range models excel in this area due to less power-hungry processors."
35
+ IT034,���� ����,����� ���� �� MST(���׳�ƽ ���� ����) ��İ� NFC(�ٰŸ� ���� ���) ����� ���̴� �����ΰ���?,"NFC ����� ���� �ܸ���� ����Ʈ�� ���� ���� ���(RF ��ȣ)�� ���� ���� ������ ��ȯ�մϴ�. �ݸ�, MST ����� ����Ʈ������ �ڱ��� ��ȣ�� �߻����� ���� ���׳�ƽ ī�� �����Ⱑ �ִ� �ܸ��⿡���� ī�� ������ ���� �� �ֵ��� �ϴ� ����Դϴ�. (�Z ���̿��� �ַ� ���Ǿ����� ���� NFC �߽����� ��ȯ ��)","NFC (Near Field Communication) payments work by establishing radio communication between the phone and an NFC-enabled payment terminal. MST (Magnetic Secure Transmission), primarily used by early Samsung Pay, generates a magnetic signal that mimics a physical card swipe, allowing payments on older terminals that only have a magnetic stripe reader. NFC requires specific NFC readers, while MST had broader (but declining) compatibility with older terminals."
36
+ IT035,��� ���,���������� '���� ���(Focus Mode)'�� ��� �����ϰ� Ȱ���ϳ���?,"���� > ���� ��忡�� '���ر��� ���', '����', '����', '���� �ð�' �� �⺻ ��带 �����ϰų� '+' ��ư�� ���� ����� ���� ��带 ����ϴ�. �� ��庰�� �˸��� ����� ����� ���� �����ϰ�, Ư�� ��� ȭ�� �� Ȩ ȭ�� �������� �����Ͽ� �ش� ��忡 �´� ȯ���� ������ �� �ֽ��ϴ�.","To set up and use Focus Modes on an iPhone: Go to Settings > Focus. Choose a provided Focus (like Do Not Disturb, Work, Sleep, Personal) or tap the '+' button to create a custom one. For each Focus, specify which contacts and apps are allowed to send notifications. You can also link specific Lock Screen and Home Screen pages to a Focus, automatically activate it based on time, location, or app usage, and share your Focus status with others."
37
+ IT036,���� �߷�,����Ʈ�� ���÷����� 'Always On Display(AOD)' ����� ���͸� �Ҹ� ��� ���� ������ ��ġ����? ��������� ��� �ּ�ȭ�ϳ���?,"AOD�� ȭ�� ��ü�� �ƴ� �Ϻ� �ȼ��� �Ѽ� �ð�, �˸� ���� ǥ���ϹǷ� �Ϲ����� ȭ�� �������ٴ� ���͸� �Ҹ� �����ϴ�. OLED ���÷��̴� ������ ǥ�� �� �ȼ��� ������ ���Ƿ� LCD���� AOD�� �����մϴ�. ����, ������ ���, ���� �ֻ���(��: 1Hz) ����, �ֺ� ��⿡ ���� ��� �ڵ� ���� ���� ���� ���͸� �Ҹ� �ּ�ȭ�մϴ�.","Always On Display (AOD) consumes less battery than having the full screen active because it only illuminates a small number of pixels to show information like time and notifications. OLED displays are particularly efficient for AOD as black pixels are completely turned off, consuming no power. Manufacturers further minimize battery drain by using low-power co-processors, drastically reducing the refresh rate (sometimes down to 1Hz) for the AOD, and automatically adjusting brightness based on ambient light or turning off AOD when the phone is in a pocket or face down."
38
+ IT037,��� Ȯ��,Qi2(ġ��)' ���� ���� ǥ���� ���� Qi ǥ�ذ� ������ �� � ���� �����Ǿ�����?,"Qi2 ǥ���� ������ MagSafe ����� ������� �� MPP(Magnetic Power Profile)�� �����Ͽ�, ������� ��� ���� �ڼ� ������ ���� ������ ��ġ���� �������� ���� ������ �����ϰ� �մϴ�. �̸� ���� ���� ȿ���� ���̰�, ���������� �� ���� ���� ���� �ӵ��� �����ϸ�, ��ġ�� �� �پ��� ������ �׼����� ������ �����ϰ� �մϴ�.","The Qi2 wireless charging standard improves upon the original Qi standard primarily by incorporating the Magnetic Power Profile (MPP), based on Apple's MagSafe technology. This ensures perfect alignment between the charger and the device using magnets, leading to improved energy efficiency, potentially faster charging speeds (initially up to 15W, similar to MagSafe), and greater convenience, while also opening possibilities for new types of magnetic accessories like stands and mounts."
39
+ IT038,���� ����,Ʈ�� ���̾�� �̾���(TWS)�� '��Ƽ�� ������ ĵ����(ANC)' ����� � ������ �۵��ϳ���?,"ANC�� �̾��� �ܺ� ����ũ�� �ֺ� ����(�ַ� �����ļ��� �ݺ����� ����)�� ������ ��, �� ������ �ݴ�Ǵ� ����(������)�� ���ĸ� �����Ͽ� ���� ����Ŀ�� ����մϴ�. �� �� ���İ� ���� �����ϸ鼭 ������ ����Ű�� �����Դϴ�.","Active Noise Cancellation (ANC) in True Wireless Stereo (TWS) earbuds works by using external microphones to pick up ambient noise (particularly low-frequency, consistent sounds like engine hum or AC noise). The ANC processor then generates an 'anti-noise' sound wave that is exactly out of phase with the incoming noise. This anti-noise is played through the earbud's internal speakers, and when the two sound waves meet, they interfere destructively, effectively cancelling out the unwanted noise before it reaches the listener's ear."
40
+ IT039,���� �ذ�,����Ʈ�� ���� ������ �����ϴٴ� �˸��� ��� ����. ���� Ȯ�� �����?,"1. ������� �ʴ� �� ���� 2. ����/������ Ŭ����(���� ����, iCloud ��) ��� �� ��⿡�� ���� �Ǵ� �뷮 ����ȭ ��� ��� 3. ij�� ������ ���� (�� ���� �Ǵ� ��� ���� �޴�) 4. �ٿ�ε� ���� ���� 5. ��뷮 ����(������, ���� ������ ��) Ȯ�� �� ���� �Ǵ� ���� �޸�/PC�� �̵� (���� ��)","To free up storage space on your smartphone when you get 'storage full' warnings: 1. Uninstall apps you no longer use. 2. Back up photos and videos to a cloud service (like Google Photos, iCloud Photos) and then use the 'Free up space' option or delete them from the device. 3. Clear cached data for apps (via individual app settings or a device care/storage management tool). 4. Clean out the Downloads folder. 5. Identify and delete large files (videos, offline maps, game data) or move them to external storage (microSD card if supported) or a computer."
41
+ IT040,��� ���,���������� �ٸ� ���������� �����͸� �ű�� ���� ���� ����� �����ΰ���?,"���� ����(Quick Start)' ����� ����ϴ� ���� ���� �����ϴ�. �� ������ ������ �Ѱ� ���� ������ ������ �θ�, ȭ�� �ȳ��� ���� ���� �������� ����, ��, ������ ��κ��� �� ���������� �������� ���� ������ �� �ֽ��ϴ�. iCloud ����� �̿��� ���� ����� �ֽ��ϴ�.","The easiest way to transfer data from an old iPhone to a new one is using 'Quick Start'. Turn on the new iPhone and place it near your old iPhone. Follow the on-screen prompts that appear on the old iPhone to use your Apple ID to set up the new device. You can then choose to transfer data directly (iPhone to iPhone wireless transfer) or restore from an iCloud backup. Direct transfer usually moves most settings, apps, and data seamlessly."
42
+ IT041,�� �м�,����Ʈ�� ī�޶��� '���� ��'�� '������ ��'�� �ٺ����� ���̴� �����ΰ���?,"���� ���� ���� ���� ��� ���������� �̵����� ���� �Ÿ��� ���������ν� �̹����� Ȯ���ϴ� ����Դϴ�. ȭ�� ���� ���� �ǻ�ü�� ������ �Կ��� �� �ֽ��ϴ�. ������ ���� �Կ��� �̹����� Ư�� ������ ����Ʈ���������� Ȯ��(ũ��)�ϴ� ����̹Ƿ�, Ȯ�� ������ ���������� ȭ���� ����(�ȼ� ���� ����)�˴ϴ�.","Optical zoom uses the camera's lens elements physically moving to change the focal length and magnify the subject without losing image quality. It's like using the zoom lens on a traditional camera. Digital zoom, however, simply crops into the existing image captured by the sensor and enlarges that portion digitally (interpolation). This results in a loss of detail and pixelation as you increase the zoom level because no new image information is actually being captured."
43
+ IT042,���� �߷�,����Ʈ���� '������ �Ǹ�(Right to Repair)' ���ǰ� ����ڿ��� ��ġ�� ������ ������ �����ϱ��?,"������ �Ǹ��� ����Ǹ� ����ڴ� �������� ���� ���񽺼��� �ܿ��� ���� �������� �̿��ϰų� �ڰ� ������ �� ���� �� �� �ְ� �˴ϴ�. �̴� ���� ��� ����, ���� �Ⱓ ����, ��� ���� ����(���� ��⹰ ����) ���� �������� ȿ���� �̾��� �� ������, ��ǰ �� ���� �Ŵ��� ���ټ� ����� ����� �� �ֽ��ϴ�.","The ""Right to Repair"" movement, when implemented through legislation or manufacturer policy changes, positively impacts smartphone users by: 1. Providing more choices for repairs beyond the original manufacturer (independent repair shops). 2. Potentially lowering repair costs due to increased competition. 3. Enabling easier self-repair through better access to genuine parts, tools, and repair manuals. 4. Extending the usable lifespan of devices, thus reducing electronic waste and saving users money on replacements."
44
+ IT043,��� Ȯ��,�ֽ� ������ ��ġ(��: ������ ��ġ 7)���� ���� ���� ����� ž��Ǿ�����? (2025�� 4�� ����),"2025�� 4�� ����, ���ȭ�� ������ ��ġ �𵨿��� ��ħ���� ����� ���� ���� ���� ����� ���� ž����� �ʾҽ��ϴ�. ���� ����� ���� ���� ���̶�� �ҽ��� ������, ���� ���� �� ����� ������ ���ȭ������ �ð��� �� �ɸ� ������ ����˴ϴ�.","(As of April 2025) No, commercially available Galaxy Watch models, including the latest anticipated versions like the Galaxy Watch 7 series, do not yet feature non-invasive continuous blood glucose monitoring. While research and development in this area are ongoing by Samsung and others, the technology faces significant technical and regulatory hurdles before it can be reliably implemented and approved for consumer smartwatches."
45
+ IT044,���� ����,"����Ʈ�� ī�޶��� 'Pro ���' �Ǵ� '������ ���'�� � ����̸�, ���� ����ϸ� ��������?","Pro ���� ISO(����), ���� �ӵ�, ȭ��Ʈ �뷱��, ����, ���� ���� �� ī�޶��� �ֿ� ������ ����ڰ� �������� ������ �� �ְ� ���ִ� ����Դϴ�. �ڵ� ��� ������� ���������� �ʰų�, Ư�� ȿ��(�����, Ư�� ���� ��)�� �ǵ������� �����ϰ� ���� �� ����ϸ� �����ϴ�.","The 'Pro' or 'Expert' mode in a smartphone camera app allows users to manually control key photographic settings, such as ISO (sensitivity), shutter speed, white balance, manual focus, and exposure compensation, much like on a DSLR or mirrorless camera. It's useful when you want more creative control over the final image than the automatic mode provides, for specific effects (like long exposures for light trails), or in tricky lighting situations where the auto mode struggles to produce the desired result."
46
+ IT045,���� �ذ�,"Ư�� ��(��: īī����, �ν�Ÿ�׷�)���� �˸��� ���� �ʾƿ�. Ȯ���ؾ� �� ������?",1. ����Ʈ�� ��ü �˸� ���� Ȯ�� (�ش� �� �˸� ��� ����) 2. �� ���� �˸� ���� Ȯ�� (�� ���� ���� �˸� �ѱ�/���� ����) 3. ���ر��� ��� �Ǵ� ���� ��� Ȱ��ȭ ���� Ȯ�� 4. ���� ��� ���� Ȯ�� (��׶��� ������ �Ǵ� �˸� ���� ����) 5. �� ij�� ���� �Ǵ� �缳ġ �õ�,"If you're not receiving notifications from a specific app (e.g., KakaoTalk, Instagram): 1. Check the phone's main notification settings to ensure notifications are allowed for that app. 2. Check the in-app notification settings within the app itself. 3. Make sure Do Not Disturb or a Focus Mode isn't active and blocking notifications. 4. Verify that power-saving modes aren't restricting background data or notifications for the app. 5. Try clearing the app's cache or, as a last resort, reinstalling the app."
47
+ IT046,��� ���,���� ����(Google Maps) �ۿ��� �������� ������ �ٿ�ε��ϰ� ����ϴ� �����?,���� ���� �� ���� > ������ ���� �� > '�������� ����' ���� > '�� ���� ����' �� > �ٿ�ε��� ������ �簢�� �ȿ� ���Խ�Ű�� '�ٿ�ε�' ��ư ������. �ٿ�ε�� ������ ���ͳ� ������ ���� �� �ڵ����� ���˴ϴ�.,To download and use offline maps in the Google Maps app: Open Google Maps > Tap your profile picture > Select 'Offline maps' > Tap 'Select your own map' > Pan and zoom the map to frame the area you want to download within the blue rectangle > Tap 'Download'. The downloaded map will be used automatically when you have a poor or no internet connection in that area.
48
+ IT047,�� �м�,"����Ʈ�� ���� ��� �� ����, PIN, ���� �ν�, �� �ν� ������ �������?","����: �������� ���ȼ� ����(������ ����). PIN: ���Ϻ��� ���ȼ� ������ ���� ���� ���� ����. ���� �ν�: �����ϰ� ���ȼ� ������, �հ��� ����(����, ��ó)�� ���� �νķ� ���� ����. �� �ν�: �ſ� ����(���� ��� ��� ����)������, ����ũ ���� ��/��ο� ������ �νķ� ����, ���ȼ�(�ֵ���, ���� ��) �̽� ���� �� ���� (��� ��Ŀ� ���� �ٸ�).","Pattern: Fast, easy to remember, but low security (easily observed). PIN: More secure than pattern, but combinations can sometimes be guessed. Fingerprint: Convenient and generally secure, but recognition can fail with wet/dirty/injured fingers. Face Recognition: Very convenient (unlocks upon looking), but can struggle with masks or in low light, and security varies greatly by type (basic 2D vs. secure 3D like Face ID; 2D can sometimes be fooled by photos)."
49
+ IT048,���� �߷�,����Ʈ�� ��ü �ֱⰡ ���� ������� ������ �ֵ� ������ �����̶�� �����ϳ���?,1. ����Ʈ�� ���� ���� ����ȭ: �ֽ����� ������ ���� ü�� ���� ���� ����. 2. ��� ���� ���: �÷��׽� �� ���� �δ� ����. 3. ����Ʈ���� ���� �Ⱓ ����: �������� �ֽ� OS ��� ����. 4. ���� ��ȭ: �ų� ���� ��� �������� ��� ��ȭ ����. 5. ȯ�� ���� �� ���Ӱ��ɼ��� ���� �ν� ����.,"The trend of longer smartphone replacement cycles can be attributed to several key factors: 1. Performance Plateau: Modern smartphones are powerful enough for most users' needs, reducing the perceived performance gap between new and older models. 2. Rising Device Prices: Flagship phone costs have increased significantly, making upgrades less affordable. 3. Longer Software Support: Manufacturers are providing OS and security updates for longer periods, keeping older devices functional and secure. 4. Slower Pace of Innovation: Year-over-year hardware innovations feel less revolutionary than in the past. 5. Increased Environmental Awareness: Users are more conscious of e-waste and the sustainability of frequent upgrades."
50
+ IT049,��� Ȯ��,���� ���� ����(Apple Vision Pro)�� � ������ ����ΰ���?,"���� ���� ���δ� ������ ����� '���� ��ǻ��(Spatial Computer)'��, ȥ�� ����(MR) �����Դϴ�. ������� ���� ������ ������ �������� ���������Ͽ� �����ָ�, ��, ��, �������� ��ȣ�ۿ��մϴ�. VR(��������)�� AR(��������) ������ ��� �����մϴ�.","Apple Vision Pro is categorized by Apple as a ""spatial computer."" It is a mixed reality (MR) headset that blends digital content with the physical world. It allows users to interact with apps and media overlaid onto their surroundings using their eyes, hands, and voice. It provides both virtual reality (VR) and augmented reality (AR) experiences."
51
+ IT050,���� ����,"Ŭ���� ���丮�� ����(iCloud, Google Drive, OneDrive ��)�� ����ϴ� �ֵ� ������ �����ΰ���?","1. ������ ��� �� ����: ��� �н�/���� �� ������ ��ȣ. 2. ���� ��� �� ���� ����ȭ: ����Ʈ��, �º���, PC ��� ���� ���� ����. 3. ���� ���� Ȯ��: ��� ���� ���� �뷮 �δ� ����. 4. ���� ���� �� ���� ���̼�.","The main advantages of using cloud storage services (like iCloud Drive, Google Drive, OneDrive, Dropbox) include: 1. Data Backup and Recovery: Protects files against device loss, theft, or failure. 2. Cross-Device Synchronization: Access the same files seamlessly across multiple devices (phone, tablet, computer). 3. Storage Expansion: Offloads files from local device storage, freeing up space. 4. Easy File Sharing and Collaboration: Simplifies sharing large files or working on documents with others."
52
+ IT051,���� ����,"����Ʈ�� ���÷��� ��� �� 'LTPO'�� �����̸�, � ������ �ֳ���?","LTPO(Low-Temperature Polycrystalline Oxide)�� ���÷��� ���÷��� �����, ȭ�� �ֻ���(Refresh Rate)�� ��Ȳ�� �°� �ſ� ���� ����(��: 1Hz)���� �������� ������ �� �ְ� ���ݴϴ�. ������ ȭ�鿡���� �ֻ����� ���� ���͸� �Ҹ� ũ�� ���� �� �ִٴ� ���� ���� ū �����Դϴ�.","LTPO (Low-Temperature Polycrystalline Oxide) is an advanced display backplane technology primarily used in OLED panels. Its key advantage is enabling a variable refresh rate (VRR) that can dynamically adjust from very high (e.g., 120Hz) down to very low (e.g., 1Hz) depending on the content being displayed. This significantly reduces power consumption, especially for static content or Always-On Displays, thus improving battery life."
53
+ IT052,���� ����,USB-PD ���� ǥ�ؿ��� 'PPS(Programmable Power Supply)'�� �����ΰ���?,"PPS�� USB Power Delivery(PD) 3.0 �̻� ���Ե� ���α׷��Ӻ� ���� ���� ǥ���Դϴ�. ���� �����Ⱑ �ǽð����� ����ϸ� ���а� ������ �̼��ϰ� �����Ͽ� ���� ȿ���� ���̰� �߿��� �ٿ��ָ�, Ư�� ���� ���� �� ���͸� �δ��� �����ִ� ������ �մϴ�.","PPS (Programmable Power Supply) is an optional standard within the USB Power Delivery (PD) 3.0 and later specifications. It allows for dynamic adjustment of voltage and current in small increments between the charger and the device being charged. This real-time negotiation leads to more efficient charging, reduced heat generation, and potentially faster charging speeds while being gentler on the battery's health compared to fixed voltage steps."
54
+ IT053,�� �м�,����Ʈ�� RAM ���� �� LPDDR5�� LPDDR5X�� �ֿ� �������� �����ΰ���?,"LPDDR5X�� LPDDR5 ��� �� ���� ������ ���� �ӵ�(�뿪��)�� �����ϸ�, ���� ȿ������ �����Ǿ����ϴ�. �̴� ����� ����, ���ػ� ������ ó��, ��Ƽ�½�ŷ ��� �� ������ �ε巯�� ������ �����ϰ� �ϸ�, ���͸� �Ҹ�� ���̴� �� �⿩�մϴ�.","LPDDR5X is an evolution of the LPDDR5 mobile RAM standard. The primary difference is increased data transfer speed (bandwidth), with LPDDR5X typically offering speeds up to 8533 Mbps compared to LPDDR5's typical max of 6400 Mbps. It also features further improvements in power efficiency. This results in faster performance for demanding tasks like gaming, high-res video processing, and heavy multitasking, while potentially consuming less power."
55
+ IT054,��� Ȯ��,��� ���÷��� ī�޶�(UDC) ����� 2025�� ���� ��� ���ر��� �����߳���? ������ ������ �ֳ���?,"2025�� ���� UDC ����� �ʱ� �� ��� ���� �����Ǿ�, ī�޶� Ȧ�� ���� ������ �����鼭�� ������ ǰ���� ��ī �Կ��� �����������ϴ�. ������ ������ �Ϲ� ��ġȦ ī�޶� ��� �� ������ ������ ������ �����̳� ���������� �ణ�� �ս��� ������, ���÷��� �ش� ������ ȭ�� ���ϼ� ������ ������ �ذ������ �ʾҽ��ϴ�.","(As of April 2025) Under-display camera (UDC) technology has matured significantly since its initial iterations. Current implementations (seen in some foldables and concept phones) offer a more seamless full-screen experience with the camera being less visible. Image quality has improved but generally still lags slightly behind traditional punch-hole cameras, especially in challenging low-light conditions or sharpness, due to light diffraction passing through the display layers. Minor display uniformity issues over the camera area can also still be perceptible."
56
+ IT055,���� ����,����Ʈ���� '��ũ�� ����(Screen Reader)' ����� �����̸� �������� �ʿ��Ѱ���?,"��ũ�� ������ ȭ�鿡 ǥ�õ� �ؽ�Ʈ, ��ư, �̹��� ���� �� ��� ��Ҹ� �������� �о��ִ� ���ټ� ����Դϴ�. �ַ� �ð� ��ְ� �ִ� ����ڰ� ����Ʈ���� �����ϰ� ������ ���� �� �ֵ��� ���� ���� ���˴ϴ�. (��: iOS�� VoiceOver, Android�� TalkBack)","A Screen Reader is an accessibility feature that converts text, buttons, image descriptions, and other elements displayed on the screen into synthesized speech or braille output. It is primarily designed for users who are blind or have significant visual impairments, enabling them to navigate the interface, read content, and interact with their smartphone effectively. Examples include VoiceOver on iOS and TalkBack on Android."
57
+ IT056,��� ���,"����� VPN�� � ��쿡 ����ϴ� ���� ������, ��� �� ������ ���� �����ΰ���?","���� Wi-Fi ��� �� ���� ��ȭ, ���� ���� ������ ����, ���� ���� ��ȣ(IP �ּ� ����) ���� ���� ����ϴ� ���� �����ϴ�. ������ �����δ� ���� VPN�� ��� �ӵ� ����, ������ �α�, ���� ���� ���� ���� �� �����Ƿ� �ŷ��� �� �ִ� ���� VPN ���񽺸� �����ϴ� ���� ����˴ϴ�. ���� VPN ����� �ҹ��� ������ �ֽ��ϴ�.","Using a mobile VPN is beneficial when: 1. Connecting to public Wi-Fi networks (enhances security). 2. Accessing geo-restricted content or services. 3. Protecting your privacy by masking your IP address. Points to consider: Free VPNs may have limitations like slow speeds, data caps, potential logging of your activity, or even security risks. Choosing a reputable paid VPN service is generally recommended for better performance and privacy. Also, be aware of the legality of VPN use in certain countries."
58
+ IT057,��� Ȯ��,����Ʈ�� ��������� �������� �߰��� ���� �Ǹ�(Trade-in) ���α׷��� ��� �̿��ϳ���?,"�Ϲ������� ������ ������Ʈ�� ���� ������ �� ��� ���� �� '���� �Ǹ�' �ɼ��� �����մϴ�. ������ ����ϴ� ����� �𵨸�, ���� ���� �Է��ϸ� ���� ���� �ݾ��� Ȯ���� �� �ְ�, �� ��� ���� �� ���� ��⸦ ������ ������� �ݳ��ϸ� ���� �˼� �� ���� �ݾ��� Ȯ���Ǿ� ȯ�� �Ǵ� ���� ����˴ϴ�. (��: Apple Trade In, �Z Ʈ���̵���)","Official trade-in programs from manufacturers like Apple (Apple Trade In) or Samsung (Samsung Trade-in) typically work as follows: When purchasing a new device online or in-store, select the trade-in option. You'll provide details about your old device (model, condition) to get an estimated trade-in value. After receiving your new device, you'll ship your old device back using provided instructions/materials. Once inspected, the final trade-in value is confirmed and applied as a credit or refund."
59
+ IT058,���� �ذ�,����Ʈ�� GPS�� ���� ��ġ�� �� �� ��ų� ��Ȯ���� ��������. �ذ� �����?,1. GPS(��ġ ����) ���� Ȯ�� (���� ��Ȯ�� ��� Ȱ��ȭ) 2. �ǿܿ��� ��� ��� (���� ��ȣ ����) 3. ��� ����� 4. ���� �� ij��/������ ���� 5. A-GPS ������ ������Ʈ (Ư�� �� ��� �Ǵ� �ڵ� ������Ʈ Ȯ��) 6. ����Ʈ�� ���̽� ���� �� �׽�Ʈ (�ݼ� �� ���� Ȯ��) 7. OS ������Ʈ Ȯ��.,"If your smartphone's GPS is inaccurate or struggles to get a location fix: 1. Check location settings and ensure 'High accuracy' mode (using Wi-Fi, mobile networks, and GPS) is enabled. 2. Go outdoors with a clear view of the sky to allow better satellite signal reception. 3. Restart your device. 4. Clear the cache and data for your map app (e.g., Google Maps). 5. Reset/update A-GPS (Assisted GPS) data (some apps can help, or it happens automatically). 6. Temporarily remove the phone case to check for interference. 7. Check for OS updates."
60
+ IT059,��� ���,�ȵ���̵� ������ ����� �ֽ���(�״���) ����� ��� �ѳ���?,���� > '��Ʈ��ũ �� ���ͳ�' �Ǵ� '����' > '����� �ֽ��� �� �״���' > '����� �ֽ���' ����ġ�� �մϴ�. �ֽ��� �̸�(SSID)�� ��й�ȣ�� Ȯ���ϰų� ������ �� �ֽ��ϴ�. ��� �����縶�� �޴� �̸��� �ణ �ٸ� �� �ֽ��ϴ�.,To turn on the mobile hotspot (tethering) feature on an Android phone: Go to Settings > Tap 'Network & internet' or 'Connections' > Select 'Hotspot & tethering' > Toggle the switch for 'Mobile Hotspot' (or Wi-Fi hotspot) to On. You can usually configure the hotspot name (SSID) and password within this menu. The exact menu names might vary slightly depending on the phone manufacturer and Android version.
61
+ IT060,�� �м�,"�ֽ� ���� �ȼ� ��ġ�� �Z ������ ��ġ�� ������ ��, ������ ������ �����ΰ���? (2025�� �� ����)","(���� �ó����� ���) �ȼ� ��ġ(��: �ȼ� ��ġ 3)�� ���� Wear OS ����, ���� ��ý���Ʈ ����, Fitbit �ǰ� ���� ����� ���� ������ �����Դϴ�. ������ ��ġ(��: ������ ��ġ 7)�� �پ��� ������/ũ�� �ɼ�, �Z ���°� ������(SmartThings ��), ���� ������ Ȱ���� ��Ư�� ����� �������̽�(�Ϻ� ��) �� �� �� ���͸� ������ �����ϴ� ������ �ֽ��ϴ�.","(Based on typical trends, hypothetical 2025 models) The Pixel Watch (e.g., Pixel Watch 3) likely excels with its stock Wear OS experience, deep Google Assistant integration, and seamless Fitbit health tracking features. The Galaxy Watch (e.g., Galaxy Watch 7) often offers more hardware variety (sizes, rotating bezel option on some models), strong integration with the Samsung ecosystem (SmartThings, Samsung Health), potentially longer battery life on certain models, and a slightly different UI approach (One UI Watch)."
62
+ IT061,���� �߷�,����Ʈ�� �����ο��� ���� ������� ���(IP68)�� Ȯ���ϴ� �Ͱ� ���� ���̼��� ���̴� �� ���̿��� � ���� ����(Trade-off)�� ���� �� �ֳ���?,"���� ������� ����� �޼��ϱ� ���� ��������� �������� �������ϰ� ����ϰ� ��ǰ���� �ſ� �����ϰ� ��ġ�ϸ�, ���� ����(��Ʈ ��)�� �ּ�ȭ�ϴ� ������ �ֽ��ϴ�. �̷��� ����� ���� ��ǰ ������ ��ư� ����� ���� �� ���� ������ �����ϰ� �ϰ� �ð��� ����� ������ų �� �ֽ��ϴ�. ��, ��� ���� ��ȭ�� ���� ���̼��� �����ϴ� ������ �� �� �ֽ��ϴ�.","Achieving a high IP rating (like IP68) often involves design choices that can hinder repairability. Manufacturers may use strong adhesives to seal the chassis, tightly pack components, use non-modular parts, and minimize ports, all of which make water and dust ingress harder. However, these same techniques make disassembly difficult and risky, increasing the complexity, time, and cost associated with repairs, thus creating a trade-off between water resistance and ease of repair."
63
+ IT062,��� Ȯ��,"����Ʈ�� ī�޶� ���� ũ��(��: 1��ġ, 1/1.3��ġ)�� ���� ǰ���� � ������ �ֳ���?","�Ϲ������� ���� ũ�Ⱑ Ŭ���� �� �ȼ��� �� ���� ���� �޾Ƶ��� �� �ֽ��ϴ�. �̴� Ư�� ������ ȯ�濡�� ����� ���� �������� dz���ϸ�, ���̳��� ������(��� ��ο� �κ� ǥ�� �ɷ�)�� ���� ������ ��� �� �����մϴ�. ����, �� ���� �ɵ�(�ڿ������� ��� �帲) ǥ������ ������ �˴ϴ�.","A larger camera sensor size (e.g., 1-inch type vs. 1/1.3-inch type) generally allows each pixel on the sensor to be larger and capture more light. This translates to several advantages, particularly: better low-light performance (less noise, more detail), improved dynamic range (capturing detail in both bright highlights and dark shadows), and a shallower natural depth of field (more background blur or bokeh) without relying solely on software processing."
64
+ IT063,���� ����,"��ƽ �ǵ��(Haptic Feedback)'�̶� �����̸�, ����Ʈ�� ����� ���迡 ��� �⿩�ϳ���?","��ƽ �ǵ���� ��ġ �Է��̳� Ư�� ���ۿ� �����Ͽ� ����ڿ��� �����̳� ������ ���� �˰��� �ǵ���� �����ϴ� ����Դϴ�. ����Ʈ�������� Ű���� Ÿ���� �� �̼��� ����, ��ư ���� ȿ��, ���ӿ����� Ÿ�ݰ� ���� �����Ͽ� ����� �������̽��� �� �������̰� ���԰� �ְ� �����, ������ ��ư�� ������ �ùķ��̼��ϴ� �� �⿩�մϴ�.","Haptic feedback refers to the use of touch sensations, like vibrations or textures, to provide feedback to the user in response to an action or event. On smartphones, sophisticated haptics (like Apple's Taptic Engine or similar Android implementations) enhance the user experience by providing subtle tactile confirmation for keyboard typing, simulating button presses, creating immersive effects in games, and making interactions feel more tangible and intuitive."
65
+ IT064,���� �ذ�,����Ʈ�� ��ȭ �� ���� ��Ҹ��� �� �� �鸮�ų� �� ��Ҹ��� �� ���޵��� �ʾƿ�. ���ΰ� �ذ�å��?,"����: ��ȭ��(����Ŀ)/����ũ ���� �̹��� ����, ��� ��ȣ �ҷ�, ����Ʈ���� ����, �������� ��� ���� ����, VoLTE ���� ���� ��. �ذ�å: 1. ��ȭ��/����ũ ���� û�� 2. ��ȭ ���� �̵�(��ȣ ���� ��) 3. ��� ����� 4. �������� ���� 5. VoLTE �ѱ�/���� �õ� 6. OS �� ��Ż� ���� ������Ʈ Ȯ��.","Poor call quality (can't hear others well / they can't hear you) can be caused by: blocked earpiece/microphone holes (dust/debris), weak cellular signal, software glitches, issues with connected Bluetooth devices, VoLTE setting problems. Solutions: 1. Gently clean the earpiece speaker and microphone holes. 2. Move to an area with better cell reception. 3. Restart your phone. 4. Turn off Bluetooth temporarily. 5. Toggle VoLTE (Voice over LTE) settings on/off. 6. Check for OS and carrier settings updates."
66
+ IT065,��� ���,����Ʈ�� ȭ�� ��ȭ ����� ��� ����ϳ���? (iOS / Android �Ϲ����� ���),"iOS: ���� ���Ϳ� 'ȭ�� ���' ��ư �߰� ��, ���� ���� ���� �ش� ��ư �� (����ũ ����� ���� ���� ���� ����). Android: ���� ���� �޴�(��� �� ����)�� 'ȭ�� ��ȭ' �Ǵ� '��ũ�� ���ڴ�' Ÿ�� �߰�/���� �� ��ȭ ���� (����� ���� �ɼ� ���� ����). �����纰�� �ణ�� ���� ����.","iOS: Add the 'Screen Recording' button to Control Center (Settings > Control Center). Open Control Center, tap the Screen Recording button (long-press to toggle microphone audio). Android (stock/common method): Swipe down to open the Quick Settings panel, find and tap the 'Screen record' or 'Screen recorder' tile (you might need to edit the panel to add it). Choose audio recording options and tap Start. Specific steps might vary slightly by manufacturer."
67
+ IT066,�� �м�,"����Ʈ�� ���� �� ���� ���丮�� �뷮(128GB, 256GB, 512GB ��)�� � �������� �����ؾ� �ұ��?","����/������(Ư�� 4K) �Կ� ��, ����� ���� ��ġ ����, �������� ���� ������(����, ��ȭ) �뷮, Ŭ���� ���丮�� Ȱ�� ���� ���� �����ؾ� �մϴ�. �Ϲ������� ����/������ ���� ����ڴ� 256GB �̻�, ����/�� ���� ����ڴ� 512GB �̻�, Ŭ���� Ȱ�뵵�� ���� �⺻���� ��븸 �Ѵٸ� 128GB�� ������ �� �ֽ��ϴ�. microSD ī�� Ȯ�� ���� ���ε� �߿��� ���� �����Դϴ�.","Choosing internal storage capacity (128GB, 256GB, 512GB, etc.) depends on: How often you take photos/videos (especially high-resolution like 4K), how many large games you install, how much offline media (music, movies) you store, and how heavily you rely on cloud storage. General guidance: Casual users relying on cloud might be okay with 128GB. Moderate users/photographers often find 256GB a good balance. Heavy users, gamers, or videographers might need 512GB or more. Also consider if the phone supports microSD card expansion."
68
+ IT067,���� �߷�,"����Ʈ�� �� ����(�۽����, �÷��̽����)�� �ξ� ���� ������ ��å ������ �����ڿ� �Һ��ڿ��� ��ġ�� ������ �����ϱ��?","������: ���� ������(15~30%)�� ���� ���ҷ� �̾��� �� ���� �λ� �Ǵ� ���� ��� ������ �� �� ������, �ܺ� ���� �ý��� ���� �� �� ���� ���� ���� ����. �Һ���: ���߻��� �δ��� �� ����/������ �λ����� ������ �� ����. �ܺ� ���� ��� �� ���� ���Ǽ�/���ȼ� ��ȭ ���ɼ�. �� ���� ���°� ���� �� ���ſ� ������ �� �� ����.","Debates over in-app purchase commission rates (typically 15-30%) on major app stores impact developers and consumers: Developers: High fees reduce revenue, potentially leading to higher app prices, reduced investment, or exploring alternative payment systems (which can risk app removal). Consumers: Developer costs might be passed on as higher prices or subscription fees. Allowing external payments could offer choice but might fragment the payment experience or raise security concerns for some. The policies influence competition and innovation within the app ecosystem."
69
+ IT068,��� Ȯ��,"�Z '����(DeX)' ����� �����̸�, � ��⿡�� ����� �� �ֳ���?","�Z DeX�� �Z ������ ����Ʈ���̳� �º����� �����, TV, PC � �����Ͽ� ����ũ�� ��ǻ�Ϳ� ������ ��� ȯ���� �����ϴ� ����Դϴ�. Ű����, ���콺 ������ ���� ��Ƽ�½�ŷ, ���� �۾�, ���������̼� ���� ū ȭ�鿡�� �����ϰ� ������ �� �ֽ��ϴ�. �ַ� ������ S �ø���, ��Ʈ �ø���(����), Z ���� �ø���, �� S �ø��� �� �÷��׽� ��⿡�� �����˴ϴ�.","Samsung DeX (Desktop Experience) is a feature that allows users to connect their Samsung Galaxy smartphone or tablet to an external display (monitor, TV, or PC) to get a desktop-like computing environment. With a keyboard and mouse connected, users can multitask with multiple windows, work on documents, give presentations, and use apps on a larger screen. It's primarily available on flagship Galaxy devices, including recent Galaxy S series, Z Fold series, and Tab S series models."
70
+ IT069,���� ����,"����Ʈ�� ��� ��� �׽�Ʈ �� '���(Freshwater)' ������ ������ �ǹ��ϸ�, �� �ٴ幰�̳� �ٸ� ��ü�� ���ؾ� �ϳ���?","����Ʈ�� ��� �׽�Ʈ�� ������ ����� ȯ�濡�� ������ ��(���)�� �������� ����˴ϴ�. �ٴ幰�� ���� ������ �νļ��� ���ϰ�, ������� ȭ�� ��ǰ �� �ٸ� ��ü�� ������, ����, �ν� ���� �����Ͽ� ��� ���� �ջ��̳� ������ ����ų �� �ֽ��ϴ�. ���� ��� ����� ��� ȯ�濡 �����Ǹ�, �ٸ� ��ü�� ����Ǵ� ���� ���ؾ� �մϴ�.","Smartphone water resistance tests (IP ratings) are conducted under controlled lab conditions using fresh water. Saltwater (seawater) is highly corrosive due to salt content and can damage seals and internal components quickly. Other liquids like sugary drinks, coffee, or chemicals can cause stickiness, residue buildup, corrosion, and short circuits. Therefore, the advertised water resistance rating applies specifically to fresh water exposure, and contact with other liquids should be avoided."
71
+ IT070,���� �ذ�,����Ʈ�� ȭ�� ��ġ�� ���������� �� �� �ǰų� Ư�� ������ �� �ſ�. ������ �����ϱ��?,"����: 1. ȭ�� ��ȣ �ʸ�/��ȭ ���� ���� (���, ����, �β�) 2. ȭ�� ���� (����, ����) 3. �Ͻ����� ����Ʈ���� ���� (�� �浹 ��) 4. Ư�� �۰��� ȣȯ�� ���� 5. �ϵ����(��ġ��ũ�� �г�) �ջ� ���ɼ�. �ذ� �õ�: �ʸ� ���� �� �׽�Ʈ, ȭ�� û��, ��� �����, �ǽ� �� ����, ������ �ɼǿ��� ��ġ �׽�Ʈ, ���Ŀ��� ���񽺼��� ����.","Intermittent or localized touch screen issues can be caused by: 1. Problems with the screen protector/tempered glass (poor fit, dirt underneath, thickness). 2. A dirty screen (water droplets, oil). 3. Temporary software glitches (app freezes). 4. Compatibility issues with a specific app. 5. Potential hardware damage to the digitizer (touch panel). Troubleshooting: Test without the screen protector, clean the screen thoroughly, restart the device, uninstall recently added apps, use developer options for touch diagnostics, and if problems persist, seek professional repair."
72
+ IT071,���� �ذ�,Wi-Fi ������ �Ǵµ� �ڲ� ���ܿ�. ��� �ؾ� �ϳ���?,1. ������(�����) �� ����Ʈ�� ����� 2. ������� ����Ʈ�� �Ÿ� Ȯ�� �� ��ֹ� ���� 3. ����Ʈ�� Wi-Fi �������� '��Ʈ��ũ ���� �ʱ�ȭ' �õ� 4. Ư�� Wi-Fi ��Ʈ��ũ������ �߻��ϴ��� Ȯ�� (�ٸ� Wi-Fi �׽�Ʈ) 5. ������ �߿��� ������Ʈ Ȯ�� 6. �ٸ� ��⵵ ���� ���� �� ������ ���� �Ǵ� ���ͳ� ȸ�� ���� �ʿ�.,"(Forum Post Snippet) ""My phone keeps disconnecting from Wi-Fi even though it shows connected initially. I've tried restarting everything. Usually, checking router placement, resetting network settings on the phone, or even checking the router's channel settings can help. If it happens everywhere, might be the phone; if only at home and affects other devices, likely the router or ISP."""
73
+ IT072,���� �ذ�,����� ������(LTE/5G)�� ���ڱ� �� �ſ�. ��Ż� �ΰ��� �ߴµ� ���ͳ� ������ �� �˴ϴ�.,1. ����� ��� �״ٰ� ���� 2. ����Ʈ�� ����� 3. ���� > '����� ��Ʈ��ũ' > '�׼��� ����Ʈ �̸�(APN)' �ʱ�ȭ �Ǵ� Ȯ�� 4. ������ ��뷮 �ѵ� �ʰ� ���� Ȯ�� (��Ż� ��/������Ʈ) 5. SIM ī�� ������ 6. ��Ʈ��ũ ���� �ʱ�ȭ �õ� 7. Ư�� ���� ������ �� ������ �ٸ� ��ҿ��� Ȯ��.,"(Carrier Help Article) ""If mobile data isn't working despite showing a signal: 1. Toggle Airplane mode. 2. Restart your device. 3. Check APN settings (Settings > Mobile Networks > Access Point Names > Reset to default). 4. Verify you haven't exceeded your data limit. 5. Reseat your SIM card. 6. Try resetting network settings. If the issue persists, contact customer support."""
74
+ IT073,���� �ذ�,����Ʈ�� ���͸� ���� �ӵ��� �ʹ� ������. �������� �ξ� ���� �ɸ��ϴ�.,1. ��ǰ �Ǵ� ������ ���� ������ �� ���̺� ��� ���� Ȯ�� 2. ���� ���̺� �ջ� ���� Ȯ�� (�ٸ� ���̺��� �׽�Ʈ) 3. ���� ��Ʈ(����) �̹��� Ȯ�� �� û�� (�ε巯�� �� ���) 4. ���� �� ����� �� ��� ���� 5. ��� ����� 6. ��׶��� �� Ȱ�� Ȯ�� (���͸� �Ҹ� ���� �� Ȯ��) 7. ���� ���� �����Ͽ� ���� �ӵ� Ȯ�� (����Ʈ���� ���� ����).,"(Troubleshooting Guide) ""Slow charging can be due to a faulty cable, adapter, or dirty charging port. Always use certified chargers/cables. Check the port for lint/debris. Avoid heavy usage while charging. Background apps can also consume power. Testing in Safe Mode can help rule out third-party app issues. If the problem continues, the battery itself might be degrading."""
75
+ IT074,���� �ذ�,����Ʈ�� ������ ���ڱ� �� ������. ȭ���� �׳� ��İ� ���ɴϴ�.,"1. ���͸� ���� ���� ���ɼ�: �ּ� 30�� �̻� ���� �õ� 2. ���� ����� �õ� (������ ��� Ȯ��: ��) ����+�����ٿ� ��� ������) 3. �ٸ� ������ �� ���̺��� ���� �õ� 4. ���� ���� ���� ���� Ȯ�� 5. ȭ�� ��Ⱑ ������ �Ǿ� �ְų� �ܺ� ���÷��� ���� �������� Ȯ�� (���� ��) 6. �� ������� �ذ� �� �Ǹ� ���� ���� ���� �ʿ� (���͸�, ���κ��� �� �ϵ���� ���� ���ɼ�).","(Device Manual Excerpt) ""If your phone doesn't turn on (black screen): 1. Charge the device for at least 30 minutes as the battery might be fully depleted. 2. Attempt a forced restart (e.g., Press and hold Power + Volume Down buttons for 10-15 seconds). 3. Try a different charger and cable. 4. Check the charging port connection. If unresponsive, it might require professional service."""
76
+ IT075,���� �ذ�,����Ʈ�� ī�޶� ���� �����ϸ� ȭ���� ��İ� �����ų� ���� �ٷ� ����˴ϴ�.,"1. ī�޶� ���� ���� ���� Ȯ�� 2. ��� ����� 3. ���� > ���ø����̼� > ī�޶� �� > ���� ���� > 'ij�� ����' �� '������ ����' �õ� (������ ���� �� ���� �ʱ�ȭ��) 4. �ٸ� ī�޶� ��(��: �ν�Ÿ�׷�, ����� ��) ���� �� ���� ���� Ȯ�� (�ϵ���� ���� ����) 5. ���� ��忡�� ī�޶� �� ���� (����Ʈ���� �浹 Ȯ��) 6. OS ������Ʈ Ȯ�� 7. ���� �ʱ�ȭ (���� ����, ������ ��� �ʼ�).","(Tech Support Forum) ""Camera app showing black screen or crashing? First, restart the phone. Then try clearing the camera app's cache and data (Settings > Apps > Camera > Storage). Check if other apps using the camera work. Test in Safe Mode. If it still fails, it could be a hardware issue or might need a factory reset after backing up data."""
77
+ IT076,���� �ذ�,SIM ī�带 ã�� �� ����' �Ǵ� 'SIM ī�� ����' ���� �޽����� ����.,1. ����� ��� �״ٰ� ���� 2. ��� ����� 3. SIM ī�� Ʈ���̸� ���� SIM ī�尡 �ùٸ��� �����Ǿ����� Ȯ�� �� ������ (�ݼ� ���˸� �ε巴�� �۱�) 4. �ٸ� �۵��ϴ� SIM ī�带 �־� �׽�Ʈ (SIM ī�� ��ü �ҷ� Ȯ��) 5. ���� SIM ī�带 �ٸ� ��⿡ �־� �׽�Ʈ (��� ���� Ȯ��) 6. ��Ʈ��ũ ���� �ʱ�ȭ 7. ��Ż翡 ���� (SIM ī�� Ȱ��ȭ/��ü �ʿ� ���ɼ�).,"(Mobile Network Provider FAQ) ""Seeing a 'No SIM card' error? 1. Toggle Airplane mode. 2. Restart your device. 3. Remove and reinsert the SIM card, ensuring it's seated correctly and the contacts are clean. 4. Try the SIM in another phone, or another working SIM in your phone to isolate the issue (SIM vs. phone). 5. Reset network settings. 6. Contact us if the problem persists, as the SIM might need replacement or reactivation."""
78
+ IT077,���� �ذ�,����Ʈ���� ���� ���� �ڲ� ����õſ�.,"1. �ֱ� ��ġ�� �� Ȯ�� �� ���� (Ư�� �� �浹 ���ɼ�) 2. SD ī�� ��� �� ���� �� ���� Ȯ�� (SD ī�� ���� ���ɼ�) 3. ���͸� ���� ���� Ȯ�� (��ü�� �ƴ� ���) �Ǵ� ���͸� ����ȭ �ǽ� 4. ���� ���� ���� ���� Ȯ�� 5. OS ������Ʈ Ȯ�� �� ��ġ 6. ���� ��� ���� �� ���� Ȯ�� (�ý��� �� ��������, ��ġ�� �� �������� ����) 7. ���� �ʱ�ȭ (������ ��� �ʼ�).","(Android Help Center) ""Random reboots can be caused by problematic apps, faulty hardware (like battery or SD card), insufficient storage, or OS issues. Try uninstalling recently added apps, removing the SD card, checking for OS updates, and testing in Safe Mode. If frequent reboots continue, a factory reset (after backup) or hardware inspection might be necessary."""
79
+ IT078,���� �ذ�,Ư�� ���� �����ϸ� �ٷ� '���� �����Ǿ����ϴ�' �޽����� �߸鼭 ����˴ϴ�.,"1. �� ���� ���� �� �ٽ� ���� 2. ��� ����� 3. ���� > ���ø����̼� > �ش� �� > ���� ���� > 'ij�� ����' �õ� 4. �� ������Ʈ Ȯ�� (�� ����) 5. �� ���� �� �缳ġ 6. OS ������ �� ȣȯ�� Ȯ�� (Ư�� OS ������Ʈ ���� �߻� ��) 7. '������ ����' �õ� (�� ���� �� ������ �ʱ�ȭ��, ������ ����) 8. �����ڿ��� ����.","(App Troubleshooting Guide) ""If an app crashes immediately upon opening: 1. Force stop the app and relaunch. 2. Restart your phone. 3. Clear the app's cache (Settings > Apps > [App Name] > Storage). 4. Check for app updates in the Play Store/App Store. 5. Uninstall and reinstall the app. 6. Ensure app compatibility with your current OS version. Clearing app data (use cautiously) might also help."""
80
+ IT079,���� �ذ�,����Ʈ�� ���� �߿� ��Ⱑ �ʹ� �߰ſ�����. ������ �ǰ���? ��� �ؾ� �ϳ���?,"���� �� �ణ�� �߿��� ����������, ������ ����� ������ �̴߰ٸ� ���ǰ� �ʿ��մϴ�. 1. ���� �� ����� ����/�� ��� �ߴ� 2. ��dz�� �� �Ǵ� ������ ���� (�̺� �� �� ���ϱ�) 3. ��ǰ/���� ������ �� ���̺� ��� Ȯ�� 4. ����Ʈ�� ���̽� ��� ���� 5. ���� ���� ��� ��Ȱ��ȭ (���� ���� ��) 6. ��� ����� �� ����. ���������� ������ �߿� �� ���͸� �Ǵ� ���� ȸ�� ������ �� ������ ���� �ʿ�.","(Battery Safety Information) ""It's normal for your phone to get slightly warm while charging, especially during fast charging. However, if it becomes uncomfortably hot: Stop using demanding apps while charging, ensure good ventilation (avoid charging under pillows), use the original or certified charger/cable, remove the case, and consider disabling fast charging temporarily if the option exists. Persistent excessive heat could indicate an issue requiring service."""
81
+ IT080,���� �ذ�,����Ʈ�� ȭ���� �̼��ϰ� �����ŷ���. ������ �����?,1. ȭ�� ��� ���� Ȯ�� (�ڵ� ��� ���� �Ǵ� Ư�� ��� ���� ����) 2. Ư�� �� ��� �ÿ��� �߻��ϴ��� Ȯ�� (�� ȣȯ�� ����) 3. ��� ����� 4. ������ �ɼ� Ȯ�� (�ϵ���� �������� ��� �� �� �� ���� ���� ���� �õ� - ���� �ʿ�) 5. ���� ��� ���� �� ���� Ȯ�� 6. OS ������Ʈ Ȯ�� 7. ȭ�� ��ü�� �ϵ���� ���� ���ɼ� (���ΰ��� �ٸ�). ���� �� ���� ���� ����.,"(Display Troubleshooting Tips) ""Screen flickering can be caused by software glitches, app incompatibility, or hardware issues. Check if it happens at specific brightness levels or only in certain apps. Try restarting the phone, disabling adaptive brightness temporarily, checking developer options (use caution), and testing in Safe Mode. If flickering persists across all apps and conditions, it might be a display hardware problem."""
82
+ IT081,���� �ذ�,����Ʈ�� ����Ŀ �Ҹ��� �۰ų� �������ŷ���.,"1. ����Ŀ �׸�(����) �̹��� Ȯ�� �� �ε巯�� �ַ� û�� 2. ���� ���� Ȯ�� (�̵�� ����, ��ȭ ���� ��) 3. ���ر��� ��� �� ���Ұ� ���� ���� Ȯ�� 4. Ư�� �ۿ����� ���� �߻��ϴ��� Ȯ�� 5. �������� ����Ŀ/�̾��� ���� ���� Ȯ�� �� ���� �� �׽�Ʈ 6. ��� ����� 7. ���� ��忡�� �׽�Ʈ (����Ʈ���� ���� Ȯ��) 8. ����Ŀ ��ü�� �ϵ���� ���� ���ɼ�.","(Audio Problem Fixes) ""Low or distorted speaker sound? 1. Clean the speaker grille gently. 2. Double-check all volume settings (media, call, ringtone). 3. Ensure Do Not Disturb isn't silencing media. 4. Test sound in multiple apps. 5. Disconnect any Bluetooth audio devices. 6. Restart the phone. 7. Test in Safe Mode. If the issue remains, the speaker hardware might be faulty."""
83
+ IT082,���� �ذ�,����Ʈ�� ���� ��ư�̳� ���� ��ư�� �����ϰų� ������ ������ �����.,1. ��ư �ֺ� �̹��� Ȯ�� �� û�� (���� ���� ��� ��) 2. ���̽��� ��ư�� ������ �ְų� �����ϴ��� Ȯ�� �� �����ϰ� �׽�Ʈ 3. ��� ����� (ȭ�� ��ġ�� ���� ��) 4. ������ �ջ�(���� ��) ���� Ȯ�� 5. ����Ʈ������ ��ư ��� Ȱ�� (���ټ� �޴� �� - �ӽù���) 6. �������� ���� �� ������ ��ư ��ǰ ���� ���ɼ� ���� ���� ���� �湮 �ʿ�.,"(Hardware Button Issues) ""If power or volume buttons are stuck or unresponsive: 1. Check for debris around the button and try cleaning gently (e.g., with compressed air). 2. Remove the case to ensure it's not interfering. 3. Restart the device if possible using on-screen options. 4. Consider physical damage history. 5. You might be able to use software alternatives temporarily (e.g., accessibility menus). Persistent issues likely require hardware repair."""
84
+ IT083,���� �ذ�,���� ���� �е忡 ����Ʈ���� �÷����Ƶ� ������ ���۵��� �ʾƿ�.,"1. ����Ʈ�� ���� ���� ������ �����ϴ��� Ȯ�� 2. ���� �е� ���� ���� Ȯ�� 3. ����Ʈ���� ���� �е� ���� ��ġ Ȯ�� (��Ȯ�� ���� ��ġ�� ���߱�) 4. ����Ʈ�� ���̽� ���� �� �õ� (�β��� ���̽�, �ݼ� ������ �� ���� ����) 5. ����Ʈ�� �� ���� �е� �����(���� ��) 6. �ٸ� ���� ���� ���� ���� ���� �е� �׽�Ʈ 7. �ٸ� ���� ������� ����Ʈ�� �׽�Ʈ.","(Wireless Charging Not Working) ""Phone not charging wirelessly? 1. Confirm your phone supports Qi wireless charging. 2. Ensure the charging pad is powered on. 3. Adjust phone placement on the pad (coil alignment is key). 4. Remove thick cases or metal attachments. 5. Restart both phone and pad (if possible). 6. Test the pad with another compatible device, or your phone on another pad to isolate the problem."""
85
+ IT084,���� �ذ�,"����Ʈ��ġ(������ ��ġ, ���� ��ġ ��)�� ����Ʈ�����κ��� �˸��� ���� ���ؿ�.",1. ����Ʈ���� ��ġ �������� ���� ���� Ȯ�� 2. ��ġ�� ����� ��� �Ǵ� ���ر��� ���� �����Ǿ� �ִ��� Ȯ�� 3. ����Ʈ�� ��(��ġ ���� ��)���� �˸� ���� Ȯ�� (�˸� ���� �� ���� ��) 4. ����Ʈ�� ��ü �˸� ���� Ȯ�� (�ش� �� �˸� ��� ����) 5. ����Ʈ���� ��ġ ��� ����� 6. ��ġ ���� �� ������Ʈ Ȯ�� 7. ��ġ �ʱ�ȭ �� �翬�� (���� ����).,"(Smartwatch Notification Sync) ""Not getting phone notifications on your watch? 1. Check Bluetooth connection between phone and watch. 2. Ensure the watch isn't in Airplane or Do Not Disturb mode. 3. Verify notification settings in the watch companion app on your phone (which apps are allowed). 4. Check phone's notification settings for those apps. 5. Restart both devices. 6. Update the watch app. 7. As a last resort, unpair and reset the watch, then re-pair."""
86
+ IT085,���� �ذ�,���� ����(�Ǵ� iCloud) ����ó�� �� ����Ʈ������ ����ȭ���� �ʾƿ�.,1. �� ����Ʈ�� ���ͳ� ���� ���� Ȯ�� (Wi-Fi/����� ������) 2. ����Ʈ�� ���� > ���� �޴����� �ش� ����/iCloud ���� �α��� �� ����ȭ ���� Ȯ�� ('����ó' ����ȭ ���� �ִ���) 3. ���� ����ȭ �õ� (���� ���� �� '���� ����ȭ' ��ư) 4. ����ó �� ��ü ���� Ȯ�� (ǥ���� ���� ���� ��) 5. ��� ����� 6. ���� ���� �� �ٽ� �߰� 7. ��(contacts.google.com / icloud.com)���� ����ó ���������� ���̴��� Ȯ��.,"(Account Sync Issues) ""Contacts not syncing to new phone? 1. Ensure stable internet connection. 2. Go to Settings > Accounts > [Your Google/iCloud Account] and check if 'Contacts' sync is enabled. 3. Try initiating a manual sync ('Sync now'). 4. Check settings within the Contacts app itself (accounts to display). 5. Restart the phone. 6. Remove and re-add the account. 7. Verify contacts appear correctly on the web version (contacts.google.com or icloud.com)."""
87
+ IT086,���� �ذ�,�ȵ���̵� ������ 'System UI�� �������� �ʽ��ϴ�' �Ǵ� 'System UI�� �����Ǿ����ϴ�' ������ ����.,"1. ��� ����� 2. �ֱ� ��ġ/������Ʈ�� �� Ȯ�� �� ���� (Ư�� ��ó, ���� �� �ý��� UI�� ���� �ִ� ��) 3. ���� > ���ø����̼� > �� ��� ���� '������' > '�ý��� �� ǥ��' > 'System UI' �� ���� > ���� ���� > 'ij�� ����' �õ� 4. Google �� �� Google Play ���� ������Ʈ Ȯ�� �� ij�� ���� 5. ���� ���� �����Ͽ� ���� Ȯ�� 6. ���� �ʱ�ȭ (������ ��� �ʼ�).","(Android Common Issues Forum) ""Getting 'System UI isn't responding'? This often relates to a conflicting app (like a third-party launcher or widget), outdated Google apps, or corrupted cache. Try restarting, uninstalling recent apps, clearing System UI cache (Settings > Apps > Show system apps > System UI > Storage > Clear cache), updating Google apps, and testing in Safe Mode. A factory reset is the last resort."""
88
+ IT087,���� �ذ�,���� �÷��� ����(Google Play Services) ���� ���� �޽����� ��� ���Ϳ�.,1. ��� ����� 2. ���� > ���ø����̼� > (�ý��� �� ǥ��) > Google Play ���� > ���� ���� > 'ij�� ����' > '��� ������ �����' �õ� (���� ��α��� �ʿ��� �� ����) 3. Google Play ���� �� ������Ʈ Ȯ�� (�÷��� ����� �Ǵ� APK Mirror �� �ŷ��� �� �ִ� ��ó) 4. Google Play ����� �� ij�� �� ������ ���� 5. Google ���� ���� �� ���� 6. ��¥ �� �ð� ���� �ڵ� ����ȭ Ȯ��.,"(Google Support Page Snippet) ""Errors related to Google Play Services can often be resolved by: 1. Restarting the device. 2. Clearing the cache and data for Google Play Services (Settings > Apps > Google Play Services > Storage > Manage space > Clear All Data). 3. Ensuring Play Services and Play Store apps are up-to-date. 4. Removing and re-adding your Google account. 5. Checking that date and time are set automatically."""
89
+ IT088,���� �ذ�,"���� �Է��Ϸ��� �ϴµ� Ű���尡 ��Ÿ���� �ʰų�, ��Ÿ���ٰ� �ٷ� �������.","1. ��� ����� 2. ���� ��� ���� Ű���� �� ���� ���� (���� > ���ø����̼� > �ش� Ű���� ��) 3. �ش� Ű���� �� ij�� ���� (���� ���� �޴�) 4. �ٸ� Ű���� ������ ���� �� �׽�Ʈ (��: Gboard, �Z Ű���� ��) 5. �ش� Ű���� �� ������Ʈ Ȯ�� �Ǵ� �缳ġ 6. ���� ��忡�� �׽�Ʈ (Ÿ�� �� �浹 Ȯ��).","(Keyboard Troubleshooting Steps) ""Keyboard not appearing or disappearing? 1. Restart your phone. 2. Force stop the keyboard app you are using (via Settings > Apps). 3. Clear the cache for that keyboard app. 4. Try switching to a different keyboard app (like Gboard or the default manufacturer keyboard). 5. Update or reinstall the problematic keyboard app. 6. Test in Safe Mode to rule out conflicts."""
90
+ IT089,���� �ذ�,"����� �ֽ��̿� �ٸ� ���� ����Ǵµ�, ���ͳ� ������ �Ҿ����ϰų� �ڲ� ����ϴ�.","1. �ֽ��� �� ����Ʈ�� ����� �� ����� ��� ����� 2. �ֽ��� ���� Ȯ�� (���� ���� ��� �� ����, ���� ��� ��) 3. ����Ʈ���� ����� ������ ��ȣ ���� Ȯ�� 4. �ֽ��� ���ļ� �뿪 ���� �õ� (2.4GHz <-> 5GHz) 5. �ֺ� ȯ���� ���� ���� Ȯ�� 6. ����Ʈ�� ��׶��� ������ ��� ���� ���� Ȯ�� 7. ����Ʈ�� OS ������Ʈ Ȯ��.","(Mobile Hotspot Guide) ""Unstable hotspot connection for connected devices? 1. Restart both the hotspot phone and the connected device. 2. Check hotspot settings (max connections, power saving options). 3. Ensure the hotspot phone has a strong mobile data signal. 4. Try switching the hotspot band (2.4GHz vs 5GHz). 5. Minimize potential interference. 6. Check for background data restrictions on the hotspot phone. 7. Keep the OS updated."""
91
+ IT090,���� �ذ�,�������� �̾������� ���� ���� �� �Ҹ��� �ڲ� ����ų� �����ſ�.,"1. �̾����� ����Ʈ�� ����� �� ���� 2. �̾��� ���͸� �ܷ� Ȯ�� 3. ����Ʈ���� �̾��� �Ÿ� Ȯ�� (������ ����) 4. �ٸ� �������� ��� �Ǵ� Wi-Fi ��ȣ���� ���� �ּ�ȭ (�ֺ� ��� ����) 5. ����Ʈ�� ���̽� ���� �� �׽�Ʈ 6. ����Ʈ�� �� �̾��� �߿��� �ֽ� ������Ʈ 7. ������ �ɼǿ��� �������� �ڵ� ���� �õ� (SBC, AAC, aptX �� - ȣȯ�� Ȯ�� �ʿ�) 8. �ٸ� ����Ʈ���� �����Ͽ� �׽�Ʈ (���� ���� ��� �ľ�).","(Bluetooth Audio Stuttering Fixes) ""Bluetooth audio skipping? 1. Restart & re-pair devices. 2. Ensure earbuds are charged. 3. Keep phone and earbuds close. 4. Minimize interference (turn off other Bluetooth devices, move away from routers). 5. Remove phone case. 6. Update firmware for both phone and earbuds. 7. Try changing Bluetooth codecs in Developer Options (if applicable). 8. Test earbuds with another phone to isolate the issue."""
92
+ IT091,���� �ذ�,"��ȭ�� �� ȭ���� ������ �ʰų�, ��ȭ ���� �Ŀ��� ȭ���� �ٷ� ������ �ʾƿ�. (���� ���� ����)",1. ȭ�� ��� ���� ���� �κ�(��ȭ�� ��ó) �̹��� Ȯ�� �� û�� 2. ȭ�� ��ȣ �ʸ�/��ȭ ������ ������ �����ų� �����ϴ��� Ȯ�� �� �����ϰ� �׽�Ʈ 3. ��� ����� 4. ���� ���� �׽�Ʈ ���(���� ��� �Ǵ� ������Ƽ ��) ��� 5. OS ������Ʈ Ȯ�� 6. ���� ��ü�� �ϵ���� ���� ���ɼ� (���� �� ���� ���� ����).,"(Proximity Sensor Issues) ""Screen not turning off during calls or staying off after? This is likely a proximity sensor issue. 1. Clean the sensor area near the earpiece. 2. Check if your screen protector is blocking the sensor; test without it. 3. Restart the phone. 4. Use a diagnostic tool/app to test the sensor. 5. Check for OS updates. If it persists, the sensor hardware might be faulty and require service."""
93
+ IT092,���� �ذ�,����Ʈ�� ȭ�� �ڵ� ȸ��(����/���� ��ȯ) ����� �۵����� �ʾƿ�.,"1. ���� ���� �޴����� '�ڵ� ȸ��' ����� ���� �ִ��� Ȯ�� (���� �ִٸ� ���Ͽ� �ѱ�) 2. ��� ����� 3. Ư�� �ۿ����� ȸ���� �� �Ǵ��� Ȯ�� (�� ��ü���� ȸ�� ������ ���ɼ�) 4. ����(���ӵ���, ���̷ν�����) ���� (���� ��� �Ǵ� ���� �� ���) 5. ���� ��忡�� �׽�Ʈ (����Ʈ���� �浹 Ȯ��) 6. OS ������Ʈ Ȯ��. ���� �� ���� �ϵ���� ���� ���ɼ�.","(Auto-Rotate Not Working) ""Screen not auto-rotating? 1. Check the Quick Settings panel and ensure 'Auto-rotate' (or Portrait/Landscape lock) is enabled. 2. Restart your device. 3. Verify if rotation works in other apps (some apps don't support rotation). 4. Use a sensor testing app to check the accelerometer/gyroscope. 5. Test in Safe Mode. 6. Check for OS updates. Persistent issues might point to a sensor hardware failure."""
94
+ IT093,���� �ذ�,���� �ν� ������ �� �۵����� �ʰų� �ʹ� ������.,1. ���� ǥ�� �� �հ��� û�� ���� Ȯ�� (�����ϰ� �����ϰ�) 2. ��ϵ� ���� ���� �� ���� (���� ������ ���� �� ��ĵ) 3. ȭ�� ��ȣ �ʸ�/��ȭ ������ ����(Ư�� ���÷��� ������)�� �����ϴ��� Ȯ�� (ȣȯ �ʸ� ���) 4. ��� ����� 5. OS �� ���� ��ü �ν� ����Ʈ���� ������Ʈ Ȯ�� 6. �������� ���� �ν� ���� ����ȭ �ɼ� Ȯ�� (�ִϸ��̼� ȿ�� ���� ��) 7. ���� ��� �׽�Ʈ.,"(Fingerprint Sensor Troubleshooting) ""Slow or unreliable fingerprint sensor? 1. Clean the sensor surface and your finger (ensure dry). 2. Delete registered fingerprints and re-register them carefully from multiple angles. 3. Ensure your screen protector is compatible (especially for under-display sensors). 4. Restart the phone. 5. Check for OS/biometric software updates. 6. Look for fingerprint optimization settings (e.g., disabling animations). 7. Test in Safe Mode."""
95
+ IT094,���� �ذ�,���͸��� 20~30% ���Ҵٰ� ǥ�õǴµ� ���ڱ� ������ ����������.,1. ���͸� ����(Ķ���극�̼�) �õ�: ���� ���� -> ���� ����(�ڵ� ����) -> �ٽ� ���� ����. (��Ȯ�� ����� ������ ������ �� ����) 2. ���͸� ��� ��� Ȯ�� (Ư�� ���� �������� �Ҹ� �����ϴ���) 3. �߿� ȯ�� ���� ���� Ȯ�� (���¿��� ���͸� ���� ����) 4. OS ������Ʈ Ȯ�� (���͸� ���� ���� ���� ���ɼ�) 5. ���͸� ����ȭ ���ɼ� ���� (���� ���� ���͸�). ���� �� ���͸� ��ü ���� �ʿ�.,"(Phone Shutting Off Unexpectedly) ""Phone dying suddenly even with 20-30% battery left? 1. Try battery calibration (fully charge -> use until auto-off -> fully charge again). 2. Check battery usage stats for rogue apps. 3. Avoid extreme cold temperatures. 4. Check for OS updates. 5. This often indicates battery degradation (wear and tear). Consider battery replacement if the issue persists."""
96
+ IT095,���� �ذ�,������ ���� '���� ���͸� ����(Wireless PowerShare)' ����� �۵����� �ʾƿ�.,1. ���� ���͸� ���� ��� Ȱ��ȭ Ȯ�� (���� ���� �޴� �Ǵ� ���� > ���͸�) 2. �����ϴ� ���� ���͸� �ܷ��� ���� ����(���� 30%) �̻����� Ȯ�� 3. ���� ���� ��Ⱑ Qi ���� ���� ǥ���� �����ϴ��� Ȯ�� 4. �� ��� �޸� �߾��� ��Ȯ�� �´�� �ִ��� Ȯ�� 5. �� ��� ��� ���̽� ���� �� �õ� 6. �����ϴ� ���� �����⿡ ����Ǿ� ���� ������ Ȯ�� (�Ϻ� �� ����) 7. ��� �����.,"(Wireless PowerShare Issues) ""Wireless PowerShare not working? 1. Ensure the feature is enabled (Quick Settings or Battery settings). 2. Check if the sharing phone's battery is above the minimum threshold (usually 30%). 3. Confirm the receiving device supports Qi wireless charging. 4. Align the backs of the devices carefully (center-to-center). 5. Remove cases from both devices. 6. Disconnect the sharing phone from any wired charger. 7. Restart the sharing phone."""
97
+ IT096,���� �ذ�,"��ǻ�Ϳ��� ����Ʈ������ ����/������ ������ �����ߴµ�, ������ �ۿ��� ���� ���̰ų� ������ �ʾƿ�.","1. ���� ���� �� ���� �Ҿ��� ���ɼ�: ���̺� ���� ���� Ȯ�� �� �ٸ� ���̺�/��Ʈ ���, ������ �õ� 2. ����Ʈ�� ���� ���� ���� ���� Ȯ�� 3. ���� ���� ȣȯ�� Ȯ�� (����Ʈ������ �����ϴ� �ڵ�/��������) 4. ���� ���� ��ü �ջ� ���� Ȯ�� (��ǻ�Ϳ��� ���� ����Ǵ���) 5. ����Ʈ�� �̵�� ��ij�� ����� (��� ����� �Ǵ� ���� �� ���) 6. ���� Ž���� ������ ���� ���� ���� �� ���� �õ�.","(File Corruption After Transfer) ""Files appear corrupted or won't open after copying from PC? 1. Ensure a stable connection during transfer; try a different cable/port and re-transfer. 2. Check for sufficient storage space on the phone. 3. Verify file format compatibility (supported codecs). 4. Confirm the original file isn't corrupted (check on PC). 5. Trigger media rescan (restarting phone usually does this). 6. Try opening the file directly using a file manager app."""
98
+ IT097,���� �ذ�,����Ʈ������ '�� ��� ã��(Find My Device / Find My Mobile)'�� ��ġ ��ȸ�� �� �ſ�.,1. �ش� ��� Ȱ��ȭ ���� Ȯ�� (���� > ����/���� > �� ��� ã��) 2. ����Ʈ�� ���� ���� �ְ� ���ͳ�(����� ������ �Ǵ� Wi-Fi)�� ����Ǿ� �ִ��� Ȯ�� 3. ��ġ(GPS) ���� ���� �ִ��� Ȯ�� 4. �ش� ����/�Z ���� �α��� ���� Ȯ�� 5. ��׶��� ������ ���� ���� Ȯ�� (�� ��� ã�� ���� ����) 6. ��� �����.,"(Find My Device Not Working) ""Unable to locate phone using Find My Device/Mobile? 1. Ensure the feature is enabled in settings (Security/Account > Find My Device). 2. The phone must be powered on and connected to the internet (mobile data or Wi-Fi). 3. Location/GPS services must be turned on. 4. Verify you're signed into the correct Google/Samsung account. 5. Check for background data restrictions for related services. 6. Restart the phone."""
99
+ IT098,���� �ذ�,S��(�Ǵ� ���� �潽)�� �º���/���� ������� �ʰų� �ʱ� �ν��� ����Ȯ�ؿ�.,1. S��/�����潽 ���� ���� Ȯ�� �� ���� (��� ���� �Ǵ� ���� ����) 2. �������� ���� Ȯ�� �� �翬�� (S�� ���� �׼� �� ��� ��� ��) 3. ���� ����/�ջ� ���� Ȯ�� �� ��ü 4. ȭ�� ��ȣ �ʸ�/��ȭ ������ �����ϴ��� Ȯ�� (Ư�� �β��� �ʸ�) 5. ��� ����� 6. S��/�����潽 ���� �ʱ�ȭ (����Ʈ���� ���� �޴�) 7. �ٸ� ȣȯ �ۿ��� �׽�Ʈ.,"(Stylus Connection/Accuracy Problems) ""S Pen/Apple Pencil not connecting or writing inaccurately? 1. Ensure the stylus is charged (attach to device or charge separately). 2. Check Bluetooth settings and try re-pairing (needed for air actions/features). 3. Inspect the nib/tip for wear or damage and replace if needed. 4. Check if the screen protector is interfering (especially thick ones). 5. Restart the host device. 6. Reset stylus settings if available. 7. Test in different compatible apps."""
100
+ IT099,���� �ذ�,"����Ʈ���� USB-C ���̺��� ����Ϳ� �����ߴµ� ȭ���� ������ �ʾƿ�. (DeX, �̷��� ��)",1. ����Ʈ�� ���� USB-C ���� ���� ���(DisplayPort Alt Mode �Ǵ� �ش� ���(DeX ��)) �����ϴ��� Ȯ�� 2. ����ϴ� USB-C ���̺��� ���� ���(DP Alt Mode) �����ϴ��� Ȯ�� (���� ���� ���̺� �Ұ�) 3. ����� �Է� �ҽ� ���� Ȯ�� (����� HDMI/DP ��Ʈ ����) 4. �ٸ� ����� �Ǵ� �ٸ� ���̺��� �׽�Ʈ 5. ����Ʈ�� �� ����� ����� 6. ����Ʈ�� ���� Ȯ�� (DeX/ȭ�� �̷��� ���� ����).,"(USB-C to Monitor No Signal) ""No display when connecting phone to monitor via USB-C? 1. Verify your phone model supports video output over USB-C (DisplayPort Alt Mode, DeX, etc.). 2. Ensure you're using a USB-C cable that supports video output (not just charging). 3. Check the monitor's input source setting (select the correct HDMI/DP port). 4. Test with a different monitor or cable. 5. Restart both the phone and monitor. 6. Check relevant phone settings (DeX, Screen Mirroring)."""
101
+ IT100,���� �ذ�,��Ʃ�� �ۿ��� �������� ���µ� ȭ���� �������� �ڲ� ���۸��� �ɸ��ų� ���ܿ�. ���ͳ� �ӵ��� �����.,1. ��Ʃ�� �� ij�� ���� (���� > ���ø����̼� > YouTube > ���� ����) 2. ��Ʃ�� �� ������Ʈ Ȯ�� �Ǵ� �缳ġ 3. ��� ����� 4. �����(������) ����� 5. �ٸ� ��⿡���� ���� ���� Ȯ�� (��Ʈ��ũ ���� ����) 6. DNS ���� ���� �õ� (��: Google DNS 8.8.8.8) 7. ��׶��忡�� ������ ���� ����ϴ� �� Ȯ�� �� ���� 8. �ð��� ���� �� �õ� (��Ʈ��ũ ȥ�� �ð���).,"(YouTube Buffering Despite Fast Internet) ""YouTube videos keep buffering/stuttering even with good internet speed? 1. Clear YouTube app cache. 2. Update or reinstall the YouTube app. 3. Restart your device. 4. Restart your router. 5. Check if other devices on the network have the same issue. 6. Try changing DNS settings (e.g., to Google DNS). 7. Close background apps consuming bandwidth. 8. Test during different times of day (off-peak hours)."""
102
+ ��ó,,,,
103
+ https://blog.nus.edu.sg/esim/for-travel/the-best-esim-card-for-sweden-travel-in-2024.html,,,,
104
+ https://www.windowtonews.com/news.php?id=558541&cat_id=15,,,,
docs/project_plan.md ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG5_2_ChooseLLM 프로젝트 계획서
2
+
3
+ ## 프로젝트 개요
4
+ 이 프로젝트는 RAG(Retrieval-Augmented Generation) 기반 검색 챗봇으로, 지식베이스에서 관련 정보를 검색하여 사용자 질의에 답변합니다. 주요 특징으로는 OpenAI와 DeepSeek 등 다양한 LLM을 선택하여 사용할 수 있는 기능과 장치 관리 기능을 제공합니다.
5
+
6
+ ## 주요 기능
7
+ 1. 지식베이스 관리
8
+ - 문서 업로드 (.txt, .md, .csv 등)
9
+ - 문서 청크 분할 및 벡터 인덱싱
10
+ - 지식베이스 문서 목록 조회
11
+
12
+ 2. LLM 선택
13
+ - OpenAI, DeepSeek 등 다양한 LLM 선택 가능
14
+ - LLM 변경 시 실시간 전환
15
+ - 현재 사용 중인 LLM 정보 표시
16
+
17
+ 3. 챗봇 기능
18
+ - 텍스트 기반 질의응답
19
+ - 음성 인식 및 질의응답 (VITO STT 활용)
20
+ - 검색 결과 정보 출처 표시
21
+
22
+ 4. 장치 관리 기능
23
+ - 로컬 PC와 통신하는 기능
24
+ - 장치 상태 확인 및 장치 목록 조회
25
+ - 프로그램 목록 조회 및 실행
26
+
27
+ ## 기술 스택
28
+ - **백엔드:** Flask (Python)
29
+ - **프론트엔드:** HTML, CSS, JavaScript
30
+ - **LLM 통합:** OpenAI API, DeepSeek API
31
+ - **검색 기능:** 벡터 검색, 재순위화
32
+ - **STT 서비스:** VITO API
33
+ - **통신 프로토콜:** RESTful API
34
+
35
+ ## 완료된 작업
36
+ - [X] 프로젝트 기본 구조 설정
37
+ - [X] Flask 웹 서버 구현
38
+ - [X] 다중 LLM 인터페이스 구현 (OpenAI, DeepSeek)
39
+ - [X] 사용자 인증 기능 구현
40
+ - [X] 벡터 검색 및 재순위화 기능 구현
41
+ - [X] 문서 처리 및 관리 기능 구현
42
+ - [X] 웹 인터페이스 구현
43
+ - [X] 장치 관리 기능 통합
44
+ - [X] 장치 관리 UI 구현
45
+
46
+ ## 진행해야 할 작업
47
+ - [ ] LLM 선택 UI 개선
48
+ - 드롭다운에 아이콘 추가
49
+ - 각 LLM별 상세 모델 정보 표시
50
+ - [ ] 응답 내 소스 링크 클릭 시 원문 표시 기능
51
+ - [ ] 응답 속도 개선
52
+ - [ ] 오류 처리 강화
53
+ - [ ] 테스트 케이스 작성 및 유닛 테스트 구현
54
+ - [ ] 사용자 가이드 문서 작성
55
+
56
+ ## 통합 구현 내용
57
+
58
+ ### 1. 웹앱 통합
59
+ - 단일 웹 애플리케이션에 RAG 검색 챗봇과 장치 관리 기능 통합
60
+ - 탭 기반 인터페이스로 기능 분리 (대화, 문서관리, 장치관리)
61
+ - Flask를 통한 중앙 집중식 서버 관리
62
+
63
+ ### 2. 장치 관리 기능
64
+ - 장치 상태 확인 기능
65
+ - 연결된 장치 목록 조회
66
+ - 실행 가능한 프로그램 목록 조회 및 실행
67
+ - 서버와의 RESTful API 통신
68
+
69
+ ### 3. 프론트엔드 구현
70
+ - 직관적인 사용자 인터페이스
71
+ - 실시간 상태 업데이트
72
+ - 에러 처리 및 사용자 피드백 제공
73
+ - 반응형 디자인 적용
74
+
75
+ ## 파일 구조
76
+ ```
77
+ RAG5_2_ChooseLLM/
78
+ ├── app/
79
+ │ ├── app.py # 메인 Flask 애플리케이션
80
+ │ ├── app_routes.py # 기본 라우트 정의
81
+ │ ├── app_device_routes.py # 장치 관리 관련 라우트
82
+ │ ├── static/
83
+ │ │ ├── css/
84
+ │ │ │ └── style.css # 스타일시트
85
+ │ │ └── js/
86
+ │ │ ├── app.js # 메인 JavaScript
87
+ │ │ └── app-device.js # 장치 관리 JavaScript
88
+ │ └── templates/
89
+ │ ├── index.html # 메인 페이지 템플릿
90
+ │ └── login.html # 로그인 페이지 템플릿
91
+ ├── data/ # 업로드된 문서 저장
92
+ ├── docs/ # 프로젝트 문서
93
+ ├── retrieval/ # 검색 관련 모듈
94
+ └── utils/ # 유틸리티 모듈
95
+ ```
96
+
97
+ ## 환경 변수 설정
98
+ - `ADMIN_USERNAME`: 관리자 사용자명 (기본값: admin)
99
+ - `ADMIN_PASSWORD`: 관리자 비밀번호 (기본값: rag12345)
100
+ - `DEVICE_SERVER_URL`: 장치 관리 서버 URL (기본값: http://localhost:5050)
101
+ - `OPENAI_API_KEY`: OpenAI API 키
102
+ - `DEEPSEEK_API_KEY`: DeepSeek API 키
103
+ - `VITO_API_KEY`: VITO STT API 키
104
+
105
+ ## 참고 사항
106
+ - 장치 관리 서버는 별도로 실행되어야 함 (포트 5050)
107
+ - OpenAI, DeepSeek 및 VITO API 키는 .env 파일에 설정해야 함
108
+ - 초기 로그인 계정 정보는 환경 변수에 설정하거나 기본값 사용
huggingface-space.yml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ title: RAG 검색 챗봇 with 음성인식
2
+ emoji: 🤖
3
+ colorFrom: indigo
4
+ colorTo: blue
5
+ sdk: gradio
6
+ sdk_version: 3.44.4
7
+ app_file: app.py
8
+ pinned: false
9
+ license: mit
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask>=2.0.1
2
+ python-dotenv>=1.0.0
3
+ requests>=2.25.1
4
+ numpy>=1.20.0
5
+ scikit-learn>=1.0.2
6
+ gunicorn>=20.1.0
7
+ werkzeug>=2.0.1
8
+ nltk>=3.6.5
9
+ sentence-transformers>=2.2.2
10
+ gradio>=3.50.0,<4.0.0
11
+ openai>=1.0.0
retrieval/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # 검색 모듈 패키지
retrieval/base_retriever.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 기본 검색기 인터페이스
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Dict, Any, Union, Tuple
7
+
8
+ class BaseRetriever(ABC):
9
+ """검색 인터페이스를 정의하는 추상 기본 클래스"""
10
+
11
+ @abstractmethod
12
+ def search(self, query: str, top_k: int = 5, **kwargs) -> List[Dict[str, Any]]:
13
+ """
14
+ 주어진 쿼리에 대해 검색을 수행하고 결과를 반환합니다.
15
+
16
+ Args:
17
+ query: 검색 쿼리
18
+ top_k: 반환할 상위 결과 수
19
+ **kwargs: 추가 검색 매개변수
20
+
21
+ Returns:
22
+ 검색 결과 목록 (각 결과는 딕셔너리 형태)
23
+ """
24
+ pass
25
+
26
+ @abstractmethod
27
+ def add_documents(self, documents: List[Dict[str, Any]]) -> None:
28
+ """
29
+ 검색기에 문서를 추가합니다.
30
+
31
+ Args:
32
+ documents: 추가할 문서 목록 (각 문서는 딕셔너리 형태)
33
+ """
34
+ pass
35
+
36
+ def get_relevant_documents(self, query: str, top_k: int = 5, **kwargs) -> List[Dict[str, Any]]:
37
+ """
38
+ search 메서드의 별칭
39
+
40
+ Args:
41
+ query: 검색 쿼리
42
+ top_k: 반환할 상위 결과 수
43
+ **kwargs: 추가 검색 매개변수
44
+
45
+ Returns:
46
+ 검색 결과 목록
47
+ """
48
+ return self.search(query, top_k, **kwargs)
retrieval/reranker.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 재순위화 검색 구현 모듈
3
+ """
4
+
5
+ import logging
6
+ from typing import List, Dict, Any, Optional, Union, Callable
7
+ from .base_retriever import BaseRetriever
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class ReRanker(BaseRetriever):
12
+ """
13
+ 검색 결과 재순위화 검색기
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ base_retriever: BaseRetriever,
19
+ rerank_model: Optional[Union[str, Any]] = None,
20
+ rerank_fn: Optional[Callable] = None,
21
+ rerank_field: str = "text",
22
+ rerank_batch_size: int = 32
23
+ ):
24
+ """
25
+ ReRanker 초기화
26
+
27
+ Args:
28
+ base_retriever: 기본 검색기 인스턴스
29
+ rerank_model: 재순위화 모델 (Cross-Encoder) 이름 또는 인스턴스
30
+ rerank_fn: 사용자 정의 재순위화 함수 (제공된 경우 rerank_model 대신 사용)
31
+ rerank_field: 재순위화에 사용할 문서 필드
32
+ rerank_batch_size: 재순위화 모델 배치 크기
33
+ """
34
+ self.base_retriever = base_retriever
35
+ self.rerank_field = rerank_field
36
+ self.rerank_batch_size = rerank_batch_size
37
+ self.rerank_fn = rerank_fn
38
+
39
+ # 재순위화 모델 로드 (사용자 정의 함수가 제공되지 않은 경우)
40
+ if rerank_fn is None and rerank_model is not None:
41
+ try:
42
+ from sentence_transformers import CrossEncoder
43
+ if isinstance(rerank_model, str):
44
+ logger.info(f"재순위화 모델 '{rerank_model}' 로드 중...")
45
+ self.rerank_model = CrossEncoder(rerank_model)
46
+ else:
47
+ self.rerank_model = rerank_model
48
+ except ImportError:
49
+ logger.warning("sentence-transformers 패키지가 설치되지 않았습니다. pip install sentence-transformers 명령으로 설치하세요.")
50
+ raise
51
+ else:
52
+ self.rerank_model = None
53
+
54
+ def add_documents(self, documents: List[Dict[str, Any]]) -> None:
55
+ """
56
+ 기본 검색기에 문서 추가
57
+
58
+ Args:
59
+ documents: 추가할 문서 목록
60
+ """
61
+ self.base_retriever.add_documents(documents)
62
+
63
+ def search(self, query: str, top_k: int = 5, first_stage_k: int = 30, **kwargs) -> List[Dict[str, Any]]:
64
+ """
65
+ 2단계 검색 수행: 기본 검색 + 재순위화
66
+
67
+ Args:
68
+ query: 검색 쿼리
69
+ top_k: 최종적으로 반환할 상위 결과 수
70
+ first_stage_k: 첫 번째 단계에서 검색할 결과 수
71
+ **kwargs: 추가 검색 매개변수
72
+
73
+ Returns:
74
+ 재순위화된 검색 결과 목록
75
+ """
76
+ # 첫 번째 단계: 기본 검색기로 more_k 문서 검색
77
+ logger.info(f"기본 검색기로 {first_stage_k}개 문서 검색 중...")
78
+ initial_results = self.base_retriever.search(query, top_k=first_stage_k, **kwargs)
79
+
80
+ if not initial_results:
81
+ logger.warning("첫 번째 단계 검색 결과가 없습니다.")
82
+ return []
83
+
84
+ if len(initial_results) < first_stage_k:
85
+ logger.info(f"요청한 {first_stage_k}개보다 적은 {len(initial_results)}개 결과를 검색했습니다.")
86
+
87
+ # 사용자 정의 재순위화 함수가 제공된 경우
88
+ if self.rerank_fn is not None:
89
+ logger.info("사용자 정의 함수로 재순위화 중...")
90
+ reranked_results = self.rerank_fn(query, initial_results)
91
+ return reranked_results[:top_k]
92
+
93
+ # 재순위화 모델이 로드된 경우
94
+ elif self.rerank_model is not None:
95
+ logger.info(f"CrossEncoder 모델로 재순위화 중...")
96
+
97
+ # 텍스트 쌍 생성
98
+ text_pairs = []
99
+ for doc in initial_results:
100
+ if self.rerank_field not in doc:
101
+ logger.warning(f"문서에 필드 '{self.rerank_field}'가 없습니다.")
102
+ continue
103
+ text_pairs.append([query, doc[self.rerank_field]])
104
+
105
+ # 모델로 점수 계산
106
+ scores = self.rerank_model.predict(
107
+ text_pairs,
108
+ batch_size=self.rerank_batch_size,
109
+ show_progress_bar=True if len(text_pairs) > 10 else False
110
+ )
111
+
112
+ # 결과 재정렬
113
+ for idx, doc in enumerate(initial_results[:len(scores)]):
114
+ doc["rerank_score"] = float(scores[idx])
115
+
116
+ reranked_results = sorted(
117
+ initial_results[:len(scores)],
118
+ key=lambda x: x.get("rerank_score", 0),
119
+ reverse=True
120
+ )
121
+
122
+ return reranked_results[:top_k]
123
+
124
+ # 재순위화 없이 초기 결과 반환
125
+ else:
126
+ logger.info("재순위화 모델/함수가 없어 초기 검색 결과를 그대로 반환합니다.")
127
+ return initial_results[:top_k]
retrieval/vector_retriever.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 벡터 검색 구현 모듈
3
+ """
4
+
5
+ import os
6
+ import numpy as np
7
+ from typing import List, Dict, Any, Optional, Union, Tuple
8
+ import logging
9
+ from sentence_transformers import SentenceTransformer
10
+ from .base_retriever import BaseRetriever
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class VectorRetriever(BaseRetriever):
15
+ """
16
+ 임베딩 기반 벡터 검색 구현
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ embedding_model: Optional[Union[str, SentenceTransformer]] = "paraphrase-multilingual-MiniLM-L12-v2",
22
+ documents: Optional[List[Dict[str, Any]]] = None,
23
+ embedding_field: str = "text",
24
+ embedding_device: str = "cpu"
25
+ ):
26
+ """
27
+ VectorRetriever 초기화
28
+
29
+ Args:
30
+ embedding_model: 임베딩 모델 이름 또는 SentenceTransformer 인스턴스
31
+ documents: 초기 문서 목록 (선택 사항)
32
+ embedding_field: 임베딩할 문서 필드 이름
33
+ embedding_device: 임베딩 모델 실행 장치 ('cpu' 또는 'cuda')
34
+ """
35
+ self.embedding_field = embedding_field
36
+ self.model_name = None
37
+
38
+ # 임베딩 모델 로드
39
+ if isinstance(embedding_model, str):
40
+ logger.info(f"임베딩 모델 '{embedding_model}' 로드 중...")
41
+ self.model_name = embedding_model
42
+ self.embedding_model = SentenceTransformer(embedding_model, device=embedding_device)
43
+ else:
44
+ self.embedding_model = embedding_model
45
+ # 모델이 이미 로드된 인스턴스일 경우 이름 추출
46
+ if hasattr(embedding_model, '_modules') and 'modules' in embedding_model._modules:
47
+ self.model_name = "loaded_sentence_transformer"
48
+
49
+ # 문서 저장소 초기화
50
+ self.documents = []
51
+ self.document_embeddings = None
52
+
53
+ # 초기 문서가 제공된 경우 추가
54
+ if documents:
55
+ self.add_documents(documents)
56
+
57
+ def add_documents(self, documents: List[Dict[str, Any]]) -> None:
58
+ """
59
+ 검색기에 문서를 추가하고 임베딩 생성
60
+
61
+ Args:
62
+ documents: 추가할 문서 목록
63
+ """
64
+ if not documents:
65
+ logger.warning("추가할 문서가 없습니다.")
66
+ return
67
+
68
+ # 문서 추가
69
+ document_texts = []
70
+ for doc in documents:
71
+ if self.embedding_field not in doc:
72
+ logger.warning(f"문서에 필드 '{self.embedding_field}'가 없습니다. 건너뜁니다.")
73
+ continue
74
+
75
+ self.documents.append(doc)
76
+ document_texts.append(doc[self.embedding_field])
77
+
78
+ if not document_texts:
79
+ logger.warning(f"임베딩할 텍스트가 없습니다. 모든 문서에 '{self.embedding_field}' 필드가 있는지 확인하세요.")
80
+ return
81
+
82
+ # 문서 임베딩 생성
83
+ logger.info(f"{len(document_texts)}개 문서의 임베딩 생성 중...")
84
+ new_embeddings = self.embedding_model.encode(document_texts, show_progress_bar=True)
85
+
86
+ # 기존 임베딩과 병합
87
+ if self.document_embeddings is None:
88
+ self.document_embeddings = new_embeddings
89
+ else:
90
+ self.document_embeddings = np.vstack([self.document_embeddings, new_embeddings])
91
+
92
+ logger.info(f"총 {len(self.documents)}개 문서, {self.document_embeddings.shape[0]}개 임베딩 저장됨")
93
+
94
+ def search(self, query: str, top_k: int = 5, **kwargs) -> List[Dict[str, Any]]:
95
+ """
96
+ 쿼리에 대한 벡터 검색 수행
97
+
98
+ Args:
99
+ query: 검색 쿼리
100
+ top_k: 반환할 상위 결과 수
101
+ **kwargs: 추가 검색 매개변수
102
+
103
+ Returns:
104
+ 관련성 점수와 함께 검색된 문서 목록
105
+ """
106
+ if not self.documents or self.document_embeddings is None:
107
+ logger.warning("검색할 문서가 없습니다.")
108
+ return []
109
+
110
+ # 쿼리 임베딩 생성
111
+ query_embedding = self.embedding_model.encode(query)
112
+
113
+ # 코사인 유사도 계산
114
+ scores = np.dot(self.document_embeddings, query_embedding) / (
115
+ np.linalg.norm(self.document_embeddings, axis=1) * np.linalg.norm(query_embedding)
116
+ )
117
+
118
+ # 상위 결과 선택
119
+ top_indices = np.argsort(scores)[-top_k:][::-1]
120
+
121
+ # 결과 형식화
122
+ results = []
123
+ for idx in top_indices:
124
+ doc = self.documents[idx].copy()
125
+ doc["score"] = float(scores[idx])
126
+ results.append(doc)
127
+
128
+ return results
129
+
130
+ def save(self, directory: str) -> None:
131
+ """
132
+ 검색기 상태를 디스크에 저장
133
+
134
+ Args:
135
+ directory: 저장할 디렉토리 경로
136
+ """
137
+ import pickle
138
+ import json
139
+
140
+ os.makedirs(directory, exist_ok=True)
141
+
142
+ # 문서 저장
143
+ with open(os.path.join(directory, "documents.json"), "w", encoding="utf-8") as f:
144
+ json.dump(self.documents, f, ensure_ascii=False, indent=2)
145
+
146
+ # 임베딩 저장
147
+ if self.document_embeddings is not None:
148
+ np.save(os.path.join(directory, "embeddings.npy"), self.document_embeddings)
149
+
150
+ # 모델 정보 저장
151
+ model_info = {
152
+ "model_name": self.model_name or "paraphrase-multilingual-MiniLM-L12-v2", # 기본값 설정
153
+ "embedding_dim": self.embedding_model.get_sentence_embedding_dimension() if hasattr(self.embedding_model, 'get_sentence_embedding_dimension') else 384
154
+ }
155
+
156
+ with open(os.path.join(directory, "model_info.json"), "w") as f:
157
+ json.dump(model_info, f)
158
+
159
+ logger.info(f"검색기 상태를 '{directory}'에 저장했습니다.")
160
+
161
+ @classmethod
162
+ def load(cls, directory: str, embedding_model: Optional[Union[str, SentenceTransformer]] = None) -> "VectorRetriever":
163
+ """
164
+ 디스크에서 검색기 상태를 로드
165
+
166
+ Args:
167
+ directory: 로드할 디렉토리 경로
168
+ embedding_model: 사용할 임베딩 모델 (제공되지 않으면 저장된 정보 사용)
169
+
170
+ Returns:
171
+ 로드된 VectorRetriever 인스턴스
172
+ """
173
+ import json
174
+
175
+ # 모델 정보 로드
176
+ with open(os.path.join(directory, "model_info.json"), "r") as f:
177
+ model_info = json.load(f)
178
+
179
+ # 임베딩 모델 인스턴스화
180
+ if embedding_model is None:
181
+ # 모델 이름을 사용하여 모델 인스턴스화
182
+ if "model_name" in model_info and isinstance(model_info["model_name"], str):
183
+ embedding_model = model_info["model_name"]
184
+ else:
185
+ # 안전장치: 모델 이름이 없거나 정수인 경우(이전 버전 호환성) 기본 모델 사용
186
+ logger.warning("유효한 모델 이름을 찾을 수 없습니다. 기본 모델을 사용합니다.")
187
+ embedding_model = "paraphrase-multilingual-MiniLM-L12-v2"
188
+
189
+ # 검색기 인스턴스 생성 (문서 없이)
190
+ retriever = cls(embedding_model=embedding_model)
191
+
192
+ # 문서 로드
193
+ with open(os.path.join(directory, "documents.json"), "r", encoding="utf-8") as f:
194
+ retriever.documents = json.load(f)
195
+
196
+ # 임베딩 로드
197
+ embeddings_path = os.path.join(directory, "embeddings.npy")
198
+ if os.path.exists(embeddings_path):
199
+ retriever.document_embeddings = np.load(embeddings_path)
200
+
201
+ logger.info(f"검색기 상태를 '{directory}'에서 로드했습니다.")
202
+ return retriever
run.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 개발 환경에서 빠르게 실행할 수 있는 스크립트
3
+ """
4
+ import os
5
+ import logging
6
+ import webbrowser
7
+ from app.app import app
8
+
9
+ # 로거 설정
10
+ logging.basicConfig(
11
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
12
+ level=logging.INFO
13
+ )
14
+ logger = logging.getLogger(__name__)
15
+
16
+ if __name__ == "__main__":
17
+ port = int(os.environ.get("PORT", 5000))
18
+ logger.info(f"서버를 http://localhost:{port}에서 시작합니다...")
19
+
20
+ # 브라우저 자동 실행 (선택 사항)
21
+ webbrowser.open_new(f"http://localhost:{port}")
22
+
23
+ # 플라스크 서버 실행
24
+ app.run(debug=True, host="0.0.0.0", port=port)
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.10.12
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # 유틸리티 모듈 패키지
utils/deepseek_client.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepSeek API 클라이언트 모듈
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ from typing import List, Dict, Any, Optional, Union
9
+ from dotenv import load_dotenv
10
+ import requests
11
+
12
+ # 환경 변수 로드
13
+ load_dotenv()
14
+
15
+ # 로거 설정
16
+ logger = logging.getLogger("DeepSeekLLM")
17
+ if not logger.hasHandlers():
18
+ handler = logging.StreamHandler()
19
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
20
+ handler.setFormatter(formatter)
21
+ logger.addHandler(handler)
22
+ logger.setLevel(logging.INFO)
23
+
24
+ class DeepSeekLLM:
25
+ """DeepSeek API 래퍼 클래스"""
26
+
27
+ def __init__(self):
28
+ """DeepSeek LLM 클래스 초기화"""
29
+ self.api_key = os.getenv("DEEPSEEK_API_KEY")
30
+ self.api_base = os.getenv("DEEPSEEK_API_BASE", "https://api.deepseek.com")
31
+ self.model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
32
+
33
+ if not self.api_key:
34
+ logger.warning("DeepSeek API 키가 .env 파일에 설정되지 않았습니다.")
35
+ logger.warning("DEEPSEEK_API_KEY를 확인하세요.")
36
+ else:
37
+ logger.info("DeepSeek API 키 로드 완료.")
38
+
39
+ def chat_completion(
40
+ self,
41
+ messages: List[Dict[str, str]],
42
+ temperature: float = 0.7,
43
+ max_tokens: int = 1000,
44
+ **kwargs
45
+ ) -> Dict[str, Any]:
46
+ """
47
+ DeepSeek 채팅 완성 API 호출
48
+
49
+ Args:
50
+ messages: 채팅 메시지 목록
51
+ temperature: 생성 온도 (낮을수록 결정적)
52
+ max_tokens: 생성할 최대 토큰 수
53
+ **kwargs: 추가 API 매개변수
54
+
55
+ Returns:
56
+ API 응답 (딕셔너리)
57
+ """
58
+ if not self.api_key:
59
+ logger.error("API 키가 설정되지 않아 DeepSeek API를 호출할 수 없습니다.")
60
+ raise ValueError("DeepSeek API 키가 설정되지 않았습니다.")
61
+
62
+ try:
63
+ logger.info(f"DeepSeek API 요청 전송 중 (모델: {self.model})")
64
+
65
+ # API 요청 헤더 및 데이터 준비
66
+ headers = {
67
+ "Authorization": f"Bearer {self.api_key}",
68
+ "Content-Type": "application/json"
69
+ }
70
+
71
+ data = {
72
+ "model": self.model,
73
+ "messages": messages,
74
+ "temperature": temperature,
75
+ "max_tokens": max_tokens
76
+ }
77
+
78
+ # 추가 매개변수 병합
79
+ for key, value in kwargs.items():
80
+ if key not in data:
81
+ data[key] = value
82
+
83
+ # API 요청 보내기
84
+ endpoint = f"{self.api_base}/v1/chat/completions"
85
+ response = requests.post(
86
+ endpoint,
87
+ headers=headers,
88
+ json=data
89
+ )
90
+
91
+ # 응답 검증
92
+ response.raise_for_status()
93
+ return response.json()
94
+
95
+ except requests.exceptions.RequestException as e:
96
+ logger.error(f"DeepSeek API 요청 실패: {e}")
97
+ raise Exception(f"DeepSeek API 요청 실패: {e}")
98
+ except json.JSONDecodeError as e:
99
+ logger.error(f"DeepSeek API 응답 파싱 실패: {e}")
100
+ raise Exception(f"DeepSeek API 응답 파싱 실패: {e}")
101
+
102
+ def generate(
103
+ self,
104
+ prompt: str,
105
+ system_prompt: Optional[str] = None,
106
+ temperature: float = 0.7,
107
+ max_tokens: int = 1000,
108
+ **kwargs
109
+ ) -> str:
110
+ """
111
+ 간단한 텍스트 생성 인터페이스
112
+
113
+ Args:
114
+ prompt: 사용자 프롬프트
115
+ system_prompt: 시스템 프롬프트 (선택 사항)
116
+ temperature: 생성 온도
117
+ max_tokens: 생성할 최대 토큰 수
118
+ **kwargs: 추가 API 매개변수
119
+
120
+ Returns:
121
+ 생성된 텍스트
122
+ """
123
+ messages = []
124
+
125
+ if system_prompt:
126
+ messages.append({"role": "system", "content": system_prompt})
127
+
128
+ messages.append({"role": "user", "content": prompt})
129
+
130
+ try:
131
+ response = self.chat_completion(
132
+ messages=messages,
133
+ temperature=temperature,
134
+ max_tokens=max_tokens,
135
+ **kwargs
136
+ )
137
+
138
+ # 응답 검증 및 처리
139
+ if not response or 'choices' not in response or not response['choices']:
140
+ logger.error("DeepSeek API 응답에서 생성된 텍스트를 찾을 수 없습니다.")
141
+ return ""
142
+
143
+ return response['choices'][0]['message']['content'].strip()
144
+
145
+ except Exception as e:
146
+ logger.error(f"텍스트 생성 중 오류 발생: {e}")
147
+ return f"오류: {str(e)}"
148
+
149
+ def rag_generate(
150
+ self,
151
+ query: str,
152
+ context: List[str],
153
+ system_prompt: Optional[str] = None,
154
+ temperature: float = 0.3,
155
+ max_tokens: int = 1000,
156
+ **kwargs
157
+ ) -> str:
158
+ """
159
+ RAG 검색 결과를 활용한 텍스트 생성
160
+
161
+ Args:
162
+ query: 사용자 질의
163
+ context: 검색된 문맥 목록
164
+ system_prompt: 시스템 프롬프트 (선택 사항)
165
+ temperature: 생성 온도
166
+ max_tokens: 생성할 최대 토큰 수
167
+ **kwargs: 추가 API 매개변수
168
+
169
+ Returns:
170
+ 생성된 텍스트
171
+ """
172
+ if not system_prompt:
173
+ system_prompt = """당신은 검색 결과를 기반으로 질문에 답변하는 도우미입니다.
174
+ - 검색 결과는 <context> 태그 안에 제공됩니다.
175
+ - 검색 결과에 답변이 있으면 해당 정보를 사용하여 명확하게 답변하세요.
176
+ - 검색 결과에 답변이 없으면 "검색 결과에 관련 정보가 없습니다"라고 말하세요.
177
+ - 검색 내용을 그대로 복사하지 말고, 자연스러운 한국어로 답변을 작성하세요.
178
+ - 답변은 간결하고 정확하게 제공하세요."""
179
+
180
+ # 중요: 컨텍스트 길이 제한
181
+ max_context = 10
182
+ if len(context) > max_context:
183
+ logger.warning(f"컨텍스트가 너무 길어 처음 {max_context}개만 사용합니다.")
184
+ context = context[:max_context]
185
+
186
+ # 각 컨텍스트 액세스
187
+ limited_context = []
188
+ for i, doc in enumerate(context):
189
+ # 각 문서를 1000자로 제한
190
+ if len(doc) > 1000:
191
+ logger.warning(f"문서 {i+1}의 길이가 제한되었습니다 ({len(doc)} -> 1000)")
192
+ doc = doc[:1000] + "...(생략)"
193
+ limited_context.append(doc)
194
+
195
+ context_text = "\n\n".join([f"문서 {i+1}: {doc}" for i, doc in enumerate(limited_context)])
196
+
197
+ prompt = f"""질문: {query}
198
+
199
+ <context>
200
+ {context_text}
201
+ </context>
202
+
203
+ 위 검색 결과를 참고하여 질문에 답변해 주세요."""
204
+
205
+ try:
206
+ return self.generate(
207
+ prompt=prompt,
208
+ system_prompt=system_prompt,
209
+ temperature=temperature,
210
+ max_tokens=max_tokens,
211
+ **kwargs
212
+ )
213
+ except Exception as e:
214
+ logger.error(f"RAG 텍스트 생성 중 오류 발생: {e}")
215
+ return f"오류: {str(e)}"
utils/document_processor.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 문서 처리 유틸리티 모듈
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import csv
8
+ import io
9
+ import logging
10
+ from typing import List, Dict, Any, Optional, Tuple, Union
11
+ import numpy as np
12
+
13
+ logger = logging.getLogger("DocProcessor")
14
+ if not logger.hasHandlers():
15
+ handler = logging.StreamHandler()
16
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
17
+ handler.setFormatter(formatter)
18
+ logger.addHandler(handler)
19
+ logger.setLevel(logging.INFO)
20
+
21
+ class DocumentProcessor:
22
+ """문서 처리 유틸리티 클래스"""
23
+
24
+ @staticmethod
25
+ def split_text(
26
+ text: str,
27
+ chunk_size: int = 512,
28
+ chunk_overlap: int = 50,
29
+ separator: str = "\n"
30
+ ) -> List[str]:
31
+ """
32
+ 텍스트를 더 작은 청크로 분할
33
+
34
+ Args:
35
+ text: 분할할 텍스트
36
+ chunk_size: 각 청크의 최대 문자 수
37
+ chunk_overlap: 청크 간 중첩되는 문자 수
38
+ separator: 분할 시 사용할 구분자
39
+
40
+ Returns:
41
+ 분할된 텍스트 청크 목록
42
+ """
43
+ if not text or chunk_size <= 0:
44
+ return []
45
+
46
+ # 구분자로 분할
47
+ parts = text.split(separator)
48
+ chunks = []
49
+ current_chunk = []
50
+ current_size = 0
51
+
52
+ for part in parts:
53
+ part_size = len(part)
54
+
55
+ if current_size + part_size + len(current_chunk) > chunk_size and current_chunk:
56
+ # 현재 청크가 최대 크기를 초과하면 저장
57
+ chunks.append(separator.join(current_chunk))
58
+
59
+ # 중첩을 위해 일부 청크 유지
60
+ overlap_tokens = []
61
+ overlap_size = 0
62
+ for token in reversed(current_chunk):
63
+ if overlap_size + len(token) <= chunk_overlap:
64
+ overlap_tokens.insert(0, token)
65
+ overlap_size += len(token) + 1 # separator 길이 포함
66
+ else:
67
+ break
68
+
69
+ current_chunk = overlap_tokens
70
+ current_size = overlap_size - len(current_chunk) # separator 길이 제외
71
+
72
+ current_chunk.append(part)
73
+ current_size += part_size
74
+
75
+ # 마지막 청크 추가
76
+ if current_chunk:
77
+ chunks.append(separator.join(current_chunk))
78
+
79
+ return chunks
80
+
81
+ @staticmethod
82
+ def clean_text(text: str, remove_urls: bool = True, remove_extra_whitespace: bool = True) -> str:
83
+ """
84
+ 텍스트 정제
85
+
86
+ Args:
87
+ text: 정제할 텍스트
88
+ remove_urls: URL 제거 여부
89
+ remove_extra_whitespace: 여분의 공백 제거 여부
90
+
91
+ Returns:
92
+ 정제된 텍스트
93
+ """
94
+ if not text:
95
+ return ""
96
+
97
+ # URL 제거
98
+ if remove_urls:
99
+ text = re.sub(r'https?://\S+|www\.\S+', '', text)
100
+
101
+ # 특수 문자 및 HTML 태그 정제
102
+ text = re.sub(r'<.*?>', '', text) # HTML 태그 제거
103
+
104
+ # 여분의 공백 제거
105
+ if remove_extra_whitespace:
106
+ text = re.sub(r'\s+', ' ', text).strip()
107
+
108
+ return text
109
+
110
+ @staticmethod
111
+ def text_to_documents(
112
+ text: str,
113
+ metadata: Optional[Dict[str, Any]] = None,
114
+ chunk_size: int = 512,
115
+ chunk_overlap: int = 50
116
+ ) -> List[Dict[str, Any]]:
117
+ """
118
+ 텍스트를 문서 객체 목록으로 변환
119
+
120
+ Args:
121
+ text: 변환할 텍스트
122
+ metadata: 문서에 추가할 메타데이터
123
+ chunk_size: 각 청크의 최대 문자 수
124
+ chunk_overlap: 청크 간 중첩되는 문자 수
125
+
126
+ Returns:
127
+ 문서 객체 목록
128
+ """
129
+ if not text:
130
+ return []
131
+
132
+ # 텍스트 정제
133
+ clean = DocumentProcessor.clean_text(text)
134
+
135
+ # 텍스트 분할
136
+ chunks = DocumentProcessor.split_text(
137
+ clean,
138
+ chunk_size=chunk_size,
139
+ chunk_overlap=chunk_overlap
140
+ )
141
+
142
+ # 문서 객체 생성
143
+ documents = []
144
+ for i, chunk in enumerate(chunks):
145
+ doc = {
146
+ "text": chunk,
147
+ "index": i,
148
+ "chunk_count": len(chunks)
149
+ }
150
+
151
+ # 메타데이터 추가
152
+ if metadata:
153
+ doc.update(metadata)
154
+
155
+ documents.append(doc)
156
+
157
+ return documents
158
+
159
+ @staticmethod
160
+ def load_documents_from_directory(
161
+ directory: str,
162
+ extensions: List[str] = [".txt", ".md", ".csv"],
163
+ recursive: bool = True,
164
+ chunk_size: int = 512,
165
+ chunk_overlap: int = 50
166
+ ) -> List[Dict[str, Any]]:
167
+ """
168
+ 디렉토리에서 문서 로드 및 처리
169
+
170
+ Args:
171
+ directory: 로드할 디렉토리 경로
172
+ extensions: 처리할 파일 확장자 목록
173
+ recursive: 하위 디렉토리 검색 여부
174
+ chunk_size: 각 청크의 최대 문자 수
175
+ chunk_overlap: 청크 간 중첩되는 문자 수
176
+
177
+ Returns:
178
+ 문서 객체 목록
179
+ """
180
+ if not os.path.isdir(directory):
181
+ logger.error(f"디렉토리를 찾을 수 없습니다: {directory}")
182
+ return []
183
+
184
+ documents = []
185
+
186
+ for root, dirs, files in os.walk(directory):
187
+ if not recursive and root != directory:
188
+ continue
189
+
190
+ for file in files:
191
+ _, ext = os.path.splitext(file)
192
+ if ext.lower() not in extensions:
193
+ continue
194
+
195
+ file_path = os.path.join(root, file)
196
+ rel_path = os.path.relpath(file_path, directory)
197
+
198
+ try:
199
+ logger.info(f"파일 로드 중: {rel_path}")
200
+ # 먼저 UTF-8로 시도
201
+ try:
202
+ with open(file_path, 'r', encoding='utf-8') as f:
203
+ content = f.read()
204
+ except UnicodeDecodeError:
205
+ # UTF-8로 실패하면 CP949(한국어 Windows 기본 인코딩)로 시도
206
+ logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {rel_path}")
207
+ with open(file_path, 'r', encoding='cp949') as f:
208
+ content = f.read()
209
+
210
+ # 메타데이터 생성
211
+ metadata = {
212
+ "source": rel_path,
213
+ "filename": file,
214
+ "filetype": ext.lower()[1:],
215
+ "filepath": file_path
216
+ }
217
+
218
+ # CSV 파일은 특별 처리
219
+ if ext.lower() == '.csv':
220
+ logger.info(f"CSV 파일 감지, 행 단위로 분할 처리: {rel_path}")
221
+ file_docs = DocumentProcessor.csv_to_documents(content, metadata)
222
+ else:
223
+ # 일반 텍스트 문서 처리
224
+ file_docs = DocumentProcessor.text_to_documents(
225
+ content,
226
+ metadata=metadata,
227
+ chunk_size=chunk_size,
228
+ chunk_overlap=chunk_overlap
229
+ )
230
+
231
+ documents.extend(file_docs)
232
+ logger.info(f"{len(file_docs)}개 청크 추출: {rel_path}")
233
+
234
+ except Exception as e:
235
+ logger.error(f"파일 '{rel_path}' 처리 중 오류 발생: {e}")
236
+ continue
237
+
238
+ logger.info(f"총 {len(documents)}개 문서 청크를 로드했습니다.")
239
+ return documents
240
+
241
+ @staticmethod
242
+ def prepare_rag_context(results: List[Dict[str, Any]], field: str = "text") -> List[str]:
243
+ """
244
+ 검색 결과에서 RAG에 사용할 컨텍스트 추출
245
+
246
+ Args:
247
+ results: 검색 결과 목록
248
+ field: 텍스트 내용이 있는 필드 이름
249
+
250
+ Returns:
251
+ 컨텍스트 텍스트 목록
252
+ """
253
+ context = []
254
+
255
+ for result in results:
256
+ if field in result:
257
+ context.append(result[field])
258
+
259
+ return context
260
+
261
+ @staticmethod
262
+ def csv_to_documents(content: str, metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
263
+ """
264
+ CSV 파일 내용을 행 단위로 분리하여 각 행을 별도의 문서로 처리
265
+
266
+ Args:
267
+ content: CSV 파일의 내용
268
+ metadata: 기본 메타데이터
269
+
270
+ Returns:
271
+ 문서 객체 목록 (각 행이 별도의 문서)
272
+ """
273
+ documents = []
274
+
275
+ try:
276
+ # 일반 CSV 파싱 시도 (코마 구분자 기본)
277
+ try:
278
+ csv_reader = csv.reader(io.StringIO(content))
279
+ rows = list(csv_reader)
280
+ if len(rows) > 0 and len(rows[0]) > 1:
281
+ # 코마로 제대로 구분되었다고 판단
282
+ logger.info(f"CSV 파일 코마 구분자로 처리: {metadata.get('source', 'unknown')}")
283
+ has_valid_format = True
284
+ else:
285
+ # 코마로 제대로 구분되지 않음
286
+ has_valid_format = False
287
+ except Exception:
288
+ has_valid_format = False
289
+
290
+ # 코마 형식이 아닌 경우, 공백 구분자 처리 시도
291
+ if not has_valid_format:
292
+ logger.warning(f"CSV 파일이 표준 코마 형식이 아닙니다. 공백 구분자로 처리하겠습니다: {metadata.get('source', 'unknown')}")
293
+ lines = content.strip().split('\n')
294
+
295
+ for i, line in enumerate(lines):
296
+ # IT로 시작하는 줄만 처리 (데이터 행으로 간주)
297
+ if not line.strip().startswith('IT'):
298
+ continue
299
+
300
+ # 공백으로 분리하되, 최소 5개 열로 보장
301
+ parts = line.split(maxsplit=4)
302
+
303
+ # 유효한 행의 최소 길이 확인
304
+ if len(parts) < 5:
305
+ logger.warning(f"행 {i+1} 부족한 데이터: {line[:50]}...")
306
+ continue
307
+
308
+ # 각 필드 추출
309
+ doc_id = parts[0].strip() # IT 번호
310
+ query_type = parts[1].strip() # 쿼리 유형
311
+ question = parts[2].strip() # 질문
312
+ answer = parts[3].strip() # 답변
313
+ reference = parts[4].strip() if len(parts) > 4 else "" # 참조
314
+
315
+ # 문서 텍스트 생성 - 각 필드를 구분하여 포함
316
+ text = f"ID: {doc_id}\n"
317
+ text += f"쿼리 유형: {query_type}\n"
318
+ text += f"질의 (Question): {question}\n"
319
+ text += f"응답 (Answer): {answer}\n"
320
+ if reference:
321
+ text += f"참조 문서/맥락 (Reference/Context): {reference}"
322
+
323
+ # 문서 객체 생성
324
+ doc_metadata = metadata.copy()
325
+ doc_metadata.update({
326
+ "row": i,
327
+ "query_type": query_type,
328
+ "question": question,
329
+ "answer": answer,
330
+ "reference": reference
331
+ })
332
+
333
+ document = {
334
+ "text": text,
335
+ "id": doc_id, # IT 번호를 ID로 사용
336
+ **doc_metadata
337
+ }
338
+
339
+ documents.append(document)
340
+ logger.debug(f"IT 문서 처리: {doc_id} - {question[:30]}...")
341
+
342
+ logger.info(f"공백 구분자 CSV 파일 '{metadata.get('source', 'unknown')}'에서 {len(documents)}개 행을 문서로 변환했습니다.")
343
+ return documents
344
+
345
+ # 표준 CSV 형식 처리 (코마 구분자 사용)
346
+ if not rows:
347
+ logger.warning(f"CSV 파일에 데이터가 없습니다: {metadata.get('source', 'unknown')}")
348
+ return []
349
+
350
+ # 첫 번째 행을 헤더로 사용
351
+ headers = rows[0]
352
+ logger.debug(f"CSV 헤더: {headers}")
353
+
354
+ # 각 행을 별도의 문서로 변환
355
+ for i, row in enumerate(rows[1:], 1): # 헤더 제외, 1부터 시작
356
+ # 행이 헤더보다 짧으면 빈 값으로 채움
357
+ while len(row) < len(headers):
358
+ row.append("")
359
+
360
+ # 행 데이터를 사전형으로 변환
361
+ row_data = {headers[j]: value for j, value in enumerate(row) if j < len(headers)}
362
+
363
+ # 첫 번째 열을 ID로 사용 (있는 경우)
364
+ row_id = row[0] if row and len(row) > 0 else f"row_{i}"
365
+
366
+ # 문서 텍스트 생성 - 모든 필드를 포함한 표현
367
+ text_parts = []
368
+ for j, header in enumerate(headers):
369
+ if j < len(row) and row[j]:
370
+ text_parts.append(f"{header}: {row[j]}")
371
+
372
+ text = "\n".join(text_parts)
373
+
374
+ # 문서 객체 생성
375
+ doc_metadata = metadata.copy()
376
+ doc_metadata.update({
377
+ "row": i,
378
+ "row_id": row_id,
379
+ "total_rows": len(rows) - 1, # 헤더 제외
380
+ "csv_data": row_data # 원본 행 데이터도 저장
381
+ })
382
+
383
+ document = {
384
+ "text": text,
385
+ "id": row_id,
386
+ **doc_metadata
387
+ }
388
+
389
+ documents.append(document)
390
+
391
+ logger.info(f"CSV 파일 '{metadata.get('source', 'unknown')}'에서 {len(documents)}개 행을 문서로 변환했습니다.")
392
+
393
+ except Exception as e:
394
+ logger.error(f"CSV 파일 처리 중 오류 발생: {e}")
395
+
396
+ return documents
utils/llm_client.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepSeek LLM API 클라이언트 모듈
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import requests
9
+ from typing import List, Dict, Any, Optional, Union
10
+ from dotenv import load_dotenv
11
+
12
+ # 환경 변수 로드
13
+ load_dotenv()
14
+
15
+ # 로거 설정
16
+ logger = logging.getLogger("DeepSeekLLM")
17
+ if not logger.hasHandlers():
18
+ handler = logging.StreamHandler()
19
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
20
+ handler.setFormatter(formatter)
21
+ logger.addHandler(handler)
22
+ logger.setLevel(logging.INFO)
23
+
24
+ class DeepSeekLLM:
25
+ """DeepSeek LLM API 래퍼 클래스"""
26
+
27
+ def __init__(self):
28
+ """DeepSeek LLM 클래스 초기화"""
29
+ self.api_key = os.getenv("DEEPSEEK_API_KEY")
30
+ self.endpoint = os.getenv("DEEPSEEK_ENDPOINT", "https://api.deepseek.com/v1/chat/completions")
31
+ self.model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
32
+
33
+ if not self.api_key:
34
+ logger.warning("DeepSeek API 키가 .env 파일에 설정되지 않았습니다.")
35
+ logger.warning("DEEPSEEK_API_KEY를 확인하세요.")
36
+ else:
37
+ logger.info("DeepSeek LLM API 키 로드 완료.")
38
+
39
+ def chat_completion(
40
+ self,
41
+ messages: List[Dict[str, str]],
42
+ temperature: float = 0.7,
43
+ max_tokens: int = 1000,
44
+ stream: bool = False,
45
+ **kwargs
46
+ ) -> Dict[str, Any]:
47
+ """
48
+ DeepSeek 채팅 완성 API 호출
49
+
50
+ Args:
51
+ messages: 채팅 메시지 목록
52
+ temperature: 생성 온도 (낮을수록 결정적)
53
+ max_tokens: 생성할 최대 토큰 수
54
+ stream: 스트리밍 응답 활성화 여부
55
+ **kwargs: 추가 API 매개변수
56
+
57
+ Returns:
58
+ API 응답 (딕셔너리)
59
+ """
60
+ if not self.api_key:
61
+ logger.error("API 키가 설정되지 않아 DeepSeek API를 호출할 수 없습니다.")
62
+ raise ValueError("DeepSeek API 키가 설정되지 않았습니다.")
63
+
64
+ headers = {
65
+ "Authorization": f"Bearer {self.api_key}",
66
+ "Content-Type": "application/json"
67
+ }
68
+
69
+ payload = {
70
+ "model": self.model,
71
+ "messages": messages,
72
+ "temperature": temperature,
73
+ "max_tokens": max_tokens,
74
+ "stream": stream,
75
+ **kwargs
76
+ }
77
+
78
+ try:
79
+ logger.info(f"DeepSeek API 요청 전송 중: {self.endpoint}")
80
+ response = requests.post(
81
+ self.endpoint,
82
+ headers=headers,
83
+ json=payload,
84
+ timeout=60 # 타임아웃 설정
85
+ )
86
+ response.raise_for_status()
87
+
88
+ if stream:
89
+ return response # 스트리밍 응답은 원시 응답 객체 반환
90
+ else:
91
+ return response.json()
92
+
93
+ except requests.exceptions.Timeout:
94
+ logger.error("DeepSeek API 요청 시간 초과")
95
+ raise TimeoutError("DeepSeek API 요청 시간 초과")
96
+ except requests.exceptions.RequestException as e:
97
+ logger.error(f"DeepSeek API 요청 실패: {e}")
98
+ if hasattr(e, 'response') and e.response is not None:
99
+ logger.error(f"응답 코드: {e.response.status_code}, 내용: {e.response.text}")
100
+ raise ConnectionError(f"DeepSeek API 요청 실패: {e}")
101
+
102
+ def generate(
103
+ self,
104
+ prompt: str,
105
+ system_prompt: Optional[str] = None,
106
+ temperature: float = 0.7,
107
+ max_tokens: int = 1000,
108
+ **kwargs
109
+ ) -> str:
110
+ """
111
+ 간단한 텍스트 생성 인터페이스
112
+
113
+ Args:
114
+ prompt: 사용자 프롬프트
115
+ system_prompt: 시스템 프롬프트 (선택 사항)
116
+ temperature: 생성 온도
117
+ max_tokens: 생성할 최대 토큰 수
118
+ **kwargs: 추가 API 매개변수
119
+
120
+ Returns:
121
+ 생성된 텍스트
122
+ """
123
+ messages = []
124
+
125
+ if system_prompt:
126
+ messages.append({"role": "system", "content": system_prompt})
127
+
128
+ messages.append({"role": "user", "content": prompt})
129
+
130
+ try:
131
+ response = self.chat_completion(
132
+ messages=messages,
133
+ temperature=temperature,
134
+ max_tokens=max_tokens,
135
+ **kwargs
136
+ )
137
+
138
+ if not response or "choices" not in response or not response["choices"]:
139
+ logger.error("DeepSeek API 응답에서 생성된 텍스트를 찾을 수 없습니다.")
140
+ return ""
141
+
142
+ return response["choices"][0]["message"]["content"].strip()
143
+
144
+ except Exception as e:
145
+ logger.error(f"텍스트 생성 중 오류 발생: {e}")
146
+ return f"오류: {str(e)}"
147
+
148
+ def rag_generate(
149
+ self,
150
+ query: str,
151
+ context: List[str],
152
+ system_prompt: Optional[str] = None,
153
+ temperature: float = 0.3,
154
+ max_tokens: int = 1000,
155
+ **kwargs
156
+ ) -> str:
157
+ """
158
+ RAG 검색 결과를 활용한 텍스트 생성
159
+
160
+ Args:
161
+ query: 사용자 질의
162
+ context: 검색된 문맥 목록
163
+ system_prompt: 시스템 프롬프트 (선택 사항)
164
+ temperature: 생성 온도
165
+ max_tokens: 생성할 최대 토큰 수
166
+ **kwargs: 추가 API 매개변수
167
+
168
+ Returns:
169
+ 생성된 텍스트
170
+ """
171
+ if not system_prompt:
172
+ system_prompt = """당신은 검색 결과를 기반으로 질문에 답변하는 도우미입니다.
173
+ - 검색 결과는 <context> 태그 안에 제공됩니다.
174
+ - 검색 결과에 답변이 있으면 해당 정보를 사용하여 명확하게 답변하세요.
175
+ - 검색 결과에 답변이 없으면 "검색 결과에 관련 정보가 없습니다"라고 말하세요.
176
+ - 검색 내용을 그대로 복사하지 말고, 자연스러운 한국어로 답변을 작성하세요.
177
+ - 답변은 간결하고 정확하게 제공하세요."""
178
+
179
+ context_text = "\n\n".join([f"문서 {i+1}: {doc}" for i, doc in enumerate(context)])
180
+
181
+ prompt = f"""질문: {query}
182
+
183
+ <context>
184
+ {context_text}
185
+ </context>
186
+
187
+ 위 검색 결과를 참고하여 질문에 답변해 주세요."""
188
+
189
+ try:
190
+ return self.generate(
191
+ prompt=prompt,
192
+ system_prompt=system_prompt,
193
+ temperature=temperature,
194
+ max_tokens=max_tokens,
195
+ **kwargs
196
+ )
197
+ except Exception as e:
198
+ logger.error(f"RAG 텍스트 생성 중 오류 발생: {e}")
199
+ return f"오류: {str(e)}"
utils/llm_interface.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM 인터페이스 모듈 - 다양한 LLM을 통합 관리
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from typing import List, Dict, Any, Optional, Union
8
+ from dotenv import load_dotenv
9
+
10
+ # LLM 클라이언트 임포트
11
+ from utils.openai_client import OpenAILLM
12
+ from utils.deepseek_client import DeepSeekLLM
13
+
14
+ # 환경 변수 로드
15
+ load_dotenv()
16
+
17
+ # 로거 설정
18
+ logger = logging.getLogger("LLMInterface")
19
+ if not logger.hasHandlers():
20
+ handler = logging.StreamHandler()
21
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
22
+ handler.setFormatter(formatter)
23
+ logger.addHandler(handler)
24
+ logger.setLevel(logging.INFO)
25
+
26
+ class LLMInterface:
27
+ """다양한 LLM API를 통합 관리하는 인터페이스 클래스"""
28
+
29
+ # 지원되는 LLM 목록 (UI에서 표시될 이름과 내부 식별자)
30
+ SUPPORTED_LLMS = {
31
+ "OpenAI": "openai",
32
+ "DeepSeek": "deepseek"
33
+ }
34
+
35
+ def __init__(self, default_llm: str = "openai"):
36
+ """LLM 인터페이스 초기화
37
+
38
+ Args:
39
+ default_llm: 기본 LLM 식별자 ('openai' 또는 'deepseek')
40
+ """
41
+ # LLM 클라이언트 초기화
42
+ self.llm_clients = {
43
+ "openai": OpenAILLM(),
44
+ "deepseek": DeepSeekLLM()
45
+ }
46
+
47
+ # 기본 LLM 설정 (유효하지 않은 경우 openai로 설정)
48
+ if default_llm not in self.llm_clients:
49
+ logger.warning(f"지정된 기본 LLM '{default_llm}'가 유효하지 않습니다. 'openai'로 설정됩니다.")
50
+ default_llm = "openai"
51
+
52
+ self.default_llm = default_llm
53
+ self.current_llm = default_llm
54
+
55
+ logger.info(f"LLM 인터페이스 초기화 완료, 기본 LLM: {default_llm}")
56
+
57
+ def set_llm(self, llm_id: str) -> bool:
58
+ """현재 LLM을 설정
59
+
60
+ Args:
61
+ llm_id: LLM 식별자
62
+
63
+ Returns:
64
+ 성공 여부
65
+ """
66
+ if llm_id not in self.llm_clients:
67
+ logger.error(f"지원되지 않는 LLM 식별자: {llm_id}")
68
+ return False
69
+
70
+ self.current_llm = llm_id
71
+ logger.info(f"현재 LLM이 '{llm_id}'로 설정되었습니다.")
72
+ return True
73
+
74
+ def get_current_llm_name(self) -> str:
75
+ """현재 LLM의 표시 이름 반환"""
76
+ for name, id in self.SUPPORTED_LLMS.items():
77
+ if id == self.current_llm:
78
+ return name
79
+ return "Unknown"
80
+
81
+ def get_current_llm_details(self) -> Dict[str, str]:
82
+ """현재 LLM의 세부 정보 반환"""
83
+ name = self.get_current_llm_name()
84
+ model = ""
85
+
86
+ if self.current_llm == "openai":
87
+ model = self.llm_clients["openai"].model
88
+ elif self.current_llm == "deepseek":
89
+ model = self.llm_clients["deepseek"].model
90
+
91
+ return {
92
+ "name": name,
93
+ "id": self.current_llm,
94
+ "model": model
95
+ }
96
+
97
+ def generate(
98
+ self,
99
+ prompt: str,
100
+ system_prompt: Optional[str] = None,
101
+ llm_id: Optional[str] = None,
102
+ **kwargs
103
+ ) -> str:
104
+ """텍스트 생성
105
+
106
+ Args:
107
+ prompt: 사용자 프롬프트
108
+ system_prompt: 시스템 프롬프트 (선택 사항)
109
+ llm_id: 사용할 LLM 식별자 (미지정 시 현재 LLM 사용)
110
+ **kwargs: 추가 인자 (temperature, max_tokens 등)
111
+
112
+ Returns:
113
+ 생성된 텍스트
114
+ """
115
+ # 사용할 LLM 결정
116
+ llm_to_use = llm_id if llm_id and llm_id in self.llm_clients else self.current_llm
117
+ llm_client = self.llm_clients[llm_to_use]
118
+
119
+ # LLM 정보 로깅
120
+ logger.info(f"텍스트 생성 요청, LLM: {llm_to_use}")
121
+
122
+ # 생성 요청
123
+ return llm_client.generate(
124
+ prompt=prompt,
125
+ system_prompt=system_prompt,
126
+ **kwargs
127
+ )
128
+
129
+ def rag_generate(
130
+ self,
131
+ query: str,
132
+ context: List[str],
133
+ llm_id: Optional[str] = None,
134
+ **kwargs
135
+ ) -> str:
136
+ """RAG 기반 텍스트 생성
137
+
138
+ Args:
139
+ query: 사용자 질의
140
+ context: 검색된 문맥 목록
141
+ llm_id: 사용할 LLM 식별자 (미지정 시 현재 LLM 사용)
142
+ **kwargs: 추가 인자 (temperature, max_tokens 등)
143
+
144
+ Returns:
145
+ 생성된 텍스트
146
+ """
147
+ # 사용할 LLM 결정
148
+ llm_to_use = llm_id if llm_id and llm_id in self.llm_clients else self.current_llm
149
+ llm_client = self.llm_clients[llm_to_use]
150
+
151
+ # LLM 정보 로깅
152
+ logger.info(f"RAG 텍스트 생성 요청, LLM: {llm_to_use}")
153
+
154
+ # 생성 요청
155
+ return llm_client.rag_generate(
156
+ query=query,
157
+ context=context,
158
+ **kwargs
159
+ )
utils/openai_client.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI API 클라이언트 모듈
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ from typing import List, Dict, Any, Optional, Union
9
+ from dotenv import load_dotenv
10
+ from openai import OpenAI
11
+
12
+ # 환경 변수 로드
13
+ load_dotenv()
14
+
15
+ # 로거 설정
16
+ logger = logging.getLogger("OpenAILLM")
17
+ if not logger.hasHandlers():
18
+ handler = logging.StreamHandler()
19
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
20
+ handler.setFormatter(formatter)
21
+ logger.addHandler(handler)
22
+ logger.setLevel(logging.INFO)
23
+
24
+ class OpenAILLM:
25
+ """OpenAI API 래퍼 클래스"""
26
+
27
+ def __init__(self):
28
+ """OpenAI LLM 클래스 초기화"""
29
+ self.api_key = os.getenv("OPENAI_API_KEY")
30
+ self.model = os.getenv("OPENAI_MODEL", "gpt-3.5-turbo")
31
+
32
+ if not self.api_key:
33
+ logger.warning("OpenAI API 키가 .env 파일에 설정되지 않았습니다.")
34
+ logger.warning("OPENAI_API_KEY를 확인하세요.")
35
+ else:
36
+ # OpenAI 클라이언트 초기화
37
+ self.client = OpenAI(api_key=self.api_key)
38
+ logger.info("OpenAI API 키 로드 완료.")
39
+
40
+ def chat_completion(
41
+ self,
42
+ messages: List[Dict[str, str]],
43
+ temperature: float = 0.7,
44
+ max_tokens: int = 1000,
45
+ **kwargs
46
+ ) -> Dict[str, Any]:
47
+ """
48
+ OpenAI 채팅 완성 API 호출
49
+
50
+ Args:
51
+ messages: 채팅 메시지 목록
52
+ temperature: 생성 온도 (낮을수록 결정적)
53
+ max_tokens: 생성할 최대 토큰 수
54
+ **kwargs: 추가 API 매개변수
55
+
56
+ Returns:
57
+ API 응답 (딕셔너리)
58
+ """
59
+ if not self.api_key:
60
+ logger.error("API 키가 설정되지 않아 OpenAI API를 호출할 수 없습니다.")
61
+ raise ValueError("OpenAI API 키가 설정되지 않았습니다.")
62
+
63
+ try:
64
+ logger.info(f"OpenAI API 요청 전송 중 (모델: {self.model})")
65
+
66
+ # 새로운 OpenAI SDK를 사용하여 API 호출
67
+ response = self.client.chat.completions.create(
68
+ model=self.model,
69
+ messages=messages,
70
+ temperature=temperature,
71
+ max_tokens=max_tokens,
72
+ **kwargs
73
+ )
74
+
75
+ return response
76
+
77
+ except Exception as e:
78
+ logger.error(f"OpenAI API 요청 실패: {e}")
79
+ raise Exception(f"OpenAI API 요청 실패: {e}")
80
+
81
+ def generate(
82
+ self,
83
+ prompt: str,
84
+ system_prompt: Optional[str] = None,
85
+ temperature: float = 0.7,
86
+ max_tokens: int = 1000,
87
+ **kwargs
88
+ ) -> str:
89
+ """
90
+ 간단한 텍스트 생성 인터페이스
91
+
92
+ Args:
93
+ prompt: 사용자 프롬프트
94
+ system_prompt: 시스템 프롬프트 (선택 사항)
95
+ temperature: 생성 온도
96
+ max_tokens: 생성할 최대 토큰 수
97
+ **kwargs: 추가 API 매개변수
98
+
99
+ Returns:
100
+ 생성된 텍스트
101
+ """
102
+ messages = []
103
+
104
+ if system_prompt:
105
+ messages.append({"role": "system", "content": system_prompt})
106
+
107
+ messages.append({"role": "user", "content": prompt})
108
+
109
+ try:
110
+ response = self.chat_completion(
111
+ messages=messages,
112
+ temperature=temperature,
113
+ max_tokens=max_tokens,
114
+ **kwargs
115
+ )
116
+
117
+ # 새로운 OpenAI SDK 응답 구조에 맞게 처리
118
+ if not response or not hasattr(response, 'choices') or not response.choices:
119
+ logger.error("OpenAI API 응답에서 생성된 텍스트를 찾을 수 없습니다.")
120
+ return ""
121
+
122
+ return response.choices[0].message.content.strip()
123
+
124
+ except Exception as e:
125
+ logger.error(f"텍스트 생성 중 오류 발생: {e}")
126
+ return f"오류: {str(e)}"
127
+
128
+ def rag_generate(
129
+ self,
130
+ query: str,
131
+ context: List[str],
132
+ system_prompt: Optional[str] = None,
133
+ temperature: float = 0.3,
134
+ max_tokens: int = 1000,
135
+ **kwargs
136
+ ) -> str:
137
+ """
138
+ RAG 검색 결과를 활용한 텍스트 생성
139
+
140
+ Args:
141
+ query: 사용자 질의
142
+ context: 검색된 문맥 목록
143
+ system_prompt: 시스템 프롬프트 (선택 사항)
144
+ temperature: 생성 온도
145
+ max_tokens: 생성할 최대 토큰 수
146
+ **kwargs: 추가 API 매개변수
147
+
148
+ Returns:
149
+ 생성된 텍스트
150
+ """
151
+ if not system_prompt:
152
+ system_prompt = """당신은 검색 결과를 기반으로 질문에 답변하는 도우미입니다.
153
+ - 검색 결과는 <context> 태그 안에 제공됩니다.
154
+ - 검색 결과에 답변이 있으면 해당 정보를 사용하여 명확하게 답변하세요.
155
+ - 검색 결과에 답변이 없으면 "검색 결과에 관련 정보가 없습니다"라고 말하세요.
156
+ - 검색 내용을 그대로 복사하지 말고, 자연스러운 한국어로 답변을 작성하세요.
157
+ - 답변은 간결하고 정확하게 제공하세요."""
158
+
159
+ # 중요: 컨텍스트 길이 제한
160
+ # gpt-4o-mini에 맞게 제한 완화
161
+ max_context = 10 # 3개에서 10개로 증가
162
+ if len(context) > max_context:
163
+ logger.warning(f"컨텍스트가 너무 길어 처음 {max_context}개만 사용합니다.")
164
+ context = context[:max_context]
165
+
166
+ # 각 컨텍스트 액세스
167
+ limited_context = []
168
+ for i, doc in enumerate(context):
169
+ # 각 문서를 1000자로 제한 (이전 500자에서 업그레이드)
170
+ if len(doc) > 1000:
171
+ logger.warning(f"문서 {i+1}의 길이가 제한되었습니다 ({len(doc)} -> 1000)")
172
+ doc = doc[:1000] + "...(생략)"
173
+ limited_context.append(doc)
174
+
175
+ context_text = "\n\n".join([f"문서 {i+1}: {doc}" for i, doc in enumerate(limited_context)])
176
+
177
+ prompt = f"""질문: {query}
178
+
179
+ <context>
180
+ {context_text}
181
+ </context>
182
+
183
+ 위 검색 결과를 참고하여 질문에 답변해 주세요."""
184
+
185
+ try:
186
+ return self.generate(
187
+ prompt=prompt,
188
+ system_prompt=system_prompt,
189
+ temperature=temperature,
190
+ max_tokens=max_tokens,
191
+ **kwargs
192
+ )
193
+ except Exception as e:
194
+ logger.error(f"RAG 텍스트 생성 중 오류 발생: {e}")
195
+ return f"오류: {str(e)}"
utils/vito_stt.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ VITO API를 사용한 음성 인식(STT) 모듈
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ import requests
9
+ import json
10
+ import time # time import 추가
11
+ from dotenv import load_dotenv
12
+
13
+ # 환경 변수 로드
14
+ load_dotenv()
15
+
16
+ # 로거 설정 (app.py와 공유하거나 독립적으로 설정 가능)
17
+ # 여기서는 독립적인 로거를 사용합니다. 필요시 app.py의 로거를 사용하도록 수정할 수 있습니다.
18
+ logger = logging.getLogger("VitoSTT")
19
+ # 기본 로깅 레벨 설정 (핸들러가 없으면 출력이 안될 수 있으므로 기본 핸들러 추가 고려)
20
+ if not logger.hasHandlers():
21
+ handler = logging.StreamHandler()
22
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
23
+ handler.setFormatter(formatter)
24
+ logger.addHandler(handler)
25
+ logger.setLevel(logging.INFO) # 기본 레벨 INFO로 설정
26
+
27
+ class VitoSTT:
28
+ """VITO STT API 래퍼 클래스"""
29
+
30
+ def __init__(self):
31
+ """VITO STT 클래스 초기화"""
32
+ self.client_id = os.getenv("VITO_CLIENT_ID")
33
+ self.client_secret = os.getenv("VITO_CLIENT_SECRET")
34
+
35
+ if not self.client_id or not self.client_secret:
36
+ logger.warning("VITO API 인증 정보가 .env 파일에 설정되지 않았습니다.")
37
+ logger.warning("VITO_CLIENT_ID와 VITO_CLIENT_SECRET를 확인하세요.")
38
+ # 에러를 발생시키거나, 기능 사용 시점에 체크하도록 둘 수 있습니다.
39
+ # 여기서는 경고만 하고 넘어갑니다.
40
+ else:
41
+ logger.info("VITO STT API 클라이언트 ID/Secret 로드 완료.")
42
+
43
+ # API 엔드포인트
44
+ self.token_url = "https://openapi.vito.ai/v1/authenticate"
45
+ self.stt_url = "https://openapi.vito.ai/v1/transcribe"
46
+
47
+ # 액세스 토큰
48
+ self.access_token = None
49
+ self._token_expires_at = 0 # 토큰 만료 시간 추적 (선택적 개선)
50
+
51
+ def get_access_token(self):
52
+ """VITO API 액세스 토큰 획득"""
53
+ # 현재 시간을 가져와 토큰 만료 여부 확인 (선택적 개선)
54
+ # now = time.time()
55
+ # if self.access_token and now < self._token_expires_at:
56
+ # logger.debug("기존 VITO API 토큰 사용")
57
+ # return self.access_token
58
+
59
+ if not self.client_id or not self.client_secret:
60
+ logger.error("API 키가 설정되지 않아 토큰을 획득할 수 없습니다.")
61
+ raise ValueError("VITO API 인증 정보가 설정되지 않았습니다.")
62
+
63
+ logger.info("VITO API 액세스 토큰 요청 중...")
64
+ try:
65
+ response = requests.post(
66
+ self.token_url,
67
+ data={"client_id": self.client_id, "client_secret": self.client_secret},
68
+ timeout=10 # 타임아웃 설정
69
+ )
70
+ response.raise_for_status() # HTTP 오류 발생 시 예외 발생
71
+
72
+ result = response.json()
73
+ self.access_token = result.get("access_token")
74
+ expires_in = result.get("expires_in", 3600) # 만료 시간 (초), 기본값 1시간
75
+ self._token_expires_at = time.time() + expires_in - 60 # 60초 여유
76
+
77
+ if not self.access_token:
78
+ logger.error("VITO API 응답에서 토큰을 찾을 수 없습니다.")
79
+ raise ValueError("VITO API 토큰을 받아오지 못했습니다.")
80
+
81
+ logger.info("VITO API 액세스 토큰 획득 성공")
82
+ return self.access_token
83
+ except requests.exceptions.Timeout:
84
+ logger.error(f"VITO API 토큰 획득 시간 초과: {self.token_url}")
85
+ raise TimeoutError("VITO API 토큰 획득 시간 초과")
86
+ except requests.exceptions.RequestException as e:
87
+ logger.error(f"VITO API 토큰 획득 실패: {e}")
88
+ if hasattr(e, 'response') and e.response is not None:
89
+ logger.error(f"응답 코드: {e.response.status_code}, 내용: {e.response.text}")
90
+ raise ConnectionError(f"VITO API 토큰 획득 실패: {e}")
91
+
92
+
93
+ def transcribe_audio(self, audio_bytes, language="ko"):
94
+ """
95
+ 오디오 바이트 데이터를 텍스트로 변환
96
+
97
+ Args:
98
+ audio_bytes: 오디오 파일 바이트 데이터
99
+ language: 언어 코드 (기본값: 'ko')
100
+
101
+ Returns:
102
+ 인식된 텍스트 또는 오류 메시지를 포함한 딕셔너리
103
+ {'success': True, 'text': '인식된 텍스트'}
104
+ {'success': False, 'error': '오류 메시지', 'details': '상세 내용'}
105
+ """
106
+ if not self.client_id or not self.client_secret:
107
+ logger.error("API 키가 설정되지 않았습니다.")
108
+ return {"success": False, "error": "API 키가 설정되지 않았습니다."}
109
+
110
+ try:
111
+ # 토큰 획득 또는 갱신
112
+ # (선택적 개선: 만료 시간 체크 로직 추가 시 self._token_expires_at 사용)
113
+ if not self.access_token: # or time.time() >= self._token_expires_at:
114
+ logger.info("VITO API 토큰 획득/갱신 시도...")
115
+ self.get_access_token()
116
+
117
+ headers = {
118
+ "Authorization": f"Bearer {self.access_token}"
119
+ }
120
+
121
+ files = {
122
+ "file": ("audio_file", audio_bytes) # 파일명 튜플로 전달
123
+ }
124
+
125
+ # API 설정값 (필요에 따라 수정)
126
+ config = {
127
+ "use_multi_channel": False,
128
+ "use_itn": True, # Inverse Text Normalization (숫자, 날짜 등 변환)
129
+ "use_disfluency_filter": True, # 필러 (음, 아...) 제거
130
+ "use_profanity_filter": False, # 비속어 필터링
131
+ "language": language,
132
+ # "type": "audio" # type 파라미터는 VITO 문서상 필수 아님 (자동 감지)
133
+ }
134
+ data = {"config": json.dumps(config)}
135
+
136
+ logger.info(f"VITO STT API ({self.stt_url}) 요청 전송 중...")
137
+ response = requests.post(
138
+ self.stt_url,
139
+ headers=headers,
140
+ files=files,
141
+ data=data,
142
+ timeout=20 # 업로드 타임아웃
143
+ )
144
+ response.raise_for_status()
145
+
146
+ result = response.json()
147
+ job_id = result.get("id")
148
+
149
+ if not job_id:
150
+ logger.error("VITO API 작업 ID를 받아오지 못했습니다.")
151
+ return {"success": False, "error": "VITO API 작업 ID를 받아오지 못했습니다."}
152
+
153
+ logger.info(f"VITO STT 작업 ID: {job_id}, 결과 확인 시작...")
154
+
155
+ # 결과 확인 URL
156
+ transcript_url = f"{self.stt_url}/{job_id}"
157
+ max_tries = 15 # 최대 시도 횟수 증가
158
+ wait_time = 2 # 대기 시간 증가 (초)
159
+
160
+ for try_count in range(max_tries):
161
+ time.sleep(wait_time) # API 부하 감소 위해 대기
162
+ logger.debug(f"결과 확인 시도 ({try_count + 1}/{max_tries}) - URL: {transcript_url}")
163
+ get_response = requests.get(
164
+ transcript_url,
165
+ headers=headers,
166
+ timeout=10 # 결과 확인 타임아웃
167
+ )
168
+ get_response.raise_for_status()
169
+
170
+ result = get_response.json()
171
+ status = result.get("status")
172
+ logger.debug(f"현재 상태: {status}")
173
+
174
+ if status == "completed":
175
+ # 결과 추출 (utterances 구조 확인 필요)
176
+ utterances = result.get("results", {}).get("utterances", [])
177
+ if utterances:
178
+ # 전체 텍스트를 하나로 합침
179
+ transcript = " ".join([seg.get("msg", "") for seg in utterances if seg.get("msg")]).strip()
180
+ logger.info(f"VITO STT 인식 성공 (일부): {transcript[:50]}...")
181
+ return {
182
+ "success": True,
183
+ "text": transcript
184
+ # "raw_result": result # 필요시 전체 결과 반환
185
+ }
186
+ else:
187
+ logger.warning("VITO STT 완료되었으나 결과 utterances가 비어있습니다.")
188
+ return {"success": True, "text": ""} # 성공이지만 텍스트 없음
189
+
190
+ elif status == "failed":
191
+ error_msg = f"VITO API 변환 실패: {result.get('message', '알 수 없는 오류')}"
192
+ logger.error(error_msg)
193
+ return {"success": False, "error": error_msg, "details": result}
194
+
195
+ elif status == "transcribing":
196
+ logger.info(f"VITO API 처리 중... ({try_count + 1}/{max_tries})")
197
+ else: # registered, waiting 등 다른 상태
198
+ logger.info(f"VITO API 상태 '{status}', 대기 중... ({try_count + 1}/{max_tries})")
199
+
200
+
201
+ logger.error(f"VITO API 응답 타임아웃 ({max_tries * wait_time}초 초과)")
202
+ return {"success": False, "error": "VITO API 응답 타임아웃"}
203
+
204
+ except requests.exceptions.HTTPError as e:
205
+ # 토큰 만료 오류 처리 (401 Unauthorized)
206
+ if e.response.status_code == 401:
207
+ logger.warning("VITO API 토큰이 만료되었거나 유효하지 않습니다. 토큰 재발급 시도...")
208
+ self.access_token = None # 기존 토큰 무효화
209
+ try:
210
+ # 재귀 호출 대신, 토큰 재발급 후 다시 시도하는 로직 구성
211
+ self.get_access_token()
212
+ logger.info("새 토큰으로 재시도합니다.")
213
+ # 재시도는 이 함수를 다시 호출하는 대신, 호출하는 쪽에서 처리하는 것이 더 안전할 수 있음
214
+ # 여기서는 한 번 더 시도하는 로직 추가 (무한 루프 방지 필요)
215
+ # return self.transcribe_audio(audio_bytes, language) # 재귀 호출 방식
216
+ # --- 비재귀 방식 ---
217
+ headers["Authorization"] = f"Bearer {self.access_token}" # 헤더 업데이트
218
+ # POST 요청부터 다시 시작 (코드 중복 발생 가능성 있음)
219
+ # ... (POST 요청 및 결과 폴링 로직 반복) ...
220
+ # 간단하게는 그냥 실패 처리하고 상위에서 재시도 유도
221
+ return {"success": False, "error": "토큰 만료 후 재시도 필요", "details": "토큰 재발급 성공"}
222
+
223
+ except Exception as token_e:
224
+ logger.error(f"토큰 재획득 실패: {token_e}")
225
+ return {"success": False, "error": f"토큰 재획득 실패: {str(token_e)}"}
226
+
227
+ else:
228
+ # 401 외 다른 HTTP 오류
229
+ error_body = ""
230
+ try:
231
+ error_body = e.response.text
232
+ except Exception:
233
+ pass
234
+ logger.error(f"VITO API HTTP 오류: {e.response.status_code}, 응답: {error_body}")
235
+ return {
236
+ "success": False,
237
+ "error": f"API HTTP 오류: {e.response.status_code}",
238
+ "details": error_body
239
+ }
240
+
241
+ except requests.exceptions.Timeout:
242
+ logger.error("VITO API 요청 시간 초과")
243
+ return {"success": False, "error": "API 요청 시간 초과"}
244
+ except requests.exceptions.RequestException as e:
245
+ logger.error(f"VITO API 요청 중 네트워크 오류 발생: {str(e)}")
246
+ return {"success": False, "error": "API 요청 네트워크 오류", "details": str(e)}
247
+ except Exception as e:
248
+ logger.error(f"음성인식 처리 중 예상치 못한 오류 발생: {str(e)}", exc_info=True)
249
+ return {
250
+ "success": False,
251
+ "error": "음성인식 내부 처리 실패",
252
+ "details": str(e)
253
+ }