업체명+URL 검색 통합 및 요식업종 가하도록 추가
parent
d1fe7fcd5f
commit
009b1a5d09
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 전용 모드 (가사 생성 안 함)")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,15 +181,16 @@ 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:
|
||||
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:
|
||||
facilities = c_elem.parent.parent.find("div").string
|
||||
return facilities
|
||||
return c_elem.parent.parent.find("div").string
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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}
|
||||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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: 음식점)';
|
||||
Loading…
Reference in New Issue