Compare commits
6 Commits
main
...
feature-Bi
| Author | SHA1 | Date |
|---|---|---|
|
|
bdee852bed | |
|
|
2b4c89217a | |
|
|
33e479de02 | |
|
|
e56aa35170 | |
|
|
e9da08bee8 | |
|
|
f7e3142ca4 |
|
|
@ -53,6 +53,4 @@ Dockerfile
|
|||
|
||||
zzz/
|
||||
credentials/service_account.json
|
||||
|
||||
# Scheduler (separate repo)
|
||||
o2o-castad-scheduler/
|
||||
|
|
@ -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(
|
||||
place_id = scraper.place_id,
|
||||
intel_result = structured_report.model_dump()
|
||||
)
|
||||
session.add(marketing_intelligence)
|
||||
|
||||
# Step 3-3: 분석 결과 DB 저장
|
||||
marketing_intel = MarketingIntel(
|
||||
place_id=scraper.place_id,
|
||||
intel_result=marketing_analysis.model_dump(),
|
||||
)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""에러 응답 스키마"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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}, "
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +861,22 @@ async def get_all_videos(
|
|||
if store_name:
|
||||
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
|
||||
if region:
|
||||
where_clauses.append(
|
||||
or_(
|
||||
Project.region.ilike(f"%{region}%"),
|
||||
Project.detail_region_info.ilike(f"%{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}%"),
|
||||
Project.detail_region_info.ilike(f"%{region}%"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
count_q = (
|
||||
select(func.count(Video.id))
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
Loading…
Reference in New Issue