From 820a2e9e357512b3f0ff4d97b4cb985b1fc57dbb Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 15 Sep 2026 16:23:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(site):=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EB=AC=B8=EA=B5=AC=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=EA=B3=BC=20=EC=A1=B0=EA=B1=B4=EB=B3=84=20=EC=88=9C=ED=99=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다. 날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인. --- docs/WEATHER.md | 17 +++++++++++ solution/backend/services/site_payload.py | 3 +- solution/backend/services/weather_notes.json | 16 ++++++++++ solution/backend/services/weather_notes.py | 21 +++++++++++++ solution/backend/tests/test_weather_notes.py | 24 +++++++++++++++ solution/shared/src/types/site-payload.ts | 2 ++ solution/site/scripts/mockup/README.md | 2 +- solution/site/src/lib/use-weather-note.ts | 28 +++++++++++++++++ solution/site/src/sections/WeatherSection.tsx | 14 +++++---- solution/site/src/sections/weather.test.tsx | 30 +++++++++++++++++++ 10 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 docs/WEATHER.md create mode 100644 solution/backend/services/weather_notes.json create mode 100644 solution/backend/services/weather_notes.py create mode 100644 solution/backend/tests/test_weather_notes.py create mode 100644 solution/site/src/lib/use-weather-note.ts create mode 100644 solution/site/src/sections/weather.test.tsx diff --git a/docs/WEATHER.md b/docs/WEATHER.md new file mode 100644 index 0000000..5b1ad0c --- /dev/null +++ b/docs/WEATHER.md @@ -0,0 +1,17 @@ +# 오늘의 날씨 + +`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection` + +관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과 +관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. + +`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets` + +하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, +브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다. +기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다. + +목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지 +않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.** +지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다. +옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다. diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index b242d32..39acbd6 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -34,6 +34,7 @@ from common.enums import ( from common.logger import LOG from services.intro_summary import summarize_intro from services.stay_guide import nol_stay_guide +from services.weather_notes import weather_notes # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 @@ -525,7 +526,7 @@ def _weather(row: dict): return { "temperature": temperature, "condition": _weather_condition(body.get("weather_code")), - # note 는 LLM 이 쓰는 안내 문구다. 지금 그걸 만드는 경로가 없으므로 넣지 않는다(지어내지 않는다). + **weather_notes(), "observedAt": observed_at, } diff --git a/solution/backend/services/weather_notes.json b/solution/backend/services/weather_notes.json new file mode 100644 index 0000000..d7f474b --- /dev/null +++ b/solution/backend/services/weather_notes.json @@ -0,0 +1,16 @@ +{ + "sky": { + "맑음": ["맑은 날에는 잠깐 쉬어 가며 오늘 담은 사진을 골라 보세요.", "분주했던 걸음을 멈추고 느긋하게 쉬어 가도 좋겠습니다.", "오늘 기억하고 싶은 장면을 한 줄 남겨 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"], + "구름많음": ["구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "여행 사진을 정리하며 잠깐 쉬어 가세요.", "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "오늘의 기분을 짧은 글로 남겨도 좋겠습니다."], + "흐림": ["흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "잠깐 책을 읽으며 다음 일정을 기다려 보세요.", "바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."], + "비": ["비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.", "서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.", "빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.", "젖은 물건을 정리하고 잠깐 쉬어 가세요.", "바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."], + "눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."] + }, + "temperature": { + "혹서": ["더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.", "한낮 산책은 짧게 하고 충분히 쉬어 가세요.", "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.", "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.", "긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."], + "더움": ["바깥 일정 사이에 실내에서 쉬는 시간을 넣어 보세요.", "외출할 때에는 물을 챙기고 무리한 이동은 줄이세요.", "주변을 둘러보더라도 한 번에 오래 걷지는 마세요.", "방문할 곳의 운영 시간을 먼저 확인하고 이동하세요.", "더위가 느껴지면 일정을 줄이고 쉬어 가세요."], + "선선": ["비나 눈이 없다면 주변을 가볍게 둘러보는 건 어떨까요?", "날씨와 이동 경로를 확인하고 짧은 산책을 계획해 보세요.", "바깥 일정을 잡기 전에 방문할 곳의 운영 여부를 확인하세요.", "편한 신발을 챙기고 자신의 속도로 동네를 둘러보세요.", "비가 오지 않는다면 일정 사이에 잠깐 걸어 보세요."], + "쌀쌀": ["외출할 때에는 걸칠 옷을 챙겨 보세요.", "바깥에 오래 머물기보다 쉬어 갈 곳을 함께 정해 두세요.", "일교차에 대비해 옷차림을 조절하세요.", "이동 경로를 짧게 잡고 실내 일정도 함께 살펴보세요.", "돌아오는 시간의 기온도 확인한 뒤 외출하세요."], + "추움": ["따뜻한 옷을 챙기고 바깥 이동은 짧게 잡아 보세요.", "찬 바람을 피할 수 있는 실내 일정을 살펴보세요.", "출발 전 눈이나 결빙으로 이동이 어려운 곳은 없는지 확인하세요.", "야외에 오래 머무르지 않도록 쉬는 시간을 충분히 두세요.", "방문할 곳의 운영 여부를 확인하고 따뜻하게 준비하세요."] + } +} diff --git a/solution/backend/services/weather_notes.py b/solution/backend/services/weather_notes.py new file mode 100644 index 0000000..47cec5f --- /dev/null +++ b/solution/backend/services/weather_notes.py @@ -0,0 +1,21 @@ +"""날씨 안내 계약. 관측값과 분리하고, 확인되지 않은 시설·영업시간은 쓰지 않는다.""" +import json +from functools import lru_cache +from pathlib import Path + + +@lru_cache(maxsize=1) +def _templates() -> dict: + return json.loads(Path(__file__).with_name("weather_notes.json").read_text(encoding="utf-8")) + + +def weather_notes() -> dict: + templates = _templates() + sky = {key: list(lines) for key, lines in templates["sky"].items()} + temperature = {key: list(lines) for key, lines in templates["temperature"].items()} + return { + "notes": {key: lines[0] for key, lines in sky.items()}, + "tempNotes": {key: lines[0] for key, lines in temperature.items()}, + "noteSets": sky, + "tempNoteSets": temperature, + } diff --git a/solution/backend/tests/test_weather_notes.py b/solution/backend/tests/test_weather_notes.py new file mode 100644 index 0000000..a307806 --- /dev/null +++ b/solution/backend/tests/test_weather_notes.py @@ -0,0 +1,24 @@ +from services.weather_notes import weather_notes +from services.site_payload import _weather + + +def test_all_weather_cases_have_five_unique_lines(): + notes = weather_notes() + assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "비", "눈"} + assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"} + lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group] + assert len(lines) == len(set(lines)) == 50 + + +def test_templates_are_not_mutated_by_a_payload(): + notes = weather_notes() + notes["noteSets"]["비"].clear() + assert len(weather_notes()["noteSets"]["비"]) == 5 + + +def test_payload_attaches_notes_only_to_real_observations(): + assert _weather({"body": {}}) is None + weather = _weather({"body": {"temperature": 0, "weather_code": 71, "observed_at": "2026-09-15T12:00"}}) + assert weather["condition"] == "눈" + assert weather["temperature"] == 0 + assert len(weather["tempNoteSets"]["추움"]) == 5 diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 3be52b6..6258710 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -287,6 +287,8 @@ export interface WeatherSnapshot { * ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다. */ notes?: Partial>; + noteSets?: Partial>; + tempNoteSets?: Partial>; /** * 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.** * diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index 59a1cb5..c6cd458 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -1230,6 +1230,6 @@ ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy" | 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** | | ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) | | 렌더러의 가요 다방 판 배치 | 운영 사이트도 `flex-wrap` 이라 곡이 늘면 진열대가 된다(`SongsSection.tsx`). 목업은 CSS 로 덮었다(**2.5** 7). 제품에 옮기려면 **전체 재굽기 + `republish_all.py`** | -| 날씨 문구를 만드는 경로 | 제품에는 없다 — `site_payload.py` `_weather()` 는 기온·하늘·관측시각만 싣는다. `notes`·`tempNotes` 는 이 목업에서 손으로 적은 것이고, 사이트마다 자동으로 나오려면 생성 잡이 하나 필요하다 | +| 날씨 문구를 만드는 경로 | 2026-09-15: 제품에 공통 안내 50개를 읽어 `noteSets`·`tempNoteSets`로 싣고 20초마다 순환하는 경로를 추가했다([WEATHER.md](../../../../docs/WEATHER.md)). 이 목업의 군산 전용 문구는 그대로다. 지역별 장소 추천을 자동 생성하는 잡은 아직 없다 | | 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 | | 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 | diff --git a/solution/site/src/lib/use-weather-note.ts b/solution/site/src/lib/use-weather-note.ts new file mode 100644 index 0000000..e0c5beb --- /dev/null +++ b/solution/site/src/lib/use-weather-note.ts @@ -0,0 +1,28 @@ +import {useEffect, useState} from 'react'; + +/** 첫 렌더는 고정해 SSR과 맞추고, 이후에는 한 바퀴 안에서 중복 없이 돌린다. */ +export function useWeatherNote(lines: string[] | undefined, fallback?: string) { + const [selection, setSelection] = useState<{lines: string[]; index: number}>(); + useEffect(() => { + if (!lines?.length) return; + let remaining: number[] = []; + let previous = -1; + const next = () => { + if (!remaining.length) remaining = lines.map((_, index) => index); + const choices = remaining.filter((index) => index !== previous); + const available = choices.length ? choices : remaining; + const index = available[Math.floor(Math.random() * available.length)]; + remaining = remaining.filter((value) => value !== index); + previous = index; + setSelection({lines, index}); + }; + // effect 안의 동기 setState 대신 첫 타이머에서 무작위 문구로 전환한다. + const first = window.setTimeout(next, 0); + const timer = window.setInterval(next, 20_000); + return () => { + window.clearTimeout(first); + window.clearInterval(timer); + }; + }, [lines]); + return lines?.[selection?.lines === lines ? selection.index : 0] ?? fallback; +} diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 0068efd..7e1010b 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -11,6 +11,7 @@ */ import {useSite} from '@site/lib/site-context'; import {useLiveWeather} from '@site/lib/use-live-weather'; +import {useWeatherNote} from '@site/lib/use-weather-note'; import {weatherBand, weatherMood} from '@site/lib/derive'; import {Card, Section} from '@site/lib/ui'; import './weather.css'; @@ -32,12 +33,12 @@ export function WeatherSection() { longitude: payload.place.longitude, }); - if (!weather) return null; - - const mood = weatherMood(weather.condition); - const band = weatherBand(weather.temperature); + const mood = weatherMood(weather?.condition ?? ''); + const band = weatherBand(weather?.temperature ?? 0); + const notes = payload.local.weather; + const skyKey = weather?.condition === '구름많음' ? '구름많음' : mood; // 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다. - const line = payload.local.weather?.notes?.[mood] ?? weather.note; + const line = useWeatherNote(notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note); /* * ★ 기온 줄 (2026-09-09, 사장님: "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰서") * 하늘 줄은 집 안 이야기(마당·CAFÉ)라 "그래서 오늘 어디를 가지"에는 답하지 않았다. @@ -45,7 +46,8 @@ export function WeatherSection() { * ★ 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 * 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ - const bandLine = payload.local.weather?.tempNotes?.[band]; + const bandLine = useWeatherNote(notes?.tempNoteSets?.[band], notes?.tempNotes?.[band]); + if (!weather) return null; const observed = observedText(weather.observedAt); return ( diff --git a/solution/site/src/sections/weather.test.tsx b/solution/site/src/sections/weather.test.tsx new file mode 100644 index 0000000..01c42c9 --- /dev/null +++ b/solution/site/src/sections/weather.test.tsx @@ -0,0 +1,30 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {SiteProvider} from '@site/lib/site-context'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {WeatherSection} from './WeatherSection'; +import type {WeatherSnapshot} from '@o2o/shared'; + +function render(weather?: WeatherSnapshot) { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.local.weather = weather; + return renderToStaticMarkup(); +} + +it('uses the first sky and temperature lines for deterministic HTML', () => { + const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '구름많음', noteSets: {'구름많음': ['구름 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}}; + expect(render(weather)).toContain('구름 첫 줄'); + expect(render(weather)).toContain('더위 첫 줄'); + expect(render(weather)).not.toContain('다음 줄'); + expect(render(weather)).toBe(render(weather)); +}); + +it('preserves legacy single notes', () => { + const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}}); + expect(html).toContain('기존 비 안내'); + expect(html).toContain('겉옷 안내'); +}); + +it('does not invent weather when an observation is missing', () => { + expect(render()).toBe(''); +});