pharmacy-pos-qr-system/backend/templates/admin_animal_chat_logs.html
thug0bin 5d7a8fc3f4 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
- 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용
2026-03-08 15:17:11 +09:00

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()">&times;</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, "&#39;")})' 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ESC로 모달 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>