사장님 지적("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>
|
||
|---|---|---|
| .. | ||
| audio | ||
| img | ||
| orig | ||
| vendor | ||
| .gitignore | ||
| audit_schedule.py | ||
| audit-all.mjs | ||
| build_itinerary.py | ||
| build_story.py | ||
| coords.json | ||
| dump_text.py | ||
| inject.css | ||
| inject.js | ||
| kingcheck.mjs | ||
| patch_stay.py | ||
| people-images-meta.json | ||
| people-images.json | ||
| people-photos.json | ||
| people-raw.json | ||
| people-rows.json | ||
| rails-test.mjs | ||
| README.md | ||
| rooms.json | ||
| SCHEDULE.md | ||
| stay-payload-new.json | ||
| stay-payload.json | ||
| TEXT.md | ||
/s/stay 시연본 — 지침서 한 장
이 문서 하나만 읽으면 된다. 예전에는 README · SECTIONS · PROMPTS · REVIEW 로 흩어져 있었고 무엇을 먼저 볼지 알 수 없었다. 전부 여기로 합쳤다(2026-09-11). 같은 폴더에 생성물 두 개가 더 있는데 지침이 아니라 데이터다 —
TEXT.md(화면에 나가는 텍스트 전문,python3 dump_text.py)와SCHEDULE.md(일정 21개 전수 검수표,python3 audit_schedule.py).
대상 https://web4ai.o2osolution.ai/s/stay (스테이,머뭄 · 전북 군산)
지금 누가 무엇을 하나
| 하는 일 | 읽을 곳 | |
|---|---|---|
| 사장님(민아) | /s/stay 시연본 자체를 고친다 |
2부 |
| 나머지 팀원 | 시연본에 있는 것을 제품이 자동으로 만들게 옮긴다 | 1부 |
시연본은 payload 가 없는 목업이다. 프리렌더가 굽지 않고 index.html 한 장을 손으로
조립했다. 그래서 "화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있고,
1부가 그 간격을 메우는 작업이다.
1부 — 실제 구현을 하는 사람
1.1 어떤 파일을 여나 (결론부터)
목업 폴더는 사양서다. 코드는 여기서 고친다.
① 계약 — 화면에 새 값을 내려면 여기부터
| 파일 | 무엇 |
|---|---|
solution/shared/src/types/site-payload.ts |
payload 필드 정의. Narrative(:316) · MediaItem · Unit |
solution/shared/src/lib/section-data.ts |
섹션 봉투({kind,version,title,items[]}) 타입 + planDay(). 21시 넘는 칸을 버리는 규칙이 여기 있다(:535) |
solution/shared/src/lib/section-prompts.ts |
프롬프트 — 서버 생성 잡이 쓰는 6종(songs · daily · people · chronicle · postcard · quiz). 고친 뒤 npm run export:prompts |
solution/frontend/src/features/builder/canvas/dataSpec.ts |
프롬프트 — 빌더 [콘텐츠] 탭에서 붙여넣는 9종. 위 6종은 shared 를 import 하고, video · event · itinerary 셋은 여기에만 있다(itinerary 는 :556) |
② 생성 — 값을 만드는 곳
| 파일 | 무엇 | 관련 할 일 |
|---|---|---|
solution/backend/services/site_payload.py |
섹션 목록·기본 on/off(:155 부근) · payload 조립 |
T1 |
solution/backend/services/itinerary.py |
여행 일정 생성 | T2 |
solution/backend/services/story_service.py |
지역 이야기(노래·인물·연표·엽서·퀴즈) 생성 잡 | T3 T5 |
solution/backend/services/external/wikimedia.py |
위키 사진 | T3 |
solution/backend/services/collector/ |
수집 어댑터 | T6 |
③ 화면 — 발행본 렌더러
| 파일 | 무엇 |
|---|---|
solution/site/src/sections/ |
섹션 컴포넌트. 히어로는 HeroPension.tsx, 일정은 items/ItinerarySection.tsx |
solution/site/src/lib/ui/use-rail-autoplay.ts |
카로셀 자동 넘김 정책 |
④ 사양 — 이 목업 폴더에서 가져갈 것
| 파일 | 옮길 자리 |
|---|---|
| 이 문서 1.4 프롬프트 전문 | shared/src/lib/section-prompts.ts |
build_itinerary.py (규칙 16종) |
backend/services/itinerary.py |
build_story.py (위키 사진 수집) |
backend/services/external/wikimedia.py |
inject.js (렌더러에 없는 기능 3개) |
site/src/sections/ |
1.2 섹션 22개 — 지금 상태
할 일 이 비면 제품이 이미 만들 수 있다. 손댈 필요 없다.
| id | 이름 | 값이 오는 자리 | 지금 누가 만드나 | 할 일 |
|---|---|---|---|---|
hero |
히어로 | narrative |
사장님이 적음 | T1 |
intro |
소개 | narrative.about[] |
LLM(빌더) | |
rooms |
객실 안내 | units[] + media[] |
사장님이 올림 | T6 |
event |
소식 | data(kind=event) |
사장님이 적음 | |
info |
기본 정보 | facts[] |
사장님이 적음 | |
booking |
실시간 예약 | — | (시연본은 꺼 둠) | |
video |
영상 | data(kind=video) |
사장님이 적음 | |
photos |
사진 갤러리 | media[] |
사장님이 올림 | |
map |
오시는 길 | place + routes[] |
수집 | |
festival |
계절별 축제 | local.festivals |
TourAPI 수집 | |
local |
지역 정보 | local.attractions/restaurants |
TourAPI 수집 | |
itinerary |
추천 일정 | data(kind=itinerary) |
services/itinerary.py + 빌더 프롬프트 dataSpec.ts:556 |
T2 |
story |
군산 이야기 | 아래 6탭 껍데기 | — | |
songs |
가요 다방 | data(kind=songs) |
LLM section-prompts.ts:67 |
T3 T5 |
daily |
오늘의 한 장 | data(kind=daily) |
LLM :91 |
섹션 자리가 없다 |
people |
인물 열전 | data(kind=people) |
LLM :113 |
T3 T5 |
chronicle |
시간의 골목 | data(kind=chronicle) |
LLM :135 |
|
postcard |
오늘의 엽서 | data(kind=postcard) |
LLM :160 |
|
quiz |
뒤집어 보는 질문 | data(kind=quiz) |
LLM :188 |
|
faq |
자주 묻는 질문 | faqs[] |
LLM + 사장님 | |
rules |
이용 규정 | facts[] |
사장님이 적음 | |
weather |
날씨 | local.weather |
open-meteo | |
planner |
계절별 추천 하루 | data(kind=planner) |
없음 | (시연본은 꺼 둠) |
섹션이 아닌 것 하나가 더 있다 — 헤더 음악 플레이어(T4).
1.3 할 일 여섯
T1 — 히어로 캐치프레이즈
증상 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다.
계약(site-payload.ts:316 Narrative)에 문자열 tagline 하나뿐이고,
히어로는 tagline ?? heroSubline ?? summary 를 그대로 찍는다(HeroPension.tsx:125).
할 일
Narrative에 필드를 더한다.catchphrases?: { version: 1; items: { text: string; // 25자 안쪽 kind: 'general' | 'season' | 'month' | 'weather'; season?: '봄'|'여름'|'가을'|'겨울'; month?: number; weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈'; }[] };- 히어로가 지금 조건에 맞는 것만 후보로 두고 돌린다.
순환 순서는
일반 → 계절 → 일반 → 월 → 일반 → 날씨한 바퀴를 만들고 그 바퀴를 돈다. 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). 계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울). 날씨는/v1/local/weather를 10분마다 읽고, 못 받아 오면 날씨 문구를 순환에서 뺀다. 목업 구현은inject.js의startCatchphrase()·buildCycle(). - 프롬프트는 1.4 ① 을 쓴다. 100개(일반 40 · 계절 24 · 월 24 · 날씨 12).
STORY_KINDS에 등록한다. 빠뜨리면 서버 생성 잡이 이 종류를 아예 모른다 —daily가 그래서 한동안 빈칸이었다.
완료 기준 새 업장 발행 시 문구 100개가 돌고, 9월에 겨울 문구가 안 나온다.
T2 — 추천 일정 ★ 가장 큰 일
증상 ① 서버 생성기(services/itinerary.py)는 하루를 관광지 2 + 맛집 2 로 짠다.
체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가
"둘째 날 해산물축제 → 근대건축관" 으로 끝난다.
증상 ② 프롬프트가 두 곳으로 갈려 있다.
일정 프롬프트는 shared/src/lib/section-prompts.ts 에 없고
frontend/src/features/builder/canvas/dataSpec.ts:556 에만 있다.
그 파일은 빌더에서 붙여넣는 9종을 담는데, 그중 songs 등 6종은 shared 를 import 하고
video · event · itinerary 셋은 자기 자리에 직접 적혀 있다.
→ 서버 생성 잡(story_service.py)은 shared 만 읽으므로 이 셋을 만들 줄 모른다.
daily 가 빈칸이던 것과 같은 종류의 사고다. 옮기면서 shared 로 올린다.
증상 ③ 지금 프롬프트가 모델에게 시각 계산을 시킨다.
dataSpec.ts 의 일정 규칙에 이런 줄이 있다 — "밤 9시를 넘기는 칸은 화면에서 빠진다 —
시각을 계산해 보고 넣는다", 그리고 minutes · moveMinutes 를 모델이 적게 한다.
모델은 못 한다. 이게 시연본 첫 판이 망가진 직접 원인이다.
★ 프롬프트 하나로 일정을 받지 않는다. 모델은 시각 계산을 못 한다 —
이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 렌더러가 조용히 버리는 칸을
만든다(shared/src/lib/section-data.ts:535). 시연본 첫 판이 정확히 그랬다.
세 단계로 나눈다.
| 단계 | 누가 | 무엇을 내놓나 | 프롬프트 |
|---|---|---|---|
| ① 장소 대장 | LLM | 장소 30곳 × {stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery} |
1.4 ② |
| ② 좌표 | 코드 | 카카오 로컬로 searchQuery → 위경도. LLM 이 적은 좌표는 쓰지 않는다 |
— |
| ③ 조립 | 코드 | 이동시간·도착시각·끼니 창 맞추기·규칙 16종 감사 | 없음 (build_itinerary.py) |
| ④ 테마 | LLM | 테마 20개 × {name, duration, audience, why, days[{kind, places[]}]} |
1.4 ③ |
코드가 하는 일(③)에서 반드시 옮겨야 하는 것 — 근거는 build_itinerary.py 줄번호
| 무엇 | 어디 | |
|---|---|---|
| 이동시간 | 좌표 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분, 최소 3분 | move_minutes() |
| 숙소 고정 | 모든 일정이 숙소에서 출발해 숙소로 복귀. 첫 칸·마지막 칸이 숙소 | :108 :182 |
| 시각 창 | 입실 15:00+ · 퇴실 11:00− · 점심 11:30–13:30 · 늦은 점심 13:00–15:00 · 저녁 17:30–20:00 · 밤산책 18:30–20:00 | :156 :166 |
| 창 맞추기 | 어긋나면 앞 칸 체류를 범위(min~max) 안에서 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 그 조합을 만들지 않는다 | plan_day() |
| 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | :470 |
| 끼니 간격 | 앞 끼니가 끝나고 3시간이 지나야 다음 끼니 창이 열린다(MEAL_GAP). 창만 지키면 통과하던 자리다 |
_window_of() |
| 감사 16종 | 하나라도 어기면 payload 를 쓰지 않고 멈춘다 | :526 · 목록은 2.4 |
★ 감사는 생성기 안에 둔다. 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — 실제로 그래서 33건이 통과했다(3.2).
하루 뼈대 — @ 는 숙소
첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@
가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@
쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@
마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@
완료 기준 새 업장에서 테마 20개가 나오고 audit() 위반 0건.
하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다.
T3 — 인물·노래 사진
증상 프롬프트가 사진 URL 을 금지하고 imageQuery 만 받는다(section-prompts.ts:128).
초상권·저작권 때문이고 그 판단은 맞다. 그런데 다음 단계가 없어서 인물 열전이 활자만으로 선다.
할 일
services/external/wikimedia.py에 문서 →pageimages→imageinfo.extmetadata경로를 붙인다.- ★ 이름으로 커먼즈를 검색하지 않는다. 동명이인이 온다 — 실측(2026-09-11):
이수현(독립운동가)→걸그룹, 은성수(금융위원장)→축구선수, 박성현(양궁)→골퍼,
전진(비전향 장기수)→아이돌, 이길여→건물. 틀린 사진은 없는 것만 못하다.
그 사람의 위키백과 문서가 대표로 쓰는 사진만 믿는다. 구현은
build_story.py. - 저작자·라이선스를
imageCredit으로 payload 에 넣는다. ★ 문자열이다(site/src/sections/items/common.tsx:206). 객체로 넣으면 렌더러가 조용히 아무것도 안 그린다 — CC BY-SA 는 출처 표시가 라이선스 조건이라 그건 침해다. - 사진 있는 사람을 앞으로 정렬한다. 빈 카드가 먼저 오면 목록이 비어 보인다.
완료 기준 인물 카드에 사진과 저작자 줄이 같이 뜬다. 시연본 실적 57명 중 18명.
T4 — 음악 플레이어
증상 media 에 오디오 종류가 없다. 목업은 mp3 를 /s/stay/audio/ 에 직접 넣었다.
할 일 MediaItem 에 kind:'audio'(+title artist durationSec)를 넣고 발행 때 같이 올린다.
헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록.
자동재생은 넣지 않는다(2026-09-11 지시). 순서는 랜덤 시작 후 순차.
목업 구현은 inject.js 의 startPlayer(), 모양은 inject.css 의 #w4d-mini · #w4d-panel.
T5 — 상한(maxItems)
| 종류 | 지금 | 시연본 | 왜 |
|---|---|---|---|
| songs | 8 | 25 | 8곡이면 '다방'이 아니라 목록이다 |
| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 |
★ 한 번에 다 받지 않는다. 25곡을 한 번에 시키면 뒤쪽이 급격히 부실해진다.
15곡씩 나눠 받고 제목으로 중복을 지운다 — 나누는 문구는 1.4 ④ 에 있다.
★ maxItems 와 프롬프트 안의 숫자를 같이 바꾼다. 어긋나면 받아 놓고 잘라 버린다.
T6 — 객실 사진 출처
시연본은 야놀자 등록본(nol.yanolja.com/stay/domestic/10068088)의 Next.js 플라이트 데이터에서
roomTypes[].photos 를 파싱해 A동 12·B동 10 을 가져왔다(rooms.json).
손으로 한 것이고 제품 경로가 아니다.
수집 어댑터를 만들려면 docs/DECISIONS.md 1-1 과 docs/DATA_SOURCE_RESEARCH.md 를 먼저 읽는다 — 봇 탐지 우회는 결론과 무관하게 영구 금지다. 현실적인 경로는 사장님이 올리는 것이거나 업소가 준 채널의 공개 API 다.
★ 사진을 눈으로 보고 세지 않는다. 시연본에서 A/B 를 눈으로 배정했다가 11/5 가 나왔고 실제는 12/10 이었다. 등록본 데이터를 쓴다.
1.4 프롬프트 전문
프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.
| 파일 | 담는 것 | 누가 읽나 |
|---|---|---|
shared/src/lib/section-prompts.ts |
songs · daily · people · chronicle · postcard · quiz | 빌더 + 서버 생성 잡 |
frontend/.../canvas/dataSpec.ts |
위 6종(shared 를 import) + video · event · itinerary | 빌더만 |
→ video · event · itinerary 는 서버가 만들 줄 모른다. 옮기면서 shared 로 올린다.
단일 출처는 solution/shared/src/lib/section-prompts.ts 다.
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와, 서버 생성 잡(services/story_service.py)이
같은 문장을 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가
조용히 생긴다.
shared/src/lib/section-prompts.ts ← 여기만 고친다
│ npm run export:prompts
▼
backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치지 않는다
공통 규칙 (모든 프롬프트 뒤에 붙는다)
[공통 규칙]
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 "미상" 이라고 쓰지 않는다.
3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.
4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다.
5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
6. 설명 문장은 항목당 두 문장을 넘기지 않는다.
7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.
★ 2번과 3번이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. 모델이 이걸 어기면 검증기가 뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다.
① 히어로 캐치프레이즈 (T1 · 신규)
너는 숙소의 카피라이터다. 아래 조건에 맞는 JSON 하나만 출력한다.
[업소] {상호} ({업종})
[지역] {지명}
[업소가 가진 사실]
{fact 목록을 한 줄씩. 예: 1920년대 적산가옥 · 독채 2동 · 최대 4인 · 마당과 정원 ·
창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분}
[해야 할 일]
[업소] 의 히어로 문구를 100개 만든다. 상호 바로 아래에 한 줄로 뜨고 7초마다 바뀐다.
네 종류로 나눠 만든다.
· 일반 40개 — 언제 나와도 되는 문구
· 계절 24개 — 봄·여름·가을·겨울 각 6개 (3개월 단위: 3~5 봄 / 6~8 여름 / 9~11 가을 / 12~2 겨울)
· 월별 24개 — 1월부터 12월까지 각 2개
· 날씨 12개 — 맑음 3 · 구름많음 2 · 흐림 2 · 비 3 · 눈 2
[스키마]
{ "kind":"catchphrase", "version":1, "items":[
{ "text":"한 줄", "kind":"general|season|month|weather",
"season":"봄|여름|가을|겨울", // kind 가 season 일 때만
"month":9, // kind 가 month 일 때만 (1~12)
"weather":"맑음|구름많음|흐림|비|눈" } // kind 가 weather 일 때만
] }
[이 아이템만의 규칙]
· 한 줄은 25자 안쪽이다. 두 문장으로 쓰지 않는다.
· **[업소가 가진 사실] 안에서만 쓴다.** 거기 없는 시설·거리·연도를 지어내지 않는다.
· 느낌표와 이모지를 쓰지 않는다. "최고" "완벽" "힐링" 같은 광고 단어를 쓰지 않는다.
· 상호를 문구에 넣지 않는다 — 바로 위에 이미 크게 떠 있다.
· 같은 사실을 두 번 쓰지 않는다. 100개가 다 다른 것을 말해야 한다.
· 계절 문구는 그 계절에만 맞는 말이어야 한다. "좋습니다" 처럼 아무 때나 되는 말은 일반으로 보낸다.
· 날씨 문구는 그 날씨일 때 손님이 **무엇을 할 수 있는지**를 말한다. 날씨 묘사만 하지 않는다.
· 월별 문구는 그 달의 날씨·행사·빛이 근거여야 한다. 숫자만 바꾼 문구를 12번 쓰지 않는다.
· source 와 verified 는 없다. 사실이 아니라 문구이고, 근거는 [업소가 가진 사실] 이다.
잘 나온 예
| 종류 | 문구 | 근거가 된 사실 |
|---|---|---|
| 일반 | 담 너머는 히로쓰 가옥입니다 | 위치 |
| 일반 | 침구는 매일 새것처럼 나갑니다 | 매일 세탁·살균 |
| 계절(가을) | 기와 위로 가을 볕이 마릅니다 | 기와 지붕 |
| 월(9월) | 가을이 담을 넘어오는 구월입니다 | 담·계절 |
| 날씨(비) | 카페 공간에서 빗소리를 들으실 수 있습니다 | 창고형 카페 |
받은 뒤 검사할 것
- 100개인가. 종류별 40/24/24/12 인가
- 25자를 넘는 것이 있는가
- [업소가 가진 사실] 에 없는 말이 있는가 ← 이게 제일 많이 틀린다
- 계절 문구가 그 계절에만 맞는가
- 같은 말이 두 번 나오는가
② 여행 일정 · 장소 대장 (T2 ① · dataSpec.ts:556 의 일정 프롬프트를 대체한다)
너는 지역 여행 코디네이터다. 아래 조건에 맞는 JSON 하나만 출력한다.
[업소] {상호} ({업종})
[지역] {지명}
[좌표] {위도},{경도}
[해야 할 일]
[업소] 에서 출발해 당일로 다녀올 수 있는 장소를 30곳까지 찾는다.
관광지·박물관·골목·바다·절 같은 볼거리 20곳과, 끼니를 해결할 곳 10곳을 섞는다.
[업소] 에서 30km 안쪽만 고른다.
[스키마]
{ "kind":"places", "version":1, "items":[
{ "name":"장소 이름",
"searchQuery":"지도에서 검색할 말",
"category":"볼거리|식당|카페",
"stayMinutes":{"min":20,"base":30,"max":45},
"todo":"거기서 무엇을 하는지 한 문장",
"indoor":true,
"needsCar":false,
"bestTime":"오전|오후|저녁|밤|상관없음",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }
[이 아이템만의 규칙]
· **todo 는 장소 설명이 아니라 할 일이다.**
✗ "1899년에 지어진 근대 건축물입니다"
✓ "옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다."
손님이 거기서 무엇을 하는지 모르면 그 줄은 쓸모가 없다.
· stayMinutes 는 **실제로 걸리는 시간**이다. min 은 빨리 보고 나오는 사람, max 는 천천히 보는 사람.
박물관 60~120분, 골목 15~40분, 식당 40~70분이 기준이다. 무엇이든 90분으로 적지 않는다.
· 식당은 **무엇을 먹는지**까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다.
· indoor 는 비 오는 날 갈 수 있는 곳만 true 다. 처마 밑·실내 전시가 여기 들어간다.
· needsCar 는 [업소] 에서 걸어서 못 가는 곳(1.5km 초과)이면 true 다.
· 좌표는 적지 않는다. 코드가 카카오 로컬에서 받는다 — 지어낸 좌표는 지도에 엉뚱한 핀을 찍는다.
· 영업시간·휴무일이 확인되면 source 에 그 페이지를 단다.
· **[업소]에서 40분 넘게 걸리는 곳을 넣었다면, 그 근처 식당도 하나 이상 넣는다.**
멀리 다녀온 뒤 시내까지 돌아와 밥을 먹으면 점심이 오후 3시가 된다 —
시연본에서 실제로 그랬다(섬에서 88분 달려와 15:47 에 늦은 점심).
· 끼니 장소는 **한 끼 40~70분**이다. 여기에 이동시간을 얹는 것은 코드가 한다.
③ 여행 일정 · 테마 짓기 (T2 ④ · 신규 · ②와 짝)
[해야 할 일]
[장소 대장] 을 재료로 1박 2일 10개, 2박 3일 10개의 테마를 만든다.
테마는 **손님의 성격**으로 가른다 — 차가 있는지, 걷기를 좋아하는지, 비가 오는지,
아이가 있는지, 사진을 찍는지, 쉬러 오는지.
[스키마]
{ "kind":"themes", "version":1, "items":[
{ "name":"테마 이름", "duration":"1박 2일|2박 3일",
"audience":"누구에게", "why":"왜 이 순서인지 한두 문장",
"days":[ { "kind":"first|mid|mid_rest|mid_island|last", "places":["장소명","장소명"] } ] } ] }
[이 아이템만의 규칙]
· places 는 [장소 대장] 에 있는 이름만 쓴다. 없는 곳을 적으면 그 테마는 버려진다.
· 뼈대의 자리 수와 places 개수가 같아야 한다.
· 끼니 자리에는 category 가 식당·카페인 곳만 넣는다.
· **한 테마 안에서 같은 곳을 두 번 넣지 않는다.**
· 테마 이름에 "최고" "완벽" 을 쓰지 않는다. 무엇을 하는 일정인지가 이름이다.
· 20개 중 하나는 **숙소에서 거의 나가지 않는 날**을 포함한다.
④ 가요 다방 (T5 · 기존 8곡 → 50곡)
[해야 할 일]
[지역]을 노래한 대중가요를 찾아 아래 JSON 으로 정리한다.
이번에는 {N}번째부터 {N+14}번째까지, 15곡을 낸다.
이미 받은 곡은 아래에 있다 — 같은 곡을 다시 내지 않는다.
[이미 받은 곡] {곡명 목록}
지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.
1960~80년대 곡을 먼저 찾고, 다 떨어지면 지자체가 만든 곡·지역 가수의 곡으로 넓힌다.
[스키마]
{ "kind":"songs", "version":1, "title":"가요 다방", "items":[
{ "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡",
"year":1966, "label":"음반사", "labelColor":"#d4551f",
"story":"곡의 배경 (두 문장 이내, 가사 없이)",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }
[이 아이템만의 규칙]
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
· **곡이 실재하는지 출처로 확인한다.** 지역 노래 목록·언론 기사·지자체 음반 안내가 근거다.
검색으로 못 찾으면 그 곡을 내지 않는다 — 15곡을 억지로 채우지 않는다.
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.
· 유튜브 영상 ID 를 적지 않는다 — 코드가 검색 주소를 만든다. 틀린 영상이 붙는 것보다 낫다.
· 숙소가 직접 만든 곡은 여기 넣지 않는다. 이 섹션은 **도시의 노래**를 모으는 자리다.
★ 시연본은 25곡에서 멈췄다. 출처에서 확인되는 군산 곡이 거기까지였다. 모자라면 모자란 채로 내는 것이 맞다 — 지어낸 곡은 아는 사람이 바로 알아본다.
⑤ 인물 열전 (T3 T5 · 기존 10명 → 50명)
[해야 할 일]
[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 50명까지 찾는다.
문학·음악·미술·연기·체육·정치·학문·기업을 고루 섞는다. 한 분야가 절반을 넘지 않게 한다.
생존 인물은 공개된 활동 사실만 쓴다.
[스키마]
{ "kind":"people", "version":1, "title":"인물 열전", "items":[
{ "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
"oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }
[이 아이템만의 규칙]
· **source.url 은 그 사람의 문서**여야 한다(위키백과 문서, 기관 소개 페이지).
"OO시 출신 인물 목록" 같은 목록 페이지는 근거가 아니다.
· oneLine 은 **그 사람이 무엇을 한 사람인지**다. "군산 출신입니다" 는 이름 아래 이미 있다.
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
· 생존 인물의 가족·거주지·건강·재산 같은 사생활은 쓰지 않는다.
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사람이 확인한다.
· years 를 모르면 뺀다.
★ 사진은 프롬프트로 받지 않는다. T3 참조 — 위키백과 문서 대표 사진만 받고 라이선스가 확인된 것만 쓴다.
⑥ 오늘의 엽서 (기존 개선 — 링크가 붙게)
[해야 할 일]
[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
[스키마]
{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
{ "line":"한 문장", "hashtags":["#태그"],
"place":"그 문장이 가리키는 장소 이름",
"searchQuery":"지도에서 검색할 말",
"postmark":"소인에 찍을 짧은 지명",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }
[이 아이템만의 규칙]
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다.
· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.
· **place 와 searchQuery 를 반드시 채운다.** 화면이 그 값으로 지도 링크를 건다.
postmark 는 도장 문구라 지명이 아닐 수 있다 — 그걸로 지도를 열면 엉뚱한 데가 나온다.
★ 시연본에서 실제로 그랬다. 소인 글자(군산 內港)로 링크를 만들었더니
군산 군산 같은 검색어가 나왔다. place 를 따로 받는 이유다.
1.5 프롬프트를 고칠 때 지킬 것
STORY_KINDS에 등록한다. 빠지면 서버 생성 잡이 그 종류를 모른다.maxItems와 프롬프트 안의 숫자를 같이 바꾼다. 어긋나면 받아 놓고 잘라 버린다.- 개수를 억지로 채우게 하지 않는다. "확실한 것이 12개면 12개만 낸다" 를 규칙에 넣는다. 채우라고 하면 채운다 — 지어내서.
- 계산은 시키지 않는다. 시각·거리·요금은 코드가 한다. 모델은 자신 있게 틀린 숫자를 준다.
- "설명" 이 아니라 "할 일" 을 시킨다. 손님이 그 자리에서 무엇을 하는지가 콘텐츠다.
- 출처의 종류를 지정한다. "출처를 달아라" 로는 목록 페이지가 온다.
- 고친 뒤
npm run export:prompts를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다.
2부 — 목업을 고치는 사람
2.1 무엇을 고치는 건가
/s/stay 는 payload 가 없는 목업이다(stay2 · stay3 도 같다).
프리렌더는 payload 를 받은 사이트만 굽는다 — 이 사이트는 재굽기 대상이 아니고,
고칠 자리는 이미 구워져 있는 index.html 하나이고 그 파일이 유일본이다.
그래서 손으로 열어 고치지 않는다. 스크립트가 원본 + 바꿀 것 → 새 index.html 을 만든다. 두 번째로 고칠 때 "무엇을 왜 바꿨는지"가 파일이 아니라 코드와 이 문서에 남아야 하기 때문이다.
stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준)
orig/index.html 원본 HTML (건드리지 않는다)
│
├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 16종 감사)
├─ build_story.py 가요 다방 · 인물 열전을 넣는다
└─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정
│
▼
build/index.html ← 배포하는 파일 (git 이그노어)
★ 렌더러(solution/site/src)를 고치지 않는다. 고치면 번들 해시가 바뀌어
전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 inject.css/inject.js 로 덮는다.
2.2 굽기 · 배포
다른 기계에서 처음 띄울 때
git clone <repo> && cd o2o-site-AEO
cp .env.example .env && cp nginx/site.conf.example nginx/site.conf
docker compose up -d
cd solution/site/scripts/mockup
python3 patch_stay.py
C=o2o-web4ai-solution-prerender
docker exec $C mkdir -p /app/solution/site/out/s/stay/img/people \
/app/solution/site/out/s/stay/img/mirror /app/solution/site/out/s/stay/audio \
/app/solution/site/out/s/stay/vendor
docker cp build/index.html $C:/app/solution/site/out/s/stay/index.html
docker cp img/. $C:/app/solution/site/out/s/stay/img/
docker cp audio/. $C:/app/solution/site/out/s/stay/audio/
docker cp vendor/. $C:/app/solution/site/out/s/stay/vendor/
node audit-all.mjs
→ http://localhost/s/stay
★ 이 시연본은 레포 안에서 자급자족한다. 번들 css/js 와 미러 사진 61장까지
vendor/ · img/mirror/ 에 들어 있고, patch_stay.py 가 경로를 /s/stay/… 로 바꿔 박는다.
그 기계에서 구운 번들 해시와 무관하게 뜬다.
(2026-09-11 이전 판은 /assets/… 를 가리켜 다른 기계에서 흰 화면이었다.)
내용을 바꾼 뒤 (전체 굽기)
python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py
순서를 지킨다. build_itinerary.py 가 stay-payload.json(원본)을 읽어
stay-payload-new.json 을 만들고, 뒤 둘이 그 파일을 이어서 고친다.
순서를 바꾸면 앞 단계 결과가 지워진다.
로컬 반영 · 검사:
docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html
node audit-all.mjs # 34개 항목
node rails-test.mjs # 카로셀 13개
/s/ 는 5분 캐시(max-age=300)다 — 확인할 때 ⌘⇧R.
킹서버
tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio vendor
scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/
ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio vendor && tar xzf stay-bundle.tgz
C=o2o-web4ai-solution-prerender
docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/img/mirror \
/app/solution/site/out/s/stay/audio /app/solution/site/out/s/stay/vendor
docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html
docker cp img/. \$C:/app/solution/site/out/s/stay/img/
docker cp audio/. \$C:/app/solution/site/out/s/stay/audio/
docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
확인: md5 -q build/index.html 과
ssh King_admin "docker exec o2o-web4ai-solution-site md5sum /srv/sites/s/stay/index.html" 이 같아야 한다.
그다음 node audit-all.mjs https://web4ai.o2osolution.ai/s/stay.
되돌리기: 컨테이너 안에 index.html.bak-20260910 이 있다.
2.3 무엇을 어디서 고치나
히어로 문구 100개 → patch_stay.py
| 자리 | 줄 | 무엇 |
|---|---|---|
GENERAL |
:20 |
일반 40개 |
SEASON |
:62 |
계절별 24개 — 봄/여름/가을/겨울 각 6 |
MONTH |
:72 |
월별 24개 — 1~12월 각 2 |
WEATHER |
:87 |
날씨별 12개 — 맑음/구름많음/흐림/비/눈 |
문장만 더하거나 지우면 된다. 개수 제한은 없다.
순환 순서·계절 구분·날씨 연동은 1.3 T1 에 적어 둔 그대로다(inject.js).
여행 일정 → build_itinerary.py
사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.
| 자리 | 줄 | 무엇 |
|---|---|---|
PLACES |
:40 |
장소 대장 — 좌표 · 지도 검색어 · 체류시간(min/base/max) · 거기서 무엇을 하는지 |
STAY_ACTION |
:108 |
머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 |
WINDOW |
:156 |
시간대가 정해진 자리 |
SKELETON |
:182 |
하루 뼈대. @ 가 붙은 것은 머뭄 |
THEMES |
:368 |
테마 21개 |
PACING |
:470 |
테마별 시작 시각과 체류 배율 |
새 장소 → PLACES 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다).
그다음 THEMES 의 원하는 자리에 이름을 적는다.
새 테마 → THEMES 에 한 덩이, PACING 에 한 줄. 뼈대의 @ 없는 자리 수와 장소 개수가
같아야 한다 — 틀리면 빌드가 멈추고 알려 준다.
가요 다방 · 인물 열전 → build_story.py
| 자리 | 줄 | 무엇 |
|---|---|---|
SONGS |
:28 |
25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄 |
PEOPLE |
:109 |
57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄 |
- 연도·작사·작곡을 모르면 비운다("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다).
- 듣기 링크는 유튜브 검색 주소다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있다.
- 인물 사진은 두 곳 — ①
people-photos.json(위키 문서 대표 사진 17장, 라이선스 확인분) ② 원본 payload 에 있던 미러 사진(채만식). 새로 가져오는 게 아니라 자리로 돌리는 것이다. - ★ 이름만으로 커먼즈를 검색하지 않는다(1.3 T3).
- 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다.
객실 사진 → patch_stay.py ROOM_PHOTOS(:142) · rooms.json
2.4 규칙 16종 — 어기면 빌드가 멈춘다
build_itinerary.py 의 audit()(:526)이 매 실행 검사한다.
하나라도 어기면 payload 파일을 쓰지 않고 위반 목록을 찍고 멈춘다.
| 규칙 | 이 규칙이 없어서 났던 일 | |
|---|---|---|
| 1 | 하루의 첫 칸·마지막 칸은 스테이 머뭄 | 출발지가 화면에서 사라졌다 |
| 2 | 같은 자리가 연달아 서지 않는다 | 아침 · 머뭄 다음이 체크아웃 · 머뭄 |
| 3 | 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐 | 마지막 날 머뭄이 3번 |
| 4 | 이동시간 = 좌표 계산값 | 담 하나 건넌 곳 8분, 45분 걸리는 섬 10분 |
| 5 | 입실·점심·늦은 점심·저녁·밤 산책 시간대 | 점심 09:40, 저녁 13:09 |
| 6 | 퇴실 11:00 전 | 체크아웃 11:35 |
| 7 | 21:00 초과 금지 | 렌더러가 그 칸을 조용히 버린다 |
| 8 | 끼니 자리에는 먹는 곳만 | |
| 9 | 같은 날 같은 식당 두 번 금지 | |
| 10 | 한 테마 안에서 같은 곳 반복 금지 | 같은 테마에 한일옥이 두 번 |
| 11 | "비가 와도 되는" 테마는 실내만 | 비 코스에 야외 두 곳 |
| 12 | "걸어서만/차 없이" 테마에 차 필요한 곳 금지 | |
| 13 | 1박 2일 = 2일, 2박 3일 = 3일 | |
| 14 | 아이 동반 테마에 술집 금지 | |
| 15 | 끼니 사이 3시간 이상 | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 |
| 16 | 볼거리는 편도 이동보다 오래 머문다(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 |
★ 왜 빌드 안인가 — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. 검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.
audit-all.mjs 는 그다음 단계다 — 구워진 HTML 을 실제 브라우저로 열어 34개 항목을 센다.
2.5 주입분(inject.js · inject.css) 다섯
렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다.
- 캐치프레이즈 순환 — React 가 그린
<p>를 찾아 단어 단위로 갈아 끼운다 → T1 - 헤더 미니 플레이어 — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4
- 링크 걸기 — 일정 정거장 187개 · 엽서 4개를 네이버 지도로
- 사진 저작자 표시 — 이 목업이 물고 있는 번들이 옛 판이라
imageCredit을 안 그린다 → T3 - 카로셀 제어 — 아래
카로셀 제어 (tameRails)
렌더러의 자동 넘김(site/src/lib/ui/use-rail-autoplay.ts)은 4.5초 고정 타이머이고,
정지는 held 카운터 하나로 센다 — 레일 위 pointerdown 에 올리고 window pointerup·
pointercancel 에 내린다. 주입분은 타이머를 건드리지 않고 그 카운터만 쓴다
(pointerdown 을 button: 2 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다).
구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다.
| 언제 | 얼마나 | |
|---|---|---|
| 쿨타임 | 무엇이든 만지면(세로 스크롤·휠·키보드 포함) | 7초 |
| 손이 닿아 있는 동안 | 손가락·마우스 버튼이 내려가 있는 내내 | 뗄 때까지 |
| 영구 정지 | 레일이 실제로 가로로 8px 넘게 움직였을 때 | 그 레일은 끝 |
★ 손가락 수로 센다 — pointer 로 세면 안 된다. 브라우저는 터치가 페이지 스크롤로
넘어가는 순간 손가락이 아직 닿아 있는데 pointercancel 을 쏜다. 훅의 정지가 거기서
풀려 손가락 밑에서 카드가 넘어간다. 실측: touchstart 0.0s → pointercancel 7.5s →
8.4s 에 넘어감. 그래서 event.touches.length 로 센다.
★ "만졌다"와 "밀었다"를 가른다. 전에는 레일 위 pointerdown·wheel 이면 곧바로 영구 정지였다. 그런데 일정 카로셀은 화면 가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락· 커서가 거의 언제나 그 위를 지난다 — 한 번 스크롤하면 그 레일이 영영 멈췄다 (실측: 세로 휠 한 번 → 20초 0px).
★ 호버 정지는 렌더러 것이고 그대로 둔다 — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인).
검증: node rails-test.mjs.
→ 운영 사이트에는 이 문제들이 그대로 있다. 훅을 고치는 것은 별도 작업이다(3.4).
주입분을 쓸 때 지킬 것
★ React 가 다시 그리면 사라진다. 그래서
- 플레이어 목록·패널은
#root밖(body 직속)에 둔다 - 헤더에 꽂는 단추는
MutationObserver로 감시해 다시 꽂는다 - 캐치프레이즈는 노드를 들고 있지 않고 매 순환마다 다시 찾는다
★ window.load 를 기다리지 않는다. 경로 지도 때문에 OSM 타일 <img> 가 140장이라
load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다.
2.6 밟으면 조용히 틀리는 자리
out/assets에 파일을 넣지 않는다. 거기는 프리렌더가 관리한다(해시 번들 대장 · 30일 보관). 손으로 넣은 사진·음원·번들은/s/stay/…에 둔다 — nginx^~ /s/가 그대로 서빙하고 (Range 206 확인) 정리 대상이 아니다.- 마크업을 고쳐 지우지 않는다. React 가 payload 로 다시 그리면서 되돌린다. 화면에서 없애야 하면 주입 CSS 로 끈다(이용 정보 맨 아래 가로선이 그 경우다).
- 렌더러가 21시를 넘기는 칸을 조용히 버린다(
shared/section-data.tsPLAN_ENDS_BY). 적어 놓고 화면에 안 나오는 칸이 생긴다 — 규칙 7이 이걸 막는다. imageCredit은 문자열이다(site/src/sections/items/common.tsx:206). 객체를 넣으면 조용히 안 나오고, 그건 라이선스 위반이 된다.- 크롤러가 보는 HTML 은 옛 내용이다. 이 페이지는 payload 로 하이드레이션해서 화면을
바꾸는 것이라, SSR 마크업과
llms.txt는 구운 날 그대로다. 시연에는 문제없지만 검색·AI 에 인용되는 값은 아니다. og:image도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다.
3부 — 공통
3.1 보고 규칙 — "했다" 고 말하기 전에
2026-09-11 지시. 이 규칙을 어긴 기록이 3.2 에 있다.
- 실행하지 않은 것을 됐다고 쓰지 않는다. 코드에서 빠진 것을 보는 건 확인이 아니다. 화면을 열어 세거나 명령을 돌려서 숫자·출력을 얻은 뒤에 말한다.
- "했다" 옆에 무엇으로 확인했는지 같이 쓴다.
audit-all.mjs 34/34·md5 일치·20초 동안 0px처럼. 근거를 못 쓰면 아직 안 끝난 것이다. - 절차를 안내할 때는 그 절차를 먼저 밟아 본다.
- 일부만 했으면 일부만 했다고 쓴다. 못 한 것을 빼고 요약하지 않는다.
- 사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다. 범위를 좁혀서 재지 말고 전수로 다시 센다.
3.2 실제로 틀렸던 방식 — 되풀이하지 말 것
| 한 짓 | 어떻게 틀렸나 | 대신 할 것 |
|---|---|---|
| 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다(캐치프레이즈 빨간 줄) | node audit-all.mjs 로 브라우저에서 확인하고 숫자를 적는다 |
| 안 띄워 보고 "이것만 하면 나온다" 고 안내 | 미러 사진 61장·번들이 레포 밖이라 다른 기계에서 흰 화면 | 절차를 그대로 밟아 본 뒤 쓴다 |
| 지시받은 한 줄만 고침 | "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 | 한 곳을 고치면 같은 종류를 전수 검사한다 |
| 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 빌드 안에 둔다(audit()) |
| 6초만 재고 "쿨타임 된다" 고 보고 | 쿨타임이 7초라 재개를 못 본 것 | 기대값보다 길게 재고 구간을 찍는다 |
| 사진을 눈으로 보고 A/B 배정 | 11/5 가 나왔는데 실제는 12/10 | 등록본 데이터(rooms.json)를 쓴다 |
| 마크업을 고쳐서 요소 제거 | 하이드레이션이 되돌렸다 | 주입 CSS 로 끈다 |
| 이름으로 사진 검색 | 동명이인이 붙었다 | 그 사람 문서의 대표 사진만 |
일정이 왜 틀렸나 (2026-09-11 검수 결과)
사장님이 화면에서 먼저 찾으신 것:
| 증상 | 몇 건 | |
|---|---|---|
| A | 아침 · 머뭄 다음 칸이 체크아웃 · 머뭄 — 같은 자리에 두 칸이 연달아 섰다 |
1 |
| B | 마지막 날 머뭄이 3번(아침 · 체크아웃 · 마무리) — 하루의 절반이 숙소 칸 | 21 |
| C | 마지막 날 섬 코스가 저녁도 없이 19:14 에 돌아와 짐만 찾고 끝 | 1 |
| D | 아침 칸이 2시간 14분 — 끼니 시각을 맞추려고 앞 칸을 늘리다 생긴 값 | 1 |
A·B 의 원인은 하나다. "1번은 숙소" 지시를 받고 하루의 첫 칸에 아침 · 머뭄 을 넣었는데,
마지막 날 뼈대에는 이미 체크아웃 · 머뭄 과 마무리 · 머뭄 이 있었다.
첫 칸만 보고 넣고 그 하루 전체를 다시 읽지 않았다.
C·D 는 검사에 그 항목이 없어서 통과했다.
고친 것 — 마지막 날 아침과 퇴실을 한 칸으로 합쳤다(아침 · 스테이 머뭄,
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요").
섬 가는 마지막 날은 바깥 정거장을 둘로 줄였다. 아침 상한을 110분으로 내렸다.
그리고 검사를 빌드 안으로 옮겼다(2.4).
지시를 주실 때 — 이렇게 주시면 한 번에 끝납니다
- "X 고치고, 그 화면 전체를 다시 훑어서 같은 종류로 이상한 데 있으면 같이 잡아." '같은 종류'라는 말이 있으면 한 건이 아니라 그 규칙을 어긴 전수를 찾는다.
- "브라우저로 열어서 확인하고 말해." / "검증 결과를 숫자로 보여줘."
- 규칙을 주실 때 예외까지 같이. "1번은 항상 머뭄. 단 마지막 날은 퇴실이 있으니 그건 네가 판단해서 제안하고 물어봐." — 예외가 안 정해져 있어서 칸을 하나 더 만들었고 그게 연속 칸이 됐다.
3.3 파일 목록 · 지울 것 · git
쓰는 파일
| 파일 | 무엇 |
|---|---|
README.md |
이 문서. 지침은 전부 여기 |
TEXT.md |
화면에 나가는 텍스트 전문 (생성물 — python3 dump_text.py) |
build_itinerary.py |
여행 일정 계산 + 규칙 16종 감사 |
build_story.py |
가요 다방 · 인물 열전 + 위키 사진 |
patch_stay.py |
캐치프레이즈 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 |
dump_text.py |
TEXT.md 생성 |
inject.css · inject.js |
화면에 덧대는 다섯 가지 |
audit-all.mjs |
브라우저로 34개 항목 전수 점검 |
rails-test.mjs |
카로셀 13개 × 데스크톱·모바일 |
kingcheck.mjs |
킹서버 반영분 확인 |
audit_schedule.py |
일정 전수 검수표 생성 → SCHEDULE.md (규칙이 안 보는 논리 12가지) |
SCHEDULE.md |
그 결과물 (생성물) |
원본 · 원자료 (건드리지 않는다)
| 파일 | 무엇 |
|---|---|
stay-payload.json · orig/index.html |
원본. 되돌릴 때 기준 |
rooms.json |
야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) |
people-photos.json · people-images.json · people-images-meta.json |
위키 문서 사진 + 라이선스 |
people-raw.json · people-rows.json |
인물 수집 원자료 |
coords.json |
카카오 로컬에서 받은 좌표 |
자산 (레포에 일부러 올렸다)
| 개수 | 왜 올리나 | |
|---|---|---|
img/ |
28장 | 2026-09-07 에 목업 자산이 통째로 404 났을 때 복구한 곳이 서버가 아니라 git 워크트리였다 |
img/people/ |
17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 |
img/mirror/ |
61장 | 히어로·소개·객실. 없으면 다른 기계에서 흰 화면 |
audio/ |
5곡 | 사장님 자작곡 |
vendor/ |
2개 | 번들 css/js. 해시가 달라도 뜨게 하려고 |
합쳐서 약 77M 이다. 전부 한 번 넣고 안 바뀌는 파일이라 git 이 싫어하는 경우가 아니다. 자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다.
git 이그노어
| 대상 | 어디 | 왜 |
|---|---|---|
build/ |
레포 루트 .gitignore:30 |
생성물. patch_stay.py 로 다시 나온다 |
stay-payload-new.json |
scripts/mockup/.gitignore |
중간 산출물 |
이 시연이 끝나면 지울 것
목업은 한시적이다. 실제 구현(1부)이 끝나면 이 폴더는 통째로 필요 없다.
# ① 레포에서
git rm -r solution/site/scripts/mockup
# ② 서버 볼륨에서 (킹서버 · 로컬 각각)
docker exec o2o-web4ai-solution-prerender rm -rf /app/solution/site/out/s/stay
# ③ 킹서버 배포 임시 폴더
ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy"
★ 지우기 전에 1부를 docs/ 로 옮긴다. 1부(제품 구현 지침)는 목업이 사라져도 남아야 하는
내용이다 — 이 폴더째 지우면 같이 없어진다. 2·3부는 목업과 함께 버린다.
stay2 · stay3 도 같은 성격의 목업이다. 함께 정리한다.
★ 지우기 전에 CLAUDE.md 의 ★★ 함정을 읽는다 —
out/s/** 이 참조하는 자산을 먼저 빼야 다른 사이트가 안 끊긴다.
3.4 남아 있는 판단 (사장님 결정 대기)
| 상태 | |
|---|---|
하루에 숙소가 3번 서는 날(아침 → … → 쉼 → 저녁 → 복귀) |
점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 |
| 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 지어내야 한다 — 하지 않는다 |
| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 |
| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 use-rail-autoplay.ts 를 고치고 전체 재굽기 + republish_all.py |
해결(2026-09-11) — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (SCHEDULE.md) |
|
| 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 |