o2o-site-AEO/solution/backend/services/itinerary.py
Mina Choi c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.

**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
  (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
  기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
  우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
  한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
  그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
  사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.

**자리 가르기**
    area_*        = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
    site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
  같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
  배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
  읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
  **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
  UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.

검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.

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

148 lines
6.5 KiB
Python

"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음.
빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로
일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면
다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다.
(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md)
★ 지어내지 않는 규칙은 여기도 적용된다.
재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다.
좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다.
하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다.
일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다.
"""
from typing import Optional
from common.utils.geo import haversine_km
# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다.
_SPOTS_PER_DAY = 2
_MEALS_PER_DAY = 2
# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다.
_MAX_RADIUS_KM = 30.0
_STOP_ATTRACTION = "attraction"
_STOP_RESTAURANT = "restaurant"
_STOP_FESTIVAL = "festival"
def _as_float(value) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def _candidates(rows: list[dict], stop_type: str,
base_lat: float, base_lng: float) -> list[dict]:
"""payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다.
행 모양은 스냅샷 local.contents 항목이다. 좌표는 **항목 최상단**의 latitude/longitude 다 —
2026-09-09 에 body 에서 컬럼으로 옮겼다(body 는 렌더러가 읽는 것만 담는다).
"""
out = []
for row in rows:
body = row.get("body") or {}
name = str(body.get("name") or row.get("title") or "").strip()
lat, lng = _as_float(row.get("latitude")), _as_float(row.get("longitude"))
if not name or lat is None or lng is None:
continue
dist = haversine_km(base_lat, base_lng, lat, lng)
if dist > _MAX_RADIUS_KM:
continue
out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng,
"distanceKm": round(dist, 1)})
out.sort(key=lambda c: c["distanceKm"])
return out
def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]:
"""일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다."""
remaining = list(stops)
ordered: list[dict] = []
lat, lng = base_lat, base_lng
while remaining:
nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"]))
remaining.remove(nxt)
ordered.append(nxt)
lat, lng = nxt["lat"], nxt["lng"]
return ordered
def _take(pool: list[dict], count: int) -> list[dict]:
taken, pool[:] = pool[:count], pool[count:]
return taken
# 일자 이름. 화면이 탭 라벨로 그대로 쓴다.
_DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"}
# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration).
_DURATION = {2: "1박 2일", 3: "2박 3일"}
def _to_stop(cand: dict) -> dict:
"""후보 → 렌더러의 PlannerStop.
★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가
지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다.
★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약).
"""
return {
"name": cand["name"],
"searchQuery": cand["name"],
"latitude": cand["lat"],
"longitude": cand["lng"],
}
def _plan_days(days: int, attractions: list[dict], restaurants: list[dict],
festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]:
"""일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다."""
spots = list(attractions)
meals = list(restaurants)
fests = list(festivals)
plan = []
for day in range(1, days + 1):
day_stops = []
# 축제는 하루 하나까지, 관광지 한 자리를 대신한다.
fest = _take(fests, 1)
day_stops += fest
day_stops += _take(spots, _SPOTS_PER_DAY - len(fest))
day_stops += _take(meals, _MEALS_PER_DAY)
if not day_stops:
break
plan.append({
"label": _DAY_LABEL.get(day, f"{day}일차"),
"stops": [_to_stop(s) for s in _order_by_route(day_stops, base_lat, base_lng)],
})
if not plan:
return None
# ★ 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]}
# 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로
# 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔).
return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan}
def build_itineraries(base_lat: Optional[float], base_lng: Optional[float],
attractions: list[dict], restaurants: list[dict],
festivals: list[dict]) -> list[dict]:
"""업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열.
입력 행 모양은 스냅샷 local.contents 항목({title, latitude, longitude, body:{name, …}})이다.
"""
if base_lat is None or base_lng is None:
return []
spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng)
meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng)
fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng)
out = []
for days in (2, 3):
# 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다.
built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool),
base_lat, base_lng)
if built:
out.append(built)
return out