jeongsoo commited on
Commit
2382288
·
1 Parent(s): 2825b67

Initial setup

Browse files
.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
app.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 메인 실행 파일
3
+ """
4
+
5
+ # os 모듈 임포트
6
+ import os
7
+ import logging
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
+ try:
17
+ # 앱 모듈에서 Flask 앱 가져오기
18
+ from app.app import app
19
+
20
+ # 장치 라우트 등록 코드 추가
21
+ try:
22
+ from flask_cors import CORS
23
+ from app.app_device_routes import register_device_routes
24
+
25
+ # CORS 설정
26
+ CORS(app, supports_credentials=True)
27
+
28
+ # 장치 서버 URL 환경 변수 (대문자로 변경)
29
+ DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050')
30
+ logger.info(f"장치 서버 URL: {DEVICE_SERVER_URL}")
31
+
32
+ # 인증 데코레이터 가져오기
33
+ from app.app import login_required
34
+
35
+ # 인증 데코레이터 오버라이드 (장치 API 인증 우회)
36
+ from functools import wraps
37
+ from flask import request, session, redirect, url_for
38
+
39
+ def device_login_required(f):
40
+ @wraps(f)
41
+ def decorated_function(*args, **kwargs):
42
+ # API 요청이고 클라이언트에서 오는 경우 인증 무시 (임시 조치)
43
+ if request.path.startswith('/api/device/'):
44
+ logger.info(f"장치 API 요청: {request.path} - 인증 제외")
45
+ return f(*args, **kwargs)
46
+
47
+ # 그 외에는 기존 login_required 사용
48
+ return login_required(f)(*args, **kwargs)
49
+ return decorated_function
50
+
51
+ # 장치 라우트 등록 (대문자 변수명 사용)
52
+ register_device_routes(app, device_login_required, DEVICE_SERVER_URL)
53
+ logger.info("장치 라우트 직접 등록 성공")
54
+
55
+ # 404 오류 핸들러 등록
56
+ @app.errorhandler(404)
57
+ def not_found(e):
58
+ # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답
59
+ from flask import request, jsonify
60
+ if request.path.startswith('/api/'):
61
+ return jsonify({"success": False, "error": "요청한 API 엔드포인트를 찾을 수 없습니다."}), 404
62
+ # 일반 웹 페이지 요청인 경우 HTML 응답
63
+ return "페이지를 찾을 수 없습니다.", 404
64
+ except Exception as e:
65
+ logger.error(f"장치 라우트 등록 실패: {e}", exc_info=True)
66
+
67
+ except ImportError as e:
68
+ logger.error(f"앱 모듈 가져오기 실패: {e}")
69
+ raise
70
+
71
+ if __name__ == '__main__':
72
+ port = int(os.environ.get("PORT", 7860))
73
+ logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
74
+ 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,919 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 flask_cors import CORS
13
+ from werkzeug.utils import secure_filename
14
+ from dotenv import load_dotenv
15
+ from functools import wraps
16
+
17
+ # 로거 설정
18
+ logging.basicConfig(
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
20
+ level=logging.DEBUG # INFO에서 DEBUG로 변경하여 더 상세한 로그 확인
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # 환경 변수 로드
25
+ load_dotenv()
26
+
27
+ # 환경 변수 로드 상태 확인 및 로깅
28
+ ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
29
+ ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
30
+
31
+ # 장치 서버 URL 환경 변수 추가
32
+ DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050')
33
+ logger.info(f"장치 서버 URL: {DEVICE_SERVER_URL}")
34
+
35
+ logger.info(f"==== 환경 변수 로드 상태 ====")
36
+ logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
37
+ # 비밀번호는 로드 여부만 기록 (보안)
38
+ logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
39
+
40
+ # 환경 변수가 없으면 기본값 설정 (개발용, 배포 시 환경 변수 설정 권장)
41
+ if not ADMIN_USERNAME:
42
+ ADMIN_USERNAME = 'admin'
43
+ logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
44
+
45
+ if not ADMIN_PASSWORD:
46
+ ADMIN_PASSWORD = 'rag12345'
47
+ logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
48
+ class MockComponent: pass
49
+
50
+ # --- 로컬 모듈 임포트 ---
51
+ # 실제 경로에 맞게 utils, retrieval 폴더가 존재해야 합니다.
52
+ try:
53
+ from utils.vito_stt import VitoSTT
54
+ from utils.llm_interface import LLMInterface
55
+ from utils.document_processor import DocumentProcessor
56
+ from retrieval.vector_retriever import VectorRetriever
57
+ from retrieval.reranker import ReRanker
58
+ # 장치 라우트 등록 함수 임포트
59
+ from app.app_device_routes import register_device_routes
60
+ except ImportError as e:
61
+ logger.error(f"로컬 모듈 임포트 실패: {e}. utils, retrieval, app 패키지가 올바른 경로에 있는지 확인하세요.")
62
+ # 개발/테스트를 위해 임시 클래스/함수 정의 (실제 사용 시 제거)
63
+
64
+ VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
65
+ def register_device_routes(*args, **kwargs):
66
+ logger.warning("Mock register_device_routes 함수 호출됨.")
67
+ pass
68
+ # --- 로컬 모듈 임포트 끝 ---
69
+
70
+
71
+ # Flask 앱 초기화
72
+ app = Flask(__name__)
73
+
74
+ # CORS 설정 - 모든 도메인에서의 요청 허용
75
+ CORS(app, supports_credentials=True)
76
+
77
+ # 세션 설정 - 고정된 시크릿 키 사용 (실제 배포 시 환경 변수 등으로 관리 권장)
78
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345') # 환경 변수 우선 사용
79
+
80
+ # --- 세션 쿠키 설정 수정 (허깅페이스 환경 고려) ---
81
+ # 허깅페이스 스페이스는 일반적으로 HTTPS로 서비스되므로 Secure=True 설정
82
+ app.config['SESSION_COOKIE_SECURE'] = True
83
+ app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript에서 쿠키 접근 방지 (보안 강화)
84
+ # SameSite='Lax'가 대부분의 경우에 더 안전하고 호환성이 좋음.
85
+ # 만약 앱이 다른 도메인의 iframe 내에서 실행되어야 한다면 'None'으로 설정해야 함.
86
+ # (단, 'None'으로 설정 시 반드시 Secure=True여야 함)
87
+ # 로그 분석 결과 iframe 환경으로 확인되어 'None'으로 변경
88
+ app.config['SESSION_COOKIE_SAMESITE'] = 'None' # <--- 이렇게 변경합니다.
89
+ app.config['SESSION_COOKIE_DOMAIN'] = None # 특정 도메인 제한 없음
90
+ app.config['SESSION_COOKIE_PATH'] = '/' # 앱 전체 경로에 쿠키 적용
91
+ app.config['PERMANENT_SESSION_LIFETIME'] = datetime.timedelta(days=1) # 세션 유효 시간 증가
92
+ # --- 세션 쿠키 설정 끝 ---
93
+
94
+ # 최대 파일 크기 설정 (10MB)
95
+ app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
96
+ # 애플리케이션 파일 기준 상대 경로 설정
97
+ APP_ROOT = os.path.dirname(os.path.abspath(__file__))
98
+ app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
99
+ app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
100
+ app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
101
+
102
+ # 필요한 폴더 생성
103
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
104
+ os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
105
+ os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
106
+
107
+ # 허용되는 오디오/문서 파일 확장자
108
+ ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
109
+ ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
110
+
111
+ # --- 전역 객체 초기화 ---
112
+ try:
113
+ llm_interface = LLMInterface(default_llm="openai")
114
+ stt_client = VitoSTT()
115
+ except NameError:
116
+ logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
117
+ llm_interface = MockComponent()
118
+ stt_client = MockComponent()
119
+
120
+ base_retriever = None
121
+ retriever = None
122
+ app_ready = False # 앱 초기화 상태 플래그
123
+ DEVICE_ROUTES_REGISTERED = False # 장치 라우트 등록 상태 플래그
124
+ # --- 전역 객체 초기화 끝 ---
125
+
126
+
127
+ # --- 인증 데코레이터 (수정됨) ---
128
+ def login_required(f):
129
+ @wraps(f)
130
+ def decorated_function(*args, **kwargs):
131
+ logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
132
+ logger.info(f"현재 플라스크 세션 객체: {session}")
133
+ logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
134
+ # 브라우저가 보낸 실제 쿠키 확인 (디버깅용)
135
+ logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
136
+
137
+ # API 요청이고 클라이언트에서 오는 경우 인증 무시 (임시 조치)
138
+ # ---> 주의: 이 부분은 보안 검토 후 실제 환경에서는 제거하거나 더 안전한 방식으로 변경해야 할 수 있습니다.
139
+ if request.path.startswith('/api/device/'):
140
+ logger.info(f"장치 API 요청: {request.path} - 인증 제외 (주의: 임시 조치)")
141
+ return f(*args, **kwargs)
142
+
143
+ # Flask 세션에 'logged_in' 키가 있는지 직접 확인
144
+ if 'logged_in' not in session:
145
+ logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
146
+ # 수동 쿠키 확인 로직 제거됨
147
+ return redirect(url_for('login', next=request.url)) # 로그인 후 원래 페이지로 돌아가도록 next 파라미터 추가
148
+
149
+ logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
150
+ return f(*args, **kwargs)
151
+ return decorated_function
152
+ # --- 인증 데코레이터 끝 ---
153
+
154
+ # --- 오류 핸들러 추가 ---
155
+ @app.errorhandler(404)
156
+ def not_found(e):
157
+ # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답
158
+ if request.path.startswith('/api/'):
159
+ return jsonify({"success": False, "error": "요청한 API 엔드포인트를 찾을 수 없습니다."}), 404
160
+ # 일반 웹 페이지 요청인 경우 HTML 응답
161
+ return "페이지를 찾을 수 없습니다.", 404
162
+
163
+ @app.errorhandler(500)
164
+ def internal_error(e):
165
+ # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답
166
+ if request.path.startswith('/api/'):
167
+ return jsonify({"success": False, "error": "서버 내부 오류가 발생했습니다."}), 500
168
+ # 일반 웹 페이지 요청인 경우 HTML 응답
169
+ return "서버 오류가 발생했습니다.", 500
170
+ # --- 오류 핸들러 끝 ---
171
+
172
+ # --- 장치 관련 라우트 등록 (수정됨: 중복 방지) ---
173
+ # 전역 플래그를 사용하여 한 번만 등록되도록 함
174
+ if not DEVICE_ROUTES_REGISTERED:
175
+ try:
176
+ # 임포트된 register_device_routes 함수 사용
177
+ # 인증 데코레이터(login_required)와 서버 URL 전달
178
+ register_device_routes(app, login_required, DEVICE_SERVER_URL)
179
+ DEVICE_ROUTES_REGISTERED = True # 등록 성공 시 플래그 설정
180
+ logger.info("장치 관련 라우트 등록 완료")
181
+ except NameError:
182
+ logger.error("register_device_routes 함수를 찾을 수 없습니다. app.app_device_routes 모듈 확인 필요.")
183
+ except Exception as e:
184
+ logger.error(f"장치 관련 라우트 등록 실패: {e}", exc_info=True)
185
+ else:
186
+ logger.info("장치 관련 라우트가 이미 등록되어 있어 건너<0xEB>뜁니다.")
187
+ # --- 장치 관련 라우트 등록 끝 ---
188
+
189
+
190
+ # --- 헬퍼 함수 ---
191
+ def allowed_audio_file(filename):
192
+ """파일이 허용된 오디오 확장자를 가지는지 확인"""
193
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
194
+
195
+ def allowed_doc_file(filename):
196
+ """파일이 허용된 문서 확장자를 가지는지 확인"""
197
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
198
+ # --- 헬퍼 함수 끝 ---
199
+
200
+
201
+ # init_retriever 함수 내부에 로깅 추가 예시
202
+ # --- 검색기 초기화 관련 함수 ---
203
+ def init_retriever():
204
+ """검색기 객체 초기화 또는 로드"""
205
+ global base_retriever, retriever
206
+
207
+ index_path = app.config['INDEX_PATH']
208
+ data_path = app.config['DATA_FOLDER'] # data_path 정의 확인
209
+ logger.info("--- init_retriever 시작 ---")
210
+
211
+ # 1. 기본 검색기 로드 또는 초기화
212
+ if os.path.exists(os.path.join(index_path, "documents.json")):
213
+ try:
214
+ logger.info(f"인덱스 로드 시도: {index_path}")
215
+ base_retriever = VectorRetriever.load(index_path)
216
+ logger.info(f"인덱스 로드 성공. 문서 {len(getattr(base_retriever, 'documents', []))}개")
217
+ except Exception as e:
218
+ logger.error(f"인덱스 로드 실패: {e}", exc_info=True)
219
+ logger.info("새 VectorRetriever 초기화 시도...")
220
+ try:
221
+ base_retriever = VectorRetriever()
222
+ logger.info("새 VectorRetriever 초기화 성공.")
223
+ except Exception as e_init:
224
+ logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True)
225
+ base_retriever = None
226
+ else:
227
+ logger.info("인덱스 파일 없음. 새 VectorRetriever 초기화 시도...")
228
+ try:
229
+ base_retriever = VectorRetriever()
230
+ logger.info("새 VectorRetriever 초기화 성공.")
231
+ except Exception as e_init:
232
+ logger.error(f"새 VectorRetriever 초기화 실패: {e_init}", exc_info=True)
233
+ base_retriever = None
234
+
235
+ if base_retriever is None:
236
+ logger.error("base_retriever 초기화/로드에 실패하여 init_retriever 중단.")
237
+ return None
238
+
239
+ # 2. 데이터 폴더 문서 로드 (기본 검색기가 비어있을 때)
240
+ needs_loading = (not hasattr(base_retriever, 'documents') or not getattr(base_retriever, 'documents', None)) # None 체크 추가
241
+ if needs_loading and os.path.exists(data_path):
242
+ logger.info(f"기본 검색기가 비어있어 {data_path}에서 문서 로드 시도...")
243
+ try:
244
+ docs = DocumentProcessor.load_documents_from_directory(
245
+ directory=data_path,
246
+ extensions=[".txt", ".md", ".csv"],
247
+ recursive=True
248
+ )
249
+ logger.info(f"{len(docs)}개 문서 로드 성공.")
250
+ if docs and hasattr(base_retriever, 'add_documents'):
251
+ logger.info("검색기에 문서 추가 시도...")
252
+ base_retriever.add_documents(docs)
253
+ logger.info("문서 추가 완료.")
254
+
255
+ if hasattr(base_retriever, 'save'):
256
+ logger.info(f"검색기 상태 저장 시도: {index_path}")
257
+ try:
258
+ base_retriever.save(index_path)
259
+ logger.info("인덱스 저장 완료.")
260
+ except Exception as e_save:
261
+ logger.error(f"인덱스 저장 실패: {e_save}", exc_info=True)
262
+ except Exception as e_load_add:
263
+ logger.error(f"DATA_FOLDER 문서 로드/추가 중 오류: {e_load_add}", exc_info=True)
264
+
265
+ # 3. 재순위화 검색기 초기화
266
+ logger.info("재순위화 검색기 초기화 시도...")
267
+ try:
268
+ def custom_rerank_fn(query, results):
269
+ query_terms = set(query.lower().split())
270
+ for result in results:
271
+ if isinstance(result, dict) and "text" in result:
272
+ text = result["text"].lower()
273
+ term_freq = sum(1 for term in query_terms if term in text)
274
+ normalized_score = term_freq / (len(text.split()) + 1) * 10
275
+ result["rerank_score"] = result.get("score", 0) * 0.7 + normalized_score * 0.3
276
+ elif isinstance(result, dict):
277
+ result["rerank_score"] = result.get("score", 0)
278
+ results.sort(key=lambda x: x.get("rerank_score", 0) if isinstance(x, dict) else 0, reverse=True)
279
+ return results
280
+
281
+ # ReRanker 클래스 사용
282
+ retriever = ReRanker(
283
+ base_retriever=base_retriever,
284
+ rerank_fn=custom_rerank_fn,
285
+ rerank_field="text"
286
+ )
287
+ logger.info("재순위화 검색기 초기화 완료.")
288
+ except Exception as e_rerank:
289
+ logger.error(f"재순위화 검색기 초기화 실패: {e_rerank}", exc_info=True)
290
+ logger.warning("재순위화 실패, 기본 검색기를 retriever로 사용합니다.")
291
+ retriever = base_retriever # fallback
292
+
293
+ logger.info("--- init_retriever 종료 ---")
294
+ return retriever
295
+
296
+ def background_init():
297
+ """백그라운드에서 검색기 초기화 수행"""
298
+ global app_ready, retriever, base_retriever, llm_interface, stt_client
299
+
300
+ temp_app_ready = False # 임시 상태 플래그
301
+ try:
302
+ logger.info("백그라운드 초기화 시작...")
303
+
304
+ # 1. LLM, STT 인터페이스 초기화 (필요 시)
305
+ if llm_interface is None or isinstance(llm_interface, MockComponent):
306
+ if 'LLMInterface' in globals() and LLMInterface != MockComponent:
307
+ llm_interface = LLMInterface(default_llm="openai")
308
+ logger.info("LLM 인터페이스 초기화 완료.")
309
+ else:
310
+ logger.warning("LLMInterface 클래스 없음. Mock 사용.")
311
+ llm_interface = MockComponent() # Mock 객체 보장
312
+ if stt_client is None or isinstance(stt_client, MockComponent):
313
+ if 'VitoSTT' in globals() and VitoSTT != MockComponent:
314
+ stt_client = VitoSTT()
315
+ logger.info("STT 클라이언트 초기화 완료.")
316
+ else:
317
+ logger.warning("VitoSTT ��래스 없음. Mock 사용.")
318
+ stt_client = MockComponent() # Mock 객체 보장
319
+
320
+
321
+ # 2. 검색기 초기화
322
+ if 'VectorRetriever' in globals() and VectorRetriever != MockComponent:
323
+ logger.info("실제 검색기 초기화 시도...")
324
+ retriever = init_retriever()
325
+ if hasattr(retriever, 'base_retriever') and base_retriever is None:
326
+ base_retriever = retriever.base_retriever
327
+ elif base_retriever is None:
328
+ logger.warning("init_retriever 후 base_retriever가 설정되지 않음. 확인 필요.")
329
+ if isinstance(retriever, VectorRetriever):
330
+ base_retriever = retriever
331
+
332
+ if retriever is not None and base_retriever is not None:
333
+ logger.info("검색기 (Retriever, Base Retriever) 초기화 성공")
334
+ temp_app_ready = True
335
+ else:
336
+ logger.error("검색기 초기화 후에도 retriever 또는 base_retriever가 None입니다.")
337
+ if base_retriever is None: base_retriever = MockComponent()
338
+ if retriever is None: retriever = MockComponent()
339
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
340
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
341
+ temp_app_ready = True
342
+
343
+ else:
344
+ logger.warning("VectorRetriever 클래스 없음. Mock 검색기 사용.")
345
+ base_retriever = MockComponent()
346
+ retriever = MockComponent()
347
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
348
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
349
+ temp_app_ready = True
350
+
351
+ logger.info(f"백그라운드 초기화 완료. 최종 상태: {'Ready' if temp_app_ready else 'Not Ready (Error during init)'}")
352
+
353
+ except Exception as e:
354
+ logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
355
+ if base_retriever is None: base_retriever = MockComponent()
356
+ if retriever is None: retriever = MockComponent()
357
+ if not hasattr(retriever, 'search'): retriever.search = lambda query, **kwargs: []
358
+ if not hasattr(base_retriever, 'documents'): base_retriever.documents = []
359
+ temp_app_ready = True
360
+ logger.warning("초기화 중 오류가 발생했지만 Mock 객체로 대체 후 앱 사용 가능 상태로 설정.")
361
+
362
+ finally:
363
+ # 최종적으로 app_ready 상태 업데이트
364
+ app_ready = temp_app_ready
365
+ # 장치 라우트 등록 호출은 여기서 제거됨 (메인 레벨에서 처리)
366
+
367
+ # 백그라운드 스레드 시작 부분은 그대로 유지
368
+ init_thread = threading.Thread(target=background_init)
369
+ init_thread.daemon = True
370
+ init_thread.start()
371
+
372
+
373
+ # --- Flask 라우트 정의 ---
374
+
375
+ @app.route('/login', methods=['GET', 'POST'])
376
+ def login():
377
+ error = None
378
+ next_url = request.args.get('next') # 리디렉션할 URL 가져오기
379
+ logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
380
+ logger.info(f"Method: {request.method}")
381
+
382
+ if request.method == 'POST':
383
+ logger.info("로그인 시도 받음")
384
+ username = request.form.get('username', '')
385
+ password = request.form.get('password', '')
386
+ logger.info(f"입력된 사용자명: {username}")
387
+ logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
388
+
389
+ valid_username = ADMIN_USERNAME
390
+ valid_password = ADMIN_PASSWORD
391
+ logger.info(f"검증용 사용자명: {valid_username}")
392
+ logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
393
+
394
+
395
+ if username == valid_username and password == valid_password:
396
+ logger.info(f"로그인 성공: {username}")
397
+ logger.debug(f"세션 설정 전: {session}")
398
+
399
+ session.permanent = True
400
+ session['logged_in'] = True
401
+ session['username'] = username
402
+ session.modified = True
403
+
404
+ logger.info(f"세션 설정 후: {session}")
405
+ logger.info("세션 설정 완료, 리디렉션 시도")
406
+
407
+ redirect_to = next_url or url_for('index')
408
+ logger.info(f"리디렉션 대상: {redirect_to}")
409
+ response = redirect(redirect_to)
410
+ return response
411
+ else:
412
+ logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
413
+ if username != valid_username: logger.warning("사용자명 불일치")
414
+ if password != valid_password: logger.warning("비밀번호 불일치")
415
+ error = '아이디 또는 비밀번호가 올바르지 않습니다.'
416
+ else:
417
+ logger.info("로그인 페이지 GET 요청")
418
+ if 'logged_in' in session:
419
+ logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
420
+ return redirect(url_for('index'))
421
+
422
+ logger.info("---------- 로그인 페이지 렌더링 ----------")
423
+ return render_template('login.html', error=error, next=next_url)
424
+
425
+
426
+ @app.route('/logout')
427
+ def logout():
428
+ logger.info("-------------- 로그아웃 요청 --------------")
429
+ logger.info(f"로그아웃 전 세션 상태: {session}")
430
+
431
+ if 'logged_in' in session:
432
+ username = session.get('username', 'unknown')
433
+ logger.info(f"사용자 {username} 로그아웃 처리 시작")
434
+ session.pop('logged_in', None)
435
+ session.pop('username', None)
436
+ session.modified = True
437
+ logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
438
+ else:
439
+ logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
440
+
441
+ logger.info("로그인 페이지로 리디렉션")
442
+ response = redirect(url_for('login'))
443
+ return response
444
+
445
+
446
+ @app.route('/')
447
+ @login_required
448
+ def index():
449
+ """메인 페이지"""
450
+ global app_ready
451
+
452
+ current_time = datetime.datetime.now()
453
+ try:
454
+ start_time = datetime.datetime.fromtimestamp(os.path.getmtime(__file__))
455
+ time_diff = (current_time - start_time).total_seconds()
456
+ if not app_ready and time_diff > 30:
457
+ logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
458
+ app_ready = True
459
+ except FileNotFoundError:
460
+ logger.warning("__file__ 경로를 찾을 수 없어 시간 비교 로직을 건너<0xEB>뜁니다.")
461
+ if not app_ready: # 기본 타임아웃 대신 간단한 로직 추가 가능
462
+ logger.warning("앱 준비 상태 확인 (시간 비교 불가)")
463
+ # 필요시 다른 준비 상태 확인 로직 추가
464
+ pass # 임시로 통과
465
+
466
+ if not app_ready:
467
+ logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
468
+ return render_template('loading.html'), 503
469
+
470
+ logger.info("메인 페이지 요청")
471
+ return render_template('index.html')
472
+
473
+
474
+ @app.route('/api/status')
475
+ @login_required
476
+ def app_status():
477
+ """앱 초기화 상태 확인 API"""
478
+ logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
479
+ return jsonify({"ready": app_ready})
480
+
481
+
482
+ @app.route('/api/llm', methods=['GET', 'POST'])
483
+ @login_required
484
+ def llm_api():
485
+ """사용 가능한 LLM 목록 및 선택 API"""
486
+ global llm_interface
487
+
488
+ if not app_ready:
489
+ return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
490
+
491
+ if request.method == 'GET':
492
+ logger.info("LLM 목록 요청")
493
+ try:
494
+ current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
495
+ supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
496
+ supported_list = [{
497
+ "name": name, "id": id, "current": id == current_details.get("id")
498
+ } for name, id in supported_llms_dict.items()]
499
+
500
+ return jsonify({
501
+ "supported_llms": supported_list,
502
+ "current_llm": current_details
503
+ })
504
+ except Exception as e:
505
+ logger.error(f"LLM 정보 조회 오류: {e}")
506
+ return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
507
+
508
+ elif request.method == 'POST':
509
+ data = request.get_json()
510
+ if not data or 'llm_id' not in data:
511
+ return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
512
+
513
+ llm_id = data['llm_id']
514
+ logger.info(f"LLM 변경 요청: {llm_id}")
515
+
516
+ try:
517
+ if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
518
+ raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
519
+
520
+ if llm_id not in llm_interface.llm_clients:
521
+ return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
522
+
523
+ success = llm_interface.set_llm(llm_id)
524
+ if success:
525
+ new_details = llm_interface.get_current_llm_details()
526
+ logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
527
+ return jsonify({
528
+ "success": True,
529
+ "message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
530
+ "current_llm": new_details
531
+ })
532
+ else:
533
+ logger.error(f"LLM 변경 실패 (ID: {llm_id})")
534
+ return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
535
+ except Exception as e:
536
+ logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
537
+ return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
538
+
539
+
540
+ @app.route('/api/chat', methods=['POST'])
541
+ @login_required
542
+ def chat():
543
+ """텍스트 기반 챗봇 API"""
544
+ global retriever
545
+
546
+ if not app_ready or retriever is None:
547
+ return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
548
+
549
+ try:
550
+ data = request.get_json()
551
+ if not data or 'query' not in data:
552
+ return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
553
+
554
+ query = data['query']
555
+ logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
556
+
557
+ if not hasattr(retriever, 'search'):
558
+ raise NotImplementedError("Retriever에 search 메소드가 없습니다.")
559
+ search_results = retriever.search(query, top_k=5, first_stage_k=6)
560
+
561
+ if not hasattr(DocumentProcessor, 'prepare_rag_context'):
562
+ raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.")
563
+ context = DocumentProcessor.prepare_rag_context(search_results, field="text")
564
+
565
+ if not context:
566
+ logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.")
567
+ pass
568
+
569
+ llm_id = data.get('llm_id', None)
570
+ if not hasattr(llm_interface, 'rag_generate'):
571
+ raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.")
572
+
573
+ if not context:
574
+ answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
575
+ logger.info("컨텍스트 없이 기본 응답 생성")
576
+ else:
577
+ answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
578
+ logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
579
+
580
+
581
+ sources = []
582
+ if search_results:
583
+ for result in search_results:
584
+ if not isinstance(result, dict):
585
+ logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
586
+ continue
587
+
588
+ if "source" in result:
589
+ source_info = {
590
+ "source": result.get("source", "Unknown"),
591
+ "score": result.get("rerank_score", result.get("score", 0))
592
+ }
593
+
594
+ if "text" in result and result.get("filetype") == "csv":
595
+ try:
596
+ text_lines = result["text"].strip().split('\n')
597
+ if text_lines:
598
+ first_line = text_lines[0].strip()
599
+ if ',' in first_line:
600
+ first_column = first_line.split(',')[0].strip()
601
+ source_info["id"] = first_column
602
+ logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}")
603
+ except Exception as e:
604
+ logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}")
605
+
606
+ sources.append(source_info)
607
+
608
+ response_data = {
609
+ "answer": answer,
610
+ "sources": sources,
611
+ "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
612
+ }
613
+ return jsonify(response_data)
614
+
615
+ except Exception as e:
616
+ logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
617
+ return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
618
+
619
+
620
+ @app.route('/api/voice', methods=['POST'])
621
+ @login_required
622
+ def voice_chat():
623
+ """음성 챗 API 엔드포인트"""
624
+ global retriever, stt_client
625
+
626
+ if not app_ready:
627
+ logger.warning("앱 초기화가 완료되지 않았지만 음성 API 요청 처리 시도")
628
+
629
+ if retriever is None:
630
+ logger.error("retriever가 아직 초기화되지 않았습니다")
631
+ return jsonify({
632
+ "transcription": "(음성을 텍스트로 변환했지만 검색 엔진이 아직 준비되지 않았습니다)",
633
+ "answer": "죄송합니다. 검색 엔진이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요.",
634
+ "sources": []
635
+ })
636
+ if stt_client is None:
637
+ return jsonify({
638
+ "transcription": "(음성 인식 기능이 준비 중입니다)",
639
+ "answer": "죄송합니다. 현재 음성 인식 서비스가 초기화 중입니다. 잠시 후 다시 시도해주세요.",
640
+ "sources": []
641
+ })
642
+
643
+ logger.info("음성 챗 요청 수신")
644
+
645
+ if 'audio' not in request.files:
646
+ logger.error("오디오 파일이 제공되지 않음")
647
+ return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
648
+
649
+ audio_file = request.files['audio']
650
+ logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
651
+
652
+ try:
653
+ with tempfile.NamedTemporaryFile(delete=True) as temp_audio:
654
+ audio_file.save(temp_audio.name)
655
+ logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
656
+
657
+ if not hasattr(stt_client, 'transcribe_audio'):
658
+ raise NotImplementedError("STT 클라이언트에 transcribe_audio 메소드가 없습니다.")
659
+
660
+ with open(temp_audio.name, 'rb') as f_bytes:
661
+ audio_bytes = f_bytes.read()
662
+ stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
663
+
664
+
665
+ if not isinstance(stt_result, dict) or not stt_result.get("success"):
666
+ error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
667
+ logger.error(f"음성인식 실패: {error_msg}")
668
+ return jsonify({
669
+ "error": "음성인식 실패",
670
+ "details": error_msg
671
+ }), 500
672
+
673
+ transcription = stt_result.get("text", "")
674
+ if not transcription:
675
+ logger.warning("음성인식 결과가 비어있습니다.")
676
+ return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다.", "transcription": ""}), 400
677
+
678
+ logger.info(f"음성인식 성공: {transcription[:50]}...")
679
+ if retriever is None:
680
+ logger.error("STT 성공 후 검색 시도 중 retriever가 None임")
681
+ return jsonify({
682
+ "transcription": transcription,
683
+ "answer": "음성을 인식했지만, 현재 검색 시스템이 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
684
+ "sources": []
685
+ })
686
+
687
+ search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
688
+ context = DocumentProcessor.prepare_rag_context(search_results, field="text")
689
+
690
+ if not context:
691
+ logger.warning("음성 쿼리에 대한 검색 결과 없음.")
692
+ pass
693
+
694
+ llm_id = request.form.get('llm_id', None)
695
+ if not context:
696
+ answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
697
+ logger.info("컨텍스트 없이 기본 응답 생성")
698
+ else:
699
+ answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
700
+ logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
701
+
702
+ enhanced_sources = []
703
+ if search_results:
704
+ for doc in search_results:
705
+ if not isinstance(doc, dict): continue
706
+ if "source" in doc:
707
+ source_info = {
708
+ "source": doc.get("source", "Unknown"),
709
+ "score": doc.get("rerank_score", doc.get("score", 0))
710
+ }
711
+ if "text" in doc and doc.get("filetype") == "csv":
712
+ try:
713
+ text_lines = doc["text"].strip().split('\n')
714
+ if text_lines:
715
+ first_line = text_lines[0].strip()
716
+ if ',' in first_line:
717
+ first_column = first_line.split(',')[0].strip()
718
+ source_info["id"] = first_column
719
+ except Exception as e:
720
+ logger.warning(f"[음성챗] CSV 소스 ID 추출 실패 ({doc.get('source')}): {e}")
721
+ enhanced_sources.append(source_info)
722
+
723
+ response_data = {
724
+ "transcription": transcription,
725
+ "answer": answer,
726
+ "sources": enhanced_sources,
727
+ "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
728
+ }
729
+ return jsonify(response_data)
730
+
731
+ except Exception as e:
732
+ logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
733
+ return jsonify({
734
+ "error": "음성 처리 중 내부 오류 발생",
735
+ "details": str(e)
736
+ }), 500
737
+
738
+
739
+ @app.route('/api/upload', methods=['POST'])
740
+ @login_required
741
+ def upload_document():
742
+ """지식베이스 문서 업로드 API"""
743
+ global base_retriever, retriever
744
+
745
+ if not app_ready or base_retriever is None:
746
+ return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
747
+
748
+ if 'document' not in request.files:
749
+ return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
750
+
751
+ doc_file = request.files['document']
752
+ if doc_file.filename == '':
753
+ return jsonify({"error": "선택된 파일이 없습니다."}), 400
754
+
755
+ if not allowed_doc_file(doc_file.filename):
756
+ logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}")
757
+ return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400
758
+
759
+ try:
760
+ filename = secure_filename(doc_file.filename)
761
+ filepath = os.path.join(app.config['DATA_FOLDER'], filename)
762
+ doc_file.save(filepath)
763
+ logger.info(f"문서 저장 완료: {filepath}")
764
+
765
+ try:
766
+ with open(filepath, 'r', encoding='utf-8') as f:
767
+ content = f.read()
768
+ except UnicodeDecodeError:
769
+ logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
770
+ try:
771
+ with open(filepath, 'r', encoding='cp949') as f:
772
+ content = f.read()
773
+ except Exception as e_cp949:
774
+ logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}")
775
+ return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400
776
+ except Exception as e_read:
777
+ logger.error(f"파일 읽기 오류 ({filename}): {e_read}")
778
+ return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500
779
+
780
+ metadata = {
781
+ "source": filename, "filename": filename,
782
+ "filetype": filename.rsplit('.', 1)[1].lower(),
783
+ "filepath": filepath
784
+ }
785
+ file_ext = metadata["filetype"]
786
+ docs = []
787
+
788
+ if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
789
+ raise NotImplementedError("DocumentProcessor에 필요한 메소드 없음")
790
+
791
+ if file_ext == 'csv':
792
+ logger.info(f"CSV 파일 처리 시작: {filename}")
793
+ docs = DocumentProcessor.csv_to_documents(content, metadata)
794
+ else:
795
+ logger.info(f"일반 텍스트 문서 처리 시작: {filename}")
796
+ if file_ext in ['pdf', 'docx']:
797
+ logger.warning(f".{file_ext} 파일 처리는 현재 구현되지 않았습니다. 텍스트 추출 로직 추가 필요.")
798
+ content = ""
799
+
800
+ if content:
801
+ docs = DocumentProcessor.text_to_documents(
802
+ content, metadata=metadata,
803
+ chunk_size=512, chunk_overlap=50
804
+ )
805
+
806
+ if docs:
807
+ if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
808
+ raise NotImplementedError("기본 검색기에 add_documents 또는 save 메소드 없음")
809
+
810
+ logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
811
+ base_retriever.add_documents(docs)
812
+
813
+ logger.info(f"검색기 상태를 저장합니다...")
814
+ index_path = app.config['INDEX_PATH']
815
+ try:
816
+ base_retriever.save(index_path)
817
+ logger.info("인덱스 저장 완료")
818
+ # 재순위화 검색기 업데이트 로직 필요 시 추가
819
+ # 예: if retriever != base_retriever and hasattr(retriever, 'update_base_retriever'): retriever.update_base_retriever(base_retriever)
820
+ return jsonify({
821
+ "success": True,
822
+ "message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)."
823
+ })
824
+ except Exception as e_save:
825
+ logger.error(f"인덱스 저장 중 오류 발생: {e_save}")
826
+ return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500
827
+ else:
828
+ logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.")
829
+ return jsonify({
830
+ "warning": True,
831
+ "message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없습니다."
832
+ })
833
+
834
+ except Exception as e:
835
+ logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True)
836
+ return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
837
+
838
+
839
+ @app.route('/api/documents', methods=['GET'])
840
+ @login_required
841
+ def list_documents():
842
+ """지식베이스 문서 목록 API"""
843
+ global base_retriever
844
+
845
+ if not app_ready or base_retriever is None:
846
+ return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
847
+
848
+ try:
849
+ sources = {}
850
+ total_chunks = 0
851
+ if hasattr(base_retriever, 'documents') and base_retriever.documents:
852
+ logger.info(f"총 {len(base_retriever.documents)}개 문서 청크에서 소스 목록 생성 중...")
853
+ for doc in base_retriever.documents:
854
+ if not isinstance(doc, dict): continue
855
+
856
+ source = doc.get("source", "unknown")
857
+ if source == "unknown" and "metadata" in doc and isinstance(doc["metadata"], dict):
858
+ source = doc["metadata"].get("source", "unknown")
859
+
860
+ if source != "unknown":
861
+ if source in sources:
862
+ sources[source]["chunks"] += 1
863
+ else:
864
+ filename = doc.get("filename", source)
865
+ filetype = doc.get("filetype", "unknown")
866
+ if "metadata" in doc and isinstance(doc["metadata"], dict):
867
+ filename = doc["metadata"].get("filename", filename)
868
+ filetype = doc["metadata"].get("filetype", filetype)
869
+
870
+ sources[source] = {
871
+ "filename": filename,
872
+ "chunks": 1,
873
+ "filetype": filetype
874
+ }
875
+ total_chunks += 1
876
+ else:
877
+ logger.info("검색기에 문서가 없거나 documents 속성을 찾을 수 없습니다.")
878
+
879
+ documents = [{"source": src, **info} for src, info in sources.items()]
880
+ documents.sort(key=lambda x: x["chunks"], reverse=True)
881
+
882
+ logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크")
883
+ return jsonify({
884
+ "documents": documents,
885
+ "total_documents": len(documents),
886
+ "total_chunks": total_chunks
887
+ })
888
+
889
+ except Exception as e:
890
+ logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True)
891
+ return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
892
+
893
+
894
+ # 정적 파일 서빙
895
+ @app.route('/static/<path:path>')
896
+ def send_static(path):
897
+ return send_from_directory('static', path)
898
+
899
+
900
+ # --- 요청 처리 훅 ---
901
+
902
+ @app.after_request
903
+ def after_request_func(response):
904
+ """모든 응답에 대해 후처리 수행"""
905
+ # logger.debug(f"[After Request] 응답 헤더: {response.headers}") # 디버깅 시 Set-Cookie 확인
906
+ return response
907
+
908
+
909
+ # 앱 실행 (로컬 테스트용)
910
+ if __name__ == '__main__':
911
+ logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
912
+ # 디버그 모드는 개발 중에만 True로 설정하고, 실제 배포 시에는 False로 설정해야 합니다.
913
+ # host='0.0.0.0' 은 모든 네트워크 인터페이스에서 접속 가능하게 합니다.
914
+ port = int(os.environ.get("PORT", 7860))
915
+ logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
916
+ # debug=True 사용 시 werkzeug reloader가 활성화되어 코드가 변경될 때 서버가 재시작될 수 있으며,
917
+ # 이 과정에서 전역 초기화 코드가 다시 실행될 수 있습니다.
918
+ # DEVICE_ROUTES_REGISTERED 플래그가 이를 방지합니다.
919
+ app.run(debug=True, host='0.0.0.0', port=port)
app/app_device_routes.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG 검색 챗봇 웹 애플리케이션 - 장치 관리 API 라우트 정의 (API 경로 수정됨)
3
+ """
4
+
5
+ import logging
6
+ import requests
7
+ from flask import request, jsonify
8
+
9
+ # 로거 가져오기
10
+ logger = logging.getLogger(__name__)
11
+
12
+ def register_device_routes(app, login_required, DEVICE_SERVER_URL):
13
+ """Flask 애플리케이션에 장치 관리 관련 라우트 등록"""
14
+
15
+ # 사용자 지정 장치 서버 URL 변수
16
+ custom_device_url = None
17
+
18
+ # URL 설정 함수
19
+ def get_device_url():
20
+ # 사용자 지정 URL이 있으면 사용, 없으면 환경변수 값 사용
21
+ return custom_device_url or DEVICE_SERVER_URL
22
+
23
+ @app.route('/api/device/connect', methods=['POST'])
24
+ @login_required
25
+ def connect_device_server():
26
+ """사용자 지정 장치 서버 URL 연결 API"""
27
+ nonlocal custom_device_url # 상위 스코프의 변수 참조
28
+
29
+ try:
30
+ # 요청에서 URL 가져오기
31
+ request_data = request.get_json()
32
+ if not request_data or 'url' not in request_data:
33
+ logger.error("URL이 제공되지 않았습니다.")
34
+ return jsonify({
35
+ "success": False,
36
+ "error": "URL이 제공되지 않았습니다."
37
+ }), 400 # Bad Request
38
+
39
+ new_url = request_data['url'].strip()
40
+ if not new_url:
41
+ logger.error("URL이 비어 있습니다.")
42
+ return jsonify({
43
+ "success": False,
44
+ "error": "URL이 비어 있습니다."
45
+ }), 400 # Bad Request
46
+
47
+ # URL 설정
48
+ logger.info(f"사용자 지정 장치 서버 URL 설정: {new_url}")
49
+ custom_device_url = new_url
50
+
51
+ # 설정된 URL로 상태 확인 시도
52
+ try:
53
+ api_path = "/api/status"
54
+ logger.info(f"장치 서버 상태 확인 요청: {custom_device_url}{api_path}")
55
+ response = requests.get(f"{custom_device_url}{api_path}", timeout=5)
56
+
57
+ if response.status_code == 200:
58
+ try:
59
+ data = response.json()
60
+ logger.info(f"사용자 지정 장치 서버 연결 성공. 응답 데이터: {data}")
61
+ return jsonify({
62
+ "success": True,
63
+ "message": "장치 서버 연결에 성공했습니다.",
64
+ "server_status": data.get("status", "정상")
65
+ })
66
+ except requests.exceptions.JSONDecodeError:
67
+ logger.error("장치 서버 응답 JSON 파싱 실패")
68
+ return jsonify({
69
+ "success": False,
70
+ "error": "장치 서버로부터 유효하지 않은 JSON 응답을 받았습니다."
71
+ }), 502 # Bad Gateway
72
+ else:
73
+ error_message = f"장치 서버 응답 오류: {response.status_code}"
74
+ try:
75
+ error_detail = response.json().get("error", response.text)
76
+ error_message += f" - {error_detail}"
77
+ except Exception:
78
+ error_message += f" - {response.text}"
79
+
80
+ logger.warning(error_message)
81
+ custom_device_url = None # 연결 실패 시 URL 초기화
82
+ return jsonify({
83
+ "success": False,
84
+ "error": error_message
85
+ }), 502 # Bad Gateway
86
+
87
+ except requests.exceptions.Timeout:
88
+ logger.error(f"장치 서버 연결 타임아웃 ({custom_device_url})")
89
+ custom_device_url = None # 연결 실패 시 URL 초기화
90
+ return jsonify({
91
+ "success": False,
92
+ "error": "장치 서버 연결 타임아웃. 서버 응답이 너무 느립니다."
93
+ }), 504 # Gateway Timeout
94
+
95
+ except requests.exceptions.ConnectionError:
96
+ logger.error(f"장치 관리 서버 연결 실패 ({custom_device_url})")
97
+ custom_device_url = None # 연결 실패 시 URL 초기화
98
+ return jsonify({
99
+ "success": False,
100
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지, URL이 정확한지 확인해주세요."
101
+ }), 502 # Bad Gateway
102
+
103
+ except Exception as e:
104
+ logger.error(f"장치 서버 연결 중 예상치 못한 오류 발생: {e}", exc_info=True)
105
+ custom_device_url = None # 연결 실패 시 URL 초기화
106
+ return jsonify({
107
+ "success": False,
108
+ "error": f"장치 서버 연결 중 오류 발생: {str(e)}"
109
+ }), 500 # Internal Server Error
110
+
111
+ except Exception as e:
112
+ logger.error(f"/api/device/connect 처리 중 내부 서버 오류: {e}", exc_info=True)
113
+ return jsonify({
114
+ "success": False,
115
+ "error": f"내부 서버 오류 발생: {str(e)}"
116
+ }), 500 # Internal Server Error
117
+
118
+ @app.route('/api/device/status', methods=['GET'])
119
+ @login_required
120
+ def device_status():
121
+ """장치 관리 서버의 상태를 확인하는 API 엔드포인트"""
122
+ try:
123
+ # requests 라이브러리는 함수 내에서 임포트할 필요 없음 (상단 임포트 사용)
124
+ # json, jsonify도 Flask에서 이미 임포트됨 (상단 임포트 사용)
125
+
126
+ # 연결 타임아웃 설정
127
+ timeout = 5 # 5초로 타임아웃 설정
128
+
129
+ try:
130
+ # 장치 서버 상태 확인 - 경로: /api/status
131
+ current_device_url = get_device_url()
132
+ api_path = "/api/status"
133
+ logger.info(f"장치 서버 상태 확인 요청: {current_device_url}{api_path}")
134
+ response = requests.get(f"{current_device_url}{api_path}", timeout=timeout)
135
+
136
+ # 응답 상태 코드 및 내용 로깅 (디버깅 강화)
137
+ logger.debug(f"장치 서버 응답 상태 코드: {response.status_code}")
138
+ try:
139
+ logger.debug(f"장치 서버 응답 내용: {response.text[:200]}...") # 너무 길면 잘라서 로깅
140
+ except Exception:
141
+ logger.debug("장치 서버 응답 내용 로깅 실패 (텍스트 형식 아님?)")
142
+
143
+
144
+ if response.status_code == 200:
145
+ # 성공 시 응답 데이터 구조 확인 및 로깅
146
+ try:
147
+ data = response.json()
148
+ logger.info(f"장치 서버 상태 확인 성공. 응답 데이터: {data}")
149
+ return jsonify({"success": True, "status": "connected", "data": data})
150
+ except requests.exceptions.JSONDecodeError:
151
+ logger.error("장치 서버 응답 JSON 파싱 실패")
152
+ return jsonify({
153
+ "success": False,
154
+ "error": "장치 서버로부터 유효하지 않은 JSON 응답을 받았습니다."
155
+ }), 502 # Bad Gateway (업스트림 서버 오류)
156
+ else:
157
+ # 실패 시 오류 메시지 포함 로깅
158
+ error_message = f"장치 서버 응답 오류: {response.status_code}"
159
+ try:
160
+ # 서버에서 오류 메시지를 json으로 보내는 경우 포함
161
+ error_detail = response.json().get("error", response.text)
162
+ error_message += f" - {error_detail}"
163
+ except Exception:
164
+ error_message += f" - {response.text}" # JSON 파싱 실패 시 원본 텍스트
165
+
166
+ logger.warning(error_message) # 경고 레벨로 로깅
167
+ return jsonify({
168
+ "success": False,
169
+ "error": error_message
170
+ }), 502 # Bad Gateway
171
+
172
+ except requests.exceptions.Timeout:
173
+ logger.error(f"장치 서버 연결 타임아웃 ({DEVICE_SERVER_URL})")
174
+ return jsonify({
175
+ "success": False,
176
+ "error": "장치 서버 연결 타임아웃. 서버 응답이 너무 느립니다."
177
+ }), 504 # Gateway Timeout
178
+
179
+ except requests.exceptions.ConnectionError:
180
+ current_device_url = get_device_url()
181
+ logger.error(f"장치 관리 서버 연결 실패 ({current_device_url})")
182
+ return jsonify({
183
+ "success": False,
184
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지, URL이 정확한지 확인해주세요."
185
+ }), 502 # Bad Gateway (연결 실패도 업스트림 문제로 간주)
186
+
187
+ except Exception as e:
188
+ logger.error(f"장치 서버 연결 중 예상치 못한 오류 발생: {e}", exc_info=True) # 상세 스택 트레이스 로깅
189
+ return jsonify({
190
+ "success": False,
191
+ "error": f"장치 서버 연결 중 오류 발생: {str(e)}"
192
+ }), 500 # Internal Server Error (Flask 앱 내부 또는 requests 라이브러리 문제 가능성)
193
+
194
+ except Exception as e:
195
+ # 이 try-except 블록은 /api/device/status 라우트 자체의 내부 오류 처리
196
+ logger.error(f"/api/device/status 처리 중 내부 서버 오류: {e}", exc_info=True)
197
+ return jsonify({
198
+ "success": False,
199
+ "error": f"��부 서버 오류 발생: {str(e)}"
200
+ }), 500 # Internal Server Error
201
+
202
+ @app.route('/api/device/list', methods=['GET'])
203
+ @login_required
204
+ def device_list():
205
+ """장치 목록 조회 API"""
206
+ logger.info("장치 목록 조회 요청")
207
+
208
+ try:
209
+ current_device_url = get_device_url()
210
+ api_path = "/api/devices"
211
+ logger.info(f"장치 목록 조회 요청: {current_device_url}{api_path}")
212
+ response = requests.get(f"{current_device_url}{api_path}", timeout=5)
213
+ logger.debug(f"장치 목록 응답 상태 코드: {response.status_code}")
214
+
215
+ if response.status_code == 200:
216
+ try:
217
+ data = response.json()
218
+ devices = data.get("devices", [])
219
+ logger.info(f"장치 목록 조회 성공: {len(devices)}개 장치")
220
+ return jsonify({
221
+ "success": True,
222
+ "devices": devices
223
+ })
224
+ except requests.exceptions.JSONDecodeError:
225
+ logger.error("장치 목록 응답 JSON 파싱 실패")
226
+ return jsonify({"success": False, "error": "장치 서버로부터 유효하지 않은 JSON 응답"}), 502
227
+ else:
228
+ error_message = f"장치 목록 조회 실패: {response.status_code}"
229
+ try: error_message += f" - {response.json().get('error', response.text)}"
230
+ except Exception: error_message += f" - {response.text}"
231
+ logger.warning(error_message)
232
+ return jsonify({
233
+ "success": False,
234
+ "error": error_message
235
+ }), 502
236
+
237
+ except requests.exceptions.Timeout:
238
+ logger.error("장치 목록 조회 시간 초과")
239
+ return jsonify({
240
+ "success": False,
241
+ "error": "장치 목록 조회 시간이 초과되었습니다."
242
+ }), 504
243
+
244
+ except requests.exceptions.ConnectionError:
245
+ logger.error("장치 관리 서버 연결 실패")
246
+ return jsonify({
247
+ "success": False,
248
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
249
+ }), 503 # Service Unavailable
250
+
251
+ except Exception as e:
252
+ logger.error(f"장치 목록 조회 중 오류 발생: {e}", exc_info=True)
253
+ return jsonify({
254
+ "success": False,
255
+ "error": f"장치 목록 조회 중 오류 발생: {str(e)}"
256
+ }), 500
257
+
258
+
259
+ @app.route('/api/device/programs', methods=['GET'])
260
+ @login_required
261
+ def device_programs():
262
+ """실행 가능한 프로그램 목록 조회 API"""
263
+ logger.info("프로그램 목록 조회 요청")
264
+
265
+ try:
266
+ current_device_url = get_device_url()
267
+ api_path = "/api/programs"
268
+ logger.info(f"프로그램 목록 조회 요청: {current_device_url}{api_path}")
269
+ response = requests.get(f"{current_device_url}{api_path}", timeout=5)
270
+ logger.debug(f"프로그램 목록 응답 상태 코드: {response.status_code}")
271
+
272
+
273
+ if response.status_code == 200:
274
+ try:
275
+ data = response.json()
276
+ programs = data.get("programs", [])
277
+ logger.info(f"프로그램 목록 조회 성공: {len(programs)}개 프로그램")
278
+ return jsonify({
279
+ "success": True,
280
+ "programs": programs
281
+ })
282
+ except requests.exceptions.JSONDecodeError:
283
+ logger.error("프로그램 목록 응답 JSON 파싱 실패")
284
+ return jsonify({"success": False, "error": "장치 서버로부터 유효하지 않은 JSON 응답"}), 502
285
+ else:
286
+ error_message = f"프로그램 목록 조회 실패: {response.status_code}"
287
+ try: error_message += f" - {response.json().get('error', response.text)}"
288
+ except Exception: error_message += f" - {response.text}"
289
+ logger.warning(error_message)
290
+ return jsonify({
291
+ "success": False,
292
+ "error": error_message
293
+ }), 502
294
+
295
+ except requests.exceptions.Timeout:
296
+ logger.error("프로그램 목록 조회 시간 초과")
297
+ return jsonify({
298
+ "success": False,
299
+ "error": "프로그램 목록 조회 시간이 초과되었습니다."
300
+ }), 504
301
+
302
+ except requests.exceptions.ConnectionError:
303
+ logger.error("장치 관리 서버 연결 실패")
304
+ return jsonify({
305
+ "success": False,
306
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
307
+ }), 503
308
+
309
+ except Exception as e:
310
+ logger.error(f"프로그램 목록 조회 중 오류 발생: {e}", exc_info=True)
311
+ return jsonify({
312
+ "success": False,
313
+ "error": f"프로그램 목록 조회 중 오류 발생: {str(e)}"
314
+ }), 500
315
+
316
+
317
+ @app.route('/api/device/programs/<program_id>/execute', methods=['POST'])
318
+ @login_required
319
+ def execute_program(program_id):
320
+ """프로그램 실행 API"""
321
+ logger.info(f"프로그램 실행 요청: {program_id}")
322
+
323
+ try:
324
+ current_device_url = get_device_url()
325
+ api_path = f"/api/programs/{program_id}/execute"
326
+ logger.info(f"프로그램 실행 요청: {current_device_url}{api_path}")
327
+ response = requests.post(
328
+ f"{current_device_url}{api_path}",
329
+ json={}, # 필요시 여기에 파라미터 추가 가능
330
+ timeout=10 # 프로그램 실행에는 더 긴 시간 부여
331
+ )
332
+ logger.debug(f"프로그램 실행 응답 상태 코드: {response.status_code}")
333
+
334
+
335
+ if response.status_code == 200:
336
+ try:
337
+ data = response.json()
338
+ success = data.get("success", False)
339
+ message = data.get("message", "")
340
+ logger.info(f"프로그램 실행 응답: {success}, {message}")
341
+ return jsonify(data) # 서버 응답 그대로 반환
342
+ except requests.exceptions.JSONDecodeError:
343
+ logger.error("프로그램 실행 응답 JSON 파싱 실패")
344
+ # 성공(200)했지만 응답이 비정상적인 경우
345
+ return jsonify({
346
+ "success": False, # 파싱 실패했으므로 False로 간주
347
+ "error": "프로그램 실행 서버로부터 유효하지 않은 JSON 응답"
348
+ }), 502
349
+ else:
350
+ error_message = f"프로그램 실행 요청 실패: {response.status_code}"
351
+ try: error_message += f" - {response.json().get('error', response.text)}"
352
+ except Exception: error_message += f" - {response.text}"
353
+ logger.warning(error_message)
354
+ return jsonify({
355
+ "success": False,
356
+ "error": error_message
357
+ }), 502 # 또는 response.status_code 를 그대로 반환하는 것도 고려
358
+
359
+ except requests.exceptions.Timeout:
360
+ logger.error("프로그램 실행 요청 시간 초과")
361
+ return jsonify({
362
+ "success": False,
363
+ "error": "프로그램 실행 요청 시간이 초과되었습니다."
364
+ }), 504
365
+
366
+ except requests.exceptions.ConnectionError:
367
+ logger.error("장치 관리 서버 연결 실패")
368
+ return jsonify({
369
+ "success": False,
370
+ "error": "장치 관리 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요."
371
+ }), 503
372
+
373
+ except Exception as e:
374
+ logger.error(f"프로그램 실행 중 오류 발생: {e}", exc_info=True)
375
+ return jsonify({
376
+ "success": False,
377
+ "error": f"프로그램 실행 중 오류 발생: {str(e)}"
378
+ }), 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,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, jsonify
10
+ from dotenv import load_dotenv
11
+ from functools import wraps
12
+ from flask_cors import CORS
13
+
14
+ # 로거 설정
15
+ logging.basicConfig(
16
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
17
+ level=logging.DEBUG
18
+ )
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # 환경 변수 로드
22
+ load_dotenv()
23
+
24
+ # 환경 변수 로드 상태 확인 및 로깅
25
+ ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
26
+ ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
27
+ DEVICE_SERVER_URL = os.getenv('DEVICE_SERVER_URL', 'http://localhost:5050')
28
+
29
+ logger.info(f"==== 환경 변수 로드 상태 ====")
30
+ logger.info(f"ADMIN_USERNAME 설정 여부: {ADMIN_USERNAME is not None}")
31
+ logger.info(f"ADMIN_PASSWORD 설정 여부: {ADMIN_PASSWORD is not None}")
32
+ logger.info(f"DEVICE_SERVER_URL: {DEVICE_SERVER_URL}")
33
+
34
+ # 환경 변수가 없으면 기본값 설정
35
+ if not ADMIN_USERNAME:
36
+ ADMIN_USERNAME = 'admin'
37
+ logger.warning("ADMIN_USERNAME 환경변수가 없어 기본값 'admin'으로 설정합니다.")
38
+
39
+ if not ADMIN_PASSWORD:
40
+ ADMIN_PASSWORD = 'rag12345'
41
+ logger.warning("ADMIN_PASSWORD 환경변수가 없어 기본값 'rag12345'로 설정합니다.")
42
+
43
+ class MockComponent: pass
44
+
45
+ # --- 로컬 모듈 임포트 ---
46
+ try:
47
+ from utils.vito_stt import VitoSTT
48
+ from utils.llm_interface import LLMInterface
49
+ from utils.document_processor import DocumentProcessor
50
+ from retrieval.vector_retriever import VectorRetriever
51
+ from retrieval.reranker import ReRanker
52
+ except ImportError as e:
53
+ logger.error(f"로컬 모듈 임포트 실패: {e}. utils 및 retrieval 패키지가 올바른 경로에 있는지 확인하세요.")
54
+ VitoSTT = LLMInterface = DocumentProcessor = VectorRetriever = ReRanker = MockComponent
55
+ # --- 로컬 모듈 임포트 끝 ---
56
+
57
+
58
+ # Flask 앱 초기화
59
+ app = Flask(__name__)
60
+
61
+ # CORS 설정 - 모든 도메인에서의 요청 허용
62
+ CORS(app, supports_credentials=True)
63
+
64
+ # 세션 설정
65
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', 'rag_chatbot_fixed_secret_key_12345')
66
+
67
+ # --- 세션 쿠키 설정 ---
68
+ app.config['SESSION_COOKIE_SECURE'] = True
69
+ app.config['SESSION_COOKIE_HTTPONLY'] = True
70
+ app.config['SESSION_COOKIE_SAMESITE'] = 'None'
71
+ app.config['SESSION_COOKIE_DOMAIN'] = None
72
+ app.config['SESSION_COOKIE_PATH'] = '/'
73
+ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=1)
74
+ # --- 세션 쿠키 설정 끝 ---
75
+
76
+ # 최대 파일 크기 설정 (10MB)
77
+ app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
78
+ # 애플리케이션 파일 기준 상대 경로 설정
79
+ APP_ROOT = os.path.dirname(os.path.abspath(__file__))
80
+ app.config['UPLOAD_FOLDER'] = os.path.join(APP_ROOT, 'uploads')
81
+ app.config['DATA_FOLDER'] = os.path.join(APP_ROOT, '..', 'data')
82
+ app.config['INDEX_PATH'] = os.path.join(APP_ROOT, '..', 'data', 'index')
83
+
84
+ # 필요한 폴더 생성
85
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
86
+ os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
87
+ os.makedirs(app.config['INDEX_PATH'], exist_ok=True)
88
+
89
+ # --- 전역 객체 초기화 ---
90
+ try:
91
+ llm_interface = LLMInterface(default_llm="openai")
92
+ stt_client = VitoSTT()
93
+ except NameError:
94
+ logger.warning("LLM 또는 STT 인터페이스 초기화 실패. Mock 객체를 사용합니다.")
95
+ llm_interface = MockComponent()
96
+ stt_client = MockComponent()
97
+
98
+ base_retriever = None
99
+ retriever = None
100
+ app_ready = False # 앱 초기화 상태 플래그
101
+ # --- 전역 객체 초기화 끝 ---
102
+
103
+
104
+ # --- 인증 데코레이터 ---
105
+ def login_required(f):
106
+ @wraps(f)
107
+ def decorated_function(*args, **kwargs):
108
+ from flask import request, session, redirect, url_for
109
+
110
+ logger.info(f"----------- 인증 필요 페이지 접근 시도: {request.path} -----------")
111
+ logger.info(f"현재 플라스크 세션 객체: {session}")
112
+ logger.info(f"현재 세션 상태: logged_in={session.get('logged_in', False)}, username={session.get('username', 'None')}")
113
+ logger.info(f"요청의 세션 쿠키 값: {request.cookies.get('session', 'None')}")
114
+
115
+ # API 요청이고 클라이언트에서 오는 경우 인증 무시 (임시 조치)
116
+ if request.path.startswith('/api/device/'):
117
+ logger.info(f"장치 API 요청: {request.path} - 인증 제외")
118
+ return f(*args, **kwargs)
119
+
120
+ # Flask 세션에 'logged_in' 키가 있는지 직접 확인
121
+ if 'logged_in' not in session:
122
+ logger.warning(f"플라스크 세션에 'logged_in' 없음. 로그인 페이지로 리디렉션.")
123
+ return redirect(url_for('login', next=request.url))
124
+
125
+ logger.info(f"인증 성공: {session.get('username', 'unknown')} 사용자가 {request.path} 접근")
126
+ return f(*args, **kwargs)
127
+ return decorated_function
128
+ # --- 인증 데코레이터 끝 ---
129
+
130
+ # --- 오류 핸들러 추가 ---
131
+ @app.errorhandler(404)
132
+ def not_found(e):
133
+ # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답
134
+ if request.path.startswith('/api/'):
135
+ return jsonify({"success": False, "error": "요청한 API 엔드포인트를 찾을 수 없습니다."}), 404
136
+ # 일반 웹 페이지 요청인 경우 HTML 응답
137
+ return "페이지를 찾을 수 없습니다.", 404
138
+
139
+ @app.errorhandler(500)
140
+ def internal_error(e):
141
+ # 클라이언트가 JSON을 기대하는 API 호출인 경우 JSON 응답
142
+ if request.path.startswith('/api/'):
143
+ return jsonify({"success": False, "error": "서버 내부 오류가 발생했습니다."}), 500
144
+ # 일반 웹 페이지 요청인 경우 HTML 응답
145
+ return "서버 오류가 발생했습니다.", 500
146
+ # --- 오류 핸들러 끝 ---
147
+
148
+
149
+ # --- 정적 파일 서빙 ---
150
+ @app.route('/static/<path:path>')
151
+ def send_static(path):
152
+ return send_from_directory('static', path)
153
+
154
+
155
+ # --- 백그라운드 초기화 함수 ---
156
+ def background_init():
157
+ """백그라운드에서 검색기 초기화 수행"""
158
+ global app_ready, retriever, base_retriever
159
+
160
+ # 즉시 앱 사용 가능 상태로 설정
161
+ app_ready = True
162
+ logger.info("앱을 즉시 사용 가능 상태로 설정 (app_ready=True)")
163
+
164
+ try:
165
+ from app.init_retriever import init_retriever
166
+
167
+ # 기본 검색기 초기화 (보험)
168
+ if base_retriever is None:
169
+ base_retriever = MockComponent()
170
+ if hasattr(base_retriever, 'documents'):
171
+ base_retriever.documents = []
172
+
173
+ # 임시 retriever 설정
174
+ if retriever is None:
175
+ retriever = MockComponent()
176
+ if not hasattr(retriever, 'search'):
177
+ retriever.search = lambda query, **kwargs: []
178
+
179
+ # 임베딩 캐시 파일 경로
180
+ cache_path = os.path.join(app.config['INDEX_PATH'], "cached_embeddings.gz")
181
+
182
+ # 캐시된 임베딩 로드 시도
183
+ try:
184
+ from app.init_retriever import load_embeddings
185
+ cached_retriever = load_embeddings(cache_path)
186
+
187
+ if cached_retriever:
188
+ # 캐시된 데이터가 있으면 바로 사용
189
+ base_retriever = cached_retriever
190
+
191
+ # 재순위화 검색기 초기화
192
+ retriever = ReRanker(
193
+ base_retriever=base_retriever,
194
+ rerank_fn=lambda query, results: results,
195
+ rerank_field="text"
196
+ )
197
+
198
+ logger.info("캐시된 임베딩으로 검색기 초기화 완료 (빠른 시작)")
199
+ else:
200
+ # 캐시된 데이터가 없으면 전체 초기화 진행
201
+ logger.info("캐시된 임베딩이 없어 전체 초기화 시작")
202
+ retriever = init_retriever(app, base_retriever, retriever, ReRanker)
203
+ logger.info("전체 초기화 완료")
204
+ except ImportError:
205
+ logger.warning("임베딩 캐시 모듈을 찾을 수 없습니다. 전체 초기화를 진행합니다.")
206
+ retriever = init_retriever(app, base_retriever, retriever, ReRanker)
207
+
208
+ logger.info("앱 초기화 완료 (모든 컴포넌트 준비됨)")
209
+ except Exception as e:
210
+ logger.error(f"앱 백그라운드 초기화 중 심각한 오류 발생: {e}", exc_info=True)
211
+ # 초기화 실패 시 기본 객체 생성
212
+ if base_retriever is None:
213
+ base_retriever = MockComponent()
214
+ if hasattr(base_retriever, 'documents'):
215
+ base_retriever.documents = []
216
+ if retriever is None:
217
+ retriever = MockComponent()
218
+ if not hasattr(retriever, 'search'):
219
+ retriever.search = lambda query, **kwargs: []
220
+
221
+ logger.warning("초기화 중 오류가 있지만 앱은 계속 사용 가능합니다.")
222
+
223
+
224
+ # --- 라우트 등록 ---
225
+ def register_all_routes():
226
+ try:
227
+ # 기본 라우트 등록
228
+ from app.app_routes import register_routes
229
+ register_routes(
230
+ app, login_required, llm_interface, retriever, stt_client,
231
+ DocumentProcessor, base_retriever, app_ready,
232
+ ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL
233
+ )
234
+
235
+ # 장치 관리 라우트 등록
236
+ from app.app_device_routes import register_device_routes
237
+ register_device_routes(app, login_required, DEVICE_SERVER_URL)
238
+
239
+ logger.info("모든 라우트 등록 완료")
240
+ except ImportError as e:
241
+ logger.error(f"라우트 모듈 임포트 실패: {e}")
242
+ except Exception as e:
243
+ logger.error(f"라우트 등록 중 오류 발생: {e}", exc_info=True)
244
+
245
+
246
+ # --- 앱 초기화 및 실행 ---
247
+ def initialize_app():
248
+ # 백그라운드 초기화 스레드 시작
249
+ init_thread = threading.Thread(target=background_init)
250
+ init_thread.daemon = True
251
+ init_thread.start()
252
+
253
+ # 라우트 등록
254
+ register_all_routes()
255
+
256
+ logger.info("앱 초기화 완료")
257
+
258
+
259
+ # 앱 초기화 실행
260
+ initialize_app()
261
+
262
+
263
+ # --- 앱 실행 (직접 실행 시) ---
264
+ if __name__ == '__main__':
265
+ logger.info("Flask 앱을 직접 실행합니다 (개발용 서버).")
266
+ port = int(os.environ.get("PORT", 7860))
267
+ logger.info(f"서버를 http://0.0.0.0:{port} 에서 시작합니다.")
268
+ app.run(debug=True, host='0.0.0.0', port=port)
app/app_routes.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # 임베딩 저장 함수
32
+ def save_embeddings(base_retriever, file_path):
33
+ """임베딩 데이터를 압축하여 파일에 저장"""
34
+ import pickle
35
+ import gzip
36
+
37
+ try:
38
+ # 저장 디렉토리가 없으면 생성
39
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
40
+
41
+ # 타임스탬프 추가
42
+ save_data = {
43
+ 'timestamp': datetime.now().isoformat(),
44
+ 'retriever': base_retriever
45
+ }
46
+
47
+ # 압축하여 저장 (용량 줄이기)
48
+ with gzip.open(file_path, 'wb') as f:
49
+ pickle.dump(save_data, f)
50
+
51
+ logger.info(f"임베딩 데이터를 {file_path}에 압축하여 저장했습니다.")
52
+ return True
53
+ except Exception as e:
54
+ logger.error(f"임베딩 저장 중 오류 발생: {e}")
55
+ return False
56
+
57
+ @app.route('/login', methods=['GET', 'POST'])
58
+ def login():
59
+ error = None
60
+ next_url = request.args.get('next')
61
+ logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
62
+ logger.info(f"Method: {request.method}")
63
+
64
+ if request.method == 'POST':
65
+ logger.info("로그인 시도 받음")
66
+ username = request.form.get('username', '')
67
+ password = request.form.get('password', '')
68
+ logger.info(f"입력된 사용자명: {username}")
69
+ logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
70
+
71
+ # 환경 변수 또는 기본값과 비교
72
+ valid_username = ADMIN_USERNAME
73
+ valid_password = ADMIN_PASSWORD
74
+ logger.info(f"검증용 사용자명: {valid_username}")
75
+ logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
76
+
77
+ if username == valid_username and password == valid_password:
78
+ logger.info(f"로그인 성공: {username}")
79
+ # 세션 설정 전 현재 세션 상태 로깅
80
+ logger.debug(f"세션 설정 전: {session}")
81
+
82
+ # 세션에 로그인 정보 저장
83
+ session.permanent = True
84
+ session['logged_in'] = True
85
+ session['username'] = username
86
+ session.modified = True
87
+
88
+ logger.info(f"세션 설정 후: {session}")
89
+ logger.info("세션 설정 완료, 리디렉션 시도")
90
+
91
+ # 로그인 성공 후 리디렉션
92
+ redirect_to = next_url or url_for('index')
93
+ logger.info(f"리디렉션 대상: {redirect_to}")
94
+ response = redirect(redirect_to)
95
+ return response
96
+ else:
97
+ logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
98
+ if username != valid_username: logger.warning("사용자명 불일치")
99
+ if password != valid_password: logger.warning("비밀번호 불일치")
100
+ error = '아이디 또는 비밀번호가 올바르지 않습니다.'
101
+ else:
102
+ logger.info("로그인 페이지 GET 요청")
103
+ if 'logged_in' in session:
104
+ logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
105
+ return redirect(url_for('index'))
106
+
107
+ logger.info("---------- 로그인 페이지 렌더링 ----------")
108
+ return render_template('login.html', error=error, next=next_url)
109
+
110
+
111
+ @app.route('/logout')
112
+ def logout():
113
+ logger.info("-------------- 로그아웃 요청 --------------")
114
+ logger.info(f"로그아웃 전 세션 상태: {session}")
115
+
116
+ if 'logged_in' in session:
117
+ username = session.get('username', 'unknown')
118
+ logger.info(f"사용자 {username} 로그아웃 처리 시작")
119
+ session.pop('logged_in', None)
120
+ session.pop('username', None)
121
+ session.modified = True
122
+ logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
123
+ else:
124
+ logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
125
+
126
+ logger.info("로그인 페이지로 리디렉션")
127
+ response = redirect(url_for('login'))
128
+ return response
129
+
130
+
131
+ @app.route('/')
132
+ @login_required
133
+ def index():
134
+ """메인 페이지"""
135
+ nonlocal app_ready
136
+
137
+ # 앱 준비 상태 확인 - 30초 이상 지났으면 강제로 ready 상태로 변경
138
+ current_time = datetime.now()
139
+ start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
140
+ time_diff = (current_time - start_time).total_seconds()
141
+
142
+ if not app_ready and time_diff > 30:
143
+ logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
144
+ app_ready = True
145
+
146
+ if not app_ready:
147
+ logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
148
+ return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
149
+
150
+ logger.info("메인 페이지 요청")
151
+ return render_template('index.html')
152
+
153
+
154
+ @app.route('/api/status')
155
+ @login_required
156
+ def app_status():
157
+ """앱 초기화 상태 확인 API"""
158
+ logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
159
+ return jsonify({"ready": app_ready})
160
+
161
+
162
+ @app.route('/api/llm', methods=['GET', 'POST'])
163
+ @login_required
164
+ def llm_api():
165
+ """사용 가능한 LLM 목록 및 선택 API"""
166
+ if not app_ready:
167
+ return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
168
+
169
+ if request.method == 'GET':
170
+ logger.info("LLM 목록 요청")
171
+ try:
172
+ current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
173
+ supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
174
+ supported_list = [{
175
+ "name": name, "id": id, "current": id == current_details.get("id")
176
+ } for name, id in supported_llms_dict.items()]
177
+
178
+ return jsonify({
179
+ "supported_llms": supported_list,
180
+ "current_llm": current_details
181
+ })
182
+ except Exception as e:
183
+ logger.error(f"LLM 정보 조회 오류: {e}")
184
+ return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
185
+
186
+ elif request.method == 'POST':
187
+ data = request.get_json()
188
+ if not data or 'llm_id' not in data:
189
+ return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
190
+
191
+ llm_id = data['llm_id']
192
+ logger.info(f"LLM 변경 요청: {llm_id}")
193
+
194
+ try:
195
+ if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
196
+ raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
197
+
198
+ if llm_id not in llm_interface.llm_clients:
199
+ return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
200
+
201
+ success = llm_interface.set_llm(llm_id)
202
+ if success:
203
+ new_details = llm_interface.get_current_llm_details()
204
+ logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
205
+ return jsonify({
206
+ "success": True,
207
+ "message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
208
+ "current_llm": new_details
209
+ })
210
+ else:
211
+ logger.error(f"LLM 변경 실패 (ID: {llm_id})")
212
+ return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
213
+ except Exception as e:
214
+ logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
215
+ return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
216
+
217
+
218
+ @app.route('/api/chat', methods=['POST'])
219
+ @login_required
220
+ def chat():
221
+ """텍스트 기반 챗봇 API"""
222
+ if not app_ready or retriever is None:
223
+ return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
224
+
225
+ try:
226
+ data = request.get_json()
227
+ if not data or 'query' not in data:
228
+ return jsonify({"error": "쿼리가 제공���지 않았습니다."}), 400
229
+
230
+ query = data['query']
231
+ logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
232
+
233
+ # RAG 검색 수행
234
+ if not hasattr(retriever, 'search'):
235
+ raise NotImplementedError("Retriever에 search 메소드가 없습니다.")
236
+ search_results = retriever.search(query, top_k=5, first_stage_k=6)
237
+
238
+ # 컨텍스트 준비
239
+ if not hasattr(DocumentProcessor, 'prepare_rag_context'):
240
+ raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.")
241
+ context = DocumentProcessor.prepare_rag_context(search_results, field="text")
242
+
243
+ if not context:
244
+ logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.")
245
+
246
+ # LLM에 질의
247
+ llm_id = data.get('llm_id', None)
248
+ if not hasattr(llm_interface, 'rag_generate'):
249
+ raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.")
250
+
251
+ if not context:
252
+ answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
253
+ logger.info("컨텍스트 없이 기본 응답 생성")
254
+ else:
255
+ answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
256
+ logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
257
+
258
+ # 소스 정보 추출 (CSV ID 추출 로직 포함)
259
+ sources = []
260
+ if search_results:
261
+ for result in search_results:
262
+ if not isinstance(result, dict):
263
+ logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
264
+ continue
265
+
266
+ if "source" in result:
267
+ source_info = {
268
+ "source": result.get("source", "Unknown"),
269
+ "score": result.get("rerank_score", result.get("score", 0))
270
+ }
271
+
272
+ # CSV 파일 특정 처리
273
+ if "text" in result and result.get("filetype") == "csv":
274
+ try:
275
+ text_lines = result["text"].strip().split('\n')
276
+ if text_lines:
277
+ first_line = text_lines[0].strip()
278
+ if ',' in first_line:
279
+ first_column = first_line.split(',')[0].strip()
280
+ source_info["id"] = first_column
281
+ logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}")
282
+ except Exception as e:
283
+ logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}")
284
+
285
+ sources.append(source_info)
286
+
287
+ # 최종 응답
288
+ response_data = {
289
+ "answer": answer,
290
+ "sources": sources,
291
+ "llm": llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {}
292
+ }
293
+ return jsonify(response_data)
294
+
295
+ except Exception as e:
296
+ logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
297
+ return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
app/docs/project_plan.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG 챗봇 + LocalPCAgent 통합 프로젝트 계획
2
+
3
+ ## 프로젝트 개요
4
+
5
+ 이 프로젝트는 RAG 챗봇 백엔드(Flask 기반)와 LocalPCAgent 제어 인터페이스를 통합하는 것을 목표로 합니다. 사용자는 RAG 챗봇 인터페이스 내에서 LocalPCAgent를 통해 원격으로 PC를 제어할 수 있게 됩니다.
6
+
7
+ ## 완료된 작업
8
+
9
+ ### 1. 장치 서버 연결 기능 개선 (2025-05-02)
10
+
11
+ - `app-device.js` 파일에서 `connectServer()` 함수 수정:
12
+ - 환경변수에 저장된 URL을 우선적으로 사용하도록 수정
13
+ - 텍스트박스에 입력된 URL 주소는 환경변수 URL 연결 실패 시 백업으로 사용
14
+ - 연결 상태 및 오류 메시지 개선
15
+
16
+ - `app_device_routes.py` 파일에 새로운 기능 추가:
17
+ - 사용자 지정 URL 저장을 위한 `custom_device_url` 변수 추가
18
+ - URL 관리를 위한 `get_device_url()` 함수 구현
19
+ - `/api/device/connect` 엔드포인트 추가하여 사용자 지정 URL 설정 기능 구현
20
+ - 모든 API 엔드포인트에서 `get_device_url()` 함수를 사용하도록 업데이트
21
+
22
+ ## 예정된 작업
23
+
24
+ ### 1. 추가 UI 개선
25
+
26
+ - 장치 서버 URL 입력 필드에 기본 텍스트 추가 (예: "환경변수에 저장된 URL 사용, 또는 직접 입력")
27
+ - 연결 성공/실패 시 UI 피드백 개선
28
+
29
+ ### 2. 오류 처리 강화
30
+
31
+ - 오류 메시지 개선 및 더 구체적인 가이드 제공
32
+ - 네트워크 오류 발생 시 자동 재시도 기능
33
+
34
+ ### 3. 테스트
35
+
36
+ - 환경변수 URL 및 사용자 지정 URL 전환 테스트
37
+ - 다양한 오류 상황 시뮬레이션 및 복구 테스트
38
+
39
+ ## 기술 스택
40
+
41
+ - 프론트엔드: JavaScript, HTML, CSS
42
+ - 백엔드: Flask (Python)
43
+ - 통신: RESTful API
44
+ - 장치 제어: LocalPCAgent API
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,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 장치 제어 관련 스타일 */
2
+
3
+ /* 장치 제어 섹션 */
4
+ #deviceSection {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 20px;
8
+ max-width: 1000px;
9
+ margin: 0 auto;
10
+ padding: 20px;
11
+ }
12
+
13
+ /* 장치 연결 컨테이너 */
14
+ .device-connection {
15
+ background-color: var(--bg-color-secondary);
16
+ border-radius: 8px;
17
+ padding: 15px;
18
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
19
+ }
20
+
21
+ .device-connection h3 {
22
+ margin-top: 0;
23
+ margin-bottom: 15px;
24
+ color: var(--text-color-primary);
25
+ font-size: 1.2rem;
26
+ }
27
+
28
+ .device-connection-form {
29
+ display: flex;
30
+ gap: 10px;
31
+ margin-bottom: 15px;
32
+ }
33
+
34
+ .device-connection-form input {
35
+ flex: 1;
36
+ padding: 10px;
37
+ border: 1px solid var(--border-color);
38
+ border-radius: 4px;
39
+ font-size: 0.95rem;
40
+ }
41
+
42
+ .device-connection-form button {
43
+ background-color: var(--primary-color);
44
+ color: white;
45
+ border: none;
46
+ border-radius: 4px;
47
+ padding: 10px 15px;
48
+ cursor: pointer;
49
+ font-weight: 500;
50
+ transition: background-color 0.2s;
51
+ }
52
+
53
+ .device-connection-form button:hover {
54
+ background-color: var(--primary-color-dark);
55
+ }
56
+
57
+ .device-connection-form button:disabled {
58
+ background-color: var(--disabled-color);
59
+ cursor: not-allowed;
60
+ }
61
+
62
+ .connection-status {
63
+ padding: 10px;
64
+ border-radius: 4px;
65
+ font-size: 0.9rem;
66
+ }
67
+
68
+ .connection-status.connected {
69
+ background-color: rgba(25, 135, 84, 0.1);
70
+ color: #198754;
71
+ border: 1px solid rgba(25, 135, 84, 0.2);
72
+ }
73
+
74
+ .connection-status.disconnected {
75
+ background-color: rgba(108, 117, 125, 0.1);
76
+ color: #6c757d;
77
+ border: 1px solid rgba(108, 117, 125, 0.2);
78
+ }
79
+
80
+ .connection-status.error {
81
+ background-color: rgba(220, 53, 69, 0.1);
82
+ color: #dc3545;
83
+ border: 1px solid rgba(220, 53, 69, 0.2);
84
+ }
85
+
86
+ /* 장치 기능 컨테이너 */
87
+ .device-functions {
88
+ background-color: var(--bg-color-secondary);
89
+ border-radius: 8px;
90
+ padding: 15px;
91
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
92
+ display: none; /* 초기에는 숨김 */
93
+ }
94
+
95
+ .device-functions.active {
96
+ display: block;
97
+ }
98
+
99
+ .device-functions h3 {
100
+ margin-top: 0;
101
+ margin-bottom: 15px;
102
+ color: var(--text-color-primary);
103
+ font-size: 1.2rem;
104
+ }
105
+
106
+ .function-buttons {
107
+ display: flex;
108
+ gap: 10px;
109
+ margin-bottom: 15px;
110
+ flex-wrap: wrap;
111
+ }
112
+
113
+ .function-buttons button {
114
+ background-color: var(--secondary-color);
115
+ color: white;
116
+ border: none;
117
+ border-radius: 4px;
118
+ padding: 8px 12px;
119
+ cursor: pointer;
120
+ font-size: 0.9rem;
121
+ transition: background-color 0.2s;
122
+ }
123
+
124
+ .function-buttons button:hover {
125
+ background-color: var(--secondary-color-dark);
126
+ }
127
+
128
+ .function-buttons button:disabled {
129
+ background-color: var(--disabled-color);
130
+ cursor: not-allowed;
131
+ }
132
+
133
+ .device-status-result {
134
+ width: 100%;
135
+ min-height: 100px;
136
+ max-height: 200px;
137
+ padding: 10px;
138
+ border: 1px solid var(--border-color);
139
+ border-radius: 4px;
140
+ font-family: monospace;
141
+ font-size: 0.9rem;
142
+ overflow-y: auto;
143
+ background-color: var(--bg-color-tertiary);
144
+ margin-bottom: 15px;
145
+ resize: vertical;
146
+ }
147
+
148
+ /* 프로그램 실행 컨테이너 */
149
+ .program-control {
150
+ background-color: var(--bg-color-secondary);
151
+ border-radius: 8px;
152
+ padding: 15px;
153
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
154
+ display: none; /* 초기에는 숨김 */
155
+ }
156
+
157
+ .program-control.active {
158
+ display: block;
159
+ }
160
+
161
+ .program-control h3 {
162
+ margin-top: 0;
163
+ margin-bottom: 15px;
164
+ color: var(--text-color-primary);
165
+ font-size: 1.2rem;
166
+ }
167
+
168
+ .program-list-container {
169
+ margin-bottom: 20px;
170
+ }
171
+
172
+ .program-list {
173
+ width: 100%;
174
+ border-collapse: collapse;
175
+ margin-bottom: 15px;
176
+ }
177
+
178
+ .program-list th, .program-list td {
179
+ padding: 10px;
180
+ text-align: left;
181
+ border-bottom: 1px solid var(--border-color);
182
+ }
183
+
184
+ .program-list th {
185
+ background-color: var(--bg-color-tertiary);
186
+ font-weight: 500;
187
+ }
188
+
189
+ .program-list tr:hover {
190
+ background-color: var(--bg-color-tertiary);
191
+ }
192
+
193
+ .program-select-container {
194
+ margin-bottom: 15px;
195
+ }
196
+
197
+ .program-select-container select {
198
+ width: 100%;
199
+ padding: 10px;
200
+ border: 1px solid var(--border-color);
201
+ border-radius: 4px;
202
+ font-size: 0.95rem;
203
+ background-color: var(--bg-color-tertiary);
204
+ }
205
+
206
+ .execute-btn {
207
+ background-color: var(--primary-color);
208
+ color: white;
209
+ border: none;
210
+ border-radius: 4px;
211
+ padding: 10px 15px;
212
+ cursor: pointer;
213
+ font-weight: 500;
214
+ transition: background-color 0.2s;
215
+ width: 100%;
216
+ margin-bottom: 15px;
217
+ }
218
+
219
+ .execute-btn:hover {
220
+ background-color: var(--primary-color-dark);
221
+ }
222
+
223
+ .execute-btn:disabled {
224
+ background-color: var(--disabled-color);
225
+ cursor: not-allowed;
226
+ }
227
+
228
+ .execute-result {
229
+ padding: 10px;
230
+ border-radius: 4px;
231
+ margin-top: 10px;
232
+ font-size: 0.9rem;
233
+ }
234
+
235
+ .execute-result.success {
236
+ background-color: rgba(25, 135, 84, 0.1);
237
+ color: #198754;
238
+ border: 1px solid rgba(25, 135, 84, 0.2);
239
+ }
240
+
241
+ .execute-result.error {
242
+ background-color: rgba(220, 53, 69, 0.1);
243
+ color: #dc3545;
244
+ border: 1px solid rgba(220, 53, 69, 0.2);
245
+ }
246
+
247
+ .execute-result.warning {
248
+ background-color: rgba(255, 193, 7, 0.1);
249
+ color: #ffc107;
250
+ border: 1px solid rgba(255, 193, 7, 0.2);
251
+ }
252
+
253
+ /* 로딩 표시 */
254
+ .loading-spinner {
255
+ display: inline-block;
256
+ width: 16px;
257
+ height: 16px;
258
+ border: 2px solid rgba(0, 0, 0, 0.1);
259
+ border-radius: 50%;
260
+ border-top-color: var(--primary-color);
261
+ animation: spin 1s ease-in-out infinite;
262
+ margin-right: 8px;
263
+ vertical-align: middle;
264
+ }
265
+
266
+ @keyframes spin {
267
+ to { transform: rotate(360deg); }
268
+ }
269
+
270
+ .loading-message {
271
+ display: flex;
272
+ align-items: center;
273
+ justify-content: center;
274
+ padding: 20px;
275
+ font-size: 0.95rem;
276
+ color: var(--text-color-secondary);
277
+ }
278
+
279
+ /* 에러 메시지 */
280
+ .error-message {
281
+ background-color: rgba(220, 53, 69, 0.1);
282
+ color: #dc3545;
283
+ border: 1px solid rgba(220, 53, 69, 0.2);
284
+ padding: 10px;
285
+ border-radius: 4px;
286
+ margin-top: 10px;
287
+ font-size: 0.9rem;
288
+ }
289
+
290
+ /* 없음 메시지 */
291
+ .no-programs-message {
292
+ text-align: center;
293
+ padding: 20px;
294
+ font-size: 0.95rem;
295
+ color: var(--text-color-secondary);
296
+ border: 1px dashed var(--border-color);
297
+ border-radius: 4px;
298
+ margin-top: 10px;
299
+ }
300
+
301
+ /* 재시도 버튼 */
302
+ .retry-button {
303
+ background-color: var(--secondary-color);
304
+ color: white;
305
+ border: none;
306
+ border-radius: 4px;
307
+ padding: 8px 12px;
308
+ cursor: pointer;
309
+ font-size: 0.9rem;
310
+ margin-top: 10px;
311
+ transition: background-color 0.2s;
312
+ }
313
+
314
+ .retry-button:hover {
315
+ background-color: var(--secondary-color-dark);
316
+ }
app/static/css/style.css ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ --hover-color: #f1f5f9;
14
+ --transition: all 0.3s ease;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+
23
+ body {
24
+ font-family: 'Pretendard', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
25
+ line-height: 1.6;
26
+ color: var(--text-color);
27
+ background-color: var(--bg-color);
28
+ margin: 0;
29
+ }
30
+
31
+ .container {
32
+ max-width: 1000px;
33
+ margin: 0 auto;
34
+ padding: 20px;
35
+ min-height: 100vh;
36
+ display: flex;
37
+ flex-direction: column;
38
+ }
39
+
40
+ /* 헤더 스타일 */
41
+ header {
42
+ text-align: center;
43
+ margin-bottom: 20px;
44
+ }
45
+
46
+ header h1 {
47
+ color: var(--primary-color);
48
+ margin-bottom: 15px;
49
+ }
50
+
51
+ .llm-selector {
52
+ margin-bottom: 15px;
53
+ display: flex;
54
+ justify-content: center;
55
+ align-items: center;
56
+ gap: 10px;
57
+ }
58
+
59
+ .llm-selector label {
60
+ font-weight: 600;
61
+ color: var(--primary-color);
62
+ }
63
+
64
+ #llmSelect {
65
+ padding: 8px 12px;
66
+ border: 1px solid var(--border-color);
67
+ border-radius: 4px;
68
+ background-color: white;
69
+ font-size: 14px;
70
+ outline: none;
71
+ transition: var(--transition);
72
+ }
73
+
74
+ #llmSelect:focus {
75
+ border-color: var(--primary-color);
76
+ }
77
+
78
+ .tabs {
79
+ display: flex;
80
+ justify-content: center;
81
+ margin-bottom: 20px;
82
+ }
83
+
84
+ .tab {
85
+ padding: 10px 20px;
86
+ background-color: var(--card-bg);
87
+ border: 1px solid var(--border-color);
88
+ border-radius: 4px;
89
+ margin: 0 5px;
90
+ cursor: pointer;
91
+ transition: var(--transition);
92
+ }
93
+
94
+ .tab:hover {
95
+ background-color: var(--hover-color);
96
+ }
97
+
98
+ .tab.active {
99
+ background-color: var(--primary-color);
100
+ color: white;
101
+ border-color: var(--primary-color);
102
+ }
103
+
104
+ /* 메인 컨텐츠 */
105
+ main {
106
+ flex-grow: 1;
107
+ }
108
+
109
+ .tab-content {
110
+ display: none;
111
+ }
112
+
113
+ .tab-content.active {
114
+ display: block;
115
+ }
116
+
117
+ /* 채팅 섹션 */
118
+ .chat-container {
119
+ background-color: var(--card-bg);
120
+ border-radius: 8px;
121
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
122
+ overflow: hidden;
123
+ height: 70vh;
124
+ display: flex;
125
+ flex-direction: column;
126
+ }
127
+
128
+ .chat-messages {
129
+ flex-grow: 1;
130
+ overflow-y: auto;
131
+ padding: 20px;
132
+ }
133
+
134
+ .message {
135
+ margin-bottom: 20px;
136
+ display: flex;
137
+ align-items: flex-start;
138
+ }
139
+
140
+ .message.user {
141
+ justify-content: flex-end;
142
+ }
143
+
144
+ .message-content {
145
+ padding: 12px 16px;
146
+ border-radius: 12px;
147
+ max-width: 80%;
148
+ }
149
+
150
+ .message.system .message-content {
151
+ background-color: #f0f7ff;
152
+ color: var(--primary-dark);
153
+ }
154
+
155
+ .message.user .message-content {
156
+ background-color: var(--primary-color);
157
+ color: white;
158
+ border-top-right-radius: 4px;
159
+ }
160
+
161
+ .message.bot .message-content {
162
+ background-color: #f1f5f9;
163
+ color: var(--text-color);
164
+ border-top-left-radius: 4px;
165
+ }
166
+
167
+ .message p {
168
+ margin-bottom: 8px;
169
+ }
170
+
171
+ .message p:last-child {
172
+ margin-bottom: 0;
173
+ }
174
+
175
+ .message .sources {
176
+ font-size: 0.85em;
177
+ color: var(--light-text);
178
+ margin-top: 5px;
179
+ }
180
+
181
+ .message .source-item {
182
+ margin-right: 10px;
183
+ }
184
+
185
+ .message .transcription {
186
+ font-style: italic;
187
+ opacity: 0.8;
188
+ font-size: 0.9em;
189
+ margin-bottom: 8px;
190
+ }
191
+
192
+ .chat-input-container {
193
+ display: flex;
194
+ padding: 15px;
195
+ border-top: 1px solid var(--border-color);
196
+ background-color: var(--card-bg);
197
+ }
198
+
199
+ #userInput {
200
+ flex-grow: 1;
201
+ border: 1px solid var(--border-color);
202
+ border-radius: 20px;
203
+ padding: 10px 15px;
204
+ font-size: 16px;
205
+ resize: none;
206
+ outline: none;
207
+ transition: var(--transition);
208
+ }
209
+
210
+ #userInput:focus {
211
+ border-color: var(--primary-color);
212
+ }
213
+
214
+ .mic-button, .send-button, .stop-recording-button {
215
+ border: none;
216
+ background-color: transparent;
217
+ color: var(--primary-color);
218
+ font-size: 20px;
219
+ margin-left: 10px;
220
+ cursor: pointer;
221
+ transition: var(--transition);
222
+ width: 40px;
223
+ height: 40px;
224
+ border-radius: 50%;
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: center;
228
+ }
229
+
230
+ .mic-button:hover, .send-button:hover, .stop-recording-button:hover {
231
+ background-color: var(--hover-color);
232
+ }
233
+
234
+ .stop-recording-button {
235
+ background-color: var(--error-color);
236
+ color: white;
237
+ }
238
+
239
+ .stop-recording-button:hover {
240
+ background-color: #dc2626; /* 더 어두운 빨간색 */
241
+ }
242
+
243
+ .recording-status {
244
+ display: flex;
245
+ align-items: center;
246
+ padding: 10px 15px;
247
+ background-color: rgba(239, 68, 68, 0.1);
248
+ border-top: 1px solid var(--border-color);
249
+ color: var(--error-color);
250
+ }
251
+
252
+ .recording-indicator {
253
+ position: relative;
254
+ width: 12px;
255
+ height: 12px;
256
+ margin-right: 10px;
257
+ }
258
+
259
+ .recording-pulse {
260
+ position: absolute;
261
+ width: 100%;
262
+ height: 100%;
263
+ background-color: var(--error-color);
264
+ border-radius: 50%;
265
+ animation: pulse 1.5s infinite;
266
+ }
267
+
268
+ @keyframes pulse {
269
+ 0% {
270
+ transform: scale(0.8);
271
+ opacity: 1;
272
+ }
273
+ 70% {
274
+ transform: scale(1.5);
275
+ opacity: 0;
276
+ }
277
+ 100% {
278
+ transform: scale(0.8);
279
+ opacity: 0;
280
+ }
281
+ }
282
+
283
+ .hidden {
284
+ display: none;
285
+ }
286
+
287
+ /* 문서 관리 섹션 */
288
+ .docs-container {
289
+ background-color: var(--card-bg);
290
+ border-radius: 8px;
291
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
292
+ overflow: hidden;
293
+ padding: 20px;
294
+ }
295
+
296
+ .upload-section {
297
+ margin-bottom: 30px;
298
+ padding-bottom: 20px;
299
+ border-bottom: 1px solid var(--border-color);
300
+ }
301
+
302
+ .upload-section h2, .docs-list-section h2 {
303
+ margin-bottom: 15px;
304
+ color: var(--primary-color);
305
+ }
306
+
307
+ .file-upload {
308
+ display: flex;
309
+ align-items: center;
310
+ margin-bottom: 20px;
311
+ }
312
+
313
+ .file-upload input[type="file"] {
314
+ display: none;
315
+ }
316
+
317
+ .file-upload label {
318
+ padding: 10px 20px;
319
+ background-color: var(--primary-color);
320
+ color: white;
321
+ border-radius: 4px;
322
+ cursor: pointer;
323
+ transition: var(--transition);
324
+ }
325
+
326
+ .file-upload label:hover {
327
+ background-color: var(--primary-dark);
328
+ }
329
+
330
+ #fileName {
331
+ margin-left: 15px;
332
+ color: var(--light-text);
333
+ }
334
+
335
+ .upload-button {
336
+ padding: 10px 20px;
337
+ background-color: var(--secondary-color);
338
+ color: white;
339
+ border: none;
340
+ border-radius: 4px;
341
+ cursor: pointer;
342
+ transition: var(--transition);
343
+ display: flex;
344
+ align-items: center;
345
+ }
346
+
347
+ .upload-button i {
348
+ margin-right: 8px;
349
+ }
350
+
351
+ .upload-button:hover {
352
+ background-color: #d97706; /* 더 어두운 주황색 */
353
+ }
354
+
355
+ .upload-status {
356
+ margin-top: 15px;
357
+ padding: 10px 15px;
358
+ border-radius: 4px;
359
+ }
360
+
361
+ .upload-status.success {
362
+ background-color: rgba(16, 185, 129, 0.1);
363
+ color: var(--success-color);
364
+ }
365
+
366
+ .upload-status.error {
367
+ background-color: rgba(239, 68, 68, 0.1);
368
+ color: var(--error-color);
369
+ }
370
+
371
+ .docs-list-section {
372
+ position: relative;
373
+ }
374
+
375
+ .refresh-button {
376
+ position: absolute;
377
+ top: 0;
378
+ right: 0;
379
+ padding: 5px 10px;
380
+ background-color: transparent;
381
+ color: var(--primary-color);
382
+ border: 1px solid var(--primary-color);
383
+ border-radius: 4px;
384
+ cursor: pointer;
385
+ transition: var(--transition);
386
+ display: flex;
387
+ align-items: center;
388
+ }
389
+
390
+ .refresh-button i {
391
+ margin-right: 5px;
392
+ }
393
+
394
+ .refresh-button:hover {
395
+ background-color: var(--hover-color);
396
+ }
397
+
398
+ .docs-list {
399
+ width: 100%;
400
+ border-collapse: collapse;
401
+ margin-top: 20px;
402
+ }
403
+
404
+ .docs-list th, .docs-list td {
405
+ padding: 12px 15px;
406
+ text-align: left;
407
+ border-bottom: 1px solid var(--border-color);
408
+ }
409
+
410
+ .docs-list th {
411
+ background-color: #f1f5f9;
412
+ font-weight: 600;
413
+ }
414
+
415
+ .docs-list tr:hover {
416
+ background-color: var(--hover-color);
417
+ }
418
+
419
+ .loading-indicator {
420
+ display: flex;
421
+ flex-direction: column;
422
+ align-items: center;
423
+ justify-content: center;
424
+ padding: 30px;
425
+ }
426
+
427
+ .spinner {
428
+ width: 40px;
429
+ height: 40px;
430
+ border: 4px solid #f3f3f3;
431
+ border-top: 4px solid var(--primary-color);
432
+ border-radius: 50%;
433
+ animation: spin 1s linear infinite;
434
+ margin-bottom: 15px;
435
+ }
436
+
437
+ @keyframes spin {
438
+ 0% { transform: rotate(0deg); }
439
+ 100% { transform: rotate(360deg); }
440
+ }
441
+
442
+ .no-docs-message {
443
+ text-align: center;
444
+ padding: 30px;
445
+ color: var(--light-text);
446
+ }
447
+
448
+ /* 푸터 */
449
+ footer {
450
+ text-align: center;
451
+ margin-top: 30px;
452
+ padding-top: 20px;
453
+ border-top: 1px solid var(--border-color);
454
+ color: var(--light-text);
455
+ font-size: 0.9em;
456
+ display: flex;
457
+ justify-content: space-between;
458
+ align-items: center;
459
+ flex-wrap: wrap;
460
+ }
461
+
462
+ footer p {
463
+ margin-bottom: 5px;
464
+ }
465
+
466
+ .current-llm {
467
+ color: var(--primary-color);
468
+ font-weight: 500;
469
+ }
470
+
471
+ #currentLLMInfo {
472
+ font-weight: 600;
473
+ }
474
+
475
+ /* 반응형 스타일 */
476
+ @media (max-width: 768px) {
477
+ .container {
478
+ padding: 10px;
479
+ }
480
+
481
+ .chat-container {
482
+ height: 65vh;
483
+ }
484
+
485
+ .message-content {
486
+ max-width: 90%;
487
+ }
488
+
489
+ .file-upload {
490
+ flex-direction: column;
491
+ align-items: flex-start;
492
+ }
493
+
494
+ #fileName {
495
+ margin-left: 0;
496
+ margin-top: 10px;
497
+ }
498
+
499
+ .refresh-button {
500
+ position: static;
501
+ margin-top: 10px;
502
+ margin-bottom: 10px;
503
+ }
504
+
505
+ footer {
506
+ flex-direction: column;
507
+ text-align: center;
508
+ }
509
+
510
+ .current-llm {
511
+ margin-top: 10px;
512
+ }
513
+
514
+ .llm-selector {
515
+ flex-direction: column;
516
+ gap: 5px;
517
+ }
518
+ }
app/static/js/app-core.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 UI 공통 유틸리티 JavaScript
3
+ */
4
+
5
+ // 전역 유틸리티 함수
6
+ const AppUtils = {
7
+ // 시스템 알림 메시지 추가
8
+ addSystemNotification: function(message) {
9
+ console.log(`[시스템 알림] ${message}`);
10
+
11
+ const messageDiv = document.createElement('div');
12
+ messageDiv.classList.add('message', 'system');
13
+
14
+ const contentDiv = document.createElement('div');
15
+ contentDiv.classList.add('message-content');
16
+
17
+ const messageP = document.createElement('p');
18
+ messageP.innerHTML = `<i class="fas fa-info-circle"></i> ${message}`;
19
+ contentDiv.appendChild(messageP);
20
+
21
+ messageDiv.appendChild(contentDiv);
22
+
23
+ // 채팅 메시지 영역이 있으면 추가
24
+ const chatMessages = document.getElementById('chatMessages');
25
+ if (chatMessages) {
26
+ chatMessages.appendChild(messageDiv);
27
+ // 스크롤을 가장 아래로 이동
28
+ chatMessages.scrollTop = chatMessages.scrollHeight;
29
+ }
30
+ },
31
+
32
+ // 오류 메시지 추가
33
+ addErrorMessage: function(errorText) {
34
+ console.error(`[오류] ${errorText}`);
35
+
36
+ const messageDiv = document.createElement('div');
37
+ messageDiv.classList.add('message', 'system');
38
+
39
+ const contentDiv = document.createElement('div');
40
+ contentDiv.classList.add('message-content');
41
+ contentDiv.style.backgroundColor = 'rgba(239, 68, 68, 0.1)';
42
+ contentDiv.style.color = 'var(--error-color)';
43
+
44
+ const errorP = document.createElement('p');
45
+ errorP.innerHTML = `<i class="fas fa-exclamation-circle"></i> ${errorText}`;
46
+ contentDiv.appendChild(errorP);
47
+
48
+ messageDiv.appendChild(contentDiv);
49
+
50
+ // 채팅 메시지 영역이 있으면 추가
51
+ const chatMessages = document.getElementById('chatMessages');
52
+ if (chatMessages) {
53
+ chatMessages.appendChild(messageDiv);
54
+ // 스크롤을 가장 아래로 이동
55
+ chatMessages.scrollTop = chatMessages.scrollHeight;
56
+ }
57
+ },
58
+
59
+ // 타임아웃 기능이 있는 fetch
60
+ fetchWithTimeout: async function(url, options = {}, timeout = 30000) {
61
+ console.log(`API 요청: ${options.method || 'GET'} ${url}`);
62
+
63
+ const controller = new AbortController();
64
+ const id = setTimeout(() => controller.abort(), timeout);
65
+
66
+ try {
67
+ const response = await fetch(url, {
68
+ ...options,
69
+ signal: controller.signal
70
+ });
71
+ clearTimeout(id);
72
+ console.log(`API 응답 상태: ${response.status}`);
73
+ return response;
74
+ } catch (error) {
75
+ clearTimeout(id);
76
+ if (error.name === 'AbortError') {
77
+ console.error(`API 요청 타임아웃: ${url}`);
78
+ throw new Error('요청 시간이 초과되었습니다.');
79
+ }
80
+ console.error(`API 요청 실패: ${url}`, error);
81
+ throw error;
82
+ }
83
+ },
84
+
85
+ // 로딩 스피너 HTML 생성
86
+ createLoadingSpinner: function() {
87
+ return '<div class="loading-spinner"></div>';
88
+ },
89
+
90
+ // 날짜 포맷팅
91
+ formatDate: function(date) {
92
+ return new Date(date).toLocaleString('ko-KR', {
93
+ year: 'numeric',
94
+ month: '2-digit',
95
+ day: '2-digit',
96
+ hour: '2-digit',
97
+ minute: '2-digit'
98
+ });
99
+ },
100
+
101
+ // HTML 문자열 이스케이프 (XSS 방지)
102
+ escapeHtml: function(html) {
103
+ const div = document.createElement('div');
104
+ div.textContent = html;
105
+ return div.innerHTML;
106
+ }
107
+ };
108
+
109
+ // 페이지 로드 완료 시 공통 초기화
110
+ document.addEventListener('DOMContentLoaded', function() {
111
+ console.log('앱 코어 모듈 초기화 완료');
112
+ });
app/static/js/app-device.js ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 장치 제어 JavaScript
3
+ */
4
+
5
+ // 장치 제어 모듈
6
+ const DeviceControl = {
7
+ // 장치 제어 상태
8
+ isConnected: false,
9
+ isStatusChecked: false,
10
+ isLoadingPrograms: false,
11
+ programsList: [],
12
+
13
+ // DOM 요소들
14
+ elements: {
15
+ // 탭 및 섹션
16
+ deviceTab: null,
17
+ deviceSection: null,
18
+
19
+ // 연결 관련
20
+ deviceServerUrlInput: null,
21
+ connectDeviceServerBtn: null,
22
+ deviceConnectionStatus: null,
23
+
24
+ // 기본 기능
25
+ deviceBasicFunctions: null,
26
+ checkDeviceStatusBtn: null,
27
+ deviceStatusResult: null,
28
+
29
+ // 프로그램 실행
30
+ deviceProgramControl: null,
31
+ getProgramsBtn: null,
32
+ programsList: null,
33
+ programSelectDropdown: null,
34
+ executeProgramBtn: null,
35
+ executeResult: null
36
+ },
37
+
38
+ // 모듈 초기화
39
+ init: function() {
40
+ console.log('장치 제어 모듈 초기화 중...');
41
+
42
+ // DOM 요소 참조 가져오기
43
+ this.initElements();
44
+
45
+ // 이벤트 리스너 등록
46
+ this.initEventListeners();
47
+
48
+ console.log('장치 제어 모듈 초기화 완료');
49
+ },
50
+
51
+ // DOM 요소 참조 초기화
52
+ initElements: function() {
53
+ // 탭 및 섹션
54
+ this.elements.deviceTab = document.getElementById('deviceTab');
55
+ this.elements.deviceSection = document.getElementById('deviceSection');
56
+
57
+ // 연결 관련
58
+ this.elements.deviceServerUrlInput = document.getElementById('deviceServerUrlInput');
59
+ this.elements.connectDeviceServerBtn = document.getElementById('connectDeviceServerBtn');
60
+ this.elements.deviceConnectionStatus = document.getElementById('deviceConnectionStatus');
61
+
62
+ // 기본 기능
63
+ this.elements.deviceBasicFunctions = document.getElementById('deviceBasicFunctions');
64
+ this.elements.checkDeviceStatusBtn = document.getElementById('checkDeviceStatusBtn');
65
+ this.elements.deviceStatusResult = document.getElementById('deviceStatusResult');
66
+
67
+ // 프로그램 실행
68
+ this.elements.deviceProgramControl = document.getElementById('deviceProgramControl');
69
+ this.elements.getProgramsBtn = document.getElementById('getProgramsBtn');
70
+ this.elements.programsList = document.getElementById('programsList');
71
+ this.elements.programSelectDropdown = document.getElementById('programSelectDropdown');
72
+ this.elements.executeProgramBtn = document.getElementById('executeProgramBtn');
73
+ this.elements.executeResult = document.getElementById('executeResult');
74
+
75
+ console.log('장치 제어 DOM 요소 참조 초기화 완료');
76
+ },
77
+
78
+ // 이벤트 리스너 등록
79
+ initEventListeners: function() {
80
+ // 탭 전환
81
+ if (this.elements.deviceTab) {
82
+ this.elements.deviceTab.addEventListener('click', () => {
83
+ console.log('장치 제어 탭 클릭');
84
+ this.switchToDeviceTab();
85
+ });
86
+ }
87
+
88
+ // 서버 연결
89
+ if (this.elements.connectDeviceServerBtn) {
90
+ this.elements.connectDeviceServerBtn.addEventListener('click', () => {
91
+ console.log('장치 서버 연결 버튼 클릭');
92
+ this.connectServer();
93
+ });
94
+ }
95
+
96
+ // 엔터 키로 연결
97
+ if (this.elements.deviceServerUrlInput) {
98
+ this.elements.deviceServerUrlInput.addEventListener('keydown', (event) => {
99
+ if (event.key === 'Enter') {
100
+ console.log('장치 서버 URL 입력 필드에서 엔터 키 감지');
101
+ event.preventDefault();
102
+ this.connectServer();
103
+ }
104
+ });
105
+ }
106
+
107
+ // 장치 상태 확인
108
+ if (this.elements.checkDeviceStatusBtn) {
109
+ this.elements.checkDeviceStatusBtn.addEventListener('click', () => {
110
+ console.log('장치 상태 확인 버튼 클릭');
111
+ this.checkDeviceStatus();
112
+ });
113
+ }
114
+
115
+ // 프로그램 목록 조회
116
+ if (this.elements.getProgramsBtn) {
117
+ this.elements.getProgramsBtn.addEventListener('click', () => {
118
+ console.log('프로그램 목록 새로고침 버튼 클릭');
119
+ this.loadProgramsList();
120
+ });
121
+ }
122
+
123
+ // 프로그램 선택 변경
124
+ if (this.elements.programSelectDropdown) {
125
+ this.elements.programSelectDropdown.addEventListener('change', (event) => {
126
+ console.log(`프로그램 선택 변경: ${event.target.value}`);
127
+ this.updateExecuteButton();
128
+ });
129
+ }
130
+
131
+ // 프로그램 실행
132
+ if (this.elements.executeProgramBtn) {
133
+ this.elements.executeProgramBtn.addEventListener('click', () => {
134
+ const programId = this.elements.programSelectDropdown.value;
135
+ console.log(`프로그램 실행 버튼 클릭, 선택된 ID: ${programId}`);
136
+ this.executeProgram(programId);
137
+ });
138
+ }
139
+
140
+ console.log('장치 제어 이벤트 리스너 등록 완료');
141
+ },
142
+
143
+ // 장치 제어 탭으로 전환
144
+ switchToDeviceTab: function() {
145
+ // 모든 탭과 탭 콘텐츠 비활성화
146
+ const tabs = document.querySelectorAll('.tab');
147
+ const tabContents = document.querySelectorAll('.tab-content');
148
+
149
+ tabs.forEach(tab => tab.classList.remove('active'));
150
+ tabContents.forEach(content => content.classList.remove('active'));
151
+
152
+ // 장치 제어 탭 활성화
153
+ this.elements.deviceTab.classList.add('active');
154
+ this.elements.deviceSection.classList.add('active');
155
+
156
+ console.log('장치 제어 탭으로 전환 완료');
157
+ },
158
+
159
+ // 서버 연결 함수
160
+ connectServer: async function() {
161
+ // URL 가져오기 (입력된 것이 있으면 백업으로 사용)
162
+ const inputUrl = this.elements.deviceServerUrlInput.value.trim();
163
+
164
+ // 연결 시도 중 UI 업데이트
165
+ this.elements.connectDeviceServerBtn.disabled = true;
166
+ this.updateConnectionStatus('connecting', '환경변수에 저장된 서버로 연결 시도 중...');
167
+
168
+ try {
169
+ console.log('환경변수에 저장된 장치 서버로 연결 시도');
170
+
171
+ // 백엔드 API 호출하여 서버 상태 확인
172
+ const response = await AppUtils.fetchWithTimeout('/api/device/status', {
173
+ method: 'GET'
174
+ }, 10000); // 10초 타임아웃
175
+
176
+ const data = await response.json();
177
+
178
+ if (response.ok && data.success) {
179
+ // 연결 성공
180
+ console.log('환경변수 설정 장치 서버 연결 성공:', data);
181
+ this.isConnected = true;
182
+ this.updateConnectionStatus('connected', `서버 연결 성공! 상태: ${data.server_status || '정상'}`);
183
+
184
+ // 기능 UI 활성화
185
+ this.elements.deviceBasicFunctions.classList.add('active');
186
+ this.elements.deviceProgramControl.classList.add('active');
187
+
188
+ // 장치 상태 자동 체크
189
+ this.checkDeviceStatus();
190
+
191
+ // 프로그램 목록 자동 로드
192
+ this.loadProgramsList();
193
+
194
+ // 시스템 알림
195
+ AppUtils.addSystemNotification(`장치 관리 서버 연결 성공! (환경변수 URL)`);
196
+ } else {
197
+ // 환경변수 URL 연결 실패, 입력된 URL로 시도
198
+ console.warn('환경변수 설정 장치 서버 연결 실패, 입력 URL로 재시도합니다:', data);
199
+
200
+ // 입력 URL이 있는지 확인
201
+ if (!inputUrl) {
202
+ console.error('입력된 URL이 없어 연결 실패');
203
+ this.isConnected = false;
204
+ this.updateConnectionStatus('error', '환경변수 URL 연결 실패 및 입력된 URL이 없습니다. URL을 입력해주세요.');
205
+ return;
206
+ }
207
+
208
+ // 입력 URL로 재시도
209
+ this.updateConnectionStatus('connecting', `입력 URL(${inputUrl})로 연결 시도 중...`);
210
+ console.log(`입력한 URL로 장치 서버 연결 시도: ${inputUrl}`);
211
+
212
+ // 백엔드 API 호출 - 입력 URL 사용
213
+ const customUrlResponse = await AppUtils.fetchWithTimeout('/api/device/connect', {
214
+ method: 'POST',
215
+ headers: {
216
+ 'Content-Type': 'application/json'
217
+ },
218
+ body: JSON.stringify({ url: inputUrl })
219
+ }, 10000);
220
+
221
+ const customUrlData = await customUrlResponse.json();
222
+
223
+ if (customUrlResponse.ok && customUrlData.success) {
224
+ // 입력 URL 연결 성공
225
+ console.log('입력 URL 장치 서버 연결 성공:', customUrlData);
226
+ this.isConnected = true;
227
+ this.updateConnectionStatus('connected', `서버 연결 성공! 상태: ${customUrlData.server_status || '정상'}`);
228
+
229
+ // 기능 UI 활성화
230
+ this.elements.deviceBasicFunctions.classList.add('active');
231
+ this.elements.deviceProgramControl.classList.add('active');
232
+
233
+ // 장치 상태 자동 체크
234
+ this.checkDeviceStatus();
235
+
236
+ // 프로그램 목록 자동 로드
237
+ this.loadProgramsList();
238
+
239
+ // 시스템 알림
240
+ AppUtils.addSystemNotification(`장치 관리 서버 연결 성공! (${inputUrl})`);
241
+ } else {
242
+ // 입력 URL 연결 실패
243
+ console.error('입력 URL 장치 서버 연결 실패:', customUrlData);
244
+ this.isConnected = false;
245
+ this.updateConnectionStatus('error', `서버 연결 실패: ${customUrlData.error || '서버 응답 오류'}`);
246
+ }
247
+ }
248
+ } catch (error) {
249
+ // 예외 발생
250
+ console.error('서버 연결 중 오류 발생:', error);
251
+ this.isConnected = false;
252
+
253
+ // 환경변수 URL 연결 실패, 입력된 URL로 시도
254
+ if (inputUrl) {
255
+ console.warn('환경변수 URL 연결 시 오류 발생, 입력 URL로 재시도합니다');
256
+
257
+ try {
258
+ // 입력 URL로 재시도
259
+ this.updateConnectionStatus('connecting', `입력 URL(${inputUrl})로 연결 시도 중...`);
260
+ console.log(`입력한 URL로 장치 서버 연결 시도: ${inputUrl}`);
261
+
262
+ // 백엔드 API 호출 - 입력 URL 사용
263
+ const customUrlResponse = await AppUtils.fetchWithTimeout('/api/device/connect', {
264
+ method: 'POST',
265
+ headers: {
266
+ 'Content-Type': 'application/json'
267
+ },
268
+ body: JSON.stringify({ url: inputUrl })
269
+ }, 10000);
270
+
271
+ const customUrlData = await customUrlResponse.json();
272
+
273
+ if (customUrlResponse.ok && customUrlData.success) {
274
+ // 입력 URL 연결 성공
275
+ console.log('입력 URL 장치 서버 연결 성공:', customUrlData);
276
+ this.isConnected = true;
277
+ this.updateConnectionStatus('connected', `서버 연결 성공! 상태: ${customUrlData.server_status || '정상'}`);
278
+
279
+ // 기능 UI 활성화
280
+ this.elements.deviceBasicFunctions.classList.add('active');
281
+ this.elements.deviceProgramControl.classList.add('active');
282
+
283
+ // 장치 상태 자동 체크
284
+ this.checkDeviceStatus();
285
+
286
+ // 프로그램 목록 자동 로드
287
+ this.loadProgramsList();
288
+
289
+ // 시스템 알림
290
+ AppUtils.addSystemNotification(`장치 관리 서버 연결 성공! (${inputUrl})`);
291
+ return; // 성공하면 여기서 종료
292
+ } else {
293
+ // 입력 URL 연결 실패
294
+ console.error('입력 URL 장치 서버 연결 실패:', customUrlData);
295
+ this.updateConnectionStatus('error', `서버 연결 실패: ${customUrlData.error || '서버 응답 오류'}`);
296
+ }
297
+ } catch (inputUrlError) {
298
+ // 입력 URL로 재시도 중 오류
299
+ console.error('입력 URL로 재시도 중 오류 발생:', inputUrlError);
300
+
301
+ if (inputUrlError.message.includes('시간이 초과')) {
302
+ this.updateConnectionStatus('error', '서버 연결 시간 초과. 서버가 실행 중인지 확인해주세요.');
303
+ } else {
304
+ this.updateConnectionStatus('error', `서버 연결 오류: ${inputUrlError.message}`);
305
+ }
306
+ }
307
+ } else {
308
+ // 텍스트박스에 URL이 없는 경우
309
+ if (error.message.includes('시간이 초과')) {
310
+ this.updateConnectionStatus('error', '환경변수 URL 연결 시간 초과. URL을 입력하여 다시 시도해주세요.');
311
+ } else {
312
+ this.updateConnectionStatus('error', `환경변수 URL 연결 오류. URL을 입력하여 다시 시도해주세요: ${error.message}`);
313
+ }
314
+ }
315
+ } finally {
316
+ // 버튼 다시 활성화
317
+ this.elements.connectDeviceServerBtn.disabled = false;
318
+ }
319
+ },
320
+
321
+ // 연결 상태 업데이트
322
+ updateConnectionStatus: function(status, message) {
323
+ const statusElement = this.elements.deviceConnectionStatus;
324
+
325
+ // 모든 상태 클래스 제거
326
+ statusElement.classList.remove('connected', 'disconnected', 'error', 'connecting');
327
+
328
+ // 상태에 따라 클래스 추가
329
+ statusElement.classList.add(status);
330
+
331
+ // 메시지 업데이트
332
+ statusElement.textContent = message;
333
+
334
+ console.log(`연결 상태 업데이트: ${status} - ${message}`);
335
+ },
336
+
337
+ // 장치 상태 확인
338
+ checkDeviceStatus: async function() {
339
+ if (!this.isConnected) {
340
+ this.elements.deviceStatusResult.value = '오류: 먼저 서버에 연결해야 합니다.';
341
+ console.error('장치 상태 확인 시도 중 오류: 서버 연결 안됨');
342
+ return;
343
+ }
344
+
345
+ // 상태 확인 중 UI 업데이트
346
+ this.elements.checkDeviceStatusBtn.disabled = true;
347
+ this.elements.deviceStatusResult.value = '장치 상태 확인 중...';
348
+
349
+ try {
350
+ console.log('장치 상태 확인 요청 전송');
351
+
352
+ // 백엔드 API 호출
353
+ const response = await AppUtils.fetchWithTimeout('/api/device/status', {
354
+ method: 'GET'
355
+ });
356
+
357
+ const data = await response.json();
358
+
359
+ if (response.ok && data.success) {
360
+ // 상태 확인 성공
361
+ console.log('장치 상태 확인 성공:', data);
362
+ this.isStatusChecked = true;
363
+ this.elements.deviceStatusResult.value = JSON.stringify(data, null, 2);
364
+ } else {
365
+ // 상태 확인 실패
366
+ console.error('장치 상태 확인 실패:', data);
367
+ this.elements.deviceStatusResult.value = `상태 확인 실패: ${data.error || '알 수 없는 오류'}`;
368
+ }
369
+ } catch (error) {
370
+ // 예외 발생
371
+ console.error('장치 상태 확인 중 오류 발생:', error);
372
+ this.elements.deviceStatusResult.value = `상태 확인 중 오류 발생: ${error.message}`;
373
+ } finally {
374
+ // 버튼 다시 활성화
375
+ this.elements.checkDeviceStatusBtn.disabled = false;
376
+ }
377
+ },
378
+
379
+ // 프로그램 목록 조회
380
+ loadProgramsList: async function() {
381
+ if (!this.isConnected) {
382
+ this.showProgramsError('오류: 먼저 서버에 연결해야 합니다.');
383
+ console.error('프로그램 목록 조회 시도 중 오류: 서버 연결 안됨');
384
+ return;
385
+ }
386
+
387
+ // 이미 로딩 중이면 중복 요청 방지
388
+ if (this.isLoadingPrograms) {
389
+ console.log('이미 프로그램 목록 로딩 중');
390
+ return;
391
+ }
392
+
393
+ // 로딩 중 UI 업데이트
394
+ this.isLoadingPrograms = true;
395
+ this.elements.getProgramsBtn.disabled = true;
396
+ this.elements.programsList.innerHTML = `
397
+ <div class="loading-message">
398
+ ${AppUtils.createLoadingSpinner()} 프로그램 목록 로드 중...
399
+ </div>
400
+ `;
401
+
402
+ try {
403
+ console.log('프로그램 목록 조회 요청 전송');
404
+
405
+ // 백엔드 API 호출
406
+ const response = await AppUtils.fetchWithTimeout('/api/device/programs', {
407
+ method: 'GET'
408
+ });
409
+
410
+ const data = await response.json();
411
+
412
+ if (response.ok && data.success) {
413
+ // 목록 조회 성공
414
+ console.log('프로그램 목록 조회 성공:', data);
415
+ this.programsList = data.programs || [];
416
+
417
+ // 목록 표시
418
+ this.displayProgramsList();
419
+
420
+ // 드롭다운 업데이트
421
+ this.updateProgramsDropdown();
422
+
423
+ // 실행 버튼 상태 업데이트
424
+ this.updateExecuteButton();
425
+ } else {
426
+ // 목록 조회 실패
427
+ console.error('프로그램 목록 조회 실패:', data);
428
+ this.showProgramsError(`프로그램 목록 조회 실패: ${data.error || '알 수 없는 오류'}`);
429
+ }
430
+ } catch (error) {
431
+ // 예외 발생
432
+ console.error('프로그램 목록 조회 중 오류 발생:', error);
433
+ this.showProgramsError(`프로그램 목록 조회 중 오류 발생: ${error.message}`);
434
+ } finally {
435
+ // 로딩 상태 및 버튼 상태 복원
436
+ this.isLoadingPrograms = false;
437
+ this.elements.getProgramsBtn.disabled = false;
438
+ }
439
+ },
440
+
441
+ // 프로그램 목록 표시
442
+ displayProgramsList: function() {
443
+ const programsListElement = this.elements.programsList;
444
+
445
+ if (!this.programsList || this.programsList.length === 0) {
446
+ programsListElement.innerHTML = `
447
+ <div class="no-programs-message">
448
+ <i class="fas fa-info-circle"></i> 등록된 프로그램이 없습니다.
449
+ </div>
450
+ `;
451
+ return;
452
+ }
453
+
454
+ // 테이블 형태로 프로그램 목록 표시
455
+ let html = `
456
+ <table class="program-list">
457
+ <thead>
458
+ <tr>
459
+ <th>이름</th>
460
+ <th>설명</th>
461
+ <th>경로</th>
462
+ </tr>
463
+ </thead>
464
+ <tbody>
465
+ `;
466
+
467
+ // 프로그램 항목 생성
468
+ this.programsList.forEach(program => {
469
+ html += `
470
+ <tr>
471
+ <td>${AppUtils.escapeHtml(program.name || '알 수 없음')}</td>
472
+ <td>${AppUtils.escapeHtml(program.description || '-')}</td>
473
+ <td>${AppUtils.escapeHtml(program.path || '-')}</td>
474
+ </tr>
475
+ `;
476
+ });
477
+
478
+ html += `
479
+ </tbody>
480
+ </table>
481
+ <div style="margin-top: 10px; font-size: 0.9em; color: #666;">
482
+ 총 ${this.programsList.length}개 프로그램
483
+ </div>
484
+ `;
485
+
486
+ programsListElement.innerHTML = html;
487
+ },
488
+
489
+ // 프로그램 드롭다운 업데이트
490
+ updateProgramsDropdown: function() {
491
+ const dropdown = this.elements.programSelectDropdown;
492
+
493
+ // 기존 옵션 제거
494
+ dropdown.innerHTML = '';
495
+
496
+ // 기본 옵션 추가
497
+ const defaultOption = document.createElement('option');
498
+ defaultOption.value = '';
499
+ defaultOption.textContent = this.programsList.length > 0
500
+ ? '-- 실행할 프로그램 선택 --'
501
+ : '-- 프로그램 없음 --';
502
+ dropdown.appendChild(defaultOption);
503
+
504
+ // 프로그램 옵션 추가
505
+ this.programsList.forEach(program => {
506
+ const option = document.createElement('option');
507
+ option.value = program.id || '';
508
+ option.textContent = program.name || '알 수 없음';
509
+
510
+ // 설명이 있으면 괄호로 추가
511
+ if (program.description) {
512
+ option.textContent += ` (${program.description})`;
513
+ }
514
+
515
+ dropdown.appendChild(option);
516
+ });
517
+ },
518
+
519
+ // 실행 버튼 상태 업데이트
520
+ updateExecuteButton: function() {
521
+ const dropdown = this.elements.programSelectDropdown;
522
+ const executeBtn = this.elements.executeProgramBtn;
523
+
524
+ // 선택된 프로그램이 있을 때만 버튼 활성화
525
+ executeBtn.disabled = !dropdown.value;
526
+ },
527
+
528
+ // 프로그램 실행
529
+ executeProgram: async function(programId) {
530
+ if (!this.isConnected) {
531
+ this.showExecuteResult('error', '오류: 먼저 서버에 연결해야 합니다.');
532
+ console.error('프로그램 실행 시도 중 오류: 서버 연결 안됨');
533
+ return;
534
+ }
535
+
536
+ if (!programId) {
537
+ this.showExecuteResult('error', '오류: 실행할 프로그램을 선택해주세요.');
538
+ console.error('프로그램 실행 시도 중 오류: 프로그램 ID 없음');
539
+ return;
540
+ }
541
+
542
+ // 실행 중 UI 업데이트
543
+ this.elements.executeProgramBtn.disabled = true;
544
+ this.showExecuteResult('loading', '프로그램 실행 중...');
545
+
546
+ try {
547
+ console.log(`프로그램 실행 요청 전송: ${programId}`);
548
+
549
+ // 백엔드 API 호출
550
+ const response = await AppUtils.fetchWithTimeout(`/api/device/programs/${programId}/execute`, {
551
+ method: 'POST',
552
+ headers: {
553
+ 'Content-Type': 'application/json'
554
+ },
555
+ body: JSON.stringify({})
556
+ }, 15000); // 15초 타임아웃 (실행에 시간이 더 걸릴 수 있음)
557
+
558
+ const data = await response.json();
559
+
560
+ if (response.ok && data.success) {
561
+ // 실행 성공
562
+ console.log('프로그램 실행 성공:', data);
563
+ this.showExecuteResult('success', `실행 성공: ${data.message || '프로그램이 성공적으로 실행되었습니다.'}`);
564
+
565
+ // 시스템 알림
566
+ AppUtils.addSystemNotification(`프로그램 실행 성공: ${this.getSelectedProgramName()}`);
567
+ } else {
568
+ // 실행 실패
569
+ console.error('프로그램 실행 실패:', data);
570
+ this.showExecuteResult('error', `실행 실패: ${data.error || '알 수 없는 오류'}`);
571
+ }
572
+ } catch (error) {
573
+ // 예외 발생
574
+ console.error('프로그램 실행 중 오류 발생:', error);
575
+
576
+ if (error.message.includes('시간이 초과')) {
577
+ this.showExecuteResult('error', '프로그램 실행 요청 시간 초과. 서버 응답이 없습니다.');
578
+ } else {
579
+ this.showExecuteResult('error', `프로그램 실행 중 오류 발생: ${error.message}`);
580
+ }
581
+ } finally {
582
+ // 버튼 다시 활성화
583
+ this.elements.executeProgramBtn.disabled = false;
584
+ }
585
+ },
586
+
587
+ // 선택된 프로그램 이름 가져오기
588
+ getSelectedProgramName: function() {
589
+ const dropdown = this.elements.programSelectDropdown;
590
+ const selectedOption = dropdown.options[dropdown.selectedIndex];
591
+ return selectedOption ? selectedOption.textContent : '알 수 없는 프로그램';
592
+ },
593
+
594
+ // 프로그램 목록 오류 표시
595
+ showProgramsError: function(errorMessage) {
596
+ this.elements.programsList.innerHTML = `
597
+ <div class="error-message">
598
+ <i class="fas fa-exclamation-circle"></i> ${errorMessage}
599
+ <button class="retry-button" id="retryLoadProgramsBtn">
600
+ <i class="fas fa-sync"></i> 다시 시도
601
+ </button>
602
+ </div>
603
+ `;
604
+
605
+ // 재시도 버튼 이벤트 리스너
606
+ document.getElementById('retryLoadProgramsBtn').addEventListener('click', () => {
607
+ console.log('프로그램 목록 재시도 버튼 클릭');
608
+ this.loadProgramsList();
609
+ });
610
+ },
611
+
612
+ // 실행 결과 표시
613
+ showExecuteResult: function(status, message) {
614
+ const resultElement = this.elements.executeResult;
615
+
616
+ // 모든 상태 클래스 제거
617
+ resultElement.classList.remove('success', 'error', 'warning');
618
+
619
+ // 내용 초기화
620
+ resultElement.innerHTML = '';
621
+
622
+ // 상태에 따라 처리
623
+ switch (status) {
624
+ case 'success':
625
+ resultElement.classList.add('success');
626
+ resultElement.innerHTML = `<i class="fas fa-check-circle"></i> ${message}`;
627
+ break;
628
+ case 'error':
629
+ resultElement.classList.add('error');
630
+ resultElement.innerHTML = `<i class="fas fa-exclamation-circle"></i> ${message}`;
631
+ break;
632
+ case 'warning':
633
+ resultElement.classList.add('warning');
634
+ resultElement.innerHTML = `<i class="fas fa-exclamation-triangle"></i> ${message}`;
635
+ break;
636
+ case 'loading':
637
+ resultElement.innerHTML = `${AppUtils.createLoadingSpinner()} ${message}`;
638
+ break;
639
+ default:
640
+ resultElement.textContent = message;
641
+ }
642
+ }
643
+ };
644
+
645
+ // 페이지 로드 완료 시 모듈 초기화
646
+ document.addEventListener('DOMContentLoaded', function() {
647
+ console.log('장치 제어 모듈 로드됨');
648
+
649
+ // DOM이 완전히 로드된 후 약간의 지연을 두고 초기화
650
+ setTimeout(() => {
651
+ DeviceControl.init();
652
+ }, 100);
653
+ });
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,717 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG 검색 챗봇 UI JavaScript
3
+ * 메인 파일 - 코어 및 장치 제어 모듈과 통합
4
+ */
5
+
6
+ // DOM 요소
7
+ const chatTab = document.getElementById('chatTab');
8
+ const docsTab = document.getElementById('docsTab');
9
+ const deviceTab = document.getElementById('deviceTab'); // 장치 제어 탭 추가
10
+ const chatSection = document.getElementById('chatSection');
11
+ const docsSection = document.getElementById('docsSection');
12
+ const deviceSection = document.getElementById('deviceSection'); // 장치 제어 섹션 추가
13
+ const chatMessages = document.getElementById('chatMessages');
14
+ const userInput = document.getElementById('userInput');
15
+ const sendButton = document.getElementById('sendButton');
16
+ const micButton = document.getElementById('micButton');
17
+ const stopRecordingButton = document.getElementById('stopRecordingButton');
18
+ const recordingStatus = document.getElementById('recordingStatus');
19
+ const uploadForm = document.getElementById('uploadForm');
20
+ const documentFile = document.getElementById('documentFile');
21
+ const fileName = document.getElementById('fileName');
22
+ const uploadButton = document.getElementById('uploadButton');
23
+ const uploadStatus = document.getElementById('uploadStatus');
24
+ const refreshDocsButton = document.getElementById('refreshDocsButton');
25
+ const docsList = document.getElementById('docsList');
26
+ const docsLoading = document.getElementById('docsLoading');
27
+ const noDocsMessage = document.getElementById('noDocsMessage');
28
+ const llmSelect = document.getElementById('llmSelect');
29
+ const currentLLMInfo = document.getElementById('currentLLMInfo');
30
+
31
+ // LLM 관련 변수
32
+ let currentLLM = 'openai';
33
+ let supportedLLMs = [];
34
+
35
+ // 녹음 관련 변수
36
+ let mediaRecorder = null;
37
+ let audioChunks = [];
38
+ let isRecording = false;
39
+
40
+ // 앱 초기화 상태 확인 함수
41
+ async function checkAppStatus() {
42
+ try {
43
+ console.log('앱 상태 확인 요청 전송');
44
+ const response = await fetch('/api/status');
45
+ if (!response.ok) {
46
+ console.error(`앱 상태 확인 실패: ${response.status}`);
47
+ return false;
48
+ }
49
+ const data = await response.json();
50
+ console.log(`앱 상태 확인 결과: ${data.ready ? '준비됨' : '준비 안됨'}`);
51
+ return data.ready;
52
+ } catch (error) {
53
+ console.error('앱 상태 확인 중 오류 발생:', error);
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * LLM 목록 로드 함수
60
+ */
61
+ async function loadLLMs() {
62
+ try {
63
+ // API 요청
64
+ console.log('LLM 목록 요청 전송');
65
+ const response = await AppUtils.fetchWithTimeout('/api/llm', {
66
+ method: 'GET'
67
+ });
68
+
69
+ const data = await response.json();
70
+ supportedLLMs = data.supported_llms;
71
+ currentLLM = data.current_llm.id;
72
+
73
+ console.log(`LLM 목록 로드 성공: ${supportedLLMs.length}개 모델`);
74
+
75
+ // LLM 선택 드롭다운 업데이트
76
+ llmSelect.innerHTML = '';
77
+ supportedLLMs.forEach(llm => {
78
+ const option = document.createElement('option');
79
+ option.value = llm.id;
80
+ option.textContent = llm.name;
81
+ option.selected = llm.current;
82
+ llmSelect.appendChild(option);
83
+ });
84
+
85
+ // 현재 LLM 표시
86
+ updateCurrentLLMInfo(data.current_llm);
87
+ } catch (error) {
88
+ console.error('LLM 목록 로드 실패:', error);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * LLM 변경 함수
94
+ * @param {string} llmId - 변경할 LLM ID
95
+ */
96
+ async function changeLLM(llmId) {
97
+ try {
98
+ // API 요청
99
+ console.log(`LLM 변경 요청: ${llmId}`);
100
+ const response = await AppUtils.fetchWithTimeout('/api/llm', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json'
104
+ },
105
+ body: JSON.stringify({ llm_id: llmId })
106
+ });
107
+
108
+ const data = await response.json();
109
+
110
+ if (data.success) {
111
+ currentLLM = llmId;
112
+ updateCurrentLLMInfo(data.current_llm);
113
+ console.log(`LLM 변경 성공: ${data.current_llm.name}`);
114
+
115
+ // 시스템 메시지 추가
116
+ const systemMessage = `LLM이 ${data.current_llm.name}(으)로 변경되었습니다. 모델: ${data.current_llm.model}`;
117
+ AppUtils.addSystemNotification(systemMessage);
118
+ } else if (data.error) {
119
+ console.error('LLM 변경 오류:', data.error);
120
+ alert(`LLM 변경 오류: ${data.error}`);
121
+ }
122
+ } catch (error) {
123
+ console.error('LLM 변경 실패:', error);
124
+ alert('LLM 변경 중 오류가 발생했습니다.');
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 현재 LLM 정보 표시 업데이트
130
+ * @param {Object} llmInfo - LLM 정보 객체
131
+ */
132
+ function updateCurrentLLMInfo(llmInfo) {
133
+ if (currentLLMInfo) {
134
+ currentLLMInfo.textContent = `${llmInfo.name} (${llmInfo.model})`;
135
+ }
136
+ }
137
+
138
+ // 탭 전환 함수
139
+ function switchTab(tabName) {
140
+ console.log(`탭 전환: ${tabName}`);
141
+
142
+ // 모든 탭과 섹션 비활성화
143
+ chatTab.classList.remove('active');
144
+ docsTab.classList.remove('active');
145
+ deviceTab.classList.remove('active');
146
+ chatSection.classList.remove('active');
147
+ docsSection.classList.remove('active');
148
+ deviceSection.classList.remove('active');
149
+
150
+ // 선택한 탭과 섹션 활성화
151
+ if (tabName === 'chat') {
152
+ chatTab.classList.add('active');
153
+ chatSection.classList.add('active');
154
+ } else if (tabName === 'docs') {
155
+ docsTab.classList.add('active');
156
+ docsSection.classList.add('active');
157
+ // 문서 목록 로드
158
+ loadDocuments();
159
+ } else if (tabName === 'device') {
160
+ deviceTab.classList.add('active');
161
+ deviceSection.classList.add('active');
162
+ }
163
+ }
164
+
165
+ // 채팅 메시지 전송 함수
166
+ async function sendMessage() {
167
+ const message = userInput.value.trim();
168
+ if (!message) {
169
+ console.log('메시지가 비어있어 전송하지 않음');
170
+ return;
171
+ }
172
+
173
+ console.log('메시지 전송 시작');
174
+
175
+ // UI 업데이트
176
+ addMessage(message, 'user');
177
+ userInput.value = '';
178
+ adjustTextareaHeight();
179
+
180
+ // 로딩 메시지 추가
181
+ const loadingMessageId = addLoadingMessage();
182
+
183
+ try {
184
+ // API 요청
185
+ console.log(`/api/chat API 호출: ${message.substring(0, 30)}${message.length > 30 ? '...' : ''}`);
186
+ const response = await AppUtils.fetchWithTimeout('/api/chat', {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Content-Type': 'application/json'
190
+ },
191
+ body: JSON.stringify({
192
+ query: message,
193
+ llm_id: currentLLM // 현재 선택된 LLM 전송
194
+ })
195
+ });
196
+
197
+ // 로딩 메시지 제거
198
+ removeLoadingMessage(loadingMessageId);
199
+
200
+ // 응답 형식 확인
201
+ let data;
202
+ try {
203
+ data = await response.json();
204
+ console.log('API 응답 수신 완료');
205
+
206
+ // 디버깅: 응답 구조 및 내용 로깅
207
+ console.log('응답 구조:', Object.keys(data));
208
+ if (data.answer) {
209
+ console.log('응답 길이:', data.answer.length);
210
+ console.log('응답 내용 일부:', data.answer.substring(0, 50) + '...');
211
+ }
212
+ } catch (jsonError) {
213
+ console.error('응답 JSON 파싱 실패:', jsonError);
214
+ AppUtils.addErrorMessage('서버 응답을 처리할 수 없습니다. 다시 시도해 주세요.');
215
+ return;
216
+ }
217
+
218
+ // 응답 표시
219
+ if (data.error) {
220
+ console.error(`API 오류 응답: ${data.error}`);
221
+ AppUtils.addErrorMessage(data.error);
222
+ } else if (!data.answer || data.answer.trim() === '') {
223
+ console.error('응답 내용이 비어있음');
224
+ AppUtils.addErrorMessage('서버에서 빈 응답을 받았습니다. 다시 시도해 주세요.');
225
+ } else {
226
+ // LLM 정보 업데이트
227
+ if (data.llm) {
228
+ console.log(`LLM 정보 업데이트: ${data.llm.name}`);
229
+ updateCurrentLLMInfo(data.llm);
230
+ }
231
+
232
+ try {
233
+ // 메시지 추가
234
+ addMessage(data.answer, 'bot', null, data.sources);
235
+ console.log('챗봇 응답 표시 완료');
236
+ } catch (displayError) {
237
+ console.error('응답 표시 중 오류:', displayError);
238
+ AppUtils.addErrorMessage('응답을 표시하는 중 오류가 발생했습니다. 다시 시도해 주세요.');
239
+ }
240
+ }
241
+ } catch (error) {
242
+ console.error('메시지 전송 중 오류 발생:', error);
243
+ removeLoadingMessage(loadingMessageId);
244
+ AppUtils.addErrorMessage('오류가 발생했습니다. 다시 시도해 주세요.');
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 음성 녹음 시작 함수
250
+ */
251
+ async function startRecording() {
252
+ if (isRecording) {
253
+ console.log('이미 녹음 중');
254
+ return;
255
+ }
256
+
257
+ try {
258
+ console.log('마이크 접근 권한 요청');
259
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
260
+ isRecording = true;
261
+ audioChunks = [];
262
+
263
+ mediaRecorder = new MediaRecorder(stream);
264
+
265
+ mediaRecorder.addEventListener('dataavailable', (event) => {
266
+ if (event.data.size > 0) {
267
+ console.log('오디오 청크 데이터 수신');
268
+ audioChunks.push(event.data);
269
+ }
270
+ });
271
+
272
+ mediaRecorder.addEventListener('stop', () => {
273
+ console.log('녹음 중지 이벤트 - 오디오 메시지 전송');
274
+ sendAudioMessage();
275
+ });
276
+
277
+ // 녹음 시작
278
+ mediaRecorder.start();
279
+ console.log('녹음 시작됨');
280
+
281
+ // UI 업데이트
282
+ micButton.style.display = 'none';
283
+ recordingStatus.classList.remove('hidden');
284
+ } catch (error) {
285
+ console.error('음성 녹음 권한을 얻을 수 없습니다:', error);
286
+ alert('마이크 접근 권한이 필요합니다.');
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 음성 녹음 중지 함수
292
+ */
293
+ function stopRecording() {
294
+ if (!isRecording || !mediaRecorder) {
295
+ console.log('녹음 중이 아님');
296
+ return;
297
+ }
298
+
299
+ console.log('녹음 중지 요청');
300
+ mediaRecorder.stop();
301
+ isRecording = false;
302
+
303
+ // UI 업데이트
304
+ micButton.style.display = 'flex';
305
+ recordingStatus.classList.add('hidden');
306
+ }
307
+
308
+ /**
309
+ * 녹음된 오디오 메시지 전송 함수
310
+ */
311
+ async function sendAudioMessage() {
312
+ if (audioChunks.length === 0) {
313
+ console.log('오디오 청크가 없음');
314
+ return;
315
+ }
316
+
317
+ console.log(`오디오 메시지 전송 시작: ${audioChunks.length}개 청크`);
318
+
319
+ // 오디오 Blob 생성
320
+ const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
321
+
322
+ // 로딩 메시지 추가
323
+ const loadingMessageId = addLoadingMessage();
324
+
325
+ try {
326
+ // FormData에 오디오 추가
327
+ const formData = new FormData();
328
+ formData.append('audio', audioBlob, 'recording.wav');
329
+ // 현재 선택된 LLM 추가
330
+ formData.append('llm_id', currentLLM);
331
+
332
+ console.log('/api/voice API 호출');
333
+ // API 요청
334
+ const response = await AppUtils.fetchWithTimeout('/api/voice', {
335
+ method: 'POST',
336
+ body: formData
337
+ }, 30000); // 음성 처리는 더 긴 타임아웃
338
+
339
+ // 로딩 메시지 제거
340
+ removeLoadingMessage(loadingMessageId);
341
+
342
+ // 응답 형식 확인
343
+ let data;
344
+ try {
345
+ data = await response.json();
346
+ console.log('음성 API 응답 수신 완료');
347
+
348
+ // 디버깅: 응답 구조 및 내용 로깅
349
+ console.log('음성 응답 구조:', Object.keys(data));
350
+ if (data.answer) {
351
+ console.log('음성 응답 길이:', data.answer.length);
352
+ console.log('음성 응답 내용 일부:', data.answer.substring(0, 50) + '...');
353
+ }
354
+ if (data.transcription) {
355
+ console.log('음성 인식 길이:', data.transcription.length);
356
+ }
357
+ } catch (jsonError) {
358
+ console.error('음성 응답 JSON 파싱 실패:', jsonError);
359
+ AppUtils.addErrorMessage('서버 응답을 처리할 수 없습니다. 다시 시도해 주세요.');
360
+ return;
361
+ }
362
+
363
+ // 응답 표시
364
+ if (data.error) {
365
+ console.error(`음성 API 오류 응답: ${data.error}`);
366
+ AppUtils.addErrorMessage(data.error);
367
+ } else if (!data.answer || data.answer.trim() === '') {
368
+ console.error('음성 응답 내용이 비어있음');
369
+ AppUtils.addErrorMessage('서버에서 빈 응답을 받았습니다. 다시 시도해 주세요.');
370
+ } else {
371
+ try {
372
+ // LLM 정보 업데이트
373
+ if (data.llm) {
374
+ console.log(`LLM 정보 업데이트: ${data.llm.name}`);
375
+ updateCurrentLLMInfo(data.llm);
376
+ }
377
+
378
+ // 사용자 메시지(음성 텍스트) 추가
379
+ if (data.transcription) {
380
+ console.log(`음성 인식 결과: ${data.transcription.substring(0, 30)}${data.transcription.length > 30 ? '...' : ''}`);
381
+ addMessage(data.transcription, 'user');
382
+ }
383
+
384
+ // 봇 응답 추가
385
+ addMessage(data.answer, 'bot', data.transcription, data.sources);
386
+ console.log('음성 채팅 응답 표시 완료');
387
+ } catch (displayError) {
388
+ console.error('음성 응답 표시 중 오류:', displayError);
389
+ AppUtils.addErrorMessage('응답을 표시하는 중 오류가 발생했습니다. 다시 시도해 주세요.');
390
+ }
391
+ }
392
+ } catch (error) {
393
+ console.error('음성 메시지 전송 중 오류 발생:', error);
394
+ removeLoadingMessage(loadingMessageId);
395
+ AppUtils.addErrorMessage('오디오 처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
396
+ }
397
+ }
398
+
399
+ /**
400
+ * 문서 업로드 함수
401
+ */
402
+ async function uploadDocument() {
403
+ if (documentFile.files.length === 0) {
404
+ console.log('선택된 파일 없음');
405
+ alert('파일을 선택해 주세요.');
406
+ return;
407
+ }
408
+
409
+ console.log(`문서 업로드 시작: ${documentFile.files[0].name}`);
410
+
411
+ // UI 업데이트
412
+ uploadStatus.classList.remove('hidden');
413
+ uploadStatus.className = 'upload-status';
414
+ uploadStatus.innerHTML = '<div class="spinner"></div><p>업로드 중...</p>';
415
+ uploadButton.disabled = true;
416
+
417
+ try {
418
+ const formData = new FormData();
419
+ formData.append('document', documentFile.files[0]);
420
+
421
+ // API 요청
422
+ console.log('/api/upload API 호출');
423
+ const response = await AppUtils.fetchWithTimeout('/api/upload', {
424
+ method: 'POST',
425
+ body: formData
426
+ }, 20000); // 업로드는 더 긴 타임아웃
427
+
428
+ const data = await response.json();
429
+ console.log('업로드 API 응답 수신 완료');
430
+
431
+ // 응답 처리
432
+ if (data.error) {
433
+ console.error(`업로드 오류: ${data.error}`);
434
+ uploadStatus.className = 'upload-status error';
435
+ uploadStatus.textContent = `오류: ${data.error}`;
436
+ } else if (data.warning) {
437
+ console.warn(`업로드 경고: ${data.message}`);
438
+ uploadStatus.className = 'upload-status warning';
439
+ uploadStatus.textContent = data.message;
440
+ } else {
441
+ console.log(`업로드 성공: ${data.message}`);
442
+ uploadStatus.className = 'upload-status success';
443
+ uploadStatus.textContent = data.message;
444
+
445
+ // 문서 목록 새로고침
446
+ loadDocuments();
447
+
448
+ // 입력 필드 초기화
449
+ documentFile.value = '';
450
+ fileName.textContent = '선택된 파일 없음';
451
+ }
452
+ } catch (error) {
453
+ console.error('문서 업로드 중 오류 발생:', error);
454
+ uploadStatus.className = 'upload-status error';
455
+ uploadStatus.textContent = '업로드 중 오류가 발생했습니다. 다시 시도해 주세요.';
456
+ } finally {
457
+ uploadButton.disabled = false;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * 문서 목록 로드 함수
463
+ */
464
+ async function loadDocuments() {
465
+ console.log('문서 목록 로드 시작');
466
+
467
+ // UI 업데이트
468
+ docsList.querySelector('tbody').innerHTML = '';
469
+ docsLoading.classList.remove('hidden');
470
+ noDocsMessage.classList.add('hidden');
471
+
472
+ try {
473
+ // API 요청
474
+ console.log('/api/documents API 호출');
475
+ const response = await AppUtils.fetchWithTimeout('/api/documents', {
476
+ method: 'GET'
477
+ });
478
+
479
+ const data = await response.json();
480
+ console.log(`문서 목록 로드 성공: ${data.documents ? data.documents.length : 0}개 문서`);
481
+
482
+ // 응답 처리
483
+ docsLoading.classList.add('hidden');
484
+
485
+ if (!data.documents || data.documents.length === 0) {
486
+ console.log('문서 없음');
487
+ noDocsMessage.classList.remove('hidden');
488
+ return;
489
+ }
490
+
491
+ // 문서 목록 업데이트
492
+ const tbody = docsList.querySelector('tbody');
493
+ data.documents.forEach(doc => {
494
+ const row = document.createElement('tr');
495
+
496
+ const fileNameCell = document.createElement('td');
497
+ fileNameCell.textContent = doc.filename || doc.source;
498
+ row.appendChild(fileNameCell);
499
+
500
+ const chunksCell = document.createElement('td');
501
+ chunksCell.textContent = doc.chunks;
502
+ row.appendChild(chunksCell);
503
+
504
+ const typeCell = document.createElement('td');
505
+ typeCell.textContent = doc.filetype || '-';
506
+ row.appendChild(typeCell);
507
+
508
+ tbody.appendChild(row);
509
+ });
510
+ } catch (error) {
511
+ console.error('문서 목록 로드 중 오류 발생:', error);
512
+ docsLoading.classList.add('hidden');
513
+ noDocsMessage.classList.remove('hidden');
514
+ noDocsMessage.querySelector('p').textContent = '문서 목록을 불러오는 중 오류가 발생했습니다.';
515
+ }
516
+ }
517
+
518
+ /**
519
+ * 메시지 추가 함수
520
+ * @param {string} text - 메시지 내용
521
+ * @param {string} sender - 메시지 발신자 ('user' 또는 'bot' 또는 'system')
522
+ * @param {string|null} transcription - 음성 인식 텍스트 (선택 사항)
523
+ * @param {Array|null} sources - 소스 정보 배열 (선택 사항)
524
+ */
525
+ function addMessage(text, sender, transcription = null, sources = null) {
526
+ console.log(`메시지 추가: sender=${sender}, length=${text ? text.length : 0}`);
527
+
528
+ const messageDiv = document.createElement('div');
529
+ messageDiv.classList.add('message', sender);
530
+
531
+ const contentDiv = document.createElement('div');
532
+ contentDiv.classList.add('message-content');
533
+
534
+ // 음성 인식 텍스트 추가 (있는 경우)
535
+ if (transcription && sender === 'bot') {
536
+ const transcriptionP = document.createElement('p');
537
+ transcriptionP.classList.add('transcription');
538
+ transcriptionP.textContent = `"${transcription}"`;
539
+ contentDiv.appendChild(transcriptionP);
540
+ }
541
+
542
+ // 메시지 텍스트 추가
543
+ const textP = document.createElement('p');
544
+ textP.textContent = text;
545
+ contentDiv.appendChild(textP);
546
+
547
+ // 소스 정보 추가 (있는 경우)
548
+ if (sources && sources.length > 0 && sender === 'bot') {
549
+ console.log(`소스 정보 추가: ${sources.length}개 소스`);
550
+ const sourcesDiv = document.createElement('div');
551
+ sourcesDiv.classList.add('sources');
552
+
553
+ const sourcesTitle = document.createElement('strong');
554
+ sourcesTitle.textContent = '출처: ';
555
+ sourcesDiv.appendChild(sourcesTitle);
556
+
557
+ sources.forEach((source, index) => {
558
+ if (index < 3) { // 최대 3개까지만 표시
559
+ const sourceSpan = document.createElement('span');
560
+ sourceSpan.classList.add('source-item');
561
+ sourceSpan.textContent = source.source;
562
+ sourcesDiv.appendChild(sourceSpan);
563
+ }
564
+ });
565
+
566
+ contentDiv.appendChild(sourcesDiv);
567
+ }
568
+
569
+ messageDiv.appendChild(contentDiv);
570
+ chatMessages.appendChild(messageDiv);
571
+
572
+ // 스크롤을 가장 아래로 이동
573
+ chatMessages.scrollTop = chatMessages.scrollHeight;
574
+ }
575
+
576
+ /**
577
+ * 로딩 메시지 추가 함수
578
+ * @returns {string} 로딩 메시지 ID
579
+ */
580
+ function addLoadingMessage() {
581
+ console.log('로딩 메시지 추가');
582
+ const id = 'loading-' + Date.now();
583
+ const messageDiv = document.createElement('div');
584
+ messageDiv.classList.add('message', 'bot');
585
+ messageDiv.id = id;
586
+
587
+ const contentDiv = document.createElement('div');
588
+ contentDiv.classList.add('message-content');
589
+
590
+ const loadingP = document.createElement('p');
591
+ loadingP.innerHTML = '<div class="spinner" style="width: 20px; height: 20px; display: inline-block; margin-right: 10px;"></div> 생각 중...';
592
+ contentDiv.appendChild(loadingP);
593
+
594
+ messageDiv.appendChild(contentDiv);
595
+ chatMessages.appendChild(messageDiv);
596
+
597
+ // 스크롤을 가장 아래로 이동
598
+ chatMessages.scrollTop = chatMessages.scrollHeight;
599
+
600
+ return id;
601
+ }
602
+
603
+ /**
604
+ * 로딩 메시지 제거 함수
605
+ * @param {string} id - 로딩 메시지 ID
606
+ */
607
+ function removeLoadingMessage(id) {
608
+ console.log(`로딩 메시지 제거: ${id}`);
609
+ const loadingMessage = document.getElementById(id);
610
+ if (loadingMessage) {
611
+ loadingMessage.remove();
612
+ }
613
+ }
614
+
615
+ /**
616
+ * textarea 높이 자동 조정 함수
617
+ */
618
+ function adjustTextareaHeight() {
619
+ userInput.style.height = 'auto';
620
+ userInput.style.height = Math.min(userInput.scrollHeight, 100) + 'px';
621
+ }
622
+
623
+ // 페이지 로드 시 초기화
624
+ document.addEventListener('DOMContentLoaded', () => {
625
+ console.log('메인 UI 초기화 중...');
626
+
627
+ // 앱 상태 확인 (로딩 페이지가 아닌 경우에만)
628
+ if (window.location.pathname === '/' && !document.getElementById('app-loading-indicator')) {
629
+ // 앱 상태 주기적으로 확인
630
+ const statusInterval = setInterval(async () => {
631
+ const isReady = await checkAppStatus();
632
+ if (isReady) {
633
+ clearInterval(statusInterval);
634
+ console.log('앱이 준비되었습니다.');
635
+
636
+ // 앱이 준비되면 LLM 목록 로드
637
+ loadLLMs();
638
+ }
639
+ }, 5000);
640
+ }
641
+
642
+ // 탭 전환 이벤트 리스너
643
+ chatTab.addEventListener('click', () => {
644
+ console.log('채팅 탭 클릭');
645
+ switchTab('chat');
646
+ });
647
+
648
+ docsTab.addEventListener('click', () => {
649
+ console.log('문서 관리 탭 클릭');
650
+ switchTab('docs');
651
+ });
652
+
653
+ // 장치 제어 탭은 DeviceControl 모듈에서 이벤트 리스너 등록함
654
+
655
+ // LLM 선택 이벤트 리스너
656
+ llmSelect.addEventListener('change', (event) => {
657
+ console.log(`LLM 변경: ${event.target.value}`);
658
+ changeLLM(event.target.value);
659
+ });
660
+
661
+ // 메시지 전송 이벤트 리스너
662
+ sendButton.addEventListener('click', () => {
663
+ console.log('메시지 전송 버튼 클릭');
664
+ sendMessage();
665
+ });
666
+
667
+ userInput.addEventListener('keydown', (event) => {
668
+ if (event.key === 'Enter' && !event.shiftKey) {
669
+ console.log('텍스트 입력에서 엔터 키 감지');
670
+ event.preventDefault();
671
+ sendMessage();
672
+ }
673
+ });
674
+
675
+ // 음성 인식 이벤트 리스너
676
+ micButton.addEventListener('click', () => {
677
+ console.log('마이크 버튼 클릭');
678
+ startRecording();
679
+ });
680
+
681
+ stopRecordingButton.addEventListener('click', () => {
682
+ console.log('녹음 중지 버튼 클릭');
683
+ stopRecording();
684
+ });
685
+
686
+ // 문서 업로드 이벤트 리스너
687
+ documentFile.addEventListener('change', (event) => {
688
+ console.log('파일 선택 변경');
689
+ if (event.target.files.length > 0) {
690
+ fileName.textContent = event.target.files[0].name;
691
+ } else {
692
+ fileName.textContent = '선택된 파일 없음';
693
+ }
694
+ });
695
+
696
+ uploadForm.addEventListener('submit', (event) => {
697
+ console.log('문서 업로드 폼 제출');
698
+ event.preventDefault();
699
+ uploadDocument();
700
+ });
701
+
702
+ // 문서 목록 새로고침 이벤트 리스너
703
+ refreshDocsButton.addEventListener('click', () => {
704
+ console.log('문서 목록 새로고침 버튼 클릭');
705
+ loadDocuments();
706
+ });
707
+
708
+ // 자동 입력 필드 크기 조정
709
+ userInput.addEventListener('input', adjustTextareaHeight);
710
+
711
+ // 초기 문서 목록 로드
712
+ if (docsSection.classList.contains('active')) {
713
+ loadDocuments();
714
+ }
715
+
716
+ console.log('메인 UI 초기화 완료');
717
+ });
app/templates/index.html ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/device-style.css') }}">
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <header>
14
+ <h1>RAG 검색 챗봇</h1>
15
+ <div class="header-actions">
16
+ <div class="llm-selector">
17
+ <label for="llmSelect">LLM 선택:</label>
18
+ <select id="llmSelect">
19
+ <!-- 옵션은 JavaScript에서 동적으로 로드됩니다 -->
20
+ </select>
21
+ </div>
22
+ <div class="user-info">
23
+ <span>사용자: {% if session.username %}{{ session.username }}{% else %}손님{% endif %}</span>
24
+ <a href="{{ url_for('logout') }}" class="logout-button">
25
+ <i class="fas fa-sign-out-alt"></i> 로그아웃
26
+ </a>
27
+ </div>
28
+ </div>
29
+ <div class="tabs">
30
+ <button id="chatTab" class="tab active">대화</button>
31
+ <button id="docsTab" class="tab">문서관리</button>
32
+ <button id="deviceTab" class="tab">장치제어</button>
33
+ </div>
34
+ </header>
35
+
36
+ <main>
37
+ <!-- 대화 탭 -->
38
+ <section id="chatSection" class="tab-content active">
39
+ <div class="chat-container">
40
+ <div class="chat-messages" id="chatMessages">
41
+ <div class="message system">
42
+ <div class="message-content">
43
+ <p>안녕하세요! 지식베이스에 대해 궁금한 점을 물어보세요. 음성으로 질문하시려면 마이크 버튼을 누르세요.</p>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="chat-input-container">
49
+ <textarea id="userInput" placeholder="메시지를 입력하세요..." rows="1"></textarea>
50
+ <button id="micButton" class="mic-button">
51
+ <i class="fas fa-microphone"></i>
52
+ </button>
53
+ <button id="sendButton" class="send-button">
54
+ <i class="fas fa-paper-plane"></i>
55
+ </button>
56
+ </div>
57
+
58
+ <div id="recordingStatus" class="recording-status hidden">
59
+ <div class="recording-indicator">
60
+ <div class="recording-pulse"></div>
61
+ </div>
62
+ <span>녹음 중...</span>
63
+ <button id="stopRecordingButton" class="stop-recording-button">
64
+ <i class="fas fa-stop"></i>
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </section>
69
+
70
+ <!-- 문서관리 탭 -->
71
+ <section id="docsSection" class="tab-content">
72
+ <div class="docs-container">
73
+ <div class="upload-section">
74
+ <h2>문서 업로드</h2>
75
+ <p>지식베이스에 추가할 문서를 업로드하세요. (지원 형식: .txt, .md, .csv)</p>
76
+
77
+ <form id="uploadForm" enctype="multipart/form-data">
78
+ <div class="file-upload">
79
+ <input type="file" id="documentFile" name="document" accept=".txt,.md,.csv">
80
+ <label for="documentFile">파일 선택</label>
81
+ <span id="fileName">선택된 파일 없음</span>
82
+ </div>
83
+
84
+ <button type="submit" id="uploadButton" class="upload-button">
85
+ <i class="fas fa-upload"></i> 업로드
86
+ </button>
87
+ </form>
88
+
89
+ <div id="uploadStatus" class="upload-status hidden"></div>
90
+ </div>
91
+
92
+ <div class="docs-list-section">
93
+ <h2>문서 목록</h2>
94
+ <button id="refreshDocsButton" class="refresh-button">
95
+ <i class="fas fa-sync-alt"></i> 새로고침
96
+ </button>
97
+
98
+ <div class="docs-list-container">
99
+ <table id="docsList" class="docs-list">
100
+ <thead>
101
+ <tr>
102
+ <th>파일명</th>
103
+ <th>청크 수</th>
104
+ <th>유형</th>
105
+ </tr>
106
+ </thead>
107
+ <tbody>
108
+ <!-- 문서 목록이 여기에 동적으로 추가됩니다 -->
109
+ </tbody>
110
+ </table>
111
+
112
+ <div id="docsLoading" class="loading-indicator">
113
+ <div class="spinner"></div>
114
+ <p>문서 로딩 중...</p>
115
+ </div>
116
+
117
+ <div id="noDocsMessage" class="no-docs-message hidden">
118
+ <p>지식베이스에 등록된 문서가 없습니다. 문서를 업로드해 주세요.</p>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </section>
124
+ <!-- 장치 제어 탭 -->
125
+ <section id="deviceSection" class="tab-content">
126
+ <div class="device-connection">
127
+ <h3>1. 장치 서버 연결</h3>
128
+ <div class="device-connection-form">
129
+ <input type="text" id="deviceServerUrlInput" placeholder="LocalPCAgent Ngrok URL 입력 (https://xxxx-xx-xxx-xxx.ngrok-free.app 형식)">
130
+ <button id="connectDeviceServerBtn">연결</button>
131
+ </div>
132
+ <div id="deviceConnectionStatus" class="connection-status disconnected">연결 상태: 연결되지 않음</div>
133
+ </div>
134
+
135
+ <div id="deviceBasicFunctions" class="device-functions">
136
+ <h3>2. 기본 기능</h3>
137
+ <div class="function-buttons">
138
+ <button id="checkDeviceStatusBtn">장치 상태 확인</button>
139
+ </div>
140
+ <textarea id="deviceStatusResult" class="device-status-result" readonly></textarea>
141
+ </div>
142
+
143
+ <div id="deviceProgramControl" class="program-control">
144
+ <h3>3. 프로그램 실행</h3>
145
+ <div class="function-buttons">
146
+ <button id="getProgramsBtn">프로그램 목록 새로고침</button>
147
+ </div>
148
+ <div id="programsList" class="program-list-container">
149
+ <div class="no-programs-message">프로그램 목록이 여기에 표시됩니다.</div>
150
+ </div>
151
+ <div class="program-select-container">
152
+ <select id="programSelectDropdown">
153
+ <option value="">-- 목록 새로고침 후 선택 --</option>
154
+ </select>
155
+ </div>
156
+ <button id="executeProgramBtn" class="execute-btn" disabled>선택한 프로그램 실행</button>
157
+ <div id="executeResult" class="execute-result"></div>
158
+ </div>
159
+ </section>
160
+ </main>
161
+
162
+ <footer>
163
+ <p>© 2025 RAG 검색 챗봇 | OpenAI/DeepSeek LLM & VITO STT 활용</p>
164
+ <div class="current-llm">
165
+ <span>Current LLM: </span>
166
+ <span id="currentLLMInfo">-</span>
167
+ </div>
168
+ </footer>
169
+ </div>
170
+
171
+ <script src="{{ url_for('static', filename='js/app-core.js') }}"></script>
172
+ <script src="{{ url_for('static', filename='js/app-device.js') }}"></script>
173
+ <script src="{{ url_for('static', filename='js/app.js') }}"></script>
174
+ </body>
175
+ </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,,,,
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,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
12
+ flask-cors>=3.0.10
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,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepSeek API 클라이언트 모듈
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import traceback
9
+ from typing import List, Dict, Any, Optional, Union
10
+ from dotenv import load_dotenv
11
+ import requests
12
+
13
+ # 환경 변수 로드
14
+ load_dotenv()
15
+
16
+ # 로거 설정
17
+ logger = logging.getLogger("DeepSeekLLM")
18
+ if not logger.hasHandlers():
19
+ handler = logging.StreamHandler()
20
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
21
+ handler.setFormatter(formatter)
22
+ logger.addHandler(handler)
23
+ logger.setLevel(logging.INFO)
24
+
25
+ class DeepSeekLLM:
26
+ """DeepSeek API 래퍼 클래스"""
27
+
28
+ def __init__(self):
29
+ """DeepSeek LLM 클래스 초기화"""
30
+ self.api_key = os.getenv("DEEPSEEK_API_KEY")
31
+ self.api_base = os.getenv("DEEPSEEK_API_BASE", "https://api.deepseek.com")
32
+ self.model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
33
+
34
+ if not self.api_key:
35
+ logger.warning("DeepSeek API 키가 .env 파일에 설정되지 않았습니다.")
36
+ logger.warning("DEEPSEEK_API_KEY를 확인하세요.")
37
+ else:
38
+ logger.info("DeepSeek 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
+ DeepSeek 채팅 완성 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 키가 설정되지 않아 DeepSeek API를 호출할 수 없습니다.")
61
+ raise ValueError("DeepSeek API 키가 설정되지 않았습니다.")
62
+
63
+ try:
64
+ logger.info(f"DeepSeek API 요청 전송 중 (모델: {self.model})")
65
+
66
+ # API 요청 헤더 및 데이터 준비
67
+ headers = {
68
+ "Authorization": f"Bearer {self.api_key}",
69
+ "Content-Type": "application/json"
70
+ }
71
+
72
+ data = {
73
+ "model": self.model,
74
+ "messages": messages,
75
+ "temperature": temperature,
76
+ "max_tokens": max_tokens
77
+ }
78
+
79
+ # 추가 매개변수 병합
80
+ for key, value in kwargs.items():
81
+ if key not in data:
82
+ data[key] = value
83
+
84
+ # API 요청 보내기
85
+ endpoint = f"{self.api_base}/v1/chat/completions"
86
+
87
+ # 디버깅: API 요청 데이터 로깅 (민감 정보 제외)
88
+ debug_data = data.copy()
89
+ debug_data["messages"] = f"[{len(data['messages'])}개 메시지]"
90
+ logger.debug(f"DeepSeek API 요청 데이터: {json.dumps(debug_data)}")
91
+
92
+ response = requests.post(
93
+ endpoint,
94
+ headers=headers,
95
+ json=data,
96
+ timeout=30 # 30초 타임아웃 설정
97
+ )
98
+
99
+ # 응답 상태 코드 확인
100
+ if not response.ok:
101
+ logger.error(f"DeepSeek API 오류: 상태 코드 {response.status_code}")
102
+ logger.error(f"응답 내용: {response.text}")
103
+ return {"error": f"API 오류: 상태 코드 {response.status_code}", "detail": response.text}
104
+
105
+ # 응답 파싱
106
+ try:
107
+ result = response.json()
108
+ logger.debug(f"API 응답 구조: {list(result.keys())}")
109
+ return result
110
+ except json.JSONDecodeError as e:
111
+ logger.error(f"DeepSeek API JSON 파싱 실패: {e}")
112
+ logger.error(f"원본 응답: {response.text[:500]}...")
113
+ return {"error": "API 응답을 파싱할 수 없습니다", "detail": str(e)}
114
+
115
+ except requests.exceptions.RequestException as e:
116
+ logger.error(f"DeepSeek API 요청 실패: {e}")
117
+ return {"error": f"API 요청 실패: {str(e)}"}
118
+ except Exception as e:
119
+ logger.error(f"DeepSeek API 호출 중 예상치 못한 오류: {e}")
120
+ logger.error(traceback.format_exc())
121
+ return {"error": f"예상치 못한 오류: {str(e)}"}
122
+
123
+ def generate(
124
+ self,
125
+ prompt: str,
126
+ system_prompt: Optional[str] = None,
127
+ temperature: float = 0.7,
128
+ max_tokens: int = 1000,
129
+ **kwargs
130
+ ) -> str:
131
+ """
132
+ 간단한 텍스트 생성 인터페이스
133
+
134
+ Args:
135
+ prompt: 사용자 프롬프트
136
+ system_prompt: 시스템 프롬프트 (선택 사항)
137
+ temperature: 생성 온도
138
+ max_tokens: 생성할 최대 토큰 수
139
+ **kwargs: 추가 API 매개변수
140
+
141
+ Returns:
142
+ 생성된 텍스트
143
+ """
144
+ messages = []
145
+
146
+ if system_prompt:
147
+ messages.append({"role": "system", "content": system_prompt})
148
+
149
+ messages.append({"role": "user", "content": prompt})
150
+
151
+ try:
152
+ response = self.chat_completion(
153
+ messages=messages,
154
+ temperature=temperature,
155
+ max_tokens=max_tokens,
156
+ **kwargs
157
+ )
158
+
159
+ # 오류 응답 확인
160
+ if "error" in response:
161
+ logger.error(f"텍스트 생성 중 API 오류: {response['error']}")
162
+ error_detail = response.get("detail", "")
163
+ return f"API 오류: {response['error']} {error_detail}"
164
+
165
+ # 응답 형식 검증
166
+ if 'choices' not in response or not response['choices']:
167
+ logger.error(f"API 응답에 'choices' 필드가 없습니다: {response}")
168
+ return "응답 형식 오류: 생성된 텍스트를 찾을 수 없습니다."
169
+
170
+ # 메시지 컨텐츠 확인
171
+ choice = response['choices'][0]
172
+ if 'message' not in choice or 'content' not in choice['message']:
173
+ logger.error(f"API 응답에 예상 필드가 없습니다: {choice}")
174
+ return "응답 형식 오류: 메시지 내용을 찾을 수 없습니다."
175
+
176
+ generated_text = choice['message']['content'].strip()
177
+ logger.info(f"텍스트 생성 완료 (길이: {len(generated_text)})")
178
+ return generated_text
179
+
180
+ except Exception as e:
181
+ logger.error(f"텍스트 생성 중 예외 발생: {e}")
182
+ logger.error(traceback.format_exc())
183
+ return f"오류 발생: {str(e)}"
184
+
185
+ def rag_generate(
186
+ self,
187
+ query: str,
188
+ context: List[str],
189
+ system_prompt: Optional[str] = None,
190
+ temperature: float = 0.3,
191
+ max_tokens: int = 1000,
192
+ **kwargs
193
+ ) -> str:
194
+ """
195
+ RAG 검색 결과를 활용한 텍스트 생성
196
+
197
+ Args:
198
+ query: 사용자 질의
199
+ context: 검색된 문맥 목록
200
+ system_prompt: 시스템 프롬프트 (선택 사항)
201
+ temperature: 생성 온도
202
+ max_tokens: 생성할 최대 토큰 수
203
+ **kwargs: 추가 API 매개변수
204
+
205
+ Returns:
206
+ 생성된 텍스트
207
+ """
208
+ if not system_prompt:
209
+ system_prompt = """당신은 검색 결과를 기반으로 질문에 답변하는 도우미입니다.
210
+ - 검색 결과는 <context> 태그 안에 제공됩니다.
211
+ - 검색 결과에 답변이 있으면 해당 정보를 사용하여 명확하게 답변하세요.
212
+ - 검색 결과에 답변이 없으면 "검색 결과에 관련 정보가 없습니다"라고 말하세요.
213
+ - 검색 내용을 그대로 복사하지 말고, 자연스러운 한국어로 답변을 작성하세요.
214
+ - 답변은 간결하고 정확하게 제공하세요."""
215
+
216
+ try:
217
+ # 중요: 컨텍스트 길이 제한
218
+ max_context = 10
219
+ if len(context) > max_context:
220
+ logger.warning(f"컨텍스트가 너무 길어 처음 {max_context}개만 사용합니다.")
221
+ context = context[:max_context]
222
+
223
+ # 각 컨텍스트 액세스
224
+ limited_context = []
225
+ for i, doc in enumerate(context):
226
+ # 각 문서를 1000자로 제한
227
+ if len(doc) > 1000:
228
+ logger.warning(f"문서 {i+1}의 길이가 제한되었습니다 ({len(doc)} -> 1000)")
229
+ doc = doc[:1000] + "...(생략)"
230
+ limited_context.append(doc)
231
+
232
+ context_text = "\n\n".join([f"문서 {i+1}: {doc}" for i, doc in enumerate(limited_context)])
233
+
234
+ prompt = f"""질문: {query}
235
+
236
+ <context>
237
+ {context_text}
238
+ </context>
239
+
240
+ 위 검색 결과를 참고하여 질문에 답변해 주세요."""
241
+
242
+ logger.info(f"RAG 프롬프트 생성 완료 (길이: {len(prompt)})")
243
+
244
+ result = self.generate(
245
+ prompt=prompt,
246
+ system_prompt=system_prompt,
247
+ temperature=temperature,
248
+ max_tokens=max_tokens,
249
+ **kwargs
250
+ )
251
+
252
+ # 결과가 오류 메시지인지 확인
253
+ if result.startswith("오류") or result.startswith("API 오류") or result.startswith("응답 형식 오류"):
254
+ logger.error(f"RAG 생성 결과가 오류를 포함합니다: {result}")
255
+ # 좀 더 사용자 친화적인 오류 메시지 반환
256
+ return "죄송합니다. 현재 응답을 생성하는데 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
257
+
258
+ return result
259
+
260
+ except Exception as e:
261
+ logger.error(f"RAG 텍스트 생성 중 예외 발생: {str(e)}")
262
+ logger.error(traceback.format_exc())
263
+ return "죄송합니다. 응답 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
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
+ }