From 5d7a8fc3f4b38297a4c82539f656e4d8481ada7d Mon Sep 17 00:00:00 2001 From: thug0bin Date: Sun, 8 Mar 2026 15:17:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(animal-chat):=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 - 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용 --- backend/app.py | 98 ++- backend/db/animal_chat_logs_schema.sql | 47 ++ backend/templates/admin_animal_chat_logs.html | 597 ++++++++++++++++++ backend/utils/animal_chat_logger.py | 277 ++++++++ 4 files changed, 1012 insertions(+), 7 deletions(-) create mode 100644 backend/db/animal_chat_logs_schema.sql create mode 100644 backend/templates/admin_animal_chat_logs.html create mode 100644 backend/utils/animal_chat_logger.py 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 @@ + + + + + + 동물약 챗봇 로그 - 청춘약국 + + + + + + +
+ +

🐾 동물약 챗봇 로그

+

RAG 기반 동물약 상담 기록 · 토큰 사용량 · 비용 분석

+
+ +
+ +
+
+
총 대화
+
-
+
+
+
평균 응답시간
+
-
+
ms
+
+
+
총 토큰
+
-
+
+
+
총 비용
+
-
+
USD
+
+
+
평균 벡터 검색
+
-
+
ms
+
+
+
에러
+
-
+
+
+ + +
+ + ~ + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
시간질문응답토큰비용소요시간상태
+
+ 로딩 중... +
+
+
+ + + + + + + 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()