mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2026-06-13 12:59:10 +00:00
release
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# Package initialization 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
|
||||
@@ -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"]
|
||||
Vendored
+153
@@ -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 "=========================================="
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. 차별화된 서비스 제공
|
||||
|
||||
📱 실행 방법:
|
||||
- 네이버 플레이스, 구글 정보 최신화
|
||||
- 고객 불만 신속 해결로 신뢰 구축
|
||||
- 작은 이벤트라도 꾸준히 진행
|
||||
|
||||
💰 비용: 거의 무료 (시간 투자 위주)
|
||||
📈 기대효과: 꾸준한 성장과 단골 확보
|
||||
|
||||
⚠️ 핵심은 지속성입니다!"""
|
||||
@@ -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
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
# Package initialization 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 "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user