feat(animal-chat): 로깅 시스템 구축
- SQLite DB: animal_chat_logs.db - 로거 모듈: utils/animal_chat_logger.py - 단계별 로깅: - MSSQL (보유 동물약): 개수, 소요시간 - PostgreSQL (RAG): 개수, 소요시간 - LanceDB (벡터 검색): 상위 N개, 유사도, 소스, 소요시간 - OpenAI: 모델, 토큰(입력/출력), 비용, 소요시간 - Admin 페이지: /admin/animal-chat-logs - API: /api/animal-chat-logs - 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용
This commit is contained in:
parent
be1e6c2bb7
commit
5d7a8fc3f4
@ -3142,6 +3142,13 @@ def api_animal_chat():
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
from utils.animal_chat_logger import ChatLogEntry, log_chat
|
||||
|
||||
# 로그 엔트리 초기화
|
||||
log_entry = ChatLogEntry()
|
||||
total_start = time.time()
|
||||
|
||||
if not OPENAI_AVAILABLE:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
@ -3157,12 +3164,24 @@ def api_animal_chat():
|
||||
'message': '메시지를 입력해주세요.'
|
||||
}), 400
|
||||
|
||||
# 보유 동물약 목록 조회
|
||||
animal_drugs = _get_animal_drugs()
|
||||
# 입력 로깅
|
||||
last_user_msg = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||
log_entry.user_message = last_user_msg
|
||||
log_entry.history_length = len(messages)
|
||||
log_entry.session_id = data.get('session_id', '')
|
||||
|
||||
# APC가 있는 제품의 상세 정보 조회 (RAG)
|
||||
# 보유 동물약 목록 조회 (MSSQL)
|
||||
mssql_start = time.time()
|
||||
animal_drugs = _get_animal_drugs()
|
||||
log_entry.mssql_drug_count = len(animal_drugs)
|
||||
log_entry.mssql_duration_ms = int((time.time() - mssql_start) * 1000)
|
||||
|
||||
# APC가 있는 제품의 상세 정보 조회 (PostgreSQL RAG)
|
||||
pgsql_start = time.time()
|
||||
apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')]
|
||||
rag_data = _get_animal_drug_rag(apc_codes) if apc_codes else {}
|
||||
log_entry.pgsql_rag_count = len(rag_data)
|
||||
log_entry.pgsql_duration_ms = int((time.time() - pgsql_start) * 1000)
|
||||
|
||||
available_products_text = ""
|
||||
if animal_drugs:
|
||||
@ -3194,15 +3213,19 @@ def api_animal_chat():
|
||||
|
||||
# 벡터 DB 검색 (LanceDB RAG)
|
||||
vector_context = ""
|
||||
vector_start = time.time()
|
||||
try:
|
||||
from utils.animal_rag import get_animal_rag
|
||||
# 마지막 사용자 메시지로 검색
|
||||
last_user_msg = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||
if last_user_msg:
|
||||
rag = get_animal_rag()
|
||||
vector_results = rag.search(last_user_msg, n_results=3)
|
||||
log_entry.vector_results_count = len(vector_results)
|
||||
log_entry.vector_top_scores = [r.get('score', 0) for r in vector_results]
|
||||
log_entry.vector_sources = [f"{r.get('source', '')}#{r.get('section', '')}" for r in vector_results]
|
||||
vector_context = rag.get_context_for_chat(last_user_msg, n_results=3)
|
||||
except Exception as e:
|
||||
logging.warning(f"벡터 검색 실패 (무시): {e}")
|
||||
log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000)
|
||||
|
||||
# System Prompt 구성
|
||||
system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format(
|
||||
@ -3211,6 +3234,7 @@ def api_animal_chat():
|
||||
)
|
||||
|
||||
# OpenAI API 호출
|
||||
openai_start = time.time()
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
api_messages = [{"role": "system", "content": system_prompt}]
|
||||
@ -3229,6 +3253,13 @@ def api_animal_chat():
|
||||
|
||||
ai_response = response.choices[0].message.content
|
||||
|
||||
# OpenAI 로깅
|
||||
log_entry.openai_model = OPENAI_MODEL
|
||||
log_entry.openai_prompt_tokens = response.usage.prompt_tokens
|
||||
log_entry.openai_completion_tokens = response.usage.completion_tokens
|
||||
log_entry.openai_total_tokens = response.usage.total_tokens
|
||||
log_entry.openai_duration_ms = int((time.time() - openai_start) * 1000)
|
||||
|
||||
# 응답에서 언급된 보유 제품 찾기 (부분 매칭)
|
||||
mentioned_products = []
|
||||
# 공백 제거한 버전도 준비 (AI가 띄어쓰기 넣을 수 있음)
|
||||
@ -3289,6 +3320,12 @@ def api_animal_chat():
|
||||
'category': drug.get('category') # 분류 (내부구충제, 심장사상충약 등)
|
||||
})
|
||||
|
||||
# 최종 로깅
|
||||
log_entry.assistant_response = ai_response
|
||||
log_entry.products_mentioned = [p['name'] for p in mentioned_products[:5]]
|
||||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||||
log_chat(log_entry)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': ai_response,
|
||||
@ -3299,18 +3336,27 @@ def api_animal_chat():
|
||||
}
|
||||
})
|
||||
|
||||
except RateLimitError:
|
||||
except RateLimitError as e:
|
||||
log_entry.error = f"RateLimitError: {e}"
|
||||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||||
log_chat(log_entry)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'AI 사용량 한도에 도달했습니다. 잠시 후 다시 시도해주세요.'
|
||||
}), 429
|
||||
except APITimeoutError:
|
||||
except APITimeoutError as e:
|
||||
log_entry.error = f"APITimeoutError: {e}"
|
||||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||||
log_chat(log_entry)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'AI 응답 시간이 초과되었습니다. 다시 시도해주세요.'
|
||||
}), 504
|
||||
except Exception as e:
|
||||
logging.error(f"동물약 챗봇 오류: {e}")
|
||||
log_entry.error = str(e)
|
||||
log_entry.total_duration_ms = int((time.time() - total_start) * 1000)
|
||||
log_chat(log_entry)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류가 발생했습니다: {str(e)}'
|
||||
@ -8008,6 +8054,44 @@ def mobile_upload_page(session_id):
|
||||
''', session_id=session_id, barcode=barcode)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 동물약 챗봇 로그 API
|
||||
# ============================================
|
||||
|
||||
@app.route('/admin/animal-chat-logs')
|
||||
def admin_animal_chat_logs():
|
||||
"""동물약 챗봇 로그 페이지"""
|
||||
return render_template('admin_animal_chat_logs.html')
|
||||
|
||||
|
||||
@app.route('/api/animal-chat-logs')
|
||||
def api_animal_chat_logs():
|
||||
"""동물약 챗봇 로그 조회 API"""
|
||||
from utils.animal_chat_logger import get_logs, get_stats
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
error_only = request.args.get('error_only') == 'true'
|
||||
limit = int(request.args.get('limit', 100))
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
logs = get_logs(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
error_only=error_only
|
||||
)
|
||||
|
||||
stats = get_stats(date_from=date_from, date_to=date_to)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'logs': logs,
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
47
backend/db/animal_chat_logs_schema.sql
Normal file
47
backend/db/animal_chat_logs_schema.sql
Normal file
@ -0,0 +1,47 @@
|
||||
-- 동물약 챗봇 로그 스키마
|
||||
-- 생성일: 2026-03-08
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
|
||||
-- 입력
|
||||
user_message TEXT,
|
||||
history_length INTEGER,
|
||||
|
||||
-- MSSQL (보유 동물약)
|
||||
mssql_drug_count INTEGER,
|
||||
mssql_duration_ms INTEGER,
|
||||
|
||||
-- PostgreSQL (RAG)
|
||||
pgsql_rag_count INTEGER,
|
||||
pgsql_duration_ms INTEGER,
|
||||
|
||||
-- LanceDB (벡터 검색)
|
||||
vector_results_count INTEGER,
|
||||
vector_top_scores TEXT, -- JSON: [0.92, 0.85, 0.78]
|
||||
vector_sources TEXT, -- JSON: ["file1.md#section", ...]
|
||||
vector_duration_ms INTEGER,
|
||||
|
||||
-- OpenAI
|
||||
openai_model TEXT,
|
||||
openai_prompt_tokens INTEGER,
|
||||
openai_completion_tokens INTEGER,
|
||||
openai_total_tokens INTEGER,
|
||||
openai_cost_usd REAL,
|
||||
openai_duration_ms INTEGER,
|
||||
|
||||
-- 출력
|
||||
assistant_response TEXT,
|
||||
products_mentioned TEXT, -- JSON array
|
||||
|
||||
-- 메타
|
||||
total_duration_ms INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_session ON chat_logs(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_error ON chat_logs(error);
|
||||
597
backend/templates/admin_animal_chat_logs.html
Normal file
597
backend/templates/admin_animal_chat_logs.html
Normal file
@ -0,0 +1,597 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>동물약 챗봇 로그 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 컨텐츠 */
|
||||
.content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.green { color: #10b981; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
.stat-value.red { color: #ef4444; }
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn.primary {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.filter-btn.primary:hover { background: #059669; }
|
||||
.filter-btn.secondary {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 테이블 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
tr:hover { background: #f8fafc; }
|
||||
tr.error { background: #fef2f2; }
|
||||
.time-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.token-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.cost-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.duration-cell {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.error-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-item {
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.message-box {
|
||||
background: #f8fafc;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.message-box.user {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.message-box.assistant {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/products">제품 관리</a>
|
||||
</div>
|
||||
<h1>🐾 동물약 챗봇 로그</h1>
|
||||
<p>RAG 기반 동물약 상담 기록 · 토큰 사용량 · 비용 분석</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 대화</div>
|
||||
<div class="stat-value green" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 응답시간</div>
|
||||
<div class="stat-value blue" id="statDuration">-</div>
|
||||
<div class="stat-sub">ms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 토큰</div>
|
||||
<div class="stat-value" id="statTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 비용</div>
|
||||
<div class="stat-value orange" id="statCost">-</div>
|
||||
<div class="stat-sub">USD</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 벡터 검색</div>
|
||||
<div class="stat-value blue" id="statVector">-</div>
|
||||
<div class="stat-sub">ms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">에러</div>
|
||||
<div class="stat-value red" id="statErrors">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filter-bar">
|
||||
<input type="date" class="filter-input" id="dateFrom" />
|
||||
<span style="color:#94a3b8;">~</span>
|
||||
<input type="date" class="filter-input" id="dateTo" />
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" id="errorOnly" />
|
||||
에러만 보기
|
||||
</label>
|
||||
<button class="filter-btn primary" onclick="loadLogs()">검색</button>
|
||||
<button class="filter-btn secondary" onclick="resetFilters()">초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>질문</th>
|
||||
<th>응답</th>
|
||||
<th>토큰</th>
|
||||
<th>비용</th>
|
||||
<th>소요시간</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTable">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">
|
||||
<div class="spinner"></div>
|
||||
로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal" id="detailModal" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔍 대화 상세</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 동적 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 초기 로드
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 기본 날짜: 오늘
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('dateTo').value = today;
|
||||
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
async function loadLogs() {
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
const errorOnly = document.getElementById('errorOnly').checked;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.append('date_from', dateFrom);
|
||||
if (dateTo) params.append('date_to', dateTo);
|
||||
if (errorOnly) params.append('error_only', 'true');
|
||||
params.append('limit', '200');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/animal-chat-logs?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderStats(data.stats);
|
||||
renderLogs(data.logs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그 조회 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
document.getElementById('statTotal').textContent = (stats.total_chats || 0).toLocaleString();
|
||||
document.getElementById('statDuration').textContent = (stats.avg_duration_ms || 0).toLocaleString();
|
||||
document.getElementById('statTokens').textContent = (stats.total_tokens || 0).toLocaleString();
|
||||
document.getElementById('statCost').textContent = '$' + (stats.total_cost_usd || 0).toFixed(4);
|
||||
document.getElementById('statVector').textContent = (stats.avg_vector_ms || 0).toLocaleString();
|
||||
document.getElementById('statErrors').textContent = stats.error_count || 0;
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logsTable');
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:#94a3b8;">로그가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = logs.map(log => `
|
||||
<tr class="${log.error ? 'error' : ''}" onclick='showDetail(${JSON.stringify(log).replace(/'/g, "'")})' style="cursor:pointer;">
|
||||
<td class="time-cell">${formatTime(log.created_at)}</td>
|
||||
<td class="msg-cell" title="${escapeHtml(log.user_message || '')}">${escapeHtml(truncate(log.user_message, 40))}</td>
|
||||
<td class="msg-cell" title="${escapeHtml(log.assistant_response || '')}">${escapeHtml(truncate(log.assistant_response, 50))}</td>
|
||||
<td class="token-cell">${log.openai_total_tokens || 0}</td>
|
||||
<td class="cost-cell">$${(log.openai_cost_usd || 0).toFixed(4)}</td>
|
||||
<td class="duration-cell">${log.total_duration_ms || 0}ms</td>
|
||||
<td>${log.error ? '<span class="error-badge">에러</span>' : '✅'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showDetail(log) {
|
||||
const vectorScores = JSON.parse(log.vector_top_scores || '[]');
|
||||
const vectorSources = JSON.parse(log.vector_sources || '[]');
|
||||
const products = JSON.parse(log.products_mentioned || '[]');
|
||||
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="detail-section">
|
||||
<h3>📊 처리 시간</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">MSSQL (보유 동물약)</div>
|
||||
<div class="detail-value">${log.mssql_duration_ms || 0}ms (${log.mssql_drug_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">PostgreSQL (RAG)</div>
|
||||
<div class="detail-value">${log.pgsql_duration_ms || 0}ms (${log.pgsql_rag_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">벡터 검색 (LanceDB)</div>
|
||||
<div class="detail-value">${log.vector_duration_ms || 0}ms (${log.vector_results_count || 0}개)</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">OpenAI API</div>
|
||||
<div class="detail-value">${log.openai_duration_ms || 0}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">총 소요시간</div>
|
||||
<div class="detail-value" style="color:#10b981;">${log.total_duration_ms || 0}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">모델</div>
|
||||
<div class="detail-value">${log.openai_model || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>🎯 토큰 & 비용</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">입력 토큰</div>
|
||||
<div class="detail-value">${log.openai_prompt_tokens || 0}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">출력 토큰</div>
|
||||
<div class="detail-value">${log.openai_completion_tokens || 0}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">비용</div>
|
||||
<div class="detail-value" style="color:#f59e0b;">$${(log.openai_cost_usd || 0).toFixed(6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${vectorSources.length > 0 ? `
|
||||
<div class="detail-section">
|
||||
<h3>📚 벡터 검색 결과</h3>
|
||||
<div style="font-size:13px;">
|
||||
${vectorSources.map((src, i) => `
|
||||
<div style="padding:8px 12px;background:#f0fdf4;border-radius:6px;margin-bottom:6px;">
|
||||
<strong>[${(vectorScores[i] * 100 || 0).toFixed(0)}%]</strong> ${src}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>💬 사용자 질문</h3>
|
||||
<div class="message-box user">${escapeHtml(log.user_message || '')}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>🤖 AI 응답</h3>
|
||||
<div class="message-box assistant">${escapeHtml(log.assistant_response || '')}</div>
|
||||
</div>
|
||||
|
||||
${products.length > 0 ? `
|
||||
<div class="detail-section">
|
||||
<h3>📦 언급된 제품</h3>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
${products.map(p => `<span style="background:#10b981;color:#fff;padding:4px 12px;border-radius:20px;font-size:13px;">${p}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${log.error ? `
|
||||
<div class="detail-section">
|
||||
<h3>⚠️ 에러</h3>
|
||||
<div class="message-box" style="background:#fef2f2;color:#dc2626;">${escapeHtml(log.error)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('detailModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('dateFrom').value = '';
|
||||
document.getElementById('dateTo').value = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('errorOnly').checked = false;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 유틸
|
||||
function formatTime(dt) {
|
||||
if (!dt) return '-';
|
||||
return dt.replace('T', ' ').substring(5, 16);
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
277
backend/utils/animal_chat_logger.py
Normal file
277
backend/utils/animal_chat_logger.py
Normal file
@ -0,0 +1,277 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동물약 챗봇 로깅 모듈
|
||||
- SQLite에 대화 로그 저장
|
||||
- 각 단계별 소요시간, 토큰, 비용 기록
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DB 경로
|
||||
DB_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs.db"
|
||||
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs_schema.sql"
|
||||
|
||||
# GPT-4o-mini 가격 (USD per 1K tokens)
|
||||
INPUT_COST_PER_1K = 0.00015 # $0.15 / 1M = $0.00015 / 1K
|
||||
OUTPUT_COST_PER_1K = 0.0006 # $0.60 / 1M = $0.0006 / 1K
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatLogEntry:
|
||||
"""챗봇 로그 엔트리"""
|
||||
session_id: str = ""
|
||||
|
||||
# 입력
|
||||
user_message: str = ""
|
||||
history_length: int = 0
|
||||
|
||||
# MSSQL
|
||||
mssql_drug_count: int = 0
|
||||
mssql_duration_ms: int = 0
|
||||
|
||||
# PostgreSQL
|
||||
pgsql_rag_count: int = 0
|
||||
pgsql_duration_ms: int = 0
|
||||
|
||||
# LanceDB
|
||||
vector_results_count: int = 0
|
||||
vector_top_scores: List[float] = field(default_factory=list)
|
||||
vector_sources: List[str] = field(default_factory=list)
|
||||
vector_duration_ms: int = 0
|
||||
|
||||
# OpenAI
|
||||
openai_model: str = ""
|
||||
openai_prompt_tokens: int = 0
|
||||
openai_completion_tokens: int = 0
|
||||
openai_total_tokens: int = 0
|
||||
openai_cost_usd: float = 0.0
|
||||
openai_duration_ms: int = 0
|
||||
|
||||
# 출력
|
||||
assistant_response: str = ""
|
||||
products_mentioned: List[str] = field(default_factory=list)
|
||||
|
||||
# 메타
|
||||
total_duration_ms: int = 0
|
||||
error: str = ""
|
||||
|
||||
def calculate_cost(self):
|
||||
"""토큰 기반 비용 계산"""
|
||||
self.openai_cost_usd = (
|
||||
self.openai_prompt_tokens * INPUT_COST_PER_1K / 1000 +
|
||||
self.openai_completion_tokens * OUTPUT_COST_PER_1K / 1000
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
try:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
|
||||
if SCHEMA_PATH.exists():
|
||||
schema = SCHEMA_PATH.read_text(encoding='utf-8')
|
||||
conn.executescript(schema)
|
||||
else:
|
||||
# 스키마 파일 없으면 인라인 생성
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
user_message TEXT,
|
||||
history_length INTEGER,
|
||||
mssql_drug_count INTEGER,
|
||||
mssql_duration_ms INTEGER,
|
||||
pgsql_rag_count INTEGER,
|
||||
pgsql_duration_ms INTEGER,
|
||||
vector_results_count INTEGER,
|
||||
vector_top_scores TEXT,
|
||||
vector_sources TEXT,
|
||||
vector_duration_ms INTEGER,
|
||||
openai_model TEXT,
|
||||
openai_prompt_tokens INTEGER,
|
||||
openai_completion_tokens INTEGER,
|
||||
openai_total_tokens INTEGER,
|
||||
openai_cost_usd REAL,
|
||||
openai_duration_ms INTEGER,
|
||||
assistant_response TEXT,
|
||||
products_mentioned TEXT,
|
||||
total_duration_ms INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_logs(created_at);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"동물약 챗봇 로그 DB 초기화: {DB_PATH}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DB 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def log_chat(entry: ChatLogEntry) -> Optional[int]:
|
||||
"""
|
||||
챗봇 대화 로그 저장
|
||||
|
||||
Returns:
|
||||
저장된 로그 ID (실패시 None)
|
||||
"""
|
||||
try:
|
||||
# 비용 계산
|
||||
entry.calculate_cost()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO chat_logs (
|
||||
session_id, user_message, history_length,
|
||||
mssql_drug_count, mssql_duration_ms,
|
||||
pgsql_rag_count, pgsql_duration_ms,
|
||||
vector_results_count, vector_top_scores, vector_sources, vector_duration_ms,
|
||||
openai_model, openai_prompt_tokens, openai_completion_tokens,
|
||||
openai_total_tokens, openai_cost_usd, openai_duration_ms,
|
||||
assistant_response, products_mentioned,
|
||||
total_duration_ms, error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
entry.session_id,
|
||||
entry.user_message,
|
||||
entry.history_length,
|
||||
entry.mssql_drug_count,
|
||||
entry.mssql_duration_ms,
|
||||
entry.pgsql_rag_count,
|
||||
entry.pgsql_duration_ms,
|
||||
entry.vector_results_count,
|
||||
json.dumps(entry.vector_top_scores),
|
||||
json.dumps(entry.vector_sources),
|
||||
entry.vector_duration_ms,
|
||||
entry.openai_model,
|
||||
entry.openai_prompt_tokens,
|
||||
entry.openai_completion_tokens,
|
||||
entry.openai_total_tokens,
|
||||
entry.openai_cost_usd,
|
||||
entry.openai_duration_ms,
|
||||
entry.assistant_response,
|
||||
json.dumps(entry.products_mentioned),
|
||||
entry.total_duration_ms,
|
||||
entry.error
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.debug(f"챗봇 로그 저장: ID={log_id}, tokens={entry.openai_total_tokens}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"로그 저장 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_logs(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
date_from: str = None,
|
||||
date_to: str = None,
|
||||
error_only: bool = False
|
||||
) -> List[Dict]:
|
||||
"""로그 조회"""
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM chat_logs WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
query += " AND created_at <= ?"
|
||||
params.append(date_to + " 23:59:59")
|
||||
if error_only:
|
||||
query += " AND error IS NOT NULL AND error != ''"
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"로그 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_stats(date_from: str = None, date_to: str = None) -> Dict:
|
||||
"""통계 조회"""
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_chats,
|
||||
AVG(total_duration_ms) as avg_duration_ms,
|
||||
SUM(openai_total_tokens) as total_tokens,
|
||||
SUM(openai_cost_usd) as total_cost_usd,
|
||||
AVG(openai_total_tokens) as avg_tokens,
|
||||
SUM(CASE WHEN error IS NOT NULL AND error != '' THEN 1 ELSE 0 END) as error_count,
|
||||
AVG(vector_duration_ms) as avg_vector_ms,
|
||||
AVG(openai_duration_ms) as avg_openai_ms
|
||||
FROM chat_logs
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
query += " AND created_at <= ?"
|
||||
params.append(date_to + " 23:59:59")
|
||||
|
||||
cursor.execute(query, params)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return {
|
||||
'total_chats': row[0] or 0,
|
||||
'avg_duration_ms': round(row[1] or 0),
|
||||
'total_tokens': row[2] or 0,
|
||||
'total_cost_usd': round(row[3] or 0, 4),
|
||||
'avg_tokens': round(row[4] or 0),
|
||||
'error_count': row[5] or 0,
|
||||
'avg_vector_ms': round(row[6] or 0),
|
||||
'avg_openai_ms': round(row[7] or 0)
|
||||
}
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"통계 조회 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# 모듈 로드 시 DB 초기화
|
||||
init_db()
|
||||
Loading…
Reference in New Issue
Block a user