o2o-site-AEO/solution/backend/services/place_category.py
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

107 lines
6.3 KiB
Python

"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory).
★ 업종 판별에 LLM 을 부르지 않는다. 상호명으로 카카오·네이버를 부르는 건 어차피 하는 일이고,
그 응답에 분류가 함께 온다(`category_name`, 카카오는 `category_group_code` 까지).
지금까지 받아 놓고 쓰지 않았을 뿐이다 — 여기서 그 값을 업종으로 옮긴다.
★ 못 정하면 None 이다. 억지로 하나를 고르지 않는다. 업종은 수집 스키마와 JSON-LD 타입을
통째로 정하는 값이라, 틀린 업종으로 시작하면 되돌리는 비용이 크다.
None 이면 화면이 사장님에게 직접 묻는다.
"""
from typing import Optional
from common.enums import PlaceCategory
# 카카오 category_group_code. 한글 분류 문자열은 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다.
# ★ HP8(병원)은 여기 없다 — 병원 전체가 아니라 피부과·성형외과만 열려 있어서,
# 코드만으로는 우리 업종인지 알 수 없다. 아래 키워드로 한 번 더 좁힌다.
_BY_GROUP_CODE = {
"AD5": PlaceCategory.LODGING,
"CE7": PlaceCategory.CAFE,
"FD6": PlaceCategory.RESTAURANT,
}
_HOSPITAL_GROUP_CODE = "HP8"
# 분류 문자열 키워드. 네이버는 group_code 를 주지 않아 이 경로만 탄다.
# ★ 순서가 결과를 바꾼다. 카카오·네이버 모두 카페를 "음식점 > 카페" 아래 두기 때문에
# CAFE 를 RESTAURANT 보다 먼저 봐야 한다. 뒤집으면 카페가 전부 음식점이 된다.
_KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [
# ★ "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. 앞의 둘로 보면 피부관리실(에스테틱)이
# 병원으로 걸린다 — 의료 광고 규제가 걸리는 업종이라 잘못 붙이면 가장 비싸다.
(PlaceCategory.CLINIC, ("피부과", "성형외과")),
(PlaceCategory.LODGING, ("숙박", "펜션", "호텔", "모텔", "리조트", "게스트하우스", "민박")),
(PlaceCategory.CAFE, ("카페", "커피", "베이커리", "제과", "디저트")),
(PlaceCategory.RESTAURANT, ("음식점", "한식", "일식", "중식", "양식", "분식",
"주점", "술집", "치킨", "횟집", "뷔페", "패스트푸드")),
]
def guess_category(
category_name: Optional[str], group_code: Optional[str] = None
) -> Optional[PlaceCategory]:
"""분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다. 모르면 None.
category_name 예 — 카카오 "가정,생활 > 숙박 > 펜션" · 네이버 "숙박>펜션"
"""
text = (category_name or "").replace(" ", "")
code = (group_code or "").strip().upper()
if code == _HOSPITAL_GROUP_CODE:
# 병원이라는 것까지만 안다. 진료과가 문자열에 없으면 우리 업종인지 알 수 없다.
return PlaceCategory.CLINIC if _match(text) is PlaceCategory.CLINIC else None
if code in _BY_GROUP_CODE:
by_code = _BY_GROUP_CODE[code]
# ★ 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를
# FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. 어느 쪽이 맞는지는
# 실측하지 않았다 — 사장님이 바꿀 수 있으니 이름이 더 구체적인 쪽을 기본값으로 둔다.
if by_code is PlaceCategory.RESTAURANT and _match(text) is PlaceCategory.CAFE:
return PlaceCategory.CAFE
return by_code
return _match(text)
def _match(text: str) -> Optional[PlaceCategory]:
for category, words in _KEYWORDS:
if any(word in text for word in words):
return category
return None
# ── 음식 중분류(TourAPI lclsSystm2) 추정 ─────────────────────────────────
# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정).
# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전)
# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집.
#
# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 —
# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다.
# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 —
# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다.
_FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")),
("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")),
("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥",
"떡볶이", "간식", "토스트", "패스트푸드")),
("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크",
"프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")),
("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈",
"해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")),
]
def guess_food_class(category_name: Optional[str]) -> Optional[str]:
"""외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음).
예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 ·
카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02
"""
text = (category_name or "").replace(" ", "")
if not text:
return None
for code, words in _FOOD_CLASS_KEYWORDS:
if any(word in text for word in words):
return code
return None