diff --git a/backend/app.py b/backend/app.py index 33139d0..7952535 100644 --- a/backend/app.py +++ b/backend/app.py @@ -23,9 +23,11 @@ from sqlalchemy import text from dotenv import load_dotenv import json import time +from pathlib import Path -# 환경 변수 로드 -load_dotenv() +# 환경 변수 로드 (명시적 경로) +env_path = Path(__file__).parent / '.env' +load_dotenv(dotenv_path=env_path) # OpenAI import try: @@ -2627,6 +2629,268 @@ def admin_sales_detail(): return render_template('admin_sales_detail.html') +# ============================================================================= +# 동물약 추천 챗봇 API +# ============================================================================= + +# 동물약 지식 베이스 (RAG 컨텍스트) +ANIMAL_DRUG_KNOWLEDGE = """ +## 🐕 심장사상충 예방약 (매월 투여) +- **하트가드 (Heartgard)**: 이버멕틴 성분, 츄어블, 소/중/대형견용, 고기맛 +- **다이로하트 (Dirohart)**: 이버멕틴 성분, 하트가드 제네릭, 경제적 +- **하트캅 (Heartcap)**: 밀베마이신 옥심, 태블릿, 정밀한 용량 + +## 🐕 외부기생충 (벼룩/진드기) 예방약 +- **넥스가드 (NexGard)**: 아폭솔라너 성분, 츄어블, 1개월 지속, 맛있는 소고기맛 +- **넥스가드 스펙트라**: 넥스가드 + 심장사상충 예방 (아폭솔라너 + 밀베마이신) +- **브라벡토 (Bravecto)**: 플루랄라너 성분, 츄어블, **3개월** 지속 (12주) +- **심파리카 (Simparica)**: 사롤라너 성분, 츄어블, 1개월 지속 +- **심파리카 트리오**: 심파리카 + 심장사상충 + 내부기생충 예방 +- **크레델리오 (Credelio)**: 로틸라너 성분, 1개월 지속, 소형 정제 + +## 🐱 고양이 전용 +- **브라벡토 스팟온 (고양이)**: 외부기생충, 3개월 지속, 바르는 타입 +- **레볼루션 (Revolution)**: 셀라멕틴 성분, 스팟온, 심장사상충 + 벼룩 + 귀진드기 +- **브로드라인**: 피프로닐 + 에피프리노미드, 내/외부기생충 + 심장사상충 + +## 🪱 내부기생충 (구충제) +- **안텔민 (Antelmin)**: 프라지콴텔 + 피란텔, 회충/촌충/편충 +- **파라캅 (Paracap)**: 펜벤다졸 성분, 광범위 구충 +- **드론탈 (Drontal)**: 프라지콴텔 + 피란텔, 국제적 신뢰 + +## 💊 용량 안내 +- 체중별 제품 선택이 중요합니다 +- 소형견(5kg 미만), 중형견(5-10kg), 대형견(10-25kg), 초대형견(25kg 이상) +- 고양이는 반드시 고양이 전용 제품 사용 + +## ⚠️ 주의사항 +- 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의 +- 임신/수유 중인 동물은 수의사 상담 필요 +- 체중 정확히 측정 후 제품 선택 +""" + +# 동물약 챗봇 System Prompt +ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입니다. 반려동물 보호자에게 친절하고 전문적인 상담을 제공합니다. + +**역할:** +1. 동물약 추천 (심장사상충, 외부기생충, 구충제 등) +2. 제품 비교 및 장단점 설명 +3. 용법/용량 안내 +4. 주의사항 안내 + +**대화 스타일:** +- 친근하고 따뜻하게 🐕🐱 +- 이모지 적절히 활용 +- 간결하지만 정확한 정보 +- 최종 결정은 수의사 상담 권장 + +**제약:** +- 처방전이 필요한 약은 수의사 처방 안내 +- 진단/치료는 수의사 영역임을 명시 +- 보유 제품 위주로 추천 + +**응답 형식:** +- 짧고 명확하게 (3-5문장) +- 추천 제품이 있으면 이름과 특징 언급 +- 체중/종류 확인 필요시 질문 + +{available_products} + +{knowledge_base}""" + + +def _get_animal_drugs(): + """보유 중인 동물약 목록 조회 (APC 이미지 포함)""" + try: + drug_session = db_manager.get_session('PM_DRUG') + # CD_ITEM_UNIT_MEMBER에서 APC 바코드 조회 (0230237로 시작하는 것) + query = text(""" + SELECT + G.DrugCode, + G.GoodsName, + G.Saleprice, + G.BARCODE, + ( + SELECT TOP 1 U.CD_CD_BARCODE + FROM CD_ITEM_UNIT_MEMBER U + WHERE U.DRUGCODE = G.DrugCode + AND U.CD_CD_BARCODE LIKE '023%' + ORDER BY U.CHANGE_DATE DESC + ) AS APC_CODE + FROM CD_GOODS G + WHERE G.POS_BOON = '010103' + AND G.GoodsSelCode = 'B' + ORDER BY G.GoodsName + """) + rows = drug_session.execute(query).fetchall() + + result = [] + for r in rows: + apc = r.APC_CODE if hasattr(r, 'APC_CODE') else None + image_url = None + if apc: + # APC가 있으면 이미지 URL 생성 + image_url = f"https://ani.0bin.in/img/{apc}_F.jpg" + + result.append({ + 'code': r.DrugCode, + 'name': r.GoodsName, + 'price': float(r.Saleprice) if r.Saleprice else 0, + 'barcode': r.BARCODE or '', + 'apc': apc, + 'image_url': image_url + }) + + return result + except Exception as e: + logging.warning(f"동물약 목록 조회 실패: {e}") + return [] + + +@app.route('/api/animal-chat', methods=['POST']) +def api_animal_chat(): + """ + 동물약 추천 챗봇 API + + Request: + { + "messages": [ + {"role": "user", "content": "강아지 심장사상충 약 추천해주세요"} + ] + } + + Response: + { + "success": true, + "message": "AI 응답 텍스트", + "products": [{"name": "...", "price": ...}] // 언급된 보유 제품 + } + """ + try: + if not OPENAI_AVAILABLE: + return jsonify({ + 'success': False, + 'message': 'AI 기능을 사용할 수 없습니다. 관리자에게 문의하세요.' + }), 503 + + data = request.get_json() + messages = data.get('messages', []) + + if not messages: + return jsonify({ + 'success': False, + 'message': '메시지를 입력해주세요.' + }), 400 + + # 보유 동물약 목록 조회 + animal_drugs = _get_animal_drugs() + available_products_text = "" + if animal_drugs: + product_list = "\n".join([ + f"- {d['name']} ({d['price']:,.0f}원)" for d in animal_drugs + ]) + available_products_text = f""" +**현재 보유 동물약:** +{product_list} +""" + + # System Prompt 구성 + system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format( + available_products=available_products_text, + knowledge_base=ANIMAL_DRUG_KNOWLEDGE + ) + + # OpenAI API 호출 + client = OpenAI(api_key=OPENAI_API_KEY) + + api_messages = [{"role": "system", "content": system_prompt}] + for msg in messages[-10:]: # 최근 10개 메시지만 + api_messages.append({ + "role": msg.get("role", "user"), + "content": msg.get("content", "") + }) + + response = client.chat.completions.create( + model=OPENAI_MODEL, + messages=api_messages, + max_tokens=500, + temperature=0.7 + ) + + ai_response = response.choices[0].message.content + + # 응답에서 언급된 보유 제품 찾기 (부분 매칭) + mentioned_products = [] + ai_response_lower = ai_response.lower() + + for drug in animal_drugs: + drug_name = drug['name'] + # 제품명에서 핵심 키워드 추출 (괄호 앞부분) + # 예: "다이로하트정M(12~22kg)" → "다이로하트" + base_name = drug_name.split('(')[0].split('/')[0].strip() + # 사이즈 제거: "다이로하트정M" → "다이로하트" + for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']: + if base_name.endswith(suffix): + base_name = base_name[:-len(suffix)] + base_name = base_name.strip() + + # 핵심 키워드가 AI 응답에 포함되어 있는지 확인 + if len(base_name) >= 2 and base_name.lower() in ai_response_lower: + # 중복 방지 (같은 제품 계열은 하나만) + already_added = any(base_name.lower() in p['name'].lower() for p in mentioned_products) + if not already_added: + mentioned_products.append({ + 'name': drug_name, + 'price': drug['price'], + 'code': drug['code'], + 'image_url': drug.get('image_url') # APC 이미지 URL + }) + + return jsonify({ + 'success': True, + 'message': ai_response, + 'products': mentioned_products[:5], # 최대 5개 + 'usage': { + 'input_tokens': response.usage.prompt_tokens, + 'output_tokens': response.usage.completion_tokens + } + }) + + except RateLimitError: + return jsonify({ + 'success': False, + 'message': 'AI 사용량 한도에 도달했습니다. 잠시 후 다시 시도해주세요.' + }), 429 + except APITimeoutError: + return jsonify({ + 'success': False, + 'message': 'AI 응답 시간이 초과되었습니다. 다시 시도해주세요.' + }), 504 + except Exception as e: + logging.error(f"동물약 챗봇 오류: {e}") + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }), 500 + + +@app.route('/api/animal-drugs') +def api_animal_drugs(): + """보유 동물약 목록 API""" + try: + drugs = _get_animal_drugs() + return jsonify({ + 'success': True, + 'items': drugs, + 'count': len(drugs) + }) + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + # ===== 제품 검색 페이지 ===== @app.route('/admin/products') @@ -3660,5 +3924,5 @@ if __name__ == '__main__': else: logging.error(f"포트 {PORT} 해제 실패. 수동으로 확인하세요.") - # 개발 모드로 실행 - app.run(host='0.0.0.0', port=PORT, debug=True) + # 프로덕션 모드로 실행 + app.run(host='0.0.0.0', port=PORT, debug=False, threaded=True) diff --git a/backend/templates/admin_products.html b/backend/templates/admin_products.html index bfd19ed..819efb1 100644 --- a/backend/templates/admin_products.html +++ b/backend/templates/admin_products.html @@ -48,11 +48,224 @@ /* ── 컨텐츠 ── */ .content { - max-width: 1200px; + max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; } + /* ── 플로팅 챗봇 ── */ + .chatbot-panel { + position: fixed; + right: 24px; + bottom: 90px; + width: 370px; + background: #fff; + border-radius: 20px; + border: 1px solid #e2e8f0; + box-shadow: 0 8px 40px rgba(0,0,0,0.15); + display: none; + flex-direction: column; + height: 520px; + max-height: calc(100vh - 120px); + z-index: 998; + animation: chatSlideUp 0.3s ease; + } + .chatbot-panel.open { + display: flex; + } + @keyframes chatSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } + } + .chatbot-header { + padding: 16px 20px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-radius: 16px 16px 0 0; + color: #fff; + } + .chatbot-header h2 { + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + } + .chatbot-header p { + font-size: 12px; + opacity: 0.85; + margin-top: 4px; + } + .chatbot-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + .chat-message { + max-width: 85%; + padding: 12px 16px; + border-radius: 16px; + font-size: 14px; + line-height: 1.5; + word-break: break-word; + } + .chat-message.user { + background: #8b5cf6; + color: #fff; + align-self: flex-end; + border-bottom-right-radius: 4px; + } + .chat-message.assistant { + background: #f1f5f9; + color: #334155; + align-self: flex-start; + border-bottom-left-radius: 4px; + } + .chat-message.system { + background: #fef3c7; + color: #92400e; + align-self: center; + font-size: 12px; + padding: 8px 16px; + } + .chat-message .products-mentioned { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #cbd5e1; + } + .chat-message .product-chip { + display: inline-block; + background: #10b981; + color: #fff; + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + margin: 2px 4px 2px 0; + cursor: pointer; + } + .chat-message .product-chip:hover { + background: #059669; + } + .chatbot-input { + padding: 16px; + border-top: 1px solid #e2e8f0; + display: flex; + gap: 10px; + } + .chatbot-input input { + flex: 1; + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 24px; + font-size: 14px; + font-family: inherit; + transition: all 0.2s; + } + .chatbot-input input:focus { + outline: none; + border-color: #10b981; + box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1); + } + .chatbot-input button { + width: 44px; + height: 44px; + border-radius: 50%; + background: #10b981; + color: #fff; + border: none; + cursor: pointer; + font-size: 18px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + .chatbot-input button:hover { background: #059669; } + .chatbot-input button:disabled { background: #94a3b8; cursor: not-allowed; } + .chatbot-suggestions { + padding: 12px 16px; + background: #f8fafc; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .suggestion-chip { + background: #e0e7ff; + color: #4338ca; + padding: 6px 12px; + border-radius: 16px; + font-size: 12px; + cursor: pointer; + border: none; + transition: all 0.2s; + } + .suggestion-chip:hover { + background: #c7d2fe; + } + .typing-indicator { + display: flex; + gap: 4px; + padding: 12px 16px; + align-self: flex-start; + } + .typing-indicator span { + width: 8px; + height: 8px; + background: #94a3b8; + border-radius: 50%; + animation: typing 1.4s infinite; + } + .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } + .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + @keyframes typing { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-6px); opacity: 1; } + } + + /* ── 챗봇 토글 버튼 (항상 표시) ── */ + .chatbot-toggle { + position: fixed; + bottom: 24px; + right: 24px; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: #fff; + border: none; + font-size: 28px; + cursor: pointer; + box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4); + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s, box-shadow 0.2s; + } + .chatbot-toggle:hover { + transform: scale(1.1); + box-shadow: 0 6px 28px rgba(16, 185, 129, 0.5); + } + .chatbot-toggle.active { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4); + } + + /* 모바일 */ + @media (max-width: 640px) { + .chatbot-panel { + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 75vh; + border-radius: 20px 20px 0 0; + } + .chatbot-toggle { bottom: 16px; right: 16px; } + } + /* ── 검색 영역 ── */ .search-section { background: #fff; @@ -344,7 +557,7 @@

🔍 제품 검색

-

전체 제품 검색 · QR 라벨 인쇄

+

전체 제품 검색 · QR 라벨 인쇄 · 🐾 동물약 AI 상담

@@ -395,8 +608,36 @@
+ + + +
+
+

🐾 동물약 AI 상담

+

심장사상충, 외부기생충, 구충제 등 무엇이든 물어보세요

+
+
+ + + + +
+
+
+ 안녕하세요! 🐾 동물약 상담 AI입니다.

+ 반려동물의 심장사상충 예방, 벼룩/진드기 예방, 구충제 등에 대해 무엇이든 물어보세요! +
+
+
+ + +
+ + +