diff --git a/.gitignore b/.gitignore index 82d9caa..4e7949e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ venv/ .venv/ *.log +_dev_scripts/ diff --git a/clawdbot_client.py b/clawdbot_client.py new file mode 100644 index 0000000..de6f058 --- /dev/null +++ b/clawdbot_client.py @@ -0,0 +1,615 @@ +""" +Clawdbot Gateway Python 클라이언트 +카카오톡 봇과 동일한 Gateway WebSocket API를 통해 Claude와 통신 +추가 API 비용 없음 (Claude Max 구독 재활용) +""" + +import sys +import os + +# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지) +# 주의: 다른 모듈에서 import 시 중복 설정 방지 +if sys.platform == 'win32' and __name__ == '__main__': + import io + if hasattr(sys.stdout, 'buffer') and not isinstance(sys.stdout, io.TextIOWrapper): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + os.environ.setdefault('PYTHONIOENCODING', 'utf-8') + +import json +import uuid +import asyncio +import logging +from pathlib import Path + +import websockets + +logger = logging.getLogger(__name__) + +# Gateway 설정 (clawdbot.json에서 읽기) +CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json' + + +def _load_gateway_config(): + """clawdbot.json에서 Gateway 설정 로드""" + try: + with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f: + config = json.load(f) + gw = config.get('gateway', {}) + return { + 'port': gw.get('port', 18789), + 'token': gw.get('auth', {}).get('token', ''), + } + except Exception as e: + logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}") + return {'port': 18789, 'token': ''} + + +async def _ask_gateway(message, session_id='pharmacy-upsell', + system_prompt=None, timeout=60, model=None): + """ + Clawdbot Gateway WebSocket API 호출 + + 프로토콜: + 1. WS 연결 + 2. 서버 → connect.challenge (nonce) + 3. 클라이언트 → connect 요청 (token) + 4. 서버 → connect 응답 (ok) + 5. 클라이언트 → agent 요청 + 6. 서버 → accepted (ack) → 최종 응답 + + Returns: + str: AI 응답 텍스트 (실패 시 None) + """ + config = _load_gateway_config() + url = f"ws://127.0.0.1:{config['port']}" + token = config['token'] + + try: + async with websockets.connect(url, max_size=25 * 1024 * 1024, + close_timeout=5) as ws: + # 1. connect.challenge 대기 + nonce = None + challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10) + challenge = json.loads(challenge_msg) + if challenge.get('event') == 'connect.challenge': + nonce = challenge.get('payload', {}).get('nonce') + + # 2. connect 요청 + connect_id = str(uuid.uuid4()) + connect_frame = { + 'type': 'req', + 'id': connect_id, + 'method': 'connect', + 'params': { + 'minProtocol': 3, + 'maxProtocol': 3, + 'client': { + 'id': 'gateway-client', + 'displayName': 'Pharmacy Upsell', + 'version': '1.0.0', + 'platform': 'win32', + 'mode': 'backend', + 'instanceId': str(uuid.uuid4()), + }, + 'caps': [], + 'auth': { + 'token': token, + }, + 'role': 'operator', + 'scopes': ['operator.admin'], + } + } + await ws.send(json.dumps(connect_frame)) + + # 3. connect 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + if data.get('id') == connect_id: + if not data.get('ok'): + error = data.get('error', {}).get('message', 'unknown') + logger.warning(f"[Clawdbot] connect 실패: {error}") + return None + break # 연결 성공 + + # 4. 모델 오버라이드 (sessions.patch) + if model: + patch_id = str(uuid.uuid4()) + patch_frame = { + 'type': 'req', + 'id': patch_id, + 'method': 'sessions.patch', + 'params': { + 'key': session_id, + 'model': model, + } + } + await ws.send(json.dumps(patch_frame)) + # patch 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + if data.get('id') == patch_id: + if not data.get('ok'): + logger.warning(f"[Clawdbot] sessions.patch 실패: {data.get('error', {}).get('message', 'unknown')}") + break + + # 5. agent 요청 + agent_id = str(uuid.uuid4()) + agent_params = { + 'message': message, + 'sessionId': session_id, + 'sessionKey': session_id, + 'timeout': timeout, + 'idempotencyKey': str(uuid.uuid4()), + } + if system_prompt: + agent_params['extraSystemPrompt'] = system_prompt + + agent_frame = { + 'type': 'req', + 'id': agent_id, + 'method': 'agent', + 'params': agent_params, + } + await ws.send(json.dumps(agent_frame)) + + # 5. agent 응답 대기 (accepted → final) + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=timeout + 30) + data = json.loads(msg) + + # 이벤트 무시 (tick 등) + if data.get('event'): + continue + + # 우리 요청에 대한 응답인지 확인 + if data.get('id') != agent_id: + continue + + payload = data.get('payload', {}) + status = payload.get('status') + + # accepted는 대기 + if status == 'accepted': + continue + + # 최종 응답 + if data.get('ok'): + payloads = payload.get('result', {}).get('payloads', []) + text = '\n'.join(p.get('text', '') for p in payloads if p.get('text')) + return text or None + else: + error = data.get('error', {}).get('message', 'unknown') + logger.warning(f"[Clawdbot] agent 실패: {error}") + return None + + except asyncio.TimeoutError: + logger.warning("[Clawdbot] Gateway 타임아웃") + return None + except (ConnectionRefusedError, OSError) as e: + logger.warning(f"[Clawdbot] Gateway 연결 실패 (꺼져있음?): {e}") + return None + except Exception as e: + logger.warning(f"[Clawdbot] Gateway 오류: {e}") + return None + + +def ask_clawdbot(message, session_id='pharmacy-upsell', + system_prompt=None, timeout=60, model=None): + """ + 동기 래퍼: Flask에서 직접 호출 가능 + + Args: + message: 사용자 메시지 + session_id: 세션 ID (대화 구분용) + system_prompt: 추가 시스템 프롬프트 + timeout: 타임아웃 (초) + model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5') + + Returns: + str: AI 응답 텍스트 (실패 시 None) + """ + try: + loop = asyncio.new_event_loop() + result = loop.run_until_complete( + _ask_gateway(message, session_id, system_prompt, timeout, model=model) + ) + loop.close() + return result + except Exception as e: + logger.warning(f"[Clawdbot] 호출 실패: {e}") + return None + + +# 업셀링 전용 ────────────────────────────────────── + +UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # 업셀링은 Sonnet (빠르고 충분) + +UPSELL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다. +고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다. +강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요. +반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요.""" + + +def generate_upsell(user_name, current_items, recent_products): + """ + 업셀링 추천 생성 + + Args: + user_name: 고객명 + current_items: 오늘 구매 품목 문자열 (예: "타이레놀, 챔프 시럽") + recent_products: 최근 구매 이력 문자열 + + Returns: + dict: {'product': '...', 'reason': '...', 'message': '...'} 또는 None + """ + prompt = f"""고객 이름: {user_name} +오늘 구매한 약: {current_items} +최근 구매 이력: {recent_products} + +위 정보를 바탕으로 이 고객에게 추천할 약품 하나를 제안해주세요. + +규칙: +1. 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천 +2. 실제 약국에서 판매하는 일반의약품/건강기능식품만 추천 (처방약 제외) +3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤 +4. 구체적인 제품명 사용 (예: "비타민C 1000", "오메가3" 등) + +응답은 반드시 아래 JSON 형식으로만: +{{"product": "추천 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [오늘 구매 품목]과 함께 [추천약]도 추천드려요. [간단한 이유]."}}""" + + response_text = ask_clawdbot( + prompt, + session_id=f'upsell-{user_name}', + system_prompt=UPSELL_SYSTEM_PROMPT, + timeout=30, + model=UPSELL_MODEL + ) + + if not response_text: + return None + + return _parse_upsell_response(response_text) + + +UPSELL_REAL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다. +고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다. +반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요. +목록에 없는 제품은 절대 추천하지 마세요. +강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요. +반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요.""" + + +def generate_upsell_real(user_name, current_items, recent_products, available_products): + """ + 실데이터 기반 업셀링 추천 생성 + available_products: 약국 보유 제품 리스트 [{'name': ..., 'price': ..., 'sales': ...}, ...] + """ + product_list = '\n'.join( + f"- {p['name']} ({int(p['price'])}원, 최근 {p['sales']}건 판매)" + for p in available_products if p.get('name') + ) + + prompt = f"""고객 이름: {user_name} +오늘 구매한 약: {current_items} +최근 구매 이력: {recent_products} + +[약국 보유 제품 목록 — 이 중에서만 추천하세요] +{product_list} + +규칙: +1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천 +2. 오늘 이미 구매한 제품은 추천하지 마세요 +3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤 +4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요 + +응답은 반드시 아래 JSON 형식으로만: +{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [추천 메시지 2문장 이내]"}}""" + + response_text = ask_clawdbot( + prompt, + session_id=f'upsell-real-{user_name}', + system_prompt=UPSELL_REAL_SYSTEM_PROMPT, + timeout=30, + model=UPSELL_MODEL + ) + + if not response_text: + return None + + return _parse_upsell_response(response_text) + + +# ===== Claude 상태 조회 ===== + +async def _get_gateway_status(): + """ + Clawdbot Gateway에서 세션 목록 조회 + 토큰 차감 없음 (AI 호출 아님) + """ + config = _load_gateway_config() + url = f"ws://127.0.0.1:{config['port']}" + token = config['token'] + + try: + async with websockets.connect(url, max_size=25 * 1024 * 1024, + close_timeout=5) as ws: + # 1. connect.challenge 대기 + challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10) + challenge = json.loads(challenge_msg) + nonce = None + if challenge.get('event') == 'connect.challenge': + nonce = challenge.get('payload', {}).get('nonce') + + # 2. connect 요청 + connect_id = str(uuid.uuid4()) + connect_frame = { + 'type': 'req', + 'id': connect_id, + 'method': 'connect', + 'params': { + 'minProtocol': 3, + 'maxProtocol': 3, + 'client': { + 'id': 'gateway-client', + 'displayName': 'Pharmacy Status', + 'version': '1.0.0', + 'platform': 'win32', + 'mode': 'backend', + 'instanceId': str(uuid.uuid4()), + }, + 'caps': [], + 'auth': {'token': token}, + 'role': 'operator', + 'scopes': ['operator.read'], + } + } + await ws.send(json.dumps(connect_frame)) + + # 3. connect 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + if data.get('id') == connect_id: + if not data.get('ok'): + error = data.get('error', {}).get('message', 'connect failed') + logger.warning(f"[Clawdbot] connect 실패: {error}") + return {'error': error, 'connected': False} + break + + # 4. sessions.list 요청 + list_id = str(uuid.uuid4()) + list_frame = { + 'type': 'req', + 'id': list_id, + 'method': 'sessions.list', + 'params': { + 'limit': 10 + } + } + await ws.send(json.dumps(list_frame)) + + # 5. 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + + # 이벤트 무시 + if data.get('event'): + continue + + if data.get('id') == list_id: + if data.get('ok'): + return { + 'connected': True, + 'sessions': data.get('payload', {}) + } + else: + error = data.get('error', {}).get('message', 'unknown') + return {'error': error, 'connected': True} + + except asyncio.TimeoutError: + logger.warning("[Clawdbot] Gateway 타임아웃") + return {'error': 'timeout', 'connected': False} + except (ConnectionRefusedError, OSError) as e: + logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}") + return {'error': str(e), 'connected': False} + except Exception as e: + logger.warning(f"[Clawdbot] 상태 조회 실패: {e}") + return {'error': str(e), 'connected': False} + + +def get_claude_status(): + """ + 동기 래퍼: Claude 상태 조회 + + Returns: + dict: 상태 정보 + """ + try: + loop = asyncio.new_event_loop() + result = loop.run_until_complete(_get_gateway_status()) + loop.close() + return result + except Exception as e: + logger.warning(f"[Clawdbot] 상태 조회 실패: {e}") + return {'error': str(e), 'connected': False} + + +def _parse_upsell_response(text): + """AI 응답에서 JSON 추출""" + import re + try: + # ```json ... ``` 블록 추출 시도 + json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + # 직접 JSON 파싱 시도 + start = text.find('{') + end = text.rfind('}') + if start >= 0 and end > start: + json_str = text[start:end + 1] + else: + return None + + data = json.loads(json_str) + + if 'product' not in data or 'message' not in data: + return None + + return { + 'product': data['product'], + 'reason': data.get('reason', ''), + 'message': data['message'], + } + except (json.JSONDecodeError, Exception) as e: + logger.warning(f"[Clawdbot] 업셀 응답 파싱 실패: {e}") + return None + + +# ===== 동물약 추천 ===== + +PET_RECOMMEND_MODEL = None # 기본 모델 사용 (claude-opus-4-5) + +PET_RECOMMEND_SYSTEM_PROMPT = """당신은 동물약국 전문 약사입니다. +반려동물의 증상을 보고 적합한 동물약을 추천합니다. + +주의사항: +- MDR-1 유전자 변이가 있는 견종(콜리, 셰퍼드, 보더콜리 등)에게 이버멕틴 계열은 신중히 권고 +- 체중에 맞는 용량 안내 필수 +- 임신/수유 중인 동물은 별도 주의 + +응답은 자연스럽고 전문적인 톤으로 작성하세요.""" + + +def generate_pet_recommendation(animal_type, weight, symptoms, breed=None, + is_pregnant=False, is_nursing=False, + available_products=None): + """ + 동물약 추천 생성 (Clawdbot Gateway 사용) + + Args: + animal_type: 'dog' 또는 'cat' + weight: 체중 (kg) + symptoms: 증상 리스트 (예: ['눈이 충혈됨', '눈곱이 많음']) + breed: 견종 (MDR-1 체크용, 선택) + is_pregnant: 임신 여부 + is_nursing: 수유 여부 + available_products: 약국 보유 제품 리스트 (선택) + + Returns: + str: AI 추천 텍스트 (실패 시 None) + """ + animal_name = "강아지" if animal_type == "dog" else "고양이" + symptoms_text = ", ".join(symptoms) if symptoms else "증상 없음" + + # 특이사항 구성 + notes = [] + if breed: + notes.append(f"견종: {breed}") + if is_pregnant: + notes.append("임신 중") + if is_nursing: + notes.append("수유 중") + notes_text = " / ".join(notes) if notes else "없음" + + # 제품 목록 구성 + product_list = "" + if available_products: + product_list = "\n\n[약국 보유 동물약 목록]\n" + "\n".join( + f"- {p.get('name', p)} ({p.get('price', '가격미정')}원)" + if isinstance(p, dict) else f"- {p}" + for p in available_products[:20] # 최대 20개 + ) + product_list += "\n\n위 목록에 있는 제품을 우선 추천해주세요." + + prompt = f"""반려동물 정보: +- 종류: {animal_name} +- 체중: {weight}kg +- 증상: {symptoms_text} +- 특이사항: {notes_text} +{product_list} + +위 정보를 바탕으로 적합한 동물약을 추천해주세요. + +추천 형식: +1. 추천 약품명 (1~2개) +2. 각 약품의 용법/용량 (체중 기준) +3. 추천 이유 +4. 주의사항 (있다면) + +간결하고 전문적으로 답변해주세요.""" + + session_id = f"pet-recommend-{animal_type}-{weight}kg" + + response = ask_clawdbot( + prompt, + session_id=session_id, + system_prompt=PET_RECOMMEND_SYSTEM_PROMPT, + timeout=60, + model=PET_RECOMMEND_MODEL + ) + + return response + + +def get_ai_product_description(product_name, animal_type=None): + """ + 특정 동물약에 대한 AI 설명 생성 + + Args: + product_name: 제품명 + animal_type: 'dog' 또는 'cat' (선택) + + Returns: + str: AI 설명 텍스트 + """ + animal_context = "" + if animal_type: + animal_name = "강아지" if animal_type == "dog" else "고양이" + animal_context = f" ({animal_name}용)" + + prompt = f"""동물약 '{product_name}'{animal_context}에 대해 간단히 설명해주세요. + +포함할 내용: +1. 주요 효능/효과 +2. 사용 대상 (어떤 동물, 어떤 증상) +3. 일반적인 용법 +4. 주의사항 + +3~4문장으로 간결하게 설명해주세요.""" + + return ask_clawdbot( + prompt, + session_id=f"pet-product-info", + system_prompt="당신은 동물약 전문 약사입니다. 정확하고 간결하게 설명해주세요.", + timeout=30, + model=PET_RECOMMEND_MODEL + ) + + +# ===== 테스트 ===== + +if __name__ == "__main__": + print("=" * 60) + print("[PET] Clawdbot 동물약 추천 테스트") + print("=" * 60) + + # 테스트: 동물약 추천 + print("\n[테스트] 동물약 추천...") + result = generate_pet_recommendation( + animal_type="dog", + weight=5, + symptoms=["눈이 충혈됨", "눈곱이 많음"], + breed="말티즈" + ) + + if result: + print(f"\n추천 결과:\n{result}") + else: + print("추천 실패") + + print("\n" + "=" * 60) diff --git a/db_setup.py b/db_setup.py index 839d743..32d406b 100644 --- a/db_setup.py +++ b/db_setup.py @@ -963,5 +963,100 @@ class InventorySupplementary(Base): supplementary = relationship('SupplementaryProduct', back_populates='inventories') -# 26. 테이블 생성 +# ============================================================ +# 26. RecommendationLog 테이블 (추천 조회 로그) +# 보호자 질문 → 추천 결과 → 백그라운드 분석 결과 저장 +# ============================================================ +class RecommendationLog(Base): + """추천 조회 로그 테이블 - GPT vs Opus 비교용""" + __tablename__ = 'recommendation_log' + __table_args__ = ( + Index('ix_reclog_created', 'created_at'), + Index('ix_reclog_animal', 'animal_type'), + Index('ix_reclog_session', 'session_id'), + {'comment': '동물약 추천 조회 로그'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + session_id = Column(String(100), nullable=True, comment='세션 ID') + + # 입력 정보 + animal_type = Column(String(10), nullable=False, comment='동물 종류: dog/cat') + breed = Column(String(100), nullable=True, comment='견종/묘종') + weight_kg = Column(Float, nullable=True, comment='체중 (kg)') + pregnancy_status = Column(String(20), nullable=True, comment='임신/수유 상태') + symptoms = Column(JSONB, nullable=True, comment='선택된 증상 코드 목록') + symptom_descriptions = Column(JSONB, nullable=True, comment='증상 설명 목록') + + # 추천 결과 + matched_products = Column(JSONB, nullable=True, comment='추천된 제품 목록') + product_count = Column(Integer, default=0, comment='추천 제품 수') + + # AI 응답 (즉시 응답) + gpt_response = Column(Text, nullable=True, comment='GPT-4o-mini 즉시 응답') + gpt_response_time_ms = Column(Integer, nullable=True, comment='GPT 응답 시간 (ms)') + + # AI 응답 (백그라운드 Opus) + opus_response = Column(Text, nullable=True, comment='Claude Opus 심층 분석') + opus_response_time_ms = Column(Integer, nullable=True, comment='Opus 응답 시간 (ms)') + opus_analyzed_at = Column(DateTime, nullable=True, comment='Opus 분석 완료 시간') + + # 근거 자료 + evidence_references = Column(JSONB, nullable=True, comment='PubMed 등 근거 자료') + evidence_fetched_at = Column(DateTime, nullable=True, comment='근거 자료 수집 시간') + + # 품질 비교 + quality_score_gpt = Column(Float, nullable=True, comment='GPT 응답 품질 점수') + quality_score_opus = Column(Float, nullable=True, comment='Opus 응답 품질 점수') + reviewed_at = Column(DateTime, nullable=True, comment='검토 완료 시간') + reviewer_notes = Column(Text, nullable=True, comment='검토자 메모') + + # 메타 정보 + created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now(), nullable=False, comment='생성 시간') + client_ip = Column(String(50), nullable=True, comment='클라이언트 IP') + user_agent = Column(String(500), nullable=True, comment='User Agent') + + +# ============================================================ +# 27. EvidenceReference 테이블 (근거 자료) +# PubMed 등에서 수집한 논문/근거 자료 +# ============================================================ +class EvidenceReference(Base): + """근거 자료 테이블 - PubMed 논문 등""" + __tablename__ = 'evidence_reference' + __table_args__ = ( + Index('ix_evidence_component', 'component_code'), + Index('ix_evidence_symptom', 'symptom_code'), + Index('ix_evidence_pubmed', 'pubmed_id'), + {'comment': '동물약 근거 자료 (PubMed 등)'} + ) + + id = Column(Integer, primary_key=True, comment='고유 ID') + + # 연결 정보 + component_code = Column(String(50), nullable=True, comment='성분 코드') + symptom_code = Column(String(20), nullable=True, comment='증상 코드') + product_idx = Column(Integer, nullable=True, comment='제품 IDX') + + # 논문 정보 + pubmed_id = Column(String(20), nullable=True, comment='PubMed ID') + doi = Column(String(100), nullable=True, comment='DOI') + title = Column(Text, nullable=False, comment='논문 제목') + authors = Column(Text, nullable=True, comment='저자') + journal = Column(String(300), nullable=True, comment='저널명') + publication_date = Column(Date, nullable=True, comment='출판일') + abstract = Column(Text, nullable=True, comment='초록') + + # 분석 정보 + relevance_score = Column(Float, nullable=True, comment='관련성 점수 (0~1)') + key_findings = Column(Text, nullable=True, comment='주요 발견 요약') + animal_species = Column(String(100), nullable=True, comment='연구 대상 동물') + + # 메타 정보 + source = Column(String(50), default='pubmed', comment='출처: pubmed, scholar, manual') + fetched_at = Column(DateTime, default=datetime.utcnow, comment='수집 시간') + is_verified = Column(Boolean, default=False, comment='검증 여부') + + +# 28. 테이블 생성 Base.metadata.create_all(engine) \ No newline at end of file diff --git a/pet_recommend_app.py b/pet_recommend_app.py index b158ec9..a8776e4 100644 --- a/pet_recommend_app.py +++ b/pet_recommend_app.py @@ -8,21 +8,34 @@ from flask import Flask, render_template_string, request, jsonify from db_setup import ( session, APDB, Inventory, ComponentCode, Symptoms, SymptomComponentMapping, DosageInfo, - SupplementaryProduct, UnifiedProductRecommendation, SymptomSupplementaryRecommendation, InventorySupplementary + SupplementaryProduct, UnifiedProductRecommendation, SymptomSupplementaryRecommendation, InventorySupplementary, + RecommendationLog, EvidenceReference ) -from sqlalchemy import distinct, and_, or_ +from sqlalchemy import distinct, and_, or_, desc from concurrent.futures import ThreadPoolExecutor -import openai import re +import time +import uuid +from datetime import datetime, timedelta -app = Flask(__name__) +# AI 설정: OpenAI (즉시응답) + Clawdbot (백그라운드 심층분석) +import openai +import threading -# ============================================================ -# OpenAI API 설정 -# ============================================================ OPENAI_API_KEY = "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" openai.api_key = OPENAI_API_KEY +# Clawdbot (백그라운드 Opus 분석용) +try: + from clawdbot_client import ask_clawdbot + CLAWDBOT_AVAILABLE = True +except ImportError: + ask_clawdbot = None + CLAWDBOT_AVAILABLE = False + print("[WARNING] clawdbot_client 모듈 없음. 백그라운드 Opus 분석 불가.") + +app = Flask(__name__) + # ============================================================ # MDR-1 유전자 변이 관련 설정 # ============================================================ @@ -303,7 +316,7 @@ def calculate_recommended_dosage(product_idx, weight_kg, animal_type): 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로 추천 이유 생성""" + """OpenAI GPT-4o-mini로 즉시 추천 이유 생성 (빠른 응답)""" try: # 한글 동물 타입 animal_ko = '강아지' if animal_type == 'dog' else '고양이' @@ -368,6 +381,7 @@ def generate_recommendation_reason(animal_type, symptom_descriptions, product_na # 복합 성분 제품은 더 긴 응답 허용 max_tok = 280 if is_complex_formula else 120 + # OpenAI GPT-4o-mini (빠른 응답) client = openai.OpenAI(api_key=OPENAI_API_KEY) response = client.chat.completions.create( model="gpt-4o-mini", @@ -2608,13 +2622,49 @@ def recommend(): unique_recommendations = weight_filtered_recommendations - # 응답에서 불필요한 필드 제거 + # 응답에서 불필요한 필드 제거 (로그 저장용 데이터 먼저 추출) + log_products = [] for rec in unique_recommendations: + log_products.append({ + 'product_name': rec.get('name'), # 키가 'name'임 + 'component_name': rec.get('component_name'), + 'reason': rec.get('reason', '')[:500] # 로그용 요약 + }) rec.pop('llm_pharm', None) rec.pop('efficacy_clean', None) rec.pop('component_code', None) rec.pop('product_idx', None) # 용량 계산 후 제거 + # === 로그 저장 === + try: + log_entry = RecommendationLog( + session_id=str(uuid.uuid4())[:8], + animal_type=animal_type, + breed=breed, + weight_kg=weight_kg, + pregnancy_status=pregnancy, + symptoms=symptom_codes, + symptom_descriptions=symptom_descriptions, + matched_products=log_products, + product_count=len(unique_recommendations), + gpt_response=log_products[0].get('reason') if log_products else None, + client_ip=request.remote_addr, + user_agent=request.headers.get('User-Agent', '')[:500] + ) + session.add(log_entry) + session.commit() + + # 백그라운드 Opus 분석 트리거 (비동기) + if CLAWDBOT_AVAILABLE and log_products: + threading.Thread( + target=background_opus_analysis, + args=(log_entry.id, animal_type, symptom_descriptions, log_products), + daemon=True + ).start() + except Exception as log_error: + print(f"[LOG] 저장 실패: {log_error}") + session.rollback() + return jsonify({ 'success': True, 'animal_type': animal_type, @@ -2633,6 +2683,57 @@ def recommend(): }) +# ============================================================ +# 백그라운드 Opus 분석 +# ============================================================ +def background_opus_analysis(log_id, animal_type, symptom_descriptions, products): + """백그라운드에서 Claude Opus로 심층 분석 실행""" + try: + start_time = time.time() + animal_ko = '강아지' if animal_type == 'dog' else '고양이' + + product_list = "\n".join([ + f"- {p['product_name']} ({p['component_name']})" + for p in products[:5] # 최대 5개 + ]) + + prompt = f"""반려동물 약사로서 아래 상황을 심층 분석해주세요. + +동물: {animal_ko} +증상: {', '.join(symptom_descriptions)} +추천된 제품: +{product_list} + +다음을 분석해주세요: +1. 추천된 제품들의 적합성 평가 +2. 각 제품의 작용 기전 설명 +3. 잠재적 부작용/주의사항 +4. 제품 간 상호작용 여부 +5. 추가 권장 사항 + +전문적이고 상세하게 답변해주세요.""" + + opus_response = ask_clawdbot( + prompt, + session_id=f"opus-analysis-{log_id}", + system_prompt="당신은 동물약학 전문가입니다. 심층적이고 근거 기반의 분석을 제공합니다.", + timeout=120 + ) + + elapsed_ms = int((time.time() - start_time) * 1000) + + # DB 업데이트 + log_entry = session.query(RecommendationLog).filter_by(id=log_id).first() + if log_entry and opus_response: + log_entry.opus_response = opus_response + log_entry.opus_response_time_ms = elapsed_ms + log_entry.opus_analyzed_at = datetime.utcnow() + session.commit() + print(f"[OPUS] 분석 완료 (log_id={log_id}, {elapsed_ms}ms)") + except Exception as e: + print(f"[OPUS] 분석 실패: {e}") + + @app.route('/api/recommend_by_category', methods=['POST']) def recommend_by_category(): """제품군 기반 추천 API""" @@ -3104,16 +3205,474 @@ def get_symptom_supplementary_recommendations(): }) +# ============================================================ +# Admin 페이지 - 추천 로그 조회 +# ============================================================ + +ADMIN_HTML = ''' + + +
+ + +추천 조회 로그 및 GPT vs Opus 비교
+| 시간 | +동물 | +체중 | +증상 | +추천 제품 | +Opus | +상세 | +
|---|---|---|---|---|---|---|
| 로딩 중... | ||||||