diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 8e03156..ff3a601 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -2,7 +2,9 @@ import json import time from datetime import date from pathlib import Path -from typing import Optional +from typing import Literal, Optional + +from pydantic import BaseModel from urllib.parse import unquote, urlparse import aiofiles @@ -36,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 +from app.utils.prompts.prompts import marketing_prompt, restaurant_marketing_prompt from app.utils.address_parser import extract_region_from_address from app.utils.autotag import autotag_images from config import MEDIA_ROOT @@ -48,18 +50,19 @@ router = APIRouter() @router.get( "/search/accommodation", - summary="숙박/펜션 자동완성 검색", + summary="장소 자동완성 검색 (숙박/음식점 등)", description=""" -네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다. +네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다. ## 요청 파라미터 - **query**: 검색어 (필수) +- **category**: 카테고리 ("accommodation" | "restaurant", 기본값: 전체) ## 반환 정보 - **query**: 검색어 - **count**: 검색 결과 수 (최대 10개) - **items**: 검색 결과 목록 - - **title**: 숙소명 (HTML 태그 포함 가능) + - **title**: 장소명 (HTML 태그 포함 가능) - **address**: 지번 주소 - **roadAddress**: 도로명 주소 """, @@ -72,7 +75,7 @@ router = APIRouter() async def search_accommodation( query: str, ) -> AccommodationSearchResponse: - """숙박/펜션 자동완성 검색""" + """장소 자동완성 검색 (숙박/음식점 등)""" results = await naver_search_client.search_accommodation( query=query, display=10, @@ -91,6 +94,41 @@ 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"] + + +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" + try: + chatgpt = ChatgptService() + prompt = ( + f"다음은 네이버 지도의 업종 카테고리 값입니다: '{category}'\n" + "이 업종이 숙박업(accommodation), 음식점(restaurant), 그 외(place) 중 어디에 해당하는지 분류하세요." + ) + result = await chatgpt._call_pydantic_output_chat_completion( + prompt=prompt, + output_format=_BizTypeOutput, + model="gpt-4o-mini", + img_url=None, + image_detail_high=False, + ) + return result.biz_type + 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 + + @router.post( "/crawling", summary="네이버 지도 크롤링", @@ -222,17 +260,21 @@ async def _crawling_logic( road_address = scraper.base_info.get("roadAddress", "") jibun_address = scraper.base_info.get("address", "") 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) processed_info = ProcessedInfo( customer_name=customer_name, region=region, detail_region_info=road_address or jibun_address or "", + biz_type=biz_type, ) step2_elapsed = (time.perf_counter() - step2_start) * 1000 logger.info( - f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)" + 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)" ) # ========== Step 3: ChatGPT 마케팅 분석 ========== @@ -249,8 +291,9 @@ async def _crawling_logic( } # Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환 + selected_prompt = _select_marketing_prompt(biz_type) marketing_analysis = await chatgpt_service.generate_structured_output( - marketing_prompt, input_marketing_data + selected_prompt, input_marketing_data ) # Step 3-3: 분석 결과 DB 저장 @@ -308,7 +351,8 @@ async def _crawling_logic( "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, "processed_info": processed_info, "marketing_analysis": marketing_analysis, - "m_id" : m_id + "m_id": m_id, + "biz_type": biz_type if 'biz_type' in locals() else "place", } @@ -351,8 +395,9 @@ async def manual_marketing( "region": region, "detail_region_info": request_body.address, } + selected_prompt = _select_marketing_prompt(request_body.biz_type) marketing_analysis = await chatgpt_service.generate_structured_output( - marketing_prompt, input_marketing_data + selected_prompt, input_marketing_data ) # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) @@ -386,6 +431,7 @@ async def manual_marketing( processed_info=processed_info, marketing_analysis=marketing_analysis, m_id=m_id, + biz_type=request_body.biz_type, ) diff --git a/app/home/models.py b/app/home/models.py index a7e55d5..0454108 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -114,6 +114,13 @@ class Project(Base): comment="마케팅 인텔리전스 결과 정보 저장", ) + biz_type: Mapped[str] = mapped_column( + String(50), + nullable=True, + default="place", + comment="장소 유형 (place: 기본, accommodation: 숙박, restaurant: 음식점)", + ) + language: Mapped[str] = mapped_column( String(50), nullable=False, diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index c1b4bf7..204363a 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -78,6 +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: 음식점)") # class MarketingAnalysisDetail(BaseModel): @@ -242,6 +243,7 @@ class CrawlingResponse(BaseModel): None, description="마케팅 분석 결과 . 실패 시 null" ) m_id : int = Field(..., description="마케팅 분석 결과 ID") + biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)") class ManualMarketingRequest(BaseModel): @@ -258,6 +260,7 @@ class ManualMarketingRequest(BaseModel): store_name: str = Field(..., description="업체명 / 브랜드명") address: str = Field(..., description="도로명 또는 지번 주소") + biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)") class ErrorResponse(BaseModel): diff --git a/app/home/services/naver_search.py b/app/home/services/naver_search.py index 343b1c5..b1cd742 100644 --- a/app/home/services/naver_search.py +++ b/app/home/services/naver_search.py @@ -32,7 +32,7 @@ class NaverSearchClient: display: int = 5, ) -> List[dict]: """ - 숙박/펜션 검색 + 장소 검색 (숙박, 음식점 등) Args: query: 검색어 @@ -41,8 +41,7 @@ class NaverSearchClient: Returns: 검색 결과 리스트 (address, roadAddress, title) """ - # 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가 - search_query = f"{query} 숙박" + search_query = query headers = { "X-Naver-Client-Id": self.client_id, diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index c19b975..fb1aaf5 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 +from app.utils.prompts.prompts import lyric_prompt, restaurant_lyric_prompt import traceback as tb import json # 로거 설정 @@ -288,6 +288,8 @@ async def generate_lyric( "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 } + selected_lyric_prompt = restaurant_lyric_prompt if request_body.biz_type == "restaurant" else lyric_prompt + step1_elapsed = (time.perf_counter() - step1_start) * 1000 #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") @@ -313,7 +315,8 @@ async def generate_lyric( detail_region_info=request_body.detail_region_info, language=request_body.language, user_uuid=current_user.user_uuid, - marketing_intelligence = request_body.m_id + marketing_intelligence=request_body.m_id, + biz_type=request_body.biz_type, ) session.add(project) await session.commit() @@ -327,7 +330,7 @@ async def generate_lyric( step3_start = time.perf_counter() logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") - estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) + estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data) lyric = Lyric( project_id=project.id, task_id=task_id, @@ -358,7 +361,7 @@ async def generate_lyric( background_tasks.add_task( generate_lyric_background, task_id=task_id, - prompt=lyric_prompt, + prompt=selected_lyric_prompt, lyric_input_data=lyric_input_data, lyric_id=lyric.id, ) diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index f90cbc8..ecf8c76 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -76,6 +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: 음식점)") instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)") diff --git a/app/utils/address_parser.py b/app/utils/address_parser.py index d89c5b4..a9c3ad0 100644 --- a/app/utils/address_parser.py +++ b/app/utils/address_parser.py @@ -1,3 +1,15 @@ +# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리 +METRO_SIDOS: dict[str, str] = { + "서울특별시": "서울시", + "부산광역시": "부산시", + "대구광역시": "대구시", + "인천광역시": "인천시", + "광주광역시": "광주시", + "대전광역시": "대전시", + "울산광역시": "울산시", + "세종특별시": "세종시", +} + SIDO_CITIES: dict[str, list[str]] = { "서울특별시": ["서울시"], "부산광역시": ["부산시"], @@ -61,6 +73,12 @@ def extract_sigungu(address: str) -> str: # 첫 토큰으로 도 판별 (정식명 or 약칭) sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0]) + + # 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환 + metro_name = METRO_SIDOS.get(sido) + if metro_name: + return metro_name + cities = SIDO_CITIES.get(sido) if cities and len(tokens) >= 2: second = tokens[1] diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index b5e3072..df0b4b8 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -72,6 +72,7 @@ query getAccommodation($id: String!, $deviceType: String) { self.image_link_list: list[str] | None = None self.base_info: dict | None = None self.facility_info: str | None = None + self.place_type: str | None = None # 크롤링으로 감지된 업종 (accommodation | restaurant) def _get_request_headers(self) -> dict: headers = self.DEFAULT_HEADERS.copy() @@ -95,7 +96,12 @@ query getAccommodation($id: String!, $deviceType: String) { 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) + type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url) + if type_match: + url_place_type = type_match.group(1) + if url_place_type in ("accommodation", "restaurant"): + self.place_type = url_place_type + return type_match.group(2) if not match: raise URLNotFoundException("Failed to parse place ID from URL") return match[1] @@ -167,7 +173,7 @@ query getAccommodation($id: String!, $deviceType: String) { raise GraphQLException(f"Client error: {e}") async def _get_facility_string(self, place_id: str) -> str | None: - """숙소 페이지에서 편의시설 정보를 크롤링합니다. + """장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다. Args: place_id: 네이버 지도 장소 ID @@ -175,16 +181,17 @@ query getAccommodation($id: String!, $deviceType: String) { Returns: 편의시설 정보 문자열 또는 None """ - url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home" + place_types = ["accommodation", "restaurant"] try: async with aiohttp.ClientSession() as session: - async with session.get(url, headers=self._get_request_headers()) as response: - soup = bs4.BeautifulSoup(await response.read(), "html.parser") - c_elem = soup.find("span", "place_blind", string="편의") - if c_elem: - facilities = c_elem.parent.parent.find("div").string - return facilities - return None + for place_type in place_types: + url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home" + async with session.get(url, headers=self._get_request_headers()) as response: + soup = bs4.BeautifulSoup(await response.read(), "html.parser") + c_elem = soup.find("span", "place_blind", string="편의") + if c_elem: + return c_elem.parent.parent.find("div").string + return None except Exception as e: logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") return None diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index a26ee95..d41fbc8 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -59,12 +59,24 @@ marketing_prompt = Prompt( 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, @@ -88,6 +100,8 @@ def create_dynamic_subtitle_prompt(length: int) -> Prompt: 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/templates/subtitle_prompt.txt b/app/utils/prompts/templates/subtitle_prompt.txt deleted file mode 100644 index 5b23d0d..0000000 --- a/app/utils/prompts/templates/subtitle_prompt.txt +++ /dev/null @@ -1,248 +0,0 @@ -# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized) - -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 - -1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy. -2. NEVER invent facts not in the data. You MAY freely transform expressions. -3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both. -4. NEVER output meta-text. Every field MUST be real on-screen copy the viewer reads. - NEVER output a label/header/title that describes, classifies, or points to the content. - - FORBIDDEN examples: "검색 키워드 모아보기", "키워드 모음", "추천 태그", "관련 태그", - "더보기", "리스트", "아래 내용", "목록". - - Test: "Is this the content itself, or a caption ABOUT the content?" If it's about the content → FORBIDDEN. - ---- - -### LAYER NAME FORMAT (5-criteria) - -``` -(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id) -``` - -- Criteria separator: hyphen `-` -- Multi-word value: underscore `_` -- pair_id: 3-digit zero-padded (`001`~`999`) - -Example: `subtitle-intro-hook_claim-aspirational-001` - ---- - -### TAG VALUES - -**track_role**: `subtitle` | `keyword` - -**narrative_phase** (= emotion goal): -- `intro` → Curiosity (stop the scroll) -- `welcome` → Warmth -- `core` → Trust -- `highlight` → Desire (peak moment) -- `support` → Discovery -- `accent` → Belonging -- `cta` → Action - -**content_type** → source mapping: -- `hook_claim` ← selling_points[0] or core_value -- `space_feature` ← selling_points[].description -- `emotion_cue` ← same source, sensory rewrite -- `brand_name` ← store_name (verbatim OK) -- `brand_address` ← detail_region_info (verbatim OK) -- `lifestyle_fit` ← target_persona[].favor_target -- `local_info` ← location_feature_analysis -- `target_tag` ← target_keywords[] as hashtags -- `availability` ← fixed: "지금 예약 가능" -- `cta_action` ← fixed: "예약하러 가기" - -**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent` - ---- - -### SCENE STRUCTURE - -**Anchors (FIXED — never remove):** - -| Position | Phase | subtitle | keyword | -|---|---|---|---| -| First | intro | hook_claim | brand_name | -| Last-3 | support | brand_address | brand_name | -| Last-2 | accent | target_tag | lifestyle_fit | -| Last | cta | availability | cta_action | - -**Middle (FLEXIBLE — fill by selling_points score desc):** - -| Phase | subtitle | keyword | -|---|---|---| -| welcome | emotion_cue | space_feature | -| core | space_feature | emotion_cue | -| highlight | space_feature | emotion_cue | -| support(mid) | local_info | lifestyle_fit | - -Default: 7 scenes. Fewer scenes → remove flexible slots only. - ---- - -### TEXT SPECS - -**subtitle**: 8~18 chars. Sentence fragment, conversational. -**keyword**: 2~6 chars. MUST follow Korean word-formation rules below. - ---- - -### KEYWORD RULES (한국어 조어법 기반) - -Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID. - -#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴 -한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다. - -| Structure | GOOD | BAD (역순/비문) | -|---|---|---| -| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ | -| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ | -| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ | - -#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound) -이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지. - -| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) | -|---|---|---| -| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ | -| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ | -| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ | -| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ | -| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ | - -> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지 - -#### Pattern 3: 명사 + 명사 (Natural Compound Noun) -한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다. - -| Structure | GOOD | BAD (부자연스러운 결합) | -|---|---|---| -| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) | -| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) | -| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) | - -#### Pattern 4: 해시태그형 (#키워드) -accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용. - -| GOOD | BAD | -|---|---| -| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) | - -#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만) -그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용. - -| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) | -|---|---| -| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) | -| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) | - ---- - -### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증) - -Every keyword MUST pass ALL of these: - -- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서) -- [ ] 소리 내어 읽었을 때 어색하지 않은가? -- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가? -- [ ] 허용된 5개 Pattern 중 하나에 해당하는가? -- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가? -- [ ] 금지 표현 사전에 해당하지 않는가? - ---- - -### EXPRESSION DICTIONARY - -**SCAN BEFORE WRITING.** If JSON contains these → MUST replace: - -| Forbidden | → Use Instead | -|---|---| -| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 | -| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 | -| 가성비 | 합리적인 · 가치 있는 | -| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 | -| 인스타감성 | 감성 스팟 · 기록하고 싶은 | -| 혜자 | 풍성한 · 넉넉한 | - -**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡 - -**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate: -- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독 -- 자연 계열: 숲속 · 초록 · 산림 · 계곡 -- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기 -- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지 - ---- - -### TRANSFORM RULES BY CONTENT_TYPE - -**hook_claim** (intro only): -- Format: question OR exclamation OR provocation. Pick ONE. -- FORBIDDEN: brand name, generic greetings -- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲" - -**space_feature** (core/highlight): -- ONE selling point per scene -- NEVER use korean_category directly -- Viewer must imagine themselves there -- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채" - -**emotion_cue** (welcome/core/highlight): -- Senses: smell, sound, touch, temperature, light -- Poetic fragments, not full sentences -- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스" - -**lifestyle_fit** (accent/support): -- Address target directly in their language -- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?" - -**local_info** (support): -- Accessibility or charm, NOT administrative address -- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면" - ---- - -### PACING - -``` -intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16) -``` - -**RULE: No 3+ consecutive scenes in same char-count range.** - ---- - -Keyword pattern analysis: -- "스테이펫" → brand_name verbatim (허용) -- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲") -- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼") -- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈) -- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용) -- "예약하기" → Pattern 5: 의미 완결 동사 명사형 - - -# 입력 -**입력 1: 레이어 이름 리스트** -{pitching_tag_list_string} - -**입력 2: 마케팅 인텔리전스 JSON** -{marketing_intelligence} - -**입력 3: 비즈니스 정보 ** -Business Name: {customer_name} -Region Details: {detail_region_info} - -**입력 4: 출력 언어** -Language: {language} diff --git a/app/utils/suno.py b/app/utils/suno.py index e9e809f..45f36ba 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -137,7 +137,7 @@ class SunoService: logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") else: formatted_prompt = ( - f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}" + f"[Song Duration: Exactly 1 minute - Must be around 60 seconds]\n{prompt}" ) payload: dict[str, Any] = { diff --git a/docs/database-schema/migration_2026_06_17_biz_type.sql b/docs/database-schema/migration_2026_06_17_biz_type.sql new file mode 100644 index 0000000..d17b0c6 --- /dev/null +++ b/docs/database-schema/migration_2026_06_17_biz_type.sql @@ -0,0 +1,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: 음식점)';