구워진 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>
251 lines
14 KiB
Markdown
251 lines
14 KiB
Markdown
# 이 시연본을 제품으로 옮기기 — 개발자 작업 지시서
|
||
|
||
대상 `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) 에 있다.
|