o2o-site-AEO/solution/site/scripts/mockup/build_itinerary.py
Mina Choi 703f831273 [fix] site/scripts: 끼니 간격·체류 대비 이동을 규칙으로 — 섬 코스가 103분 만에 두 번째 상
사장님 지적("1시간 30분 걸려서 갔는데 짧게 머문다")을 전수로 다시 셌다.
대상은 선유도가 아니었다(150분). 섬에서 돌아오는 길의 **끼니 간격**이다 —
늦은 점심 15:47 종료, 저녁 17:30 시작으로 103분이다. 늦은 점심 창(≤15:00)과
저녁 창(≥17:30)이 둘 다 합법이라 규칙 14종을 그냥 통과했다. 창 밖에 규칙이 따로 필요했다.

- build_itinerary.py: MEAL_GAP=180. `_window_of()` 가 앞 끼니 종료 + 3시간 뒤로 다음 끼니
  창을 민다. 섬 2박3일 둘째 날이 쉼 168분으로 늘고 저녁이 18:47 로 밀렸다
- build_itinerary.py audit(): 규칙 15(끼니 간격) · 16(볼거리는 편도 이동보다 오래 머문다)
  추가. 16은 **끼니를 뺀다** — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다
- audit_schedule.py 신설: 구워진 payload 를 표로 펴고, 만들 때 규칙이 **안 보는** 논리
  12가지(L0~L11)를 센다 → SCHEDULE.md
- README: 규칙 14종 → 16종. 장소 대장 프롬프트에 "멀리 있는 곳 근처 식당도 넣어라" 추가 —
  규칙만 고치면 다음 지역에서 또 난다
- README: 일정 프롬프트 위치 정정. shared/section-prompts.ts 가 아니라
  frontend/.../canvas/dataSpec.ts:556 이고, video·event·itinerary 셋은 **빌더에만** 있어
  서버 생성 잡이 만들 줄 모른다(daily 와 같은 사고). 그 프롬프트가 모델에게 시각 계산을
  시키고 있는 것도 적었다 — 시연본 첫 판이 망가진 직접 원인이다
- README: 목업을 지울 때 1부를 docs/ 로 옮기라는 안내

검증: 규칙 16종 위반 0건 · audit_schedule 21/21 검수완료 · audit-all.mjs 34/34

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

