feat: ai service init

This commit is contained in:
OhSeongRak 2025-06-11 14:30:15 +09:00
parent 7813f934b9
commit c94c75b4f2
12 changed files with 1059 additions and 0 deletions

6
smarketing-ai/.env Normal file
View File

@ -0,0 +1,6 @@
CLAUDE_API_KEY=your_claude_api_key_here
OPENAI_API_KEY=your_openai_api_key_here
FLASK_ENV=development
UPLOAD_FOLDER=uploads
MAX_CONTENT_LENGTH=16777216
SECRET_KEY=your-secret-key-for-production

150
smarketing-ai/app.py Normal file
View File

@ -0,0 +1,150 @@
"""
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.content_service import ContentService
from services.poster_service import PosterService
from models.request_models import ContentRequest, PosterRequest
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)
# 서비스 인스턴스 생성
content_service = ContentService()
poster_service = PosterService()
@app.route('/health', methods=['GET'])
def health_check():
"""헬스 체크 API"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'service': 'AI Marketing Service'
})
@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 = 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()
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,26 @@
"""
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 * 1024) # 16MB
# AI API 설정
CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY')
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
# 지원되는 파일 확장자
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

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,27 @@
"""
요청 모델 정의
API 요청 데이터 구조를 정의
"""
from dataclasses import dataclass
from typing import List, Optional
@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 # 추가 정보

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,200 @@
"""
마케팅 콘텐츠 생성 서비스
AI를 활용하여 플랫폼별 맞춤 게시글 생성
"""
import os
from typing import Dict, Any
from datetime import datetime
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import ContentRequest
class ContentService:
"""마케팅 콘텐츠 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 플랫폼별 콘텐츠 특성 정의
self.platform_specs = {
'인스타그램': {
'max_length': 2200,
'hashtag_count': 15,
'style': '감성적이고 시각적',
'format': '짧은 문장, 해시태그 활용'
},
'네이버 블로그': {
'max_length': 3000,
'hashtag_count': 10,
'style': '정보성과 친근함',
'format': '구조화된 내용, 상세 설명'
}
}
# 카테고리별 키워드 정의
self.category_keywords = {
'음식': ['맛집', '신메뉴', '추천', '맛있는', '특별한', '인기'],
'매장': ['분위기', '인테리어', '편안한', '아늑한', '특별한', '방문'],
'이벤트': ['할인', '이벤트', '특가', '한정', '기간한정', '혜택']
}
def generate_content(self, request: ContentRequest) -> Dict[str, Any]:
"""
마케팅 콘텐츠 생성
Args:
request: 콘텐츠 생성 요청 데이터
Returns:
생성된 콘텐츠 정보
"""
try:
# 이미지 분석
image_analysis = self._analyze_images(request.image_paths)
# AI 프롬프트 생성
prompt = self._create_content_prompt(request, image_analysis)
# AI로 콘텐츠 생성
generated_content = self.ai_client.generate_text(prompt)
# 해시태그 생성
hashtags = self._generate_hashtags(request)
# 최종 콘텐츠 포맷팅
formatted_content = self._format_content(
generated_content,
hashtags,
request.platform
)
return {
'success': True,
'content': formatted_content,
'platform': request.platform,
'category': request.category,
'generated_at': datetime.now().isoformat(),
'image_count': len(request.image_paths),
'image_analysis': image_analysis
}
except Exception as e:
return {
'success': False,
'error': str(e),
'generated_at': datetime.now().isoformat()
}
def _analyze_images(self, image_paths: list) -> Dict[str, Any]:
"""
업로드된 이미지들 분석
Args:
image_paths: 이미지 파일 경로 리스트
Returns:
이미지 분석 결과
"""
analysis_results = []
for image_path in image_paths:
try:
# 이미지 기본 정보 추출
image_info = self.image_processor.get_image_info(image_path)
# AI를 통한 이미지 내용 분석
image_description = self.ai_client.analyze_image(image_path)
analysis_results.append({
'path': image_path,
'info': image_info,
'description': image_description
})
except Exception as e:
analysis_results.append({
'path': image_path,
'error': str(e)
})
return {
'total_images': len(image_paths),
'results': analysis_results
}
def _create_content_prompt(self, request: ContentRequest, image_analysis: Dict[str, Any]) -> str:
"""
AI 콘텐츠 생성을 위한 프롬프트 생성
Args:
request: 콘텐츠 생성 요청
image_analysis: 이미지 분석 결과
Returns:
AI 프롬프트 문자열
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
category_keywords = self.category_keywords.get(request.category, [])
# 이미지 설명 추출
image_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
image_descriptions.append(result['description'])
prompt = f"""
당신은 소상공인을 위한 마케팅 콘텐츠 전문가입니다.
다음 정보를 바탕으로 {request.platform} 적합한 {request.category} 카테고리의 게시글을 작성해주세요.
**매장 정보:**
- 매장명: {request.store_name or '우리 가게'}
- 카테고리: {request.category}
- 추가 정보: {request.additional_info or '없음'}
**이벤트 정보:**
- 시작 시간: {request.start_time or '상시'}
- 종료 시간: {request.end_time or '상시'}
**이미지 분석 결과:**
{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'}
**플랫폼 특성:**
- 최대 길이: {platform_spec['max_length']}
- 스타일: {platform_spec['style']}
- 형식: {platform_spec['format']}
**요구사항:**
1. {request.platform} 특성에 맞는 톤앤매너 사용
2. {request.category} 카테고리에 적합한 내용 구성
3. 고객의 관심을 있는 매력적인 문구 사용
4. 이미지와 연관된 내용으로 작성
5. 자연스럽고 친근한 어조 사용
해시태그는 별도로 생성하므로 본문에는 포함하지 마세요.
"""
return prompt
def _generate_hashtags(self, request: ContentRequest) -> list:
"""
카테고리와 플랫폼에 맞는 해시태그 생성
Args:
request: 콘텐츠 생성 요청
Returns:
해시태그 리스트
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
category_keywords = self.category_keywords.get(request.category, [])
hashtags = []
# 기본 해시태그
if request.store_name:
hashtags.append(f"#{request.store_name.replace(' ', '')}")
# 카테고리별 해시태그
hashtags.extend([f"#{keyword}" for keyword in category_keywords[:5]])
# 공통 해시태그
common_tags = ['#맛집', '#소상공인', '#로컬맛집', '#일상', '#소통']
hashtags.extend(common_tags)
# 플랫폼별 인기 해시태그
if request.platform == '인스타그램':
hashtags.extend(['#인스타푸드', '#데일리', '#오늘뭐먹지', '#맛스타그램'])
elif request.platform == '네이버 블로그':
hashtags.extend(['#블로그', '#후기', '#추천', '#정보'])
# 최대 개수 제한
max_count = platform_spec['hashtag_count']
return hashtags[:max_count]
def _format_content(self, content: str, hashtags: list, platform: str) -> str:
"""
플랫폼에 맞게 콘텐츠 포맷팅
Args:
content: 생성된 콘텐츠
hashtags: 해시태그 리스트
platform: 플랫폼명
Returns:
포맷팅된 최종 콘텐츠
"""
platform_spec = self.platform_specs.get(platform, self.platform_specs['인스타그램'])
# 길이 제한 적용
if len(content) > platform_spec['max_length'] - 100: # 해시태그 공간 확보
content = content[:platform_spec['max_length'] - 100] + '...'
# 플랫폼별 포맷팅
if platform == '인스타그램':
# 인스타그램: 본문 + 해시태그
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n{hashtag_string}"
elif platform == '네이버 블로그':
# 네이버 블로그: 구조화된 형태
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n---\n{hashtag_string}"
else:
# 기본 형태
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n{hashtag_string}"
return formatted

View File

@ -0,0 +1,304 @@
"""
홍보 포스터 생성 서비스
AI와 이미지 처리를 활용한 시각적 마케팅 자료 생성
"""
import os
import base64
from typing import Dict, Any
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterRequest
class PosterService:
"""홍보 포스터 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 포스터 기본 설정
self.poster_config = {
'width': 1080,
'height': 1350, # 인스타그램 세로 비율
'background_color': (255, 255, 255),
'text_color': (50, 50, 50),
'accent_color': (255, 107, 107)
}
# 카테고리별 색상 테마
self.category_themes = {
'음식': {
'primary': (255, 107, 107), # 빨강
'secondary': (255, 206, 84), # 노랑
'background': (255, 248, 240) # 크림
},
'매장': {
'primary': (74, 144, 226), # 파랑
'secondary': (120, 198, 121), # 초록
'background': (248, 251, 255) # 연한 파랑
},
'이벤트': {
'primary': (156, 39, 176), # 보라
'secondary': (255, 193, 7), # 금색
'background': (252, 248, 255) # 연한 보라
}
}
def generate_poster(self, request: PosterRequest) -> Dict[str, Any]:
"""
홍보 포스터 생성
Args:
request: 포스터 생성 요청 데이터
Returns:
생성된 포스터 정보
"""
try:
# 포스터 텍스트 내용 생성
poster_text = self._generate_poster_text(request)
# 이미지 전처리
processed_images = self._process_images(request.image_paths)
# 포스터 이미지 생성
poster_image = self._create_poster_image(request, poster_text, processed_images)
# 이미지를 base64로 인코딩
poster_base64 = self._encode_image_to_base64(poster_image)
return {
'success': True,
'poster_data': poster_base64,
'poster_text': poster_text,
'category': request.category,
'generated_at': datetime.now().isoformat(),
'image_count': len(request.image_paths),
'format': 'base64'
}
except Exception as e:
return {
'success': False,
'error': str(e),
'generated_at': datetime.now().isoformat()
}
def _generate_poster_text(self, request: PosterRequest) -> Dict[str, str]:
"""
포스터에 들어갈 텍스트 내용 생성
Args:
request: 포스터 생성 요청
Returns:
포스터 텍스트 구성 요소들
"""
# 이미지 분석
image_descriptions = []
for image_path in request.image_paths:
try:
description = self.ai_client.analyze_image(image_path)
image_descriptions.append(description)
except:
continue
# AI 프롬프트 생성
prompt = f"""
당신은 소상공인을 위한 포스터 카피라이터입니다.
다음 정보를 바탕으로 매력적인 포스터 문구를 작성해주세요.
**매장 정보:**
- 매장명: {request.store_name or '우리 가게'}
- 카테고리: {request.category}
- 추가 정보: {request.additional_info or '없음'}
**이벤트 정보:**
- 이벤트 제목: {request.event_title or '특별 이벤트'}
- 할인 정보: {request.discount_info or '특가 진행'}
- 시작 시간: {request.start_time or '상시'}
- 종료 시간: {request.end_time or '상시'}
**이미지 설명:**
{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'}
다음 형식으로 응답해주세요:
1. 메인 헤드라인 (10글자 이내, 임팩트 있게)
2. 서브 헤드라인 (20글자 이내, 구체적 혜택)
3. 설명 문구 (30글자 이내, 친근하고 매력적으로)
4. 행동 유도 문구 (15글자 이내, 액션 유도)
항목은 줄바꿈으로 구분해서 작성해주세요.
"""
# AI로 텍스트 생성
generated_text = self.ai_client.generate_text(prompt)
# 생성된 텍스트 파싱
lines = generated_text.strip().split('\n')
return {
'main_headline': lines[0] if len(lines) > 0 else request.event_title or '특별 이벤트',
'sub_headline': lines[1] if len(lines) > 1 else request.discount_info or '지금 바로!',
'description': lines[2] if len(lines) > 2 else '특별한 혜택을 놓치지 마세요',
'call_to_action': lines[3] if len(lines) > 3 else '지금 방문하세요!'
}
def _process_images(self, image_paths: list) -> list:
"""
포스터에 사용할 이미지들 전처리
Args:
image_paths: 원본 이미지 경로 리스트
Returns:
전처리된 이미지 객체 리스트
"""
processed_images = []
for image_path in image_paths:
try:
# 이미지 로드 및 리사이즈
image = Image.open(image_path)
# RGBA로 변환 (투명도 처리)
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 포스터에 맞게 리사이즈 (최대 400x400)
image.thumbnail((400, 400), Image.Resampling.LANCZOS)
processed_images.append(image)
except Exception as e:
print(f"이미지 처리 오류 {image_path}: {e}")
continue
return processed_images
def _create_poster_image(self, request: PosterRequest, poster_text: Dict[str, str], images: list) -> Image.Image:
"""
실제 포스터 이미지 생성
Args:
request: 포스터 생성 요청
poster_text: 포스터 텍스트
images: 전처리된 이미지 리스트
Returns:
생성된 포스터 이미지
"""
# 카테고리별 테마 적용
theme = self.category_themes.get(request.category, self.category_themes['음식'])
# 캔버스 생성
poster = Image.new('RGBA',
(self.poster_config['width'], self.poster_config['height']),
theme['background'])
draw = ImageDraw.Draw(poster)
# 폰트 설정 (시스템 기본 폰트 사용)
try:
# 다양한 폰트 시도
title_font = ImageFont.truetype("arial.ttf", 60)
subtitle_font = ImageFont.truetype("arial.ttf", 40)
text_font = ImageFont.truetype("arial.ttf", 30)
small_font = ImageFont.truetype("arial.ttf", 24)
except:
# 기본 폰트 사용
title_font = ImageFont.load_default()
subtitle_font = ImageFont.load_default()
text_font = ImageFont.load_default()
small_font = ImageFont.load_default()
# 레이아웃 계산
y_pos = 80
# 1. 메인 헤드라인
main_headline = poster_text['main_headline']
bbox = draw.textbbox((0, 0), main_headline, font=title_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), main_headline,
fill=theme['primary'], font=title_font)
y_pos += 100
# 2. 서브 헤드라인
sub_headline = poster_text['sub_headline']
bbox = draw.textbbox((0, 0), sub_headline, font=subtitle_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), sub_headline,
fill=theme['secondary'], font=subtitle_font)
y_pos += 80
# 3. 이미지 배치 (있는 경우)
if images:
image_y = y_pos + 30
if len(images) == 1:
# 단일 이미지: 중앙 배치
img = images[0]
img_x = (self.poster_config['width'] - img.width) // 2
poster.paste(img, (img_x, image_y), img)
y_pos = image_y + img.height + 50
elif len(images) == 2:
# 두 개 이미지: 나란히 배치
total_width = sum(img.width for img in images) + 20
start_x = (self.poster_config['width'] - total_width) // 2
for i, img in enumerate(images):
img_x = start_x + (i * (img.width + 20))
poster.paste(img, (img_x, image_y), img)
y_pos = image_y + max(img.height for img in images) + 50
else:
# 여러 이미지: 그리드 형태
cols = 2
rows = (len(images) + cols - 1) // cols
img_spacing = 20
for i, img in enumerate(images[:4]): # 최대 4개
row = i // cols
col = i % cols
img_x = (self.poster_config['width'] // cols) * col + \
(self.poster_config['width'] // cols - img.width) // 2
img_y = image_y + row * (200 + img_spacing)
poster.paste(img, (img_x, img_y), img)
y_pos = image_y + rows * (200 + img_spacing) + 30
# 4. 설명 문구
description = poster_text['description']
# 긴 텍스트는 줄바꿈 처리
words = description.split()
lines = []
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=text_font)
if bbox[2] - bbox[0] < self.poster_config['width'] - 100:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
for line in lines:
bbox = draw.textbbox((0, 0), line, font=text_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=text_font)
y_pos += 40
y_pos += 30
# 5. 기간 정보 (있는 경우)
if request.start_time and request.end_time:
period_text = f"기간: {request.start_time} ~ {request.end_time}"
bbox = draw.textbbox((0, 0), period_text, font=small_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), period_text, fill=(120, 120, 120), font=small_font)
y_pos += 50
# 6. 행동 유도 문구 (버튼 스타일)
cta_text = poster_text['call_to_action']
bbox = draw.textbbox((0, 0), cta_text, font=subtitle_font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 버튼 배경
button_width = text_width + 60
button_height = text_height + 30
button_x = (self.poster_config['width'] - button_width) // 2
button_y = self.poster_config['height'] - 150
draw.rounded_rectangle([button_x, button_y, button_x + button_width, button_y + button_height],
radius=25, fill=theme['primary'])
# 버튼 텍스트
text_x = button_x + (button_width - text_width) // 2
text_y = button_y + (button_height - text_height) // 2
draw.text((text_x, text_y), cta_text, fill=(255, 255, 255), font=subtitle_font)
# 7. 매장명 (하단)
if request.store_name:
store_text = request.store_name
bbox = draw.textbbox((0, 0), store_text, font=text_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
y_pos = self.poster_config['height'] - 50
draw.text((x_pos, y_pos), store_text, fill=(100, 100, 100), font=text_font)
return poster
def _encode_image_to_base64(self, image: Image.Image) -> str:
"""
PIL 이미지를 base64 문자열로 인코딩
Args:
image: PIL 이미지 객체
Returns:
base64 인코딩된 이미지 문자열
"""
import io
# RGB로 변환 (JPEG 저장을 위해)
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=90)
img_buffer.seek(0)
# base64 인코딩
img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
return f"data:image/jpeg;base64,{img_base64}"

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,176 @@
"""
AI 클라이언트 유틸리티
Claude AI OpenAI API 호출을 담당
"""
import os
import base64
from typing import Optional
import anthropic
import openai
from PIL import Image
import io
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')
# 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:
openai.api_key = self.openai_api_key
self.openai_client = openai
else:
self.openai_client = None
def generate_text(self, prompt: str, max_tokens: int = 1000) -> str:
"""
텍스트 생성 (Claude 우선, 실패시 OpenAI 사용)
Args:
prompt: 생성할 텍스트의 프롬프트
max_tokens: 최대 토큰
Returns:
생성된 텍스트
"""
# Claude AI 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-sonnet-20240229",
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-3.5-turbo",
messages=[
{"role": "user", "content": prompt}
],
max_tokens=max_tokens
)
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 호출 실패: {e}")
# 기본 응답 (AI 서비스 모두 실패시)
return self._generate_fallback_content(prompt)
def analyze_image(self, image_path: str) -> str:
"""
이미지 분석 설명 생성
Args:
image_path: 분석할 이미지 경로
Returns:
이미지 설명 텍스트
"""
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-sonnet-20240229",
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-4-vision-preview",
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로 인코딩
Args:
image_path: 이미지 파일 경로
Returns:
base64 인코딩된 이미지 문자열
"""
with open(image_path, "rb") as image_file:
# 이미지 크기 조정 (API 제한 고려)
image = Image.open(image_file)
# 최대 크기 제한 (1024x1024)
if image.width > 1024 or image.height > 1024:
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
# JPEG로 변환하여 파일 크기 줄이기
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 서비스 실패시 기본 콘텐츠 생성
Args:
prompt: 원본 프롬프트
Returns:
기본 콘텐츠
"""
if "콘텐츠" in prompt or "게시글" in prompt:
return """안녕하세요! 오늘도 맛있는 하루 되세요 😊
우리 가게의 특별한 메뉴를 소개합니다!
정성껏 준비한 음식으로 여러분을 맞이하겠습니다.
많은 관심과 사랑 부탁드려요!"""
elif "포스터" in prompt:
return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!"
else:
return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."

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