diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index ff3a601..fa6a6de 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -38,7 +38,7 @@ from app.utils.common import generate_task_id from app.utils.logger import get_logger from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException 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.autotag import autotag_images from config import MEDIA_ROOT @@ -56,7 +56,7 @@ router = APIRouter() ## 요청 파라미터 - **query**: 검색어 (필수) -- **category**: 카테고리 ("accommodation" | "restaurant", 기본값: 전체) +- **category**: 카테고리 ## 반환 정보 - **query**: 검색어 @@ -75,7 +75,7 @@ router = APIRouter() async def search_accommodation( query: str, ) -> AccommodationSearchResponse: - """장소 자동완성 검색 (숙박/음식점 등)""" + """장소 자동완성 검색""" results = await naver_search_client.search_accommodation( query=query, 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) -class _BizTypeOutput(BaseModel): - biz_type: Literal["accommodation", "restaurant", "place"] +class _IndustryOutput(BaseModel): + industry: Literal["stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction"] -async def _resolve_biz_type(place_type: str | None, category: str) -> str: - """크롤링에서 감지된 place_type을 우선 사용하고, 없으면 AI 분류로 판단.""" - if place_type in ("accommodation", "restaurant"): - return place_type - if not category: - return "place" +async def _resolve_industry(category: str, customer_name: str = "") -> str: + """업체를 통합 프롬프트의 8개 industry enum 중 하나로 AI 분류. + + 분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다. + 근거가 둘 다 없거나 분류 실패 시 빈 문자열 반환(프롬프트가 범용 처리). + """ + if category: + basis_label, basis_value = "네이버 지도 카테고리", category + elif customer_name: + basis_label, basis_value = "업체명", customer_name + else: + return "" try: chatgpt = ChatgptService() prompt = ( - f"다음은 네이버 지도의 업종 카테고리 값입니다: '{category}'\n" - "이 업종이 숙박업(accommodation), 음식점(restaurant), 그 외(place) 중 어디에 해당하는지 분류하세요." + f"{basis_label}: '{basis_value}'\n" + "위 정보를 바탕으로 이 업체를 다음 8개 업종 중 가장 적합한 하나로 분류하세요: " + "stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), " + "salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), " + "academy(학원/교습소), attraction(관광/체험/액티비티)." ) result = await chatgpt._call_pydantic_output_chat_completion( prompt=prompt, - output_format=_BizTypeOutput, + output_format=_IndustryOutput, model="gpt-4o-mini", img_url=None, image_detail_high=False, ) - return result.biz_type + return result.industry except Exception as e: - logger.warning(f"[biz_type] AI 분류 실패, 기본값 place 반환: {e}") - return "place" - - -def _select_marketing_prompt(biz_type: str): - if biz_type == "restaurant": - return restaurant_marketing_prompt - return marketing_prompt + logger.warning(f"[industry] AI 분류 실패, 빈 값 반환: {e}") + return "" @router.post( @@ -262,19 +265,19 @@ async def _crawling_logic( customer_name = scraper.base_info.get("name", "") category = scraper.base_info.get("category", "") 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( customer_name=customer_name, region=region, detail_region_info=road_address or jibun_address or "", - biz_type=biz_type, + industry=industry, ) step2_elapsed = (time.perf_counter() - step2_start) * 1000 logger.info( 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 마케팅 분석 ========== @@ -288,15 +291,15 @@ async def _crawling_logic( "customer_name": customer_name, "region": region, "detail_region_info": road_address or "", + "industry": industry, # 통합 프롬프트 내부 분기용 업종 enum } # Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환 - selected_prompt = _select_marketing_prompt(biz_type) 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( place_id=scraper.place_id, intel_result=marketing_analysis.model_dump(), @@ -352,7 +355,7 @@ async def _crawling_logic( "processed_info": processed_info, "marketing_analysis": marketing_analysis, "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 호출 → 마케팅 분석 결과 생성 # place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우) chatgpt_service = ChatgptService() + # 요청에 industry가 있으면 사용, 없으면 업체명 기반 AI 분류 + industry = request_body.industry or await _resolve_industry( + "", request_body.store_name + ) + processed_info.industry = industry input_marketing_data = { "customer_name": request_body.store_name, "region": region, "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( - selected_prompt, input_marketing_data + marketing_prompt, input_marketing_data ) # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) @@ -431,7 +439,7 @@ async def manual_marketing( processed_info=processed_info, marketing_analysis=marketing_analysis, m_id=m_id, - biz_type=request_body.biz_type, + industry=industry, ) diff --git a/app/home/models.py b/app/home/models.py index 0454108..73e216b 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -114,11 +114,11 @@ class Project(Base): comment="마케팅 인텔리전스 결과 정보 저장", ) - biz_type: Mapped[str] = mapped_column( + industry: Mapped[str] = mapped_column( String(50), nullable=True, - default="place", - comment="장소 유형 (place: 기본, accommodation: 숙박, restaurant: 음식점)", + default="", + comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction)", ) language: Mapped[str] = mapped_column( diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index 204363a..a3d317e 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -78,7 +78,7 @@ class ProcessedInfo(BaseModel): customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") region: 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): @@ -243,7 +243,7 @@ class CrawlingResponse(BaseModel): None, description="마케팅 분석 결과 . 실패 시 null" ) 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): @@ -260,7 +260,7 @@ class ManualMarketingRequest(BaseModel): store_name: 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): diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index fb1aaf5..c8ef911 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -47,7 +47,7 @@ from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.logger import get_logger 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 json # 로거 설정 @@ -286,9 +286,11 @@ async def generate_lyric( "marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False), "language" : request_body.language, "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 #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, user_uuid=current_user.user_uuid, marketing_intelligence=request_body.m_id, - biz_type=request_body.biz_type, + industry=request_body.industry, ) session.add(project) await session.commit() diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index ecf8c76..10479bb 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -76,7 +76,7 @@ class GenerateLyricRequest(BaseModel): description="영상 방향 (horizontal: 가로형, vertical: 세로형)", ), 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 전용 모드 (가사 생성 안 함)") diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index fa84303..722e47a 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -195,6 +195,7 @@ async def generate_subtitle_background( customer_name=customer_name, detail_region_info=store_address, language=language, + industry=project.industry, ) pitching_output_list = generated_subtitles.pitching_results diff --git a/app/social/services/seo_service.py b/app/social/services/seo_service.py index 28cf0dc..f4d7f93 100644 --- a/app/social/services/seo_service.py +++ b/app/social/services/seo_service.py @@ -94,8 +94,10 @@ class SeoService: ), "language": project.language, "target_keywords": hashtags, + "industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum } + # 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용 chatgpt = ChatgptService(timeout=180) yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) diff --git a/app/social/uploader/youtube.py b/app/social/uploader/youtube.py index d2baf20..fc0b982 100644 --- a/app/social/uploader/youtube.py +++ b/app/social/uploader/youtube.py @@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader): body = { "snippet": { "title": metadata.title, - "description": metadata.description or "", + "description": self._sanitize_description(metadata.description), "tags": metadata.tags or [], "categoryId": self._get_category_id(metadata), }, @@ -380,6 +380,12 @@ class YouTubeUploader(BaseSocialUploader): ) 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: """ PrivacyStatus를 YouTube API 형식으로 변환 diff --git a/app/utils/autotag.py b/app/utils/autotag.py index 25e0427..7b65909 100644 --- a/app/utils/autotag.py +++ b/app/utils/autotag.py @@ -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_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True) - MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에 + MAX_RETRY = 2 for _ in range(MAX_RETRY): failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)] print("Failed", failed_idx) diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index e7b51df..776e090 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -30,6 +30,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications) """ import copy +import random import time from enum import StrEnum from typing import Literal @@ -470,10 +471,20 @@ class CreatomateService: match template_type: case "image": 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: + # 이미지 부족(슬롯 수 > 이미지 수): 재사용이 불가피하므로 슬롯에 가장 적합한 이미지(최고점) 선택 + maximum_idx = image_score_list.index(max(image_score_list)) selected = taged_image_list[maximum_idx] 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) image_name = selected["image_url"] modifications[template_component_name] =image_name @@ -679,6 +690,8 @@ class CreatomateService: """ url = f"{self.BASE_URL}/v2/renders" + source["frame_rate"] = 30 + last_error: Exception | None = None for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1): diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index 356bf7e..9c8f83b 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -1,3 +1,5 @@ +import re + import gspread from pydantic import BaseModel 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 functools import lru_cache +# {known_field} 형태만 치환하기 위한 패턴. JSON 예시 등 리터럴 중괄호는 보존된다. +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + logger = get_logger("prompt") _SCOPES = [ @@ -15,15 +20,33 @@ _SCOPES = [ _sheet_cache: dict[str, tuple[str, str]] = {} +def _get_spreadsheet(): + creds = Credentials.from_service_account_file( + prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES + ) + return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET) + + +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: - creds = Credentials.from_service_account_file( - prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES - ) - gc = gspread.authorize(creds) - 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) + 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] @@ -41,43 +64,43 @@ class Prompt(): self.prompt_output_class = prompt_output_class 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: verified_input = self.prompt_input_class(**input_data) - build_template = self.prompt_template - build_template = build_template.format(**verified_input.model_dump()) + values = 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: logger.debug(f"build_template: {build_template}") logger.debug(f"input_data: {input_data}") return build_template +# 업종 분기는 각 프롬프트 내부에서 {industry} 변수로 처리하므로 시트는 타입별 1개로 통합. +_preload_all_sheets([ + "marketing", + "lyric", + "subtitle", + "yt_upload", + "image_tag", +]) + + +# 업종 분기는 프롬프트 내부 {industry} 변수로 처리하므로 타입별 단일 프롬프트만 둔다. marketing_prompt = Prompt( sheet_name="marketing", prompt_input_class=MarketingPromptInput, prompt_output_class=MarketingPromptOutput, ) -restaurant_marketing_prompt = Prompt( - sheet_name="m_restaurant", - prompt_input_class=MarketingPromptInput, - prompt_output_class=MarketingPromptOutput, -) - lyric_prompt = Prompt( sheet_name="lyric", prompt_input_class=LyricPromptInput, prompt_output_class=LyricPromptOutput, ) -restaurant_lyric_prompt = Prompt( - sheet_name="l_restaurant", - prompt_input_class=LyricPromptInput, - prompt_output_class=LyricPromptOutput, -) - yt_upload_prompt = Prompt( sheet_name="yt_upload", prompt_input_class=YTUploadPromptInput, @@ -91,18 +114,10 @@ image_autotag_prompt = Prompt( ) @lru_cache() -def create_dynamic_subtitle_prompt(length: int) -> Prompt: +def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt: + # industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨. return Prompt( sheet_name="subtitle", prompt_input_class=SubtitlePromptInput, 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() \ No newline at end of file diff --git a/app/utils/prompts/schemas/image.py b/app/utils/prompts/schemas/image.py index 71b67d3..4c38a7d 100644 --- a/app/utils/prompts/schemas/image.py +++ b/app/utils/prompts/schemas/image.py @@ -94,6 +94,7 @@ class NarrativePreference(BaseModel): # Input 정의 class ImageTagPromptInput(BaseModel): 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="공간적 정보를 가지는 태그 리스트") subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트") camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트") diff --git a/app/utils/prompts/schemas/lyric.py b/app/utils/prompts/schemas/lyric.py index a00e1ba..c2cef87 100644 --- a/app/utils/prompts/schemas/lyric.py +++ b/app/utils/prompts/schemas/lyric.py @@ -9,6 +9,7 @@ class LyricPromptInput(BaseModel): marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") language : str= Field(..., description = "가사 언어") timing_rules : str = Field(..., description = "시간 제어문") + industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기") # Output 정의 class LyricPromptOutput(BaseModel): diff --git a/app/utils/prompts/schemas/marketing.py b/app/utils/prompts/schemas/marketing.py index 23a4d8c..5dbc906 100644 --- a/app/utils/prompts/schemas/marketing.py +++ b/app/utils/prompts/schemas/marketing.py @@ -6,6 +6,7 @@ class MarketingPromptInput(BaseModel): customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") region : str = Field(..., description = "마케팅 대상 지역") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") + industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기") # Output 정의 class BrandIdentity(BaseModel): diff --git a/app/utils/prompts/schemas/subtitle.py b/app/utils/prompts/schemas/subtitle.py index aa991e4..46491f7 100644 --- a/app/utils/prompts/schemas/subtitle.py +++ b/app/utils/prompts/schemas/subtitle.py @@ -10,6 +10,7 @@ class SubtitlePromptInput(BaseModel): customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") 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 정의 class PitchingOutput(BaseModel): diff --git a/app/utils/prompts/schemas/youtube.py b/app/utils/prompts/schemas/youtube.py index d81b97d..2acae39 100644 --- a/app/utils/prompts/schemas/youtube.py +++ b/app/utils/prompts/schemas/youtube.py @@ -8,6 +8,7 @@ class YTUploadPromptInput(BaseModel): marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") language : str= Field(..., description = "영상 언어") target_keywords: List[str] = Field(..., description="태그 키워드 리스트") + industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction). 통합 프롬프트가 이 값으로 내부 분기") # Output 정의 class YTUploadPromptOutput(BaseModel): diff --git a/app/utils/subtitles.py b/app/utils/subtitles.py index 5c6f12a..cfb6369 100644 --- a/app/utils/subtitles.py +++ b/app/utils/subtitles.py @@ -16,16 +16,17 @@ class SubtitleContentsGenerator(): def __init__(self): 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() logger.info( f"[SubtitleContentsGenerator] START - customer: {customer_name}, " f"pitching_count: {len(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) marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False) input_data = { @@ -34,6 +35,7 @@ class SubtitleContentsGenerator(): "customer_name" : customer_name, "detail_region_info" : detail_region_info, "language" : language, + "industry": industry, # 가사/영상 파이프라인에서 전달된 업종 enum } logger.info( diff --git a/docs/database-schema/migration_2026_06_17_biz_type.sql b/docs/database-schema/migration_2026_06_17_biz_type.sql index bdf923b..af1a4ad 100644 --- a/docs/database-schema/migration_2026_06_17_biz_type.sql +++ b/docs/database-schema/migration_2026_06_17_biz_type.sql @@ -2,9 +2,10 @@ -- Migration: project 테이블에 biz_type 컬럼 추가 -- Date: 2026-06-17 -- Description: 업종 구분 컬럼명 biz_type --- (accommodation: 숙박, restaurant: 음식점 등 업종 확장 예정) -- ============================================================ ALTER TABLE project ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place' COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id; + +ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT '';