범용 프롬프트 적용

feature-imageMatch
김성경 2026-06-29 15:06:04 +09:00
parent 8415e1c3a7
commit 04d8614919
18 changed files with 140 additions and 85 deletions

View File

@ -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,
)

View File

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

View File

@ -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):

View File

@ -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()

View File

@ -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 전용 모드 (가사 생성 안 함)")

View File

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

View File

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

View File

@ -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 형식으로 변환

View File

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

View File

@ -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):

View File

@ -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 _read_sheet_data(sheet_name: str) -> tuple[str, str]:
if sheet_name not in _sheet_cache:
def _get_spreadsheet():
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)
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:
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()

View File

@ -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="카메라 정보를 가지는 태그 리스트")

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

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

View File

@ -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 '';