diff --git a/backend/app.py b/backend/app.py
index 2e5e3fa..bb520f1 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -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
diff --git a/backend/db/animal_chat_logs_schema.sql b/backend/db/animal_chat_logs_schema.sql
new file mode 100644
index 0000000..3bef898
--- /dev/null
+++ b/backend/db/animal_chat_logs_schema.sql
@@ -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);
diff --git a/backend/templates/admin_animal_chat_logs.html b/backend/templates/admin_animal_chat_logs.html
new file mode 100644
index 0000000..23c3b50
--- /dev/null
+++ b/backend/templates/admin_animal_chat_logs.html
@@ -0,0 +1,597 @@
+
+
+
+
+
+ 동물약 챗봇 로그 - 청춘약국
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/utils/animal_chat_logger.py b/backend/utils/animal_chat_logger.py
new file mode 100644
index 0000000..0f65500
--- /dev/null
+++ b/backend/utils/animal_chat_logger.py
@@ -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()