o2o-site-AEO/solution/site/scripts/mockup/audit_schedule.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

182 lines
9.5 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.

"""구워진 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}")