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
+1
View File
@@ -0,0 +1 @@
# Package initialization file
+200
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
+304
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}"