This commit is contained in:
OhSeongRak
2025-06-17 10:05:16 +09:00
commit 44d7312a85
178 changed files with 15106 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Python 가상환경
venv/
env/
ENV/
.venv/
.env/
# Python 캐시
__pycache__/
*.py[cod]
*$py.class
*.so
# 환경 변수 파일
.env
.env.local
.env.*.local
# IDE 설정
.vscode/
.idea/
*.swp
*.swo
+131
View File
@@ -0,0 +1,131 @@
"""
마케팅 팁 생성 API 엔드포인트
Java 서비스와 연동되는 API
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
import logging
from services.marketing_tip_service import MarketingTipService
from models.marketing_tip_models import MarketingTipGenerateRequest, MarketingTipResponse
logger = logging.getLogger(__name__)
# Blueprint 생성
marketing_tip_bp = Blueprint('marketing_tip', __name__)
# 서비스 인스턴스
marketing_tip_service = MarketingTipService()
@marketing_tip_bp.route('/api/v1/generate-marketing-tip', methods=['POST'])
def generate_marketing_tip():
"""
AI 마케팅 팁 생성 API
Java 서비스에서 호출하는 엔드포인트
"""
try:
# 요청 데이터 검증
if not request.is_json:
return jsonify({
'tip': '',
'status': 'error',
'message': 'Content-Type이 application/json이어야 합니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
data = request.get_json()
if not data:
return jsonify({
'tip': '',
'status': 'error',
'message': '요청 데이터가 없습니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
# 필수 필드 검증
if 'store_name' not in data or not data['store_name']:
return jsonify({
'tip': '',
'status': 'error',
'message': '매장명(store_name)은 필수입니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
if 'business_type' not in data or not data['business_type']:
return jsonify({
'tip': '',
'status': 'error',
'message': '업종(business_type)은 필수입니다.',
'generated_at': '',
'store_name': '',
'business_type': '',
'ai_model': ''
}), 400
logger.info(f"마케팅 팁 생성 요청: {data.get('store_name', 'Unknown')}")
# 요청 모델 생성
try:
request_model = MarketingTipGenerateRequest(**data)
except ValueError as e:
return jsonify({
'tip': '',
'status': 'error',
'message': f'요청 데이터 형식이 올바르지 않습니다: {str(e)}',
'generated_at': '',
'store_name': data.get('store_name', ''),
'business_type': data.get('business_type', ''),
'ai_model': ''
}), 400
# 매장 정보 구성
store_data = {
'store_name': request_model.store_name,
'business_type': request_model.business_type,
'location': request_model.location or '',
'seat_count': request_model.seat_count or 0
}
# 마케팅 팁 생성
result = marketing_tip_service.generate_marketing_tip(
store_data=store_data,
)
logger.info(f"마케팅 팁 생성 완료: {result.get('store_name', 'Unknown')}")
return jsonify(result), 200
except Exception as e:
logger.error(f"마케팅 팁 생성 API 오류: {str(e)}")
return jsonify({
'tip': '죄송합니다. 일시적인 오류로 마케팅 팁을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.',
'status': 'error',
'message': f'서버 오류가 발생했습니다: {str(e)}',
'generated_at': '',
'store_name': data.get('store_name', '') if 'data' in locals() else '',
'business_type': data.get('business_type', '') if 'data' in locals() else '',
'ai_model': 'error'
}), 500
@marketing_tip_bp.route('/api/v1/health', methods=['GET'])
def health_check():
"""
헬스체크 API
"""
return jsonify({
'status': 'healthy',
'service': 'marketing-tip-api',
'timestamp': datetime.now().isoformat()
}), 200
+307
View File
@@ -0,0 +1,307 @@
"""
AI 마케팅 서비스 Flask 애플리케이션
점주를 위한 마케팅 콘텐츠 및 포스터 자동 생성 서비스
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
from werkzeug.utils import secure_filename
import os
from datetime import datetime
import traceback
from config.config import Config
from services.sns_content_service import SnsContentService
from services.poster_service import PosterService
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
from api.marketing_tip_api import marketing_tip_bp
def create_app():
"""Flask 애플리케이션 팩토리"""
app = Flask(__name__)
app.config.from_object(Config)
# CORS 설정
CORS(app)
# 업로드 폴더 생성
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'temp'), exist_ok=True)
os.makedirs('templates/poster_templates', exist_ok=True)
# 서비스 인스턴스 생성
poster_service = PosterService()
sns_content_service = SnsContentService()
# Blueprint 등록
app.register_blueprint(marketing_tip_bp)
@app.route('/health', methods=['GET'])
def health_check():
"""헬스 체크 API"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'service': 'AI Marketing Service'
})
# ===== 새로운 API 엔드포인트 =====
@app.route('/api/ai/sns', methods=['GET'])
def generate_sns_content():
"""
SNS 게시물 생성 API (새로운 요구사항)
Java 서버에서 JSON 형태로 요청받아 HTML 형식의 게시물 반환
"""
try:
# JSON 요청 데이터 검증
if not request.is_json:
return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400
data = request.get_json()
if not data:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증
required_fields = ['title', 'category', 'contentType', 'platform', 'images']
for field in required_fields:
if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
# 요청 모델 생성
sns_request = SnsContentGetRequest(
title=data.get('title'),
category=data.get('category'),
contentType=data.get('contentType'),
platform=data.get('platform'),
images=data.get('images', []),
requirement=data.get('requirement'),
storeName=data.get('storeName'),
storeType=data.get('storeType'),
target=data.get('target'),
#toneAndManner=data.get('toneAndManner'),
#emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'),
eventName=data.get('eventName'),
startDate=data.get('startDate'),
endDate=data.get('endDate')
)
# SNS 콘텐츠 생성
result = sns_content_service.generate_sns_content(sns_request)
if result['success']:
return jsonify({'content': result['content']})
else:
return jsonify({'error': result['error']}), 500
except Exception as e:
app.logger.error(f"SNS 콘텐츠 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.route('/api/ai/poster', methods=['GET'])
def generate_poster_content():
"""
홍보 포스터 생성 API
실제 제품 이미지를 포함한 분위기 배경 포스터 생성
"""
try:
# JSON 요청 데이터 검증
if not request.is_json:
return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400
data = request.get_json()
if not data:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증
required_fields = ['title', 'category', 'contentType', 'images']
for field in required_fields:
if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
# 날짜 변환 처리
start_date = None
end_date = None
if data.get('startDate'):
try:
from datetime import datetime
start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'startDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
if data.get('endDate'):
try:
from datetime import datetime
end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'endDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
# 요청 모델 생성
poster_request = PosterContentGetRequest(
title=data.get('title'),
category=data.get('category'),
contentType=data.get('contentType'),
images=data.get('images', []),
photoStyle=data.get('photoStyle'),
requirement=data.get('requirement'),
toneAndManner=data.get('toneAndManner'),
emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'),
eventName=data.get('eventName'),
startDate=start_date,
endDate=end_date
)
# 포스터 생성 (V3 사용)
result = poster_service.generate_poster(poster_request)
if result['success']:
return jsonify({
'content': result['content'],
})
else:
return jsonify({'error': result['error']}), 500
except Exception as e:
app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500
# ===== 기존 API 엔드포인트 (하위 호환성) =====
@app.route('/api/content/generate', methods=['POST'])
def generate_content():
"""
마케팅 콘텐츠 생성 API (기존)
점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
files = request.files.getlist('images')
for file in files:
if file and file.filename:
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_filename = f"{timestamp}_{filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename)
file.save(file_path)
uploaded_files.append(file_path)
# 요청 모델 생성
content_request = ContentRequest(
category=request.form.get('category', '음식'),
platform=request.form.get('platform', '인스타그램'),
image_paths=uploaded_files,
start_time=request.form.get('start_time'),
end_time=request.form.get('end_time'),
store_name=request.form.get('store_name', ''),
additional_info=request.form.get('additional_info', '')
)
# 콘텐츠 생성
result = sns_content_service.generate_content(content_request)
# 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
return jsonify(result)
except Exception as e:
# 에러 발생 시 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
app.logger.error(f"콘텐츠 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.route('/api/poster/generate', methods=['POST'])
def generate_poster():
"""
홍보 포스터 생성 API (기존)
점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
files = request.files.getlist('images')
for file in files:
if file and file.filename:
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_filename = f"{timestamp}_{filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename)
file.save(file_path)
uploaded_files.append(file_path)
# 요청 모델 생성
poster_request = PosterRequest(
category=request.form.get('category', '음식'),
image_paths=uploaded_files,
start_time=request.form.get('start_time'),
end_time=request.form.get('end_time'),
store_name=request.form.get('store_name', ''),
event_title=request.form.get('event_title', ''),
discount_info=request.form.get('discount_info', ''),
additional_info=request.form.get('additional_info', '')
)
# 포스터 생성
result = poster_service.generate_poster(poster_request)
# 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
return jsonify(result)
except Exception as e:
# 에러 발생 시 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.errorhandler(413)
def too_large(e):
"""파일 크기 초과 에러 처리"""
return jsonify({'error': '업로드된 파일이 너무 큽니다. (최대 16MB)'}), 413
@app.errorhandler(500)
def internal_error(error):
"""내부 서버 에러 처리"""
return jsonify({'error': '내부 서버 오류가 발생했습니다.'}), 500
return app
if __name__ == '__main__':
app = create_app()
host = os.getenv('SERVER_HOST', '0.0.0.0')
port = int(os.getenv('SERVER_PORT', '5001'))
app.run(host=host, port=port, debug=True)
+1
View File
@@ -0,0 +1 @@
# Package initialization file
+39
View File
@@ -0,0 +1,39 @@
"""
Flask 애플리케이션 설정
환경변수를 통한 설정 관리
"""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""애플리케이션 설정 클래스"""
# Flask 기본 설정
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 파일 업로드 설정
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads'
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1536) # 16MB
# AI API 설정
CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY')
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
# Azure Blob Storage 설정
AZURE_STORAGE_ACCOUNT_NAME = os.environ.get('AZURE_STORAGE_ACCOUNT_NAME') or 'stdigitalgarage02'
AZURE_STORAGE_ACCOUNT_KEY = os.environ.get('AZURE_STORAGE_ACCOUNT_KEY')
AZURE_STORAGE_CONTAINER_NAME = os.environ.get('AZURE_STORAGE_CONTAINER_NAME') or 'ai-content'
# 지원되는 파일 확장자
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 템플릿 설정
POSTER_TEMPLATE_PATH = 'templates/poster_templates'
@staticmethod
def allowed_file(filename):
"""업로드 파일 확장자 검증"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 포트 노출
EXPOSE 5001
# 애플리케이션 실행
CMD ["python", "app.py"]
+153
View File
@@ -0,0 +1,153 @@
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/run/podman', memory: false),
emptyDirVolume(mountPath: '/root/.azure', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
stage("Get Source") {
checkout scm
props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
"""
}
}
}
stage('Build & Push Docker Image') {
container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
sh """
echo "=========================================="
echo "Building smarketing-ai Python Flask application"
echo "Image Tag: ${imageTag}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \
-f deployment/container/Dockerfile \
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
# 이미지 푸시
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
withCredentials([
string(credentialsId: 'secret-key', variable: 'SECRET_KEY'),
string(credentialsId: 'claude-api-key', variable: 'CLAUDE_API_KEY'),
string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY'),
string(credentialsId: 'azure-storage-account-name', variable: 'AZURE_STORAGE_ACCOUNT_NAME'),
string(credentialsId: 'azure-storage-account-key', variable: 'AZURE_STORAGE_ACCOUNT_KEY')
]) {
sh """
export namespace=${namespace}
export replicas=${props.replicas}
export resources_requests_cpu=${props.resources_requests_cpu}
export resources_requests_memory=${props.resources_requests_memory}
export resources_limits_cpu=${props.resources_limits_cpu}
export resources_limits_memory=${props.resources_limits_memory}
export upload_folder=${props.upload_folder}
export max_content_length=${props.max_content_length}
export allowed_extensions=${props.allowed_extensions}
export server_host=${props.server_host}
export server_port=${props.server_port}
export azure_storage_container_name=${props.azure_storage_container_name}
# 이미지 경로 환경변수 설정
export smarketing_image_path=${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
# Sensitive 환경변수 설정 (Jenkins Credentials에서)
export secret_key=\$SECRET_KEY
export claude_api_key=\$CLAUDE_API_KEY
export openai_api_key=\$OPENAI_API_KEY
export azure_storage_account_name=\$AZURE_STORAGE_ACCOUNT_NAME
export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY
# manifest 생성
envsubst < deployment/${manifest}.template > deployment/${manifest}
echo "Generated manifest file:"
cat deployment/${manifest}
"""
}
}
container('azure-cli') {
sh """
kubectl apply -f deployment/${manifest}
echo "Waiting for smarketing deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s
echo "=========================================="
echo "Getting LoadBalancer External IP..."
# External IP 확인 (최대 5분 대기)
for i in {1..30}; do
EXTERNAL_IP=\$(kubectl -n ${namespace} get service smarketing-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
if [ "\$EXTERNAL_IP" != "" ] && [ "\$EXTERNAL_IP" != "null" ]; then
echo "External IP assigned: \$EXTERNAL_IP"
break
fi
echo "Waiting for External IP... (attempt \$i/30)"
sleep 10
done
# 서비스 상태 확인
kubectl -n ${namespace} get pods -l app=smarketing
kubectl -n ${namespace} get service smarketing-service
echo "=========================================="
echo "Deployment Complete!"
echo "Service URL: http://\$EXTERNAL_IP:${props.server_port}"
echo "Health Check: http://\$EXTERNAL_IP:${props.server_port}/health"
echo "=========================================="
"""
}
}
}
}
+170
View File
@@ -0,0 +1,170 @@
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
],
volumes: [
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
stage("Get Source") {
checkout scm
props = readProperties file: "deployment/deploy_env_vars"
}
stage('Build & Push Docker Image') {
container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
sh """
echo "=========================================="
echo "Building smarketing-ai for ArgoCD GitOps"
echo "Image Tag: ${imageTag}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \
-f deployment/container/Dockerfile \
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
# 이미지 푸시
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
"""
}
}
}
stage('Update Manifest Repository') {
container('git') {
withCredentials([usernamePassword(
credentialsId: 'github-credentials-${props.teamid}',
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
# Git 설정
git config --global user.email "jenkins@company.com"
git config --global user.name "Jenkins CI"
# Manifest 저장소 클론 (팀별 저장소로 수정 필요)
git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git
cd smarketing-ai-manifest
echo "=========================================="
echo "Updating smarketing-ai manifest repository:"
echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
# smarketing deployment 파일 업데이트
if [ -f "smarketing/smarketing-deployment.yaml" ]; then
# 이미지 태그 업데이트
sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \
smarketing/smarketing-deployment.yaml
echo "Updated smarketing deployment to image tag: ${imageTag}"
cat smarketing/smarketing-deployment.yaml | grep "image:"
else
echo "Warning: smarketing-deployment.yaml not found"
echo "Creating manifest directory structure..."
# 기본 구조 생성
mkdir -p smarketing
# 기본 deployment 파일 생성
cat > smarketing/smarketing-deployment.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: smarketing
labels:
app: smarketing
spec:
replicas: 1
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: 256m
memory: 512Mi
limits:
cpu: 1024m
memory: 2048Mi
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}
EOF
echo "Created basic smarketing-deployment.yaml"
fi
# 변경사항 커밋 및 푸시
git add .
git commit -m "Update smarketing-ai image tag to ${imageTag}
Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
Build: ${env.BUILD_NUMBER}
Branch: ${env.BRANCH_NAME}
Commit: ${env.GIT_COMMIT}"
git push origin main
echo "=========================================="
echo "ArgoCD GitOps Update Completed!"
echo "Updated Service: smarketing-ai:${imageTag}"
echo "ArgoCD will automatically detect and deploy these changes."
echo "=========================================="
"""
}
}
}
}
}
@@ -0,0 +1,113 @@
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-config
namespace: ${namespace}
data:
SERVER_HOST: "${server_host}"
SERVER_PORT: "${server_port}"
UPLOAD_FOLDER: "${upload_folder}"
MAX_CONTENT_LENGTH: "${max_content_length}"
ALLOWED_EXTENSIONS: "${allowed_extensions}"
AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}"
---
# Secret
apiVersion: v1
kind: Secret
metadata:
name: smarketing-secret
namespace: ${namespace}
type: Opaque
stringData:
SECRET_KEY: "${secret_key}"
CLAUDE_API_KEY: "${claude_api_key}"
OPENAI_API_KEY: "${openai_api_key}"
AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}"
AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}"
---
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: ${namespace}
labels:
app: smarketing
spec:
replicas: ${replicas}
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: ${smarketing_image_path}
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
livenessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}
---
# Service (LoadBalancer type for External IP)
apiVersion: v1
kind: Service
metadata:
name: smarketing-service
namespace: ${namespace}
labels:
app: smarketing
spec:
type: LoadBalancer
ports:
- port: 5001
targetPort: 5001
protocol: TCP
name: http
selector:
app: smarketing
+27
View File
@@ -0,0 +1,27 @@
# Team Settings
teamid=won
root_project=smarketing-ai
namespace=smarketing
# Container Registry Settings
registry=acrdigitalgarage02.azurecr.io
image_org=won
# Application Settings
replicas=1
# Resource Settings
resources_requests_cpu=256m
resources_requests_memory=512Mi
resources_limits_cpu=1024m
resources_limits_memory=2048Mi
# Flask App Settings (non-sensitive)
upload_folder=/app/uploads
max_content_length=16777216
allowed_extensions=png,jpg,jpeg,gif,webp
server_host=0.0.0.0
server_port=5001
# Azure Storage Settings (non-sensitive)
azure_storage_container_name=ai-content
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-config
namespace: smarketing
data:
SERVER_HOST: "0.0.0.0"
SERVER_PORT: "5001"
UPLOAD_FOLDER: "/app/uploads"
MAX_CONTENT_LENGTH: "16777216" # 16MB
ALLOWED_EXTENSIONS: "png,jpg,jpeg,gif,webp"
@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: smarketing
labels:
app: smarketing
spec:
replicas: 1
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: acrdigitalgarage02.azurecr.io/smarketing-ai:latest
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: 256m
memory: 512Mi
limits:
cpu: 1024m
memory: 2048Mi
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}
@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-ingress
namespace: smarketing
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/enable-cors: "true"
spec:
rules:
- host: smarketing.20.249.184.228.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: smarketing-service
port:
number: 80
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: smarketing-secret
namespace: smarketing
type: Opaque
data:
OPENAI_API_KEY: c2stcHJvai1BbjRRX3VTNnNzQkxLU014VXBYTDBPM0lteUJuUjRwNVFTUHZkRnNSeXpFWGE0M21ISnhBcUkzNGZQOEduV2ZxclBpQ29VZ2pmbFQzQmxia0ZKZklMUGVqUFFIem9ZYzU4Yzc4UFkzeUo0dkowTVlfNGMzNV82dFlQUlkzTDBIODAwWWVvMnpaTmx6V3hXNk1RMFRzSDg5T1lNWUEK
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: smarketing-service
namespace: smarketing
labels:
app: smarketing
spec:
type: LoadBalancer
ports:
- port: 5001
targetPort: 5001
protocol: TCP
name: http
selector:
app: smarketing
+1
View File
@@ -0,0 +1 @@
# Package initialization file
@@ -0,0 +1,93 @@
"""
마케팅 팁 API 요청/응답 모델
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from datetime import datetime
class MenuInfo(BaseModel):
"""메뉴 정보 모델"""
menu_id: int = Field(..., description="메뉴 ID")
menu_name: str = Field(..., description="메뉴명")
category: str = Field(..., description="메뉴 카테고리")
price: int = Field(..., description="가격")
description: Optional[str] = Field(None, description="메뉴 설명")
class Config:
schema_extra = {
"example": {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
"menu_list": [
{
"menu_id": 1,
"menu_name": "아메리카노",
"category": "음료",
"price": 4000,
"description": "깊고 진한 맛의 아메리카노"
},
{
"menu_id": 2,
"menu_name": "카페라떼",
"category": "음료",
"price": 4500,
"description": "부드러운 우유 거품이 올라간 카페라떼"
},
{
"menu_id": 3,
"menu_name": "치즈케이크",
"category": "디저트",
"price": 6000,
"description": "진한 치즈 맛의 수제 케이크"
}
],
"additional_requirement": "젊은 고객층을 타겟으로 한 마케팅"
}
}
class MarketingTipGenerateRequest(BaseModel):
"""마케팅 팁 생성 요청 모델"""
store_name: str = Field(..., description="매장명")
business_type: str = Field(..., description="업종")
location: Optional[str] = Field(None, description="위치")
seat_count: Optional[int] = Field(None, description="좌석 수")
menu_list: Optional[List[MenuInfo]] = Field(default=[], description="메뉴 목록")
class Config:
schema_extra = {
"example": {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
}
}
class MarketingTipResponse(BaseModel):
"""마케팅 팁 응답 모델"""
tip: str = Field(..., description="생성된 마케팅 팁")
status: str = Field(..., description="응답 상태 (success, fallback, error)")
message: str = Field(..., description="응답 메시지")
generated_at: str = Field(..., description="생성 시간")
store_name: str = Field(..., description="매장명")
business_type: str = Field(..., description="업종")
ai_model: str = Field(..., description="사용된 AI 모델")
class Config:
schema_extra = {
"example": {
"tip": "☕ 더블샷 카페 여름 마케팅 전략\n\n💡 핵심 포인트:\n1. 여름 한정 시원한 음료 개발\n2. SNS 이벤트로 젊은 고객층 공략\n3. 더위 피할 수 있는 쾌적한 환경 어필",
"status": "success",
"message": "AI 마케팅 팁이 성공적으로 생성되었습니다.",
"generated_at": "2024-06-13T15:30:00",
"store_name": "더블샷 카페",
"business_type": "카페",
"ai_model": "claude"
}
}
+71
View File
@@ -0,0 +1,71 @@
"""
요청 모델 정의
API 요청 데이터 구조를 정의
"""
from dataclasses import dataclass
from typing import List, Optional
from datetime import date
@dataclass
class SnsContentGetRequest:
"""SNS 게시물 생성 요청 모델"""
title: str
category: str
contentType: str
platform: str
images: List[str] # 이미지 URL 리스트
target : Optional[str] = None # 타켓
requirement: Optional[str] = None
storeName: Optional[str] = None
storeType: Optional[str] = None
#toneAndManner: Optional[str] = None
#emotionIntensity: Optional[str] = None
menuName: Optional[str] = None
eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[date] = None # LocalDate -> date
@dataclass
class PosterContentGetRequest:
"""홍보 포스터 생성 요청 모델"""
title: str
category: str
contentType: str
images: List[str] # 이미지 URL 리스트
photoStyle: Optional[str] = None
requirement: Optional[str] = None
toneAndManner: Optional[str] = None
emotionIntensity: Optional[str] = None
menuName: Optional[str] = None
eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[date] = None # LocalDate -> date
# 기존 모델들은 유지
@dataclass
class ContentRequest:
"""마케팅 콘텐츠 생성 요청 모델 (기존)"""
category: str
platform: str
image_paths: List[str]
start_time: Optional[str] = None
end_time: Optional[str] = None
store_name: Optional[str] = None
additional_info: Optional[str] = None
@dataclass
class PosterRequest:
"""홍보 포스터 생성 요청 모델 (기존)"""
category: str
image_paths: List[str]
start_time: Optional[str] = None
end_time: Optional[str] = None
store_name: Optional[str] = None
event_title: Optional[str] = None
discount_info: Optional[str] = None
additional_info: Optional[str] = None
+9
View File
@@ -0,0 +1,9 @@
Flask==3.0.0
Flask-CORS==4.0.0
Pillow>=9.0.0
requests==2.31.0
anthropic>=0.25.0
openai>=1.12.0
python-dotenv==1.0.0
Werkzeug==3.0.1
azure-storage-blob>=12.19.0
+1
View File
@@ -0,0 +1 @@
# Package initialization file
@@ -0,0 +1,331 @@
"""
마케팅 팁 생성 서비스
Java 서비스에서 요청받은 매장 정보를 기반으로 AI 마케팅 팁을 생성
"""
import os
import logging
from typing import Dict, Any, Optional
import anthropic
import openai
from datetime import datetime
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MarketingTipService:
"""마케팅 팁 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.claude_api_key = os.getenv('CLAUDE_API_KEY')
self.openai_api_key = os.getenv('OPENAI_API_KEY')
# Claude 클라이언트 초기화
if self.claude_api_key:
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
else:
self.claude_client = None
logger.warning("Claude API 키가 설정되지 않았습니다.")
# OpenAI 클라이언트 초기화
if self.openai_api_key:
self.openai_client = openai.OpenAI(api_key=self.openai_api_key)
else:
self.openai_client = None
logger.warning("OpenAI API 키가 설정되지 않았습니다.")
def generate_marketing_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str] = None) -> Dict[str, Any]:
"""
매장 정보를 기반으로 AI 마케팅 팁 생성
Args:
store_data: 매장 정보 (store_name, business_type, location 등)
Returns:
생성된 마케팅 팁과 메타데이터
"""
try:
logger.info(f"마케팅 팁 생성 시작: {store_data.get('store_name', 'Unknown')}")
# 1. 프롬프트 생성
prompt = self._create_marketing_prompt(store_data, additional_requirement)
# 2. AI 서비스 호출 (Claude 우선, 실패 시 OpenAI)
tip_content = self._call_ai_service(prompt)
# 3. 응답 데이터 구성
response = {
'tip': tip_content,
'status': 'success',
'message': 'AI 마케팅 팁이 성공적으로 생성되었습니다.',
'generated_at': datetime.now().isoformat(),
'store_name': store_data.get('store_name', ''),
'business_type': store_data.get('business_type', ''),
'ai_model': 'claude' if self.claude_client else 'openai'
}
logger.info(f"마케팅 팁 생성 완료: {store_data.get('store_name', 'Unknown')}")
logger.info(f"마케팅 팁 생성 완료: {response}")
return response
except Exception as e:
logger.error(f"마케팅 팁 생성 실패: {str(e)}")
# 실패 시 Fallback 팁 반환
fallback_tip = self._create_fallback_tip(store_data, additional_requirement)
return {
'tip': fallback_tip,
'status': 'fallback',
'message': 'AI 서비스 호출 실패로 기본 팁을 제공합니다.',
'generated_at': datetime.now().isoformat(),
'store_name': store_data.get('store_name', ''),
'business_type': store_data.get('business_type', ''),
'ai_model': 'fallback'
}
def _create_marketing_prompt(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
"""마케팅 팁 생성을 위한 프롬프트 생성"""
store_name = store_data.get('store_name', '매장')
business_type = store_data.get('business_type', '소상공인')
location = store_data.get('location', '')
seat_count = store_data.get('seat_count', 0)
menu_list = store_data.get('menu_list', [])
prompt = f"""
당신은 소상공인 마케팅 전문가입니다.
현재 유행하고 성공한 마케팅 예시를 검색하여 확인 한 후, 참고하여 아래 내용을 작성해주세요.
당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다.
지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요.
소상공인을 위한 실용적인 마케팅 팁을 생성해주세요.
매장 정보:
- 매장명: {store_name}
- 업종: {business_type}
- 위치: {location}
- 좌석 수: {seat_count}
"""
# 🔥 메뉴 정보 추가
if menu_list and len(menu_list) > 0:
prompt += f"\n메뉴 정보:\n"
for menu in menu_list:
menu_name = menu.get('menu_name', '')
category = menu.get('category', '')
price = menu.get('price', 0)
description = menu.get('description', '')
prompt += f"- {menu_name} ({category}): {price:,}원 - {description}\n"
prompt += """
아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요:
1. **실행 가능성**: 소상공인이 실제로 적용할 수 있는 현실적인 방법
2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 수 있는 전략
3. **구체성**: 실행 단계가 명확하고 구체적일 것
4. **시의성**: 현재 계절, 유행, 트렌드를 반영
5. **지역성**: 지역 특성 및 현재 날씨를 고려할 것
응답 형식 (300자 내외, 간결하게):
html 형식으로 출력
핵심 마케팅 팁은 제목없이 한번 더 상단에 보여주세요
부제목과 내용은 분리해서 출력
아래의 부제목 앞에는 이모지 포함
- 핵심 마케팅 팁 (1개)
- 실행 방법 (1개)
- 예상 비용과 기대 효과
- 주의사항 또는 유의점
- 참고했던 실제 성공한 마케팅
- 오늘의 응원의 문장 (간결하게 1개)
심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요.
"""
return prompt
def _call_ai_service(self, prompt: str) -> str:
"""AI 서비스 호출"""
# Claude API 우선 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-sonnet-20240229",
max_tokens=1000,
temperature=0.7,
messages=[
{
"role": "user",
"content": prompt
}
]
)
if response.content and len(response.content) > 0:
logger.info(f"마케팅 팁 생성 완료: {response.content}")
return response.content[0].text.strip()
except Exception as e:
logger.warning(f"Claude API 호출 실패: {str(e)}")
# OpenAI API 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "system",
"content": "당신은 소상공인을 위한 마케팅 전문가입니다. 실용적이고 구체적인 마케팅 조언을 제공해주세요."
},
{
"role": "user",
"content": prompt
}
],
max_tokens=800,
temperature=0.7
)
if response.choices and len(response.choices) > 0:
return response.choices[0].message.content.strip()
except Exception as e:
logger.warning(f"OpenAI API 호출 실패: {str(e)}")
# 모든 AI 서비스 호출 실패
raise Exception("모든 AI 서비스 호출에 실패했습니다.")
def _create_fallback_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
"""AI 서비스 실패 시 규칙 기반 Fallback 팁 생성"""
store_name = store_data.get('store_name', '매장')
business_type = store_data.get('business_type', '')
location = store_data.get('location', '')
menu_list = store_data.get('menu_list', [])
if menu_list and len(menu_list) > 0:
# 가장 비싼 메뉴 찾기 (시그니처 메뉴로 가정)
expensive_menu = max(menu_list, key=lambda x: x.get('price', 0), default=None)
# 카테고리별 메뉴 분석
categories = {}
for menu in menu_list:
category = menu.get('category', '기타')
if category not in categories:
categories[category] = []
categories[category].append(menu)
main_category = max(categories.keys(), key=lambda x: len(categories[x])) if categories else '메뉴'
if expensive_menu:
signature_menu = expensive_menu.get('menu_name', '시그니처 메뉴')
return f"""🎯 {store_name} 메뉴 기반 마케팅 전략
💡 핵심 전략:
- SNS를 활용한 홍보 강화
- 고객 리뷰 관리 및 적극 활용
- 지역 커뮤니티 참여로 인지도 향상
📱 실행 방법:
1. 인스타그램/네이버 블로그 정기 포스팅
2. 고객 만족도 조사 및 피드백 반영
3. 주변 상권과의 협력 이벤트 기획
💰 예상 효과: 월 매출 10-15% 증가 가능
⚠️ 주의사항: 꾸준한 실행과 고객 소통이 핵심"""
# 업종별 기본 팁
if '카페' in business_type or '커피' in business_type:
return f"""{store_name} 카페 마케팅 전략
💡 핵심 포인트:
1. 시그니처 음료 개발 및 SNS 홍보
2. 계절별 한정 메뉴로 재방문 유도
3. 인스타그램 포토존 설치
📱 실행 방법:
- 매주 신메뉴 또는 이벤트 인스타 포스팅
- 고객 사진 리포스트로 참여 유도
- 해시태그 #근처카페 #데이트코스 활용
💰 비용: 월 5-10만원 내외
📈 기대효과: 젊은 고객층 20% 증가"""
elif '음식점' in business_type or '식당' in business_type:
return f"""🍽️ {store_name} 음식점 마케팅 전략
💡 핵심 포인트:
1. 대표 메뉴 스토리텔링
2. 배달앱 리뷰 관리 강화
3. 단골 고객 혜택 프로그램
📱 실행 방법:
- 요리 과정 영상으로 신뢰도 구축
- 리뷰 적극 답변으로 고객 관리
- 방문 횟수별 할인 혜택 제공
💰 비용: 월 3-7만원 내외
📈 기대효과: 재방문율 25% 향상"""
elif '베이커리' in business_type or '빵집' in business_type:
return f"""🍞 {store_name} 베이커리 마케팅 전략
💡 핵심 포인트:
1. 갓 구운 빵 타이밍 알림 서비스
2. 계절 한정 빵 출시
3. 포장 디자인으로 선물용 어필
📱 실행 방법:
- 네이버 톡톡으로 빵 완성 시간 안내
- 명절/기념일 특별 빵 한정 판매
- 예쁜 포장지로 브랜딩 강화
💰 비용: 월 5-8만원 내외
📈 기대효과: 단골 고객 30% 증가"""
# 지역별 특성 고려
if location:
location_tip = ""
if '강남' in location or '서초' in location:
location_tip = "\n🏢 강남권 특화: 직장인 대상 점심 세트메뉴 강화"
elif '홍대' in location or '신촌' in location:
location_tip = "\n🎓 대학가 특화: 학생 할인 및 그룹 이벤트 진행"
elif '강북' in location or '노원' in location:
location_tip = "\n🏘️ 주거지역 특화: 가족 단위 고객 대상 패키지 상품"
return f"""🎯 {store_name} 지역 맞춤 마케팅
💡 기본 전략:
- 온라인 리뷰 관리 강화
- 단골 고객 혜택 프로그램
- 지역 커뮤니티 참여{location_tip}
📱 실행 방법:
1. 구글/네이버 지도 정보 최신화
2. 동네 맘카페 홍보 참여
3. 주변 상권과 상생 이벤트
💰 비용: 월 3-5만원
📈 기대효과: 인지도 및 매출 향상"""
# 기본 범용 팁
return f"""🎯 {store_name} 기본 마케팅 전략
💡 핵심 3가지:
1. 온라인 존재감 강화 (SNS, 리뷰 관리)
2. 고객 소통 및 피드백 활용
3. 차별화된 서비스 제공
📱 실행 방법:
- 네이버 플레이스, 구글 정보 최신화
- 고객 불만 신속 해결로 신뢰 구축
- 작은 이벤트라도 꾸준히 진행
💰 비용: 거의 무료 (시간 투자 위주)
📈 기대효과: 꾸준한 성장과 단골 확보
⚠️ 핵심은 지속성입니다!"""
+202
View File
@@ -0,0 +1,202 @@
"""
포스터 생성 서비스 V3
OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1개 + 프롬프트 내 예시 링크 10개)
"""
import os
from typing import Dict, Any, List
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterService:
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련)
self.example_images = [
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png"
]
# 포토 스타일별 프롬프트
self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
'모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃',
'빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감',
'컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러',
'우아한': '우아하고 고급스러운 느낌, 세련된 분위기',
'캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인'
}
# 카테고리별 이미지 스타일
self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는'
}
# 톤앤매너별 디자인 스타일
self.tone_styles = {
'친근한': '따뜻하고 친근한 색감, 부드러운 느낌',
'정중한': '격식 있고 신뢰감 있는 디자인',
'재미있는': '밝고 유쾌한 분위기, 활기찬 색상',
'전문적인': '전문적이고 신뢰할 수 있는 디자인'
}
# 감정 강도별 디자인
self.emotion_designs = {
'약함': '은은하고 차분한 색감, 절제된 표현',
'보통': '적당히 활기찬 색상, 균형잡힌 디자인',
'강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인'
}
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
포스터 생성 (메인 이미지 1개 분석 + 예시 링크 7개 프롬프트 제공)
"""
try:
# 메인 이미지 확인
if not request.images:
return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'}
main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴
# 메인 이미지 분석
main_image_analysis = self._analyze_main_image(main_image_url)
# 포스터 생성 프롬프트 생성 (예시 링크 10개 포함)
prompt = self._create_poster_prompt_v3(request, main_image_analysis)
# OpenAI로 이미지 생성
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1536")
return {
'success': True,
'content': image_url,
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _analyze_main_image(self, image_url: str) -> Dict[str, Any]:
"""
메인 메뉴 이미지 분석
"""
temp_files = []
try:
# 이미지 다운로드
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
# 이미지 분석
image_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path)
colors = self.image_processor.analyze_colors(temp_path, 5)
return {
'url': image_url,
'info': image_info,
'description': image_description,
'dominant_colors': colors,
'is_food': self.image_processor.is_food_image(temp_path)
}
else:
return {
'url': image_url,
'error': '이미지 다운로드 실패'
}
except Exception as e:
return {
'url': image_url,
'error': str(e)
}
def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
main_analysis: Dict[str, Any]) -> str:
"""
포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7개 포함)
"""
# 메인 이미지 정보 활용
main_description = main_analysis.get('description', '맛있는 음식')
main_colors = main_analysis.get('dominant_colors', [])
image_info = main_analysis.get('info', {})
# 이미지 크기 및 비율 정보
aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0
image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형"
# 색상 정보를 텍스트로 변환
color_description = ""
if main_colors:
color_rgb = main_colors[:3] # 상위 3개 색상
color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감"
# 예시 이미지 링크들을 문자열로 변환
example_links = "\n".join([f"- {link}" for link in self.example_images])
prompt = f"""
## 카페 홍보 포스터 디자인 요청
### 📋 기본 정보
카테고리: {request.category}
콘텐츠 타입: {request.contentType}
메뉴명: {request.menuName or '없음'}
메뉴 정보: {main_description}
### 📅 이벤트 기간
시작일: {request.startDate or '지금'}
종료일: {request.endDate or '한정 기간'}
이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요.
### 🎨 디자인 요구사항
메인 이미지 처리
- 기존 메인 이미지는 변경하지 않고 그대로 유지
- 포스터 전체 크기의 1/3 이하로 배치
- 이미지와 조화로운 작은 장식 이미지 추가
- 크기: {image_orientation}
텍스트 요소
- 메뉴명 (필수)
- 간단한 추가 홍보 문구 (새로 생성, 한글) 혹은 "{request.requirement or '눈길을 끄는 전문적인 디자인'}"라는 요구사항에 맞는 문구
- 메뉴명 외 추가되는 문구는 1줄만 작성
텍스트 배치 규칙
- 글자가 이미지 경계를 벗어나지 않도록 주의
- 모서리에 너무 가깝게 배치하지 말 것
- 적당한 크기로 가독성 확보
- 아기자기한 한글 폰트 사용
### 🎨 디자인 스타일
참조 이미지
{example_links}의 URL을 참고하여 비슷한 스타일로 제작
색상 가이드
{color_description}
전체적인 디자인 방향
타겟: 한국 카페 고객층
스타일: 화려하고 매력적인 디자인
목적: 소셜미디어 공유용 (적합한 크기)
톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
### 🎯 최종 목표
고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
"""
return prompt
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
"""
마케팅 팁 API 테스트 스크립트
"""
import requests
import json
def test_marketing_tip_api():
"""마케팅 팁 API 테스트"""
# 테스트 데이터
test_data = {
"store_name": "더블샷 카페",
"business_type": "카페",
"location": "서울시 강남구 역삼동",
"seat_count": 30,
}
# API 호출
url = "http://localhost:5001/api/v1/generate-marketing-tip"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer dummy-key"
}
try:
response = requests.post(url, json=test_data, headers=headers)
print(f"Status Code: {response.status_code}")
print(f"Response: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
if response.status_code == 200:
print("✅ 테스트 성공!")
else:
print("❌ 테스트 실패!")
except Exception as e:
print(f"❌ 테스트 오류: {str(e)}")
if __name__ == "__main__":
test_marketing_tip_api()
+1
View File
@@ -0,0 +1 @@
# Package initialization file
+237
View File
@@ -0,0 +1,237 @@
"""
AI 클라이언트 유틸리티
Claude AI 및 OpenAI API 호출을 담당
"""
import os
import base64
import requests
from typing import Optional, List
import anthropic
import openai
from PIL import Image
import io
from utils.blob_storage import BlobStorageClient
class AIClient:
"""AI API 클라이언트 클래스"""
def __init__(self):
"""AI 클라이언트 초기화"""
self.claude_api_key = os.getenv('CLAUDE_API_KEY')
self.openai_api_key = os.getenv('OPENAI_API_KEY')
# Blob Storage 클라이언트 초기화
self.blob_client = BlobStorageClient()
# Claude 클라이언트 초기화
if self.claude_api_key:
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
else:
self.claude_client = None
# OpenAI 클라이언트 초기화
if self.openai_api_key:
self.openai_client = openai.OpenAI(api_key=self.openai_api_key)
else:
self.openai_client = None
def download_image_from_url(self, image_url: str) -> str:
"""
URL에서 이미지를 다운로드하여 임시 파일로 저장
Args:
image_url: 다운로드할 이미지 URL
Returns:
임시 저장된 파일 경로
"""
try:
response = requests.get(image_url, timeout=30)
response.raise_for_status()
# 임시 파일로 저장
import tempfile
import uuid
file_extension = image_url.split('.')[-1] if '.' in image_url else 'jpg'
temp_filename = f"temp_{uuid.uuid4()}.{file_extension}"
temp_path = os.path.join('uploads', 'temp', temp_filename)
# 디렉토리 생성
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
with open(temp_path, 'wb') as f:
f.write(response.content)
return temp_path
except Exception as e:
print(f"이미지 다운로드 실패 {image_url}: {e}")
return None
def generate_image_with_openai(self, prompt: str, size: str = "1024x1536") -> str:
"""
gpt를 사용하여 이미지 생성
Args:
prompt: 이미지 생성 프롬프트
size: 이미지 크기 (1024x1536)
Returns:
Azure Blob Storage에 저장된 이미지 URL
"""
try:
if not self.openai_client:
raise Exception("OpenAI API 키가 설정되지 않았습니다.")
response = self.openai_client.images.generate(
model="gpt-image-1",
prompt=prompt,
size=size,
n=1,
)
# base64 이미지 데이터 추출
b64_data = response.data[0].b64_json
image_data = base64.b64decode(b64_data)
# Azure Blob Storage에 업로드
blob_url = self.blob_client.upload_image(image_data, 'png')
print(f"✅ 이미지 생성 및 업로드 완료: {blob_url}")
return blob_url
except Exception as e:
raise Exception(f"이미지 생성 실패: {str(e)}")
def generate_text(self, prompt: str, max_tokens: int = 1000) -> str:
"""
텍스트 생성 (Claude 우선, 실패시 OpenAI 사용)
"""
# Claude AI 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=max_tokens,
messages=[
{"role": "user", "content": prompt}
]
)
return response.content[0].text
except Exception as e:
print(f"Claude AI 호출 실패: {e}")
# OpenAI 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "user", "content": prompt}
],
max_tokens=max_tokens
)
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 호출 실패: {e}")
# 기본 응답
return self._generate_fallback_content(prompt)
def analyze_image(self, image_path: str) -> str:
"""
이미지 분석 및 설명 생성
"""
try:
# 이미지를 base64로 인코딩
image_base64 = self._encode_image_to_base64(image_path)
# Claude Vision API 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=500,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 음식이라면 맛있어 보이는 특징을, 매장이라면 분위기를, 이벤트라면 특별함을 강조해서 한국어로 50자 이내로 설명해주세요."
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": image_base64
}
}
]
}
]
)
return response.content[0].text
except Exception as e:
print(f"Claude 이미지 분석 실패: {e}")
# OpenAI Vision API 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 한국어로 50자 이내로 설명해주세요."
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_base64}"
}
}
]
}
],
max_tokens=300
)
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 이미지 분석 실패: {e}")
except Exception as e:
print(f"이미지 분석 전체 실패: {e}")
return "맛있고 매력적인 음식점의 특별한 순간"
def _encode_image_to_base64(self, image_path: str) -> str:
"""이미지 파일을 base64로 인코딩"""
with open(image_path, "rb") as image_file:
image = Image.open(image_file)
if image.width > 1024 or image.height > 1024:
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
if image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
img_buffer = io.BytesIO()
image.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0)
return base64.b64encode(img_buffer.getvalue()).decode('utf-8')
def _generate_fallback_content(self, prompt: str) -> str:
"""AI 서비스 실패시 기본 콘텐츠 생성"""
if "콘텐츠" in prompt or "게시글" in prompt:
return """안녕하세요! 오늘도 맛있는 하루 되세요 😊
우리 가게의 특별한 메뉴를 소개합니다!
정성껏 준비한 음식으로 여러분을 맞이하겠습니다.
많은 관심과 사랑 부탁드려요!"""
elif "포스터" in prompt:
return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!"
else:
return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."
+117
View File
@@ -0,0 +1,117 @@
"""
Azure Blob Storage 유틸리티
이미지 업로드 및 URL 생성 기능 제공
"""
import os
from datetime import datetime
from typing import Optional
from azure.storage.blob import BlobServiceClient, ContentSettings
from config.config import Config
class BlobStorageClient:
"""Azure Blob Storage 클라이언트 클래스"""
def __init__(self):
"""Blob Storage 클라이언트 초기화"""
self.account_name = Config.AZURE_STORAGE_ACCOUNT_NAME
self.account_key = Config.AZURE_STORAGE_ACCOUNT_KEY
self.container_name = Config.AZURE_STORAGE_CONTAINER_NAME
if not self.account_key:
raise ValueError("Azure Storage Account Key가 설정되지 않았습니다.")
# Connection String 생성
connection_string = f"DefaultEndpointsProtocol=https;AccountName={self.account_name};AccountKey={self.account_key};EndpointSuffix=core.windows.net"
# Blob Service Client 초기화
self.blob_service_client = BlobServiceClient.from_connection_string(connection_string)
def upload_image(self, image_data: bytes, file_extension: str = 'png') -> str:
"""
이미지를 Blob Storage에 업로드
Args:
image_data: 업로드할 이미지 바이트 데이터
file_extension: 파일 확장자 (기본값: 'png')
Returns:
업로드된 이미지의 Public URL
"""
try:
# 파일명 생성: poster_YYYYMMDDHHMMSS.png
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
blob_name = f"poster_{timestamp}.{file_extension}"
# Content Type 설정
content_settings = ContentSettings(content_type=f'image/{file_extension}')
# Blob 업로드
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=blob_name
)
blob_client.upload_blob(
image_data,
content_settings=content_settings,
overwrite=True
)
# Public URL 생성
blob_url = f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{blob_name}"
print(f"✅ 이미지 업로드 완료: {blob_url}")
return blob_url
except Exception as e:
print(f"❌ Blob Storage 업로드 실패: {str(e)}")
raise Exception(f"이미지 업로드 실패: {str(e)}")
def upload_file(self, file_path: str) -> str:
"""
로컬 파일을 Blob Storage에 업로드
Args:
file_path: 업로드할 로컬 파일 경로
Returns:
업로드된 파일의 Public URL
"""
try:
# 파일 확장자 추출
file_extension = os.path.splitext(file_path)[1][1:].lower()
# 파일 읽기
with open(file_path, 'rb') as file:
file_data = file.read()
# 업로드
return self.upload_image(file_data, file_extension)
except Exception as e:
print(f"❌ 파일 업로드 실패: {str(e)}")
raise Exception(f"파일 업로드 실패: {str(e)}")
def delete_blob(self, blob_name: str) -> bool:
"""
Blob 삭제
Args:
blob_name: 삭제할 Blob 이름
Returns:
삭제 성공 여부
"""
try:
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=blob_name
)
blob_client.delete_blob()
print(f"✅ Blob 삭제 완료: {blob_name}")
return True
except Exception as e:
print(f"❌ Blob 삭제 실패: {str(e)}")
return False
+166
View File
@@ -0,0 +1,166 @@
"""
이미지 처리 유틸리티
이미지 분석, 변환, 최적화 기능 제공
"""
import os
from typing import Dict, Any, Tuple
from PIL import Image, ImageOps
import io
class ImageProcessor:
"""이미지 처리 클래스"""
def __init__(self):
"""이미지 프로세서 초기화"""
self.supported_formats = {'JPEG', 'PNG', 'WEBP', 'GIF'}
self.max_size = (2048, 2048) # 최대 크기
self.thumbnail_size = (400, 400) # 썸네일 크기
def get_image_info(self, image_path: str) -> Dict[str, Any]:
"""
이미지 기본 정보 추출
Args:
image_path: 이미지 파일 경로
Returns:
이미지 정보 딕셔너리
"""
try:
with Image.open(image_path) as image:
info = {
'filename': os.path.basename(image_path),
'format': image.format,
'mode': image.mode,
'size': image.size,
'width': image.width,
'height': image.height,
'file_size': os.path.getsize(image_path),
'aspect_ratio': round(image.width / image.height, 2) if image.height > 0 else 0
}
# 이미지 특성 분석
info['is_landscape'] = image.width > image.height
info['is_portrait'] = image.height > image.width
info['is_square'] = abs(image.width - image.height) < 50
return info
except Exception as e:
return {
'filename': os.path.basename(image_path),
'error': str(e)
}
def resize_image(self, image_path: str, target_size: Tuple[int, int],
maintain_aspect: bool = True) -> Image.Image:
"""
이미지 크기 조정
Args:
image_path: 원본 이미지 경로
target_size: 목표 크기 (width, height)
maintain_aspect: 종횡비 유지 여부
Returns:
리사이즈된 PIL 이미지
"""
try:
with Image.open(image_path) as image:
if maintain_aspect:
# 종횡비 유지하며 리사이즈
image.thumbnail(target_size, Image.Resampling.LANCZOS)
return image.copy()
else:
# 강제 리사이즈
return image.resize(target_size, Image.Resampling.LANCZOS)
except Exception as e:
raise Exception(f"이미지 리사이즈 실패: {str(e)}")
def optimize_image(self, image_path: str, quality: int = 85) -> bytes:
"""
이미지 최적화 (파일 크기 줄이기)
Args:
image_path: 원본 이미지 경로
quality: JPEG 품질 (1-100)
Returns:
최적화된 이미지 바이트
"""
try:
with Image.open(image_path) as image:
# RGBA를 RGB로 변환 (JPEG 저장을 위해)
if image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
# 크기가 너무 크면 줄이기
if image.width > self.max_size[0] or image.height > self.max_size[1]:
image.thumbnail(self.max_size, Image.Resampling.LANCZOS)
# 바이트 스트림으로 저장
img_buffer = io.BytesIO()
image.save(img_buffer, format='JPEG', quality=quality, optimize=True)
return img_buffer.getvalue()
except Exception as e:
raise Exception(f"이미지 최적화 실패: {str(e)}")
def create_thumbnail(self, image_path: str, size: Tuple[int, int] = None) -> Image.Image:
"""
썸네일 생성
Args:
image_path: 원본 이미지 경로
size: 썸네일 크기 (기본값: self.thumbnail_size)
Returns:
썸네일 PIL 이미지
"""
if size is None:
size = self.thumbnail_size
try:
with Image.open(image_path) as image:
# 정사각형 썸네일 생성
thumbnail = ImageOps.fit(image, size, Image.Resampling.LANCZOS)
return thumbnail
except Exception as e:
raise Exception(f"썸네일 생성 실패: {str(e)}")
def analyze_colors(self, image_path: str, num_colors: int = 5) -> list:
"""
이미지의 주요 색상 추출
Args:
image_path: 이미지 파일 경로
num_colors: 추출할 색상 개수
Returns:
주요 색상 리스트 [(R, G, B), ...]
"""
try:
with Image.open(image_path) as image:
# RGB로 변환
if image.mode != 'RGB':
image = image.convert('RGB')
# 이미지 크기 줄여서 처리 속도 향상
image.thumbnail((150, 150))
# 색상 히스토그램 생성
colors = image.getcolors(maxcolors=256*256*256)
if colors:
# 빈도순으로 정렬
colors.sort(key=lambda x: x[0], reverse=True)
# 상위 색상들 반환
dominant_colors = []
for count, color in colors[:num_colors]:
dominant_colors.append(color)
return dominant_colors
return [(128, 128, 128)] # 기본 회색
except Exception as e:
print(f"색상 분석 실패: {e}")
return [(128, 128, 128)] # 기본 회색
def is_food_image(self, image_path: str) -> bool:
"""
음식 이미지 여부 간단 판별
(실제로는 AI 모델이 필요하지만, 여기서는 기본적인 휴리스틱 사용)
Args:
image_path: 이미지 파일 경로
Returns:
음식 이미지 여부
"""
try:
# 파일명에서 키워드 확인
filename = os.path.basename(image_path).lower()
food_keywords = ['food', 'meal', 'dish', 'menu', '음식', '메뉴', '요리']
for keyword in food_keywords:
if keyword in filename:
return True
# 색상 분석으로 간단 판별 (음식은 따뜻한 색조가 많음)
colors = self.analyze_colors(image_path, 3)
warm_color_count = 0
for r, g, b in colors:
# 따뜻한 색상 (빨강, 노랑, 주황 계열) 확인
if r > 150 or (r > g and r > b):
warm_color_count += 1
return warm_color_count >= 2
except:
return False