- 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>
3120 lines
122 KiB
Python
3120 lines
122 KiB
Python
"""
|
||
동물약 추천 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)
|