Compare commits

..

6 Commits

10 changed files with 272 additions and 160 deletions

2
.gitignore vendored
View File

@ -53,6 +53,4 @@ Dockerfile
zzz/
credentials/service_account.json
# Scheduler (separate repo)
o2o-castad-scheduler/

View File

@ -25,6 +25,7 @@ from app.home.schemas.home_schema import (
ImageUploadResponse,
ImageUploadResultItem,
ImageUrlItem,
ManualMarketingRequest,
ProcessedInfo,
# MarketingAnalysis,
)
@ -36,52 +37,12 @@ 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.address_parser import extract_region_from_address
from app.utils.autotag import autotag_images
from config import MEDIA_ROOT
# 로거 설정
logger = get_logger("home")
# 전국 시/군 이름 목록 (roadAddress에서 region 추출용)
# fmt: off
KOREAN_CITIES = [
# 특별시/광역시
"서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시",
# 경기도
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
# 강원특별자치도
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
"양구군", "인제군", "고성군", "양양군",
# 충청북도
"청주시", "충주시", "제천시",
"보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군",
# 충청남도
"천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시",
"금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군",
# 전북특별자치도
"전주시", "군산시", "익산시", "정읍시", "남원시", "김제시",
"완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군",
# 전라남도
"목포시", "여수시", "순천시", "나주시", "광양시",
"담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군",
"해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군",
# 경상북도
"포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시",
"의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군",
"예천군", "봉화군", "울진군", "울릉군",
# 경상남도
"창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시",
"의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군",
# 제주특별자치도
"제주시", "서귀포시",
]
# fmt: on
# router = APIRouter(tags=["Home"])
router = APIRouter()
@ -126,40 +87,8 @@ async def search_accommodation(
)
METRO_CITY_MAP = {
"서울": "서울시", "부산": "부산시", "대구": "대구시",
"인천": "인천시", "광주": "광주시", "대전": "대전시",
"울산": "울산시", "세종": "세종시",
}
def _extract_region_from_address(road_address: str | None) -> str:
"""roadAddress에서 시/군 이름 추출
매칭 우선순위:
1. KOREAN_CITIES 직접 매칭 (/ 접미사 포함)
2. KOREAN_CITIES 접미사 생략 매칭
3. 주소 번째 토큰이 /군으로 끝나는 경우 (: "전북 군산시 ...")
4. 주소 번째 토큰이 /동인 경우 번째 토큰으로 광역시 매핑 (: "서울 강남구 ...")
"""
if not road_address:
return ""
for city in KOREAN_CITIES:
if city in road_address:
return city
if city[:-1] in road_address:
return city
tokens = road_address.split()
if len(tokens) >= 2:
second = tokens[1]
if second.endswith("") or second.endswith(""):
return second
if second.endswith("") or second.endswith(""):
return METRO_CITY_MAP.get(tokens[0], "")
return ""
def _extract_region_from_address(road_address: str | None, jibun_address: str | None = None) -> str:
return extract_region_from_address(road_address, jibun_address)
@router.post(
@ -290,14 +219,15 @@ async def _crawling_logic(
marketing_analysis = None
if scraper.base_info:
road_address = scraper.base_info.get("roadAddress", "") or scraper.base_info.get("address", "")
road_address = scraper.base_info.get("roadAddress", "")
jibun_address = scraper.base_info.get("address", "")
customer_name = scraper.base_info.get("name", "")
region = _extract_region_from_address(road_address)
region = _extract_region_from_address(road_address, jibun_address)
processed_info = ProcessedInfo(
customer_name=customer_name,
region=region,
detail_region_info=road_address or "",
detail_region_info=road_address or jibun_address or "",
)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
@ -310,70 +240,29 @@ async def _crawling_logic(
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
try:
# Step 3-1: ChatGPT 서비스 초기화
step3_1_start = time.perf_counter()
# Step 3-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
chatgpt_service = ChatgptService()
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
logger.debug(
f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)"
)
# Step 3-2: 프롬프트 생성
# step3_2_start = time.perf_counter()
input_marketing_data = {
"customer_name": customer_name,
"region": region,
"detail_region_info": road_address or "",
}
# prompt = chatgpt_service.build_market_analysis_prompt()
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
# step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
# Step 3-3: GPT API 호출
step3_3_start = time.perf_counter()
structured_report = await chatgpt_service.generate_structured_output(
# Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
marketing_analysis = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data
)
marketing_intelligence = MarketingIntel(
# Step 3-3: 분석 결과 DB 저장
marketing_intel = MarketingIntel(
place_id=scraper.place_id,
intel_result = structured_report.model_dump()
intel_result=marketing_analysis.model_dump(),
)
session.add(marketing_intelligence)
session.add(marketing_intel)
await session.commit()
await session.refresh(marketing_intelligence)
m_id = marketing_intelligence.id
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info(
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
)
logger.debug(
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
)
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
step3_4_start = time.perf_counter()
logger.debug(
f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}"
)
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
# parsed = await chatgpt_service.parse_marketing_analysis(
# structured_report, facility_info=scraper.facility_info
# )
# marketing_analysis = MarketingAnalysis(**parsed)
logger.debug(
f"structured_report = {structured_report.model_dump()}"
)
marketing_analysis = structured_report
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
logger.debug(
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
)
await session.refresh(marketing_intel)
m_id = marketing_intel.id
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(
@ -395,7 +284,6 @@ async def _crawling_logic(
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
)
logger.exception("[crawling] Step 3 상세 오류:")
# GPT 실패 시에도 크롤링 결과는 반환
marketing_analysis = None
gpt_status = "failed"
else:
@ -413,8 +301,6 @@ async def _crawling_logic(
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if "step3_elapsed" in locals():
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if "step3_3_elapsed" in locals():
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
return {
"status": gpt_status if 'gpt_status' in locals() else "completed",
@ -426,6 +312,83 @@ async def _crawling_logic(
}
@router.post(
"/marketing",
summary="업체명+주소 직접 입력 마케팅 분석",
description="""
네이버 크롤링 없이 업체명과 주소를 직접 입력받아 마케팅 분석을 수행합니다.
## 요청 필드
- **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수)
## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
- **marketing_analysis**: ChatGPT 마케팅 분석 결과
- **m_id**: 마케팅 분석 결과 ID (이후 영상생성 파이프라인에 사용)
""",
response_model=CrawlingResponse,
tags=["Marketing"],
)
async def manual_marketing(
request_body: ManualMarketingRequest,
session: AsyncSession = Depends(get_session),
):
# Step 1: 주소에서 지역명 추출 및 processed_info 구성
region = _extract_region_from_address(request_body.address)
processed_info = ProcessedInfo(
customer_name=request_body.store_name,
region=region,
detail_region_info=request_body.address,
)
try:
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
chatgpt_service = ChatgptService()
input_marketing_data = {
"customer_name": request_body.store_name,
"region": region,
"detail_region_info": request_body.address,
}
marketing_analysis = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data
)
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
marketing_intel = MarketingIntel(
place_id=None,
intel_result=marketing_analysis.model_dump(),
)
session.add(marketing_intel)
await session.commit()
await session.refresh(marketing_intel)
m_id = marketing_intel.id
logger.debug(f"[MarketingPrompt] INSERT id={marketing_intel.id}")
except ChatGPTResponseError as e:
logger.error(
f"[marketing] ChatGPT Error: status={e.status}, "
f"code={e.error_code}, message={e.error_message}"
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"마케팅 분석 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
)
except Exception as e:
logger.error(f"[marketing] 마케팅 분석 중 오류: {e}")
logger.exception("[marketing] 상세 오류:")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="마케팅 분석 중 오류가 발생했습니다.",
)
return CrawlingResponse(
status="completed",
processed_info=processed_info,
marketing_analysis=marketing_analysis,
m_id=m_id,
)
async def _autocomplete_logic(autocomplete_item:dict):
step1_start = time.perf_counter()
try:

View File

@ -289,10 +289,10 @@ class MarketingIntel(Base):
comment="고유 식별자",
)
place_id: Mapped[str] = mapped_column(
place_id: Mapped[Optional[str]] = mapped_column(
String(36),
nullable=False,
comment="매장 소스별 고유 식별자",
nullable=True,
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
)
intel_result : Mapped[dict[str, Any]] = mapped_column(

View File

@ -234,7 +234,7 @@ class CrawlingResponse(BaseModel):
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
)
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
image_count: Optional[int] = Field(None, description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
@ -244,6 +244,22 @@ class CrawlingResponse(BaseModel):
m_id : int = Field(..., description="마케팅 분석 결과 ID")
class ManualMarketingRequest(BaseModel):
"""업체명+주소 직접 입력 마케팅 분석 요청"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18",
}
}
)
store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소")
class ErrorResponse(BaseModel):
"""에러 응답 스키마"""

View File

@ -469,6 +469,20 @@ async def get_song_status(
word_data = await suno_service.get_lyric_timestamp(
suno_task_id, suno_audio_id
)
# None이면 Suno 타임스탬프가 아직 미준비 상태.
# processing을 반환해 클라이언트가 폴링을 계속하도록 한다.
if word_data is None:
logger.info(
f"[get_song_status] 타임스탬프 미준비 - 폴링 유지, "
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}"
)
return PollingSongResponse(
success=True,
status="processing",
message="타임스탬프 생성 중입니다.",
)
logger.debug(
f"[get_song_status] word_data from get_lyric_timestamp - "
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "

View File

@ -0,0 +1,94 @@
SIDO_CITIES: dict[str, list[str]] = {
"서울특별시": ["서울시"],
"부산광역시": ["부산시"],
"대구광역시": ["대구시"],
"인천광역시": ["인천시"],
"광주광역시": ["광주시"],
"대전광역시": ["대전시"],
"울산광역시": ["울산시"],
"세종특별시": ["세종시"],
"경기도": [
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
],
"강원도": [
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
"양구군", "인제군", "고성군", "양양군",
],
"충청북도": ["청주시", "충주시", "제천시", "보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군"],
"충청남도": ["천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", "금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군"],
"전라북도": ["전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", "완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군"],
"전라남도": ["목포시", "여수시", "순천시", "나주시", "광양시", "담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군", "해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군"],
"경상북도": ["포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", "의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군", "예천군", "봉화군", "울진군", "울릉군"],
"경상남도": ["창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", "의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군"],
"제주도": ["제주시", "서귀포시"],
}
# 도 약칭 → 정식 명칭
SIDO_NAME_ALIASES: dict[str, str] = {
"서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시",
"인천": "인천광역시", "광주": "광주광역시", "대전": "대전광역시",
"울산": "울산광역시", "세종": "세종특별시",
"경기": "경기도", "강원": "강원도",
"충북": "충청북도", "충남": "충청남도",
"전북": "전라북도", "전남": "전라남도",
"경북": "경상북도", "경남": "경상남도",
"제주": "제주도",
}
# 도 정식 명칭 → 약칭 + 이형 목록 (필터 검색용)
SIDO_SEARCH_ALIASES: dict[str, list[str]] = {
"경기도": ["경기도", "경기"],
"강원도": ["강원도", "강원", "강원특별자치도"],
"충청북도": ["충청북도", "충북", "충북특별자치도"],
"충청남도": ["충청남도", "충남"],
"전라북도": ["전라북도", "전북", "전북특별자치도"],
"전라남도": ["전라남도", "전남"],
"경상북도": ["경상북도", "경북"],
"경상남도": ["경상남도", "경남"],
"제주도": ["제주도", "제주", "제주특별자치도"],
}
def extract_sigungu(address: str) -> str:
"""주소 문자열에서 시/군 이름을 추출합니다."""
tokens = address.split()
if not tokens:
return ""
# 첫 토큰으로 도 판별 (정식명 or 약칭)
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
cities = SIDO_CITIES.get(sido)
if cities and len(tokens) >= 2:
second = tokens[1]
if second in cities:
return second
# DB에 없는 신설 행정구역 대비 — 시/군 접미사 폴백
if second.endswith(("", "")):
return second
# 도 판별 실패 시 전체 도에서 토큰 완전 일치 검색
token_set = set(tokens)
for city_list in SIDO_CITIES.values():
for city in city_list:
if city in token_set:
return city
return ""
def extract_region_from_address(
road_address: str | None,
jibun_address: str | None = None,
) -> str:
"""도로명 주소 우선으로 시/군을 추출합니다. 실패 시 지번 주소로 재시도합니다."""
if road_address:
result = extract_sigungu(road_address)
if result:
return result
if jibun_address:
return extract_sigungu(jibun_address)
return ""

View File

@ -12,6 +12,20 @@ _SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly"
]
_sheet_cache: dict[str, tuple[str, str]] = {}
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
if sheet_name not in _sheet_cache:
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)
_sheet_cache[sheet_name] = (ws.cell(3, 2).value, ws.cell(2, 2).value)
return _sheet_cache[sheet_name]
class Prompt():
sheet_name: str
prompt_template: str
@ -24,20 +38,11 @@ class Prompt():
self.sheet_name = sheet_name
self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = self._read_from_sheets()
def _read_from_sheets(self) -> tuple[str, str]:
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(self.sheet_name)
model = ws.cell(2, 2).value
input_text = ws.cell(3, 2).value
return input_text, model
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
def _reload_prompt(self):
self.prompt_template, self.prompt_model = self._read_from_sheets()
_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)

View File

@ -247,7 +247,7 @@ class SunoService:
return data
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any]:
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any] | None:
"""
음악 타임스탬프 정보 추출
@ -270,8 +270,10 @@ class SunoService:
response.raise_for_status()
data = response.json()
# Suno는 오디오 생성 완료 후에도 타임스탬프 정렬을 별도로 처리하므로,
# 빈 응답은 아직 준비되지 않은 상태를 의미한다. None을 반환해 호출부에서 폴링을 유지하게 한다.
if not data or not data["data"]:
raise ValueError("Suno API returned empty response for task status")
return None
return data["data"]["alignedWords"]

View File

@ -28,6 +28,7 @@ from app.user.models import User
from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel, ImageTag
from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService
@ -67,6 +68,7 @@ logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["Video"])
@router.get(
"/generate/{task_id}",
summary="영상 생성 요청",
@ -859,6 +861,16 @@ async def get_all_videos(
if store_name:
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
if region:
cities = SIDO_CITIES.get(region)
if cities:
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
where_clauses.append(
or_(
Project.region.in_(cities),
*[Project.detail_region_info.ilike(f"%{a}%") for a in aliases],
)
)
else:
where_clauses.append(
or_(
Project.region.ilike(f"%{region}%"),

View File

@ -0,0 +1,8 @@
-- ============================================================
-- Migration: marketing.place_id NULL 허용
-- Date: 2026-05-28
-- Description: 업체명+주소 직접 입력으로 마케팅 분석 생성 시
-- 네이버 place_id가 없으므로 NULL 허용으로 변경
-- ============================================================
ALTER TABLE marketing MODIFY place_id VARCHAR(36) NULL COMMENT '매장 소스별 고유 식별자 (네이버 크롤링 시 nv{id} 형식; 직접 입력 시 NULL)';