- 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 - 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용
598 lines
21 KiB
HTML
598 lines
21 KiB
HTML
<!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>
|