From bdee852bed9e1cef20e52101fe17506a46f39d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 2 Jun 2026 17:21:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/home/api/routers/v1/home.py | 77 +------------------------ app/song/api/routers/v1/song.py | 14 +++++ app/utils/address_parser.py | 94 +++++++++++++++++++++++++++++++ app/utils/prompts/prompts.py | 29 ++++++---- app/utils/suno.py | 6 +- app/video/api/routers/v1/video.py | 38 +------------ 6 files changed, 134 insertions(+), 124 deletions(-) create mode 100644 app/utils/address_parser.py diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 4ab81df..8e03156 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -37,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() @@ -127,41 +87,8 @@ async def search_accommodation( ) -METRO_CITY_MAP = { - "서울": "서울시", "부산": "부산시", "대구": "대구시", - "인천": "인천시", "광주": "광주시", "대전": "대전시", - "울산": "울산시", "세종": "세종시", -} - - def _extract_region_from_address(road_address: str | None, jibun_address: str | None = None) -> str: - """주소에서 시/군 이름 추출 — 도로명 우선, 실패 시 지번으로 재시도""" - - def _parse(address: str) -> str: - token_set = set(address.split()) - for city in KOREAN_CITIES: - if city in token_set: # 완전 일치 (토큰 단위) - return city - if city[:-1] in token_set: # 접미사 생략 일치 (토큰 단위) - return city - tokens = 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 "" - - if road_address: - result = _parse(road_address) - if result: - return result - - if jibun_address: - return _parse(jibun_address) - - return "" + return extract_region_from_address(road_address, jibun_address) @router.post( diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index da44dfa..f593042 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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}, " diff --git a/app/utils/address_parser.py b/app/utils/address_parser.py new file mode 100644 index 0000000..d89c5b4 --- /dev/null +++ b/app/utils/address_parser.py @@ -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 "" diff --git a/app/utils/prompts/prompts.py b/app/utils/prompts/prompts.py index 324392e..a26ee95 100644 --- a/app/utils/prompts/prompts.py +++ b/app/utils/prompts/prompts.py @@ -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) diff --git a/app/utils/suno.py b/app/utils/suno.py index 51ae5f3..9bd1e1f 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -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"] diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 12a6ff9..5b71bde 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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 @@ -66,39 +67,6 @@ logger = get_logger("video") router = APIRouter(prefix="/video", tags=["Video"]) -_SIDO_CITIES: dict[str, list[str]] = { - "특별시 / 광역시": ["서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시"], - "경기도": [ - "수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시", - "화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시", - "군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시", - "여주시", "동두천시", "과천시", "가평군", "양평군", "연천군", - ], - "강원도": [ - "춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시", - "홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군", - "양구군", "인제군", "고성군", "양양군", - ], - "충청북도": ["청주시", "충주시", "제천시", "보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군"], - "충청남도": ["천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", "금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군"], - "전라북도": ["전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", "완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군"], - "전라남도": ["목포시", "여수시", "순천시", "나주시", "광양시", "담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군", "해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군"], - "경상북도": ["포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", "의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군", "예천군", "봉화군", "울진군", "울릉군"], - "경상남도": ["창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", "의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군"], - "제주도": ["제주시", "서귀포시"], -} - -_SIDO_ALIASES: dict[str, list[str]] = { - "경기도": ["경기도", "경기"], - "강원도": ["강원도", "강원"], - "충청북도": ["충청북도", "충북"], - "충청남도": ["충청남도", "충남"], - "전라북도": ["전라북도", "전북"], - "전라남도": ["전라남도", "전남"], - "경상북도": ["경상북도", "경북"], - "경상남도": ["경상남도", "경남"], - "제주도": ["제주도", "제주"], -} @router.get( @@ -893,9 +861,9 @@ 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) + cities = SIDO_CITIES.get(region) if cities: - aliases = _SIDO_ALIASES.get(region, [region]) + aliases = SIDO_SEARCH_ALIASES.get(region, [region]) where_clauses.append( or_( Project.region.in_(cities),