사장님 지적("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>
182 lines
9.5 KiB
Python
182 lines
9.5 KiB
Python
"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 14종이 **안 보는** 논리까지 검수한다.
|
||
|
||
★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다.
|
||
이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼
|
||
규칙에 없던 것을 센다. 사장님이 화면에서 찾아내신 종류가 전부 여기 있다.
|
||
"""
|
||
import json, re, sys
|
||
from pathlib import Path
|
||
|
||
SP = Path(__file__).resolve().parent
|
||
html = (SP / "build" / "index.html").read_text(encoding="utf-8")
|
||
payload = json.loads(re.search(r"window\.__SITE_PAYLOAD__\s*=\s*(\{.*?\});?\s*</script>", html, re.S).group(1))
|
||
sec = next(s for s in payload["theme"]["sections"] if s["id"] == "itinerary")
|
||
items = json.loads(sec["data"])["items"]
|
||
|
||
STAY = "스테이 머뭄"
|
||
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
|
||
|
||
|
||
def hm(m): return f"{m // 60:02d}:{m % 60:02d}"
|
||
|
||
|
||
def plan(day):
|
||
"""렌더러(shared planDay)와 같은 계산. 21시 넘는 칸은 렌더러가 버린다."""
|
||
clock = int(day["startTime"][:2]) * 60 + int(day["startTime"][3:])
|
||
rows, dropped = [], 0
|
||
for s in day["stops"]:
|
||
move = max(0, s.get("moveMinutes", 0))
|
||
stay = max(1, s.get("minutes", 60))
|
||
arrive = clock + move
|
||
if arrive + stay > 21 * 60:
|
||
dropped += 1
|
||
continue
|
||
rows.append({"name": s["name"], "slot": s["name"].split(" · ")[0],
|
||
"place": s["name"].split(" · ")[-1], "move": move, "stay": stay,
|
||
"from": arrive, "to": arrive + stay})
|
||
clock = arrive + stay
|
||
return rows, dropped
|
||
|
||
|
||
def check(item):
|
||
"""규칙 14종이 안 보는 논리. (코드, 설명) 목록을 돌려준다."""
|
||
bad = []
|
||
name = item["name"]
|
||
seen_places = []
|
||
for day in item["days"]:
|
||
rows, dropped = plan(day)
|
||
label = day["label"]
|
||
if dropped:
|
||
bad.append(("L0", f"{label}: 21시를 넘겨 화면에서 버려지는 칸 {dropped}개"))
|
||
if not rows:
|
||
bad.append(("L0", f"{label}: 칸이 없다"))
|
||
continue
|
||
|
||
outs = [r for r in rows if r["place"] != STAY]
|
||
# L1 체류 ≥ 편도 이동 — 가는 데 든 시간보다 짧게 머물면 다녀온 의미가 없다.
|
||
# ★ 끼니는 뺀다. 밥집은 목적지가 아니라 **돌아오는 길에 들르는 자리**다 —
|
||
# 섬에서 85분 달려와 55분 먹는 것은 이상하지 않고, 섬에 30분 있는 것이 이상하다.
|
||
# (2026-09-11 사장님 지적의 실제 대상은 선유도였고, 선유도는 150분이다.)
|
||
for r in outs:
|
||
if r["slot"] in MEAL_SLOTS:
|
||
continue
|
||
if r["stay"] < r["move"]:
|
||
bad.append(("L1", f"{label} {r['place']}: 이동 {r['move']}분인데 체류 {r['stay']}분"))
|
||
# L2 끼니 — 둘째 날부터는 점심과 저녁이 다 있어야 한다(마지막 날은 저녁 없이 떠난다)
|
||
meals = [r for r in rows if r["slot"] in MEAL_SLOTS]
|
||
is_first = label == item["days"][0]["label"]
|
||
is_last = label == item["days"][-1]["label"]
|
||
if not meals:
|
||
bad.append(("L2", f"{label}: 끼니가 하나도 없다"))
|
||
elif not is_first and not is_last:
|
||
slots = {r["slot"] for r in meals}
|
||
if not (slots & {"점심", "늦은 점심"}) or "저녁" not in slots:
|
||
bad.append(("L2", f"{label}: 점심/저녁 중 하나가 없다 ({sorted(slots)})"))
|
||
# L3 끼니 간격 — 점심과 저녁이 3시간 미만이면 배가 안 꺼진다
|
||
lunch = next((r for r in meals if r["slot"] in {"점심", "늦은 점심"}), None)
|
||
dinner = next((r for r in meals if r["slot"] == "저녁"), None)
|
||
if lunch and dinner and dinner["from"] - lunch["to"] < 180:
|
||
bad.append(("L3", f"{label}: 점심 끝나고 저녁까지 {dinner['from'] - lunch['to']}분"))
|
||
# L4 하루 이동 비율 — 절반을 길에서 쓰면 그건 관광이 아니라 운전이다
|
||
span = rows[-1]["to"] - rows[0]["from"]
|
||
move_sum = sum(r["move"] for r in rows)
|
||
if span and move_sum / span > 0.40:
|
||
bad.append(("L4", f"{label}: 하루 {span}분 중 이동 {move_sum}분 ({move_sum / span:.0%})"))
|
||
# L5 너무 짧은 정거장 — 15분은 들렀다 가는 것도 아니다
|
||
for r in outs:
|
||
if r["stay"] < 15:
|
||
bad.append(("L5", f"{label} {r['place']}: 체류 {r['stay']}분"))
|
||
# L6 하루 길이 — 14시간을 넘으면 손님이 못 따라온다
|
||
if span > 14 * 60:
|
||
bad.append(("L6", f"{label}: 하루가 {span // 60}시간 {span % 60}분"))
|
||
# L7 첫날 입실 15:00 이후 · 마지막 날 퇴실 11:00 전
|
||
if is_first and rows[0]["place"] == STAY and rows[0]["from"] < 15 * 60:
|
||
bad.append(("L7", f"{label}: 입실이 {hm(rows[0]['from'])}"))
|
||
if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60:
|
||
bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}"))
|
||
# L8 시작·끝이 숙소인가
|
||
if rows[0]["place"] != STAY:
|
||
bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}"))
|
||
if rows[-1]["place"] != STAY:
|
||
bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}"))
|
||
# L9 같은 자리 연속
|
||
for i in range(1, len(rows)):
|
||
if rows[i]["place"] == rows[i - 1]["place"]:
|
||
bad.append(("L9", f"{label}: {rows[i]['place']} 가 연달아"))
|
||
seen_places += [r["place"] for r in outs]
|
||
# L10 테마 안에서 같은 곳 두 번
|
||
dup = {p for p in seen_places if seen_places.count(p) > 1}
|
||
if dup:
|
||
bad.append(("L10", f"테마 안에서 반복: {', '.join(sorted(dup))}"))
|
||
# L11 테마 이름·대상과 내용 — '걸어서'/'차 없이' 인데 편도 25분 넘는 이동이 있나
|
||
walkish = any(k in name + item.get("audience", "") for k in ("걸어서", "차 없이", "뚜벅"))
|
||
if walkish:
|
||
for day in item["days"]:
|
||
rows, _ = plan(day)
|
||
far = [r for r in rows if r["move"] > 25]
|
||
if far:
|
||
bad.append(("L11", f"걷는 테마인데 {far[0]['place']} 이동 {far[0]['move']}분"))
|
||
return bad
|
||
|
||
|
||
lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
|
||
f"생성 `python3 audit_schedule.py` · 테마 {len(items)}개 · "
|
||
f"{sum(len(i['days']) for i in items)}일 · "
|
||
f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "",
|
||
"표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).",
|
||
"규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
|
||
"이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "",
|
||
"| 코드 | 검수 항목 |", "|---|---|",
|
||
"| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |",
|
||
"| L1 | **체류 ≥ 편도 이동** — 볼거리는 가는 데 든 시간보다 오래 머문다 (끼니 제외 — 밥집은 돌아오는 길에 들르는 자리다) |",
|
||
"| L2 | 가운데 날은 점심과 저녁이 둘 다 있다 |",
|
||
"| L3 | 점심 끝나고 저녁까지 3시간 이상 |",
|
||
"| L4 | 하루의 40% 넘게 길에서 쓰지 않는다 |",
|
||
"| L5 | 바깥 정거장 체류 15분 이상 |",
|
||
"| L6 | 하루가 14시간을 넘지 않는다 |",
|
||
"| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |",
|
||
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 |",
|
||
"| L9 | 같은 자리가 연달아 서지 않는다 |",
|
||
"| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |",
|
||
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", ""]
|
||
|
||
fails = []
|
||
for item in items:
|
||
bad = check(item)
|
||
mark = "✅ **검수완료**" if not bad else "⚠️ **확인필요**"
|
||
lines += ["---", "", f"## {item['name']}", "",
|
||
f"{item['duration']} · {item.get('audience', '')} — {mark}", "",
|
||
f"> {item.get('why', '')}", ""]
|
||
for day in item["days"]:
|
||
rows, _ = plan(day)
|
||
span = rows[-1]["to"] - rows[0]["from"]
|
||
move_sum = sum(r["move"] for r in rows)
|
||
lines += [f"**{day['label']}** {hm(rows[0]['from'])}–{hm(rows[-1]['to'])} "
|
||
f"(총 {span // 60}시간 {span % 60}분 · 이동 {move_sum}분 · 정거장 {len(rows)})", "",
|
||
"| | 시각 | 자리 | 장소 | 이동 | 체류 |", "|---|---|---|---|---|---|"]
|
||
for n, r in enumerate(rows, 1):
|
||
lines.append(f"| {n} | {hm(r['from'])}–{hm(r['to'])} | {r['slot']} | {r['place']} | "
|
||
f"{r['move'] or '–'}분 | {r['stay']}분 |")
|
||
lines.append("")
|
||
if bad:
|
||
fails.append((item["name"], bad))
|
||
lines += ["**확인이 필요한 것**", ""]
|
||
lines += [f"- `{code}` {msg}" for code, msg in bad]
|
||
lines.append("")
|
||
|
||
head = [f"## 결과 — {len(items) - len(fails)}/{len(items)} 검수완료", ""]
|
||
if fails:
|
||
head += ["| 테마 | 걸린 항목 |", "|---|---|"]
|
||
head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails]
|
||
else:
|
||
head += ["**전부 통과.** 12개 항목 위반 0건."]
|
||
head.append("")
|
||
lines = lines[:8] + head + lines[8:]
|
||
|
||
(SP / "SCHEDULE.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
print(f"SCHEDULE.md · 검수완료 {len(items) - len(fails)}/{len(items)}")
|
||
for n, b in fails:
|
||
print(f" ⚠️ {n}")
|
||
for code, msg in b:
|
||
print(f" {code} {msg}")
|