"""/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")