o2o-site-AEO/solution/site/scripts/mockup/SECTIONS.md
Mina Choi a134805b3f [docs] site/scripts: 섹션 22개별 "목업은 무엇으로 만들었고 제품은 무엇으로 만드나"
빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기려는 사람이,
화면을 보고 역산해야 하는 상태였다. 시연본은 payload 가 없는 목업이라
"화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있는데 그 간격이 어디에
얼마나 있는지 적힌 곳이 없었다.

섹션마다 값이 오는 payload 자리 · 계약 유무 · 프롬프트 위치 · 목업이 손으로 한 것을
적고, 계약에 아예 없는 셋(히어로 캐치프레이즈 · 자작곡 플레이어 · 일정 규칙)은
추가할 타입과 프롬프트 초안까지 넣었다.

- SECTIONS.md 신설: 22개 섹션표 · 새로 만들어야 할 6가지 · 프롬프트 작성 규칙 · 검증
- README.md: 제품으로 옮기는 사람을 SECTIONS.md 로 보낸다. 감사 항목 수 33 → 34 정정

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

280 lines
17 KiB
Markdown
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 섹션별 — 목업은 무엇으로 만들었고, 제품은 무엇으로 만들어야 하나
작성 2026-09-11 · 대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산)
**읽는 사람**: 빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기는 사람.
시연본은 **payload 가 없는 목업**이다 — 프리렌더가 굽지 않고, `index.html` 한 장을
손으로 조립했다(`patch_stay.py`). 그래서 "화면에 있는 것" 과 "제품이 만들 수 있는 것"이
지금 **갈라져 있다.** 이 문서는 섹션마다 그 간격을 적는다.
---
## 0. 한 줄 결론
22개 섹션 중 **16개는 이미 계약과 생성 경로가 있다.** 나머지 6개가 할 일이다:
| | 섹션 | 왜 |
|---|---|---|
| ① | **히어로 캐치프레이즈** | 계약에 없다. `narrative.tagline` 은 **문자열 하나**인데 목업은 100개를 돌린다 |
| ② | **음악 플레이어(자작곡)** | 계약에 없다. `media` 에 오디오 종류가 없다 |
| ③ | **추천 일정** | 계약·생성기 둘 다 있는데 **깊이가 다르다**. 서버판은 하루 4칸(관광 2·끼니 2)이고 체류·이동·입퇴실 개념이 없다 |
| ④ | **인물·노래 사진** | 프롬프트가 `imageQuery` 만 받는다. 실제 사진을 찾아 붙이고 **라이선스를 표시하는** 경로가 없다 |
| ⑤ | **상한(maxItems)** | songs 8·people 10 인데 목업은 25·57 이다 |
| ⑥ | **객실 사진** | 목업은 야놀자 등록본에서 가져왔다. 수집 어댑터 정책([DECISIONS 1-1](../../../../docs/DECISIONS.md))에 걸리는지 결론이 필요하다 |
---
## 1. 값이 화면까지 가는 길
```
수집·생성(백엔드) → area_contents / places / media → site_payload.py → payload JSON
↓
프리렌더(Node) → out/s/<slug>/index.html
↓
window.__SITE_PAYLOAD__ → React 하이드레이션
```
- **읽는 쪽 계약**은 `solution/shared/src/types/site-payload.ts` · `shared/src/lib/section-data.ts` 한 곳이다.
- **프롬프트 계약**은 `shared/src/lib/section-prompts.ts` 한 곳이고,
백엔드는 `npm run export:prompts` 산출물(`backend/services/prompts/section_prompts.json`)을 읽는다.
**파이썬 쪽을 손으로 고치지 않는다.**
- 섹션 목록·기본 on/off 는 업종별로 `services/site_payload.py:155` 부근에 있다.
payload 안에서 섹션이 값을 받는 자리는 셋뿐이다.
| 자리 | 무엇이 들어가나 | 예 |
|---|---|---|
| `theme.sections[].data` | JSON 봉투 문자열(`{kind, version, title, items[]}`) | songs · people · itinerary · event · video · planner |
| 최상단 필드 | 업소가 적은 사실·사진·문답 | `place` `facts` `units` `media` `faqs` `links` `narrative` |
| `local.*` | 지역에서 수집한 것 | `attractions`(12) `restaurants`(8) `festivals`(10) `weather` |
---
## 2. 섹션 22개 — 한눈에
`on` 은 이 목업에서 켜져 있는지다. `story` 안의 6개는 탭이라 최상단은 꺼져 있다.
| # | id | 이름 | on | 값이 오는 자리 | 계약 | 프롬프트 | 목업에서 한 일 |
|---|---|---|---|---|---|---|---|
| 1 | `hero` | 히어로 | ● | `narrative` + 대표 사진 | 있음 | **없음** | 캐치프레이즈 100개를 주입분이 돌림 |
| 2 | `intro` | 소개 | ● | `narrative.about[]` | 있음 | 있음(빌더) | 원본 그대로 |
| 3 | `rooms` | 객실 안내 | ● | `units[]` + `media[]` | 있음 | — | 야놀자 등록본으로 **전면 교체**(A동 12·B동 10) |
| 4 | `event` | 소식 | ● | `data(kind=event)` 3건 | 있음 | — | 원본 그대로 |
| 5 | `info` | 기본 정보 | ● | `facts[]` 12건 | 있음 | — | 맨 아래 가로선만 CSS 로 제거 |
| 6 | `booking` | 실시간 예약 | ○ | — | 있음 | — | 껐다(시연에 결제 흐름 없음) |
| 7 | `video` | 영상 | ● | `data(kind=video)` 3건 | 있음 | — | 원본 그대로 |
| 8 | `photos` | 사진 갤러리 | ● | `media[]` 37장 | 있음 | — | 원본 그대로 |
| 9 | `map` | 오시는 길 | ● | `place` + `routes[]` | 있음 | — | `routes` 비어 있음 |
| 10 | `festival` | 계절별 축제 | ● | `local.festivals` 10건 | 있음 | — | 원본 그대로 |
| 11 | `local` | 지역 정보 | ● | `local.attractions/restaurants` | 있음 | — | 원본 그대로 |
| 12 | `itinerary` | **추천 일정** | ● | `data(kind=itinerary)` 21개 | 있음 | **없음** | **전부 다시 생성**(§4) |
| 13 | `story` | 군산 이야기 | ● | 아래 6탭의 껍데기 | 있음 | — | — |
| 14 | `songs` | 가요 다방 | 탭 | `data(kind=songs)` 25곡 | 있음 | 있음 `section-prompts.ts:67` | 8 → **25곡**으로 늘림 |
| 15 | `people` | 인물 열전 | 탭 | `data(kind=people)` 57명 | 있음 | 있음 `:113` | 10 → **57명** + 사진 18장 |
| 16 | `chronicle` | 시간의 골목 | 탭 | `data(kind=chronicle)` 7건 | 있음 | 있음 `:135` | 원본 그대로 |
| 17 | `postcard` | 오늘의 엽서 | 탭 | `data(kind=postcard)` 4건 | 있음 | 있음 `:160` | 지도 링크만 추가 |
| 18 | `quiz` | 뒤집어 보는 질문 | 탭 | `data(kind=quiz)` 4건 | 있음 | 있음 `:188` | 원본 그대로 |
| 19 | `faq` | 자주 묻는 질문 | ● | `faqs[]` 32건 | 있음 | 있음 | 원본 그대로 |
| 20 | `rules` | 이용 규정 | ● | `facts[]` | 있음 | — | 원본 그대로 |
| 21 | `weather` | 날씨 | ● | `local.weather` | 있음 | — | 애니메이션만 정지 |
| 22 | `planner` | 계절별 추천 하루 | ○ | `data(kind=planner)` 4건 | 있음 | **없음** | 껐다 |
`daily`(오늘의 한 장)는 프롬프트에는 있는데 **이 목업 섹션 목록에 없다.**
새 업장에서 켜려면 섹션 목록에 자리를 먼저 만들어야 한다.
---
## 3. 계약에 없는 것 — 새로 만들어야 하는 셋
### ① 히어로 캐치프레이즈 — 계약을 늘려야 한다
**지금 계약**: `Narrative`(`site-payload.ts:316`)는 `heroHeadline` `heroSubline` `tagline`
`about[]` `summary` 뿐이고 전부 **문자열 하나**다. 히어로는
`narrative.tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
**목업이 한 것**: `narrative.catchphrases = {version:1, items:[…100]}` 를 payload 에
끼워 넣고, 주입분이 7초마다 단어 단위로 갈아 끼운다. **렌더러는 이 필드를 모른다** —
주입분(`inject.js`)이 읽는다. 100개 구성은 `patch_stay.py:20`(일반 40) `:62`(계절 24)
`:72`(월 24) `:87`(날씨 12).
**제품에서 해야 할 일**
```ts
// site-payload.ts — Narrative 에 추가
catchphrases?: {
version: 1;
items: {
text: string; // 18자 안쪽
type: 'general' | 'season' | 'month' | 'weather';
season?: '봄' | '여름' | '가을' | '겨울'; // type=season
month?: number; // 1–12, type=month
weather?: '맑음' | '흐림' | '비' | '눈'; // type=weather
}[];
};
```
고르는 규칙(목업이 쓰는 것 그대로): **지금 계절·이번 달·현재 날씨에 맞는 것만 후보**로
두고 일반과 섞어 돌린다. 계절은 3개월 단위(가을 9–11, 겨울 12–2). 날씨는 `local.weather.condition`.
**새로 쓸 프롬프트** — `SECTION_PROMPTS` 에 `catchphrase` 종류를 더한다.
```
[해야 할 일]
[업소]의 히어로에 돌려 쓸 짧은 문구를 100개 쓴다.
일반 40 · 계절 24(계절마다 6) · 월 24(달마다 2) · 날씨 12(맑음/흐림/비/눈 각 3).
[스키마]
{ "kind":"catchphrase", "version":1, "items":[
{ "text":"문구", "type":"general|season|month|weather",
"season":"봄|여름|가을|겨울", "month":9, "weather":"맑음|흐림|비|눈" } ] }
[이 아이템만의 규칙]
· 18자 안쪽. 두 문장으로 쓰지 않는다.
· 느낌표·이모지를 쓰지 않는다. 광고 문구로 들리면 실패다.
· **업소에서 실제로 일어나는 일**을 쓴다. 지역 자랑이 아니다.
("새소리로 눈이 떠지는 집" ○ / "천년의 역사가 살아 숨쉬는 군산" ×)
· 계절·월·날씨 문구는 그 조건에서만 말이 되어야 한다.
9월 문구가 1월에 나와도 어색하지 않으면 그건 일반 문구다.
· 시설명·가격·전화번호를 넣지 않는다.
· source 는 필요 없다. 사실 주장이 아니라 업소가 하는 말이다.
```
### ② 자작곡 플레이어 — `media` 에 오디오가 없다
**목업**: `narrative.ownSongs = {version:1, items:[…5]}`(`patch_stay.py:109`),
파일은 `/s/stay/audio/*.mp3` 로 **직접 넣었다**. nginx `^~ /s/` 가 Range 206 으로 준다.
플레이어는 주입분이 헤더에 그린다. **프로토타입이라 파일**이고, 제품에서는 미디어 파이프라인에
태워야 한다.
**해야 할 일**: `MediaItem` 에 `kind: 'audio'` 를 넣고(`url` `title` `artist` `durationSec`),
발행 시 다른 미디어와 같이 미러/업로드한다. 렌더러에는 플레이어 컴포넌트가 없다 —
헤더에 붙는 작은 플레이어(아이콘 · 곡명 · 재생/멈춤 · 목록)를 새로 만든다.
자동재생은 **넣지 않는다**(사장님 지시 2026-09-11). 재생 순서는 **랜덤 시작 후 순차**.
### ③ 추천 일정 — 생성기가 있는데 깊이가 다르다
**서버판**(`backend/services/itinerary.py`)은 payload 빌드 때 즉석 계산한다.
하루 뼈대가 **관광지 2 + 맛집 2**, 반경 30km, 최근접 이웃 순서다.
체류시간·이동시간·입퇴실·끼니 시각 개념이 **없다**.
**목업판**(`build_itinerary.py`)이 더한 것:
| | 목업이 넣은 것 | 어디 |
|---|---|---|
| 장소별 체류 | (최소, 기본, 최대) 분 — '오후' 칸이면 뭐든 90분이던 것을 장소마다 | `:40` |
| 이동시간 | 좌표에서 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분 | `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` |
| 테마 | 21개. 테마마다 시작 시각과 체류 배율이 다르다(끝나는 시각이 13:35~21:00 로 퍼진다) | `:368` `:470` |
| 제약 | 비 와도 되는 테마는 실내만 · 차 없는 테마는 도보권만 · 아이 동반에 술집 금지 | `:519` |
| **감사** | 규칙 14종. 하나라도 어기면 **payload 를 쓰지 않고 빌드가 멈춘다** | `:526` |
**해야 할 일**: 이 규칙을 `services/itinerary.py` 로 옮긴다. 재료가 모자라면 지어내지 않는
원칙은 그대로다 — **감사에 걸리면 그 테마를 빼고, 하루도 못 채우면 섹션을 안 낸다.**
장소별 체류시간이 관건이다. 지금 `local_contents` 에는 그 값이 없다.
LLM 에 물어 채우는 게 현실적이다 — 새 프롬프트:
```
[해야 할 일]
아래 장소들에 손님이 실제로 머무는 시간을 분으로 적고,
거기서 "무엇을 하는지" 한 문장으로 쓴다.
[스키마]
{ "kind":"stopmeta", "version":1, "items":[
{ "name":"장소명", "minMinutes":25, "baseMinutes":35, "maxMinutes":50,
"indoor":true, "needsCar":false, "kidFriendly":true,
"todo":"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다." } ] }
[이 아이템만의 규칙]
· todo 는 **거기서 하는 일**이다. "무엇인지" 설명하지 않는다.
("안에 들어가 사진관 세트에서 한 장 찍습니다" ○ / "1950년대 사진관입니다" ×)
· 한 바퀴 도는 데 15분이면 15분이라고 적는다. 30분으로 올리지 않는다.
· indoor 는 비가 와도 갈 수 있느냐다. 지붕이 있어도 가는 길이 야외면 false.
· needsCar 는 숙소에서 걸어서 20분을 넘느냐다.
```
### ④ 인물·노래 사진 — `imageQuery` 다음이 없다
프롬프트는 **사진 URL 을 금지**하고 `imageQuery` 만 받는다(`section-prompts.ts:128`).
초상권·저작권 때문이고 그 판단은 맞다. 그런데 **그 다음 단계가 없어서** 인물 열전이
활자만으로 선다.
목업이 한 것(`build_story.py`): 위키백과 **문서의 `pageimages`** 만 믿는다.
이름으로 커먼즈를 검색하면 동명이인이 온다(실측: 이수현→걸그룹, 박성현→골퍼, 이길여→건물).
57명 중 **18명**만 사진을 얻었고, 얻은 것은 `imageinfo.extmetadata` 에서 저작자·라이선스를
받아 **화면에 표시한다** — CC BY-SA 의 조건이라 안 쓰면 위반이다.
**해야 할 일**: `services/external/wikimedia.py` 에 "문서 → pageimages → 라이선스" 경로를 붙이고,
`imageCredit`(문자열)을 payload 에 넣는다. ★ **문자열이다** — 객체로 넣으면 렌더러가
조용히 아무것도 안 그린다(`site/src/sections/items/common.tsx:206`).
사진이 붙은 사람을 **앞으로** 정렬한다(빈 카드가 먼저 오면 목록이 비어 보인다).
### ⑤ 상한 — `maxItems` 를 올려야 한다
| 종류 | 지금 상한 | 목업 | 왜 |
|---|---|---|---|
| songs | 8 | 25 | 도시 하나에 그만큼 있다. 8곡이면 '다방'이 아니라 목록이다 |
| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 |
| chronicle | 14 | 7 | 상한은 맞는데 **채우는 쪽이 모자라다** |
| postcard | 12 | 4 | 같다 |
상한을 올릴 때 **한 번에 다 받지 않는다.** 목업은 곡을 8개씩 세 번 나눠 받았다 —
한 번에 25곡을 시키면 뒤쪽이 급격히 부실해진다. 나눠 받고 합칠 때 제목으로 중복을 지운다.
프롬프트 분할 방법은 [PROMPTS.md](PROMPTS.md) 에 있다.
### ⑥ 객실 사진 — 출처 정책 결론이 필요하다
목업은 야놀자 등록본(`nol.yanolja.com/stay/domestic/10068088`)의 Next.js 플라이트 데이터에서
`roomTypes[].photos` 를 파싱해 A동 12·B동 10 을 가져왔다(`rooms.json`).
**이건 시연용으로 손으로 한 것이고 제품 경로가 아니다.**
수집 어댑터를 만들려면 [DECISIONS 1-1](../../../../docs/DECISIONS.md) 과
[DATA_SOURCE_RESEARCH.md](../../../../docs/DATA_SOURCE_RESEARCH.md) 를 먼저 읽는다 —
**봇 탐지 우회는 결론과 무관하게 영구 금지**다.
현실적인 제품 경로는 **사장님이 올리는 것**이거나 **업소가 준 채널의 공개 API** 다.
---
## 4. 프롬프트를 새로 쓸 때 — 이 레포의 규칙
`SECTION_PROMPT_RULES`(`section-prompts.ts:55`)가 공통 7줄이고, 종류별 규칙을 덧붙인다.
새 종류를 더할 때 지켜야 하는 것:
1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 아예 모른다 —
`daily` 가 그래서 한동안 빈칸이었다(주석에 실측이 적혀 있다).
2. **`maxItems` 와 프롬프트 안의 숫자를 같게 둔다.** 어긋나면 받아 놓고 잘라 버린다.
3. **빈 값을 지어내지 않는다**(공통 2번). 확실한 게 12개면 12개만 낸다.
4. **출처는 실제로 열리는 페이지**여야 한다(공통 3번). 검색 결과 주소는 안 된다.
5. **가사·시·소설 원문을 한 줄도 옮기지 않는다**(공통 5번).
6. `[지역]` 같은 빈칸을 남기지 않는다 — `buildSectionPrompt` 가 채워서 내보낸다.
7. 고친 뒤 **`npm run export:prompts`** 를 돌린다. 백엔드는 그 산출물을 읽는다.
---
## 5. 검증 — "됐다"고 말하기 전에
목업에서 쓰는 방식 그대로 제품에도 필요하다.
| | 무엇 | 어디 |
|---|---|---|
| 생성 단계 | 규칙 위반이면 **결과물을 쓰지 않고 멈춘다**. 검사를 생성기 **안**에 둔다 | `build_itinerary.py:526` |
| 렌더 단계 | 실제 브라우저로 열어 숫자를 센다. 34종 | `audit-all.mjs` |
| 카로셀 | 레일 13개 × 데스크톱·모바일 | `rails-test.mjs` |
★ 목업에서 같은 실수가 반복된 원인은 "검사를 안 했다"가 아니라 **"검사가 빌드 밖에 있었다"** 였다.
그때그때 쓴 일회용 스크립트로 돌리니, 뼈대를 고칠 때마다 이번에 안 본 규칙이 생겼다.
경위는 [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 2장.
---
## 6. 같이 읽을 것
| | |
|---|---|
| 목업을 고치고 배포하는 절차 · 밟으면 조용히 틀리는 자리 | [README.md](README.md) |
| 실제로 쓴 프롬프트 전문과 나눠 받는 법 | [PROMPTS.md](PROMPTS.md) |
| 화면에 나가는 텍스트 전문 | [TEXT.md](TEXT.md) |
| 일정이 왜 틀렸고 무엇을 고쳤나 | [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) |
| 값이 DB 에서 페이지까지 가는 길 | [docs/DATA_MODEL.md](../../../../docs/DATA_MODEL.md) |
| 미결 사항 · 수집 어댑터 정책 | [docs/DECISIONS.md](../../../../docs/DECISIONS.md) |