o2o-site-AEO/solution/site/scripts/mockup/SECTIONS.md
Mina Choi ea1daae576 [fix] site/scripts: 시연본을 레포 안에서 자급자족하게 — 다른 기계에서 흰 화면이던 것
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로
가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다.
번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
실측: 구운 html 이 /assets 를 63곳 가리키고 있었다.

- img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다
- patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고,
  레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다
- audit-all.mjs: 히어로 사진 검사를 새 경로로
- SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고
  PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가
- README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다

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

251 lines
14 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.

# 이 시연본을 제품으로 옮기기 — 개발자 작업 지시서
대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산) · 작성 2026-09-11
**이 문서를 읽는 사람**: 이 시연본에 있는 것을 빌더·백엔드에서 **자동으로 만들게** 하려는 사람.
설명서가 아니라 **할 일 목록**이다. 프롬프트 전문은 [PROMPTS.md](PROMPTS.md) 에 있고
여기서는 "어느 절을 쓰라"고만 가리킨다.
---
## 0. 먼저 — 화면부터 띄운다 (3분)
```bash
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 # build/index.html 을 만든다
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 # 34개 항목 전수 점검
```
→ `http://localhost/s/stay`
★ **이 시연본은 레포 안에서 자급자족한다.** 번들 css/js 와 미러 사진 61장까지
`vendor/` · `img/mirror/` 에 들어 있고, `patch_stay.py` 가 경로를 `/s/stay/…` 로 바꿔 박는다.
그래서 그 기계에서 구운 번들 해시와 **무관하게** 뜬다. (2026-09-11 이전 판은 안 그랬다 —
`/assets/…` 를 가리켜서 다른 기계에서는 흰 화면이었다.)
---
## 1. 어떤 파일을 여나
| 파일 | 무엇이 들어 있나 | 언제 연다 |
|---|---|---|
| [PROMPTS.md](PROMPTS.md) | **프롬프트 전문 7종** — 캐치프레이즈 · 장소 대장 · 테마 · 노래 · 인물 · 엽서 | 생성 프롬프트를 쓸 때 |
| [README.md](README.md) | 목업 고치는 절차 · 배포 · 밟으면 조용히 틀리는 자리 | 목업 자체를 손볼 때 |
| [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) | 일정이 왜 틀렸고 무엇을 고쳤나 | 일정 규칙을 옮기기 전에 |
| [TEXT.md](TEXT.md) | 화면에 나가는 텍스트 전문 | 문구 톤을 볼 때 |
| `build_itinerary.py` | 일정 생성기 **원본** — 규칙 14종이 여기 있다 | 일정을 서버로 옮길 때 |
| `build_story.py` | 노래·인물 + 위키 사진 수집 | 인물 사진 경로를 만들 때 |
| `patch_stay.py` | payload 조립 + 캐치프레이즈·자작곡·객실사진 주입 | 목업 데이터를 바꿀 때 |
| `inject.js` / `inject.css` | 렌더러에 **없는 기능**을 목업에 얹은 것 | 그 기능을 렌더러로 옮길 때 |
| `audit-all.mjs` / `rails-test.mjs` | 브라우저 전수 점검 | 언제나. 끝났다고 말하기 전에 |
제품 쪽 짝은 이것들이다.
| 이 목업 | 제품에서 같은 일을 하는 곳 |
|---|---|
| `build_itinerary.py` | `solution/backend/services/itinerary.py` |
| `build_story.py` | `solution/backend/services/story_service.py` |
| PROMPTS.md | `solution/shared/src/lib/section-prompts.ts` (→ `npm run export:prompts`) |
| payload 필드 | `solution/shared/src/types/site-payload.ts` · `shared/src/lib/section-data.ts` |
| 섹션 목록·기본 on/off | `solution/backend/services/site_payload.py:155` 부근 |
---
## 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` | **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**).
---
## 3. 할 일 여섯
### T1 — 히어로 캐치프레이즈
**증상** 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다.
지금 계약(`site-payload.ts:316` `Narrative`)에 문자열 `tagline` 하나뿐이고,
히어로는 `tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
**할 일**
1. `Narrative` 에 필드를 더한다.
```ts
catchphrases?: { version: 1; items: {
text: string; // 18자 안쪽
kind: 'general' | 'season' | 'month' | 'weather';
season?: '봄'|'여름'|'가을'|'겨울'; month?: number; weather?: '맑음'|'흐림'|'비'|'눈';
}[] };
```
2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다.
계절은 3개월 단위(가을 9–11, 겨울 12–2), 날씨는 `local.weather.condition`.
목업 구현은 `inject.js` 의 `startCatchphrase()` — 7초마다 단어 단위 교체.
3. 프롬프트는 **[PROMPTS.md §2](PROMPTS.md)** 전문을 그대로 쓴다. 100개(일반 40·계절 24·월 24·날씨 12).
4. `STORY_KINDS` 에 등록한다. **빠뜨리면 서버 생성 잡이 이 종류를 아예 모른다** —
`daily` 가 그래서 한동안 빈칸이었다.
**완료 기준** 새 업장을 발행했을 때 히어로 문구가 100개 돌고, 9월에 겨울 문구가 안 나온다.
---
### T2 — 추천 일정 ★ 가장 큰 일
**증상** 서버 생성기(`services/itinerary.py`)는 하루를 **관광지 2 + 맛집 2** 로 짠다.
체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가
"둘째 날 해산물축제 → 근대건축관" 으로 끝난다.
**★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 —
이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 **렌더러가 조용히 버리는** 칸을 만든다
(`shared/src/lib/section-data.ts:535`). 시연본 첫 판이 정확히 그랬다.
**세 단계로 나눈다.** 전문은 [PROMPTS.md §3](PROMPTS.md).
| 단계 | 누가 | 무엇을 내놓나 | 전문 |
|---|---|---|---|
| **3-1 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | PROMPTS.md §3-1 |
| **좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — |
| **3-2 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 14종 감사 | PROMPTS.md §3-2 · `build_itinerary.py` |
| **3-3 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | PROMPTS.md §3-3 |
**코드가 하는 일(3-2)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호
| | 무엇 | 어디 |
|---|---|---|
| 이동시간 | 좌표 계산. 도보 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` |
| 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘리고 줄인다. 이동시간을 고치지 않는다 | `plan_day()` |
| 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다 → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` |
| **감사 14종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` |
★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 —
실제로 그렇게 33건이 통과했다([REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 2장).
**완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건.
하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다.
---
### T3 — 인물·노래 사진
**증상** 프롬프트가 사진 URL 을 금지하고 `imageQuery` 만 받는다(`section-prompts.ts:128`).
초상권·저작권 때문이고 그 판단은 맞다. 그런데 **다음 단계가 없어서** 인물 열전이 활자만으로 선다.
**할 일**
1. `services/external/wikimedia.py` 에 **문서 → `pageimages` → `imageinfo.extmetadata`** 경로를 붙인다.
2. ★ **이름으로 커먼즈를 검색하지 않는다.** 동명이인이 온다 —
실측: 이수현→걸그룹, 은성수→축구선수, 박성현→골퍼, 이길여→건물.
**위키백과 문서에 붙은 대표사진만** 믿는다. 구현은 `build_story.py`.
3. 저작자·라이선스를 `imageCredit` 으로 payload 에 넣는다.
★ **문자열이다**(`site/src/sections/items/common.tsx:206`). 객체로 넣으면 렌더러가
조용히 아무것도 안 그린다 — CC BY-SA 는 출처 표시가 **라이선스 조건**이라 그건 침해다.
4. 사진 있는 사람을 **앞으로** 정렬한다. 빈 카드가 먼저 오면 목록이 비어 보인다.
**완료 기준** 인물 카드에 사진과 저작자 줄이 같이 뜬다. 목업 실적은 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곡을 한 번에 시키면 뒤쪽이 급격히 부실해진다.
8개씩 나눠 받고 제목으로 중복을 지운다 — 나누는 방법은 [PROMPTS.md §4](PROMPTS.md).
---
### T6 — 객실 사진 출처
목업은 야놀자 등록본(`nol.yanolja.com/stay/domestic/10068088`)의 Next.js 플라이트 데이터에서
`roomTypes[].photos` 를 파싱해 A동 12·B동 10 을 가져왔다(`rooms.json`).
**손으로 한 것이고 제품 경로가 아니다.**
수집 어댑터를 만들려면 [docs/DECISIONS.md](../../../../docs/DECISIONS.md) 1-1 과
[docs/DATA_SOURCE_RESEARCH.md](../../../../docs/DATA_SOURCE_RESEARCH.md) 를 먼저 읽는다 —
**봇 탐지 우회는 결론과 무관하게 영구 금지**다.
현실적인 경로는 **사장님이 올리는 것**이거나 **업소가 준 채널의 공개 API** 다.
---
## 4. 프롬프트를 새로 쓸 때 지킬 것
`SECTION_PROMPT_RULES`(`section-prompts.ts:55`)가 공통 7줄이고 종류별 규칙을 덧붙인다.
1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 모른다.
2. **`maxItems` 와 프롬프트 안의 숫자를 같게 둔다.** 어긋나면 받아 놓고 잘라 버린다.
3. **빈 값을 지어내지 않는다.** 확실한 게 12개면 12개만 낸다.
4. **출처는 실제로 열리는 페이지**여야 한다. 검색 결과 주소는 안 된다.
5. **가사·시·소설 원문을 한 줄도 옮기지 않는다.**
6. 고친 뒤 **`npm run export:prompts`** 를 돌린다 — 백엔드는 그 산출물을 읽는다.
`backend/services/prompts/section_prompts.json` 을 손으로 고치지 않는다.
---
## 5. 끝났다고 말하기 전에
| | 무엇 | 어떻게 |
|---|---|---|
| 생성 단계 | 규칙 위반이면 **결과물을 쓰지 않고 멈춘다** | 감사를 생성기 **안**에 (`build_itinerary.py:526`) |
| 렌더 단계 | 브라우저로 열어 숫자를 센다 | `node audit-all.mjs` — 34/34 |
| 카로셀 | 레일 13개 × 데스크톱·모바일 | `node rails-test.mjs` |
| 다른 기계 | 레포만 받아 띄워 본다 | §0 을 그대로 실행 |
★ 여기 있는 것 중 **"코드에서 확인했다"로 끝낸 것은 하나도 없다.**
전부 화면에서 숫자를 세서 붙였다. 그렇게 안 해서 틀린 기록이
[README.md §7](README.md) 과 [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 에 있다.