pet-recommend-app/pet_recommend_app.py
thug0bin b66129b5d0 🐾 초기 커밋: 반려동물 약품 추천 시스템
- pet_recommend_app.py: Flask 기반 약품 추천 API (포트 7001)
- db_setup.py: PostgreSQL ORM 모델 (apdb_master)
- requirements.txt: 패키지 의존성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 22:10:38 +09:00

3120 lines
122 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
동물약 추천 MVP 웹앱
- 반려동물 정보 + 증상 입력 → 약품 추천
- 실행: python pet_recommend_app.py
- 접속: http://localhost:5001
"""
from flask import Flask, render_template_string, request, jsonify
from db_setup import (
session, APDB, Inventory, ComponentCode, Symptoms, SymptomComponentMapping, DosageInfo,
SupplementaryProduct, UnifiedProductRecommendation, SymptomSupplementaryRecommendation, InventorySupplementary
)
from sqlalchemy import distinct, and_, or_
from concurrent.futures import ThreadPoolExecutor
import openai
import re
app = Flask(__name__)
# ============================================================
# OpenAI API 설정
# ============================================================
OPENAI_API_KEY = "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"
openai.api_key = OPENAI_API_KEY
# ============================================================
# MDR-1 유전자 변이 관련 설정
# ============================================================
# MDR-1 민감 성분 코드 (Avermectin 계열 등)
MDR1_SENSITIVE_COMPONENTS = {
'IC2010131', # 셀라멕틴 (Selamectin)
'IC2030110', # 이버멕틴+피란텔 (Ivermectin + Pyrantel)
'IC2030132', # 이미다클로프리드+목시덱틴 (Imidacloprid + Moxidectin)
}
# MDR-1 민감 견종 정보
MDR1_BREEDS = {
# 고위험군 (40%+)
'collie': {'name': '콜리', 'risk': 'high', 'frequency': '70%'},
'aussie': {'name': '오스트레일리안 셰퍼드', 'risk': 'high', 'frequency': '50%'},
'mini_aussie': {'name': '미니어처 오스트레일리안 셰퍼드', 'risk': 'high', 'frequency': '50%'},
'longhair_whippet': {'name': '롱헤어 휘핏', 'risk': 'high', 'frequency': '65%'},
'silken_windhound': {'name': '실켄 윈드하운드', 'risk': 'high', 'frequency': '30-50%'},
# 중위험군 (10-40%)
'sheltie': {'name': '셰틀랜드 쉽독', 'risk': 'medium', 'frequency': '15%'},
'english_shepherd': {'name': '잉글리쉬 셰퍼드', 'risk': 'medium', 'frequency': '15%'},
'whippet': {'name': '휘핏', 'risk': 'medium', 'frequency': '10-20%'},
'mcnab': {'name': '맥냅', 'risk': 'medium', 'frequency': '17%'},
'german_shepherd': {'name': '저먼 셰퍼드', 'risk': 'medium', 'frequency': '10%'},
'white_shepherd': {'name': '화이트 셰퍼드', 'risk': 'medium', 'frequency': '14%'},
# 저위험군 (10% 미만)
'old_english': {'name': '올드 잉글리쉬 쉽독', 'risk': 'low', 'frequency': '5%'},
'border_collie': {'name': '보더 콜리', 'risk': 'low', 'frequency': '2-5%'},
'chinook': {'name': '치눅', 'risk': 'low', 'frequency': '미확인'},
# 기타
'mix_herding': {'name': '믹스견 (목양견 계통)', 'risk': 'unknown', 'frequency': '변동'},
'mix_hound': {'name': '믹스견 (하운드 계통)', 'risk': 'unknown', 'frequency': '변동'},
'other': {'name': '기타 견종', 'risk': 'none', 'frequency': '-'},
'unknown': {'name': '모름', 'risk': 'unknown', 'frequency': '-'},
}
# ============================================================
# 제품군(카테고리) 정의
# ============================================================
PRODUCT_CATEGORIES = {
'heartworm': {
'name': '심장사상충제',
'icon': '💗',
'keywords': ['심장사상충', '사상충', 'dirofilaria', '하트웜'],
'description': '심장사상충 예방약'
},
'parasite': {
'name': '구충제',
'icon': '🐛',
'keywords': ['구충', '기생충', '내부구충', '촌충', '회충', '십이지장충', '선충', '벼룩', '진드기'],
'description': '내/외부 기생충 치료'
},
'skin': {
'name': '피부약',
'icon': '🧴',
'keywords': ['피부', '진균', '피부염', '알레르기', '가려움', '습진', '곰팡이', '아토피'],
'description': '피부질환 치료'
},
'ear': {
'name': '귀약',
'icon': '👂',
'keywords': ['', '외이도염', '귓병', '이염', '이진드기'],
'description': '귀질환 치료'
},
'eye': {
'name': '안약',
'icon': '👁️',
'keywords': ['안약', '', '결막염', '각막염', '안구'],
'description': '눈질환 치료'
},
'antibiotic': {
'name': '항생제',
'icon': '💊',
'keywords': ['항생', '감염', '세균', '항균'],
'description': '세균감염 치료'
},
'digestive': {
'name': '정장제',
'icon': '🍀',
'keywords': ['정장', '장내세균', '설사', '유산균', '프로바이오틱', '소화불량', '소화장애'],
'description': '소화기 건강'
},
'painkiller': {
'name': '소염진통제',
'icon': '💪',
'keywords': ['소염', '진통', '관절', '염증', '통증'],
'description': '통증/염증 완화'
},
'vomit': {
'name': '구토억제제',
'icon': '🤢',
'keywords': ['구토', '메스꺼움', '오심'],
'description': '구토 억제'
},
}
# ============================================================
# 체중 기반 용량 계산 함수
# ============================================================
def format_tablets(tablets):
"""소수점 정제 수를 사람이 읽기 쉬운 형태로 변환"""
if tablets is None:
return None
if tablets < 0.3:
return "1/4정"
elif tablets < 0.6:
return "1/2정"
elif tablets < 0.9:
return "3/4정"
elif tablets < 1.2:
return "1정"
elif tablets < 1.6:
return "1.5정"
elif tablets < 2.2:
return "2정"
elif tablets < 2.6:
return "2.5정"
elif tablets < 3.2:
return "3정"
else:
return f"{round(tablets)}"
def get_tablets_numeric(tablets):
"""
format_tablets의 역방향 함수 - 정제 수를 숫자로 반환
1/4정 → 0.25, 1/2정 → 0.5, 3/4정 → 0.75, 1정 → 1.0, 1.5정 → 1.5 등
"""
if tablets < 0.3:
return 0.25 # 1/4정
elif tablets < 0.6:
return 0.5 # 1/2정
elif tablets < 0.9:
return 0.75 # 3/4정
elif tablets < 1.2:
return 1.0 # 1정
elif tablets < 1.6:
return 1.5 # 1.5정
elif tablets < 2.2:
return 2.0 # 2정
elif tablets < 2.6:
return 2.5 # 2.5정
elif tablets < 3.2:
return 3.0 # 3정
else:
return round(tablets)
def calculate_recommended_dosage(product_idx, weight_kg, animal_type):
"""
체중 기반 적정 용량 계산
Args:
product_idx: APDB.idx (제품 고유 ID)
weight_kg: 반려동물 체중 (kg)
animal_type: 'dog' 또는 'cat'
Returns:
dict: 계산된 용량 정보 또는 None
"""
if not weight_kg or weight_kg <= 0:
return None
try:
# 먼저 이 제품의 모든 DosageInfo 조회 (피펫형 여부 판단용)
all_dosage_infos = session.query(DosageInfo).filter(
DosageInfo.apdb_idx == product_idx,
or_(
DosageInfo.animal_type == animal_type,
DosageInfo.animal_type == 'all'
)
).all()
# 피펫형 제품 여부 판단 (DosageInfo가 하나라도 피펫이면 피펫형)
is_pipette = any(d.unit_type == '피펫' for d in all_dosage_infos)
# 체중 범위에 맞는 DosageInfo 찾기
dosage_info = None
for d in all_dosage_infos:
# 체중 범위가 지정된 경우
if d.weight_min_kg is not None and d.weight_max_kg is not None:
if d.weight_min_kg <= weight_kg <= d.weight_max_kg:
dosage_info = d
break
# 체중 범위가 없는 경우 (전체 적용)
elif d.weight_min_kg is None and d.weight_max_kg is None:
dosage_info = d
break
# 피펫형 제품인데 체중 범위에 맞지 않으면 필터링을 위해 특별 응답 반환
if is_pipette and not dosage_info:
return {
'calculated': False,
'is_pipette': True, # 피펫형이므로 필터링 대상
'message': '',
'details': {},
'warning': None
}
if not dosage_info:
return None
result = {
'calculated': False,
'is_pipette': is_pipette, # 피펫형 제품 여부 (체중 필터링에 사용)
'message': '',
'details': {},
'warning': None
}
# 케이스 1: kg당 용량이 있는 경우 (정제형 등)
if dosage_info.dose_per_kg:
total_dose = weight_kg * dosage_info.dose_per_kg
result['calculated'] = True
result['details'] = {
'total_dose': round(total_dose, 2),
'dose_unit': dosage_info.dose_unit,
'frequency': dosage_info.frequency,
'route': dosage_info.route
}
# 정제 수 계산 (정제/캡슐형인 경우)
if dosage_info.unit_dose and dosage_info.unit_type in ['', '캡슐']:
tablets = total_dose / dosage_info.unit_dose
tablets_formatted = format_tablets(tablets)
# 실제 복용량 계산 (정제 수 × 1정당 함량)
tablets_numeric = get_tablets_numeric(tablets)
actual_dose = tablets_numeric * dosage_info.unit_dose
result['details']['tablets'] = round(tablets, 2)
result['details']['tablets_formatted'] = tablets_formatted
result['details']['unit_type'] = dosage_info.unit_type
result['details']['actual_dose'] = actual_dose # 실제 복용량
result['message'] = f"체중 {weight_kg}kg 기준: 1회 {tablets_formatted} ({round(actual_dose, 1)}{dosage_info.dose_unit})"
else:
result['message'] = f"체중 {weight_kg}kg 기준: 1회 {round(total_dose, 1)}{dosage_info.dose_unit}"
if dosage_info.frequency:
result['message'] += f", {dosage_info.frequency}"
# 케이스 2: 체중 범위별 고정 용량 (피펫형 등)
elif dosage_info.weight_min_kg and dosage_info.weight_max_kg:
result['calculated'] = True
result['details'] = {
'weight_min': dosage_info.weight_min_kg,
'weight_max': dosage_info.weight_max_kg,
'unit_dose': dosage_info.unit_dose,
'dose_unit': dosage_info.dose_unit,
'frequency': dosage_info.frequency,
'route': dosage_info.route
}
result['message'] = f"체중 {dosage_info.weight_min_kg}~{dosage_info.weight_max_kg}kg용"
if dosage_info.unit_dose and dosage_info.dose_unit:
result['message'] += f": {dosage_info.unit_dose}{dosage_info.dose_unit}"
if dosage_info.frequency:
result['message'] += f", {dosage_info.frequency}"
# 케이스 3: 기본 정보만 있는 경우
elif dosage_info.frequency or dosage_info.route:
result['calculated'] = True
parts = []
if dosage_info.frequency:
parts.append(dosage_info.frequency)
if dosage_info.route:
parts.append(f"{dosage_info.route} 투여")
result['message'] = ', '.join(parts)
return result
except Exception as e:
print(f"[용량 계산 오류] product_idx={product_idx}, error={e}")
return None
def generate_recommendation_reason(animal_type, symptom_descriptions, product_name, component_name, llm_pharm, efficacy_clean, component_code=None, symptom_codes=None):
"""GPT-4o-mini로 추천 이유 생성"""
try:
# 한글 동물 타입
animal_ko = '강아지' if animal_type == 'dog' else '고양이'
# 특수 케이스: 아시엔로정 + 소화기 증상 → 중증 세균성 장염 설명 추가
special_note = ""
digestive_symptoms = {'g01', 'g02', 'g03', 'g04', 'g05'} # 소화기 관련 증상 코드
if component_code == 'IA5010124' and symptom_codes: # 아시엔로정 (엔로플록사신)
if any(s in digestive_symptoms for s in symptom_codes):
special_note = "\n⚠️ 특별 참고: 이 제품은 일반 설사가 아닌 중증 세균성 장염에 사용하는 항생제입니다. 증상이 심하거나 점액/혈변이 있는 경우에 적합합니다."
# 복합 성분 여부 확인 (+ 기호로 연결된 경우)
is_complex_formula = '+' in component_name
# llm_pharm에서 컨텍스트 추출
if llm_pharm:
indication = llm_pharm.get('어떤질병에사용하나요?', '')
description = llm_pharm.get('자연어설명', '') or llm_pharm.get('LLM설명', '')
usage = llm_pharm.get('기간/용법', '')
caution = llm_pharm.get('check', '')
else:
indication = ''
description = efficacy_clean[:200] if efficacy_clean else ''
usage = ''
caution = ''
context = f"""
제품명: {product_name}
성분: {component_name}
적응증: {indication}
설명: {description}
용법: {usage}
"""
# 특별 지시 사항
special_instruction = ""
# 복합 성분 제품일 경우 각 성분 역할 설명 요청
if is_complex_formula:
special_instruction += "\n중요: 이 제품은 여러 성분이 함께 들어간 '복합제'입니다. 각 성분의 역할을 간단히 설명해주세요 (예: 'A성분은 세균을 억제하고, B성분은 염증을 가라앉힙니다'). 단, '성분이 결합되어 있다'는 표현은 화학적 오해를 줄 수 있으니 사용하지 마세요."
# 아시엔로정 소화기 증상일 경우 프롬프트에 특별 지시 추가
if component_code == 'IA5010124' and symptom_codes and any(s in digestive_symptoms for s in symptom_codes):
special_instruction += "\n주의: 이 제품은 항생제로, 중증 세균성 장염(심한 염증성 장질환)에 사용됩니다. 일반적인 설사가 아닌 세균 감염에 의한 장염에 효과적이라는 점을 반드시 언급해주세요."
# 복합 성분 제품은 더 긴 설명 허용
sentence_limit = "3-4문장" if is_complex_formula else "2문장 이내"
prompt = f"""반려인이 {animal_ko}의 증상으로 "{', '.join(symptom_descriptions)}"를 선택했습니다.
아래 제품 정보를 바탕으로, 왜 이 제품이 해당 증상에 적합한지 {sentence_limit}로 친절하고 따뜻하게 설명해주세요.
예상 질환명을 언급하고, 제품의 효능을 간단히 설명해주세요.
전문용어는 쉽게 풀어서 설명해주세요.
주의사항:
- "귀하", "고객님" 같은 딱딱한 표현 대신 "반려인님" 또는 생략하세요.
- 복합 성분 제품은 "A와 B가 결합되어" 대신 "A와 B가 함께 들어있어" 또는 "A 성분과 B 성분이 각각" 표현을 사용하세요.{special_instruction}
{context}
"""
# 복합 성분 제품은 더 긴 응답 허용
max_tok = 280 if is_complex_formula else 120
client = openai.OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tok,
temperature=0.7
)
gpt_response = response.choices[0].message.content.strip()
# 특별 참고사항 추가 (아시엔로정 + 소화기 증상)
if special_note:
return gpt_response + special_note
return gpt_response
except Exception as e:
print(f"OpenAI API Error: {e}")
# fallback: DB의 자연어설명 사용
if llm_pharm and llm_pharm.get('자연어설명'):
fallback_msg = llm_pharm.get('자연어설명')
if special_note:
return fallback_msg + special_note
return fallback_msg
return "해당 증상에 효과적인 성분이 포함된 제품입니다."
# ============================================================
# HTML 템플릿 (단일 파일로 포함)
# ============================================================
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>동물약 추천 - 애니팜</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 500px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 20px;
padding: 24px;
margin-bottom: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
font-size: 24px;
color: #333;
margin-bottom: 8px;
}
.header p {
color: #666;
font-size: 14px;
}
.logo {
font-size: 48px;
margin-bottom: 12px;
}
/* 폼 스타일 */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.form-group label .required {
color: #e74c3c;
}
/* 동물 선택 버튼 */
.animal-select {
display: flex;
gap: 12px;
}
.animal-btn {
flex: 1;
padding: 20px;
border: 2px solid #e0e0e0;
border-radius: 12px;
background: white;
cursor: pointer;
text-align: center;
transition: all 0.3s;
}
.animal-btn:hover {
border-color: #667eea;
}
.animal-btn.active {
border-color: #667eea;
background: #f0f3ff;
}
.animal-btn .icon {
font-size: 36px;
margin-bottom: 8px;
}
.animal-btn .name {
font-weight: 600;
color: #333;
}
/* 입력 필드 */
input[type="number"], input[type="text"], select {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
/* 증상 체크리스트 */
.symptom-category {
margin-bottom: 16px;
}
.symptom-category-title {
font-weight: 600;
color: #667eea;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 2px solid #f0f3ff;
}
.symptom-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.symptom-chip {
padding: 8px 14px;
border: 1px solid #e0e0e0;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.symptom-chip:hover {
border-color: #667eea;
}
.symptom-chip.active {
background: #667eea;
color: white;
border-color: #667eea;
}
/* 견종 퀵 선택 버튼 */
.breed-quick-select {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.breed-quick-btn {
flex: 1;
padding: 14px 16px;
border: 2px solid #10b981;
border-radius: 12px;
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
color: #059669;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.breed-quick-btn:hover {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.breed-quick-btn.unknown {
border-color: #6366f1;
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
color: #4f46e5;
}
.breed-quick-btn.unknown:hover {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.breed-quick-btn.active {
transform: scale(0.98);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.breed-divider {
display: flex;
align-items: center;
margin: 16px 0 12px;
color: #9ca3af;
font-size: 12px;
}
.breed-divider::before,
.breed-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e5e7eb;
}
.breed-divider span {
padding: 0 12px;
}
/* 모드 전환 탭 */
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: #f3f4f6;
padding: 4px;
border-radius: 12px;
}
.mode-tab {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: #6b7280;
}
.mode-tab:hover {
background: rgba(255,255,255,0.5);
}
.mode-tab.active {
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
color: #667eea;
}
/* 제품군 카드 그리드 */
.category-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.category-card {
padding: 16px 12px;
border: 2px solid #e5e7eb;
border-radius: 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.category-card:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.category-card.active {
border-color: #667eea;
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
}
.category-icon {
font-size: 32px;
margin-bottom: 8px;
}
.category-name {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
color: #374151;
}
.category-count {
font-size: 11px;
color: #9ca3af;
}
/* 견종 선택 드롭다운 */
.breed-dropdown {
width: 100%;
padding: 12px 14px;
border: 1px solid #e0e0e0;
border-radius: 10px;
font-size: 14px;
background: white;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
}
.breed-dropdown:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.mdr1-tooltip {
font-size: 12px;
color: #666;
cursor: help;
}
.breed-warning {
margin-top: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
}
.breed-warning.warning-high {
background: #fee2e2;
border: 1px solid #ef4444;
color: #dc2626;
}
.breed-warning.warning-medium {
background: #fef3c7;
border: 1px solid #f59e0b;
color: #d97706;
}
.breed-warning.warning-low {
background: #d1fae5;
border: 1px solid #10b981;
color: #059669;
}
.breed-warning.warning-info {
background: #e0f2fe;
border: 1px solid #0ea5e9;
color: #0284c7;
}
/* MDR-1 경고 스타일 (결과 카드) */
.mdr1-alert {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
}
.mdr1-alert.mdr1-danger {
background: #fee2e2;
border: 1px solid #ef4444;
color: #dc2626;
}
.mdr1-alert.mdr1-caution {
background: #fef3c7;
border: 1px solid #f59e0b;
color: #d97706;
}
.mdr1-alert.mdr1-info {
background: #e0f2fe;
border: 1px solid #0ea5e9;
color: #0284c7;
}
.product-card.mdr1-danger {
border: 2px solid #ef4444;
}
.product-card.mdr1-caution {
border: 2px solid #f59e0b;
}
/* 임신/수유 여부 선택 */
.pregnancy-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.pregnancy-option {
display: flex;
align-items: center;
padding: 10px 16px;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.pregnancy-option:hover {
border-color: #cbd5e1;
background: #f1f5f9;
}
.pregnancy-option input[type="radio"] {
display: none;
}
.pregnancy-option input[type="radio"]:checked + .pregnancy-label {
color: #667eea;
font-weight: 600;
}
.pregnancy-option:has(input[type="radio"]:checked) {
border-color: #667eea;
background: #f0f4ff;
}
.pregnancy-label {
font-size: 14px;
color: #475569;
}
/* 제출 버튼 */
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 결과 영역 */
.result-section {
display: none;
}
.result-section.show {
display: block;
}
.result-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.result-header .icon {
font-size: 32px;
}
.result-header .text h2 {
font-size: 18px;
color: #333;
}
.result-header .text p {
font-size: 13px;
color: #666;
}
/* 추천 제품 카드 */
.product-card {
background: #f8f9fa;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #667eea;
}
.product-card.rank-1 {
border-left-color: #f1c40f;
background: #fffef0;
}
.product-rank {
display: inline-block;
background: #667eea;
color: white;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
margin-bottom: 8px;
}
.product-card.rank-1 .product-rank {
background: #f1c40f;
color: #333;
}
.product-content {
display: flex;
align-items: flex-start;
gap: 16px;
}
.product-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
flex-shrink: 0;
background: white;
}
.product-details {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
word-break: keep-all;
}
.product-component {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.product-info {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
}
.product-tag {
background: white;
padding: 3px 8px;
border-radius: 6px;
color: #555;
}
.product-tag.target {
background: #e8f5e9;
color: #2e7d32;
}
.product-tag.warning {
background: #fff3e0;
color: #e65100;
}
.product-reason {
font-size: 13px;
color: #444;
line-height: 1.6;
margin-top: 12px;
padding: 12px;
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
border-radius: 10px;
border-left: 3px solid #667eea;
}
/* 특별 참고 메시지 (중증 세균성 장염 등) */
.special-note {
margin-top: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 8px;
border-left: 4px solid #f59e0b;
font-size: 12px;
font-weight: 600;
color: #92400e;
line-height: 1.5;
}
.special-note-icon {
display: inline-block;
margin-right: 4px;
}
.special-note strong {
color: #b45309;
font-weight: 700;
}
/* AI 추천 설명문 내 제품명/성분명 강조 */
.product-name-highlight {
display: inline;
background: #e8f0fe;
color: #1a56db;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
font-size: inherit;
}
.component-name-highlight {
display: inline;
background: #ecfdf5;
color: #047857;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
font-size: inherit;
}
/* 용량 추천 박스 */
.dosage-recommendation {
margin-top: 12px;
padding: 12px;
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border-radius: 10px;
border-left: 4px solid #10b981;
}
.dosage-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.dosage-icon {
font-size: 16px;
}
.dosage-title {
font-weight: 700;
font-size: 13px;
color: #047857;
}
.dosage-content {
font-size: 14px;
font-weight: 600;
color: #065f46;
line-height: 1.5;
}
.dosage-detail {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 12px;
color: #047857;
}
.detail-label {
opacity: 0.8;
}
.detail-value {
font-weight: 600;
}
.dosage-disclaimer {
margin-top: 8px;
padding: 6px 8px;
background: rgba(255,255,255,0.6);
border-radius: 6px;
font-size: 10px;
color: #059669;
line-height: 1.4;
}
/* 상세 사용 안내 박스 (usage_guide) */
.usage-guide {
margin-top: 16px;
padding: 16px;
background: linear-gradient(135deg, #fef9c3 0%, #fef3c7 100%);
border-radius: 12px;
border-left: 4px solid #f59e0b;
}
.usage-guide-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.usage-guide-icon {
font-size: 20px;
}
.usage-guide-title {
font-weight: 700;
font-size: 15px;
color: #92400e;
}
.usage-section {
margin-bottom: 12px;
}
.usage-section h4 {
font-size: 13px;
font-weight: 600;
color: #78350f;
margin: 0 0 8px 0;
}
/* 희석 비율 테이블 */
.usage-table {
display: flex;
flex-direction: column;
gap: 8px;
}
.usage-row {
display: grid;
grid-template-columns: 1fr 1.5fr 1fr;
gap: 8px;
padding: 8px 10px;
background: rgba(255,255,255,0.7);
border-radius: 8px;
font-size: 12px;
}
.usage-purpose {
font-weight: 600;
color: #78350f;
}
.concentration {
font-weight: 700;
color: #d97706;
margin-right: 6px;
}
.formula {
color: #92400e;
}
.usage-frequency {
color: #78350f;
font-size: 11px;
}
/* 비교형 테이블 (인체 vs 동물용) */
.comparison-row {
grid-template-columns: 1fr 1.2fr 1.5fr;
}
.comparison-header {
background: rgba(120, 53, 15, 0.1);
font-weight: 700;
font-size: 11px;
}
.comparison-header .usage-human,
.comparison-header .usage-animal {
color: #78350f;
}
.usage-label {
font-weight: 600;
color: #78350f;
}
.usage-human {
color: #6b7280;
font-size: 12px;
}
.usage-animal {
color: #047857;
font-weight: 600;
font-size: 12px;
}
/* 용량 테이블 (오리모덤 등) */
.dosage-row {
grid-template-columns: 1fr 1fr 1.5fr;
}
.dosage-header {
background: rgba(120, 53, 15, 0.1);
font-weight: 700;
font-size: 11px;
}
.dosage-header .usage-dose,
.dosage-header .usage-freq {
color: #78350f;
}
.usage-dose {
color: #d97706;
font-weight: 700;
font-size: 13px;
}
.usage-freq {
color: #78350f;
font-size: 12px;
}
/* 팁 리스트 */
.usage-list {
margin: 8px 0 0 16px;
padding: 0;
}
.usage-list li {
margin-bottom: 4px;
font-size: 12px;
color: #78350f;
}
/* 경고사항 */
.usage-warnings {
margin-top: 12px;
padding: 10px;
background: #fef2f2;
border-radius: 8px;
border: 1px solid #fecaca;
}
.usage-warnings strong {
color: #dc2626;
font-size: 13px;
}
.usage-warnings ul {
margin: 6px 0 0 16px;
padding: 0;
}
.usage-warnings li {
font-size: 12px;
color: #b91c1c;
margin-bottom: 2px;
}
/* 경고 메시지 */
.warning-box {
background: #fff3e0;
border-radius: 10px;
padding: 12px;
margin-top: 16px;
font-size: 13px;
color: #e65100;
display: flex;
align-items: flex-start;
gap: 8px;
}
.warning-box .icon {
font-size: 18px;
}
/* 로딩 */
.loading {
text-align: center;
padding: 40px;
}
.loading .spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 다시하기 버튼 */
.reset-btn {
width: 100%;
padding: 14px;
background: white;
border: 2px solid #667eea;
color: #667eea;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 16px;
}
/* 푸터 */
.footer {
text-align: center;
color: rgba(255,255,255,0.7);
font-size: 12px;
margin-top: 20px;
}
/* 추천 제품 섹션 (v2) */
.related-products {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #ddd;
}
.related-products-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.related-products-header .icon {
font-size: 18px;
}
.related-products-header h4 {
font-size: 14px;
font-weight: 600;
color: #333;
margin: 0;
}
.related-product-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
background: white;
border-radius: 10px;
margin-bottom: 8px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.2s;
}
.related-product-card:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.related-product-img {
width: 50px;
height: 50px;
min-width: 50px;
border-radius: 8px;
background: #f5f5f5;
background-size: cover;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
}
.related-product-img.no-image {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
}
.related-product-img span {
font-size: 20px;
}
.related-product-card.supplementary .related-product-img.no-image {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
}
.related-product-info {
flex: 1;
min-width: 0;
}
.relation-badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 4px;
}
.relation-badge.pre_use {
background: #dbeafe;
color: #1d4ed8;
}
.relation-badge.post_use {
background: #dcfce7;
color: #166534;
}
.relation-badge.support {
background: #fef3c7;
color: #92400e;
}
.relation-badge.recovery {
background: #fce7f3;
color: #9d174d;
}
.relation-badge.combo {
background: #ede9fe;
color: #5b21b6;
}
.related-product-name {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.related-product-reason {
font-size: 11px;
color: #666;
line-height: 1.4;
}
.related-product-tags {
display: flex;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.related-product-tags span {
font-size: 10px;
padding: 1px 5px;
background: #f3f4f6;
color: #6b7280;
border-radius: 3px;
}
.supplementary-section {
margin-top: 12px;
}
.supplementary-section-title {
font-size: 12px;
color: #666;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.category-icon {
font-size: 14px;
}
/* 추천 제품 타이틀 */
.related-products-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin: 0 0 10px 0;
display: flex;
align-items: center;
gap: 6px;
}
/* 카테고리 배지 */
.category-badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: #f3e8ff;
color: #7c3aed;
margin-left: 4px;
}
/* 보조제품 카드 스타일 */
.related-product-card.supplementary {
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
border-color: #fcd34d;
}
.related-product-card.supplementary:hover {
border-color: #f59e0b;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.15);
}
/* alternative 관계 배지 */
.relation-badge.alternative {
background: #f3f4f6;
color: #4b5563;
}
/* prevention 관계 배지 */
.relation-badge.prevention {
background: #cffafe;
color: #0e7490;
}
/* efficacy 태그 */
.efficacy-tag {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
background: #ecfdf5;
color: #047857;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="container">
<!-- 입력 폼 -->
<div id="inputSection">
<div class="card">
<div class="header">
<div class="logo"><img src="https://cdn-saas-web-218-79.cdn-nhncommerce.com/anypharm70_godomall_com/data/img/logo-header.svg" alt="애니팜 로고" style="height:48px;"></div>
<h1>동물약 추천</h1>
<p>증상 또는 제품군을 선택하면 적합한 약을 추천해드려요</p>
</div>
<!-- 모드 전환 탭 -->
<div class="mode-tabs">
<button type="button" class="mode-tab active" data-mode="symptom" onclick="switchMode('symptom')">
🔍 증상으로 추천
</button>
<button type="button" class="mode-tab" data-mode="category" onclick="switchMode('category')">
📦 제품군으로 추천
</button>
</div>
<form id="recommendForm">
<!-- 동물 선택 -->
<div class="form-group">
<label>반려동물 선택 <span class="required">*</span></label>
<div class="animal-select">
<div class="animal-btn" data-animal="dog" onclick="selectAnimal('dog')">
<div class="icon"><img src="https://cdn-saas-web-218-79.cdn-nhncommerce.com/anypharm70_godomall_com/data/skin/front/Anipharm_PC/img/icon/dog.png" alt="강아지" style="width:48px;height:48px;"></div>
<div class="name">강아지</div>
</div>
<div class="animal-btn" data-animal="cat" onclick="selectAnimal('cat')">
<div class="icon"><img src="https://cdn-saas-web-218-79.cdn-nhncommerce.com/anypharm70_godomall_com/data/skin/front/Anipharm_PC/img/icon/cat.png" alt="고양이" style="width:48px;height:48px;"></div>
<div class="name">고양이</div>
</div>
</div>
<input type="hidden" id="animalType" name="animal_type" required>
</div>
<!-- 견종 선택 (강아지 선택 시에만 표시) -->
<div class="form-group" id="breedSection" style="display:none;">
<label>견종 선택
<span class="mdr1-tooltip" title="일부 견종은 MDR-1 유전자 변이로 인해 특정 약물(이버멕틴, 셀라멕틴 등)에 민감합니다"></span>
</label>
<!-- 퀵 선택 버튼 -->
<div class="breed-quick-select">
<button type="button" class="breed-quick-btn" onclick="selectBreedQuick('other')">
✅ 기타 견종 (일반)
</button>
<button type="button" class="breed-quick-btn unknown" onclick="selectBreedQuick('unknown')">
❓ 모르겠음
</button>
</div>
<div class="breed-divider">
<span>또는 MDR-1 민감 견종 선택</span>
</div>
<select id="breedSelect" class="breed-dropdown">
<option value="">-- 견종을 선택하세요 --</option>
<optgroup label="🔴 고위험군 (MDR-1 변이 40%+)">
<option value="collie" data-risk="high">콜리 (Rough/Smooth Collie) - 70%</option>
<option value="aussie" data-risk="high">오스트레일리안 셰퍼드 - 50%</option>
<option value="mini_aussie" data-risk="high">미니어처 오스트레일리안 셰퍼드 - 50%</option>
<option value="longhair_whippet" data-risk="high">롱헤어 휘핏 - 65%</option>
<option value="silken_windhound" data-risk="high">실켄 윈드하운드 - 30-50%</option>
</optgroup>
<optgroup label="🟡 중위험군 (MDR-1 변이 10-40%)">
<option value="sheltie" data-risk="medium">셰틀랜드 쉽독 (Sheltie) - 15%</option>
<option value="english_shepherd" data-risk="medium">잉글리쉬 셰퍼드 - 15%</option>
<option value="whippet" data-risk="medium">휘핏 (Whippet) - 10-20%</option>
<option value="mcnab" data-risk="medium">맥냅 (McNab) - 17%</option>
<option value="german_shepherd" data-risk="medium">저먼 셰퍼드 - 10%</option>
<option value="white_shepherd" data-risk="medium">화이트 (스위스) 셰퍼드 - 14%</option>
</optgroup>
<optgroup label="🟢 저위험군 (MDR-1 변이 10% 미만)">
<option value="old_english" data-risk="low">올드 잉글리쉬 쉽독 - 5%</option>
<option value="border_collie" data-risk="low">보더 콜리 - 2-5%</option>
<option value="chinook" data-risk="low">치눅 (Chinook)</option>
</optgroup>
<optgroup label="⚪ 기타">
<option value="mix_herding" data-risk="unknown">믹스견 (목양견/콜리 계통)</option>
<option value="mix_hound" data-risk="unknown">믹스견 (하운드 계통)</option>
<option value="other" data-risk="none">기타 견종 (위 목록에 없음)</option>
<option value="unknown" data-risk="unknown">모르겠음</option>
</optgroup>
</select>
<input type="hidden" id="breedType" name="breed">
<div id="breedWarning" class="breed-warning" style="display:none;"></div>
</div>
<!-- 체중 -->
<div class="form-group">
<label>체중 (kg)</label>
<input type="number" id="weight" name="weight" placeholder="예: 5.5" step="0.1" min="0.1" max="100">
</div>
<!-- 임신/수유 여부 -->
<div class="form-group">
<label>임신/수유 여부</label>
<div class="pregnancy-options">
<label class="pregnancy-option">
<input type="radio" name="pregnancy" value="none" checked>
<span class="pregnancy-label">해당 없음</span>
</label>
<label class="pregnancy-option">
<input type="radio" name="pregnancy" value="pregnant">
<span class="pregnancy-label">🤰 임신 중</span>
</label>
<label class="pregnancy-option">
<input type="radio" name="pregnancy" value="nursing">
<span class="pregnancy-label">🍼 수유 중</span>
</label>
</div>
</div>
<!-- 증상 선택 (증상 모드) -->
<div class="form-group" id="symptomSection">
<label>증상 선택 <span class="required">*</span> (복수 선택 가능)</label>
<div id="symptomList">
{% for category, symptoms in symptoms_by_category.items() %}
<div class="symptom-category">
<div class="symptom-category-title">{{ category }}</div>
<div class="symptom-list">
{% for symptom in symptoms %}
<div class="symptom-chip" data-code="{{ symptom.code }}" onclick="toggleSymptom(this)">
{{ symptom.description }}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 제품군 선택 (제품군 모드) -->
<div class="form-group" id="categorySection" style="display:none;">
<label>제품군 선택 <span class="required">*</span></label>
<div class="category-grid" id="categoryGrid">
<!-- 동적으로 로드됨 -->
</div>
</div>
<button type="submit" class="submit-btn" id="submitBtn" disabled>
약품 추천받기
</button>
</form>
</div>
</div>
<!-- 결과 영역 -->
<div id="resultSection" class="result-section">
<div class="card">
<div id="resultContent">
<!-- 결과가 여기에 표시됨 -->
</div>
<button class="reset-btn" onclick="resetForm()">다시 검색하기</button>
</div>
</div>
<div class="footer">
애니팜 동물약 추천 서비스 MVP v1.0
</div>
</div>
<script>
let selectedAnimal = null;
let selectedBreed = null;
let selectedSymptoms = [];
let currentMode = 'symptom'; // 'symptom' or 'category'
let selectedCategory = null;
function selectAnimal(animal) {
selectedAnimal = animal;
document.getElementById('animalType').value = animal;
// 버튼 스타일 업데이트
document.querySelectorAll('.animal-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-animal="${animal}"]`).classList.add('active');
// 강아지 선택 시 견종 섹션 표시
const breedSection = document.getElementById('breedSection');
if (animal === 'dog') {
breedSection.style.display = 'block';
} else {
breedSection.style.display = 'none';
// 고양이 선택 시 견종 초기화
selectedBreed = null;
document.getElementById('breedSelect').value = '';
document.getElementById('breedType').value = '';
document.getElementById('breedWarning').style.display = 'none';
}
updateSubmitButton();
// 제품군 모드일 때 카테고리 로드
if (currentMode === 'category') {
loadCategories();
}
}
// 모드 전환 함수
function switchMode(mode) {
currentMode = mode;
// 탭 활성화
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
// 섹션 전환
if (mode === 'symptom') {
document.getElementById('symptomSection').style.display = 'block';
document.getElementById('categorySection').style.display = 'none';
selectedCategory = null;
// 카테고리 선택 초기화
document.querySelectorAll('.category-card').forEach(card => {
card.classList.remove('active');
});
} else {
document.getElementById('symptomSection').style.display = 'none';
document.getElementById('categorySection').style.display = 'block';
// 증상 선택 초기화
selectedSymptoms = [];
document.querySelectorAll('.symptom-chip').forEach(chip => {
chip.classList.remove('active');
});
// 카테고리 로드
if (selectedAnimal) {
loadCategories();
}
}
updateSubmitButton();
}
// 카테고리 선택 함수
function selectCategory(category) {
selectedCategory = category;
document.querySelectorAll('.category-card').forEach(card => {
card.classList.remove('active');
});
const selectedCard = document.querySelector(`[data-category="${category}"]`);
if (selectedCard) {
selectedCard.classList.add('active');
}
updateSubmitButton();
}
// 카테고리 목록 로드
async function loadCategories() {
console.log('loadCategories called, selectedAnimal:', selectedAnimal);
if (!selectedAnimal) {
console.log('No animal selected, returning');
return;
}
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '<div style="text-align:center; padding:20px; color:#6b7280;">로딩 중...</div>';
try {
const url = `/api/categories?animal_type=${selectedAnimal}`;
console.log('Fetching:', url);
const response = await fetch(url);
const data = await response.json();
console.log('API response:', data);
if (data.success && data.categories && data.categories.length > 0) {
grid.innerHTML = data.categories.map(cat => `
<div class="category-card ${selectedCategory === cat.code ? 'active' : ''}"
data-category="${cat.code}"
onclick="selectCategory('${cat.code}')">
<div class="category-icon">${cat.icon}</div>
<div class="category-name">${cat.name}</div>
<div class="category-count">${cat.count}개 제품</div>
</div>
`).join('');
} else {
console.log('No categories found or error:', data);
grid.innerHTML = '<div style="text-align:center; padding:20px; color:#6b7280;">사용 가능한 제품군이 없습니다</div>';
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
grid.innerHTML = '<div style="text-align:center; padding:20px; color:#ef4444;">카테고리 로드 실패</div>';
}
}
// 퀵 선택 함수 (기타 견종 / 모르겠음)
function selectBreedQuick(breedValue) {
selectedBreed = breedValue;
document.getElementById('breedType').value = breedValue;
document.getElementById('breedSelect').value = breedValue;
// 퀵 버튼 활성화 표시
document.querySelectorAll('.breed-quick-btn').forEach(btn => {
btn.style.opacity = '0.6';
});
event.target.style.opacity = '1';
event.target.classList.add('active');
const warningDiv = document.getElementById('breedWarning');
if (breedValue === 'unknown') {
warningDiv.innerHTML = ' 목양견/하운드 혈통이 있다면 MDR-1 유전자 검사를 권장합니다. 견종이 불확실한 경우 관련 약물 사용에 주의하세요.';
warningDiv.className = 'breed-warning warning-info';
warningDiv.style.display = 'block';
} else {
warningDiv.style.display = 'none';
}
updateSubmitButton();
}
// 견종 드롭다운 선택 이벤트
document.getElementById('breedSelect').addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
const risk = selected.dataset.risk;
selectedBreed = this.value;
document.getElementById('breedType').value = this.value;
// 퀵 버튼 비활성화 (드롭다운 선택 시)
document.querySelectorAll('.breed-quick-btn').forEach(btn => {
btn.style.opacity = '1';
btn.classList.remove('active');
});
const warningDiv = document.getElementById('breedWarning');
if (risk === 'high') {
warningDiv.innerHTML = '<strong>⚠️ MDR-1 고위험 견종</strong><br>이 견종은 이버멕틴, 셀라멕틴, 목시덱틴 등의 약물에 <strong>심각한 신경독성 부작용</strong>이 발생할 수 있습니다. 해당 약물 추천 시 반드시 수의사 상담이 필요합니다.';
warningDiv.className = 'breed-warning warning-high';
warningDiv.style.display = 'block';
} else if (risk === 'medium') {
warningDiv.innerHTML = '<strong>⚠️ MDR-1 중위험 견종</strong><br>이 견종은 일부 약물에 민감할 수 있습니다. 이버멕틴 계열 약물 사용 시 저용량부터 시작하고 모니터링이 필요합니다.';
warningDiv.className = 'breed-warning warning-medium';
warningDiv.style.display = 'block';
} else if (risk === 'low') {
warningDiv.innerHTML = ' MDR-1 저위험 견종이지만, 개체별 차이가 있을 수 있습니다.';
warningDiv.className = 'breed-warning warning-low';
warningDiv.style.display = 'block';
} else if (risk === 'unknown') {
warningDiv.innerHTML = ' 목양견/하운드 혈통이 있다면 MDR-1 유전자 검사를 권장합니다. 견종이 불확실한 경우 관련 약물 사용에 주의하세요.';
warningDiv.className = 'breed-warning warning-info';
warningDiv.style.display = 'block';
} else {
warningDiv.style.display = 'none';
}
updateSubmitButton();
});
function toggleSymptom(element) {
const code = element.dataset.code;
if (element.classList.contains('active')) {
element.classList.remove('active');
selectedSymptoms = selectedSymptoms.filter(s => s !== code);
} else {
element.classList.add('active');
selectedSymptoms.push(code);
}
updateSubmitButton();
}
function updateSubmitButton() {
const btn = document.getElementById('submitBtn');
if (currentMode === 'symptom') {
btn.disabled = !(selectedAnimal && selectedSymptoms.length > 0);
} else {
btn.disabled = !(selectedAnimal && selectedCategory);
}
}
function resetForm() {
// 폼 초기화
selectedAnimal = null;
selectedBreed = null;
selectedSymptoms = [];
selectedCategory = null;
currentMode = 'symptom';
document.getElementById('animalType').value = '';
document.getElementById('breedType').value = '';
document.getElementById('breedSelect').value = '';
document.getElementById('weight').value = '';
// 임신/수유 여부 초기화
document.querySelector('input[name="pregnancy"][value="none"]').checked = true;
document.querySelectorAll('.animal-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.symptom-chip').forEach(chip => {
chip.classList.remove('active');
});
document.querySelectorAll('.category-card').forEach(card => {
card.classList.remove('active');
});
// 모드 탭 초기화
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector('[data-mode="symptom"]').classList.add('active');
document.getElementById('symptomSection').style.display = 'block';
document.getElementById('categorySection').style.display = 'none';
// 견종 섹션 숨기기 및 퀵 버튼 초기화
document.getElementById('breedSection').style.display = 'none';
document.getElementById('breedWarning').style.display = 'none';
document.querySelectorAll('.breed-quick-btn').forEach(btn => {
btn.style.opacity = '1';
btn.classList.remove('active');
});
// 화면 전환
document.getElementById('inputSection').style.display = 'block';
document.getElementById('resultSection').classList.remove('show');
updateSubmitButton();
}
// 폼 제출
document.getElementById('recommendForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '추천 중...';
// 모드에 따라 다른 API 호출
const endpoint = currentMode === 'symptom'
? '/api/recommend'
: '/api/recommend_by_category';
const data = {
animal_type: selectedAnimal,
breed: selectedBreed || null,
weight: document.getElementById('weight').value || null,
pregnancy: document.querySelector('input[name="pregnancy"]:checked')?.value || 'none'
};
if (currentMode === 'symptom') {
data.symptoms = selectedSymptoms;
} else {
data.category = selectedCategory;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
showResult(result);
} catch (error) {
console.error('Error:', error);
document.getElementById('resultContent').innerHTML = `
<div style="text-align:center; padding:20px;">
<div style="font-size:48px;">😿</div>
<h2>오류가 발생했어요</h2>
<p>잠시 후 다시 시도해주세요</p>
</div>
`;
}
// 화면 전환
document.getElementById('inputSection').style.display = 'none';
document.getElementById('resultSection').classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = '약품 추천받기';
});
function showResult(result) {
const content = document.getElementById('resultContent');
if (!result.success || result.recommendations.length === 0) {
content.innerHTML = `
<div style="text-align:center; padding:20px;">
<div style="font-size:48px;">🔍</div>
<h2>추천 약품이 없어요</h2>
<p>${result.message || '선택하신 증상에 맞는 재고 약품이 없습니다.'}</p>
</div>
`;
return;
}
const animalIcon = result.animal_type === 'dog'
? '<img src="https://cdn-saas-web-218-79.cdn-nhncommerce.com/anypharm70_godomall_com/data/skin/front/Anipharm_PC/img/icon/dog.png" alt="강아지" style="width:48px;height:48px;">'
: '<img src="https://cdn-saas-web-218-79.cdn-nhncommerce.com/anypharm70_godomall_com/data/skin/front/Anipharm_PC/img/icon/cat.png" alt="고양이" style="width:48px;height:48px;">';
const animalName = result.animal_type === 'dog' ? '강아지' : '고양이';
// 결과 헤더 (모드에 따라 다르게 표시)
let subText = '';
if (result.category_name) {
subText = `제품군: ${result.category_name}`;
} else if (result.matched_symptoms) {
subText = `선택 증상: ${result.matched_symptoms.join(', ')}`;
}
// 체중 정보 추가
if (result.weight_kg) {
subText += ` | 체중: ${result.weight_kg}kg`;
}
// 임신/수유 상태 추가
if (result.pregnancy_status && result.pregnancy_status !== 'none') {
const pregnancyText = result.pregnancy_status === 'pregnant' ? '🤰 임신 중' : '🍼 수유 중';
subText += ` | ${pregnancyText}`;
}
// 제품명/성분명을 하이라이트하는 함수
function highlightProductAndComponents(text, productName, componentName) {
if (!text) return text;
let result = text;
// 1. 제품명 하이라이트 (가장 긴 것부터 매칭하도록)
if (productName) {
// 제품명에서 괄호 부분 제거하고 기본 이름만 추출
const baseProductName = productName.replace(/\s*\([^)]*\)/g, '').trim();
const productNames = [productName, baseProductName].filter((v, i, a) => a.indexOf(v) === i);
// 길이 순 정렬 (긴 것 먼저)
productNames.sort((a, b) => b.length - a.length);
for (const name of productNames) {
if (name && name.length >= 2) {
const escaped = name.replace(/[.*+?^${}()|\\[\\]\\\\]/g, '\\\\$&');
const regex = new RegExp(escaped, 'g');
result = result.replace(regex, `<span class="product-name-highlight">${name}</span>`);
}
}
}
// 2. 성분명 하이라이트
if (componentName) {
// 복합 성분일 경우 개별 성분 분리 ('+' 또는 '/' 또는 ','로 구분)
const components = componentName.split(/[+\/,]/).map(c => c.trim()).filter(c => c.length >= 2);
// 길이 순 정렬 (긴 것 먼저)
components.sort((a, b) => b.length - a.length);
for (const comp of components) {
// 이미 하이라이트된 부분 건너뛰기
if (comp && !result.includes(`>${comp}<`)) {
const escaped = comp.replace(/[.*+?^${}()|\\[\\]\\\\]/g, '\\\\$&');
const regex = new RegExp(escaped, 'g');
result = result.replace(regex, `<span class="component-name-highlight">${comp}</span>`);
}
}
}
return result;
}
// 특별 참고 메시지를 분리하여 스타일 적용 + 제품명/성분명 하이라이트
function formatReasonWithSpecialNote(reason, productName, componentName) {
const specialNoteMarker = '⚠️ 특별 참고:';
const parts = reason.split(specialNoteMarker);
if (parts.length > 1) {
const mainReason = highlightProductAndComponents(parts[0].trim(), productName, componentName);
const specialNote = parts[1].trim();
return `
<div class="product-reason">💡 ${mainReason}</div>
<div class="special-note">
<span class="special-note-icon">⚠️</span>
<strong>특별 참고:</strong> ${specialNote}
</div>
`;
}
const highlightedReason = highlightProductAndComponents(reason, productName, componentName);
return `<div class="product-reason">💡 ${highlightedReason}</div>`;
}
// usage_guide를 HTML로 렌더링
function renderUsageGuide(guide) {
if (!guide) return '';
let html = `
<div class="usage-guide">
<div class="usage-guide-header">
<span class="usage-guide-icon">${guide.icon || '📋'}</span>
<span class="usage-guide-title">${guide.title || '상세 사용 안내'}</span>
</div>
<div class="usage-guide-content">
`;
// sections 렌더링
if (guide.sections) {
guide.sections.forEach(section => {
html += `<div class="usage-section"><h4>${section.heading}</h4>`;
if (section.type === 'table' && section.items) {
html += '<div class="usage-table">';
// 테이블 타입 판단 (첫 번째 아이템으로)
const firstItem = section.items[0];
const isComparisonTable = firstItem && firstItem.label && (firstItem.human || firstItem.animal);
const isDosageTable = firstItem && firstItem.label && firstItem.dose;
if (isComparisonTable) {
// 비교형 테이블 헤더 (인체용 vs 동물용)
html += `
<div class="usage-row comparison-row comparison-header">
<div class="usage-label">구분</div>
<div class="usage-human">인체용</div>
<div class="usage-animal">동물용</div>
</div>
`;
} else if (isDosageTable) {
// 용량 테이블 헤더 (체중, 용량, 횟수)
html += `
<div class="usage-row dosage-row dosage-header">
<div class="usage-label">체중</div>
<div class="usage-dose">용량</div>
<div class="usage-freq">횟수</div>
</div>
`;
}
section.items.forEach(item => {
// 용량 테이블 (label, dose, frequency) - 오리모덤 등
if (item.label && item.dose) {
html += `
<div class="usage-row dosage-row">
<div class="usage-label">${item.label}</div>
<div class="usage-dose">${item.dose}</div>
<div class="usage-freq">${item.frequency || ''}</div>
</div>
`;
}
// 비교형 테이블 (label, human, animal)
else if (item.label && (item.human || item.animal)) {
html += `
<div class="usage-row comparison-row">
<div class="usage-label">${item.label}</div>
<div class="usage-human">${item.human || '-'}</div>
<div class="usage-animal">${item.animal || '-'}</div>
</div>
`;
}
// 희석 비율 테이블 (purpose, concentration, formula, frequency)
else {
html += `
<div class="usage-row">
<div class="usage-purpose">${item.purpose || ''}</div>
<div>
<span class="concentration">${item.concentration || ''}</span>
<span class="formula">${item.formula || ''}</span>
</div>
<div class="usage-frequency">${item.frequency || ''}</div>
</div>
`;
}
});
html += '</div>';
} else {
// list 타입
html += '<ul class="usage-list">';
if (section.items) {
section.items.forEach(item => {
const text = typeof item === 'string' ? item : (item.text || '');
html += `<li>${text}</li>`;
});
}
html += '</ul>';
}
html += '</div>';
});
}
// warnings 렌더링
if (guide.warnings && guide.warnings.length > 0) {
html += '<div class="usage-warnings"><strong>⚠️ 주의사항</strong><ul>';
guide.warnings.forEach(w => {
html += `<li>${w}</li>`;
});
html += '</ul></div>';
}
html += '</div></div>';
return html;
}
// 추천 제품 섹션 렌더링 함수
function renderRelatedProducts(productIdx, productName) {
// API 호출하여 추천 제품 가져오기
fetch(`/api/product/${productIdx}/recommendations`)
.then(response => response.json())
.then(data => {
const container = document.querySelector(`.related-products-${productIdx}`);
if (!container) return;
const apdbRecs = data.apdb_recommendations || [];
const suppRecs = data.supplementary_recommendations || [];
if (apdbRecs.length === 0 && suppRecs.length === 0) {
container.style.display = 'none';
return;
}
// 제품명에서 핵심 이름만 추출 (용량 정보 제거)
const shortName = productName.split(/\s+\d/)[0].trim();
let html = `<h4 class="related-products-title">💡 '<strong>${shortName}</strong>'과 함께 사용하면 좋은 제품</h4>`;
// APDB 추천 제품
apdbRecs.forEach(rec => {
const relationLabel = getRelationLabel(rec.relation_type);
html += `
<div class="related-product-card">
<div class="related-product-img ${rec.image_url ? '' : 'no-image'}"
style="${rec.image_url ? "background-image:url('" + rec.image_url + "')" : ''}">
${!rec.image_url ? '<span>📦</span>' : ''}
</div>
<div class="related-product-info">
<span class="relation-badge ${rec.relation_type}">${relationLabel}</span>
<div class="related-product-name">${rec.name}</div>
<div class="related-product-reason">${rec.reason || ''}</div>
</div>
</div>
`;
});
// 보조제품 추천
suppRecs.forEach(rec => {
const relationLabel = getRelationLabel(rec.relation_type);
const categoryIcon = getCategoryIcon(rec.category);
// 보조제품은 아직 이미지가 없으므로 카테고리 아이콘 표시
html += `
<div class="related-product-card supplementary">
<div class="related-product-img no-image">
<span>${categoryIcon}</span>
</div>
<div class="related-product-info">
<span class="relation-badge ${rec.relation_type}">${relationLabel}</span>
<span class="category-badge">${categoryIcon} ${getCategoryName(rec.category)}</span>
<div class="related-product-name">${rec.name}</div>
<div class="related-product-reason">${rec.reason || ''}</div>
${rec.efficacy_tags && rec.efficacy_tags.length > 0 ? `
<div class="related-product-tags">
${rec.efficacy_tags.map(tag => `<span class="efficacy-tag">${tag}</span>`).join('')}
</div>
` : ''}
</div>
</div>
`;
});
container.innerHTML = html;
container.style.display = 'block';
})
.catch(err => {
console.error('Failed to fetch recommendations:', err);
});
}
// 관계 유형 라벨 변환
function getRelationLabel(relationType) {
const labels = {
'pre_use': '🧴 사전 사용 권장',
'post_use': '✅ 사후 사용 권장',
'combo': '🔗 병용 권장',
'support': '🤝 보조제품',
'alternative': '↔️ 대체 가능',
'recovery': '💪 회복 보조',
'prevention': '🛡️ 예방'
};
return labels[relationType] || relationType;
}
// 카테고리 아이콘
function getCategoryIcon(category) {
const icons = {
'supplement': '💊',
'equipment': '🩺',
'care': '🧴'
};
return icons[category] || '📦';
}
// 카테고리 이름
function getCategoryName(category) {
const names = {
'supplement': '건기식',
'equipment': '보조기구',
'care': '케어용품'
};
return names[category] || category;
}
let html = `
<div class="result-header">
<div class="icon">${animalIcon}</div>
<div class="text">
<h2>${animalName} 추천 약품</h2>
<p>${subText}</p>
</div>
</div>
`;
result.recommendations.forEach((product, index) => {
const rankClass = index === 0 ? 'rank-1' : '';
const rankText = index === 0 ? '✨ Best' : `#${index + 1}`;
const imageUrl = product.image_url || 'https://via.placeholder.com/80x80?text=No+Image';
// MDR-1 경고 클래스
const mdr1Class = product.mdr1_warning ? `mdr1-${product.mdr1_warning}` : '';
html += `
<div class="product-card ${rankClass} ${mdr1Class}">
<div class="product-rank">${rankText}</div>
${product.mdr1_warning ? `
<div class="mdr1-alert mdr1-${product.mdr1_warning}">
${product.mdr1_message}
</div>
` : ''}
<div class="product-content">
<img src="${imageUrl}"
alt="${product.name}"
class="product-image"
onerror="this.src='https://via.placeholder.com/80x80?text=No+Image'">
<div class="product-details">
<div class="product-name">${product.name}</div>
<div class="product-component">${product.component_name}</div>
<div class="product-info">
<span class="product-tag target">${product.target_animals}</span>
${product.efficacy ? `<span class="product-tag">${product.efficacy}</span>` : ''}
</div>
</div>
</div>
${product.reason ? formatReasonWithSpecialNote(product.reason, product.name, product.component_name) : ''}
${product.dosage_info ? `
<div class="dosage-recommendation">
<div class="dosage-header">
<span class="dosage-icon">💊</span>
<span class="dosage-title">권장 용량</span>
</div>
<div class="dosage-content">${product.dosage_info.message}</div>
${product.dosage_info.details && product.dosage_info.details.frequency ? `
<div class="dosage-detail">
<span class="detail-label">투여 주기:</span>
<span class="detail-value">${product.dosage_info.details.frequency}</span>
</div>
` : ''}
${product.dosage_info.details && product.dosage_info.details.route ? `
<div class="dosage-detail">
<span class="detail-label">투여 방법:</span>
<span class="detail-value">${product.dosage_info.details.route}</span>
</div>
` : ''}
<div class="dosage-disclaimer">
⚠️ 표시된 용량은 일반적인 권장량입니다. 정확한 투여량은 수의사와 상담하세요.
</div>
</div>
` : ''}
${product.usage_guide ? renderUsageGuide(product.usage_guide) : ''}
<div class="related-products related-products-${product.idx}" style="display:none;"></div>
</div>
`;
});
html += `
<div class="warning-box">
<div class="icon">⚠️</div>
<div>
본 추천은 참고용이며, 증상이 지속되거나 악화되면 반드시 수의사와 상담하세요.
</div>
</div>
`;
content.innerHTML = html;
// 각 제품의 추천 제품 로드
result.recommendations.forEach(product => {
renderRelatedProducts(product.idx, product.name);
});
}
</script>
</body>
</html>
'''
# ============================================================
# 라우트
# ============================================================
def get_product_image(product):
"""제품 이미지 URL 반환 (없으면 동일 성분 제품에서 fallback)"""
# 1순위: 고도몰 CDN 이미지
if product.godoimage_url_f:
return product.godoimage_url_f
# 2순위: ani.0bin.in 이미지
if product.image_url1:
return product.image_url1
# 3순위: 동일 성분의 다른 제품에서 이미지 가져오기
if product.component_code:
fallback = session.query(APDB).filter(
APDB.component_code == product.component_code,
APDB.idx != product.idx,
(APDB.godoimage_url_f != None) | (APDB.image_url1 != None)
).first()
if fallback:
return fallback.godoimage_url_f or fallback.image_url1
return None
@app.route('/')
def index():
"""메인 페이지"""
# 증상 목록 조회 (카테고리별)
symptoms = session.query(Symptoms).order_by(Symptoms.prefix, Symptoms.symptom_code).all()
symptoms_by_category = {}
category_names = {
'a': '👁️ 눈',
'b': '🦷 구강/치아',
'c': '👂 귀',
'd': '🐾 피부/털',
'e': '🦵 다리/발',
'f': '🦴 뼈/관절',
'g': '🍽️ 소화기/배변'
}
for symptom in symptoms:
category = category_names.get(symptom.prefix, symptom.prefix_description or symptom.prefix)
if category not in symptoms_by_category:
symptoms_by_category[category] = []
symptoms_by_category[category].append({
'code': symptom.symptom_code,
'description': symptom.symptom_description
})
return render_template_string(HTML_TEMPLATE, symptoms_by_category=symptoms_by_category)
@app.route('/api/categories', methods=['GET'])
def get_categories():
"""카테고리별 제품 수 조회 API"""
try:
animal_type = request.args.get('animal_type', 'dog')
animal_ko = '' if animal_type == 'dog' else '고양이'
# 재고가 있는 제품 조회
inventory_products = session.query(APDB).join(
Inventory, Inventory.apdb_id == APDB.idx
).filter(
Inventory.transaction_type == 'INBOUND'
).distinct().all()
# 카테고리별 제품 수 계산
categories = []
for cat_code, cat_info in PRODUCT_CATEGORIES.items():
count = 0
keywords = cat_info.get('keywords', [])
for product in inventory_products:
# 동물 타입 체크 (ComponentCode에서)
component = session.query(ComponentCode).filter(
ComponentCode.code == product.component_code
).first()
if component:
target_animals = component.target_animals or []
# JSONB 리스트 형태 처리
if isinstance(target_animals, list):
target_str = ', '.join(target_animals).lower()
else:
target_str = str(target_animals).lower()
if animal_ko not in target_str and '개, 고양이' not in target_str and '고양이, 개' not in target_str:
continue
# 효능효과에서 키워드 매칭
efficacy = (product.efficacy_effect or '').lower()
# HTML 태그 제거
import re
efficacy_clean = re.sub(r'<[^>]+>', '', efficacy)
if any(kw in efficacy_clean for kw in keywords):
count += 1
if count > 0:
categories.append({
'code': cat_code,
'name': cat_info['name'],
'icon': cat_info['icon'],
'count': count,
'description': cat_info['description']
})
# 제품 수 기준 정렬
categories.sort(key=lambda x: x['count'], reverse=True)
return jsonify({
'success': True,
'categories': categories
})
except Exception as e:
print(f"카테고리 조회 오류: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'message': str(e),
'categories': []
})
@app.route('/api/recommend', methods=['POST'])
def recommend():
"""추천 API - 증상-성분 매핑 테이블 활용"""
try:
data = request.get_json()
animal_type = data.get('animal_type') # 'dog' or 'cat'
breed = data.get('breed') # 견종 (MDR-1 체크용)
weight = data.get('weight')
pregnancy = data.get('pregnancy', 'none') # 임신/수유 여부
symptom_codes = data.get('symptoms', [])
if not animal_type or not symptom_codes:
return jsonify({
'success': False,
'message': '동물 종류와 증상을 선택해주세요.'
})
# 한글 변환
animal_ko = '' if animal_type == 'dog' else '고양이'
# 1. 선택된 증상 정보 조회
matched_symptoms = session.query(Symptoms).filter(
Symptoms.symptom_code.in_(symptom_codes)
).all()
symptom_descriptions = [s.symptom_description for s in matched_symptoms]
# 2. 증상-성분 매핑에서 관련 성분 코드 조회
mapped_components = session.query(SymptomComponentMapping.component_code).filter(
SymptomComponentMapping.symptom_code.in_(symptom_codes)
).distinct().all()
mapped_component_codes = [m[0] for m in mapped_components]
# 매핑된 성분이 없으면 추천 불가
if not mapped_component_codes:
return jsonify({
'success': True,
'animal_type': animal_type,
'matched_symptoms': symptom_descriptions,
'recommendations': [],
'total_count': 0,
'message': '선택하신 증상에 맞는 약품 매핑이 아직 없습니다. 수의사와 상담을 권장합니다.'
})
# 3. 재고 제품 조회 (INBOUND) + 매핑된 성분 필터
inventory_products = session.query(APDB).join(
Inventory, Inventory.apdb_id == APDB.idx
).filter(
Inventory.transaction_type == 'INBOUND',
APDB.component_code.in_(mapped_component_codes)
).distinct().all()
# 4. 동물 종류에 맞는 제품 필터링 + 점수 계산
recommendations = []
for product in inventory_products:
# ComponentCode에서 target_animals 확인
comp = session.query(ComponentCode).filter(
ComponentCode.code == product.component_code
).first()
if not comp:
continue
target_animals = comp.target_animals or []
# 대상 동물 체크 (target_animals가 없으면 전체 사용 가능으로 간주)
if target_animals and animal_ko not in target_animals:
continue # 해당 동물에게 사용 불가
# 이 성분이 몇 개의 선택 증상과 매핑되는지 점수 계산
matching_symptom_count = session.query(SymptomComponentMapping).filter(
SymptomComponentMapping.component_code == product.component_code,
SymptomComponentMapping.symptom_code.in_(symptom_codes)
).count()
# 효능효과에서 키워드 추출
efficacy = product.efficacy_effect or ''
efficacy_clean = re.sub(r'<[^>]+>', '', efficacy).lower()
# 용도 간단 설명 추출
efficacy_short = ''
if '외이도염' in efficacy_clean or '귓병' in efficacy_clean or '' in efficacy_clean:
efficacy_short = '귀 치료제'
elif '구토' in efficacy_clean:
efficacy_short = '구토 억제제'
elif '피부' in efficacy_clean or '진균' in efficacy_clean or '피부염' in efficacy_clean:
efficacy_short = '피부 치료제'
elif '관절' in efficacy_clean or '소염' in efficacy_clean or '진통' in efficacy_clean:
efficacy_short = '소염진통제'
elif '구충' in efficacy_clean or '기생충' in efficacy_clean or '진드기' in efficacy_clean:
efficacy_short = '구충제'
elif '항생' in efficacy_clean or '감염' in efficacy_clean:
efficacy_short = '항생제'
elif '' in efficacy_clean or '설사' in efficacy_clean or '정장' in efficacy_clean:
efficacy_short = '정장제'
recommendations.append({
'idx': product.idx, # 추천 제품 API 호출용
'name': product.product_name,
'product_idx': product.idx, # 용량 계산용
'component_code': product.component_code, # MDR-1 체크용
'component_name': comp.component_name_ko or product.component_name_ko or '',
'target_animals': ', '.join(target_animals) if target_animals else '전체',
'efficacy': efficacy_short,
'score': matching_symptom_count, # 매핑 점수
'image_url': get_product_image(product), # 고도몰 CDN 이미지 (fallback 포함)
'llm_pharm': product.llm_pharm, # AI 추천 이유 생성용
'efficacy_clean': efficacy_clean, # AI 추천 이유 생성용 fallback
'usage_guide': product.usage_guide # 상세 사용 안내 (희석 비율 등)
})
# 점수순 정렬 (높은 점수 = 더 많은 증상과 매핑)
recommendations.sort(key=lambda x: x['score'], reverse=True)
# 중복 제거 (동일 제품명)
seen_names = set()
unique_recommendations = []
for rec in recommendations:
if rec['name'] not in seen_names:
seen_names.add(rec['name'])
unique_recommendations.append(rec)
# 상위 5개만
unique_recommendations = unique_recommendations[:5]
# AI 추천 이유 생성 (병렬 처리)
def generate_reason_for_product(rec):
reason = generate_recommendation_reason(
animal_type=animal_type,
symptom_descriptions=symptom_descriptions,
product_name=rec['name'],
component_name=rec['component_name'],
llm_pharm=rec.get('llm_pharm'),
efficacy_clean=rec.get('efficacy_clean', ''),
component_code=rec.get('component_code'),
symptom_codes=symptom_codes
)
return {**rec, 'reason': reason}
with ThreadPoolExecutor(max_workers=5) as executor:
unique_recommendations = list(executor.map(generate_reason_for_product, unique_recommendations))
# MDR-1 체크 (강아지 + 견종 선택된 경우)
if animal_type == 'dog' and breed and breed != 'other':
breed_info = MDR1_BREEDS.get(breed, {})
breed_risk = breed_info.get('risk', 'none')
breed_name = breed_info.get('name', '')
for rec in unique_recommendations:
component_code = rec.get('component_code', '')
if component_code in MDR1_SENSITIVE_COMPONENTS:
if breed_risk == 'high':
rec['mdr1_warning'] = 'danger'
rec['mdr1_message'] = f"⚠️ {breed_name}은(는) MDR-1 유전자 변이 고위험 견종입니다. 이 약물(이버멕틴/셀라멕틴 계열)은 심각한 신경독성 부작용을 유발할 수 있습니다. 반드시 수의사 상담 후 사용하세요."
elif breed_risk == 'medium':
rec['mdr1_warning'] = 'caution'
rec['mdr1_message'] = f"⚠️ {breed_name}은(는) MDR-1 유전자 변이 중위험 견종입니다. 이 약물 사용 시 저용량부터 시작하고, 부작용 발생 시 즉시 수의사에게 연락하세요."
elif breed_risk == 'low':
rec['mdr1_warning'] = 'info'
rec['mdr1_message'] = f" {breed_name}은(는) MDR-1 저위험 견종이나, 개체별 차이가 있을 수 있습니다. 처음 사용 시 주의깊게 관찰하세요."
elif breed_risk == 'unknown':
rec['mdr1_warning'] = 'info'
rec['mdr1_message'] = " 견종에 따라 이 약물(Avermectin 계열)에 민감할 수 있습니다. 수의사 상담을 권장합니다."
# 체중 기반 용량 계산 + 피펫형 제품 필터링
weight_kg = None
try:
if weight:
weight_kg = float(weight)
except (ValueError, TypeError):
weight_kg = None
# 피펫형 제품 체중 필터링 적용
weight_filtered_recommendations = []
for rec in unique_recommendations:
if weight_kg and weight_kg > 0:
dosage_info = calculate_recommended_dosage(
product_idx=rec.get('product_idx'),
weight_kg=weight_kg,
animal_type=animal_type
)
# 피펫형 제품: 체중 범위에 맞지 않으면 제외
if dosage_info and dosage_info.get('is_pipette'):
if dosage_info.get('calculated'):
rec['dosage_info'] = dosage_info
weight_filtered_recommendations.append(rec)
# else: 체중 범위 밖이므로 제외 (추가하지 않음)
else:
# 피펫형이 아닌 제품은 그대로 유지
if dosage_info and dosage_info.get('calculated'):
rec['dosage_info'] = dosage_info
weight_filtered_recommendations.append(rec)
else:
# 체중 미입력 시 필터링 없이 모두 포함
weight_filtered_recommendations.append(rec)
unique_recommendations = weight_filtered_recommendations
# 응답에서 불필요한 필드 제거
for rec in unique_recommendations:
rec.pop('llm_pharm', None)
rec.pop('efficacy_clean', None)
rec.pop('component_code', None)
rec.pop('product_idx', None) # 용량 계산 후 제거
return jsonify({
'success': True,
'animal_type': animal_type,
'matched_symptoms': symptom_descriptions,
'recommendations': unique_recommendations,
'total_count': len(unique_recommendations),
'weight_kg': weight_kg, # 입력된 체중 반환
'pregnancy_status': pregnancy # 임신/수유 상태 반환
})
except Exception as e:
print(f"Error: {e}")
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
@app.route('/api/recommend_by_category', methods=['POST'])
def recommend_by_category():
"""제품군 기반 추천 API"""
try:
data = request.get_json()
animal_type = data.get('animal_type') # 'dog' or 'cat'
breed = data.get('breed') # 견종 (MDR-1 체크용)
category = data.get('category') # 제품군 코드
pregnancy = data.get('pregnancy', 'none') # 임신/수유 여부
if not animal_type or not category:
return jsonify({
'success': False,
'message': '동물 종류와 제품군을 선택해주세요.'
})
animal_ko = '' if animal_type == 'dog' else '고양이'
# 카테고리 정보
category_info = PRODUCT_CATEGORIES.get(category, {})
keywords = category_info.get('keywords', [])
category_name = category_info.get('name', category)
if not keywords:
return jsonify({
'success': False,
'message': '잘못된 제품군입니다.'
})
# 재고가 있는 제품 조회
inventory_products = session.query(APDB).join(
Inventory, Inventory.apdb_id == APDB.idx
).filter(
Inventory.transaction_type == 'INBOUND'
).distinct().all()
recommendations = []
import re
for product in inventory_products:
# 동물 타입 체크 (ComponentCode에서)
component = session.query(ComponentCode).filter(
ComponentCode.code == product.component_code
).first()
if component:
target_animals = component.target_animals or []
# JSONB 리스트 형태 처리
if isinstance(target_animals, list):
target_str = ', '.join(target_animals).lower()
else:
target_str = str(target_animals).lower()
if animal_ko not in target_str and '개, 고양이' not in target_str and '고양이, 개' not in target_str:
continue
# 효능효과에서 키워드 매칭
efficacy = (product.efficacy_effect or '').lower()
efficacy_clean = re.sub(r'<[^>]+>', '', efficacy)
if any(kw in efficacy_clean for kw in keywords):
# 효능효과 짧게 추출
efficacy_short = efficacy_clean[:100] + '...' if len(efficacy_clean) > 100 else efficacy_clean
# target_animals 문자열로 변환
target_animals_str = ', '.join(component.target_animals) if component and isinstance(component.target_animals, list) else (component.target_animals if component else '')
rec = {
'idx': product.idx, # 추천 제품 API 호출용
'name': product.product_name,
'product_idx': product.idx, # 용량 계산용
'component_name': component.component_name_ko if component else product.component_code,
'component_code': product.component_code,
'target_animals': target_animals_str,
'efficacy': efficacy_short,
'image_url': get_product_image(product),
'usage_guide': product.usage_guide # 상세 사용 안내 (희석 비율 등)
}
# MDR-1 체크 (강아지이고 견종이 선택된 경우)
if animal_type == 'dog' and breed and breed != 'other':
if product.component_code in MDR1_SENSITIVE_COMPONENTS:
breed_info = MDR1_BREEDS.get(breed, {})
risk = breed_info.get('risk', 'none')
if risk == 'high':
rec['mdr1_warning'] = 'danger'
rec['mdr1_message'] = f"⚠️ <strong>{breed_info.get('name', breed)}</strong>은(는) 이 약물에 심각한 신경독성 부작용이 발생할 수 있습니다. 반드시 수의사 상담 후 사용하세요."
elif risk == 'medium':
rec['mdr1_warning'] = 'caution'
rec['mdr1_message'] = f"⚠️ <strong>{breed_info.get('name', breed)}</strong>은(는) 이 약물에 민감할 수 있습니다. 저용량부터 시작하고 모니터링하세요."
elif risk == 'unknown':
rec['mdr1_warning'] = 'info'
rec['mdr1_message'] = " 견종에 따라 이 약물에 민감할 수 있습니다. 수의사 상담을 권장합니다."
recommendations.append(rec)
# 중복 제거 (제품명 기준)
seen_names = set()
unique_recommendations = []
for rec in recommendations:
if rec['name'] not in seen_names:
seen_names.add(rec['name'])
unique_recommendations.append(rec)
# 체중 기반 용량 계산 + 피펫형 제품 필터링
weight = data.get('weight')
weight_kg = None
try:
if weight:
weight_kg = float(weight)
except (ValueError, TypeError):
weight_kg = None
# 피펫형 제품 체중 필터링 적용
weight_filtered_recommendations = []
for rec in unique_recommendations:
if weight_kg and weight_kg > 0:
dosage_info = calculate_recommended_dosage(
product_idx=rec.get('product_idx'),
weight_kg=weight_kg,
animal_type=animal_type
)
# 피펫형 제품: 체중 범위에 맞지 않으면 제외
if dosage_info and dosage_info.get('is_pipette'):
if dosage_info.get('calculated'):
rec['dosage_info'] = dosage_info
weight_filtered_recommendations.append(rec)
# else: 체중 범위 밖이므로 제외 (추가하지 않음)
else:
# 피펫형이 아닌 제품은 그대로 유지
if dosage_info and dosage_info.get('calculated'):
rec['dosage_info'] = dosage_info
weight_filtered_recommendations.append(rec)
else:
# 체중 미입력 시 필터링 없이 모두 포함
weight_filtered_recommendations.append(rec)
unique_recommendations = weight_filtered_recommendations
# component_code, product_idx 제거
for rec in unique_recommendations:
rec.pop('component_code', None)
rec.pop('product_idx', None)
return jsonify({
'success': True,
'animal_type': animal_type,
'category_name': category_name,
'recommendations': unique_recommendations,
'total_count': len(unique_recommendations),
'weight_kg': weight_kg,
'pregnancy_status': pregnancy # 임신/수유 상태 반환
})
except Exception as e:
print(f"제품군 추천 오류: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
# ============================================================
# 통합 추천 API (v2) - APDB + 보조제품
# ============================================================
def get_supplementary_image(supp):
"""보조제품 이미지 URL 반환"""
return supp.image_url or '/static/img/no-image.png'
def has_supplementary_stock(supplementary_id):
"""보조제품 재고 확인"""
inv = session.query(InventorySupplementary).filter(
InventorySupplementary.supplementary_id == supplementary_id,
InventorySupplementary.quantity > 0
).first()
return inv is not None
@app.route('/api/product/<int:product_idx>/recommendations')
def get_product_recommendations(product_idx):
"""
특정 APDB 제품의 통합 추천 목록 조회
- 관련 APDB 제품 (약품 → 약품)
- 관련 보조제품 (약품 → 건기식/기구/케어)
"""
try:
result = {
"success": True,
"product_idx": product_idx,
"apdb_recommendations": [],
"supplementary_recommendations": []
}
# 1. APDB → APDB 추천
apdb_recs = session.query(UnifiedProductRecommendation).filter(
UnifiedProductRecommendation.source_type == 'apdb',
UnifiedProductRecommendation.source_id == product_idx,
UnifiedProductRecommendation.target_type == 'apdb',
UnifiedProductRecommendation.is_active == True
).order_by(UnifiedProductRecommendation.priority.desc()).all()
for rec in apdb_recs:
product = session.query(APDB).filter(APDB.idx == rec.target_id).first()
if not product:
continue
# 재고 확인
has_stock = session.query(Inventory).filter(
Inventory.apdb_id == product.idx,
Inventory.transaction_type == 'INBOUND'
).first() is not None
if has_stock:
result["apdb_recommendations"].append({
"idx": product.idx,
"name": product.product_name,
"image_url": get_product_image(product),
"relation_type": rec.relation_type,
"reason": rec.recommendation_reason,
"priority": rec.priority
})
# 2. APDB → Supplementary 추천
supp_recs = session.query(UnifiedProductRecommendation).filter(
UnifiedProductRecommendation.source_type == 'apdb',
UnifiedProductRecommendation.source_id == product_idx,
UnifiedProductRecommendation.target_type == 'supplementary',
UnifiedProductRecommendation.is_active == True
).order_by(UnifiedProductRecommendation.priority.desc()).all()
for rec in supp_recs:
supp = session.query(SupplementaryProduct).filter(
SupplementaryProduct.id == rec.target_id,
SupplementaryProduct.is_active == True
).first()
if supp and has_supplementary_stock(supp.id):
result["supplementary_recommendations"].append({
"id": supp.id,
"name": supp.product_name,
"brand": supp.brand,
"category": supp.category,
"sub_category": supp.sub_category,
"image_url": get_supplementary_image(supp),
"relation_type": rec.relation_type,
"reason": rec.recommendation_reason,
"priority": rec.priority,
"price": float(supp.price) if supp.price else None,
"efficacy_tags": supp.efficacy_tags
})
result["total_apdb"] = len(result["apdb_recommendations"])
result["total_supplementary"] = len(result["supplementary_recommendations"])
return jsonify(result)
except Exception as e:
print(f"통합 추천 조회 오류: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
@app.route('/api/supplementary/<int:supp_id>')
def get_supplementary_detail(supp_id):
"""보조제품 상세 정보 조회"""
try:
supp = session.query(SupplementaryProduct).filter(
SupplementaryProduct.id == supp_id
).first()
if not supp:
return jsonify({
'success': False,
'message': '제품을 찾을 수 없습니다.'
})
# 재고 정보
inv = session.query(InventorySupplementary).filter(
InventorySupplementary.supplementary_id == supp_id
).first()
return jsonify({
'success': True,
'product': {
'id': supp.id,
'product_name': supp.product_name,
'product_name_en': supp.product_name_en,
'brand': supp.brand,
'manufacturer': supp.manufacturer,
'barcode': supp.barcode,
'product_code': supp.product_code,
'category': supp.category,
'sub_category': supp.sub_category,
'description': supp.description,
'usage_instructions': supp.usage_instructions,
'ingredients': supp.ingredients,
'main_ingredient': supp.main_ingredient,
'ingredient_details': supp.ingredient_details,
'efficacy': supp.efficacy,
'efficacy_tags': supp.efficacy_tags,
'target_animal': supp.target_animal,
'target_age': supp.target_age,
'target_size': supp.target_size,
'precautions': supp.precautions,
'warnings': supp.warnings,
'image_url': supp.image_url,
'image_url_2': supp.image_url_2,
'price': float(supp.price) if supp.price else None,
'unit': supp.unit,
'package_size': supp.package_size,
'external_url': supp.external_url,
'stock': inv.quantity if inv else 0
}
})
except Exception as e:
print(f"보조제품 상세 조회 오류: {e}")
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
@app.route('/api/supplementary')
def list_supplementary_products():
"""보조제품 목록 조회 (카테고리별 필터링)"""
try:
category = request.args.get('category') # supplement, equipment, care
sub_category = request.args.get('sub_category')
target_animal = request.args.get('target_animal')
query = session.query(SupplementaryProduct).filter(
SupplementaryProduct.is_active == True
)
if category:
query = query.filter(SupplementaryProduct.category == category)
if sub_category:
query = query.filter(SupplementaryProduct.sub_category == sub_category)
if target_animal:
query = query.filter(
or_(
SupplementaryProduct.target_animal == target_animal,
SupplementaryProduct.target_animal == 'all'
)
)
products = query.order_by(SupplementaryProduct.product_name).all()
result = []
for supp in products:
if has_supplementary_stock(supp.id):
result.append({
'id': supp.id,
'product_name': supp.product_name,
'brand': supp.brand,
'category': supp.category,
'sub_category': supp.sub_category,
'main_ingredient': supp.main_ingredient,
'efficacy_tags': supp.efficacy_tags,
'target_animal': supp.target_animal,
'image_url': get_supplementary_image(supp),
'price': float(supp.price) if supp.price else None,
'package_size': supp.package_size
})
return jsonify({
'success': True,
'products': result,
'total': len(result)
})
except Exception as e:
print(f"보조제품 목록 조회 오류: {e}")
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
@app.route('/api/symptoms/supplementary-recommendations', methods=['POST'])
def get_symptom_supplementary_recommendations():
"""증상 선택 시 보조제품 추천"""
try:
data = request.get_json()
symptom_codes = data.get('symptom_codes', [])
animal_type = data.get('animal_type') # dog, cat
if not symptom_codes:
return jsonify({
'success': False,
'message': '증상을 선택해주세요.'
})
# 증상 → 보조제품 매핑 조회
recommendations = session.query(SymptomSupplementaryRecommendation).filter(
SymptomSupplementaryRecommendation.symptom_code.in_(symptom_codes),
SymptomSupplementaryRecommendation.is_active == True
).order_by(SymptomSupplementaryRecommendation.priority.desc()).all()
result = []
seen_ids = set()
for rec in recommendations:
if rec.supplementary_id in seen_ids:
continue
supp = session.query(SupplementaryProduct).filter(
SupplementaryProduct.id == rec.supplementary_id,
SupplementaryProduct.is_active == True
).first()
if not supp:
continue
# 동물 타입 필터링
if animal_type and supp.target_animal:
if supp.target_animal != 'all' and supp.target_animal != animal_type:
continue
# 재고 확인
if not has_supplementary_stock(supp.id):
continue
seen_ids.add(rec.supplementary_id)
# 매칭된 증상 정보 조회
symptom = session.query(Symptoms).filter(
Symptoms.symptom_code == rec.symptom_code
).first()
result.append({
'id': supp.id,
'product_name': supp.product_name,
'brand': supp.brand,
'category': supp.category,
'sub_category': supp.sub_category,
'main_ingredient': supp.main_ingredient,
'efficacy_tags': supp.efficacy_tags,
'image_url': get_supplementary_image(supp),
'price': float(supp.price) if supp.price else None,
'relation_type': rec.relation_type,
'reason': rec.recommendation_reason,
'matched_symptom': symptom.description if symptom else rec.symptom_code
})
return jsonify({
'success': True,
'supplementary_recommendations': result,
'total': len(result)
})
except Exception as e:
print(f"증상 기반 보조제품 추천 오류: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'message': f'오류가 발생했습니다: {str(e)}'
})
# ============================================================
# 실행
# ============================================================
if __name__ == '__main__':
print("=" * 60)
print("🐾 동물약 추천 MVP 웹앱")
print("=" * 60)
print("접속 주소: http://localhost:7001")
print("종료: Ctrl+C")
print("=" * 60)
app.run(host='0.0.0.0', port=7001, debug=True)