feat: clawdbot 클라이언트 추가 및 DB/앱 업데이트

- clawdbot_client.py: 챗봇 연동 클라이언트
- db_setup.py, pet_recommend_app.py 수정
- .gitignore: _dev_scripts/ 제외 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
청춘약국
2026-04-06 18:18:14 +09:00
parent b66129b5d0
commit 297dd8e601
4 changed files with 1283 additions and 13 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ __pycache__/
venv/
.venv/
*.log
_dev_scripts/

615
clawdbot_client.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = '''
<!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', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container { max-width: 1400px; margin: 0 auto; }
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
}
.header h1 { font-size: 24px; margin-bottom: 8px; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stat-card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
.stat-card .value { font-size: 32px; font-weight: bold; color: #333; }
.stat-card .sub { font-size: 12px; color: #999; margin-top: 4px; }
.log-table {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.log-table table { width: 100%; border-collapse: collapse; }
.log-table th, .log-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #eee;
}
.log-table th { background: #f8f9fa; font-weight: 600; color: #333; }
.log-table tr:hover { background: #f8f9fa; }
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge-dog { background: #e3f2fd; color: #1976d2; }
.badge-cat { background: #fce4ec; color: #c2185b; }
.badge-opus { background: #e8f5e9; color: #388e3c; }
.badge-pending { background: #fff3e0; color: #f57c00; }
.symptom-tag {
display: inline-block;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
margin: 2px;
}
.product-list { font-size: 13px; color: #666; }
.expand-btn {
background: none;
border: 1px solid #ddd;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.expand-btn:hover { background: #f0f0f0; }
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content {
background: white;
padding: 24px;
border-radius: 12px;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.comparison-box {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
}
.comparison-box h4 { margin-bottom: 8px; color: #333; }
.comparison-box pre {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
.refresh-btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.refresh-btn:hover { background: #5a67d8; }
.time-ago { color: #999; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐾 동물약 추천 관리자</h1>
<p>추천 조회 로그 및 GPT vs Opus 비교</p>
</div>
<div class="stats" id="stats">
<div class="stat-card">
<h3>오늘 조회</h3>
<div class="value" id="today-count">-</div>
<div class="sub">건</div>
</div>
<div class="stat-card">
<h3>전체 조회</h3>
<div class="value" id="total-count">-</div>
<div class="sub">건</div>
</div>
<div class="stat-card">
<h3>Opus 분석 완료</h3>
<div class="value" id="opus-count">-</div>
<div class="sub">건</div>
</div>
<div class="stat-card">
<h3>강아지 / 고양이</h3>
<div class="value" id="animal-ratio">-</div>
<div class="sub">비율</div>
</div>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
<button class="refresh-btn" onclick="loadLogs()">🔄 새로고침</button>
<select id="filterAnimal" onchange="loadLogs()" style="padding: 8px 12px; border-radius: 8px; border: 1px solid #ddd;">
<option value="">전체 동물</option>
<option value="dog">🐕 강아지</option>
<option value="cat">🐱 고양이</option>
</select>
<select id="filterOpus" onchange="loadLogs()" style="padding: 8px 12px; border-radius: 8px; border: 1px solid #ddd;">
<option value="">전체</option>
<option value="completed">Opus 완료</option>
<option value="pending">Opus 대기중</option>
</select>
</div>
<div class="log-table">
<table>
<thead>
<tr>
<th>시간</th>
<th>동물</th>
<th>체중</th>
<th>증상</th>
<th>추천 제품</th>
<th>Opus</th>
<th>상세</th>
</tr>
</thead>
<tbody id="log-body">
<tr><td colspan="7" style="text-align:center; padding: 40px;">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="modal" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h2>📋 상세 정보</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div id="modal-body"></div>
</div>
</div>
<script>
function timeAgo(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return `${diff}초 전`;
if (diff < 3600) return `${Math.floor(diff/60)}분 전`;
if (diff < 86400) return `${Math.floor(diff/3600)}시간 전`;
return `${Math.floor(diff/86400)}일 전`;
}
async function loadLogs() {
const animal = document.getElementById('filterAnimal').value;
const opus = document.getElementById('filterOpus').value;
let url = '/admin/api/logs?limit=50';
if (animal) url += `&animal=${animal}`;
if (opus) url += `&opus=${opus}`;
const res = await fetch(url);
const data = await res.json();
// Stats
document.getElementById('today-count').textContent = data.stats.today;
document.getElementById('total-count').textContent = data.stats.total;
document.getElementById('opus-count').textContent = data.stats.opus_completed;
document.getElementById('animal-ratio').textContent =
`${data.stats.dog_count} / ${data.stats.cat_count}`;
// Table
const tbody = document.getElementById('log-body');
if (data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding: 40px;">조회 로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = data.logs.map(log => `
<tr>
<td>
<div>${new Date(log.created_at).toLocaleString('ko-KR')}</div>
<div class="time-ago">${timeAgo(log.created_at)}</div>
</td>
<td>
<span class="badge badge-${log.animal_type}">
${log.animal_type === 'dog' ? '🐕 강아지' : '🐱 고양이'}
</span>
${log.breed ? `<div style="font-size:11px; color:#666;">${log.breed}</div>` : ''}
</td>
<td>${log.weight_kg ? log.weight_kg + 'kg' : '-'}</td>
<td>
${(log.symptom_descriptions || []).slice(0, 3).map(s =>
`<span class="symptom-tag">${s.length > 15 ? s.slice(0,15) + '...' : s}</span>`
).join('')}
${(log.symptom_descriptions || []).length > 3 ? `<span class="symptom-tag">+${log.symptom_descriptions.length - 3}</span>` : ''}
</td>
<td class="product-list">
${log.product_count}개 추천
${log.matched_products && log.matched_products[0] ?
`<div style="font-size:11px; color:#333;">${log.matched_products[0].product_name}</div>` : ''}
</td>
<td>
${log.opus_response ?
'<span class="badge badge-opus">✓ 완료</span>' :
'<span class="badge badge-pending">대기중</span>'}
</td>
<td>
<button class="expand-btn" onclick="showDetail(${log.id})">상세</button>
</td>
</tr>
`).join('');
}
async function showDetail(logId) {
const res = await fetch(`/admin/api/logs/${logId}`);
const log = await res.json();
const modal = document.getElementById('detailModal');
const body = document.getElementById('modal-body');
body.innerHTML = `
<div style="margin-bottom: 16px;">
<strong>입력 정보:</strong>
<div style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-top: 8px;">
동물: ${log.animal_type === 'dog' ? '🐕 강아지' : '🐱 고양이'}
${log.breed ? ` (${log.breed})` : ''}<br>
체중: ${log.weight_kg || '-'}kg<br>
증상: ${(log.symptom_descriptions || []).join(', ')}
</div>
</div>
<div style="margin-bottom: 16px;">
<strong>추천된 제품 (${log.product_count}개):</strong>
<div style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-top: 8px;">
${(log.matched_products || []).map(p =>
`<div style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #eee;">
<strong>${p.product_name}</strong> (${p.component_name})<br>
<span style="font-size: 13px; color: #666;">${p.reason || ''}</span>
</div>`
).join('')}
</div>
</div>
<div class="comparison">
<div class="comparison-box">
<h4>💬 GPT-4o-mini (즉시 응답)</h4>
<pre>${log.gpt_response || '(없음)'}</pre>
${log.gpt_response_time_ms ? `<div style="margin-top: 8px; font-size: 12px; color: #999;">${log.gpt_response_time_ms}ms</div>` : ''}
</div>
<div class="comparison-box">
<h4>🔬 Claude Opus (심층 분석)</h4>
<pre>${log.opus_response || '(분석 대기중...)'}</pre>
${log.opus_response_time_ms ? `<div style="margin-top: 8px; font-size: 12px; color: #999;">${log.opus_response_time_ms}ms</div>` : ''}
</div>
</div>
${log.evidence_references ? `
<div style="margin-top: 16px;">
<strong>📚 근거 자료:</strong>
<div style="background: #f8f9fa; padding: 12px; border-radius: 8px; margin-top: 8px;">
${JSON.stringify(log.evidence_references, null, 2)}
</div>
</div>
` : ''}
`;
modal.classList.add('active');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('active');
}
// 초기 로드
loadLogs();
// 30초마다 자동 새로고침
setInterval(loadLogs, 30000);
</script>
</body>
</html>
'''
@app.route('/admin')
def admin_page():
"""관리자 페이지"""
return ADMIN_HTML
@app.route('/admin/api/logs')
def admin_get_logs():
"""관리자 API - 로그 조회"""
try:
limit = request.args.get('limit', 50, type=int)
animal_filter = request.args.get('animal', '')
opus_filter = request.args.get('opus', '')
query = session.query(RecommendationLog).order_by(desc(RecommendationLog.created_at))
if animal_filter:
query = query.filter(RecommendationLog.animal_type == animal_filter)
if opus_filter == 'completed':
query = query.filter(RecommendationLog.opus_response.isnot(None))
elif opus_filter == 'pending':
query = query.filter(RecommendationLog.opus_response.is_(None))
logs = query.limit(limit).all()
# 통계
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
stats = {
'total': session.query(RecommendationLog).count(),
'today': session.query(RecommendationLog).filter(RecommendationLog.created_at >= today_start).count(),
'opus_completed': session.query(RecommendationLog).filter(RecommendationLog.opus_response.isnot(None)).count(),
'dog_count': session.query(RecommendationLog).filter(RecommendationLog.animal_type == 'dog').count(),
'cat_count': session.query(RecommendationLog).filter(RecommendationLog.animal_type == 'cat').count(),
}
return jsonify({
'logs': [{
'id': log.id,
'created_at': log.created_at.isoformat() if log.created_at else None,
'animal_type': log.animal_type,
'breed': log.breed,
'weight_kg': log.weight_kg,
'symptom_descriptions': log.symptom_descriptions,
'matched_products': log.matched_products,
'product_count': log.product_count,
'opus_response': log.opus_response[:100] + '...' if log.opus_response and len(log.opus_response) > 100 else log.opus_response,
} for log in logs],
'stats': stats
})
except Exception as e:
print(f"Admin logs error: {e}")
return jsonify({'logs': [], 'stats': {}})
@app.route('/admin/api/logs/<int:log_id>')
def admin_get_log_detail(log_id):
"""관리자 API - 로그 상세 조회"""
try:
log = session.query(RecommendationLog).filter_by(id=log_id).first()
if not log:
return jsonify({'error': 'Not found'}), 404
return jsonify({
'id': log.id,
'created_at': log.created_at.isoformat() if log.created_at else None,
'animal_type': log.animal_type,
'breed': log.breed,
'weight_kg': log.weight_kg,
'pregnancy_status': log.pregnancy_status,
'symptoms': log.symptoms,
'symptom_descriptions': log.symptom_descriptions,
'matched_products': log.matched_products,
'product_count': log.product_count,
'gpt_response': log.gpt_response,
'gpt_response_time_ms': log.gpt_response_time_ms,
'opus_response': log.opus_response,
'opus_response_time_ms': log.opus_response_time_ms,
'opus_analyzed_at': log.opus_analyzed_at.isoformat() if log.opus_analyzed_at else None,
'evidence_references': log.evidence_references,
'client_ip': log.client_ip,
})
except Exception as e:
print(f"Admin log detail error: {e}")
return jsonify({'error': str(e)}), 500
# ============================================================
# 실행
# ============================================================
if __name__ == '__main__':
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
print("=" * 60)
print("🐾 동물약 추천 MVP 웹앱")
print("[PET] 동물약 추천 MVP 웹앱")
print("=" * 60)
print("접속 주소: http://localhost:7001")
print("접속 주소: http://localhost:7002")
print("종료: Ctrl+C")
print("=" * 60)
app.run(host='0.0.0.0', port=7001, debug=True)
app.run(host='0.0.0.0', port=7002, debug=False)