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:
thug0bin 2026-03-08 15:17:11 +09:00
parent be1e6c2bb7
commit 5d7a8fc3f4
4 changed files with 1012 additions and 7 deletions

View File

@ -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

View 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);

View 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()">&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>

View 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()