범용 프롬프트 적용
parent
8415e1c3a7
commit
04d8614919
|
|
@ -38,7 +38,7 @@ from app.utils.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
from app.utils.prompts.prompts import marketing_prompt, restaurant_marketing_prompt
|
from app.utils.prompts.prompts import marketing_prompt
|
||||||
from app.utils.address_parser import extract_region_from_address
|
from app.utils.address_parser import extract_region_from_address
|
||||||
from app.utils.autotag import autotag_images
|
from app.utils.autotag import autotag_images
|
||||||
from config import MEDIA_ROOT
|
from config import MEDIA_ROOT
|
||||||
|
|
@ -56,7 +56,7 @@ router = APIRouter()
|
||||||
|
|
||||||
## 요청 파라미터
|
## 요청 파라미터
|
||||||
- **query**: 검색어 (필수)
|
- **query**: 검색어 (필수)
|
||||||
- **category**: 카테고리 ("accommodation" | "restaurant", 기본값: 전체)
|
- **category**: 카테고리
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **query**: 검색어
|
- **query**: 검색어
|
||||||
|
|
@ -75,7 +75,7 @@ router = APIRouter()
|
||||||
async def search_accommodation(
|
async def search_accommodation(
|
||||||
query: str,
|
query: str,
|
||||||
) -> AccommodationSearchResponse:
|
) -> AccommodationSearchResponse:
|
||||||
"""장소 자동완성 검색 (숙박/음식점 등)"""
|
"""장소 자동완성 검색"""
|
||||||
results = await naver_search_client.search_accommodation(
|
results = await naver_search_client.search_accommodation(
|
||||||
query=query,
|
query=query,
|
||||||
display=10,
|
display=10,
|
||||||
|
|
@ -94,39 +94,42 @@ def _extract_region_from_address(road_address: str | None, jibun_address: str |
|
||||||
return extract_region_from_address(road_address, jibun_address)
|
return extract_region_from_address(road_address, jibun_address)
|
||||||
|
|
||||||
|
|
||||||
class _BizTypeOutput(BaseModel):
|
class _IndustryOutput(BaseModel):
|
||||||
biz_type: Literal["accommodation", "restaurant", "place"]
|
industry: Literal["stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction"]
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_biz_type(place_type: str | None, category: str) -> str:
|
async def _resolve_industry(category: str, customer_name: str = "") -> str:
|
||||||
"""크롤링에서 감지된 place_type을 우선 사용하고, 없으면 AI 분류로 판단."""
|
"""업체를 통합 프롬프트의 8개 industry enum 중 하나로 AI 분류.
|
||||||
if place_type in ("accommodation", "restaurant"):
|
|
||||||
return place_type
|
분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다.
|
||||||
if not category:
|
근거가 둘 다 없거나 분류 실패 시 빈 문자열 반환(프롬프트가 범용 처리).
|
||||||
return "place"
|
"""
|
||||||
|
if category:
|
||||||
|
basis_label, basis_value = "네이버 지도 카테고리", category
|
||||||
|
elif customer_name:
|
||||||
|
basis_label, basis_value = "업체명", customer_name
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
try:
|
try:
|
||||||
chatgpt = ChatgptService()
|
chatgpt = ChatgptService()
|
||||||
prompt = (
|
prompt = (
|
||||||
f"다음은 네이버 지도의 업종 카테고리 값입니다: '{category}'\n"
|
f"{basis_label}: '{basis_value}'\n"
|
||||||
"이 업종이 숙박업(accommodation), 음식점(restaurant), 그 외(place) 중 어디에 해당하는지 분류하세요."
|
"위 정보를 바탕으로 이 업체를 다음 8개 업종 중 가장 적합한 하나로 분류하세요: "
|
||||||
|
"stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), "
|
||||||
|
"salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), "
|
||||||
|
"academy(학원/교습소), attraction(관광/체험/액티비티)."
|
||||||
)
|
)
|
||||||
result = await chatgpt._call_pydantic_output_chat_completion(
|
result = await chatgpt._call_pydantic_output_chat_completion(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
output_format=_BizTypeOutput,
|
output_format=_IndustryOutput,
|
||||||
model="gpt-4o-mini",
|
model="gpt-4o-mini",
|
||||||
img_url=None,
|
img_url=None,
|
||||||
image_detail_high=False,
|
image_detail_high=False,
|
||||||
)
|
)
|
||||||
return result.biz_type
|
return result.industry
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[biz_type] AI 분류 실패, 기본값 place 반환: {e}")
|
logger.warning(f"[industry] AI 분류 실패, 빈 값 반환: {e}")
|
||||||
return "place"
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _select_marketing_prompt(biz_type: str):
|
|
||||||
if biz_type == "restaurant":
|
|
||||||
return restaurant_marketing_prompt
|
|
||||||
return marketing_prompt
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -262,19 +265,19 @@ async def _crawling_logic(
|
||||||
customer_name = scraper.base_info.get("name", "")
|
customer_name = scraper.base_info.get("name", "")
|
||||||
category = scraper.base_info.get("category", "")
|
category = scraper.base_info.get("category", "")
|
||||||
region = _extract_region_from_address(road_address, jibun_address)
|
region = _extract_region_from_address(road_address, jibun_address)
|
||||||
biz_type = await _resolve_biz_type(scraper.place_type, category)
|
industry = await _resolve_industry(category, customer_name)
|
||||||
|
|
||||||
processed_info = ProcessedInfo(
|
processed_info = ProcessedInfo(
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
region=region,
|
region=region,
|
||||||
detail_region_info=road_address or jibun_address or "",
|
detail_region_info=road_address or jibun_address or "",
|
||||||
biz_type=biz_type,
|
industry=industry,
|
||||||
)
|
)
|
||||||
|
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
||||||
f"category={category!r}, place_type={scraper.place_type!r}, biz_type={biz_type} ({step2_elapsed:.1f}ms)"
|
f"category={category!r}, place_type={scraper.place_type!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||||
|
|
@ -288,15 +291,15 @@ async def _crawling_logic(
|
||||||
"customer_name": customer_name,
|
"customer_name": customer_name,
|
||||||
"region": region,
|
"region": region,
|
||||||
"detail_region_info": road_address or "",
|
"detail_region_info": road_address or "",
|
||||||
|
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
# Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
||||||
selected_prompt = _select_marketing_prompt(biz_type)
|
|
||||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||||
selected_prompt, input_marketing_data
|
marketing_prompt, input_marketing_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3-3: 분석 결과 DB 저장
|
# Step 3-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
|
||||||
marketing_intel = MarketingIntel(
|
marketing_intel = MarketingIntel(
|
||||||
place_id=scraper.place_id,
|
place_id=scraper.place_id,
|
||||||
intel_result=marketing_analysis.model_dump(),
|
intel_result=marketing_analysis.model_dump(),
|
||||||
|
|
@ -352,7 +355,7 @@ async def _crawling_logic(
|
||||||
"processed_info": processed_info,
|
"processed_info": processed_info,
|
||||||
"marketing_analysis": marketing_analysis,
|
"marketing_analysis": marketing_analysis,
|
||||||
"m_id": m_id,
|
"m_id": m_id,
|
||||||
"biz_type": biz_type if 'biz_type' in locals() else "place",
|
"industry": industry if 'industry' in locals() else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -390,14 +393,19 @@ async def manual_marketing(
|
||||||
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성
|
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성
|
||||||
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
|
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
|
||||||
chatgpt_service = ChatgptService()
|
chatgpt_service = ChatgptService()
|
||||||
|
# 요청에 industry가 있으면 사용, 없으면 업체명 기반 AI 분류
|
||||||
|
industry = request_body.industry or await _resolve_industry(
|
||||||
|
"", request_body.store_name
|
||||||
|
)
|
||||||
|
processed_info.industry = industry
|
||||||
input_marketing_data = {
|
input_marketing_data = {
|
||||||
"customer_name": request_body.store_name,
|
"customer_name": request_body.store_name,
|
||||||
"region": region,
|
"region": region,
|
||||||
"detail_region_info": request_body.address,
|
"detail_region_info": request_body.address,
|
||||||
|
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
||||||
}
|
}
|
||||||
selected_prompt = _select_marketing_prompt(request_body.biz_type)
|
|
||||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||||
selected_prompt, input_marketing_data
|
marketing_prompt, input_marketing_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
||||||
|
|
@ -431,7 +439,7 @@ async def manual_marketing(
|
||||||
processed_info=processed_info,
|
processed_info=processed_info,
|
||||||
marketing_analysis=marketing_analysis,
|
marketing_analysis=marketing_analysis,
|
||||||
m_id=m_id,
|
m_id=m_id,
|
||||||
biz_type=request_body.biz_type,
|
industry=industry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,11 @@ class Project(Base):
|
||||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
)
|
)
|
||||||
|
|
||||||
biz_type: Mapped[str] = mapped_column(
|
industry: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
default="place",
|
default="",
|
||||||
comment="장소 유형 (place: 기본, accommodation: 숙박, restaurant: 음식점)",
|
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)",
|
||||||
)
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class ProcessedInfo(BaseModel):
|
||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)")
|
||||||
|
|
||||||
|
|
||||||
# class MarketingAnalysisDetail(BaseModel):
|
# class MarketingAnalysisDetail(BaseModel):
|
||||||
|
|
@ -243,7 +243,7 @@ class CrawlingResponse(BaseModel):
|
||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)")
|
||||||
|
|
||||||
|
|
||||||
class ManualMarketingRequest(BaseModel):
|
class ManualMarketingRequest(BaseModel):
|
||||||
|
|
@ -260,7 +260,7 @@ class ManualMarketingRequest(BaseModel):
|
||||||
|
|
||||||
store_name: str = Field(..., description="업체명 / 브랜드명")
|
store_name: str = Field(..., description="업체명 / 브랜드명")
|
||||||
address: str = Field(..., description="도로명 또는 지번 주소")
|
address: str = Field(..., description="도로명 또는 지번 주소")
|
||||||
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 비우면 업체명 기반 AI 분류")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
from app.utils.prompts.prompts import lyric_prompt, restaurant_lyric_prompt
|
from app.utils.prompts.prompts import lyric_prompt
|
||||||
import traceback as tb
|
import traceback as tb
|
||||||
import json
|
import json
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -286,9 +286,11 @@ async def generate_lyric(
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||||
"language" : request_body.language,
|
"language" : request_body.language,
|
||||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
|
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
|
||||||
}
|
}
|
||||||
|
|
||||||
selected_lyric_prompt = restaurant_lyric_prompt if request_body.biz_type == "restaurant" else lyric_prompt
|
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
||||||
|
selected_lyric_prompt = lyric_prompt
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
@ -316,7 +318,7 @@ async def generate_lyric(
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
marketing_intelligence=request_body.m_id,
|
marketing_intelligence=request_body.m_id,
|
||||||
biz_type=request_body.biz_type,
|
industry=request_body.industry,
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
),
|
),
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)")
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ async def generate_subtitle_background(
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
detail_region_info=store_address,
|
detail_region_info=store_address,
|
||||||
language=language,
|
language=language,
|
||||||
|
industry=project.industry,
|
||||||
)
|
)
|
||||||
pitching_output_list = generated_subtitles.pitching_results
|
pitching_output_list = generated_subtitles.pitching_results
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,10 @@ class SeoService:
|
||||||
),
|
),
|
||||||
"language": project.language,
|
"language": project.language,
|
||||||
"target_keywords": hashtags,
|
"target_keywords": hashtags,
|
||||||
|
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
||||||
chatgpt = ChatgptService(timeout=180)
|
chatgpt = ChatgptService(timeout=180)
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
|
||||||
body = {
|
body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": metadata.title,
|
"title": metadata.title,
|
||||||
"description": metadata.description or "",
|
"description": self._sanitize_description(metadata.description),
|
||||||
"tags": metadata.tags or [],
|
"tags": metadata.tags or [],
|
||||||
"categoryId": self._get_category_id(metadata),
|
"categoryId": self._get_category_id(metadata),
|
||||||
},
|
},
|
||||||
|
|
@ -380,6 +380,12 @@ class YouTubeUploader(BaseSocialUploader):
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _sanitize_description(self, description: str | None) -> str:
|
||||||
|
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
|
||||||
|
if not description:
|
||||||
|
return ""
|
||||||
|
return description.replace("<", "").replace(">", "")
|
||||||
|
|
||||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||||
"""
|
"""
|
||||||
PrivacyStatus를 YouTube API 형식으로 변환
|
PrivacyStatus를 YouTube API 형식으로 변환
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
|
||||||
|
|
||||||
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
||||||
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||||
MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
|
MAX_RETRY = 2
|
||||||
for _ in range(MAX_RETRY):
|
for _ in range(MAX_RETRY):
|
||||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
||||||
print("Failed", failed_idx)
|
print("Failed", failed_idx)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
@ -470,10 +471,20 @@ class CreatomateService:
|
||||||
match template_type:
|
match template_type:
|
||||||
case "image":
|
case "image":
|
||||||
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
||||||
maximum_idx = image_score_list.index(max(image_score_list))
|
|
||||||
if duplicate:
|
if duplicate:
|
||||||
|
# 이미지 부족(슬롯 수 > 이미지 수): 재사용이 불가피하므로 슬롯에 가장 적합한 이미지(최고점) 선택
|
||||||
|
maximum_idx = image_score_list.index(max(image_score_list))
|
||||||
selected = taged_image_list[maximum_idx]
|
selected = taged_image_list[maximum_idx]
|
||||||
else:
|
else:
|
||||||
|
# 이미지 충분(슬롯 수 <= 이미지 수): 상위 3장 중 점수 비례 확률로 선택해 영상 다양화
|
||||||
|
# 동일 요청을 반복할 때 매번 같은 영상이 생성되는 문제 방지
|
||||||
|
sorted_indices = sorted(range(len(image_score_list)), key=lambda i: image_score_list[i], reverse=True)
|
||||||
|
top_indices = sorted_indices[:min(3, len(sorted_indices))]
|
||||||
|
weights = [image_score_list[i] for i in top_indices]
|
||||||
|
if sum(weights) > 0:
|
||||||
|
maximum_idx = random.choices(top_indices, weights=weights, k=1)[0]
|
||||||
|
else:
|
||||||
|
maximum_idx = random.choice(top_indices)
|
||||||
selected = taged_image_list.pop(maximum_idx)
|
selected = taged_image_list.pop(maximum_idx)
|
||||||
image_name = selected["image_url"]
|
image_name = selected["image_url"]
|
||||||
modifications[template_component_name] =image_name
|
modifications[template_component_name] =image_name
|
||||||
|
|
@ -679,6 +690,8 @@ class CreatomateService:
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
|
|
||||||
|
source["frame_rate"] = 30
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import re
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
|
|
@ -6,6 +8,9 @@ from app.utils.logger import get_logger
|
||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
# {known_field} 형태만 치환하기 위한 패턴. JSON 예시 등 리터럴 중괄호는 보존된다.
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
||||||
|
|
||||||
logger = get_logger("prompt")
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
_SCOPES = [
|
_SCOPES = [
|
||||||
|
|
@ -15,15 +20,33 @@ _SCOPES = [
|
||||||
_sheet_cache: dict[str, tuple[str, str]] = {}
|
_sheet_cache: dict[str, tuple[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
|
def _get_spreadsheet():
|
||||||
if sheet_name not in _sheet_cache:
|
|
||||||
creds = Credentials.from_service_account_file(
|
creds = Credentials.from_service_account_file(
|
||||||
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
||||||
)
|
)
|
||||||
gc = gspread.authorize(creds)
|
return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET)
|
||||||
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(sheet_name)
|
|
||||||
values = ws.batch_get(["B2:B3"])[0] # [[B2], [B3]]
|
|
||||||
_sheet_cache[sheet_name] = (values[1][0], values[0][0]) # (B3=template, B2=model)
|
def _preload_all_sheets(sheet_names: list[str]):
|
||||||
|
ranges = [f"{name}!B2:B3" for name in sheet_names]
|
||||||
|
response = _get_spreadsheet().values_batch_get(ranges)
|
||||||
|
for value_range in response["valueRanges"]:
|
||||||
|
range_name = value_range.get("range", "")
|
||||||
|
name = range_name.split("!")[0].strip("'")
|
||||||
|
if name not in sheet_names:
|
||||||
|
continue
|
||||||
|
values = value_range.get("values", [])
|
||||||
|
if len(values) < 2 or not values[0] or not values[1]:
|
||||||
|
logger.warning(f"Sheet '{name}' has missing data, skipping preload")
|
||||||
|
continue
|
||||||
|
_sheet_cache[name] = (values[1][0], values[0][0]) # (B3=template, B2=model)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
|
||||||
|
if sheet_name not in _sheet_cache:
|
||||||
|
ws = _get_spreadsheet().worksheet(sheet_name)
|
||||||
|
values = ws.batch_get(["B2:B3"])[0]
|
||||||
|
_sheet_cache[sheet_name] = (values[1][0], values[0][0])
|
||||||
return _sheet_cache[sheet_name]
|
return _sheet_cache[sheet_name]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,43 +64,43 @@ class Prompt():
|
||||||
self.prompt_output_class = prompt_output_class
|
self.prompt_output_class = prompt_output_class
|
||||||
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
|
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
|
||||||
|
|
||||||
def _reload_prompt(self):
|
|
||||||
_sheet_cache.pop(self.sheet_name, None)
|
|
||||||
self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name)
|
|
||||||
|
|
||||||
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
||||||
verified_input = self.prompt_input_class(**input_data)
|
verified_input = self.prompt_input_class(**input_data)
|
||||||
build_template = self.prompt_template
|
values = verified_input.model_dump()
|
||||||
build_template = build_template.format(**verified_input.model_dump())
|
# 알려진 {필드}만 치환하고, 프롬프트 내 JSON 예시 등 리터럴 중괄호는 그대로 둔다.
|
||||||
|
# (str.format은 모든 {..}를 필드로 해석해 리터럴 중괄호에서 깨지므로 사용하지 않음)
|
||||||
|
build_template = _PLACEHOLDER_RE.sub(
|
||||||
|
lambda m: str(values[m.group(1)]) if m.group(1) in values else m.group(0),
|
||||||
|
self.prompt_template,
|
||||||
|
)
|
||||||
if not silent:
|
if not silent:
|
||||||
logger.debug(f"build_template: {build_template}")
|
logger.debug(f"build_template: {build_template}")
|
||||||
logger.debug(f"input_data: {input_data}")
|
logger.debug(f"input_data: {input_data}")
|
||||||
return build_template
|
return build_template
|
||||||
|
|
||||||
|
# 업종 분기는 각 프롬프트 내부에서 {industry} 변수로 처리하므로 시트는 타입별 1개로 통합.
|
||||||
|
_preload_all_sheets([
|
||||||
|
"marketing",
|
||||||
|
"lyric",
|
||||||
|
"subtitle",
|
||||||
|
"yt_upload",
|
||||||
|
"image_tag",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# 업종 분기는 프롬프트 내부 {industry} 변수로 처리하므로 타입별 단일 프롬프트만 둔다.
|
||||||
marketing_prompt = Prompt(
|
marketing_prompt = Prompt(
|
||||||
sheet_name="marketing",
|
sheet_name="marketing",
|
||||||
prompt_input_class=MarketingPromptInput,
|
prompt_input_class=MarketingPromptInput,
|
||||||
prompt_output_class=MarketingPromptOutput,
|
prompt_output_class=MarketingPromptOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
restaurant_marketing_prompt = Prompt(
|
|
||||||
sheet_name="m_restaurant",
|
|
||||||
prompt_input_class=MarketingPromptInput,
|
|
||||||
prompt_output_class=MarketingPromptOutput,
|
|
||||||
)
|
|
||||||
|
|
||||||
lyric_prompt = Prompt(
|
lyric_prompt = Prompt(
|
||||||
sheet_name="lyric",
|
sheet_name="lyric",
|
||||||
prompt_input_class=LyricPromptInput,
|
prompt_input_class=LyricPromptInput,
|
||||||
prompt_output_class=LyricPromptOutput,
|
prompt_output_class=LyricPromptOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
restaurant_lyric_prompt = Prompt(
|
|
||||||
sheet_name="l_restaurant",
|
|
||||||
prompt_input_class=LyricPromptInput,
|
|
||||||
prompt_output_class=LyricPromptOutput,
|
|
||||||
)
|
|
||||||
|
|
||||||
yt_upload_prompt = Prompt(
|
yt_upload_prompt = Prompt(
|
||||||
sheet_name="yt_upload",
|
sheet_name="yt_upload",
|
||||||
prompt_input_class=YTUploadPromptInput,
|
prompt_input_class=YTUploadPromptInput,
|
||||||
|
|
@ -91,18 +114,10 @@ image_autotag_prompt = Prompt(
|
||||||
)
|
)
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
|
||||||
|
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
|
||||||
return Prompt(
|
return Prompt(
|
||||||
sheet_name="subtitle",
|
sheet_name="subtitle",
|
||||||
prompt_input_class=SubtitlePromptInput,
|
prompt_input_class=SubtitlePromptInput,
|
||||||
prompt_output_class=SubtitlePromptOutput[length],
|
prompt_output_class=SubtitlePromptOutput[length],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def reload_all_prompt():
|
|
||||||
marketing_prompt._reload_prompt()
|
|
||||||
restaurant_marketing_prompt._reload_prompt()
|
|
||||||
lyric_prompt._reload_prompt()
|
|
||||||
restaurant_lyric_prompt._reload_prompt()
|
|
||||||
yt_upload_prompt._reload_prompt()
|
|
||||||
image_autotag_prompt._reload_prompt()
|
|
||||||
|
|
@ -94,6 +94,7 @@ class NarrativePreference(BaseModel):
|
||||||
# Input 정의
|
# Input 정의
|
||||||
class ImageTagPromptInput(BaseModel):
|
class ImageTagPromptInput(BaseModel):
|
||||||
img_url : str = Field(..., description="이미지 URL")
|
img_url : str = Field(..., description="이미지 URL")
|
||||||
|
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
|
||||||
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
|
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
|
||||||
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
|
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
|
||||||
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
|
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class LyricPromptInput(BaseModel):
|
||||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||||
language : str= Field(..., description = "가사 언어")
|
language : str= Field(..., description = "가사 언어")
|
||||||
timing_rules : str = Field(..., description = "시간 제어문")
|
timing_rules : str = Field(..., description = "시간 제어문")
|
||||||
|
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class LyricPromptOutput(BaseModel):
|
class LyricPromptOutput(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ class MarketingPromptInput(BaseModel):
|
||||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||||
region : str = Field(..., description = "마케팅 대상 지역")
|
region : str = Field(..., description = "마케팅 대상 지역")
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
|
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class BrandIdentity(BaseModel):
|
class BrandIdentity(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class SubtitlePromptInput(BaseModel):
|
||||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
|
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
|
||||||
|
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class PitchingOutput(BaseModel):
|
class PitchingOutput(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class YTUploadPromptInput(BaseModel):
|
||||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||||
language : str= Field(..., description = "영상 언어")
|
language : str= Field(..., description = "영상 언어")
|
||||||
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||||
|
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기")
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class YTUploadPromptOutput(BaseModel):
|
class YTUploadPromptOutput(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,17 @@ class SubtitleContentsGenerator():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.chatgpt_service = ChatgptService(timeout=60.0)
|
self.chatgpt_service = ChatgptService(timeout=60.0)
|
||||||
|
|
||||||
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean") -> SubtitlePromptOutput:
|
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean", industry: str = "") -> SubtitlePromptOutput:
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
|
f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
|
||||||
f"pitching_count: {len(pitching_label_list)}, "
|
f"pitching_count: {len(pitching_label_list)}, "
|
||||||
f"labels: {pitching_label_list}, "
|
f"labels: {pitching_label_list}, "
|
||||||
f"language: {language}"
|
f"language: {language}, "
|
||||||
|
f"industry: {industry}"
|
||||||
)
|
)
|
||||||
|
|
||||||
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
|
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list), industry)
|
||||||
pitching_label_string = "\n".join(pitching_label_list)
|
pitching_label_string = "\n".join(pitching_label_list)
|
||||||
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
||||||
input_data = {
|
input_data = {
|
||||||
|
|
@ -34,6 +35,7 @@ class SubtitleContentsGenerator():
|
||||||
"customer_name" : customer_name,
|
"customer_name" : customer_name,
|
||||||
"detail_region_info" : detail_region_info,
|
"detail_region_info" : detail_region_info,
|
||||||
"language" : language,
|
"language" : language,
|
||||||
|
"industry": industry, # 가사/영상 파이프라인에서 전달된 업종 enum
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
-- Migration: project 테이블에 biz_type 컬럼 추가
|
-- Migration: project 테이블에 biz_type 컬럼 추가
|
||||||
-- Date: 2026-06-17
|
-- Date: 2026-06-17
|
||||||
-- Description: 업종 구분 컬럼명 biz_type
|
-- Description: 업종 구분 컬럼명 biz_type
|
||||||
-- (accommodation: 숙박, restaurant: 음식점 등 업종 확장 예정)
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
ALTER TABLE project
|
ALTER TABLE project
|
||||||
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
|
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
|
||||||
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id;
|
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id;
|
||||||
|
|
||||||
|
ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT '';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue