diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index 601c42f..1165d06 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -8,9 +8,13 @@ > 고치는 것은 구워진 `index.html` 하나이고, 그 파일이 유일본이다. > 2. **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어 > 전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다. -> 3. 무엇을 고치든 **`node audit-all.mjs` 가 33개 항목 전부 통과**해야 끝난 것이다. +> 3. 무엇을 고치든 **`node audit-all.mjs` 가 34개 항목 전부 통과**해야 끝난 것이다. > "코드에서 빠졌으니 됐다" 로 끝내면 안 된다 — 실제로 그렇게 틀린 적이 있다(§7). +> **빌더·백엔드에서 이걸 제품으로 옮기는 사람은 [SECTIONS.md](SECTIONS.md) 부터 읽는다.** +> 섹션 22개마다 "목업은 무엇으로 만들었고, 제품은 무엇으로 만들어야 하나" 를 적었다 — +> 계약(payload 필드) · 프롬프트 · 아직 없는 것 셋(캐치프레이즈 · 자작곡 · 일정 규칙). + --- ## 0. 먼저 알아야 할 것 — 왜 스크립트인가 @@ -56,7 +60,7 @@ docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/ 검사: ```bash -node audit-all.mjs # 33개 항목 전수 점검 (로컬) +node audit-all.mjs # 34개 항목 전수 점검 (로컬) node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버 ``` @@ -239,7 +243,7 @@ node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버 **검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.** `audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** -33개 항목을 센다(캐치프레이즈 개수, 링크 개수, 사진 개수, 모바일 레이아웃, 404, 콘솔 오류…). +34개 항목을 센다(캐치프레이즈 개수, 링크 개수, 사진 개수, 모바일 레이아웃, 404, 콘솔 오류…). --- @@ -327,7 +331,7 @@ for f in audio/*.mp3; do docker cp \"\$f\" \$C:/app/solution/site/out/s/sta | `build_story.py` | 가요 다방 · 인물 열전 | | `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 | | `inject.css` · `inject.js` | 화면에 덧대는 것(플레이어 · 문구 순환 · 링크 · 크레딧) | -| `audit-all.mjs` | 브라우저로 33개 항목 전수 점검 | +| `audit-all.mjs` | 브라우저로 34개 항목 전수 점검 | | `verify.mjs` · `player.mjs` · `check2.mjs` · `kingcheck.mjs` · `rail.mjs` · `arrow.mjs` | 부분 점검 | | `stay-payload.json` · `orig/index.html` | **원본. 건드리지 않는다** | | `stay-payload-new.json` | 중간 산출물 | diff --git a/solution/site/scripts/mockup/SECTIONS.md b/solution/site/scripts/mockup/SECTIONS.md new file mode 100644 index 0000000..30d5223 --- /dev/null +++ b/solution/site/scripts/mockup/SECTIONS.md @@ -0,0 +1,279 @@ +# /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//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) |