diff --git a/backend/api/requirements.txt b/backend/api/requirements.txt index a7b5827..746cedc 100644 --- a/backend/api/requirements.txt +++ b/backend/api/requirements.txt @@ -4,3 +4,5 @@ sqlalchemy==2.0.23 pyodbc==5.0.1 qrcode==7.4.2 Pillow==10.1.0 +openai==1.58.1 +python-dotenv==1.0.0 diff --git a/backend/app.py b/backend/app.py index 69651c3..127a4de 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,6 +10,20 @@ import sys import os import logging from sqlalchemy import text +from dotenv import load_dotenv +import json +import time + +# 환경 변수 로드 +load_dotenv() + +# OpenAI import +try: + from openai import OpenAI, OpenAIError, RateLimitError, APITimeoutError + OPENAI_AVAILABLE = True +except ImportError: + OPENAI_AVAILABLE = False + logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.") # Path setup sys.path.insert(0, os.path.dirname(__file__)) @@ -63,6 +77,233 @@ def utc_to_kst_str(utc_time_str): return utc_time_str # 변환 실패 시 원본 반환 +# ===== OpenAI 설정 및 헬퍼 함수 ===== + +# OpenAI API 설정 +OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') +OPENAI_MODEL = os.getenv('OPENAI_MODEL', 'gpt-4o-mini') +OPENAI_MAX_TOKENS = int(os.getenv('OPENAI_MAX_TOKENS', '1000')) +OPENAI_TEMPERATURE = float(os.getenv('OPENAI_TEMPERATURE', '0.7')) + +# OpenAI 사용 가능 여부 및 API 키 확인 +if OPENAI_AVAILABLE and not OPENAI_API_KEY: + logging.warning("OPENAI_API_KEY가 설정되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.") + OPENAI_AVAILABLE = False + +# System Prompt +SYSTEM_PROMPT = """당신은 약국 고객 관리 전문가입니다. 고객의 구매 데이터를 분석하여 다음을 제공합니다: +1. 구매 패턴 및 행동 분석 +2. 주요 관심 제품 카테고리 파악 +3. 업셀링을 위한 제품 추천 +4. 고객 맞춤형 마케팅 전략 + +응답은 반드시 JSON 형식으로 작성하며, 한국어로 작성합니다. +약국 운영자가 실제로 활용할 수 있는 구체적이고 실용적인 인사이트를 제공해야 합니다.""" + +# 에러 메시지 +ERROR_MESSAGES = { + 'NO_USER': '사용자를 찾을 수 없습니다.', + 'NO_PURCHASES': '구매 이력이 없어 분석할 수 없습니다. 최소 1건 이상의 구매가 필요합니다.', + 'OPENAI_NOT_AVAILABLE': 'AI 분석 기능을 사용할 수 없습니다. 관리자에게 문의하세요.', + 'OPENAI_API_KEY_MISSING': 'OpenAI API 키가 설정되지 않았습니다.', + 'OPENAI_API_ERROR': 'OpenAI API 호출에 실패했습니다. 잠시 후 다시 시도해주세요.', + 'OPENAI_RATE_LIMIT': 'API 호출 횟수 제한에 도달했습니다. 잠시 후 다시 시도해주세요.', + 'OPENAI_TIMEOUT': 'AI 분석 시간이 초과되었습니다. 다시 시도해주세요.', + 'PARSING_ERROR': 'AI 응답을 처리하는 중 오류가 발생했습니다.', + 'UNKNOWN_ERROR': '알 수 없는 오류가 발생했습니다. 관리자에게 문의하세요.' +} + + +def categorize_product(product_name): + """제품명에서 카테고리 추정 (간단한 키워드 매칭)""" + categories = { + '소화제': ['타센', '베아제', '겔포스', '소화'], + '진통제': ['타이레놀', '게보린', '펜잘', '이부프로펜'], + '감기약': ['판콜', '화이투벤', '지르텍', '감기'], + '피부약': ['후시딘', '마데카솔', '더마틱스'], + '비타민': ['비타민', '센트룸', '활성비타민'], + '안약': ['안약', '인공눈물'], + '소염진통제': ['자미슬', '펠루비', '게보린'] + } + + for category, keywords in categories.items(): + for keyword in keywords: + if keyword in product_name: + return category + + return '기타' + + +def prepare_analysis_prompt(user, purchases): + """OpenAI API 전송용 프롬프트 생성""" + # 사용자 정보 요약 + user_summary = f"""사용자: {user['nickname']} ({user['phone']}) +가입일: {utc_to_kst_str(user['created_at']) if user['created_at'] else '-'} +포인트 잔액: {user['mileage_balance']:,}P +총 구매 건수: {len(purchases)}건 +""" + + # 구매 이력 상세 + purchase_details = [] + total_spent = 0 + all_products = [] + product_freq = {} + + for idx, purchase in enumerate(purchases, 1): + total_spent += purchase['amount'] + products_str = ', '.join([f"{item['name']} x{item['qty']}" for item in purchase['items']]) + + # 제품 빈도 계산 + for item in purchase['items']: + product_name = item['name'] + all_products.append(product_name) + product_freq[product_name] = product_freq.get(product_name, 0) + 1 + + purchase_details.append( + f"{idx}. {purchase['date']} - {purchase['amount']:,}원 구매, {purchase['points']}P 적립\n" + f" 구매 품목: {products_str}" + ) + + # 통계 계산 + avg_purchase = total_spent // len(purchases) if purchases else 0 + top_products = sorted(product_freq.items(), key=lambda x: x[1], reverse=True)[:5] + top_products_str = ', '.join([f"{name}({count}회)" for name, count in top_products]) + + # 최종 프롬프트 조립 + prompt = f"""다음은 약국 고객의 구매 데이터입니다. 구매 패턴을 분석하고 마케팅 전략을 제안해주세요. + +{user_summary} + +통계 요약: +- 총 구매 금액: {total_spent:,}원 +- 평균 구매 금액: {avg_purchase:,}원 +- 자주 구매한 품목: {top_products_str} + +구매 이력 (최근 {len(purchases)}건): +{chr(10).join(purchase_details)} + +분석 요청사항: +1. 구매 패턴 분석: 구매 빈도, 구매 금액 패턴 등 +2. 주로 구매하는 품목 카테고리 (예: 소화제, 감기약, 건강기능식품 등) +3. 추천 제품: 기존 구매 패턴을 기반으로 관심있을만한 제품 3-5가지 (업셀링) +4. 마케팅 전략: 이 고객에게 효과적일 프로모션 또는 포인트 활용 방안 + +응답은 다음 JSON 형식으로 해주세요: +{{ + "pattern": "구매 패턴에 대한 상세한 분석 (2-3문장)", + "main_products": ["카테고리1: 품목들", "카테고리2: 품목들"], + "recommendations": ["추천제품1 (이유)", "추천제품2 (이유)", "추천제품3 (이유)"], + "marketing_strategy": "마케팅 전략 제안 (2-3문장)" +}} +""" + return prompt + + +def parse_openai_response(response_text): + """OpenAI API 응답을 파싱하여 구조화된 데이터 반환""" + import re + + try: + # JSON 추출 (마크다운 코드 블록 제거) + json_match = re.search(r'```json\s*(\{.*?\})\s*```', response_text, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + # 코드 블록 없이 JSON만 있는 경우 + json_str = response_text.strip() + + # JSON 파싱 + analysis = json.loads(json_str) + + # 필수 필드 검증 + required_fields = ['pattern', 'main_products', 'recommendations', 'marketing_strategy'] + for field in required_fields: + if field not in analysis: + raise ValueError(f"필수 필드 누락: {field}") + + # 타입 검증 + if not isinstance(analysis['main_products'], list): + analysis['main_products'] = [str(analysis['main_products'])] + if not isinstance(analysis['recommendations'], list): + analysis['recommendations'] = [str(analysis['recommendations'])] + + return analysis + + except json.JSONDecodeError as e: + # JSON 파싱 실패 시 fallback + logging.error(f"JSON 파싱 실패: {e}") + return { + 'pattern': '응답 파싱에 실패했습니다.', + 'main_products': ['분석 결과를 확인할 수 없습니다.'], + 'recommendations': ['다시 시도해주세요.'], + 'marketing_strategy': response_text[:500] + } + except Exception as e: + logging.error(f"응답 파싱 오류: {e}") + raise + + +def handle_openai_error(error): + """OpenAI API 에러를 사용자 친화적 메시지로 변환""" + error_str = str(error).lower() + + if 'api key' in error_str or 'authentication' in error_str: + return ERROR_MESSAGES['OPENAI_API_KEY_MISSING'] + elif 'rate limit' in error_str or 'quota' in error_str: + return ERROR_MESSAGES['OPENAI_RATE_LIMIT'] + elif 'timeout' in error_str: + return ERROR_MESSAGES['OPENAI_TIMEOUT'] + else: + return ERROR_MESSAGES['OPENAI_API_ERROR'] + + +def call_openai_with_retry(prompt, max_retries=3): + """재시도 로직을 포함한 OpenAI API 호출""" + if not OPENAI_AVAILABLE: + return False, ERROR_MESSAGES['OPENAI_NOT_AVAILABLE'] + + client = OpenAI(api_key=OPENAI_API_KEY) + + for attempt in range(max_retries): + try: + response = client.chat.completions.create( + model=OPENAI_MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt} + ], + max_tokens=OPENAI_MAX_TOKENS, + temperature=OPENAI_TEMPERATURE, + timeout=30 + ) + + return True, response + + except RateLimitError as e: + if attempt < max_retries - 1: + wait_time = 2 ** attempt + logging.warning(f"OpenAI Rate limit, {wait_time}초 대기 후 재시도...") + time.sleep(wait_time) + else: + return False, handle_openai_error(e) + + except APITimeoutError as e: + if attempt < max_retries - 1: + logging.warning(f"OpenAI 타임아웃, 재시도 중... ({attempt+1}/{max_retries})") + time.sleep(1) + else: + return False, ERROR_MESSAGES['OPENAI_TIMEOUT'] + + except OpenAIError as e: + return False, handle_openai_error(e) + + except Exception as e: + logging.error(f"OpenAI API 호출 오류: {e}") + return False, ERROR_MESSAGES['UNKNOWN_ERROR'] + + return False, ERROR_MESSAGES['OPENAI_TIMEOUT'] + + def verify_claim_token(transaction_id, nonce): """ QR 토큰 검증 @@ -825,6 +1066,146 @@ def admin_search_product(): }), 500 +@app.route('/admin/ai-analyze-user/', methods=['POST']) +def admin_ai_analyze_user(user_id): + """OpenAI GPT를 사용한 사용자 구매 패턴 AI 분석""" + try: + # OpenAI 사용 가능 여부 확인 + if not OPENAI_AVAILABLE: + return jsonify({ + 'success': False, + 'message': ERROR_MESSAGES['OPENAI_NOT_AVAILABLE'] + }), 503 + + # 1. SQLite 연결 + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 2. 사용자 기본 정보 조회 + cursor.execute(""" + SELECT id, nickname, phone, mileage_balance, created_at + FROM users WHERE id = ? + """, (user_id,)) + user_row = cursor.fetchone() + + if not user_row: + return jsonify({ + 'success': False, + 'message': ERROR_MESSAGES['NO_USER'] + }), 404 + + user = { + 'id': user_row['id'], + 'nickname': user_row['nickname'], + 'phone': user_row['phone'], + 'mileage_balance': user_row['mileage_balance'], + 'created_at': user_row['created_at'] + } + + # 3. 구매 이력 조회 (최근 20건) + cursor.execute(""" + SELECT transaction_id, total_amount, claimable_points, claimed_at + FROM claim_tokens + WHERE claimed_by_user_id = ? + ORDER BY claimed_at DESC + LIMIT 20 + """, (user_id,)) + claimed_tokens = cursor.fetchall() + + if not claimed_tokens: + return jsonify({ + 'success': False, + 'message': ERROR_MESSAGES['NO_PURCHASES'] + }), 400 + + # 4. MSSQL에서 상품 상세 조회 + purchases = [] + session = db_manager.get_session('PM_PRES') + + for token in claimed_tokens: + transaction_id = token['transaction_id'] + + # SALE_MAIN에서 거래 시간 조회 + sale_main_query = text(""" + SELECT InsertTime + FROM SALE_MAIN + WHERE SL_NO_order = :transaction_id + """) + sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone() + + # SALE_SUB + CD_GOODS JOIN + sale_items_query = text(""" + SELECT + S.DrugCode, + ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, + S.SL_NM_item AS quantity, + S.SL_NM_cost_a AS price, + S.SL_TOTAL_PRICE AS total + FROM SALE_SUB S + LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode + WHERE S.SL_NO_order = :transaction_id + ORDER BY S.DrugCode + """) + items_raw = session.execute(sale_items_query, {'transaction_id': transaction_id}).fetchall() + + items = [{ + 'name': item.goods_name, + 'qty': int(item.quantity or 0), + 'price': int(item.price or 0) + } for item in items_raw] + + purchases.append({ + 'date': str(sale_main.InsertTime)[:16].replace('T', ' ') if sale_main and sale_main.InsertTime else '-', + 'amount': int(token['total_amount']), + 'points': int(token['claimable_points']), + 'items': items + }) + + # 5. OpenAI API 호출용 프롬프트 생성 + prompt = prepare_analysis_prompt(user, purchases) + + # 6. OpenAI API 호출 + logging.info(f"AI 분석 시작: 사용자 ID {user_id}") + success, response = call_openai_with_retry(prompt) + + if not success: + # response에는 에러 메시지가 담겨 있음 + return jsonify({ + 'success': False, + 'message': response + }), 500 + + # 7. 응답 파싱 + response_text = response.choices[0].message.content + analysis = parse_openai_response(response_text) + + logging.info(f"AI 분석 완료: 사용자 ID {user_id}, 토큰: {response.usage.total_tokens}") + + # 8. 결과 반환 + return jsonify({ + 'success': True, + 'user': { + 'id': user['id'], + 'name': user['nickname'], + 'phone': user['phone'], + 'balance': user['mileage_balance'] + }, + 'analysis': analysis, + 'metadata': { + 'model_used': OPENAI_MODEL, + 'tokens_used': response.usage.total_tokens, + 'analysis_time': datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') + } + }) + + except Exception as e: + logging.error(f"AI 분석 오류: {e}") + return jsonify({ + 'success': False, + 'message': ERROR_MESSAGES['UNKNOWN_ERROR'] + }), 500 + + @app.route('/admin/use-points', methods=['POST']) def admin_use_points(): """관리자 페이지에서 포인트 사용 (차감)""" diff --git a/backend/templates/admin.html b/backend/templates/admin.html index 54499d1..9e61824 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -601,6 +601,24 @@ + + + +