업체명+URL 검색 통합 및 요식업종 가하도록 추가

feature-imageMatch
김성경 2026-06-19 16:00:27 +09:00
parent d1fe7fcd5f
commit 009b1a5d09
12 changed files with 136 additions and 276 deletions

View File

@ -2,7 +2,9 @@ import json
import time import time
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Literal, Optional
from pydantic import BaseModel
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
import aiofiles import aiofiles
@ -36,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 from app.utils.prompts.prompts import marketing_prompt, restaurant_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
@ -48,18 +50,19 @@ router = APIRouter()
@router.get( @router.get(
"/search/accommodation", "/search/accommodation",
summary="숙박/펜션 자동완성 검색", summary="장소 자동완성 검색 (숙박/음식점 등)",
description=""" description="""
네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다. 네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다.
## 요청 파라미터 ## 요청 파라미터
- **query**: 검색어 (필수) - **query**: 검색어 (필수)
- **category**: 카테고리 ("accommodation" | "restaurant", 기본값: 전체)
## 반환 정보 ## 반환 정보
- **query**: 검색어 - **query**: 검색어
- **count**: 검색 결과 (최대 10) - **count**: 검색 결과 (최대 10)
- **items**: 검색 결과 목록 - **items**: 검색 결과 목록
- **title**: 소명 (HTML 태그 포함 가능) - **title**: 소명 (HTML 태그 포함 가능)
- **address**: 지번 주소 - **address**: 지번 주소
- **roadAddress**: 도로명 주소 - **roadAddress**: 도로명 주소
""", """,
@ -72,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,
@ -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) 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( @router.post(
"/crawling", "/crawling",
summary="네이버 지도 크롤링", summary="네이버 지도 크롤링",
@ -222,17 +260,21 @@ async def _crawling_logic(
road_address = scraper.base_info.get("roadAddress", "") road_address = scraper.base_info.get("roadAddress", "")
jibun_address = scraper.base_info.get("address", "") jibun_address = scraper.base_info.get("address", "")
customer_name = scraper.base_info.get("name", "") customer_name = scraper.base_info.get("name", "")
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)
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,
) )
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} ({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 마케팅 분석 ========== # ========== Step 3: ChatGPT 마케팅 분석 ==========
@ -249,8 +291,9 @@ async def _crawling_logic(
} }
# 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(
marketing_prompt, input_marketing_data selected_prompt, input_marketing_data
) )
# Step 3-3: 분석 결과 DB 저장 # 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, "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
"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",
} }
@ -351,8 +395,9 @@ async def manual_marketing(
"region": region, "region": region,
"detail_region_info": request_body.address, "detail_region_info": request_body.address,
} }
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(
marketing_prompt, input_marketing_data selected_prompt, input_marketing_data
) )
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
@ -386,6 +431,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,
) )

View File

@ -114,6 +114,13 @@ class Project(Base):
comment="마케팅 인텔리전스 결과 정보 저장", comment="마케팅 인텔리전스 결과 정보 저장",
) )
biz_type: Mapped[str] = mapped_column(
String(50),
nullable=True,
default="place",
comment="장소 유형 (place: 기본, accommodation: 숙박, restaurant: 음식점)",
)
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,

View File

@ -78,6 +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: 음식점)")
# class MarketingAnalysisDetail(BaseModel): # class MarketingAnalysisDetail(BaseModel):
@ -242,6 +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: 음식점)")
class ManualMarketingRequest(BaseModel): class ManualMarketingRequest(BaseModel):
@ -258,6 +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: 음식점)")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):

View File

@ -32,7 +32,7 @@ class NaverSearchClient:
display: int = 5, display: int = 5,
) -> List[dict]: ) -> List[dict]:
""" """
숙박/펜션 검색 장소 검색 (숙박, 음식점 )
Args: Args:
query: 검색어 query: 검색어
@ -41,8 +41,7 @@ class NaverSearchClient:
Returns: Returns:
검색 결과 리스트 (address, roadAddress, title) 검색 결과 리스트 (address, roadAddress, title)
""" """
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가 search_query = query
search_query = f"{query} 숙박"
headers = { headers = {
"X-Naver-Client-Id": self.client_id, "X-Naver-Client-Id": self.client_id,

View File

@ -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 from app.utils.prompts.prompts import lyric_prompt, restaurant_lyric_prompt
import traceback as tb import traceback as tb
import json import json
# 로거 설정 # 로거 설정
@ -288,6 +288,8 @@ async def generate_lyric(
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나 "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 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)")
@ -313,7 +315,8 @@ async def generate_lyric(
detail_region_info=request_body.detail_region_info, detail_region_info=request_body.detail_region_info,
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,
) )
session.add(project) session.add(project)
await session.commit() await session.commit()
@ -327,7 +330,7 @@ async def generate_lyric(
step3_start = time.perf_counter() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") 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( lyric = Lyric(
project_id=project.id, project_id=project.id,
task_id=task_id, task_id=task_id,
@ -358,7 +361,7 @@ async def generate_lyric(
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
task_id=task_id, task_id=task_id,
prompt=lyric_prompt, prompt=selected_lyric_prompt,
lyric_input_data=lyric_input_data, lyric_input_data=lyric_input_data,
lyric_id=lyric.id, lyric_id=lyric.id,
) )

View File

@ -76,6 +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: 음식점)")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)") instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")

