feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화
- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세) - 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값) - Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py) - admin.html 헤더에 AI CRM 네비 링크 추가 - docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b5a99f7b3b
commit
5042cffb9f
@ -3,13 +3,21 @@ Flask 웹 서버 - QR 마일리지 적립
|
||||
간편 적립: 전화번호 + 이름만 입력
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
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')
|
||||
|
||||
from flask import Flask, request, render_template, jsonify, redirect, url_for, session
|
||||
import hashlib
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from dotenv import load_dotenv
|
||||
@ -2060,6 +2068,56 @@ def admin_alimtalk():
|
||||
return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats)
|
||||
|
||||
|
||||
@app.route('/admin/ai-crm')
|
||||
def admin_ai_crm():
|
||||
"""AI 업셀링 CRM 대시보드"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 추천 목록 (최근 50건) + 사용자 정보 JOIN
|
||||
cursor.execute("""
|
||||
SELECT r.*, u.nickname, u.phone as user_phone
|
||||
FROM ai_recommendations r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
recs = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# trigger_products JSON 파싱
|
||||
for rec in recs:
|
||||
tp = rec.get('trigger_products')
|
||||
if tp:
|
||||
try:
|
||||
rec['trigger_list'] = json.loads(tp)
|
||||
except Exception:
|
||||
rec['trigger_list'] = [tp]
|
||||
else:
|
||||
rec['trigger_list'] = []
|
||||
|
||||
# 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > datetime('now')) THEN 1 ELSE 0 END) as active_count,
|
||||
SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,
|
||||
SUM(CASE WHEN displayed_count > 0 THEN 1 ELSE 0 END) as displayed_count
|
||||
FROM ai_recommendations
|
||||
""")
|
||||
stats = dict(cursor.fetchone())
|
||||
|
||||
# 오늘 생성 건수
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as today_count
|
||||
FROM ai_recommendations
|
||||
WHERE date(created_at) = date('now')
|
||||
""")
|
||||
stats['today_count'] = cursor.fetchone()['today_count']
|
||||
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
return render_template('admin_ai_crm.html', recs=recs, stats=stats, now=now)
|
||||
|
||||
|
||||
@app.route('/api/admin/alimtalk/nhn-history')
|
||||
def api_admin_alimtalk_nhn_history():
|
||||
"""NHN Cloud 실제 발송 내역 API"""
|
||||
|
||||
@ -4,6 +4,17 @@ Clawdbot Gateway Python 클라이언트
|
||||
추가 API 비용 없음 (Claude Max 구독 재활용)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
if hasattr(sys.stdout, 'buffer'):
|
||||
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
|
||||
|
||||
@ -398,7 +398,10 @@
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
</div>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
412
backend/templates/admin_ai_crm.html
Normal file
412
backend/templates/admin_ai_crm.html
Normal file
@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 업셀링 CRM - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6366f1 50%, #8b5cf6 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #d97706; }
|
||||
.stat-value.indigo { color: #6366f1; }
|
||||
|
||||
/* ── 테이블 섹션 ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.section-sub {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: -0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr { cursor: pointer; transition: background .15s; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #16a34a; }
|
||||
.badge-dismissed { background: #f1f5f9; color: #64748b; }
|
||||
.badge-expired { background: #fee2e2; color: #dc2626; }
|
||||
.badge-trigger {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
margin: 1px 2px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.badge-product {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 메시지 말줄임 ── */
|
||||
.msg-ellipsis {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 노출 횟수 ── */
|
||||
.display-count {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.display-count.zero { color: #cbd5e1; }
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-field {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-raw {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-raw pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.empty-text { font-size: 14px; font-weight: 500; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.header { padding: 20px 16px 18px; }
|
||||
.content { padding: 16px 12px 40px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
||||
</div>
|
||||
<h1>AI 업셀링 CRM</h1>
|
||||
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체 생성</div>
|
||||
<div class="stat-value default">{{ stats.total or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Active</div>
|
||||
<div class="stat-value green">{{ stats.active_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">고객 반응 (닫기)</div>
|
||||
<div class="stat-value orange">{{ stats.dismissed_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">오늘 생성</div>
|
||||
<div class="stat-value indigo">{{ stats.today_count or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추천 목록 -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">추천 생성 로그</div>
|
||||
<div class="section-sub">최근 50건 · 클릭하여 상세 보기</div>
|
||||
</div>
|
||||
|
||||
{% if recs %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>생성일시</th>
|
||||
<th>고객</th>
|
||||
<th>트리거 품목</th>
|
||||
<th>추천 제품</th>
|
||||
<th>AI 메시지</th>
|
||||
<th>상태</th>
|
||||
<th style="text-align:center">노출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rec in recs %}
|
||||
<tr onclick="toggleDetail({{ rec.id }})">
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">
|
||||
{{ rec.created_at[5:16] if rec.created_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-weight:600;font-size:13px;">{{ rec.nickname or '알 수 없음' }}</div>
|
||||
{% if rec.user_phone %}
|
||||
<div style="font-size:11px;color:#94a3b8;">{{ rec.user_phone[:3] }}-****-{{ rec.user_phone[-4:] }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.trigger_list %}
|
||||
{% for item in rec.trigger_list %}
|
||||
<span class="badge badge-trigger">{{ item }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:#cbd5e1;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-product">{{ rec.recommended_product }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="msg-ellipsis" title="{{ rec.recommendation_message }}">{{ rec.recommendation_message }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.status == 'active' and (not rec.expires_at or rec.expires_at > now) %}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{% elif rec.status == 'dismissed' %}
|
||||
<span class="badge badge-dismissed">Dismissed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-expired">Expired</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="display-count {{ 'zero' if not rec.displayed_count else '' }}">
|
||||
{{ rec.displayed_count or 0 }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 상세 아코디언 -->
|
||||
<tr class="detail-row" id="detail-{{ rec.id }}">
|
||||
<td colspan="7">
|
||||
<div class="detail-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">추천 이유</div>
|
||||
<div class="detail-value">{{ rec.recommendation_reason or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">거래 ID</div>
|
||||
<div class="detail-value">{{ rec.transaction_id or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 일시</div>
|
||||
<div class="detail-value">{{ rec.displayed_at or '미노출' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">닫기 일시</div>
|
||||
<div class="detail-value">{{ rec.dismissed_at or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">만료 일시</div>
|
||||
<div class="detail-value">{{ rec.expires_at or '없음' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 횟수</div>
|
||||
<div class="detail-value">{{ rec.displayed_count or 0 }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if rec.ai_raw_response %}
|
||||
<div class="detail-raw">
|
||||
<div class="detail-label">AI 원본 응답</div>
|
||||
<pre>{{ rec.ai_raw_response }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<div class="empty-text">아직 생성된 AI 추천이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById('detail-' + id);
|
||||
if (!row) return;
|
||||
// 다른 열린 것 닫기
|
||||
document.querySelectorAll('.detail-row.open').forEach(function(el) {
|
||||
if (el.id !== 'detail-' + id) el.classList.remove('open');
|
||||
});
|
||||
row.classList.toggle('open');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -396,16 +396,21 @@
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:12px 24px 32px;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -416,6 +421,87 @@
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
@ -429,17 +515,25 @@
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec() {
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.animation = 'recSlideDown .3s ease forwards';
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){ document.getElementById('rec-sheet').style.display='none'; }, 300);
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
|
||||
173
docs/ai-upselling-crm.md
Normal file
173
docs/ai-upselling-crm.md
Normal file
@ -0,0 +1,173 @@
|
||||
# AI 업셀링 CRM — 마이페이지 맞춤 추천 시스템
|
||||
|
||||
## 개요
|
||||
키오스크 적립 시 고객 구매이력을 AI가 분석하여 맞춤 제품을 추천.
|
||||
고객이 알림톡 → 마이페이지 접속 시 바텀시트 팝업으로 자연스럽게 표시.
|
||||
|
||||
## 기술 스택
|
||||
- **AI 엔진**: Clawdbot Gateway (Claude Max 구독 재활용, 추가 비용 없음)
|
||||
- **통신**: WebSocket (`ws://127.0.0.1:18789`) — JSON-RPC 프로토콜
|
||||
- **저장소**: SQLite `ai_recommendations` 테이블
|
||||
- **프론트**: 바텀시트 UI (드래그 닫기 지원)
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```
|
||||
키오스크 적립 (POST /api/kiosk/claim)
|
||||
│
|
||||
├─ 1. 적립 처리 (기존)
|
||||
├─ 2. 알림톡 발송 (기존)
|
||||
└─ 3. AI 추천 생성 (fire-and-forget)
|
||||
│
|
||||
├─ 최근 구매 이력 수집 (SQLite + MSSQL SALE_SUB)
|
||||
├─ Clawdbot Gateway → Claude 호출
|
||||
├─ 추천 결과 → ai_recommendations 저장
|
||||
└─ 실패 시 무시 (추천은 부가 기능)
|
||||
|
||||
고객: 알림톡 버튼 클릭 → /my-page
|
||||
│
|
||||
├─ 1.5초 후 GET /api/recommendation/{user_id}
|
||||
│
|
||||
├─ 추천 있음 → 바텀시트 슬라이드업
|
||||
│ ├─ 아래로 드래그 → 닫기
|
||||
│ ├─ "다음에요" → dismiss
|
||||
│ └─ "관심있어요!" → dismiss + 기록
|
||||
│
|
||||
└─ 추천 없음 → 아무것도 안 뜸
|
||||
```
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
### `backend/services/clawdbot_client.py`
|
||||
Clawdbot Gateway Python 클라이언트.
|
||||
|
||||
**Gateway WebSocket 프로토콜 (v3):**
|
||||
1. WS 연결 → `ws://127.0.0.1:{port}`
|
||||
2. 서버 → `connect.challenge` 이벤트 (nonce 전달)
|
||||
3. 클라이언트 → `connect` 요청 (token + client info)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → `agent` 요청 (message + systemPrompt)
|
||||
6. 서버 → `accepted` ack → 최종 응답 (`payloads[].text`)
|
||||
|
||||
**주요 함수:**
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `_load_gateway_config()` | `~/.clawdbot/clawdbot.json`에서 port, token 읽기 |
|
||||
| `_ask_gateway(message, ...)` | async WebSocket 통신 |
|
||||
| `ask_clawdbot(message, ...)` | 동기 래퍼 (Flask에서 호출) |
|
||||
| `generate_upsell(user_name, current_items, recent_products)` | 업셀 프롬프트 구성 + 호출 + JSON 파싱 |
|
||||
| `_parse_upsell_response(text)` | AI 응답에서 JSON 추출 |
|
||||
|
||||
**Gateway 설정:**
|
||||
- 설정 파일: `~/.clawdbot/clawdbot.json`
|
||||
- Client ID: `gateway-client` (허용된 상수 중 하나)
|
||||
- Protocol: v3 (minProtocol=3, maxProtocol=3)
|
||||
|
||||
### `backend/db/mileage_schema.sql` — ai_recommendations 테이블
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL, -- "고려은단 비타민C 1000"
|
||||
recommendation_message TEXT NOT NULL, -- 고객에게 보여줄 메시지
|
||||
recommendation_reason TEXT, -- 내부용 추천 이유
|
||||
trigger_products TEXT, -- JSON: 트리거된 구매 품목
|
||||
ai_raw_response TEXT, -- AI 원본 응답
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/dismissed
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME, -- 7일 후 만료
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### `backend/app.py` — API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/recommendation/<user_id>` | GET | 최신 active 추천 조회 (마이페이지용) |
|
||||
| `/api/recommendation/<rec_id>/dismiss` | POST | 추천 닫기 (status→dismissed) |
|
||||
|
||||
**추천 생성 위치**: `api_kiosk_claim()` 함수 끝부분, `_generate_upsell_recommendation()` 호출
|
||||
|
||||
### `backend/templates/my_page.html` — 바텀시트 UI
|
||||
|
||||
**기능:**
|
||||
- 페이지 로드 1.5초 후 추천 API fetch
|
||||
- 💊 아이콘 + AI 메시지 + 제품명 배지 (보라색 그라디언트)
|
||||
- **터치 드래그 닫기**: 아래로 80px 이상 드래그하면 dismiss
|
||||
- 배경 탭 닫기, "다음에요"/"관심있어요!" 버튼
|
||||
- 슬라이드업/다운 CSS 애니메이션
|
||||
|
||||
## AI 프롬프트
|
||||
|
||||
**시스템 프롬프트:**
|
||||
```
|
||||
당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
반드시 JSON 형식으로만 응답하세요.
|
||||
```
|
||||
|
||||
**유저 프롬프트 구조:**
|
||||
```
|
||||
고객 이름: {name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
규칙:
|
||||
1. 함께 먹으면 좋은 약 1가지만 추천 (일반의약품/건강기능식품)
|
||||
2. 메시지 2문장 이내, 따뜻한 톤
|
||||
3. JSON: {"product": "...", "reason": "...", "message": "..."}
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"product": "고려은단 비타민C 1000",
|
||||
"reason": "감기약 구매로 면역력 보충 필요",
|
||||
"message": "김영빈님, 감기약 드시는 동안 비타민C도 함께 챙겨드시면 회복에 도움이 돼요."
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback 정책
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| Gateway 꺼져있음 | 추천 생성 스킵, 로그만 남김 |
|
||||
| AI 응답 파싱 실패 | 저장 안 함 |
|
||||
| 추천 없을 때 마이페이지 방문 | 바텀시트 안 뜸 |
|
||||
| 7일 경과 | `expires_at` 만료, 조회 안 됨 |
|
||||
| dismiss 후 재방문 | 같은 추천 안 뜸 (새 적립 시 새 추천 생성) |
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 1. Gateway 연결 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
print(ask_clawdbot('안녕'))
|
||||
"
|
||||
|
||||
# 2. 업셀 생성 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
import json
|
||||
from services.clawdbot_client import generate_upsell
|
||||
result = generate_upsell('홍길동', '타이레놀, 챔프시럽', '비타민C, 소화제')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
"
|
||||
|
||||
# 3. API 테스트
|
||||
curl https://mile.0bin.in/api/recommendation/1
|
||||
|
||||
# 4. DB 확인
|
||||
python -c "
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('db/mileage.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
for r in conn.execute('SELECT * FROM ai_recommendations ORDER BY id DESC LIMIT 5'):
|
||||
print(json.dumps(dict(r), ensure_ascii=False))
|
||||
"
|
||||
```
|
||||
74
docs/windows-utf8-encoding.md
Normal file
74
docs/windows-utf8-encoding.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Windows 콘솔 한글 인코딩 (UTF-8) 가이드
|
||||
|
||||
## 문제
|
||||
Windows 콘솔 기본 인코딩이 `cp949`여서 Python에서 한글 출력 시 깨짐 발생.
|
||||
Claude Code bash 터미널, cmd, PowerShell 모두 동일 증상.
|
||||
|
||||
```
|
||||
# 깨진 출력 예시
|
||||
{"product": "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>", "message": "<22>迵<EFBFBD><E8BFB5><EFBFBD>, ..."}
|
||||
```
|
||||
|
||||
## 해결: 3단계 방어
|
||||
|
||||
### 1단계: Python 파일 상단 — sys.stdout UTF-8 래핑
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
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')
|
||||
```
|
||||
|
||||
**적용 위치**: `app.py`, `clawdbot_client.py` 등 진입점 파일 맨 위 (import 전)
|
||||
|
||||
> 모듈로 import되는 파일은 `hasattr(sys.stdout, 'buffer')` 체크 추가:
|
||||
> ```python
|
||||
> if sys.platform == 'win32':
|
||||
> import io
|
||||
> if hasattr(sys.stdout, 'buffer'):
|
||||
> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
> sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
> ```
|
||||
|
||||
### 2단계: 환경변수 — PYTHONIOENCODING
|
||||
```bash
|
||||
# ~/.bashrc (Claude Code bash 세션)
|
||||
export PYTHONIOENCODING=utf-8
|
||||
```
|
||||
|
||||
또는 실행 시:
|
||||
```bash
|
||||
PYTHONIOENCODING=utf-8 python backend/app.py
|
||||
```
|
||||
|
||||
### 3단계: json.dumps — ensure_ascii=False
|
||||
```python
|
||||
import json
|
||||
data = {"product": "비타민C", "message": "추천드려요"}
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
```
|
||||
`ensure_ascii=False` 없으면 `\uBE44\uD0C0\uBBFCC` 같은 유니코드 이스케이프로 출력됨.
|
||||
|
||||
## 프로젝트 내 적용 현황
|
||||
|
||||
| 파일 | 방식 |
|
||||
|------|------|
|
||||
| `backend/app.py` | sys.stdout 래핑 + PYTHONIOENCODING |
|
||||
| `backend/services/clawdbot_client.py` | sys.stdout 래핑 (buffer 체크) |
|
||||
| `backend/ai_tag_products.py` | sys.stdout 래핑 |
|
||||
| `backend/view_products.py` | sys.stdout 래핑 |
|
||||
| `backend/import_il1beta_foods.py` | sys.stdout 래핑 |
|
||||
| `backend/import_products_from_mssql.py` | sys.stdout 래핑 |
|
||||
| `backend/update_product_category.py` | sys.stdout 래핑 |
|
||||
| `backend/gui/check_cash.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `backend/gui/check_sunab.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `~/.bashrc` | `export PYTHONIOENCODING=utf-8` |
|
||||
|
||||
## 주의사항
|
||||
- Flask 로거(`logging.info()` 등)도 stderr로 출력하므로 **stderr도 반드시 래핑**
|
||||
- `io.TextIOWrapper`는 이미 래핑된 스트림에 중복 적용하면 에러남 → `hasattr(sys.stdout, 'buffer')` 체크
|
||||
- PyQt GUI에서는 stdout이 다를 수 있음 → `hasattr` 가드 필수
|
||||
Loading…
Reference in New Issue
Block a user