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

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)