Spaces:
Sleeping
Sleeping
Initial setup
Browse files- .gitignore +38 -0
- Procfile +5 -0
- app.py +74 -0
- app/__init__.py +1 -0
- app/app.py +919 -0
- app/app_device_routes.py +378 -0
- app/app_part2.py +209 -0
- app/app_part3.py +163 -0
- app/app_revised.py +268 -0
- app/app_routes.py +297 -0
- app/docs/project_plan.md +44 -0
- app/init_retriever.py +149 -0
- app/static/css/device-style.css +316 -0
- app/static/css/style.css +518 -0
- app/static/js/app-core.js +112 -0
- app/static/js/app-device.js +653 -0
- app/static/js/app-docs.js +196 -0
- app/static/js/app-llm.js +273 -0
- app/static/js/app.js +717 -0
- app/templates/index.html +175 -0
- app/templates/loading.html +75 -0
- app/templates/login.html +126 -0
- app_gradio.py +206 -0
- data/DatasetForRag.csv +104 -0
- huggingface-space.yml +9 -0
- requirements.txt +12 -0
- retrieval/__init__.py +1 -0
- retrieval/base_retriever.py +48 -0
- retrieval/reranker.py +127 -0
- retrieval/vector_retriever.py +202 -0
- run.py +24 -0
- runtime.txt +1 -0
- utils/__init__.py +1 -0
- utils/deepseek_client.py +263 -0
- utils/document_processor.py +396 -0
- utils/llm_client.py +199 -0
- utils/llm_interface.py +159 -0
- utils/openai_client.py +195 -0
- utils/vito_stt.py +253 -0
.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 |
+
}
|