View File

@ -1,3 +1,15 @@
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
METRO_SIDOS: dict[str, str] = {
"서울특별시": "서울시",
"부산광역시": "부산시",
"대구광역시": "대구시",
"인천광역시": "인천시",
"광주광역시": "광주시",
"대전광역시": "대전시",
"울산광역시": "울산시",
"세종특별시": "세종시",
}
SIDO_CITIES: dict[str, list[str]] = { SIDO_CITIES: dict[str, list[str]] = {
"서울특별시": ["서울시"], "서울특별시": ["서울시"],
"부산광역시": ["부산시"], "부산광역시": ["부산시"],
@ -61,6 +73,12 @@ def extract_sigungu(address: str) -> str:
# 첫 토큰으로 도 판별 (정식명 or 약칭) # 첫 토큰으로 도 판별 (정식명 or 약칭)
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0]) 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) cities = SIDO_CITIES.get(sido)
if cities and len(tokens) >= 2: if cities and len(tokens) >= 2:
second = tokens[1] second = tokens[1]

View File

@ -72,6 +72,7 @@ query getAccommodation($id: String!, $deviceType: String) {
self.image_link_list: list[str] | None = None self.image_link_list: list[str] | None = None
self.base_info: dict | None = None self.base_info: dict | None = None
self.facility_info: str | None = None self.facility_info: str | None = None
self.place_type: str | None = None # 크롤링으로 감지된 업종 (accommodation | restaurant)
def _get_request_headers(self) -> dict: def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy() headers = self.DEFAULT_HEADERS.copy()
@ -95,7 +96,12 @@ query getAccommodation($id: String!, $deviceType: String) {
match = re.search(place_pattern, self.url) match = re.search(place_pattern, self.url)
if not match: if not match:
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161) # 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: if not match:
raise URLNotFoundException("Failed to parse place ID from URL") raise URLNotFoundException("Failed to parse place ID from URL")
return match[1] return match[1]
@ -167,7 +173,7 @@ query getAccommodation($id: String!, $deviceType: String) {
raise GraphQLException(f"Client error: {e}") raise GraphQLException(f"Client error: {e}")
async def _get_facility_string(self, place_id: str) -> str | None: async def _get_facility_string(self, place_id: str) -> str | None:
"""소 페이지에서 편의시설 정보를 크롤링합니다. """소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
Args: Args:
place_id: 네이버 지도 장소 ID place_id: 네이버 지도 장소 ID
@ -175,15 +181,16 @@ query getAccommodation($id: String!, $deviceType: String) {
Returns: Returns:
편의시설 정보 문자열 또는 None 편의시설 정보 문자열 또는 None
""" """
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home" place_types = ["accommodation", "restaurant"]
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
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: async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser") soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의") c_elem = soup.find("span", "place_blind", string="편의")
if c_elem: if c_elem:
facilities = c_elem.parent.parent.find("div").string return c_elem.parent.parent.find("div").string
return facilities
return None return None
except Exception as e: except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")

View File

@ -59,12 +59,24 @@ marketing_prompt = Prompt(
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,
@ -88,6 +100,8 @@ def create_dynamic_subtitle_prompt(length: int) -> Prompt:
def reload_all_prompt(): def reload_all_prompt():
marketing_prompt._reload_prompt() marketing_prompt._reload_prompt()
restaurant_marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt() lyric_prompt._reload_prompt()
restaurant_lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt() yt_upload_prompt._reload_prompt()
image_autotag_prompt._reload_prompt() image_autotag_prompt._reload_prompt()

View File

@ -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}

View File

@ -137,7 +137,7 @@ class SunoService:
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨") logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
else: else:
formatted_prompt = ( 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] = { payload: dict[str, Any] = {

View File

@ -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: 음식점)';