diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index dedbcd2..c19b975 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -269,15 +269,6 @@ async def generate_lyric( step1_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") - promotional_expressions = { - "Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소", - "English" : "Instagram vibes, picture-perfect day, healing, travel, getaway", - "Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地", - "Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景", - "Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย", - "Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp" - }# HARD CODED, 어디에 정리하지? 아직 정리되지 않음 - timing_rules = { "60s" : """ 8–12 lines @@ -294,7 +285,6 @@ async def generate_lyric( "detail_region_info" : request_body.detail_region_info or "", "marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False), "language" : request_body.language, - "promotional_expression_example" : promotional_expressions[request_body.language], "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 } diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index dd0620c..fa84303 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -186,13 +186,15 @@ async def generate_subtitle_background( store_address = project.detail_region_info customer_name = project.store_name - logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}") + language = project.language or "Korean" + logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}, language: {language}") generated_subtitles = await subtitle_generator.generate_subtitle_contents( marketing_intelligence=marketing_intelligence.intel_result, pitching_label_list=pitchings, customer_name=customer_name, detail_region_info=store_address, + language=language, ) pitching_output_list = generated_subtitles.pitching_results diff --git a/app/utils/bgm_lyrics.py b/app/utils/bgm_lyrics.py index fa8bc20..77d0338 100644 --- a/app/utils/bgm_lyrics.py +++ b/app/utils/bgm_lyrics.py @@ -4,15 +4,24 @@ BGM 모드용 더미 가사 템플릿 instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록 placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다. -3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일. -분위기(밝음/감성/에너지)만 가사 텍스트로 차별화합니다. +장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz +섹션 태그 없이 한국어 10줄로 구성. """ -import random - -_BGM_DUMMY_LYRICS_LIST = [ - # 버전 1 — 밝고 경쾌한 분위기 - ( +_BGM_DUMMY_LYRICS: dict[str, str] = { + "K-Pop": ( + "반짝이는 눈빛으로 날 바라봐\n" + "심장이 터질 것 같은 이 느낌\n" + "너만 보면 세상이 달라 보여\n" + "오늘도 설레임이 멈추질 않아\n" + "같이 걸어가는 이 길 위에서\n" + "우리 둘만의 노래가 흘러\n" + "빛나는 순간들을 모아모아\n" + "영원히 기억할 우리의 이야기\n" + "지금 이 순간 네 곁에 있을게\n" + "함께라면 뭐든 할 수 있어\n" + ), + "Pop": ( "햇살 가득한 아침이 시작되고\n" "따스한 바람이 살며시 불어와\n" "거리마다 웃음꽃이 피어나고\n" @@ -24,8 +33,31 @@ _BGM_DUMMY_LYRICS_LIST = [ "오늘 하루도 빛나는 하루야\n" "환한 미소로 하루를 마무리해\n" ), - # 버전 2 — 잔잔하고 감성적인 분위기 - ( + "R&B": ( + "부드럽게 흐르는 이 그루브에\n" + "몸이 저절로 리듬을 타기 시작해\n" + "네 목소리가 귓가에 맴돌고\n" + "이 감각이 온몸을 감싸줘\n" + "달콤한 밤이 깊어질수록\n" + "너와 나 사이 거리가 좁혀져\n" + "촛불처럼 은은하게 타오르는\n" + "이 감정을 숨길 수가 없어\n" + "부드럽게 내 손을 잡아줘\n" + "오늘 밤 우리만의 노래를 불러\n" + ), + "Hip-Hop": ( + "내 방식대로 살아가는 이 길\n" + "누가 뭐래도 내 페이스로 가\n" + "매일 아침 눈을 뜨면 새로운 무대\n" + "두려움 없이 앞으로 나아가\n" + "땀과 노력으로 쌓아온 오늘\n" + "포기란 없어 끝까지 밀어붙여\n" + "내 이름을 기억해 두라고\n" + "이 무대 위에 내 발자국 남겨\n" + "하나둘 쌓여가는 내 이야기\n" + "진짜배기는 지금부터 시작이야\n" + ), + "Ballad": ( "저녁 노을이 물드는 창가에서\n" "조용히 흘러가는 시간 속에\n" "잔잔한 바람이 마음을 적시고\n" @@ -37,8 +69,7 @@ _BGM_DUMMY_LYRICS_LIST = [ "이 순간 여기 머무는 것만으로도 충분해\n" "고요한 밤이 나를 감싸 안아줘\n" ), - # 버전 3 — 강렬하고 에너지 넘치는 분위기 - ( + "EDM": ( "밤거리에 불빛이 타오르고\n" "심장이 두근두근 뛰기 시작해\n" "온몸에 퍼지는 뜨거운 열기\n" @@ -50,14 +81,63 @@ _BGM_DUMMY_LYRICS_LIST = [ "끝까지 불태워 이 에너지를\n" "새벽빛이 밝아올 때까지 달려\n" ), -] + "Rock": ( + "굉음을 내며 울려 퍼지는 기타\n" + "온몸을 뒤흔드는 강렬한 비트\n" + "규칙 따위는 집어치우고\n" + "있는 그대로 외쳐봐\n" + "불꽃처럼 타오르는 이 열정\n" + "아무도 막을 수 없어 지금\n" + "거침없이 달려가는 이 무대\n" + "목청껏 소리 질러 자유를\n" + "부서질 듯 뜨겁게 흔들어\n" + "이 밤이 끝날 때까지 록이야\n" + ), + "Jazz": ( + "커피 향이 피어오르는 오후\n" + "느긋하게 흐르는 재즈 선율에\n" + "발끝이 리듬을 타기 시작해\n" + "달콤한 여유가 가득 차오르고\n" + "창밖엔 황금빛 도시가 반짝여\n" + "잔을 들어 이 순간을 건배해\n" + "스윙하는 박자에 몸을 맡기고\n" + "부드럽게 흘러가는 이 밤을\n" + "기억 속에 새겨두고 싶어\n" + "재즈처럼 자유롭게 살고 싶어\n" + ), +} -def get_random_bgm_lyrics() -> tuple[str, int]: - """BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다. +_GENRE_ALIAS: dict[str, str] = { + "kpop": "K-Pop", + "k-pop": "K-Pop", + "k_pop": "K-Pop", + "pop": "Pop", + "rnb": "R&B", + "r&b": "R&B", + "r_b": "R&B", + "hiphop": "Hip-Hop", + "hip-hop": "Hip-Hop", + "hip_hop": "Hip-Hop", + "ballad": "Ballad", + "edm": "EDM", + "rock": "Rock", + "jazz": "Jazz", +} + + +def normalize_genre(genre: str) -> str: + return _GENRE_ALIAS.get(genre.lower(), genre) + + +def get_bgm_lyrics(genre: str) -> str: + """장르에 맞는 BGM 더미 가사를 반환합니다. + + Args: + genre: 장르명 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz) + 대소문자 및 구분자(-, _) 무관하게 처리됩니다. Returns: - (lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3) + 선택된 장르의 가사 텍스트 """ - index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST)) - return _BGM_DUMMY_LYRICS_LIST[index], index + 1 + return _BGM_DUMMY_LYRICS[normalize_genre(genre)] diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index 409fcc2..b5e3072 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -93,6 +93,9 @@ query getAccommodation($id: String!, $deviceType: String) { raise URLNotFoundException("This URL does not contain a place ID") match = re.search(place_pattern, self.url) + if not match: + # place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161) + match = re.search(r"place\.naver\.com/[a-zA-Z]+/(\d+)", self.url) if not match: raise URLNotFoundException("Failed to parse place ID from URL") return match[1] diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index a26ee95..bbbd2e7 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -1,4 +1,6 @@ +import os import gspread +from pathlib import Path from pydantic import BaseModel from google.oauth2.service_account import Credentials from config import prompt_settings @@ -34,15 +36,24 @@ class Prompt(): prompt_input_class = BaseModel prompt_output_class = BaseModel - def __init__(self, sheet_name, prompt_input_class, prompt_output_class): + def __init__(self, sheet_name, prompt_input_class, prompt_output_class, template_file: str = None, prompt_model: str = None): self.sheet_name = sheet_name self.prompt_input_class = prompt_input_class self.prompt_output_class = prompt_output_class - self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name) + if template_file: + self.prompt_template = Path(template_file).read_text(encoding="utf-8") + self.prompt_model = prompt_model + self._template_file = template_file + else: + self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name) + self._template_file = None def _reload_prompt(self): - _sheet_cache.pop(self.sheet_name, None) - self.prompt_template, self.prompt_model = _read_sheet_data(self.sheet_name) + if self._template_file: + self.prompt_template = Path(self._template_file).read_text(encoding="utf-8") + else: + _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) @@ -77,12 +88,17 @@ image_autotag_prompt = Prompt( prompt_output_class=ImageTagPromptOutput, ) +_SUBTITLE_TEMPLATE_PATH = str(Path(__file__).parent / "templates" / "subtitle_prompt.txt") + + @lru_cache() def create_dynamic_subtitle_prompt(length: int) -> Prompt: return Prompt( sheet_name="subtitle", prompt_input_class=SubtitlePromptInput, prompt_output_class=SubtitlePromptOutput[length], + template_file=_SUBTITLE_TEMPLATE_PATH, + prompt_model=os.getenv("SUBTITLE_PROMPT_MODEL", "gpt-4o-mini"), ) diff --git a/app/utils/prompts/schemas/lyric.py b/app/utils/prompts/schemas/lyric.py index 9a1698b..a00e1ba 100644 --- a/app/utils/prompts/schemas/lyric.py +++ b/app/utils/prompts/schemas/lyric.py @@ -8,7 +8,6 @@ class LyricPromptInput(BaseModel): detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") language : str= Field(..., description = "가사 언어") - promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시") timing_rules : str = Field(..., description = "시간 제어문") # Output 정의 diff --git a/app/utils/prompts/schemas/subtitle.py b/app/utils/prompts/schemas/subtitle.py index 3ec94c3..aa991e4 100644 --- a/app/utils/prompts/schemas/subtitle.py +++ b/app/utils/prompts/schemas/subtitle.py @@ -1,16 +1,16 @@ from pydantic import BaseModel, create_model, Field -from typing import List, Optional +from typing import List from functools import lru_cache # Input 정의 class SubtitlePromptInput(BaseModel): - marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보") + marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보") pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify") customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") - - #subtillecars : + language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)") + # Output 정의 class PitchingOutput(BaseModel): pitching_tag: str = Field(..., description="피칭 레이블") diff --git a/app/utils/prompts/templates/subtitle_prompt.txt b/app/utils/prompts/templates/subtitle_prompt.txt index 44f11c5..ebde93a 100644 --- a/app/utils/prompts/templates/subtitle_prompt.txt +++ b/app/utils/prompts/templates/subtitle_prompt.txt @@ -2,6 +2,13 @@ You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data. +**GENERATION LANGUAGE**: Korean (한국어) +**OUTPUT LANGUAGE**: {language} + +**WORKFLOW**: +1. Generate all subtitles and keywords in Korean following the rules below. +2. If {language} is NOT Korean, translate every subtitle text and keyword to {language} while preserving meaning, tone, and approximate character count spirit. Fixed anchor texts (availability, cta_action) must also be translated naturally. + --- ### RULES @@ -231,3 +238,6 @@ Keyword pattern analysis: **입력 3: 비즈니스 정보 ** Business Name: {customer_name} Region Details: {detail_region_info} + +**입력 4: 출력 언어** +Language: {language} diff --git a/app/utils/subtitles.py b/app/utils/subtitles.py index 06c6a12..5c6f12a 100644 --- a/app/utils/subtitles.py +++ b/app/utils/subtitles.py @@ -16,22 +16,24 @@ 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) -> 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") -> 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"labels: {pitching_label_list}, " + f"language: {language}" ) dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list)) pitching_label_string = "\n".join(pitching_label_list) marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False) input_data = { - "marketing_intelligence" : marketing_intel_string , + "marketing_intelligence" : marketing_intel_string, "pitching_tag_list_string" : pitching_label_string, "customer_name" : customer_name, "detail_region_info" : detail_region_info, + "language" : language, } logger.info( diff --git a/app/utils/suno.py b/app/utils/suno.py index 9bd1e1f..e9e809f 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -60,7 +60,7 @@ from typing import Any import httpx from app.song.schemas.song_schema import PollingSongResponse, SongClipData -from app.utils.bgm_lyrics import get_random_bgm_lyrics +from app.utils.bgm_lyrics import get_bgm_lyrics, normalize_genre as _normalize_genre from app.utils.logger import get_logger from config import apikey_settings, recovery_settings @@ -129,10 +129,12 @@ class SunoService: """ actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL + normalized_genre = _normalize_genre(genre) if genre else None + if instrumental: - bgm_lyrics, bgm_version = get_random_bgm_lyrics() + bgm_lyrics = get_bgm_lyrics(genre) formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}" - logger.info(f"[Suno] BGM 더미 가사 버전 {bgm_version} 선택됨") + logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") else: formatted_prompt = ( f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}" @@ -145,8 +147,8 @@ class SunoService: "prompt": formatted_prompt, "callBackUrl": actual_callback_url, } - if genre: - payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre + if normalized_genre: + payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre last_error: Exception | None = None