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:
thug0bin 2026-02-26 20:38:04 +09:00
parent b5a99f7b3b
commit 5042cffb9f
7 changed files with 840 additions and 15 deletions

View File

@ -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 from flask import Flask, request, render_template, jsonify, redirect, url_for, session
import hashlib import hashlib
import base64 import base64
import secrets import secrets
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import sys
import os
import logging import logging
from sqlalchemy import text from sqlalchemy import text
from dotenv import load_dotenv from dotenv import load_dotenv
@ -2060,6 +2068,56 @@ def admin_alimtalk():
return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats) 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') @app.route('/api/admin/alimtalk/nhn-history')
def api_admin_alimtalk_nhn_history(): def api_admin_alimtalk_nhn_history():
"""NHN Cloud 실제 발송 내역 API""" """NHN Cloud 실제 발송 내역 API"""

View File

@ -4,6 +4,17 @@ Clawdbot Gateway Python 클라이언트
추가 API 비용 없음 (Claude Max 구독 재활용) 추가 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 json
import uuid import uuid
import asyncio import asyncio

View File

@ -398,7 +398,10 @@
<div class="header-title">📊 관리자 대시보드</div> <div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div> <div class="header-subtitle">청춘약국 마일리지 관리</div>
</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>
</div> </div>

View 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>

View File

@ -396,16 +396,21 @@
<!-- AI 추천 바텀시트 --> <!-- AI 추천 바텀시트 -->
<div id="rec-sheet" style="display:none;"> <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-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 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 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 id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
<div style="font-size:48px;margin-bottom:16px;">💊</div> <div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></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>
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);"> <div style="padding:0 24px 32px;">
<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> <div style="text-align:center;padding:8px 0 20px;">
<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="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> </div>
</div> </div>
@ -416,6 +421,87 @@
</style> </style>
<script> <script>
let _recId = null; 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() { window.addEventListener('load', function() {
{% if user_id %} {% if user_id %}
setTimeout(async function() { setTimeout(async function() {
@ -429,17 +515,25 @@
document.getElementById('rec-sheet').style.display = 'block'; document.getElementById('rec-sheet').style.display = 'block';
document.getElementById('rec-backdrop').onclick = dismissRec; document.getElementById('rec-backdrop').onclick = dismissRec;
} }
} catch(e) {} } catch(e) {
console.error('[AI추천] 에러:', e);
}
}, 1500); }, 1500);
{% endif %} {% endif %}
}); });
function dismissRec() { function dismissRec() {
const c = document.getElementById('rec-content'); const c = document.getElementById('rec-content');
const b = document.getElementById('rec-backdrop'); 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.opacity = '0';
b.style.transition = 'opacity .3s'; 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(){}); if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
} }
</script> </script>

173
docs/ai-upselling-crm.md Normal file
View 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))
"
```

View 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` 가드 필수