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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
*.log
|
*.log
|
||||||
|
_dev_scripts/
|
||||||
|
|||||||
615
clawdbot_client.py
Normal file
615
clawdbot_client.py
Normal 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)
|
||||||
97
db_setup.py
97
db_setup.py
@@ -963,5 +963,100 @@ class InventorySupplementary(Base):
|
|||||||
supplementary = relationship('SupplementaryProduct', back_populates='inventories')
|
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)
|
Base.metadata.create_all(engine)
|
||||||
@@ -8,21 +8,34 @@
|
|||||||
from flask import Flask, render_template_string, request, jsonify
|
from flask import Flask, render_template_string, request, jsonify
|
||||||
from db_setup import (
|
from db_setup import (
|
||||||
session, APDB, Inventory, ComponentCode, Symptoms, SymptomComponentMapping, DosageInfo,
|
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
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import openai
|
|
||||||
import re
|
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 = "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"
|
||||||
openai.api_key = OPENAI_API_KEY
|
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 유전자 변이 관련 설정
|
# 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):
|
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:
|
try:
|
||||||
# 한글 동물 타입
|
# 한글 동물 타입
|
||||||
animal_ko = '강아지' if animal_type == 'dog' else '고양이'
|
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
|
max_tok = 280 if is_complex_formula else 120
|
||||||
|
|
||||||
|
# OpenAI GPT-4o-mini (빠른 응답)
|
||||||
client = openai.OpenAI(api_key=OPENAI_API_KEY)
|
client = openai.OpenAI(api_key=OPENAI_API_KEY)
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model="gpt-4o-mini",
|
model="gpt-4o-mini",
|
||||||
@@ -2608,13 +2622,49 @@ def recommend():
|
|||||||
|
|
||||||
unique_recommendations = weight_filtered_recommendations
|
unique_recommendations = weight_filtered_recommendations
|
||||||
|
|
||||||
# 응답에서 불필요한 필드 제거
|
# 응답에서 불필요한 필드 제거 (로그 저장용 데이터 먼저 추출)
|
||||||
|
log_products = []
|
||||||
for rec in unique_recommendations:
|
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('llm_pharm', None)
|
||||||
rec.pop('efficacy_clean', None)
|
rec.pop('efficacy_clean', None)
|
||||||
rec.pop('component_code', None)
|
rec.pop('component_code', None)
|
||||||
rec.pop('product_idx', 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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'animal_type': animal_type,
|
'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'])
|
@app.route('/api/recommend_by_category', methods=['POST'])
|
||||||
def recommend_by_category():
|
def recommend_by_category():
|
||||||
"""제품군 기반 추천 API"""
|
"""제품군 기반 추천 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()">×</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__':
|
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("=" * 60)
|
||||||
print("🐾 동물약 추천 MVP 웹앱")
|
print("[PET] 동물약 추천 MVP 웹앱")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("접속 주소: http://localhost:7001")
|
print("접속 주소: http://localhost:7002")
|
||||||
print("종료: Ctrl+C")
|
print("종료: Ctrl+C")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
app.run(host='0.0.0.0', port=7001, debug=True)
|
app.run(host='0.0.0.0', port=7002, debug=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user