Spaces:
No application file
No application file
fix
Browse files- app/app_routes.py +191 -176
- app/static/js/app-device.js +162 -150
app/app_routes.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
"""
|
2 |
-
RAG
|
3 |
"""
|
4 |
|
5 |
import os
|
@@ -11,19 +11,20 @@ from flask import request, jsonify, render_template, send_from_directory, sessio
|
|
11 |
from datetime import datetime
|
12 |
from werkzeug.utils import secure_filename
|
13 |
|
14 |
-
# 로거
|
|
|
15 |
|
16 |
def register_routes(app, login_required, llm_interface, retriever, stt_client, DocumentProcessor, base_retriever, app_ready, ADMIN_USERNAME, ADMIN_PASSWORD, DEVICE_SERVER_URL):
|
17 |
-
"""Flask
|
18 |
|
19 |
-
#
|
20 |
def allowed_audio_file(filename):
|
21 |
-
"""
|
22 |
ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
|
23 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
|
24 |
|
25 |
def allowed_doc_file(filename):
|
26 |
-
"""
|
27 |
ALLOWED_DOC_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'csv'}
|
28 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_DOC_EXTENSIONS
|
29 |
|
@@ -31,69 +32,70 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
31 |
def login():
|
32 |
error = None
|
33 |
next_url = request.args.get('next')
|
34 |
-
logger.info(f"--------------
|
35 |
logger.info(f"Method: {request.method}")
|
36 |
|
37 |
if request.method == 'POST':
|
38 |
-
logger.info("
|
39 |
username = request.form.get('username', '')
|
40 |
password = request.form.get('password', '')
|
41 |
-
logger.info(f"
|
42 |
-
logger.info(f"
|
43 |
|
44 |
-
#
|
45 |
valid_username = ADMIN_USERNAME
|
46 |
valid_password = ADMIN_PASSWORD
|
47 |
-
logger.info(f"검증용
|
48 |
-
logger.info(f"검증용
|
49 |
|
50 |
if username == valid_username and password == valid_password:
|
51 |
-
logger.info(f"
|
52 |
-
#
|
53 |
-
logger.debug(f"
|
54 |
|
55 |
-
#
|
|
|
56 |
session['logged_in'] = True
|
57 |
session['username'] = username
|
58 |
session.modified = True
|
59 |
|
60 |
-
logger.info(f"
|
61 |
-
logger.info("
|
62 |
|
63 |
-
#
|
64 |
redirect_to = next_url or url_for('index')
|
65 |
-
logger.info(f"
|
66 |
response = redirect(redirect_to)
|
67 |
return response
|
68 |
else:
|
69 |
-
logger.warning("
|
70 |
-
if username != valid_username: logger.warning("
|
71 |
-
if password != valid_password: logger.warning("
|
72 |
-
error = '
|
73 |
else:
|
74 |
-
logger.info("
|
75 |
if 'logged_in' in session:
|
76 |
-
logger.info("
|
77 |
return redirect(url_for('index'))
|
78 |
|
79 |
-
logger.info("----------
|
80 |
return render_template('login.html', error=error, next=next_url)
|
81 |
|
82 |
|
83 |
@app.route('/logout')
|
84 |
def logout():
|
85 |
-
"""
|
86 |
if 'logged_in' in session:
|
87 |
username = session.get('username', 'unknown')
|
88 |
-
logger.info(f"
|
89 |
session.pop('logged_in', None)
|
90 |
session.pop('username', None)
|
91 |
session.modified = True
|
92 |
-
logger.info(f"
|
93 |
else:
|
94 |
-
logger.warning("
|
95 |
|
96 |
-
logger.info("
|
97 |
response = redirect(url_for('login'))
|
98 |
return response
|
99 |
|
@@ -101,42 +103,43 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
101 |
@app.route('/')
|
102 |
@login_required
|
103 |
def index():
|
104 |
-
"""메인
|
105 |
nonlocal app_ready
|
106 |
|
107 |
-
#
|
|
|
108 |
start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
|
109 |
time_diff = (current_time - start_time).total_seconds()
|
110 |
|
111 |
if not app_ready and time_diff > 30:
|
112 |
-
logger.warning(f"
|
113 |
app_ready = True
|
114 |
|
115 |
if not app_ready:
|
116 |
-
logger.info("
|
117 |
-
return render_template('loading.html'), 503 #
|
118 |
|
119 |
-
logger.info("메인
|
120 |
return render_template('index.html')
|
121 |
|
122 |
|
123 |
@app.route('/api/status')
|
124 |
@login_required
|
125 |
def app_status():
|
126 |
-
"""
|
127 |
-
logger.info(f"
|
128 |
return jsonify({"ready": app_ready})
|
129 |
|
130 |
|
131 |
@app.route('/api/llm', methods=['GET', 'POST'])
|
132 |
@login_required
|
133 |
def llm_api():
|
134 |
-
"""
|
135 |
if not app_ready:
|
136 |
-
return jsonify({"error": "
|
137 |
|
138 |
if request.method == 'GET':
|
139 |
-
logger.info("LLM 목록
|
140 |
try:
|
141 |
current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
|
142 |
supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
|
@@ -149,86 +152,87 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
149 |
"current_llm": current_details
|
150 |
})
|
151 |
except Exception as e:
|
152 |
-
logger.error(f"LLM
|
153 |
-
return jsonify({"error": "LLM
|
154 |
|
155 |
elif request.method == 'POST':
|
156 |
data = request.get_json()
|
157 |
if not data or 'llm_id' not in data:
|
158 |
-
return jsonify({"error": "LLM ID가
|
159 |
|
160 |
llm_id = data['llm_id']
|
161 |
-
logger.info(f"LLM
|
162 |
|
163 |
try:
|
164 |
if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
|
165 |
-
raise NotImplementedError("LLM
|
166 |
|
167 |
if llm_id not in llm_interface.llm_clients:
|
168 |
-
return jsonify({"error": f"
|
169 |
|
170 |
success = llm_interface.set_llm(llm_id)
|
171 |
if success:
|
172 |
new_details = llm_interface.get_current_llm_details()
|
173 |
-
logger.info(f"LLM
|
174 |
return jsonify({
|
175 |
"success": True,
|
176 |
-
"message": f"LLM
|
177 |
"current_llm": new_details
|
178 |
})
|
179 |
else:
|
180 |
-
logger.error(f"LLM
|
181 |
-
return jsonify({"error": "LLM
|
182 |
except Exception as e:
|
183 |
-
logger.error(f"LLM
|
184 |
-
return jsonify({"error": f"LLM
|
185 |
|
186 |
|
187 |
@app.route('/api/chat', methods=['POST'])
|
188 |
@login_required
|
189 |
def chat():
|
190 |
-
"""
|
191 |
if not app_ready or retriever is None:
|
192 |
-
return jsonify({"error": "
|
193 |
|
194 |
try:
|
195 |
data = request.get_json()
|
196 |
if not data or 'query' not in data:
|
197 |
-
return jsonify({"error": "쿼리가
|
198 |
|
199 |
query = data['query']
|
200 |
-
logger.info(f"
|
201 |
|
202 |
-
# RAG
|
203 |
if not hasattr(retriever, 'search'):
|
204 |
-
raise NotImplementedError("Retriever
|
205 |
search_results = retriever.search(query, top_k=5, first_stage_k=6)
|
206 |
|
207 |
-
#
|
208 |
-
|
|
|
209 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
210 |
|
211 |
if not context:
|
212 |
-
logger.warning("
|
213 |
|
214 |
-
# LLM
|
215 |
llm_id = data.get('llm_id', None)
|
216 |
if not hasattr(llm_interface, 'rag_generate'):
|
217 |
-
raise NotImplementedError("LLMInterface
|
218 |
|
219 |
if not context:
|
220 |
-
answer = "
|
221 |
-
logger.info("
|
222 |
else:
|
223 |
answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
|
224 |
-
logger.info(f"LLM
|
225 |
|
226 |
-
#
|
227 |
sources = []
|
228 |
if search_results:
|
229 |
for result in search_results:
|
230 |
if not isinstance(result, dict):
|
231 |
-
logger.warning(f"
|
232 |
continue
|
233 |
|
234 |
if "source" in result:
|
@@ -237,7 +241,7 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
237 |
"score": result.get("rerank_score", result.get("score", 0))
|
238 |
}
|
239 |
|
240 |
-
# CSV
|
241 |
if "text" in result and result.get("filetype") == "csv":
|
242 |
try:
|
243 |
text_lines = result["text"].strip().split('\n')
|
@@ -246,13 +250,13 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
246 |
if ',' in first_line:
|
247 |
first_column = first_line.split(',')[0].strip()
|
248 |
source_info["id"] = first_column
|
249 |
-
logger.debug(f"CSV
|
250 |
except Exception as e:
|
251 |
-
logger.warning(f"CSV
|
252 |
|
253 |
sources.append(source_info)
|
254 |
|
255 |
-
# 최종
|
256 |
response_data = {
|
257 |
"answer": answer,
|
258 |
"sources": sources,
|
@@ -261,100 +265,104 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
261 |
return jsonify(response_data)
|
262 |
|
263 |
except Exception as e:
|
264 |
-
logger.error(f"채팅 처리
|
265 |
-
return jsonify({"error": f"처리
|
266 |
|
267 |
|
268 |
@app.route('/api/voice', methods=['POST'])
|
269 |
@login_required
|
270 |
def voice_chat():
|
271 |
-
"""
|
272 |
if not app_ready:
|
273 |
-
logger.warning("
|
274 |
-
#
|
275 |
-
#
|
276 |
|
277 |
if retriever is None:
|
278 |
-
logger.error("retriever가
|
279 |
return jsonify({
|
280 |
-
"transcription": "(
|
281 |
-
"answer": "
|
282 |
"sources": []
|
283 |
})
|
284 |
-
#
|
285 |
if stt_client is None:
|
286 |
return jsonify({
|
287 |
-
"transcription": "(
|
288 |
-
"answer": "
|
289 |
"sources": []
|
290 |
})
|
291 |
|
292 |
-
logger.info("
|
293 |
|
294 |
if 'audio' not in request.files:
|
295 |
-
logger.error("
|
296 |
-
return jsonify({"error": "
|
297 |
|
298 |
audio_file = request.files['audio']
|
299 |
-
logger.info(f"
|
300 |
|
301 |
try:
|
302 |
-
#
|
303 |
-
#
|
304 |
with tempfile.NamedTemporaryFile(delete=True) as temp_audio:
|
305 |
audio_file.save(temp_audio.name)
|
306 |
-
logger.info(f"
|
307 |
-
# VitoSTT.transcribe_audio 가
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
|
|
|
|
|
|
|
|
312 |
audio_bytes = f_bytes.read()
|
313 |
stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
|
314 |
|
315 |
|
316 |
if not isinstance(stt_result, dict) or not stt_result.get("success"):
|
317 |
-
error_msg = stt_result.get("error", "
|
318 |
-
logger.error(f"
|
319 |
return jsonify({
|
320 |
-
"error": "
|
321 |
"details": error_msg
|
322 |
}), 500
|
323 |
|
324 |
transcription = stt_result.get("text", "")
|
325 |
if not transcription:
|
326 |
-
logger.warning("
|
327 |
-
return jsonify({"error": "
|
328 |
|
329 |
-
logger.info(f"
|
330 |
if retriever is None:
|
331 |
-
logger.error("STT
|
332 |
return jsonify({
|
333 |
"transcription": transcription,
|
334 |
-
"answer": "
|
335 |
"sources": []
|
336 |
})
|
337 |
-
# ---
|
338 |
-
# RAG
|
339 |
search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
|
340 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
341 |
|
342 |
if not context:
|
343 |
-
logger.warning("
|
344 |
-
# answer = "
|
345 |
pass
|
346 |
|
347 |
-
# LLM
|
348 |
-
llm_id = request.form.get('llm_id', None) #
|
349 |
if not context:
|
350 |
-
answer = "
|
351 |
-
logger.info("
|
352 |
else:
|
353 |
answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
|
354 |
-
logger.info(f"LLM
|
355 |
|
356 |
|
357 |
-
#
|
358 |
enhanced_sources = []
|
359 |
if search_results:
|
360 |
for doc in search_results:
|
@@ -373,10 +381,10 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
373 |
first_column = first_line.split(',')[0].strip()
|
374 |
source_info["id"] = first_column
|
375 |
except Exception as e:
|
376 |
-
logger.warning(f"[
|
377 |
enhanced_sources.append(source_info)
|
378 |
|
379 |
-
# 최종
|
380 |
response_data = {
|
381 |
"transcription": transcription,
|
382 |
"answer": answer,
|
@@ -386,9 +394,9 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
386 |
return jsonify(response_data)
|
387 |
|
388 |
except Exception as e:
|
389 |
-
logger.error(f"
|
390 |
return jsonify({
|
391 |
-
"error": "
|
392 |
"details": str(e)
|
393 |
}), 500
|
394 |
|
@@ -396,45 +404,45 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
396 |
@app.route('/api/upload', methods=['POST'])
|
397 |
@login_required
|
398 |
def upload_document():
|
399 |
-
"""
|
400 |
-
if base_retriever is None:
|
401 |
-
return jsonify({"error": "
|
402 |
|
403 |
if 'document' not in request.files:
|
404 |
-
return jsonify({"error": "문서
|
405 |
|
406 |
doc_file = request.files['document']
|
407 |
if doc_file.filename == '':
|
408 |
-
return jsonify({"error": "
|
409 |
|
410 |
if not allowed_doc_file(doc_file.filename):
|
411 |
-
logger.error(f"
|
412 |
-
return jsonify({"error": f"
|
413 |
|
414 |
try:
|
415 |
filename = secure_filename(doc_file.filename)
|
416 |
filepath = os.path.join(app.config['DATA_FOLDER'], filename)
|
417 |
doc_file.save(filepath)
|
418 |
-
logger.info(f"문서
|
419 |
|
420 |
-
# 문서 처리 (
|
421 |
try:
|
422 |
with open(filepath, 'r', encoding='utf-8') as f:
|
423 |
content = f.read()
|
424 |
except UnicodeDecodeError:
|
425 |
-
logger.info(f"UTF-8
|
426 |
try:
|
427 |
with open(filepath, 'r', encoding='cp949') as f:
|
428 |
content = f.read()
|
429 |
except Exception as e_cp949:
|
430 |
-
logger.error(f"CP949
|
431 |
-
return jsonify({"error": "
|
432 |
except Exception as e_read:
|
433 |
-
logger.error(f"
|
434 |
-
return jsonify({"error": f"
|
435 |
|
436 |
|
437 |
-
#
|
438 |
metadata = {
|
439 |
"source": filename, "filename": filename,
|
440 |
"filetype": filename.rsplit('.', 1)[1].lower(),
|
@@ -444,84 +452,91 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
444 |
docs = []
|
445 |
|
446 |
if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
|
447 |
-
raise NotImplementedError("DocumentProcessor
|
448 |
|
449 |
if file_ext == 'csv':
|
450 |
-
logger.info(f"CSV
|
451 |
-
docs = DocumentProcessor.csv_to_documents(content, metadata) #
|
452 |
-
|
453 |
-
|
|
|
454 |
if file_ext in ['pdf', 'docx']:
|
455 |
-
logger.warning(f".{file_ext}
|
456 |
-
#
|
457 |
-
#
|
458 |
# content = extract_text_from_docx(filepath)
|
459 |
-
#
|
|
|
460 |
|
461 |
-
if content: #
|
462 |
docs = DocumentProcessor.text_to_documents(
|
463 |
content, metadata=metadata,
|
464 |
chunk_size=512, chunk_overlap=50
|
465 |
)
|
466 |
|
467 |
-
#
|
|
|
468 |
if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
|
469 |
-
raise NotImplementedError("기본
|
470 |
|
471 |
-
logger.info(f"{len(docs)}
|
472 |
base_retriever.add_documents(docs)
|
473 |
|
474 |
-
#
|
475 |
-
logger.info(f"
|
476 |
index_path = app.config['INDEX_PATH']
|
477 |
try:
|
478 |
base_retriever.save(index_path)
|
479 |
-
logger.info("
|
480 |
-
#
|
481 |
-
#
|
482 |
return jsonify({
|
483 |
"success": True,
|
484 |
-
"message": f"
|
485 |
})
|
486 |
except Exception as e_save:
|
487 |
-
logger.error(f"
|
488 |
-
return jsonify({"error": f"
|
489 |
else:
|
490 |
-
logger.warning(f"
|
491 |
-
#
|
492 |
return jsonify({
|
493 |
"warning": True,
|
494 |
-
"message": f"
|
495 |
})
|
496 |
|
497 |
except Exception as e:
|
498 |
-
logger.error(f"
|
499 |
-
return jsonify({"error": f"
|
500 |
|
501 |
|
502 |
@app.route('/api/documents', methods=['GET'])
|
503 |
@login_required
|
504 |
def list_documents():
|
505 |
-
"""
|
506 |
-
if base_retriever is None:
|
507 |
-
return jsonify({"error": "
|
508 |
|
509 |
try:
|
510 |
sources = {}
|
511 |
total_chunks = 0
|
512 |
-
# base_retriever.documents
|
513 |
-
|
|
|
514 |
for doc in base_retriever.documents:
|
515 |
-
# 문서
|
|
|
516 |
|
517 |
-
source = doc.get("source", "unknown") #
|
|
|
518 |
source = doc["metadata"].get("source", "unknown") # Langchain Document 구조 고려
|
519 |
|
520 |
if source != "unknown":
|
521 |
if source in sources:
|
522 |
sources[source]["chunks"] += 1
|
523 |
else:
|
524 |
-
#
|
|
|
525 |
filetype = doc.get("filetype", "unknown")
|
526 |
if "metadata" in doc and isinstance(doc["metadata"], dict):
|
527 |
filename = doc["metadata"].get("filename", filename)
|
@@ -534,13 +549,13 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
534 |
}
|
535 |
total_chunks += 1
|
536 |
else:
|
537 |
-
logger.info("
|
538 |
|
539 |
-
# 목록
|
540 |
documents = [{"source": src, **info} for src, info in sources.items()]
|
541 |
documents.sort(key=lambda x: x["chunks"], reverse=True)
|
542 |
|
543 |
-
logger.info(f"문서 목록 조회
|
544 |
return jsonify({
|
545 |
"documents": documents,
|
546 |
"total_documents": len(documents),
|
@@ -548,5 +563,5 @@ def register_routes(app, login_required, llm_interface, retriever, stt_client, D
|
|
548 |
})
|
549 |
|
550 |
except Exception as e:
|
551 |
-
logger.error(f"문서 목록 조회
|
552 |
-
return jsonify({"error": f"문서 목록 조회
|
|
|
1 |
"""
|
2 |
+
RAG 검색 챗봇 웹 애플리케이션 - API 라우트 정의
|
3 |
"""
|
4 |
|
5 |
import os
|
|
|
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 |
|
|
|
32 |
def login():
|
33 |
error = None
|
34 |
next_url = request.args.get('next')
|
35 |
+
logger.info(f"-------------- 로그인 페이지 접속 (Next: {next_url}) --------------")
|
36 |
logger.info(f"Method: {request.method}")
|
37 |
|
38 |
if request.method == 'POST':
|
39 |
+
logger.info("로그인 시도 받음")
|
40 |
username = request.form.get('username', '')
|
41 |
password = request.form.get('password', '')
|
42 |
+
logger.info(f"입력된 사용자명: {username}")
|
43 |
+
logger.info(f"비밀번호 입력 여부: {len(password) > 0}")
|
44 |
|
45 |
+
# 환경 변수 또는 기본값과 비교
|
46 |
valid_username = ADMIN_USERNAME
|
47 |
valid_password = ADMIN_PASSWORD
|
48 |
+
logger.info(f"검증용 사용자명: {valid_username}")
|
49 |
+
logger.info(f"검증용 비밀번호 존재 여부: {valid_password is not None and len(valid_password) > 0}")
|
50 |
|
51 |
if username == valid_username and password == valid_password:
|
52 |
+
logger.info(f"로그인 성공: {username}")
|
53 |
+
# 세션 설정 전 현재 세션 상태 로깅
|
54 |
+
logger.debug(f"세션 설정 전: {session}")
|
55 |
|
56 |
+
# 세션에 로그인 정보 저장
|
57 |
+
session.permanent = True
|
58 |
session['logged_in'] = True
|
59 |
session['username'] = username
|
60 |
session.modified = True
|
61 |
|
62 |
+
logger.info(f"세션 설정 후: {session}")
|
63 |
+
logger.info("세션 설정 완료, 리디렉션 시도")
|
64 |
|
65 |
+
# 로그인 성공 후 리디렉션
|
66 |
redirect_to = next_url or url_for('index')
|
67 |
+
logger.info(f"리디렉션 대상: {redirect_to}")
|
68 |
response = redirect(redirect_to)
|
69 |
return response
|
70 |
else:
|
71 |
+
logger.warning("로그인 실패: 아이디 또는 비밀번호 불일치")
|
72 |
+
if username != valid_username: logger.warning("사용자명 불일치")
|
73 |
+
if password != valid_password: logger.warning("비밀번호 불일치")
|
74 |
+
error = '아이디 또는 비밀번호가 ��바르지 않습니다.'
|
75 |
else:
|
76 |
+
logger.info("로그인 페이지 GET 요청")
|
77 |
if 'logged_in' in session:
|
78 |
+
logger.info("이미 로그인된 사용자, 메인 페이지로 리디렉션")
|
79 |
return redirect(url_for('index'))
|
80 |
|
81 |
+
logger.info("---------- 로그인 페이지 렌더링 ----------")
|
82 |
return render_template('login.html', error=error, next=next_url)
|
83 |
|
84 |
|
85 |
@app.route('/logout')
|
86 |
def logout():
|
87 |
+
"""로그아웃 처리"""
|
88 |
if 'logged_in' in session:
|
89 |
username = session.get('username', 'unknown')
|
90 |
+
logger.info(f"사용자 {username} 로그아웃 처리 시작")
|
91 |
session.pop('logged_in', None)
|
92 |
session.pop('username', None)
|
93 |
session.modified = True
|
94 |
+
logger.info(f"세션 정보 삭제 완료. 현재 세션: {session}")
|
95 |
else:
|
96 |
+
logger.warning("로그인되지 않은 상태에서 로그아웃 시도")
|
97 |
|
98 |
+
logger.info("로그인 페이지로 리디렉션")
|
99 |
response = redirect(url_for('login'))
|
100 |
return response
|
101 |
|
|
|
103 |
@app.route('/')
|
104 |
@login_required
|
105 |
def index():
|
106 |
+
"""메인 페이지"""
|
107 |
nonlocal app_ready
|
108 |
|
109 |
+
# 앱 준비 상태 확인 - 30초 이상 지났으면 강제로 ready 상태로 변경
|
110 |
+
current_time = datetime.now()
|
111 |
start_time = datetime.fromtimestamp(os.path.getmtime(__file__))
|
112 |
time_diff = (current_time - start_time).total_seconds()
|
113 |
|
114 |
if not app_ready and time_diff > 30:
|
115 |
+
logger.warning(f"앱이 30초 이상 초기화 중 상태입니다. 강제로 ready 상태로 변경합니다.")
|
116 |
app_ready = True
|
117 |
|
118 |
if not app_ready:
|
119 |
+
logger.info("앱이 아직 준비되지 않아 로딩 페이지 표시")
|
120 |
+
return render_template('loading.html'), 503 # 서비스 준비 안됨 상태 코드
|
121 |
|
122 |
+
logger.info("메인 페이지 요청")
|
123 |
return render_template('index.html')
|
124 |
|
125 |
|
126 |
@app.route('/api/status')
|
127 |
@login_required
|
128 |
def app_status():
|
129 |
+
"""앱 초기화 상태 확인 API"""
|
130 |
+
logger.info(f"앱 상태 확인 요청: {'Ready' if app_ready else 'Not Ready'}")
|
131 |
return jsonify({"ready": app_ready})
|
132 |
|
133 |
|
134 |
@app.route('/api/llm', methods=['GET', 'POST'])
|
135 |
@login_required
|
136 |
def llm_api():
|
137 |
+
"""사용 가능한 LLM 목록 및 선택 API"""
|
138 |
if not app_ready:
|
139 |
+
return jsonify({"error": "앱이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
140 |
|
141 |
if request.method == 'GET':
|
142 |
+
logger.info("LLM 목록 요청")
|
143 |
try:
|
144 |
current_details = llm_interface.get_current_llm_details() if hasattr(llm_interface, 'get_current_llm_details') else {"id": "unknown", "name": "Unknown"}
|
145 |
supported_llms_dict = llm_interface.SUPPORTED_LLMS if hasattr(llm_interface, 'SUPPORTED_LLMS') else {}
|
|
|
152 |
"current_llm": current_details
|
153 |
})
|
154 |
except Exception as e:
|
155 |
+
logger.error(f"LLM 정보 조회 오류: {e}")
|
156 |
+
return jsonify({"error": "LLM 정보 조회 중 오류 발생"}), 500
|
157 |
|
158 |
elif request.method == 'POST':
|
159 |
data = request.get_json()
|
160 |
if not data or 'llm_id' not in data:
|
161 |
+
return jsonify({"error": "LLM ID가 제공되지 않았습니다."}), 400
|
162 |
|
163 |
llm_id = data['llm_id']
|
164 |
+
logger.info(f"LLM 변경 요청: {llm_id}")
|
165 |
|
166 |
try:
|
167 |
if not hasattr(llm_interface, 'set_llm') or not hasattr(llm_interface, 'llm_clients'):
|
168 |
+
raise NotImplementedError("LLM 인터페이스에 필요한 메소드/속성 없음")
|
169 |
|
170 |
if llm_id not in llm_interface.llm_clients:
|
171 |
+
return jsonify({"error": f"지원되지 않는 LLM ID: {llm_id}"}), 400
|
172 |
|
173 |
success = llm_interface.set_llm(llm_id)
|
174 |
if success:
|
175 |
new_details = llm_interface.get_current_llm_details()
|
176 |
+
logger.info(f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.")
|
177 |
return jsonify({
|
178 |
"success": True,
|
179 |
+
"message": f"LLM이 '{new_details.get('name', llm_id)}'로 변경되었습니다.",
|
180 |
"current_llm": new_details
|
181 |
})
|
182 |
else:
|
183 |
+
logger.error(f"LLM 변경 실패 (ID: {llm_id})")
|
184 |
+
return jsonify({"error": "LLM 변경 중 내부 오류 발생"}), 500
|
185 |
except Exception as e:
|
186 |
+
logger.error(f"LLM 변경 처리 중 오류: {e}", exc_info=True)
|
187 |
+
return jsonify({"error": f"LLM 변경 중 오류 발생: {str(e)}"}), 500
|
188 |
|
189 |
|
190 |
@app.route('/api/chat', methods=['POST'])
|
191 |
@login_required
|
192 |
def chat():
|
193 |
+
"""텍스트 기반 챗봇 API"""
|
194 |
if not app_ready or retriever is None:
|
195 |
+
return jsonify({"error": "앱/검색기가 아직 초기화 중입니다. 잠시 후 다시 시도해주세요."}), 503
|
196 |
|
197 |
try:
|
198 |
data = request.get_json()
|
199 |
if not data or 'query' not in data:
|
200 |
+
return jsonify({"error": "쿼리가 제공되지 않았습니다."}), 400
|
201 |
|
202 |
query = data['query']
|
203 |
+
logger.info(f"텍스트 쿼리 수신: {query[:100]}...")
|
204 |
|
205 |
+
# RAG 검색 수행
|
206 |
if not hasattr(retriever, 'search'):
|
207 |
+
raise NotImplementedError("Retriever에 search 메소드가 없습니다.")
|
208 |
search_results = retriever.search(query, top_k=5, first_stage_k=6)
|
209 |
|
210 |
+
# 컨텍스트 준비
|
211 |
+
if not hasattr(DocumentProcessor, 'prepare_rag_context'):
|
212 |
+
raise NotImplementedError("DocumentProcessor에 prepare_rag_context 메소드가 없습니다.")
|
213 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
214 |
|
215 |
if not context:
|
216 |
+
logger.warning("검색 결과가 없어 컨텍스트를 생성하지 못함.")
|
217 |
|
218 |
+
# LLM에 질의
|
219 |
llm_id = data.get('llm_id', None)
|
220 |
if not hasattr(llm_interface, 'rag_generate'):
|
221 |
+
raise NotImplementedError("LLMInterface에 rag_generate 메소드가 없습니다.")
|
222 |
|
223 |
if not context:
|
224 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
225 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
226 |
else:
|
227 |
answer = llm_interface.rag_generate(query, context, llm_id=llm_id)
|
228 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
229 |
|
230 |
+
# 소스 정보 추출 (CSV ID 추출 로직 포함)
|
231 |
sources = []
|
232 |
if search_results:
|
233 |
for result in search_results:
|
234 |
if not isinstance(result, dict):
|
235 |
+
logger.warning(f"예상치 못한 검색 결과 형식: {type(result)}")
|
236 |
continue
|
237 |
|
238 |
if "source" in result:
|
|
|
241 |
"score": result.get("rerank_score", result.get("score", 0))
|
242 |
}
|
243 |
|
244 |
+
# CSV 파일 특정 처리
|
245 |
if "text" in result and result.get("filetype") == "csv":
|
246 |
try:
|
247 |
text_lines = result["text"].strip().split('\n')
|
|
|
250 |
if ',' in first_line:
|
251 |
first_column = first_line.split(',')[0].strip()
|
252 |
source_info["id"] = first_column
|
253 |
+
logger.debug(f"CSV 소스 ID 추출: {first_column} from {source_info['source']}")
|
254 |
except Exception as e:
|
255 |
+
logger.warning(f"CSV 소스 ID 추출 실패 ({result.get('source')}): {e}")
|
256 |
|
257 |
sources.append(source_info)
|
258 |
|
259 |
+
# 최종 응답
|
260 |
response_data = {
|
261 |
"answer": answer,
|
262 |
"sources": sources,
|
|
|
265 |
return jsonify(response_data)
|
266 |
|
267 |
except Exception as e:
|
268 |
+
logger.error(f"채팅 처리 중 오류 발생: {e}", exc_info=True)
|
269 |
+
return jsonify({"error": f"처리 중 오류가 발생했습니다: {str(e)}"}), 500
|
270 |
|
271 |
|
272 |
@app.route('/api/voice', methods=['POST'])
|
273 |
@login_required
|
274 |
def voice_chat():
|
275 |
+
"""음성 챗 API 엔드포인트"""
|
276 |
if not app_ready:
|
277 |
+
logger.warning("앱 초기화가 완료되지 않았지만 음성 API 요청 처리 시도")
|
278 |
+
# 여기서 바로 리턴하지 않고 계속 진행
|
279 |
+
# 사전 검사: retriever와 stt_client가 제대로 초기화되었는지 확인
|
280 |
|
281 |
if retriever is None:
|
282 |
+
logger.error("retriever가 아직 초기화되지 않았습니다")
|
283 |
return jsonify({
|
284 |
+
"transcription": "(음성을 텍스트로 변환했지만 검색 엔진이 아직 준비되지 않았습니다)",
|
285 |
+
"answer": "죄송합니다. 검색 엔진이 아직 초기화 중입니다. 잠시 후 다시 시도해주세요.",
|
286 |
"sources": []
|
287 |
})
|
288 |
+
# 또는 필수 컴포넌트가 없을 때만 특별 응답 반환
|
289 |
if stt_client is None:
|
290 |
return jsonify({
|
291 |
+
"transcription": "(음성 인식 기능이 준비 중입니다)",
|
292 |
+
"answer": "죄송합니다. 현재 음성 인식 서비스가 초기화 중입니다. 잠시 후 다시 시도해주세요.",
|
293 |
"sources": []
|
294 |
})
|
295 |
|
296 |
+
logger.info("음성 챗 요청 수신")
|
297 |
|
298 |
if 'audio' not in request.files:
|
299 |
+
logger.error("오디오 파일이 제공되지 않음")
|
300 |
+
return jsonify({"error": "오디오 파일이 제공되지 않았습니다."}), 400
|
301 |
|
302 |
audio_file = request.files['audio']
|
303 |
+
logger.info(f"수신된 오디오 파일: {audio_file.filename} ({audio_file.content_type})")
|
304 |
|
305 |
try:
|
306 |
+
# 오디오 파일 처리
|
307 |
+
# 임시 파일 사용 고려 (메모리 부담 줄이기 위해)
|
308 |
with tempfile.NamedTemporaryFile(delete=True) as temp_audio:
|
309 |
audio_file.save(temp_audio.name)
|
310 |
+
logger.info(f"오디오 파일을 임시 저장: {temp_audio.name}")
|
311 |
+
# VitoSTT.transcribe_audio 가 파일 경로 또는 바이트를 받을 수 있도록 구현되어야 함
|
312 |
+
# 여기서는 파일 경로를 사용한다고 가정
|
313 |
+
if not hasattr(stt_client, 'transcribe_audio'):
|
314 |
+
raise NotImplementedError("STT 클라이언트에 transcribe_audio 메소드가 없습니다.")
|
315 |
+
|
316 |
+
# 파일 경로로 전달 시
|
317 |
+
# stt_result = stt_client.transcribe_audio(temp_audio.name, language="ko")
|
318 |
+
# 바이트로 전달 시
|
319 |
+
with open(temp_audio.name, 'rb') as f_bytes:
|
320 |
audio_bytes = f_bytes.read()
|
321 |
stt_result = stt_client.transcribe_audio(audio_bytes, language="ko")
|
322 |
|
323 |
|
324 |
if not isinstance(stt_result, dict) or not stt_result.get("success"):
|
325 |
+
error_msg = stt_result.get("error", "알 수 없는 STT 오류") if isinstance(stt_result, dict) else "STT 결과 형식 오류"
|
326 |
+
logger.error(f"음성인식 실패: {error_msg}")
|
327 |
return jsonify({
|
328 |
+
"error": "음성인식 실패",
|
329 |
"details": error_msg
|
330 |
}), 500
|
331 |
|
332 |
transcription = stt_result.get("text", "")
|
333 |
if not transcription:
|
334 |
+
logger.warning("음성인식 결과가 비어있습니다.")
|
335 |
+
return jsonify({"error": "음성에서 텍스트를 인식하지 못했습니다.", "transcription": ""}), 400
|
336 |
|
337 |
+
logger.info(f"음성인식 성공: {transcription[:50]}...")
|
338 |
if retriever is None:
|
339 |
+
logger.error("STT 성공 후 검색 시도 중 retriever가 None임")
|
340 |
return jsonify({
|
341 |
"transcription": transcription,
|
342 |
+
"answer": "음성을 인식했지만, 현재 검색 시스템이 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
|
343 |
"sources": []
|
344 |
})
|
345 |
+
# --- 이후 로직은 /api/chat과 거의 동일 ---
|
346 |
+
# RAG 검색 수행
|
347 |
search_results = retriever.search(transcription, top_k=5, first_stage_k=6)
|
348 |
context = DocumentProcessor.prepare_rag_context(search_results, field="text")
|
349 |
|
350 |
if not context:
|
351 |
+
logger.warning("음성 쿼리에 대한 검색 결과 없음.")
|
352 |
+
# answer = "죄송합니다. 관련 정보를 찾을 수 없습니다." (아래 LLM 호출 로직에서 처리)
|
353 |
pass
|
354 |
|
355 |
+
# LLM 호출
|
356 |
+
llm_id = request.form.get('llm_id', None) # 음성 요청은 form 데이터로 LLM ID 받을 수 있음
|
357 |
if not context:
|
358 |
+
answer = "죄송합니다. 관련 정보를 찾을 수 없습니다."
|
359 |
+
logger.info("컨텍스트 없이 기본 응답 생성")
|
360 |
else:
|
361 |
answer = llm_interface.rag_generate(transcription, context, llm_id=llm_id)
|
362 |
+
logger.info(f"LLM 응답 생성 완료 (길이: {len(answer)})")
|
363 |
|
364 |
|
365 |
+
# 소스 정보 추출
|
366 |
enhanced_sources = []
|
367 |
if search_results:
|
368 |
for doc in search_results:
|
|
|
381 |
first_column = first_line.split(',')[0].strip()
|
382 |
source_info["id"] = first_column
|
383 |
except Exception as e:
|
384 |
+
logger.warning(f"[음성챗] CSV 소스 ID 추출 실패 ({doc.get('source')}): {e}")
|
385 |
enhanced_sources.append(source_info)
|
386 |
|
387 |
+
# 최종 응답
|
388 |
response_data = {
|
389 |
"transcription": transcription,
|
390 |
"answer": answer,
|
|
|
394 |
return jsonify(response_data)
|
395 |
|
396 |
except Exception as e:
|
397 |
+
logger.error(f"음성 챗 처리 중 오류 발생: {e}", exc_info=True)
|
398 |
return jsonify({
|
399 |
+
"error": "음성 처리 중 내부 오류 발생",
|
400 |
"details": str(e)
|
401 |
}), 500
|
402 |
|
|
|
404 |
@app.route('/api/upload', methods=['POST'])
|
405 |
@login_required
|
406 |
def upload_document():
|
407 |
+
"""지식베이스 문서 업로드 API"""
|
408 |
+
if not app_ready or base_retriever is None:
|
409 |
+
return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
|
410 |
|
411 |
if 'document' not in request.files:
|
412 |
+
return jsonify({"error": "문서 파일이 제공되지 않았습니다."}), 400
|
413 |
|
414 |
doc_file = request.files['document']
|
415 |
if doc_file.filename == '':
|
416 |
+
return jsonify({"error": "선택된 파일이 없습니다."}), 400
|
417 |
|
418 |
if not allowed_doc_file(doc_file.filename):
|
419 |
+
logger.error(f"허용되지 않는 파일 형식: {doc_file.filename}")
|
420 |
+
return jsonify({"error": f"허용되지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_DOC_EXTENSIONS)}"}), 400
|
421 |
|
422 |
try:
|
423 |
filename = secure_filename(doc_file.filename)
|
424 |
filepath = os.path.join(app.config['DATA_FOLDER'], filename)
|
425 |
doc_file.save(filepath)
|
426 |
+
logger.info(f"문서 저장 완료: {filepath}")
|
427 |
|
428 |
+
# 문서 처리 (인코딩 처리 포함)
|
429 |
try:
|
430 |
with open(filepath, 'r', encoding='utf-8') as f:
|
431 |
content = f.read()
|
432 |
except UnicodeDecodeError:
|
433 |
+
logger.info(f"UTF-8 디코딩 실패, CP949로 시도: {filename}")
|
434 |
try:
|
435 |
with open(filepath, 'r', encoding='cp949') as f:
|
436 |
content = f.read()
|
437 |
except Exception as e_cp949:
|
438 |
+
logger.error(f"CP949 디코딩 실패 ({filename}): {e_cp949}")
|
439 |
+
return jsonify({"error": "파일 인코딩을 읽을 수 없습니다 (UTF-8, CP949 시도 실패)."}), 400
|
440 |
except Exception as e_read:
|
441 |
+
logger.error(f"파일 읽기 오류 ({filename}): {e_read}")
|
442 |
+
return jsonify({"error": f"파일 읽기 중 오류 발생: {str(e_read)}"}), 500
|
443 |
|
444 |
|
445 |
+
# 메타데이터 및 문서 분할/처리
|
446 |
metadata = {
|
447 |
"source": filename, "filename": filename,
|
448 |
"filetype": filename.rsplit('.', 1)[1].lower(),
|
|
|
452 |
docs = []
|
453 |
|
454 |
if not hasattr(DocumentProcessor, 'csv_to_documents') or not hasattr(DocumentProcessor, 'text_to_documents'):
|
455 |
+
raise NotImplementedError("DocumentProcessor에 필요한 메소드 없음")
|
456 |
|
457 |
if file_ext == 'csv':
|
458 |
+
logger.info(f"CSV 파일 처리 시작: {filename}")
|
459 |
+
docs = DocumentProcessor.csv_to_documents(content, metadata) # 행 단위 처리 가정
|
460 |
+
else: # 기타 텍스트 기반 문서
|
461 |
+
logger.info(f"일반 텍스트 문서 처리 시작: {filename}")
|
462 |
+
# PDF, DOCX 등은 별도 라이브러리(pypdf, python-docx) 필요
|
463 |
if file_ext in ['pdf', 'docx']:
|
464 |
+
logger.warning(f".{file_ext} 파일 처리는 현재 구현되지 않았습니다. 텍스트 추출 로직 추가 필요.")
|
465 |
+
# 여기에 pdf/docx 텍스트 추출 로직 추가
|
466 |
+
# 예: content = extract_text_from_pdf(filepath)
|
467 |
# content = extract_text_from_docx(filepath)
|
468 |
+
# 임시로 비워둠
|
469 |
+
content = ""
|
470 |
|
471 |
+
if content: # 텍스트 내용이 있을 때만 처리
|
472 |
docs = DocumentProcessor.text_to_documents(
|
473 |
content, metadata=metadata,
|
474 |
chunk_size=512, chunk_overlap=50
|
475 |
)
|
476 |
|
477 |
+
# 검색기에 문서 추가 및 인덱스 저장
|
478 |
+
if docs:
|
479 |
if not hasattr(base_retriever, 'add_documents') or not hasattr(base_retriever, 'save'):
|
480 |
+
raise NotImplementedError("기본 검색기에 add_documents 또는 save 메소드 없음")
|
481 |
|
482 |
+
logger.info(f"{len(docs)}개 문서 청크를 검색기에 추가합니다...")
|
483 |
base_retriever.add_documents(docs)
|
484 |
|
485 |
+
# 인덱스 저장 (업로드마다 저장 - 비효율적일 수 있음)
|
486 |
+
logger.info(f"검색기 상태를 저장합니다...")
|
487 |
index_path = app.config['INDEX_PATH']
|
488 |
try:
|
489 |
base_retriever.save(index_path)
|
490 |
+
logger.info("인덱스 저장 완료")
|
491 |
+
# 재순위화 검색기도 업데이트 필요 시 로직 추가
|
492 |
+
# 예: retriever.update_base_retriever(base_retriever)
|
493 |
return jsonify({
|
494 |
"success": True,
|
495 |
+
"message": f"파일 '{filename}' 업로드 및 처리 완료 ({len(docs)}개 청크 추가)."
|
496 |
})
|
497 |
except Exception as e_save:
|
498 |
+
logger.error(f"인덱스 저장 중 오류 발생: {e_save}")
|
499 |
+
return jsonify({"error": f"인덱스 저장 중 오류: {str(e_save)}"}), 500
|
500 |
else:
|
501 |
+
logger.warning(f"파일 '{filename}'에서 처리할 내용이 없거나 지원되지 않는 형식입니다.")
|
502 |
+
# 파일은 저장되었으므로 성공으로 간주할지 결정 필요
|
503 |
return jsonify({
|
504 |
"warning": True,
|
505 |
+
"message": f"파일 '{filename}'이 저장되었지만 처리할 내용이 없습니다."
|
506 |
})
|
507 |
|
508 |
except Exception as e:
|
509 |
+
logger.error(f"파일 업로드 또는 처리 중 오류 발생: {e}", exc_info=True)
|
510 |
+
return jsonify({"error": f"파일 업로드 중 오류: {str(e)}"}), 500
|
511 |
|
512 |
|
513 |
@app.route('/api/documents', methods=['GET'])
|
514 |
@login_required
|
515 |
def list_documents():
|
516 |
+
"""지식베이스 문서 목록 API"""
|
517 |
+
if not app_ready or base_retriever is None:
|
518 |
+
return jsonify({"error": "앱/기본 검색기가 아직 초기화 중입니다."}), 503
|
519 |
|
520 |
try:
|
521 |
sources = {}
|
522 |
total_chunks = 0
|
523 |
+
# base_retriever.documents 와 같은 속성이 실제 클래스에 있다고 가정
|
524 |
+
if hasattr(base_retriever, 'documents') and base_retriever.documents:
|
525 |
+
logger.info(f"총 {len(base_retriever.documents)}개 문서 청크에서 소스 목록 생성 중...")
|
526 |
for doc in base_retriever.documents:
|
527 |
+
# 문서 청크가 딕셔너리 형태라고 가정
|
528 |
+
if not isinstance(doc, dict): continue
|
529 |
|
530 |
+
source = doc.get("source", "unknown") # 메타데이터에서 source 가져오기
|
531 |
+
if source == "unknown" and "metadata" in doc and isinstance(doc["metadata"], dict):
|
532 |
source = doc["metadata"].get("source", "unknown") # Langchain Document 구조 고려
|
533 |
|
534 |
if source != "unknown":
|
535 |
if source in sources:
|
536 |
sources[source]["chunks"] += 1
|
537 |
else:
|
538 |
+
# 메타데이터에서 추가 정보 가져오기
|
539 |
+
filename = doc.get("filename", source)
|
540 |
filetype = doc.get("filetype", "unknown")
|
541 |
if "metadata" in doc and isinstance(doc["metadata"], dict):
|
542 |
filename = doc["metadata"].get("filename", filename)
|
|
|
549 |
}
|
550 |
total_chunks += 1
|
551 |
else:
|
552 |
+
logger.info("검색기에 문서가 없거나 documents 속성을 찾을 수 없습니다.")
|
553 |
|
554 |
+
# 목록 형식 변환 및 정렬
|
555 |
documents = [{"source": src, **info} for src, info in sources.items()]
|
556 |
documents.sort(key=lambda x: x["chunks"], reverse=True)
|
557 |
|
558 |
+
logger.info(f"문서 목록 조회 완료: {len(documents)}개 소스 파일, {total_chunks}개 청크")
|
559 |
return jsonify({
|
560 |
"documents": documents,
|
561 |
"total_documents": len(documents),
|
|
|
563 |
})
|
564 |
|
565 |
except Exception as e:
|
566 |
+
logger.error(f"문서 목록 조회 중 오류 발생: {e}", exc_info=True)
|
567 |
+
return jsonify({"error": f"문서 목록 조회 중 오류: {str(e)}"}), 500
|
app/static/js/app-device.js
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
/**
|
2 |
-
* RAG
|
3 |
*/
|
4 |
|
5 |
-
// DOM
|
6 |
const deviceTab = document.getElementById('deviceTab');
|
7 |
const deviceSection = document.getElementById('deviceSection');
|
8 |
const checkDeviceStatusButton = document.getElementById('checkDeviceStatusButton');
|
@@ -19,110 +19,118 @@ const programsLoading = document.getElementById('programsLoading');
|
|
19 |
const programsList = document.getElementById('programsList');
|
20 |
const noProgramsMessage = document.getElementById('noProgramsMessage');
|
21 |
|
22 |
-
//
|
23 |
-
// 직접
|
24 |
-
let DEVICE_SERVER_URL = ''; //
|
|
|
25 |
/**
|
26 |
-
*
|
27 |
-
* @param {string} endpoint - API
|
28 |
-
* @returns {string} -
|
29 |
*/
|
30 |
function getDeviceApiUrl(endpoint) {
|
31 |
-
// 직접
|
32 |
if (DEVICE_SERVER_URL) {
|
33 |
return `${DEVICE_SERVER_URL}${endpoint}`;
|
34 |
}
|
35 |
|
36 |
-
//
|
37 |
return endpoint;
|
38 |
}
|
39 |
|
40 |
/**
|
41 |
-
*
|
42 |
*/
|
43 |
function initDeviceServerSettings() {
|
44 |
-
console.log("
|
45 |
-
//
|
46 |
fetch('/api/device/settings')
|
47 |
.then(response => {
|
48 |
if (!response.ok) {
|
49 |
-
throw new Error('
|
50 |
}
|
51 |
return response.json();
|
52 |
})
|
53 |
.then(data => {
|
54 |
if (data.server_url) {
|
55 |
-
console.log(
|
56 |
DEVICE_SERVER_URL = data.server_url;
|
57 |
} else {
|
58 |
-
//
|
59 |
const currentHost = window.location.hostname;
|
60 |
const protocol = window.location.protocol;
|
61 |
-
DEVICE_SERVER_URL = `${protocol}//${currentHost}:
|
62 |
-
console.log(
|
63 |
}
|
64 |
})
|
65 |
.catch(error => {
|
66 |
-
console.error('
|
67 |
-
//
|
68 |
const currentHost = window.location.hostname;
|
69 |
const protocol = window.location.protocol;
|
70 |
-
DEVICE_SERVER_URL = `${protocol}//${currentHost}:
|
71 |
-
console.log(
|
72 |
});
|
73 |
}
|
74 |
|
75 |
-
//
|
76 |
-
|
|
|
77 |
|
78 |
-
//
|
|
|
79 |
|
80 |
-
//
|
81 |
-
//
|
82 |
if (typeof window.switchTab !== 'function') {
|
83 |
-
console.log("window.switchTab
|
84 |
} else {
|
85 |
-
console.log("window.switchTab
|
86 |
}
|
87 |
|
88 |
-
//
|
89 |
-
|
|
|
90 |
checkDeviceStatus();
|
91 |
});
|
92 |
|
93 |
-
//
|
94 |
-
|
|
|
95 |
loadDevices();
|
96 |
});
|
97 |
|
98 |
-
//
|
99 |
-
|
|
|
100 |
loadPrograms();
|
101 |
});
|
102 |
});
|
103 |
|
104 |
/**
|
105 |
-
*
|
106 |
-
* @param {Error} error -
|
107 |
-
* @returns {string} -
|
108 |
*/
|
109 |
function handleError(error) {
|
110 |
-
console.error("
|
111 |
|
112 |
if (error.name === 'AbortError') {
|
113 |
-
return '
|
114 |
}
|
115 |
|
116 |
if (error.message && (error.message.includes('NetworkError') || error.message.includes('Failed to fetch'))) {
|
117 |
-
return '
|
118 |
}
|
119 |
|
120 |
-
return
|
121 |
}
|
122 |
|
123 |
/**
|
124 |
-
* HTML
|
125 |
-
* @param {string} unsafe -
|
|
|
|
|
126 |
function escapeHtml(unsafe) {
|
127 |
if (typeof unsafe !== 'string') return unsafe;
|
128 |
return unsafe
|
@@ -134,54 +142,55 @@ function escapeHtml(unsafe) {
|
|
134 |
}
|
135 |
|
136 |
/**
|
137 |
-
*
|
138 |
*/
|
139 |
async function checkDeviceStatus() {
|
140 |
-
console.log("
|
141 |
|
142 |
-
// UI
|
143 |
deviceStatusResult.classList.add('hidden');
|
144 |
deviceStatusLoading.classList.remove('hidden');
|
145 |
|
146 |
try {
|
147 |
-
//
|
148 |
const controller = new AbortController();
|
149 |
-
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5
|
150 |
-
|
|
|
151 |
const response = await fetch(getDeviceApiUrl('/api/status'), {
|
152 |
signal: controller.signal
|
153 |
});
|
154 |
|
155 |
-
clearTimeout(timeoutId); //
|
156 |
|
157 |
-
//
|
158 |
if (!response.ok) {
|
159 |
-
throw new Error(`HTTP
|
160 |
}
|
161 |
|
162 |
const data = await response.json();
|
163 |
-
console.log("
|
164 |
|
165 |
-
// UI
|
166 |
deviceStatusLoading.classList.add('hidden');
|
167 |
deviceStatusResult.classList.remove('hidden');
|
168 |
|
169 |
if (data.status === "online") {
|
170 |
-
//
|
171 |
statusIcon.innerHTML = '<i class="fas fa-circle online"></i>';
|
172 |
-
statusText.textContent =
|
173 |
|
174 |
-
//
|
175 |
loadDevices();
|
176 |
} else {
|
177 |
-
//
|
178 |
statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
179 |
-
statusText.textContent =
|
180 |
}
|
181 |
} catch (error) {
|
182 |
-
console.error("
|
183 |
|
184 |
-
// UI
|
185 |
deviceStatusLoading.classList.add('hidden');
|
186 |
deviceStatusResult.classList.remove('hidden');
|
187 |
|
@@ -191,52 +200,53 @@ async function checkDeviceStatus() {
|
|
191 |
}
|
192 |
|
193 |
/**
|
194 |
-
*
|
195 |
*/
|
196 |
async function loadDevices() {
|
197 |
-
console.log("
|
198 |
|
199 |
-
// UI
|
200 |
deviceList.innerHTML = '';
|
201 |
noDevicesMessage.classList.add('hidden');
|
202 |
devicesLoading.classList.remove('hidden');
|
203 |
|
204 |
try {
|
205 |
-
//
|
206 |
const controller = new AbortController();
|
207 |
-
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5
|
208 |
-
|
|
|
209 |
const response = await fetch(getDeviceApiUrl('/api/devices'), {
|
210 |
signal: controller.signal
|
211 |
});
|
212 |
|
213 |
-
clearTimeout(timeoutId); //
|
214 |
|
215 |
-
//
|
216 |
if (!response.ok) {
|
217 |
-
throw new Error(`HTTP
|
218 |
}
|
219 |
|
220 |
const data = await response.json();
|
221 |
-
console.log("
|
222 |
|
223 |
-
// UI
|
224 |
devicesLoading.classList.add('hidden');
|
225 |
|
226 |
if (data.devices && data.devices.length > 0) {
|
227 |
-
//
|
228 |
data.devices.forEach(device => {
|
229 |
const deviceElement = createDeviceItem(device);
|
230 |
deviceList.appendChild(deviceElement);
|
231 |
});
|
232 |
} else {
|
233 |
-
//
|
234 |
noDevicesMessage.classList.remove('hidden');
|
235 |
}
|
236 |
} catch (error) {
|
237 |
-
console.error("
|
238 |
|
239 |
-
// UI
|
240 |
devicesLoading.classList.add('hidden');
|
241 |
noDevicesMessage.classList.remove('hidden');
|
242 |
noDevicesMessage.textContent = handleError(error);
|
@@ -244,61 +254,61 @@ async function loadDevices() {
|
|
244 |
}
|
245 |
|
246 |
/**
|
247 |
-
*
|
248 |
-
* @param {Object} device -
|
249 |
-
* @returns {HTMLElement} -
|
250 |
*/
|
251 |
function createDeviceItem(device) {
|
252 |
const deviceItem = document.createElement('div');
|
253 |
deviceItem.classList.add('device-item');
|
254 |
|
255 |
-
//
|
256 |
-
if (device.status === 'online' || device.status === '
|
257 |
deviceItem.classList.add('online');
|
258 |
-
} else if (device.status === 'offline' || device.status === '
|
259 |
deviceItem.classList.add('offline');
|
260 |
} else if (device.status === 'warning' || device.status === '경고') {
|
261 |
deviceItem.classList.add('warning');
|
262 |
}
|
263 |
|
264 |
-
//
|
265 |
const deviceHeader = document.createElement('div');
|
266 |
deviceHeader.classList.add('device-item-header');
|
267 |
|
268 |
const deviceName = document.createElement('div');
|
269 |
deviceName.classList.add('device-name');
|
270 |
-
deviceName.textContent = device.name || '
|
271 |
|
272 |
const deviceStatusBadge = document.createElement('div');
|
273 |
deviceStatusBadge.classList.add('device-status-badge');
|
274 |
|
275 |
-
//
|
276 |
-
if (device.status === 'online' || device.status === '
|
277 |
deviceStatusBadge.classList.add('online');
|
278 |
-
deviceStatusBadge.textContent = '
|
279 |
-
} else if (device.status === 'offline' || device.status === '
|
280 |
deviceStatusBadge.classList.add('offline');
|
281 |
-
deviceStatusBadge.textContent = '
|
282 |
} else if (device.status === 'warning' || device.status === '경고') {
|
283 |
deviceStatusBadge.classList.add('warning');
|
284 |
deviceStatusBadge.textContent = '경고';
|
285 |
} else {
|
286 |
-
deviceStatusBadge.textContent = device.status || '
|
287 |
}
|
288 |
|
289 |
deviceHeader.appendChild(deviceName);
|
290 |
deviceHeader.appendChild(deviceStatusBadge);
|
291 |
|
292 |
-
//
|
293 |
const deviceInfo = document.createElement('div');
|
294 |
deviceInfo.classList.add('device-info');
|
295 |
-
deviceInfo.textContent =
|
296 |
|
297 |
-
//
|
298 |
const deviceDetails = document.createElement('div');
|
299 |
deviceDetails.classList.add('device-details');
|
300 |
|
301 |
-
//
|
302 |
if (device.ip) {
|
303 |
deviceDetails.textContent += `IP: ${device.ip}`;
|
304 |
}
|
@@ -306,10 +316,10 @@ function createDeviceItem(device) {
|
|
306 |
deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') + `MAC: ${device.mac}`;
|
307 |
}
|
308 |
if (device.lastSeen) {
|
309 |
-
deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') +
|
310 |
}
|
311 |
|
312 |
-
//
|
313 |
deviceItem.appendChild(deviceHeader);
|
314 |
deviceItem.appendChild(deviceInfo);
|
315 |
|
@@ -321,52 +331,53 @@ function createDeviceItem(device) {
|
|
321 |
}
|
322 |
|
323 |
/**
|
324 |
-
*
|
325 |
*/
|
326 |
async function loadPrograms() {
|
327 |
-
console.log("
|
328 |
|
329 |
-
// UI
|
330 |
programsList.innerHTML = '';
|
331 |
noProgramsMessage.classList.add('hidden');
|
332 |
programsLoading.classList.remove('hidden');
|
333 |
|
334 |
try {
|
335 |
-
//
|
336 |
const controller = new AbortController();
|
337 |
-
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5
|
338 |
-
|
|
|
339 |
const response = await fetch(getDeviceApiUrl('/api/programs'), {
|
340 |
signal: controller.signal
|
341 |
});
|
342 |
|
343 |
-
clearTimeout(timeoutId); //
|
344 |
|
345 |
-
//
|
346 |
if (!response.ok) {
|
347 |
-
throw new Error(`HTTP
|
348 |
}
|
349 |
|
350 |
const data = await response.json();
|
351 |
-
console.log("
|
352 |
|
353 |
-
// UI
|
354 |
programsLoading.classList.add('hidden');
|
355 |
|
356 |
if (data.programs && data.programs.length > 0) {
|
357 |
-
//
|
358 |
data.programs.forEach(program => {
|
359 |
const programElement = createProgramItem(program);
|
360 |
programsList.appendChild(programElement);
|
361 |
});
|
362 |
} else {
|
363 |
-
//
|
364 |
noProgramsMessage.classList.remove('hidden');
|
365 |
}
|
366 |
} catch (error) {
|
367 |
-
console.error("
|
368 |
|
369 |
-
// UI
|
370 |
programsLoading.classList.add('hidden');
|
371 |
noProgramsMessage.classList.remove('hidden');
|
372 |
noProgramsMessage.textContent = handleError(error);
|
@@ -374,25 +385,25 @@ async function loadPrograms() {
|
|
374 |
}
|
375 |
|
376 |
/**
|
377 |
-
*
|
378 |
-
* @param {Object} program -
|
379 |
-
* @returns {HTMLElement} -
|
380 |
*/
|
381 |
function createProgramItem(program) {
|
382 |
const programItem = document.createElement('div');
|
383 |
programItem.classList.add('program-item');
|
384 |
|
385 |
-
//
|
386 |
const programHeader = document.createElement('div');
|
387 |
programHeader.classList.add('program-item-header');
|
388 |
|
389 |
const programName = document.createElement('div');
|
390 |
programName.classList.add('program-name');
|
391 |
-
programName.textContent = program.name || '
|
392 |
|
393 |
programHeader.appendChild(programName);
|
394 |
|
395 |
-
//
|
396 |
if (program.description) {
|
397 |
const programDescription = document.createElement('div');
|
398 |
programDescription.classList.add('program-description');
|
@@ -400,15 +411,15 @@ function createProgramItem(program) {
|
|
400 |
programItem.appendChild(programDescription);
|
401 |
}
|
402 |
|
403 |
-
//
|
404 |
const executeButton = document.createElement('button');
|
405 |
executeButton.classList.add('execute-btn');
|
406 |
-
executeButton.textContent = '
|
407 |
executeButton.addEventListener('click', () => {
|
408 |
executeProgram(program.id, program.name);
|
409 |
});
|
410 |
|
411 |
-
//
|
412 |
programItem.appendChild(programHeader);
|
413 |
programItem.appendChild(executeButton);
|
414 |
|
@@ -416,72 +427,72 @@ function createProgramItem(program) {
|
|
416 |
}
|
417 |
|
418 |
/**
|
419 |
-
*
|
420 |
-
* @param {string} programId -
|
421 |
-
* @param {string} programName -
|
422 |
*/
|
423 |
async function executeProgram(programId, programName) {
|
424 |
if (!programId) return;
|
425 |
|
426 |
-
console.log(
|
427 |
|
428 |
-
//
|
429 |
-
if (!confirm(`'${programName}'
|
430 |
-
console.log('
|
431 |
return;
|
432 |
}
|
433 |
|
434 |
try {
|
435 |
-
// 로딩
|
436 |
-
showNotification(`'${programName}'
|
437 |
|
438 |
-
// API
|
439 |
const response = await fetch(getDeviceApiUrl(`/api/programs/${programId}/execute`), {
|
440 |
method: 'POST',
|
441 |
headers: {
|
442 |
'Content-Type': 'application/json'
|
443 |
},
|
444 |
-
body: JSON.stringify({}) //
|
445 |
});
|
446 |
|
447 |
-
//
|
448 |
if (!response.ok) {
|
449 |
-
throw new Error(`HTTP
|
450 |
}
|
451 |
|
452 |
const data = await response.json();
|
453 |
-
console.log(
|
454 |
|
455 |
// 결과 처리
|
456 |
if (data.success) {
|
457 |
-
showNotification(`'${programName}'
|
458 |
} else {
|
459 |
-
showNotification(`'${programName}'
|
460 |
}
|
461 |
} catch (error) {
|
462 |
-
console.error(
|
463 |
-
showNotification(`'${programName}'
|
464 |
}
|
465 |
}
|
466 |
|
467 |
/**
|
468 |
-
*
|
469 |
-
* @param {string} message -
|
470 |
-
* @param {string} type -
|
471 |
*/
|
472 |
function showNotification(message, type = 'info') {
|
473 |
-
// 기존
|
474 |
const existingNotification = document.querySelector('.notification');
|
475 |
if (existingNotification) {
|
476 |
existingNotification.remove();
|
477 |
}
|
478 |
|
479 |
-
//
|
480 |
const notification = document.createElement('div');
|
481 |
notification.classList.add('notification', type);
|
482 |
notification.textContent = message;
|
483 |
|
484 |
-
//
|
485 |
const closeButton = document.createElement('button');
|
486 |
closeButton.classList.add('notification-close');
|
487 |
closeButton.innerHTML = '×';
|
@@ -491,15 +502,16 @@ function showNotification(message, type = 'info') {
|
|
491 |
|
492 |
notification.appendChild(closeButton);
|
493 |
|
494 |
-
//
|
495 |
document.body.appendChild(notification);
|
496 |
|
497 |
-
//
|
498 |
setTimeout(() => {
|
499 |
if (document.body.contains(notification)) {
|
500 |
notification.remove();
|
501 |
}
|
502 |
-
}, 5000); // 5
|
|
|
503 |
|
504 |
-
// checkDeviceStatus
|
505 |
-
window.checkDeviceStatus = checkDeviceStatus;
|
|
|
1 |
/**
|
2 |
+
* RAG 검색 챗봇 장치 관리 JavaScript
|
3 |
*/
|
4 |
|
5 |
+
// DOM 요소
|
6 |
const deviceTab = document.getElementById('deviceTab');
|
7 |
const deviceSection = document.getElementById('deviceSection');
|
8 |
const checkDeviceStatusButton = document.getElementById('checkDeviceStatusButton');
|
|
|
19 |
const programsList = document.getElementById('programsList');
|
20 |
const noProgramsMessage = document.getElementById('noProgramsMessage');
|
21 |
|
22 |
+
// 장치 서버 URL 설정
|
23 |
+
// 직접 접근 설정: 빈 문자열이면 현재 호스트의 5050 포트를 사용
|
24 |
+
let DEVICE_SERVER_URL = ''; // 서버에서 전달된 URL이 있으면 초기화 시 설정됨
|
25 |
+
|
26 |
/**
|
27 |
+
* 장치 서버 API 경로 생성 함수
|
28 |
+
* @param {string} endpoint - API 엔드포인트 경로
|
29 |
+
* @returns {string} - 완전한 API URL
|
30 |
*/
|
31 |
function getDeviceApiUrl(endpoint) {
|
32 |
+
// 직접 접근 모드인 경우 (별도 서버에 직접 요청)
|
33 |
if (DEVICE_SERVER_URL) {
|
34 |
return `${DEVICE_SERVER_URL}${endpoint}`;
|
35 |
}
|
36 |
|
37 |
+
// 프록시 모드인 경우 (내부 API 경로 사용)
|
38 |
return endpoint;
|
39 |
}
|
40 |
|
41 |
/**
|
42 |
+
* 장치 서버 설정 초기화 (페이지 로드 시 호출)
|
43 |
*/
|
44 |
function initDeviceServerSettings() {
|
45 |
+
console.log("장치 서버 설정 초기화 시작");
|
46 |
+
// 서버 URL 설정 (웹 서버로부터 설정 불러오기)
|
47 |
fetch('/api/device/settings')
|
48 |
.then(response => {
|
49 |
if (!response.ok) {
|
50 |
+
throw new Error('설정을 가져올 수 없습니다');
|
51 |
}
|
52 |
return response.json();
|
53 |
})
|
54 |
.then(data => {
|
55 |
if (data.server_url) {
|
56 |
+
console.log(`장치 서버 URL 설정됨: ${data.server_url}`);
|
57 |
DEVICE_SERVER_URL = data.server_url;
|
58 |
} else {
|
59 |
+
// 설정 없음 - 자동 생성 (현재 호스트 + 포트 5050)
|
60 |
const currentHost = window.location.hostname;
|
61 |
const protocol = window.location.protocol;
|
62 |
+
DEVICE_SERVER_URL = `${protocol}//${currentHost}:5050`;
|
63 |
+
console.log(`장치 서버 URL 자동 설정: ${DEVICE_SERVER_URL}`);
|
64 |
}
|
65 |
})
|
66 |
.catch(error => {
|
67 |
+
console.error('장치 서버 설정 초기화 오류:', error);
|
68 |
+
// 기본값으로 설정 (현재 호스트 + 포트 5050)
|
69 |
const currentHost = window.location.hostname;
|
70 |
const protocol = window.location.protocol;
|
71 |
+
DEVICE_SERVER_URL = `${protocol}//${currentHost}:5050`;
|
72 |
+
console.log(`장치 서버 URL 기본값 설정: ${DEVICE_SERVER_URL}`);
|
73 |
});
|
74 |
}
|
75 |
|
76 |
+
// 페이지 로드 시 초기화
|
77 |
+
document.addEventListener('DOMContentLoaded', () => {
|
78 |
+
console.log("장치 관리 모듈 초기화");
|
79 |
|
80 |
+
// 장치 서버 설정 초기화
|
81 |
+
initDeviceServerSettings();
|
82 |
|
83 |
+
// 탭 전환 이벤트 리스너는 이미 app.js에서 등록되어 있으므로 여기서는 등록하지 않음
|
84 |
+
// 대신 전역 함수가 올바르게 설정되어 있는지 확인
|
85 |
if (typeof window.switchTab !== 'function') {
|
86 |
+
console.log("window.switchTab 함수가 정의되지 않았습니다. 내부 구현을 사용합니다.");
|
87 |
} else {
|
88 |
+
console.log("window.switchTab 함수 사용 가능합니다.");
|
89 |
}
|
90 |
|
91 |
+
// 장치 상태 확인 버튼 이벤트 리스너
|
92 |
+
checkDeviceStatusButton.addEventListener('click', () => {
|
93 |
+
console.log("장치 상태 확인 버튼 클릭");
|
94 |
checkDeviceStatus();
|
95 |
});
|
96 |
|
97 |
+
// 장치 목록 새로고침 버튼 이벤트 리스너
|
98 |
+
refreshDevicesButton.addEventListener('click', () => {
|
99 |
+
console.log("장치 목록 새로고침 버튼 클릭");
|
100 |
loadDevices();
|
101 |
});
|
102 |
|
103 |
+
// 프로그램 목록 로드 버튼 이벤트 리스너
|
104 |
+
loadProgramsButton.addEventListener('click', () => {
|
105 |
+
console.log("프로그램 목록 로드 버튼 클릭");
|
106 |
loadPrograms();
|
107 |
});
|
108 |
});
|
109 |
|
110 |
/**
|
111 |
+
* 에러 처리 헬퍼 함수
|
112 |
+
* @param {Error} error - 발생한 오류
|
113 |
+
* @returns {string} - 사용자에게 표시할 오류 메시지
|
114 |
*/
|
115 |
function handleError(error) {
|
116 |
+
console.error("오류 발생:", error);
|
117 |
|
118 |
if (error.name === 'AbortError') {
|
119 |
+
return '요청 시간이 초과되었습니다. 서버가 응답하지 않습니다.';
|
120 |
}
|
121 |
|
122 |
if (error.message && (error.message.includes('NetworkError') || error.message.includes('Failed to fetch'))) {
|
123 |
+
return '네트워크 오류가 발생했습니다. 서버에 연결할 수 없습니다.';
|
124 |
}
|
125 |
|
126 |
+
return `오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`;
|
127 |
}
|
128 |
|
129 |
/**
|
130 |
+
* HTML 이스케이프 함수 (XSS 방지)
|
131 |
+
* @param {string} unsafe - 이스케이프 전 문자열
|
132 |
+
* @returns {string} - 이스케이프 후 문자열
|
133 |
+
*/
|
134 |
function escapeHtml(unsafe) {
|
135 |
if (typeof unsafe !== 'string') return unsafe;
|
136 |
return unsafe
|
|
|
142 |
}
|
143 |
|
144 |
/**
|
145 |
+
* 장치 관리 서버 상태 확인 함수 - 전역 함수로 export
|
146 |
*/
|
147 |
async function checkDeviceStatus() {
|
148 |
+
console.log("장치 상태 확인 중...");
|
149 |
|
150 |
+
// UI 업데이트
|
151 |
deviceStatusResult.classList.add('hidden');
|
152 |
deviceStatusLoading.classList.remove('hidden');
|
153 |
|
154 |
try {
|
155 |
+
// 타임아웃 설정을 위한 컨트롤러
|
156 |
const controller = new AbortController();
|
157 |
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
|
158 |
+
|
159 |
+
// API 요청 - 수정된 URL 구성 사용
|
160 |
const response = await fetch(getDeviceApiUrl('/api/status'), {
|
161 |
signal: controller.signal
|
162 |
});
|
163 |
|
164 |
+
clearTimeout(timeoutId); // 타임아웃 해제
|
165 |
|
166 |
+
// 응답 처리
|
167 |
if (!response.ok) {
|
168 |
+
throw new Error(`HTTP 오류: ${response.status}`);
|
169 |
}
|
170 |
|
171 |
const data = await response.json();
|
172 |
+
console.log("장치 상태 응답:", data);
|
173 |
|
174 |
+
// UI 업데이트
|
175 |
deviceStatusLoading.classList.add('hidden');
|
176 |
deviceStatusResult.classList.remove('hidden');
|
177 |
|
178 |
if (data.status === "online") {
|
179 |
+
// 온라인 상태인 경우
|
180 |
statusIcon.innerHTML = '<i class="fas fa-circle online"></i>';
|
181 |
+
statusText.textContent = `서버 상태: ${data.status || '정상'}`;
|
182 |
|
183 |
+
// 자동으로 ���치 목록 로드
|
184 |
loadDevices();
|
185 |
} else {
|
186 |
+
// 오프라인 또는 오류 상태인 경우
|
187 |
statusIcon.innerHTML = '<i class="fas fa-circle offline"></i>';
|
188 |
+
statusText.textContent = `서버 오류: ${data.error || '알 수 없는 오류'}`;
|
189 |
}
|
190 |
} catch (error) {
|
191 |
+
console.error("장치 상태 확인 오류:", error);
|
192 |
|
193 |
+
// UI 업데이트
|
194 |
deviceStatusLoading.classList.add('hidden');
|
195 |
deviceStatusResult.classList.remove('hidden');
|
196 |
|
|
|
200 |
}
|
201 |
|
202 |
/**
|
203 |
+
* 장치 목록 로드 함수
|
204 |
*/
|
205 |
async function loadDevices() {
|
206 |
+
console.log("장치 목록 로드 중...");
|
207 |
|
208 |
+
// UI 업데이트
|
209 |
deviceList.innerHTML = '';
|
210 |
noDevicesMessage.classList.add('hidden');
|
211 |
devicesLoading.classList.remove('hidden');
|
212 |
|
213 |
try {
|
214 |
+
// 타임아웃 설정을 위한 컨트롤러
|
215 |
const controller = new AbortController();
|
216 |
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
|
217 |
+
|
218 |
+
// API 요청 - 수정된 URL 구성 사용
|
219 |
const response = await fetch(getDeviceApiUrl('/api/devices'), {
|
220 |
signal: controller.signal
|
221 |
});
|
222 |
|
223 |
+
clearTimeout(timeoutId); // 타임아웃 해제
|
224 |
|
225 |
+
// 응답 처리
|
226 |
if (!response.ok) {
|
227 |
+
throw new Error(`HTTP 오류: ${response.status}`);
|
228 |
}
|
229 |
|
230 |
const data = await response.json();
|
231 |
+
console.log("장치 목록 응답:", data);
|
232 |
|
233 |
+
// UI 업데이트
|
234 |
devicesLoading.classList.add('hidden');
|
235 |
|
236 |
if (data.devices && data.devices.length > 0) {
|
237 |
+
// 장치 목록 표시
|
238 |
data.devices.forEach(device => {
|
239 |
const deviceElement = createDeviceItem(device);
|
240 |
deviceList.appendChild(deviceElement);
|
241 |
});
|
242 |
} else {
|
243 |
+
// 장치 없음 메시지 표시
|
244 |
noDevicesMessage.classList.remove('hidden');
|
245 |
}
|
246 |
} catch (error) {
|
247 |
+
console.error("장치 목록 로드 오류:", error);
|
248 |
|
249 |
+
// UI 업데이트
|
250 |
devicesLoading.classList.add('hidden');
|
251 |
noDevicesMessage.classList.remove('hidden');
|
252 |
noDevicesMessage.textContent = handleError(error);
|
|
|
254 |
}
|
255 |
|
256 |
/**
|
257 |
+
* 장치 아이템 생성 함수
|
258 |
+
* @param {Object} device - 장치 정보 객체
|
259 |
+
* @returns {HTMLElement} - 장치 아이템 DOM 요소
|
260 |
*/
|
261 |
function createDeviceItem(device) {
|
262 |
const deviceItem = document.createElement('div');
|
263 |
deviceItem.classList.add('device-item');
|
264 |
|
265 |
+
// 상태에 따른 클래스 추가
|
266 |
+
if (device.status === 'online' || device.status === '온라인') {
|
267 |
deviceItem.classList.add('online');
|
268 |
+
} else if (device.status === 'offline' || device.status === '오프라인') {
|
269 |
deviceItem.classList.add('offline');
|
270 |
} else if (device.status === 'warning' || device.status === '경고') {
|
271 |
deviceItem.classList.add('warning');
|
272 |
}
|
273 |
|
274 |
+
// 장치 헤더 (이름 및 상태)
|
275 |
const deviceHeader = document.createElement('div');
|
276 |
deviceHeader.classList.add('device-item-header');
|
277 |
|
278 |
const deviceName = document.createElement('div');
|
279 |
deviceName.classList.add('device-name');
|
280 |
+
deviceName.textContent = device.name || '알 수 없는 장치';
|
281 |
|
282 |
const deviceStatusBadge = document.createElement('div');
|
283 |
deviceStatusBadge.classList.add('device-status-badge');
|
284 |
|
285 |
+
// 상태에 따른 배지 설정
|
286 |
+
if (device.status === 'online' || device.status === '온라인') {
|
287 |
deviceStatusBadge.classList.add('online');
|
288 |
+
deviceStatusBadge.textContent = '온라인';
|
289 |
+
} else if (device.status === 'offline' || device.status === '오프라인') {
|
290 |
deviceStatusBadge.classList.add('offline');
|
291 |
+
deviceStatusBadge.textContent = '오프라인';
|
292 |
} else if (device.status === 'warning' || device.status === '경고') {
|
293 |
deviceStatusBadge.classList.add('warning');
|
294 |
deviceStatusBadge.textContent = '경고';
|
295 |
} else {
|
296 |
+
deviceStatusBadge.textContent = device.status || '알 수 없음';
|
297 |
}
|
298 |
|
299 |
deviceHeader.appendChild(deviceName);
|
300 |
deviceHeader.appendChild(deviceStatusBadge);
|
301 |
|
302 |
+
// 장치 정보
|
303 |
const deviceInfo = document.createElement('div');
|
304 |
deviceInfo.classList.add('device-info');
|
305 |
+
deviceInfo.textContent = `유형: ${device.type || '알 수 없음'}`;
|
306 |
|
307 |
+
// 장치 세부 정보
|
308 |
const deviceDetails = document.createElement('div');
|
309 |
deviceDetails.classList.add('device-details');
|
310 |
|
311 |
+
// 추가 정보가 있는 경우 표시
|
312 |
if (device.ip) {
|
313 |
deviceDetails.textContent += `IP: ${device.ip}`;
|
314 |
}
|
|
|
316 |
deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') + `MAC: ${device.mac}`;
|
317 |
}
|
318 |
if (device.lastSeen) {
|
319 |
+
deviceDetails.textContent += (deviceDetails.textContent ? ', ' : '') + `마지막 활동: ${device.lastSeen}`;
|
320 |
}
|
321 |
|
322 |
+
// 아이템에 요소 추가
|
323 |
deviceItem.appendChild(deviceHeader);
|
324 |
deviceItem.appendChild(deviceInfo);
|
325 |
|
|
|
331 |
}
|
332 |
|
333 |
/**
|
334 |
+
* 프로그램 목록 로드 함수
|
335 |
*/
|
336 |
async function loadPrograms() {
|
337 |
+
console.log("프로그램 목록 로드 중...");
|
338 |
|
339 |
+
// UI 업데이트
|
340 |
programsList.innerHTML = '';
|
341 |
noProgramsMessage.classList.add('hidden');
|
342 |
programsLoading.classList.remove('hidden');
|
343 |
|
344 |
try {
|
345 |
+
// 타임아웃 설정을 위한 컨트롤러
|
346 |
const controller = new AbortController();
|
347 |
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5초 타임아웃
|
348 |
+
|
349 |
+
// API 요청 - 수정된 URL 구성 사용
|
350 |
const response = await fetch(getDeviceApiUrl('/api/programs'), {
|
351 |
signal: controller.signal
|
352 |
});
|
353 |
|
354 |
+
clearTimeout(timeoutId); // 타임아웃 해제
|
355 |
|
356 |
+
// 응답 처리
|
357 |
if (!response.ok) {
|
358 |
+
throw new Error(`HTTP 오류: ${response.status}`);
|
359 |
}
|
360 |
|
361 |
const data = await response.json();
|
362 |
+
console.log("프로그램 목록 응답:", data);
|
363 |
|
364 |
+
// UI 업데이트
|
365 |
programsLoading.classList.add('hidden');
|
366 |
|
367 |
if (data.programs && data.programs.length > 0) {
|
368 |
+
// 프로그램 목록 표시
|
369 |
data.programs.forEach(program => {
|
370 |
const programElement = createProgramItem(program);
|
371 |
programsList.appendChild(programElement);
|
372 |
});
|
373 |
} else {
|
374 |
+
// 프로그램 없음 메시지 표시
|
375 |
noProgramsMessage.classList.remove('hidden');
|
376 |
}
|
377 |
} catch (error) {
|
378 |
+
console.error("프로그램 목록 로드 오류:", error);
|
379 |
|
380 |
+
// UI 업데이트
|
381 |
programsLoading.classList.add('hidden');
|
382 |
noProgramsMessage.classList.remove('hidden');
|
383 |
noProgramsMessage.textContent = handleError(error);
|
|
|
385 |
}
|
386 |
|
387 |
/**
|
388 |
+
* 프로그램 아이템 생성 함���
|
389 |
+
* @param {Object} program - 프로그램 정보 객체
|
390 |
+
* @returns {HTMLElement} - 프로그램 아이템 DOM 요소
|
391 |
*/
|
392 |
function createProgramItem(program) {
|
393 |
const programItem = document.createElement('div');
|
394 |
programItem.classList.add('program-item');
|
395 |
|
396 |
+
// 프로그램 헤더 (이름)
|
397 |
const programHeader = document.createElement('div');
|
398 |
programHeader.classList.add('program-item-header');
|
399 |
|
400 |
const programName = document.createElement('div');
|
401 |
programName.classList.add('program-name');
|
402 |
+
programName.textContent = program.name || '알 수 없는 프로그램';
|
403 |
|
404 |
programHeader.appendChild(programName);
|
405 |
|
406 |
+
// 프로그램 설명
|
407 |
if (program.description) {
|
408 |
const programDescription = document.createElement('div');
|
409 |
programDescription.classList.add('program-description');
|
|
|
411 |
programItem.appendChild(programDescription);
|
412 |
}
|
413 |
|
414 |
+
// 실행 버튼
|
415 |
const executeButton = document.createElement('button');
|
416 |
executeButton.classList.add('execute-btn');
|
417 |
+
executeButton.textContent = '실행';
|
418 |
executeButton.addEventListener('click', () => {
|
419 |
executeProgram(program.id, program.name);
|
420 |
});
|
421 |
|
422 |
+
// 아이템에 요소 추가
|
423 |
programItem.appendChild(programHeader);
|
424 |
programItem.appendChild(executeButton);
|
425 |
|
|
|
427 |
}
|
428 |
|
429 |
/**
|
430 |
+
* 프로그램 실행 함수
|
431 |
+
* @param {string} programId - 실행할 프로그램 ID
|
432 |
+
* @param {string} programName - 프로그램 이름 (알림용)
|
433 |
*/
|
434 |
async function executeProgram(programId, programName) {
|
435 |
if (!programId) return;
|
436 |
|
437 |
+
console.log(`프로그램 실행 요청: ${programId} (${programName})`);
|
438 |
|
439 |
+
// 실행 확인
|
440 |
+
if (!confirm(`'${programName}' 프로그램을 실행하시겠습니까?`)) {
|
441 |
+
console.log('프로그램 실행 취소됨');
|
442 |
return;
|
443 |
}
|
444 |
|
445 |
try {
|
446 |
+
// 로딩 알림 표시
|
447 |
+
showNotification(`'${programName}' 실행 중...`, 'info');
|
448 |
|
449 |
+
// API 요청 - 수정된 URL 구성 사용
|
450 |
const response = await fetch(getDeviceApiUrl(`/api/programs/${programId}/execute`), {
|
451 |
method: 'POST',
|
452 |
headers: {
|
453 |
'Content-Type': 'application/json'
|
454 |
},
|
455 |
+
body: JSON.stringify({}) // 필요 시 추가 파라미터 전달
|
456 |
});
|
457 |
|
458 |
+
// 응답 처리
|
459 |
if (!response.ok) {
|
460 |
+
throw new Error(`HTTP 오류: ${response.status}`);
|
461 |
}
|
462 |
|
463 |
const data = await response.json();
|
464 |
+
console.log(`프로그램 실행 응답:`, data);
|
465 |
|
466 |
// 결과 처리
|
467 |
if (data.success) {
|
468 |
+
showNotification(`'${programName}' 실행 성공: ${data.message}`, 'success');
|
469 |
} else {
|
470 |
+
showNotification(`'${programName}' 실행 실패: ${data.message || '알 수 없는 오류'}`, 'error');
|
471 |
}
|
472 |
} catch (error) {
|
473 |
+
console.error(`프로그램 실행 오류 (${programId}):`, error);
|
474 |
+
showNotification(`'${programName}' 실행 오류: ${handleError(error)}`, 'error');
|
475 |
}
|
476 |
}
|
477 |
|
478 |
/**
|
479 |
+
* 알림 표시 함수
|
480 |
+
* @param {string} message - 알림 메시지
|
481 |
+
* @param {string} type - 알림 유형 ('success', 'error', 'warning')
|
482 |
*/
|
483 |
function showNotification(message, type = 'info') {
|
484 |
+
// 기존 알림이 있으면 제거
|
485 |
const existingNotification = document.querySelector('.notification');
|
486 |
if (existingNotification) {
|
487 |
existingNotification.remove();
|
488 |
}
|
489 |
|
490 |
+
// 새 알림 생성
|
491 |
const notification = document.createElement('div');
|
492 |
notification.classList.add('notification', type);
|
493 |
notification.textContent = message;
|
494 |
|
495 |
+
// 알림 닫기 버튼
|
496 |
const closeButton = document.createElement('button');
|
497 |
closeButton.classList.add('notification-close');
|
498 |
closeButton.innerHTML = '×';
|
|
|
502 |
|
503 |
notification.appendChild(closeButton);
|
504 |
|
505 |
+
// 문서에 알림 추가
|
506 |
document.body.appendChild(notification);
|
507 |
|
508 |
+
// 일정 시간 후 자동으로 사라지도록 설정
|
509 |
setTimeout(() => {
|
510 |
if (document.body.contains(notification)) {
|
511 |
notification.remove();
|
512 |
}
|
513 |
+
}, 5000); // 5초 후 사라짐
|
514 |
+
}
|
515 |
|
516 |
+
// checkDeviceStatus 함수를 전역으로 노출
|
517 |
+
window.checkDeviceStatus = checkDeviceStatus;
|