682 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""/s/stay 목업의 '추천 일정' 을 짠다 — 펜션에서 나서고 펜션으로 돌아오는 하루.
★ 왜 스크립트인가
20개 테마 × 하루 5~6칸을 손으로 적으면 시각이 어긋난다. 렌더러(shared `planDay`)는
**21시를 넘기는 칸을 조용히 버린다** — 적어 놓고 화면에 안 나오는 칸이 생긴다.
그래서 사람은 **어느 자리에 어느 장소**만 고르고, 시각은 여기서 계산한다.
★ 2026-09-10 전면 재작성. 앞선 판이 논리적으로 틀린 자리가 네 군데였다(전수 감사).
① 이동시간을 슬롯에 박아 뒀다 — 담 하나 건너인 히로쓰 가옥이 8분, 45분 걸리는 선유도가 10분.
→ **좌표에서 계산한다**(1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분).
② 체류시간도 슬롯이 정했다 — '오후' 칸이면 무엇이든 90~115분이라 우체통거리에 2시간 서 있었다.
→ **장소마다 (최소·기본·최대)** 를 준다.
③ 입실이 14:30 에 잡혔다(payload `check_in_time` 은 15:00), 퇴실은 11:35(규정 11:00).
④ 점심이 09:40, 저녁이 13:09 에 잡혔다.
→ ③④ 는 **창(WINDOW)** 으로 못 박고, 앞 칸 체류를 범위 안에서 늘이고 줄여 맞춘다.
맞추지 못하면 시작 시각을 당긴다. 그래도 안 되면 그 조합은 만들지 않는다(조용히 틀리게 두지 않는다).
★ 지어내지 않는다. 좌표는 payload(기존 정거장)와 카카오 로컬 검색(새 장소)에서 온 값이고,
한 줄 설명은 **거기서 무엇을 하는지**다 — 장소 소개는 이미 '주변 정보' 섹션이 한다.
"""
import json
import math
from pathlib import Path
SP = Path(__file__).parent
SRC = SP / "stay-payload.json"
DST = SP / "stay-payload-new.json"
payload = json.loads(SRC.read_text(encoding="utf-8"))
SECT = {s["id"]: s for s in payload["theme"]["sections"]}
BASE = (payload["place"]["latitude"], payload["place"]["longitude"])
STAY = "스테이 머뭄"
# ── 장소 대장 ─────────────────────────────────────────────────────────────────
# (위도, 경도, 지도 검색어, (최소·기본·최대 체류분), 거기서 무엇을 하는지)
# ★ 설명이 아니라 **할 일**이다 (2026-09-10 사장님: "가서 뭐 할지를 추천해 줘야지").
# "1899년에 지어진 건물입니다" 는 손님이 거기서 뭘 해야 하는지 안 알려 준다.
PLACES = {
STAY: (35.9864175, 126.7061254, None, (30, 40, 60), None),
"히로쓰 가옥": (35.9861696, 126.7060575, "군산 신흥동 일본식가옥", (20, 30, 45),
"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다."),
"초원사진관": (35.9877370, 126.7083826, "군산 초원사진관", (20, 30, 45),
"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다."),
"군산근대역사박물관": (35.9908197, 126.7121232, "군산근대역사박물관", (60, 90, 120),
"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다."),
"군산근대건축관": (35.9894202, 126.7121232, "군산근대건축관", (30, 45, 70),
"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다."),
"군산근대미술관": (35.9899368, 126.7108000, "군산근대미술관", (30, 45, 70),
"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다."),
"구 군산세관 본관": (35.9915088, 126.7135000, "구 군산세관", (20, 30, 45),
"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다."),
"해망굴": (35.9913181, 126.7050000, "군산 해망굴", (15, 20, 30),
"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다."),
"경암동 철길마을": (35.9813520, 126.7362331, "군산 경암동 철길마을", (35, 50, 80),
"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다."),
"군산 내항 부잔교": (35.9892997, 126.7174508, "군산 내항 부잔교", (25, 35, 55),
"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다."),
"째보선창": (35.9875345, 126.7199875, "군산 째보선창", (20, 30, 45),
"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다."),
"군산 시간여행마을": (35.9883192, 126.7109377, "군산 시간여행마을", (40, 60, 90),
"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다."),
"월명호수": (35.9766391, 126.6913696, "군산 월명호수", (35, 50, 80),
"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다."),
"은적사": (35.9748304, 126.6854726, "군산 은적사", (30, 40, 60),
"대웅전까지 올라갔다 내려옵니다. 절 마당에서 한 번 앉아 갑니다."),
"채만식문학관": (36.0085981, 126.7578853, "군산 채만식문학관", (40, 60, 90),
"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다."),
"동국사": (35.9827663, 126.7074296, "군산 동국사", (25, 35, 50),
"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다."),
"우체통거리": (35.9859965, 126.7119708, "군산 우체통거리", (15, 25, 40),
"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다."),
"고군산군도 선유도": (35.8102050, 126.4124053, "군산 선유도", (120, 150, 210),
"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다."),
"장자도": (35.8101000, 126.3973000, "군산 장자도", (40, 50, 80),
"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다."),
"군산 내항": (35.9892997, 126.7174508, "군산 내항", (30, 40, 60),
"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다."),
# 끼니 — 무엇을 먹는지까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다.
"빈해원": (35.9888841, 126.7139765, "군산 빈해원", (55, 70, 90),
"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다."),
"일흥옥": (35.9851498, 126.7091839, "군산 일흥옥", (40, 50, 65),
"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다."),
"한일옥": (35.9874081, 126.7100000, "군산 한일옥", (40, 50, 65),
"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다."),
"명월갈비": (35.9863914, 126.7101663, "군산 명월갈비", (60, 75, 95),
"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다."),
"국제반점": (35.9881100, 126.7108985, "군산 국제반점", (45, 55, 70),
"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다."),
"만남스넥": (35.9841788, 126.7097763, "군산 만남스넥", (30, 40, 55),
"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다."),
"도란": (35.9868220, 126.7075100, "군산 도란", (60, 80, 100),
"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다."),
"물밀소": (35.9845243, 126.7073211, "군산 물밀소", (25, 35, 50),
"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다."),
"이성당": (35.9870443, 126.7111681, "군산 이성당", (25, 35, 50),
"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다."),
"째보선창 근처 카페": (35.9875345, 126.7199875, "군산 째보선창 카페", (45, 60, 90),
"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다."),
"동국사다온": (35.9860907, 126.7144400, "군산 동국사 다온", (30, 40, 55),
"대추차나 오미자차를 시켜 절 마당을 보며 마십니다."),
}
# 펜션 칸은 장소가 아니라 **행위**가 시간을 정한다.
STAY_ACTION = {
"체크인": ((30, 40, 50), "짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다."),
"머뭄으로": ((20, 30, 45), "돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다."),
# ★ 오후에 한 번 숙소로 돌아온다. 넣은 이유가 둘이다.
# ① 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을 바깥 두 곳으로는 못 채운다 —
# 억지로 채우면 골목 하나에 두 시간 서 있는 일정이 된다(그래서 실제로 그랬다).
# ② 독채 펜션에 묵는 손님은 실제로 그렇게 한다. 이 집이 목적지인 일정이다.
"쉼": ((30, 70, 190), "숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다."),
# ★ 하루의 1번은 **언제나 머뭄**이다 (2026-09-10 사장님: "1은 스테이 머뭄이어야 한다고 말했지?").
# 한때 이 칸을 빼고 "시작 시각 = 나서는 시각" 으로 뒀다 — 출발지가 화면에서 사라졌다.
# 빈 칸으로 두지 않으려면 **실제로 하는 일**이 있어야 한다. 이 집은 부엌과 창고형 카페가
# 딸린 독채라 아침을 여기서 보낸다 — 그게 이 숙소를 고른 이유이기도 하다.
"아침": ((40, 60, 110), "부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다."),
# ★ 마지막 날 아침은 **퇴실까지 한 칸**이다 (2026-09-11 사장님: "체크인하고 바로 체크아웃하는
# 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아**
# 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다.
# 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다.
"아침(퇴실)": ((45, 75, 115),
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."),
"머뭄에서 쉬는 날": ((120, 210, 360),
"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."),
# ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?").
# 짐만 찾는 데는 10분이면 되지만, 그건 한 칸이 될 만한 일이 아니다 — 손님이 시간표에서
# 읽는 것은 "여기서 무엇을 하다 가는가" 다. 마당에 한 번 더 앉았다 나서는 시간으로 잡는다.
"마무리": ((30, 40, 60), "맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다."),
}
# ── 이동시간 — 좌표에서 계산한다 ────────────────────────────────────────────────
WALK_KMH, DRIVE_KMH, PARK_MIN, WALK_LIMIT_KM = 4.0, 25.0, 5, 1.5
def haversine_km(a: tuple, b: tuple) -> float:
r, rad = 6371.0, math.pi / 180
h = (math.sin((b[0] - a[0]) * rad / 2) ** 2
+ math.cos(a[0] * rad) * math.cos(b[0] * rad) * math.sin((b[1] - a[1]) * rad / 2) ** 2)
return 2 * r * math.asin(math.sqrt(h))
def move_minutes(a: tuple, b: tuple) -> int:
dist = haversine_km(a, b)
if dist < 0.05:
return 3 # 같은 자리(부잔교↔내항 야경)라도 옮겨 서는 시간은 있다
raw = dist / WALK_KMH * 60 if dist <= WALK_LIMIT_KM else dist / DRIVE_KMH * 60 + PARK_MIN
return max(3, int(round(raw)))
# ── 시간대가 정해진 약속 ───────────────────────────────────────────────────────
# 도착이 이 창 안에 들어와야 한다. 입·퇴실은 payload 의 check_in_time·check_out_time 과 같다.
WINDOW = {
"체크인": (15 * 60, 20 * 60),
"점심": (11 * 60 + 30, 13 * 60 + 30),
"저녁": (17 * 60 + 30, 20 * 60),
"늦은 점심": (13 * 60, 15 * 60),
"밤 산책": (18 * 60 + 30, 20 * 60),
}
DAY_ENDS_BY = 21 * 60
# ★ 끼니 사이 최소 간격 (2026-09-11 검수).
# 섬 코스에서 늦은 점심이 15:47 에 끝나고 저녁이 17:30 이었다 — 103분이다.
# 창(窓)만 지키면 통과한다: 늦은 점심은 15:00 까지, 저녁은 17:30 부터라 둘 다 합법인데
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다. 창 밖에 이 규칙이 따로 필요하다.
MEAL_GAP = 180
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
# 퇴실 시각. payload 의 check_out_time 과 같아야 한다 — 시간표가 규정을 어기면
# 사장님이 손님에게 설명할 수 없는 일정이 된다.
CHECK_OUT_BY = 11 * 60
# ── 하루 뼈대 ─────────────────────────────────────────────────────────────────
# 자리 이름만 정한다. 장소는 테마가 주고, 시간은 계산된다.
# ★ 펜션 칸은 **실제로 무슨 일이 일어나는 자리**만 남긴다 — 입실·퇴실·돌아옴.
# 한때 '아침 · 머뭄 20분' 을 세워 뒀는데, 이미 그 집에 있는 사람에게 아무 뜻이 없는 칸이었다
# (2026-09-10 사장님: "이건 무슨 의미야???"). 지금은 **하루의 시작 시각 = 머뭄에서 나서는 시각**이다.
# ★ 규칙 셋을 뼈대가 지킨다(아래 audit 이 매 실행 검사해서 어기면 빌드를 멈춘다).
# ① 하루의 처음과 끝은 머뭄이다 — 출발지와 복귀지.
# ② **같은 자리가 연달아 서지 않는다.** 머뭄 칸 사이에는 반드시 바깥 정거장이 있다.
# 한때 '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 이었다 — 그 사이에 손님은 아무 데도
# 가지 않는데 시간표에는 두 줄이 섰다(2026-09-11 사장님 지적). 지금은 한 칸이다.
# ③ 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐이다. 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을
# 바깥 두 곳으로는 못 채운다 — 억지로 채우면 골목 하나에 두 시간 세우게 된다(실측).
# 독채 펜션에 묵는 손님은 실제로 그때 들어와 쉰다.
SKELETON = {
"first": ["체크인@", "오후", "오후", "저녁", "밤 산책", "머뭄으로@"],
"first_late": ["체크인@", "저녁", "머뭄으로@"],
"mid": ["아침@", "오전", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
"mid_island": ["아침@", "오전", "오후", "늦은 점심", "쉼@", "저녁", "머뭄으로@"],
"mid_late": ["아침@", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
# ★ 숙소에서 노는 날. 오후에 머뭄으로 돌아와 쉬는 것이 이 테마의 내용이다
# (2026-09-10 사장님: "펜션에서 조용히 노는 날도 있어야지").
"mid_rest": ["머뭄에서 쉬는 날@", "점심", "쉼@", "저녁", "머뭄으로@"],
"last": ["아침@", "오전", "점심", "오후", "마무리@"],
"last_plus": ["아침@", "오전", "점심", "오후", "오후", "마무리@"],
# ★ 떠나는 날에 섬을 가는 코스. 왕복 두 시간이라 **바깥 정거장은 둘**이다
# (2026-09-11 감사: 섬을 오후에 넣었더니 저녁도 없이 19시에 돌아와 짐만 찾고 끝났다).
# 퇴실하고 바로 섬으로 가서, 돌아오는 길에 늦은 점심을 먹고 짐을 찾는다.
"last_island": ["아침@", "오전", "늦은 점심", "마무리@"],
}
# 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time.
CHECKOUT_KINDS = {"last", "last_plus", "last_island"}
# 하루의 시작 = **머뭄에서 아침을 여는 시각**이다(첫날만 입실 시각).
START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island": "07:40",
"mid_late": "09:40", "mid_rest": "10:00", "last": "08:10", "last_plus": "07:50",
"last_island": "08:00"}
# 시작을 앞당길 수 있는 한계. 첫날은 입실(15:00)이 있어 당길 이유가 없다.
FLOOR = {"first": "15:00", "first_late": "18:00", "mid": "07:20", "mid_island": "07:00",
"mid_late": "09:00", "mid_rest": "09:30", "last": "07:20", "last_plus": "07:20",
"last_island": "07:20"}
DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"]
DURATION = {2: "1박 2일", 3: "2박 3일"}
def _minutes(text: str) -> int:
hour, minute = text.split(":")
return int(hour) * 60 + int(minute)
def _action(slot: str, kind: str) -> tuple:
"""머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸한다."""
if slot == "아침" and kind in CHECKOUT_KINDS:
return STAY_ACTION["아침(퇴실)"]
return STAY_ACTION[slot]
def _stay_spec(slot: str, place: str, kind: str) -> tuple:
if place == STAY:
return _action(slot, kind)[0]
return PLACES[place][3]
def _note(slot: str, place: str, kind: str) -> str | None:
if place == STAY:
return _action(slot, kind)[1]
return PLACES[place][4]
def _window_of(slot: str, last_meal_end: int | None):
"""그 자리의 도착 창. 끼니는 **앞 끼니가 끝난 뒤 MEAL_GAP** 이 지나야 연다."""
window = WINDOW.get(slot)
if not window:
return None
early, late = window
if slot in MEAL_SLOTS and last_meal_end is not None:
early = max(early, last_meal_end + MEAL_GAP)
return (early, late) if early <= late else None
def plan_day(kind: str, places: list[str], label: str, start: int, pace: float):
"""하루 하나를 시각까지 계산한다. 창을 못 맞추면 None.
★ 창을 맞추는 방법은 **앞 칸에 더 머무는 것**이다. 이동시간을 늘리면 거짓말이 되고,
빈 시간을 끼우면 시간표에 구멍이 난다. 사람은 실제로 그렇게 한다 — 밥 때가 이르면
박물관을 조금 더 본다. 범위(min~max)를 벗어나면 그 조합은 포기한다.
"""
slots = SKELETON[kind]
need = [s for s in slots if not s.endswith("@")]
if len(places) != len(need):
raise ValueError(f"{label}/{kind}: 자리 {len(need)}개인데 장소 {len(places)}개")
feed = iter(places)
rows = []
for slot in slots:
name = slot[:-1] if slot.endswith("@") else slot
place = STAY if slot.endswith("@") else next(feed)
low, base, high = _stay_spec(name, place, kind)
rows.append({"slot": name, "place": place, "kind": kind,
"stay": max(low, min(high, int(round(base * pace)))),
"low": low, "high": high})
# ★ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 **끝나야** 한다.
# 체크아웃을 따로 세우던 때는 이 칸이 머뭄에 두 번 서게 만들었다(2026-09-11 감사).
if kind in CHECKOUT_KINDS:
if start + rows[0]["stay"] > CHECK_OUT_BY:
rows[0]["stay"] = max(rows[0]["low"], CHECK_OUT_BY - start)
if start + rows[0]["stay"] > CHECK_OUT_BY or rows[0]["stay"] < rows[0]["low"]:
return None
clock = start
prev = BASE
last_meal_end = None
for index, row in enumerate(rows):
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
# 하루의 1번은 머뭄이다 — 이미 그 자리에 있으니 이동시간이 없다.
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
arrive = clock + row["move"]
window = _window_of(row["slot"], last_meal_end)
if window is None and row["slot"] in WINDOW:
return None # 앞 끼니와 겹쳐 창이 닫혔다 — 이 조합은 포기한다
if window:
early, late = window
if arrive < early:
# 앞 칸들에 더 머문다. 뒤에서부터 늘려야 방금 지나온 자리가 길어진다.
short = early - arrive
for back in reversed(rows[:index]):
room = back["high"] - back["stay"]
take = min(room, short)
back["stay"] += take
short -= take
if short == 0:
break
if short > 0:
return None # 늘려도 못 채운다 — 시작을 늦춰야 하는 조합
return plan_day_fixed(rows, start)
if arrive > late:
short = arrive - late
for back in reversed(rows[:index]):
room = back["stay"] - back["low"]
take = min(room, short)
back["stay"] -= take
short -= take
if short == 0:
break
if short > 0:
return None # 줄여도 못 당긴다 — 시작을 앞당겨야 하는 조합
return plan_day_fixed(rows, start)
clock = arrive + row["stay"]
if row["slot"] in MEAL_SLOTS:
last_meal_end = clock
prev = coords
if clock > DAY_ENDS_BY:
return None
return _render(rows, start, label)
def plan_day_fixed(rows: list[dict], start: int):
"""체류시간을 고친 뒤 처음부터 다시 훑는다(앞 칸을 바꿨으니 뒤 시각이 전부 달라진다)."""
clock = start
prev = BASE
last_meal_end = None
for index, row in enumerate(rows):
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
arrive = clock + row["move"]
window = _window_of(row["slot"], last_meal_end)
if row["slot"] in WINDOW and (window is None or not (window[0] <= arrive <= window[1])):
return None
clock = arrive + row["stay"]
if row["slot"] in MEAL_SLOTS:
last_meal_end = clock
prev = coords
if clock > DAY_ENDS_BY:
return None
return rows, start
def _render(rows: list[dict], start: int, label: str) -> dict:
stops = []
for row in rows:
place = row["place"]
stop = {"name": f"{row['slot']} · {place}", "minutes": row["stay"],
"moveMinutes": row["move"],
"latitude": PLACES[place][0], "longitude": PLACES[place][1]}
note = _note(row["slot"], place, row.get("kind", ""))
if note:
stop["note"] = note
query = PLACES[place][2]
if query:
stop["searchQuery"] = query
stops.append(stop)
return {"label": label, "startTime": f"{start // 60:02d}:{start % 60:02d}", "stops": stops}
def build_day(kind: str, places: list[str], label: str, start: str | None, pace: float) -> dict:
"""시작 시각을 옮겨 가며 모든 창을 맞추는 하루를 찾는다."""
begin = _minutes(start or START[kind])
floor = _minutes(FLOOR[kind])
candidates = [begin] + [begin + delta for delta in range(10, 190, 10)] \
+ [begin - delta for delta in range(10, 190, 10) if begin - delta >= floor]
for scale in (pace, pace * 0.92, pace * 0.85, 1.0, 0.8):
for clock in candidates:
if clock < floor or clock > 20 * 60:
continue
result = plan_day(kind, places, label, clock, scale)
if result is None:
continue
if isinstance(result, tuple): # 창을 맞추느라 체류를 고친 경우
rows, begin_at = result
return _render(rows, begin_at, label)
return result
raise ValueError(f"{label}/{kind}: 창(입·퇴실·끼니·21시)을 다 맞추는 시각이 없다")
# ── 테마 20개 ────────────────────────────────────────────────────────────────
# (이름, 기간, 누구에게, 왜, [(뼈대, [장소…])])
THEMES = [
("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님",
"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.",
[("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]),
("last_island", ["고군산군도 선유도", "국제반점"])]),
("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님",
"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.",
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
("last", ["군산근대건축관", "한일옥", "우체통거리"])]),
("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님",
"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.",
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
("last", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]),
("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님",
"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.",
[("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]),
("last", ["군산근대미술관", "일흥옥", "해망굴"])]),
("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님",
"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.",
[("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]),
("last", ["이성당", "만남스넥", "군산근대역사박물관"])]),
("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님",
"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.",
[("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]),
("last", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]),
("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님",
"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.",
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
("last", ["째보선창", "국제반점", "구 군산세관 본관"])]),
("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님",
"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.",
[("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]),
("last", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]),
("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님",
"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.",
[("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]),
("last", ["경암동 철길마을", "이성당", "월명호수"])]),
("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님",
"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.",
[("first_late", ["명월갈비"]),
("last_plus", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]),
("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님",
"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.",
[("first", ["히로쓰 가옥", "동국사", "도란", "군산 내항"]),
("mid_rest", ["한일옥", "빈해원"]),
("last", ["이성당", "만남스넥", "군산 시간여행마을"])]),
("섬과 도심을 반씩 도는 2박 3일", "2박 3일", "차를 가져온 손님",
"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.",
[("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]),
("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]),
("last", ["초원사진관", "일흥옥", "이성당"])]),
("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님",
"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.",
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
("mid", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴", "명월갈비"]),
("last", ["우체통거리", "만남스넥", "군산 시간여행마을"])]),
("먹으러 오는 2박 3일", "2박 3일", "맛집을 도는 손님",
"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.",
[("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]),
("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]),
("last", ["동국사", "국제반점", "군산근대역사박물관"])]),
("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님",
"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.",
[("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]),
("mid_rest", ["만남스넥", "일흥옥"]),
("last", ["동국사", "물밀소", "월명호수"])]),
("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님",
"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.",
[("first", ["초원사진관", "동국사", "도란", "군산 내항"]),
("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]),
("last", ["우체통거리", "일흥옥", "군산근대미술관"])]),
("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님",
"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.",
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]),
("last", ["채만식문학관", "한일옥", "이성당"])]),
("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님",
"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.",
[("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]),
("mid_late", ["일흥옥", "군산근대역사박물관", "해망굴", "빈해원"]),
("last", ["이성당", "만남스넥", "초원사진관"])]),
("섬에서 하루를 다 쓰는 2박 3일", "2박 3일", "차를 가져온 손님",
"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.",
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]),
("last", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]),
("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님",
"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.",
[("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]),
("mid_rest", ["한일옥", "빈해원"]),
("last", ["군산근대역사박물관", "국제반점", "초원사진관"])]),
("철길마을과 항구를 같이 보는 2박 3일", "2박 3일", "사진을 찍는 손님",
"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.",
[("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]),
("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]),
("last", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]),
]
# 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 —
# 쉬러 오는 손님(1.25)과 사진만 찍는 손님(0.8)은 같은 곳을 봐도 하루 길이가 다르다.
# 창(끼니·입퇴실)에 걸리면 build_day 가 시작을 옮겨 맞춘다.
PACING = {
"섬까지 다녀오는 1박 2일": (["15:00", "08:20"], 1.0),
"걸어서만 도는 1박 2일": (["15:00", "09:20"], 1.05),
"비가 와도 되는 1박 2일": (["15:20", "09:40"], 1.15),
"근대건축만 보는 1박 2일": (["15:00", "08:50"], 0.95),
"이성당으로 시작하는 1박 2일": (["16:10", "07:30"], 0.9),
"철길마을까지 가는 1박 2일": (["15:40", "08:30"], 0.95),
"항구만 도는 1박 2일": (["16:30", "09:20"], 1.05),
"소설 『탁류』를 따라 걷는 1박 2일": (["15:10", "09:00"], 1.1),
"아이와 함께 1박 2일": (["15:30", "10:10"], 0.85),
"늦게 도착하는 1박 2일": (["19:00", "08:40"], 1.0),
"천천히 도는 2박 3일": (["16:20", "10:40", "09:50"], 1.25),
"섬과 도심을 반씩 도는 2박 3일": (["15:10", "07:40", "09:20"], 1.0),
"골목을 세 번 나눠 걷는 2박 3일": (["15:00", "09:10", "08:20"], 1.1),
"먹으러 오는 2박 3일": (["16:40", "10:20", "08:40"], 1.15),
"사진만 찍는 2박 3일": (["15:50", "07:20", "07:40"], 0.8),
"비가 와도 되는 2박 3일": (["15:20", "10:10", "09:40"], 1.2),
"아무 데도 안 가는 2박 3일": (["15:00", "10:00", "09:00"], 1.3),
"늦게 시작하는 2박 3일": (["17:20", "10:30", "10:40"], 1.0),
"섬에서 하루를 다 쓰는 2박 3일": (["15:00", "07:30", "09:40"], 0.95),
"소설 『탁류』를 따라 걷는 2박 3일": (["15:00", "09:50", "08:30"], 1.1),
"철길마을과 항구를 같이 보는 2박 3일": (["16:00", "07:50", "09:30"], 0.9),
}
items = []
for name, duration, audience, why, days in THEMES:
starts, pace = PACING.get(name, (None, 1.0))
built = [build_day(kind, places, DAY_LABEL[i],
(starts[i] if starts and i < len(starts) else None), pace)
for i, (kind, places) in enumerate(days)]
items.append({"name": name, "duration": duration, "audience": audience, "why": why,
"days": built})
env = {"kind": "itinerary", "version": 1, "title": "추천 일정",
"subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다", "items": items}
SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False)
# ── 검증: 규칙을 어기면 **빌드를 멈춘다** ────────────────────────────────────
#
# ★ 왜 스크립트 안인가 (2026-09-11)
# 지금까지 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다
# "이번에 안 본 규칙" 이 생겼고, 사장님이 화면에서 먼저 찾았다 —
# '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 인 일정이 그렇게 나갔다.
# 검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다. 이제 어기면 payload 를 안 만든다.
FOOD_SLOTS = {"점심", "저녁", "늦은 점심"}
FOOD = {"빈해원", "일흥옥", "한일옥", "명월갈비", "국제반점", "만남스넥", "도란", "물밀소",
"이성당", "째보선창 근처 카페", "동국사다온"}
INDOOR = {"군산근대역사박물관", "군산근대건축관", "군산근대미술관", "채만식문학관", STAY} | FOOD
NEEDS_CAR = {"고군산군도 선유도", "장자도", "채만식문학관", "경암동 철길마을", "월명호수", "은적사"}
REST_SLOTS = {"쉼", "머뭄에서 쉬는 날"}
NIGHT_ONLY = {"군산 내항"}
BAR = {"도란"}
DAYS_OF = {"1박 2일": 2, "2박 3일": 3}
def audit(built: list[dict]) -> list[str]:
bad: list[str] = []
def flag(item, day, text):
bad.append(f"{item['name']} / {day['label'] if day else '-'} — {text}")
for item in built:
want = DAYS_OF.get(item["duration"])
if want and len(item["days"]) != want:
flag(item, None, f"{item['duration']}인데 {len(item['days'])}일")
seen_all: list[str] = []
for day in item["days"]:
slots = [s["name"].split(" · ")[0] for s in day["stops"]]
places = [s["name"].split(" · ")[-1] for s in day["stops"]]
seen_all += [p for p in places if p != STAY]
# ① 하루의 처음과 끝은 머뭄
if places[0] != STAY:
flag(item, day, f"1번이 머뭄이 아니다 ({places[0]})")
if places[-1] != STAY:
flag(item, day, f"마지막이 머뭄이 아니다 ({places[-1]})")
# ② 같은 자리가 연달아 서지 않는다
for i in range(len(places) - 1):
if places[i] == places[i + 1]:
flag(item, day, f"같은 자리 연속: {slots[i]} → {slots[i + 1]} ({places[i]})")
# ③ 가운데 머뭄 칸은 쉼·쉬는 날만
for i in range(1, len(places) - 1):
if places[i] == STAY and slots[i] not in REST_SLOTS:
flag(item, day, f"가운데 머뭄 칸이 쉼이 아니다 ({slots[i]})")
clock = _minutes(day["startTime"])
prev = BASE
for i, stop in enumerate(day["stops"]):
# ④ 이동시간은 좌표 계산값과 같아야 한다
real = 0 if i == 0 else move_minutes(prev, (stop["latitude"], stop["longitude"]))
if stop["moveMinutes"] != real:
flag(item, day, f"이동시간 {stop['moveMinutes']}분 (실거리 {real}분) — {places[i]}")
arrive = clock + stop["moveMinutes"]
# ⑤ 시간대가 정해진 자리
window = WINDOW.get(slots[i])
if window and not (window[0] <= arrive <= window[1]):
flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖")
# ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다
if i == 0 and slots[0] == "아침" and len(item["days"]) == want and day is item["days"][-1]:
if arrive + stop["minutes"] > CHECK_OUT_BY:
end = arrive + stop["minutes"]
flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다")
# ⑧ 끼니 자리에는 먹는 곳만
if slots[i] in FOOD_SLOTS and places[i] not in FOOD:
flag(item, day, f"{slots[i]} 자리에 식당이 아닌 {places[i]}")
if slots[i] == "밤 산책" and places[i] not in NIGHT_ONLY:
flag(item, day, f"밤 산책 자리에 {places[i]}")
clock = arrive + stop["minutes"]
prev = (stop["latitude"], stop["longitude"])
# ⑦ 21시를 넘기지 않는다(넘기면 렌더러가 그 칸을 조용히 버린다)
if clock > DAY_ENDS_BY:
flag(item, day, f"{clock // 60}:{clock % 60:02d} 종료 — 21시 초과")
# ⑮ 끼니 사이 최소 간격 (2026-09-11 검수에서 걸린 것)
# 창만 지키면 통과한다 — 늦은 점심 15:47 종료, 저녁 17:30 시작이 둘 다 합법이었다.
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다.
walk = _minutes(day["startTime"])
last_meal = None
for sl, stop in zip(slots, day["stops"]):
walk += stop["moveMinutes"]
if sl in FOOD_SLOTS:
if last_meal is not None and walk - last_meal < MEAL_GAP:
flag(item, day, f"앞 끼니 끝나고 {walk - last_meal}분 만에 {sl}")
last_meal = walk + stop["minutes"]
walk += stop["minutes"]
# ⑯ 볼거리는 **가는 시간보다 오래 머문다**
# 끼니는 뺀다 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다.
# (섬에서 85분 달려와 55분 먹는 것은 이상하지 않다. 섬에 30분 있으면 이상하다.)
for sl, pl, stop in zip(slots, places, day["stops"]):
if pl == STAY or sl in FOOD_SLOTS:
continue
if stop["minutes"] < stop["moveMinutes"]:
flag(item, day, f"{pl}: 이동 {stop['moveMinutes']}분인데 체류 {stop['minutes']}분")
# ⑨ 같은 날 같은 식당 두 번
meals = [p for sl, p in zip(slots, places) if sl in FOOD_SLOTS]
if len(meals) != len(set(meals)):
flag(item, day, f"같은 날 같은 식당 반복 {meals}")
# ⑪⑫⑭ 테마가 약속한 성격
if "비가 와도" in item["name"]:
for sl, pl in zip(slots, places):
if sl in ("오전", "오후") and pl not in INDOOR:
flag(item, day, f"비 테마인데 실외 {pl}")
if "걸어서만" in item["name"] or "차 없이" in item["audience"]:
for pl in places:
if pl in NEEDS_CAR:
flag(item, day, f"걷는 테마인데 차가 필요한 {pl}")
if "아이와" in item["name"]:
for pl in places:
if pl in BAR:
flag(item, day, f"아이 동반인데 {pl}")
# ⑩ 한 테마 안에서 같은 곳을 두 번 가지 않는다
repeat = sorted({p for p in seen_all if seen_all.count(p) > 1})
if repeat:
flag(item, None, f"테마 안에서 반복 {repeat}")
return bad
problems = audit(items)
for line in problems:
print(" ✗", line)
if problems:
raise SystemExit(f"\n규칙 위반 {len(problems)}건 — payload 를 쓰지 않았다.")
for item in items:
for day in item["days"]:
clock = _minutes(day["startTime"])
for stop in day["stops"]:
clock += stop["moveMinutes"] + stop["minutes"]
print(f"{item['name'][:24]:26} {day['label']} {day['startTime']}–"
f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸")
DST.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
print(f"\n테마 {len(items)}개 · 규칙 16종 위반 0건 · payload {DST.stat().st_size:,}B")