From c4af53613e723ef74c396d90f3a780071db83303 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 9 Sep 2026 17:08:31 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution,postgres-init:=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=9D=B4=EC=95=BC=EA=B8=B0=EB=A5=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EA=B0=80=20=EC=B1=84=EC=9A=B4=EB=8B=A4=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=EA=B3=BC=20=EA=B0=9C=EC=9D=B8=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. `/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구 템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0. **생성** - Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다 (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이 기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다). - 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라 우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에 한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다. - 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋). - 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서 사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다. **자리 가르기** area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로. site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집). - `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 — 같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다. - 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지 배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은 읽는 쪽이 갖는다. - ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중 **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면 UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다. - 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절. 검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에 songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12. Co-Authored-By: Claude Opus 5 (1M context) --- docs/DECISIONS.md | 47 ++++ docs/DEVLOG.md | 23 ++ package.json | 3 +- postgres-init/init-data/init.sql | 5 +- .../migrations/0007_story_rows_per_kind.sql | 18 ++ .../0008_personalization_to_site_sections.sql | 81 ++++++ solution/backend/common/enums.py | 23 ++ solution/backend/crud/local_content_crud.py | 37 +++ solution/backend/crud/site_section_crud.py | 50 ++++ solution/backend/services/collect_service.py | 7 + .../backend/services/external/tour_api.py | 76 ++++-- solution/backend/services/grounding/story.py | 118 +++++++++ solution/backend/services/itinerary.py | 10 +- .../backend/services/local_content_service.py | 96 ++++++- .../services/prompts/section_prompts.json | 41 +++ solution/backend/services/prompts/story.py | 61 +++++ solution/backend/services/site_payload.py | 54 ++-- solution/backend/services/snapshot.py | 141 ++++++++--- solution/backend/services/story_service.py | 237 ++++++++++++++++++ .../backend/tests/test_story_generation.py | 144 +++++++++++ solution/backend/worker/handlers.py | 5 +- .../src/features/builder/canvas/dataSpec.ts | 100 +------- solution/shared/scripts/export-prompts.mjs | 64 +++++ solution/shared/src/index.ts | 1 + solution/shared/src/lib/section-prompts.ts | 205 +++++++++++++++ solution/shared/src/types/site-payload.ts | 28 ++- solution/site/src/lib/derive.ts | 17 +- 27 files changed, 1502 insertions(+), 190 deletions(-) create mode 100644 postgres-init/migrations/0007_story_rows_per_kind.sql create mode 100644 postgres-init/migrations/0008_personalization_to_site_sections.sql create mode 100644 solution/backend/crud/site_section_crud.py create mode 100644 solution/backend/services/grounding/story.py create mode 100644 solution/backend/services/prompts/section_prompts.json create mode 100644 solution/backend/services/prompts/story.py create mode 100644 solution/backend/services/story_service.py create mode 100644 solution/backend/tests/test_story_generation.py create mode 100644 solution/shared/scripts/export-prompts.mjs create mode 100644 solution/shared/src/lib/section-prompts.ts diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 37e9033..4888ded 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -195,3 +195,50 @@ **업로드·저장 경로가 없다.** 이미지 재게시 권리(1-2)가 미결이라 Azure Blob 클라이언트를 일부러 아직 이식하지 않았다. - 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다. + +## 6. 지역 이야기 생성 (2026-09-09) + +가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. +`/s/stay` 시안이 이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 같은 템플릿을 +골라도 그 자리가 비었다. 그래서 서버가 채운다. + +### 6-1. 키는 업장이 아니라 지역이다 + +이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다. +`place_id` 를 키로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다 — +`area_contents` 가 `region_code` 를 키로 두는 것과 같은 이유이고, 그 표를 그대로 쓴다. + +→ **사이트별 `sections[].data` 로 복사하지 않는다.** payload 에서는 `local.story` 로 따로 싣고, + 화면이 **읽는 순간에만** 사장님이 붙여넣은 것과 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`). + 복사해 두면 지역 하나를 고칠 때 사이트 수만큼 고쳐야 한다. + +### 6-2. 검수 게이트를 두지 않는다 + +생성분은 `PUBLISHED` 로 저장해 **바로 발행본에 나간다.** 공공데이터(맛집·관광지)를 검수 없이 +싣는 2026-09-03 결정과 같은 규약이다. + +- 대신 항목마다 `verified`(확인 · 확인필요)와 `source`(열리는 URL)가 실린다. 출처가 없는 항목은 + 저장 단계에서 버리고, 항목 자신의 출처가 없어 검색 출처로 때운 항목은 `확인` 이라고 우겨도 + `확인필요` 로 내린다(`grounding/story.py`). +- 틀린 항목은 **사장님이 에디터에서 뺀다.** 별도 운영자 검수 화면을 만들지 않는다. + +이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라 +공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는 +종류가 아니다. **fact·사진·FAQ 에는 이 예외를 넓히지 않는다.** + +### 6-3. 프롬프트는 한 벌이다 + +사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다. +단일 출처는 `solution/shared/src/lib/section-prompts.ts` 이고, +`npm run export:prompts` 가 `solution/backend/services/prompts/section_prompts.json` 으로 뽑는다(커밋). +백엔드 컨테이너에 node 를 넣지 않으려고 산출물을 커밋한다 — `scripts/export_openapi.py` 의 반대 방향이다. + +### 6-4. Perplexity 한 곳이다 + +이 값들은 **출처가 붙어야** 쓸 수 있다. Gemini 는 검색을 안 해서 URL 을 지어내고, +Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. 구조는 프롬프트의 `[스키마]` 블록이 +잡고 파이썬은 항목 모양을 다시 적지 않는다 — 적으면 프론트가 필드를 하나 늘린 날 서버가 +그걸 조용히 떨어뜨린다. + +**종류당 1회, 지역당 1세트.** 다섯을 한 프롬프트에 넣으면 출력이 잘리고, 한 종이 실패하면 +전부 다시 돌고, 검색 출처가 어느 항목 것인지 섞인다. 항목당 1회는 반대로 낭비다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 88df81a..776bb61 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,29 @@ --- +## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈) + +**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. +`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구 +템플릿을 골라도 그 자리가 비었다. 이제 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다. + +- **키는 지역이다.** `area_contents`(region_code × kind) 에 종류당 한 행, `body.items` 에 항목들. + 사이트별 `sections[].data` 로 복사하지 않는다 — 화면이 읽는 순간에만 사장님이 붙여넣은 것과 + 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`). +- **Perplexity 종류당 1회.** 출처(`search_results`)가 함께 오는 유일한 통로다. 항목에 출처가 + 없으면 버리고, 검색 출처로 때운 항목은 `확인` 이라 우겨도 `확인필요` 로 내린다. +- **프롬프트는 한 벌.** 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 그대로 쓴다 — + `shared/lib/section-prompts.ts` 가 단일 출처, `npm run export:prompts` 로 백엔드용 JSON 을 뽑는다. +- **트리거는 cache-aside.** 에디터 캔버스가 주변 정보를 처음 부를 때 지역 이야기 생성 잡 + (`JobType.LOCAL_SYNC`, 선언만 있고 미배선이던 것)을 하나 넣는다. `dedupe_key = story:{region_code}` + 라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다. +- 검수 게이트는 두지 않는다 — 결론과 근거는 [DECISIONS.md 6절](DECISIONS.md). + +**검증** — `tsc -b` 통과 · 지역 이야기 단위 테스트 12건 통과. +⚠️ 이 레포의 pytest 전체는 이 브랜치 이전부터 **로컬 Postgres 인증 실패로 569건 전부 error** 다 +(`password authentication failed for user "postgres"`). 새 테스트는 DB 를 안 쓰는데 세션 픽스처가 +DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다. + ## 2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음) **무슨 일** — 예약 흐름을 화면으로 보기 위해 `StayBookingDemo` 를 예약 안내 섹션 안에 넣었다. diff --git a/package.json b/package.json index 212f836..e626ded 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "prerender": "npm run prerender -w @o2o/site", "lint": "npm run lint -w @o2o/frontend && npm run lint -w @o2o/admin && npm run lint -w @o2o/site", "orval": "npm run orval -w @o2o/frontend", - "clean": "rm -rf solution/frontend/dist admin/frontend/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite" + "clean": "rm -rf solution/frontend/dist admin/frontend/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite", + "export:prompts": "node solution/shared/scripts/export-prompts.mjs" }, "engines": { "node": ">=20" diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 894870a..8a258a7 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -478,8 +478,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents -- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_keyed ON local.local_contents (region_code, content_type, external_id) WHERE deleted = FALSE AND external_id IS NOT NULL; +-- ★ kind 가 있는 행(지역 이야기 다섯 종)은 이 인덱스에서 뺀다 — 그 다섯은 external_id 가 없어 +-- (region_code, content_type) 하나를 두고 서로 부딪친다. 이야기의 유일성은 +-- uq_local_contents_kind (region_code, kind) 가 책임진다(migrations/0004·0007). CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single ON local.local_contents (region_code, content_type) - WHERE deleted = FALSE AND external_id IS NULL; + WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL; -- site CREATE INDEX IF NOT EXISTS idx_site_versions_site ON site.site_versions (site_id); diff --git a/postgres-init/migrations/0007_story_rows_per_kind.sql b/postgres-init/migrations/0007_story_rows_per_kind.sql new file mode 100644 index 0000000..d242a60 --- /dev/null +++ b/postgres-init/migrations/0007_story_rows_per_kind.sql @@ -0,0 +1,18 @@ +-- 0007 · 지역 이야기 다섯 종이 한 지역에 나란히 설 수 있게 한다 +-- +-- `uq_local_contents_single (region_code, content_type) WHERE external_id IS NULL` 은 +-- **날씨**를 위해 만든 인덱스다 — 날씨는 출처 id 가 없고 지역당 한 행이면 된다. +-- 그런데 지역 이야기도 external_id 가 없다(유일성의 근거가 `kind` 다). 그래서 같은 지역의 +-- 가요·인물·연표·엽서·퀴즈 다섯이 `(region_code, content_type=6)` 하나를 두고 부딪친다 — +-- 실측(2026-09-09, 52군산시): 생성은 54건 다 됐는데 저장은 첫 종류만 들어가고 나머지 넷이 +-- unique 위반으로 떨어졌다. 잡은 "성공"으로 끝나고 화면만 비어 있다 — 조용히 틀리는 종류다. +-- +-- 고치는 방향: `single` 은 **kind 가 없는 행**(=날씨)에만 건다. 이야기의 유일성은 +-- 0004 가 만든 `uq_local_contents_kind (region_code, kind)` 가 이미 책임진다. +-- 인덱스 둘이 같은 행을 두고 다투지 않게, 각자 자기 몫만 보게 가른다. + +DROP INDEX IF EXISTS public.uq_local_contents_single; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single + ON public.area_contents (region_code, content_type) + WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL; diff --git a/postgres-init/migrations/0008_personalization_to_site_sections.sql b/postgres-init/migrations/0008_personalization_to_site_sections.sql new file mode 100644 index 0000000..6b5cfdb --- /dev/null +++ b/postgres-init/migrations/0008_personalization_to_site_sections.sql @@ -0,0 +1,81 @@ +-- 0008 · 공용과 개인화를 이름으로 가른다 +-- +-- 규칙 두 줄로 정리했다(2026-09-09): +-- area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → **렌더러 모양 그대로** 담는다. +-- site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·사장님 편집). +-- +-- 지금은 셋이 어긋나 있다: +-- 1) area_contents.body 가 TourAPI **원문 이름**(mapx·addr1·firstimage)이다. 렌더러가 읽는 +-- 이름(name·location·imageUrl)으로 매번 빌드에서 바꿔 왔다 — 저장할 때 바꾸면 될 일이다. +-- 2) 거리·숨김이 place_area_refs 에 있다. 그건 **사이트마다 다른 값**이라 개인화 쪽이다. +-- 3) area_contents.kind 가 지역 이야기에만 있고 장소류는 NULL 이라, 개인화 행이 무엇을 +-- 가리키는지 이름으로 알 수 없었다. + +-- ── 1. 타입명을 모든 행에 채운다 ────────────────────────────────────── +-- ★ 인덱스를 **먼저** 뗀다. 옛 uq_local_contents_kind 는 (region_code, kind) 라 같은 지역의 +-- 관광지 여러 건이 전부 kind='attraction' 이 되는 순간 부딪친다 — 실측(2026-09-09)에서 +-- `(gunsan, festival) already exists` 로 이 UPDATE 가 통째로 막혔다. +-- 지역 이야기는 "지역 × 종류 한 벌"이 맞고, 장소류의 유일성은 uq_local_contents_external +-- (source, external_id) 이 잡는다. 그래서 조건에 external_id IS NULL 을 더해 둘을 가른다. +DROP INDEX IF EXISTS public.uq_local_contents_kind; + +UPDATE public.area_contents SET kind = CASE content_type + WHEN 1 THEN 'weather' WHEN 2 THEN 'festival' WHEN 3 THEN 'attraction' + WHEN 4 THEN 'restaurant' WHEN 5 THEN 'course' END, + updated_at = now() + WHERE deleted = FALSE AND kind IS NULL AND content_type BETWEEN 1 AND 5; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind + ON public.area_contents (region_code, kind) + WHERE deleted = FALSE AND kind IS NOT NULL AND external_id IS NULL; + +-- ── 2. body 를 렌더러 모양으로 ──────────────────────────────────────── +-- ★ 좌표는 body 에서 뺀다 — latitude/longitude 컬럼이 이미 그 자리다(0004). 일정 조립이 +-- body.mapx 를 읽던 것을 컬럼으로 돌린다. 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다. +-- ★ lclsSystm2 만 남긴다. 렌더러는 안 쓰지만 서버가 쓴다(주변 맛집에서 같은 업태 제외). +-- ★ distance_m 은 여기서 사라진다. 사이트마다 다른 값이라 아래 3번으로 간다. +UPDATE public.area_contents SET body = ( + jsonb_strip_nulls(jsonb_build_object( + 'name', COALESCE(NULLIF(body->>'title', ''), title), + 'searchQuery', COALESCE(NULLIF(body->>'title', ''), title), + 'location', NULLIF(body->>'addr1', ''), + 'imageUrl', NULLIF(body->>'firstimage', ''), + 'lclsSystm2', NULLIF(body->>'lclsSystm2', '') + )) + -- 축제는 기간·홈페이지가 더 붙는다. 화면의 배지(month)·기간 문자열은 읽을 때 만든다 — + -- 날짜 원값을 남겨 둬야 노출 기간 필터와 어긋나지 않는다. + || CASE WHEN content_type = 2 THEN jsonb_strip_nulls(jsonb_build_object( + 'eventstartdate', NULLIF(body->>'eventstartdate', ''), + 'eventenddate', NULLIF(body->>'eventenddate', ''), + 'homepage', NULLIF(body->>'homepage', ''), + 'overview', NULLIF(body->>'overview', '') + )) ELSE '{}'::jsonb END + ), updated_at = now() + WHERE deleted = FALSE AND content_type BETWEEN 2 AND 5 AND body ? 'contentid'; + +-- ── 3. 거리·숨김을 사이트 쪽으로 옮긴다 ─────────────────────────────── +-- 사이트가 없는 업장(수집만 하고 발행 안 한 곳)은 옮길 자리가 없다 — 그때는 다음 수집이 +-- 사이트를 만들며 다시 쓴다. 여기서 sites 행을 만들지 않는다(발행 정책은 build_service 것이다). +-- ★ `items` 배열이 아니라 **ref → 값 맵**이다. items 는 화면에 순서대로 서는 항목들의 모양이고 +-- (songs·people·… 이 그 모양이다), 여기 담기는 건 "공용 항목 하나에 이 사이트가 덧붙인 값" +-- 조회표다. 배열로 두면 읽을 때마다 훑어야 하고 ref 가 항목마다 한 번 더 들어간다. +-- 순서도 여기서 정하지 않는다 — 정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다. +INSERT INTO public.site_sections (site_id, section_id, data, source_type, status, sort_order) +SELECT s.site_id, + 'local', + jsonb_build_object('kind', 'local', 'places', COALESCE(jsonb_object_agg( + r.local_content_id::text, + jsonb_build_object('kind', a.kind, 'distanceMeters', r.distance_m, 'hidden', r.hidden) + ), '{}'::jsonb)), + 2, -- SourceType.API — 사람이 쓴 게 아니라 수집이 만든 개인화 값이다 + 1, + 0 + FROM public.place_area_refs r + JOIN public.area_contents a ON a.local_content_id = r.local_content_id AND a.deleted = FALSE + JOIN public.sites s ON s.place_id = r.place_id AND s.deleted = FALSE + WHERE r.deleted = FALSE + GROUP BY s.site_id +ON CONFLICT DO NOTHING; + +-- place_area_refs 는 아직 지우지 않는다. 읽는 코드가 옮겨 간 것을 확인한 뒤 별도 번호로 뗀다 — +-- 같은 마이그레이션에서 옮기고 지우면, 이관이 틀렸을 때 되돌릴 원본이 없다. diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index bc24e18..601a1fb 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -297,6 +297,26 @@ class LocalContentType(CodeEnum): ATTRACTION = 3 # 관광지 (12) RESTAURANT = 4 # 음식점 (39) COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 + # ★ 지역 이야기(가요·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 — + # 군산 이야기는 군산 숙소가 같이 쓴다. 다섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는, + # 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다. + STORY = 6 + + +# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 — +# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다. +# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고, +# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다. +AREA_KIND = { + LocalContentType.WEATHER.value: "weather", + LocalContentType.FESTIVAL.value: "festival", + LocalContentType.ATTRACTION.value: "attraction", + LocalContentType.RESTAURANT.value: "restaurant", + LocalContentType.COURSE.value: "course", +} + +# 지역 이야기 다섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다. +STORY_KINDS = ("songs", "people", "chronicle", "postcard", "quiz") class LocalSource(CodeEnum): @@ -306,6 +326,9 @@ class LocalSource(CodeEnum): TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다 KAKAO_LOCAL = 3 OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록 + # ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 — + # 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다. + LLM = 5 class LocalContentStatus(CodeEnum): diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index c65c3ad..9d06460 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -76,6 +76,43 @@ class LocalContentCRUD: ) return await DB_SESSION_MNG.add(db, stmt) + async def upsert_kind(self, db, values: dict): + """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. + + ★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다. + 이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라 + `body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기 + 위해서다. 다시 생성하면 그 한 행을 덮어쓴다. + ★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도 + 걸려, 종류가 다른 두 행이 같은 키로 충돌한다.""" + stmt = pg_insert(area_contents).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=[area_contents.region_code, area_contents.kind], + index_where=and_(area_contents.deleted == False, area_contents.kind.isnot(None)), # noqa: E712 + set_={ + "title": stmt.excluded.title, + "body": stmt.excluded.body, + "content_type": stmt.excluded.content_type, + "source": stmt.excluded.source, + "status": stmt.excluded.status, + "collected_at": stmt.excluded.collected_at, + "published_at": stmt.excluded.published_at, + "updated_at": GTime.UTC(), + }, + ) + return await DB_SESSION_MNG.add(db, stmt) + + async def list_kinds(self, db, region_code: str): + """지역의 이야기 행 전부(종류당 1행). cache-aside 판단에 쓴다.""" + return await DB_SESSION_MNG.execute( + db, + select(area_contents).where( + area_contents.region_code == region_code, + area_contents.kind.isnot(None), + area_contents.deleted == False, # noqa: E712 + ), + ) + async def get_weather(self, db, region_code: str): err, rows = await DB_SESSION_MNG.execute( db, diff --git a/solution/backend/crud/site_section_crud.py b/solution/backend/crud/site_section_crud.py new file mode 100644 index 0000000..997d5e7 --- /dev/null +++ b/solution/backend/crud/site_section_crud.py @@ -0,0 +1,50 @@ +"""site_sections — **개인화 데이터**의 단일 자리. + +★ 규칙(2026-09-09) + area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로. + site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집. + +★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로 + 들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로 + 세우면서 CRUD 를 붙인다. + +★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다. +""" +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import site_sections +from common.utils.gtime import GTime + + +class SiteSectionCRUD: + async def list_by_site(self, db, site_id): + return await DB_SESSION_MNG.execute( + db, + select(site_sections).where( + site_sections.site_id == site_id, + site_sections.deleted == False, # noqa: E712 + ).order_by(site_sections.sort_order.asc()), + ) + + async def upsert(self, db, values: dict): + """섹션 하나의 개인화 값을 넣거나 갱신한다. + + ★ `uq_site_contents_section (site_id, section_id) WHERE deleted = false` 에 태운다. + ★ source_type 은 갱신하지 않는다 — 사장님이 손으로 고친 섹션(OWNER)을 수집이 + API 값으로 되돌리면, 고쳐 둔 것이 다음 수집에 조용히 사라진다. + """ + stmt = pg_insert(site_sections).values(**values) + return await DB_SESSION_MNG.add( + db, + stmt.on_conflict_do_update( + index_elements=[site_sections.site_id, site_sections.section_id], + index_where=(site_sections.deleted == False), # noqa: E712 + set_={ + "data": stmt.excluded.data, + "shared_ref": stmt.excluded.shared_ref, + "updated_at": GTime.UTC(), + }, + ), + ) diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index c765d84..c837146 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -545,6 +545,13 @@ async def run_collect(job: dict) -> dict: if result["media"]["stored"] > 0: result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id) + # ★ 지역 데이터(주변 맛집·관광지·축제 + 지역 이야기)를 **여기서** 건다. + # 수집이 끝난 시점이 좌표·행정구역이 확정되는 가장 이른 자리다. 사장님이 템플릿을 고르는 + # 동안(Step4) 백그라운드로 돌아, 생성 단계(Step5)에 닿을 즈음이면 대개 끝나 있다 — + # 전에는 에디터에 들어간 뒤에야 시작해서 첫 화면이 늘 절반만 그려졌다. + from services import story_service + result["local_job_id"] = await story_service.enqueue_region_job(place) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") return result diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index e3cc4e2..895577f 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -119,7 +119,15 @@ def _int(value) -> Optional[int]: def _normalize(item: dict) -> Optional[dict]: - """locationBasedList2 항목 1건 → place_contents.body. contentid·title·거리·종류 없으면 버린다.""" + """locationBasedList2 항목 1건 → 정규화 dict. contentid·title·거리·종류 없으면 버린다. + + ★ 여기서 **렌더러가 읽는 이름**으로 바꾼다(`name`·`location`·`imageUrl`). 예전에는 TourAPI + 원문 이름(`title`·`addr1`·`firstimage`)을 그대로 저장하고 빌드마다 바꿔 실었다 — + 같은 변환을 발행할 때마다 다시 하는 셈이었고, 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다. + ★ 저장 자리가 갈리는 값은 여기서 **평평하게** 내보내기만 한다. 어느 컬럼·어느 테이블로 + 가는지는 부르는 쪽(local_content_service.sync_place)이 정한다: + distance_m → 사이트 개인화(site_sections) 좌표 → area_contents 컬럼 + """ content_id = str(item.get("contentid") or "").strip() title = str(item.get("title") or "").strip() kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip()) @@ -127,24 +135,30 @@ def _normalize(item: dict) -> Optional[dict]: if not content_id or not title or kind is None or distance is None: return None - body = {"contentid": content_id, "title": title, "content_type": kind, "distance_m": distance} - # lclsSystm1~3 = 분류체계 대/중/소. ★ 중분류(lclsSystm2)가 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준이다. - for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", - "lclsSystm1", "lclsSystm2", "lclsSystm3"): - value = str(item.get(key) or "").strip() + out = { + "contentid": content_id, "content_type": kind, "distance_m": distance, + # 렌더러 계약(LocalPlace). searchQuery 는 이름 그대로다 — 우리가 URL 을 지어내지 않는다. + "name": title, "searchQuery": title, + } + address = str(item.get("addr1") or "").strip() + if address: + out["location"] = address + # 좌표는 컬럼으로 간다. mapX=경도 · mapY=위도 (뒤집으면 엉뚱한 지역이 붙는다). + for src, dst in (("mapx", "longitude"), ("mapy", "latitude")): + value = str(item.get(src) or "").strip() if value: - body[key] = value + out[dst] = value + # ★ 중분류만 남긴다. 렌더러는 안 쓰지만 서버가 주변 맛집에서 같은 업태(경쟁 업소)를 뺄 때 쓴다. + cls = str(item.get("lclsSystm2") or "").strip() + if cls: + out["lclsSystm2"] = cls # 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다. image = str(item.get("firstimage") or "").strip() license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() if image and license_code in _COMMERCIAL_OK_LICENSES: - body["firstimage"] = image - body["license"] = license_code - thumb = str(item.get("firstimage2") or "").strip() - if thumb: - body["firstimage2"] = thumb - return body + out["imageUrl"] = image + return out # ── 공개 API ──────────────────────────────────────────────────────────── @@ -179,30 +193,40 @@ async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: fl def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]: - """searchFestival2 항목 1건 → place_contents.body. locationBasedList2 와 달리 `dist` 를 - 안 주므로(호출측이 haversine 으로 잰 값을) 그대로 받는다. 기간은 여기 이미 있다.""" + """searchFestival2 항목 1건 → 정규화 dict. locationBasedList2 와 달리 `dist` 를 안 주므로 + (호출측이 haversine 으로 잰 값을) 그대로 받는다. + + ★ `_normalize` 와 같은 규약이다 — 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다. + ★ 기간(eventstartdate/enddate)은 **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …")로 미리 + 구워 두면 노출 기간 필터(`_festival_not_ended`·display_end_at)가 읽을 값이 없어진다. + 날짜는 사실이고 문장은 표기다 — 사실만 저장한다. + """ content_id = str(item.get("contentid") or "").strip() title = str(item.get("title") or "").strip() if not content_id or not title: return None - body = {"contentid": content_id, "title": title, - "content_type": LocalContentType.FESTIVAL.value, "distance_m": distance_m} - for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", - "lclsSystm1", "lclsSystm2", "lclsSystm3", "eventstartdate", "eventenddate"): + out = { + "contentid": content_id, "content_type": LocalContentType.FESTIVAL.value, + "distance_m": distance_m, "name": title, "searchQuery": title, + } + address = str(item.get("addr1") or "").strip() + if address: + out["location"] = address + for src, dst in (("mapx", "longitude"), ("mapy", "latitude")): + value = str(item.get(src) or "").strip() + if value: + out[dst] = value + for key in ("eventstartdate", "eventenddate", "homepage", "overview", "lclsSystm2"): value = str(item.get(key) or "").strip() if value: - body[key] = value + out[key] = value image = str(item.get("firstimage") or "").strip() license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() if image and license_code in _COMMERCIAL_OK_LICENSES: - body["firstimage"] = image - body["license"] = license_code - thumb = str(item.get("firstimage2") or "").strip() - if thumb: - body["firstimage2"] = thumb - return body + out["imageUrl"] = image + return out def _festival_not_ended(body: dict, today: date) -> bool: diff --git a/solution/backend/services/grounding/story.py b/solution/backend/services/grounding/story.py new file mode 100644 index 0000000..fedb0eb --- /dev/null +++ b/solution/backend/services/grounding/story.py @@ -0,0 +1,118 @@ +"""지역 이야기 응답 해석 — 모델이 준 JSON 에서 **쓸 수 있는 항목만** 남긴다. + +★ 왜 스키마 검증을 하지 않나 + 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면, + 프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다 — `site_payload._sections` 가 + 붙여넣기 아이템을 파싱하지 않는 것과 같은 이유다. + 그래서 여기서는 **그 항목이 화면에 설 수 있는가**만 본다: 종류마다 하나씩 있는 '이름 칸'. + +★ 출처는 두 곳에서 온다 + 모델이 항목에 단 `source` 가 1순위다. 그게 없으면 Perplexity 가 실제로 읽은 + `search_results` 의 첫 줄을 붙인다 — 모델 답변은 환각이 섞이지만 search_results 는 + 실제로 검색된 주소다(`grounding/channels.py` 와 같은 판단). + 둘 다 없으면 항목을 버린다. 출처 없는 사실은 이 레포의 규칙 위반이다. +""" +import json +import re + +from common.logger import LOG + +# 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다). +_TITLE_KEY = { + "songs": "title", + "people": "name", + "chronicle": "title", + "postcard": "line", + "quiz": "question", +} + +# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다. +_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE) + + +def _payload_text(payload: dict) -> str: + choices = payload.get("choices") or [] + if not choices or not isinstance(choices[0], dict): + return "" + return ((choices[0].get("message") or {}).get("content")) or "" + + +def _first_source(payload: dict) -> dict | None: + """Perplexity 가 실제로 읽은 첫 출처. 항목에 source 가 없을 때의 대체값.""" + for row in payload.get("search_results") or []: + if isinstance(row, dict) and (row.get("url") or "").startswith("http"): + return {"name": row.get("title") or row.get("url"), "url": row["url"]} + return None + + +def _clean_source(value) -> dict | None: + """모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 — + "검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다.""" + if not isinstance(value, dict): + return None + url = (value.get("url") or "").strip() + if not url.startswith("http"): + return None + return {"name": (value.get("name") or url).strip(), "url": url} + + +def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[str]]: + """(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. + + 한 항목이 잘못돼도 나머지를 살린다. 지역 하나에 8~14건인데 한 줄 때문에 전부 버리면 + 그 지역은 다음 재생성까지 빈 채로 남는다. + """ + title_key = _TITLE_KEY.get(kind) + if title_key is None: + raise ValueError(f"모르는 지역 이야기 종류: {kind}") + + text = _FENCE_RE.sub("", _payload_text(payload)).strip() + if not text: + return [], ["응답이 비었다"] + + try: + envelope = json.loads(text) + except (json.JSONDecodeError, ValueError) as ex: + LOG.w(f"[story] {kind} JSON 파싱 실패: {ex}") + return [], [f"JSON 이 아니다: {ex}"] + + if not isinstance(envelope, dict): + return [], ["최상위가 객체가 아니다"] + raw_items = envelope.get("items") + if not isinstance(raw_items, list): + return [], ["items 가 배열이 아니다"] + + fallback = _first_source(payload) + out: list[dict] = [] + dropped: list[str] = [] + + for raw in raw_items: + if len(out) >= limit: + break + if not isinstance(raw, dict): + dropped.append("항목이 객체가 아니다") + continue + title = (raw.get(title_key) or "").strip() if isinstance(raw.get(title_key), str) else "" + if not title: + dropped.append(f"{title_key} 가 없다") + continue + + item = {k: v for k, v in raw.items() if v not in (None, "", [], {})} + item[title_key] = title + + source = _clean_source(raw.get("source")) or fallback + if source is None: + dropped.append(f"{title}: 출처가 없다") + continue + item["source"] = source + + # ★ 모델이 "확인" 이라고 우겨도, 대체 출처로 때운 항목은 확인필요다 — + # 그 URL 은 이 항목이 아니라 이번 검색 전체의 출처다. + if item.get("verified") not in ("확인", "확인필요"): + item["verified"] = "확인필요" + elif _clean_source(raw.get("source")) is None: + item["verified"] = "확인필요" + + out.append(item) + + return out, dropped diff --git a/solution/backend/services/itinerary.py b/solution/backend/services/itinerary.py index 567f3b0..4fa3be3 100644 --- a/solution/backend/services/itinerary.py +++ b/solution/backend/services/itinerary.py @@ -38,14 +38,14 @@ def _candidates(rows: list[dict], stop_type: str, base_lat: float, base_lng: float) -> list[dict]: """payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다. - 행 모양은 스냅샷 local.contents 의 body(services/external/tour_api._normalize)다 — - mapx=경도, mapy=위도 (WGS84 문자열). + 행 모양은 스냅샷 local.contents 항목이다. 좌표는 **항목 최상단**의 latitude/longitude 다 — + 2026-09-09 에 body 에서 컬럼으로 옮겼다(body 는 렌더러가 읽는 것만 담는다). """ out = [] for row in rows: body = row.get("body") or {} - name = str(row.get("title") or body.get("title") or "").strip() - lat, lng = _as_float(body.get("mapy")), _as_float(body.get("mapx")) + name = str(body.get("name") or row.get("title") or "").strip() + lat, lng = _as_float(row.get("latitude")), _as_float(row.get("longitude")) if not name or lat is None or lng is None: continue dist = haversine_km(base_lat, base_lng, lat, lng) @@ -129,7 +129,7 @@ def build_itineraries(base_lat: Optional[float], base_lng: Optional[float], festivals: list[dict]) -> list[dict]: """업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열. - 입력 행 모양은 스냅샷 local.contents 항목({title, body:{mapx, mapy, …}})이다. + 입력 행 모양은 스냅샷 local.contents 항목({title, latitude, longitude, body:{name, …}})이다. """ if base_lat is None or base_lng is None: return [] diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index a73e85e..85f5595 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -6,14 +6,20 @@ from decimal import Decimal from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import area_contents, place_area_refs, place_channels, places -from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory +from common.database.model.models import area_contents, place_area_refs, place_channels, places, site_sections +from common.enums import ( + AREA_KIND, DBWRType, ErrorType, FactStatus, LocalContentStatus, LocalContentType, + LocalSource, PlaceCategory, SourceType, +) from common.logger import LOG from crud.local_content_crud import LocalContentCRUD from crud.place_content_crud import PlaceContentCRUD from crud.place_crud import PlaceCRUD +from crud.site_section_crud import SiteSectionCRUD from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed from services.external.naver import region_key +from services.llm import perplexity +from services import story_service from router.v1.local.protocol import ( ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData, ) @@ -36,6 +42,9 @@ _KST = timezone(timedelta(hours=9)) # 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다. # 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다. # 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido). +# 공용 실체(area_contents.body)에 넣지 않는 키. 컬럼이나 사이트 쪽에 이미 자리가 있는 것들이다. +_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude") + RESTAURANT_RADIUS_M = 5_000 ATTRACTION_RADIUS_M = 10_000 @@ -163,19 +172,25 @@ class LocalContentService: region_code = str(getattr(place, "region_code", None) or "").strip() or None changed = 0 kept_ids: set = set() + personal: dict = {} # area_contents.local_content_id → 이 사이트만의 값(거리·숨김) for body in kept: key = (body["content_type"], body["contentid"]) prev = existing.get(key) # ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다. # (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다. + # ★ 공용 실체에는 **렌더러가 읽는 것만** 담는다(2026-09-09). 거리는 사이트마다 다르고, + # 좌표·외부 id 는 컬럼이 이미 그 자리다 — body 에 또 두면 한쪽만 갱신되는 날이 온다. + # `lclsSystm2` 만 예외로 남긴다: 렌더러는 안 쓰지만 주변 맛집에서 같은 업태를 뺄 때 쓴다. + shared_body = {k: v for k, v in body.items() if k not in _BODY_DROP} content_values = { "source": LocalSource.TOUR_API.value, "external_id": body["contentid"], "content_type": body["content_type"], - "title": body["title"], - "body": body, - "latitude": _as_float(body.get("mapy")), - "longitude": _as_float(body.get("mapx")), + "kind": AREA_KIND.get(body["content_type"]), + "title": body["name"], + "body": shared_body, + "latitude": _as_float(body.get("latitude")), + "longitude": _as_float(body.get("longitude")), "region_code": region_code, # ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다. # 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다. @@ -205,7 +220,14 @@ class LocalContentService: [place_area_refs.DBType()], [lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)], ) - if prev is None or prev.body != body: + # 사이트 개인화(거리·숨김)는 아래에서 한 번에 쓴다 — 항목마다 UPDATE 하면 + # 같은 행을 N 번 쓰게 된다(섹션당 한 행이다). + personal[str(content_id)] = { + "kind": AREA_KIND.get(body["content_type"]), + "distanceMeters": body["distance_m"], + "hidden": bool(getattr(prev, "hidden", False)), + } + if prev is None or prev.body != shared_body: changed += 1 # 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다. @@ -213,6 +235,10 @@ class LocalContentService: place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids) ) + # ★ 개인화는 사이트 쪽에 쓴다. 이번 응답에 없는 항목은 자연히 빠진다 — 맵을 통째로 갈아 + # 끼우기 때문이다. 숨김은 위에서 옛 값을 물려받았으므로 재수집이 되살리지 않는다. + await self._write_site_places(place_id, personal) + counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value, LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)} for b in kept: @@ -361,6 +387,12 @@ class LocalContentService: if not synced.result.success: LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}") + # ★ 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다. + # 위 TourAPI 는 수 초면 끝나지만 이건 검색을 동반한 LLM 호출 다섯이라 분 단위다. + # 에디터를 여는 요청을 그만큼 붙잡아 두면 사장님에게는 화면이 멈춘 것으로 보인다. + # 이번 응답에는 안 실리고, 다음에 열 때(또는 발행 빌드 때) 들어온다. + await self._ensure_region_stories(place) + snapshot_local = await _local_contents(place) local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude)) res.attractions = local.get("attractions") or [] @@ -370,6 +402,55 @@ class LocalContentService: res.synced_at = synced_at return res + async def _write_site_places(self, place_id, places_map: dict) -> None: + """주변 항목의 **사이트별 값**(거리·숨김)을 `site_sections` 한 행에 쓴다. + + ★ 배열이 아니라 ref → 값 **맵**이다. 화면에 순서대로 서는 항목(songs·people…)이 아니라 + "공용 항목 하나에 이 사이트가 덧붙인 값" 조회표라, 읽을 때마다 훑을 이유가 없다. + 정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다. + ★ 사이트가 없으면 만들지 않고 건너뛴다 — 사이트를 세우는 건 발행 쪽 결정이다 + (`build_service.ensure_site`). 다음 수집이 사이트가 생긴 뒤 다시 쓴다. + """ + from services.build_service import ensure_site + + try: + site = await ensure_site(str(place_id)) + except Exception as ex: # noqa: BLE001 — 발행 전 업장은 사이트가 없을 수 있다 + LOG.i(f"[local] place={place_id} 사이트가 없어 개인화 저장을 건너뛴다: {ex}") + return + if site is None: + return + + values = { + "site_id": site.site_id, + "section_id": "local", + "data": {"kind": "local", "places": places_map}, + "source_type": SourceType.API.value, + "status": FactStatus.VERIFIED.value, + "sort_order": 0, + } + err = await DB_SESSION_MNG.execute_lambda_run( + [site_sections.DBType()], [lambda s: SiteSectionCRUD().upsert(s, values)], + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}") + + async def _ensure_region_stories(self, place) -> None: + """지역 데이터가 비어 있으면 잡을 하나 넣는다 — **보험 경로**다. + + ★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가 + 같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다. + 여기는 그 경로를 안 거친 업장(옛 데이터·수집을 건너뛴 경우)을 위한 자리다. + ★ 잡으로 돌린다. 이 함수는 에디터가 화면을 그리려고 부른 요청 안에 있어서, + 여기서 1분을 붙잡으면 사장님에게는 화면이 멈춘 것으로 보인다. + """ + if not perplexity.is_configured(): + return + region_code = str(getattr(place, "region_code", None) or "").strip() + if region_code and await story_service.has_stories(region_code): + return + await story_service.enqueue_region_job(place) + # ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ──────────────── async def publish(self, ids, user_id): @@ -453,3 +534,4 @@ class LocalContentService: return res res.weather = WeatherData.model_validate(weather) return res + diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json new file mode 100644 index 0000000..9535833 --- /dev/null +++ b/solution/backend/services/prompts/section_prompts.json @@ -0,0 +1,41 @@ +{ + "_generated": "npm run export:prompts — 손으로 고치지 않는다", + "rules": "\n[공통 규칙]\n1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.\n2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.\n3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.\n4. 근거가 확실하면 verified 를 \"확인\", 애매하면 \"확인필요\" 로 적는다. 애매한 걸 \"확인\" 으로 올리지 않는다.\n5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n6. 설명 문장은 항목당 두 문장을 넘기지 않는다.\n7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.\n", + "specs": { + "songs": { + "kind": "songs", + "label": "가요 다방", + "maxItems": 8, + "task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", + "rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n" + }, + "people": { + "kind": "people", + "label": "인물 열전", + "maxItems": 10, + "task": "[해야 할 일]\n[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.\n문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.\n\n[스키마]\n{ \"kind\":\"people\", \"version\":1, \"title\":\"인물 열전\", \"items\":[\n { \"name\":\"이름\", \"aka\":\"호·예명\", \"years\":\"1902–1950\", \"role\":\"소설가\",\n \"oneLine\":\"한 문장 소개\", \"imageQuery\":\"사진 검색어\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", + "rules": "\n[이 아이템만의 규칙]\n· \"~ 출신으로 알려진\" 처럼 근거가 전언뿐이면 verified 를 \"확인필요\" 로 한다.\n· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.\n· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.\n" + }, + "chronicle": { + "kind": "chronicle", + "label": "시간의 골목", + "maxItems": 14, + "task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", + "rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n" + }, + "postcard": { + "kind": "postcard", + "label": "오늘의 엽서", + "maxItems": 12, + "task": "[해야 할 일]\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\n\n[스키마]\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\n \"postmark\":\"소인에 찍을 짧은 지명\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", + "rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n" + }, + "quiz": { + "kind": "quiz", + "label": "뒤집어 보는 질문", + "maxItems": 12, + "task": "[해야 할 일]\n[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.\n질문은 검색하면 바로 나오는 단답형이 아니라 \"왜\" 와 \"어떻게\" 를 묻는 것으로 한다.\n\n[스키마]\n{ \"kind\":\"quiz\", \"version\":1, \"title\":\"뒤집어 보는 질문\", \"items\":[\n { \"question\":\"질문 한 문장\", \"hint\":\"두 문장 이내 힌트\",\n \"topic\":\"관련 장소·주제\", \"level\":\"초등|중등|어른\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", + "rules": "\n[이 아이템만의 규칙]\n· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.\n· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.\n· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.\n" + } + } +} diff --git a/solution/backend/services/prompts/story.py b/solution/backend/services/prompts/story.py new file mode 100644 index 0000000..760cea0 --- /dev/null +++ b/solution/backend/services/prompts/story.py @@ -0,0 +1,61 @@ +"""지역 이야기 프롬프트 계약 — 문장은 여기서 만들지 않고 **읽어 온다**. + +★ 단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다. + 사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다 — + 갈리면 "빌더에서 뽑은 것과 자동으로 채워진 것의 모양이 다르다"가 조용히 생긴다. + 이 파일이 읽는 `section_prompts.json` 은 `npm run export:prompts` 산출물이고 커밋된다. + **손으로 고치지 않는다.** +""" +import json +from functools import lru_cache +from pathlib import Path + +_SPEC_PATH = Path(__file__).with_name("section_prompts.json") + +SYSTEM_PROMPT = ( + "너는 지역 콘텐츠 리서처다. 검색으로 확인한 것만 쓰고, 확인하지 못한 값은 필드를 통째로 뺀다. " + "JSON 하나만 출력한다." +) + + +@lru_cache(maxsize=1) +def _spec() -> dict: + """산출물 로드. 없으면 즉시 터뜨린다 — 프롬프트 없이 도는 생성 잡은 빈 값을 쓴다.""" + try: + return json.loads(_SPEC_PATH.read_text(encoding="utf-8")) + except FileNotFoundError as ex: # pragma: no cover - 배포 누락은 기동 시 바로 드러난다 + raise RuntimeError( + f"{_SPEC_PATH.name} 이 없다 — 레포 루트에서 `npm run export:prompts` 를 돌려야 한다" + ) from ex + + +def kinds() -> list[str]: + return list(_spec()["specs"].keys()) + + +def label(kind: str) -> str: + return _spec()["specs"][kind]["label"] + + +def max_items(kind: str) -> int: + return int(_spec()["specs"][kind]["maxItems"]) + + +def build_prompt(kind: str, region: str) -> str: + """지역 하나 × 종류 하나의 프롬프트. + + ★ 업소 이름을 넣지 않는다. 이 값은 **지역**에 붙어 같은 지역 사이트가 나눠 쓴다 — + 업소 하나를 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다. + `shared/section-prompts.ts` 의 `buildSectionPrompt` 가 같은 분기를 갖고 있다. + """ + spec = _spec()["specs"].get(kind) + if spec is None: + raise ValueError(f"모르는 지역 이야기 종류: {kind}") + + head = ( + "너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.\n" + f"\n[지역] {region}\n" + f"\n아래 '해야 할 일'에서 [지역] = {region}. " + "업소가 지정되지 않았으므로 특정 업소를 가리키는 문장(connection 등)은 쓰지 않는다.\n" + ) + return f"{head}\n{spec['task']}\n{_spec()['rules']}{spec['rules']}" diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index b992ac7..e51d959 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -482,7 +482,7 @@ def _yyyymmdd(value) -> str: def _festival(row: dict): """FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다.""" body = row.get("body") or {} - name = _text(row.get("title")) or _text(body.get("title")) + name = _text(body.get("name")) or _text(row.get("title")) if not name: return None @@ -506,7 +506,7 @@ def _festival(row: dict): } if period: entry["period"] = period - location = _text(body.get("addr1")) + location = _text(body.get("location")) if location: entry["location"] = location description = _text(body.get("overview")) @@ -518,42 +518,45 @@ def _festival(row: dict): # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정). _put_distance(entry, body) - image = _text(body.get("firstimage")) + image = _text(body.get("imageUrl")) if image: entry["imageUrl"] = image return entry def _local_place(row: dict, category: str): - """LocalPlace(주변 명소·맛집). + """LocalPlace. - body 는 TourAPI 수집기가 채운다(services/external/tour_api._normalize) — - 주소(addr1)가 있으면 위치로 싣는다. 없는 값은 만들지 않는다.""" - name = _text(row.get("title")) + ★ 2026-09-09 부터 `body` 가 **이미 렌더러 모양**이다(`name`·`location`·`imageUrl`) — + 수집 시점에 바꿔 넣는다(`external/tour_api._normalize`). 예전에는 TourAPI 원문 이름을 + 저장하고 빌드마다 여기서 바꿔 실었다. 같은 변환을 발행할 때마다 다시 하는 셈이었고, + 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다. + 그래서 여기가 하는 일은 둘뿐이다 — 업종 라벨을 붙이고, 사이트별 거리를 표기로 바꾼다. + """ + body = row.get("body") or {} + name = _text(body.get("name")) or _text(row.get("title")) if not name: return None - body = row.get("body") or {} - entry = {"name": name, "category": category, "searchQuery": name} - location = _text(body.get("addr1")) - if location: - entry["location"] = location - # 거리는 수집 시점에 업장 좌표로 잰 값(place_contents.distance_m). 지역 캐시 항목엔 없다. - # ★ 숫자(distanceMeters)도 함께 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를 계산하려면 - # "1.2km" 같은 문자열을 다시 파싱하는 것보다 원값이 안전하다. + + entry = {"name": name, "category": category, "searchQuery": _text(body.get("searchQuery")) or name} + for key in ("location", "imageUrl"): + value = _text(body.get(key)) + if value: + entry[key] = value _put_distance(entry, body) - image = _text(body.get("firstimage")) - if image: - entry["imageUrl"] = image return entry def _put_distance(entry: dict, body: dict) -> None: - """distance_m → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" - distance = _distance_text(body.get("distance_m")) + """distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" + # ★ 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — + # 공용 실체에는 거리가 없다(업장마다 다르다). 스냅샷이 그 값을 body 에 얹어 준다. + meters = body.get("distanceMeters") + distance = _distance_text(meters) if not distance: return entry["distanceText"] = distance - entry["distanceMeters"] = int(body.get("distance_m")) + entry["distanceMeters"] = int(meters) def _distance_text(meters) -> str: @@ -621,6 +624,15 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) entry = _local_place(row, "여행코스") if entry: local["courses"].append(entry) + elif content_type == LocalContentType.STORY.value: + # ★ 지역 이야기는 **모양을 바꾸지 않는다.** body 가 이미 렌더러 계약 + # (`shared/lib/section-data.ts` 의 SongItem·PeopleItem…) 그대로다. + # 여기서 키를 손대면 사장님이 손으로 붙여넣은 같은 종류의 JSON 과 모양이 갈린다 — + # 화면은 둘을 한 배열로 이어 그린다. + kind = _text(row.get("kind")) + items = (row.get("body") or {}).get("items") + if kind and isinstance(items, list) and items: + local.setdefault("story", {})[kind] = [i for i in items if isinstance(i, dict)] # 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다. itineraries = build_itineraries( diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 6354f0f..bea1ef3 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -22,7 +22,10 @@ from sqlalchemy import or_, select from common.category_schema import get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units +from common.database.model.models import ( + place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units, + site_sections, sites, +) from common.enums import ( PUBLISHABLE_FACT_STATUSES, DBWRType, @@ -44,6 +47,9 @@ _PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES) # 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다. _LOCAL_MAX_PER_TYPE = 20 +# ★ 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004 +# `uq_local_contents_kind`). 그래서 다섯 종류가 위 상한 안에서 나란히 선다. + # 지역 원문(body)에서 스냅샷으로 옮기지 않는 키. # ★ TourAPI 원본을 통째로 담은 필드라 정규화된 값과 100% 중복이고, 축제 1건의 크기를 두 배로 만든다. # site_payload 는 정규화된 키만 읽는다. @@ -208,6 +214,11 @@ async def _local_contents(place) -> dict: .where( area_contents.region_code == region_code, area_contents.deleted == False, # noqa: E712 + # ★ **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌). + # 관광지·맛집·축제는 같은 표에 있지만 업장마다 거리가 달라, 아래 사이트 쪽에서 + # 개인화 값과 함께 읽는다. 여기서 같이 긁으면 거리 없는 항목이 먼저 들어와 + # 종류별 상한을 채워 버린다(실측 2026-09-09: 주변 12건이 전부 거리 없이 나갔다). + area_contents.external_id.is_(None), area_contents.status == LocalContentStatus.PUBLISHED.value, or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now), or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now), @@ -223,70 +234,122 @@ async def _local_contents(place) -> dict: rows = [] contents += _local_rows(rows or [], seen) - # ── 업장 반경 캐시(place_area_refs): 맛집·관광지·축제·여행코스 ── - # ★ 정렬은 **사진 있는 것 우선 → 가까운 순**(2026-09-07 결정). 종류별 상한 안에 들려면 - # 사진 없는 가까운 곳보다 사진 있는 조금 먼 곳이 이긴다 — 화면이 카드라 사진이 없으면 자리가 빈다. + # ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ── + # ★ 2026-09-09 에 자리를 갈랐다. 공용 실체는 지역이 나눠 쓰고(거리를 담을 수 없다), + # 거리·숨김은 사이트마다 다르다. 그래서 관계 테이블이 아니라 **사이트 섹션**에서 읽는다. + # 정렬은 여기가 한다 — 사진 있는 것 먼저, 그다음 가까운 순(2026-09-07 결정). + # 저장 쪽에 정렬을 구워 두면 기준이 바뀔 때 전 사이트를 다시 써야 한다. place_id = getattr(place, "place_id", None) if place_id is not None: - # ★ 실체(area_contents)와 관계(place_area_refs)를 조인한다 — 2026-09-09 에 값과 관계를 - # 갈랐다. 사진 유무는 body.firstimage 로 판단한다(같은 사실을 컬럼으로 또 두지 않는다). - nearby_q = ( - select( - area_contents.content_type, - area_contents.external_id, - area_contents.title, - area_contents.body, - area_contents.display_end_at, - # ★ _local_rows 가 읽는 필드는 빠짐없이 넣는다 — 조인으로 바꾸면서 - # source·collected_at 이 빠져 AttributeError 로 스냅샷이 통째로 죽었다. - area_contents.source, - area_contents.collected_at, - place_area_refs.distance_m, - ) - .join(place_area_refs, place_area_refs.local_content_id == area_contents.local_content_id) - .where( - place_area_refs.place_id == place_id, - place_area_refs.deleted == False, # noqa: E712 - place_area_refs.hidden == False, # noqa: E712 + personal = await _site_places(place_id) + if personal: + ids = [uuid.UUID(k) for k in personal if _is_uuid(k)] + shared_q = select(area_contents).where( + area_contents.local_content_id.in_(ids), area_contents.deleted == False, # noqa: E712 or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now), ) - .order_by( - area_contents.content_type.asc(), - # 사진 있는 것이 먼저 — 화면이 카드라 사진이 없으면 자리가 빈다. - (area_contents.body["firstimage"].astext != "").desc().nullslast(), - place_area_refs.distance_m.asc(), + err, rows = await DB_SESSION_MNG.execute_lambda( + area_contents.DBType(), DBWRType.DB_READ.value, + lambda s: DB_SESSION_MNG.execute(s, shared_q), + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}") + rows = [] + + merged = [] + for row in rows or []: + mine = personal.get(str(row.local_content_id)) or {} + if mine.get("hidden"): + continue + body = dict(row.body if isinstance(row.body, dict) else {}) + # 거리만 얹는다. 공용 실체는 이미 렌더러 모양이라 여기서 이름을 바꾸지 않는다. + if mine.get("distanceMeters") is not None: + body["distanceMeters"] = mine["distanceMeters"] + merged.append((row, body, mine.get("distanceMeters"))) + + merged.sort(key=lambda t: (not bool(t[1].get("imageUrl")), t[2] if t[2] is not None else 1 << 30)) + contents += _local_rows( + [_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value ) - ) - err, rows = await DB_SESSION_MNG.execute_lambda( - place_area_refs.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, nearby_q) - ) - if err != ErrorType.SUCCESS: - LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}") - rows = [] - contents += _local_rows(rows or [], seen, source=LocalSource.TOUR_API.value) return {"region_code": region_code or None, "contents": contents} +class _Row: + """area_contents 행 + 사이트 값이 얹힌 body. `_local_rows` 가 두 캐시를 같은 모양으로 읽게 한다.""" + + __slots__ = ("content_type", "source", "title", "body", "collected_at", "kind", + "latitude", "longitude") + + def __init__(self, row, body): + self.content_type, self.source = row.content_type, row.source + self.title, self.body, self.collected_at, self.kind = row.title, body, row.collected_at, row.kind + self.latitude, self.longitude = row.latitude, row.longitude + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + except (ValueError, AttributeError, TypeError): + return False + return True + + +async def _site_places(place_id) -> dict: + """이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden}). + + ★ 사이트가 없으면 빈 맵이다 — 발행 전 업장은 주변 정보가 안 나간다. 그건 옳다. + 개인화 값이 없다는 건 "이 사이트에 그 항목이 붙은 적이 없다"는 뜻이다. + """ + q = ( + select(site_sections.data) + .join(sites, sites.site_id == site_sections.site_id) + .where( + sites.place_id == place_id, + sites.deleted == False, # noqa: E712 + site_sections.section_id == "local", + site_sections.deleted == False, # noqa: E712 + ) + .limit(1) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q) + ) + if err != ErrorType.SUCCESS or not rows: + return {} + data = rows[0] + return (data or {}).get("places") or {} if isinstance(data, dict) else {} + + def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]: """행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다. seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다.""" out = [] for row in rows: content_type = int(row.content_type) + kind = getattr(row, "kind", None) taken = seen.get(content_type, 0) if taken >= _LOCAL_MAX_PER_TYPE: continue seen[content_type] = taken + 1 body = row.body if isinstance(row.body, dict) else {} - out.append({ + entry = { "content_type": content_type, "source": source if source is not None else row.source, "title": row.title, "body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP}, "collected_at": _iso(row.collected_at), - }) + } + if kind: + entry["kind"] = kind + # ★ 좌표는 **컬럼**에서 온다(2026-09-09). 예전에는 body.mapx/mapy 였는데, 같은 값이 + # 컬럼에도 있어 한쪽만 갱신될 자리였다. 일정 조립(services/itinerary)이 이걸 읽는다. + for key, value in (("latitude", getattr(row, "latitude", None)), + ("longitude", getattr(row, "longitude", None))): + if value is not None: + entry[key] = str(value) + out.append(entry) return out diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py new file mode 100644 index 0000000..ee34a61 --- /dev/null +++ b/solution/backend/services/story_service.py @@ -0,0 +1,237 @@ +"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다. + +★ 왜 지역 단위인가 + 이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다. + 키를 place_id 로 잡으면 같은 지역에 숙소 50곳이 들어올 때 같은 곡 목록을 50번 만든다 + — `area_contents` 가 region_code 를 키로 두는 것과 같은 이유이고, 여기가 그 표를 쓴다. + +★ 왜 종류마다 따로 부르나 + 다섯을 한 프롬프트에 넣으면 (1) 출력이 길어 잘리고 (2) 한 종이 실패하면 전부 다시 돌고 + (3) 검색 출처가 어느 항목 것인지 섞인다. 종류당 1회, 한 번에 그 종류 전부다 — + 항목당 1회는 반대로 낭비다(검색이 한 번에 여러 건을 답한다). + +★ 왜 Perplexity 한 곳인가 + 이 값들은 **출처가 붙어야** 쓸 수 있다(항목의 `source.url`). Gemini 는 검색을 안 해서 + 주소를 지어내고, Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. + 구조는 프롬프트의 [스키마] 블록이 잡고, 파이썬은 모양을 다시 적지 않는다 + (`grounding/story.py` 머리주석). + +★ 검수 게이트를 두지 않는다 (2026-09-09 결정 — docs/DECISIONS.md) + 생성분은 PUBLISHED 로 저장한다. 대신 항목마다 `verified`·`source` 가 실려 화면이 그걸 밝히고, + 틀린 항목은 사장님이 에디터에서 뺀다. 공공데이터(맛집·관광지)를 검수 없이 싣는 것과 같은 규약이다. +""" +import uuid + +import httpx + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import area_contents +from common.enums import DBWRType, ErrorType, JobType, LocalContentStatus, LocalContentType, LocalSource +from common.utils.gtime import GTime +from common.logger import LOG +from crud.local_content_crud import LocalContentCRUD +from services.grounding import story as grounding +from services.llm import perplexity +from services.prompts import story as prompts + +# ★ 다섯을 **순차로** 부른다. 처음엔 동시에 띄웠는데 실측(2026-09-09, 전북 군산시)에서 +# 다섯 중 둘이 HTTP 429 로 떨어졌다 — 같은 키로 나가는 호출이라 한 지역이 자기 자신을 막는다. +# 순차로 돌려도 건당 9~15초라 다섯이 1분 안이고(같은 실측), 이건 잡이라 사람이 기다리지 않는다. +# "빨리 끝내려다 절반을 잃는" 교환이 성립하지 않는다. + +# ★ 채널 발견(90s)보다 길게 잡는다. 같은 실측에서 가요 다방이 90초를 넘겼다 — +# "이 도시를 노래한 곡" 은 후보를 넓게 훑어야 해서 검색 왕복이 더 많다. +_TIMEOUT = httpx.Timeout(240.0, connect=10.0) + +# 생성분에는 노출 종료가 없다. 축제와 달리 "지난 것"이 되지 않는다 — +# 1966년 곡은 내년에도 1966년 곡이다. 갱신은 운영자가 다시 돌릴 때만 일어난다. +_DISPLAY_END = None + + +# 봉투 버전. 사장님이 붙여넣는 JSON 의 `version` 과 같은 자리다 — 읽는 쪽이 둘을 구분하지 +# 않아야 하므로 값도 같게 둔다(`shared/lib/section-data.ts`). +_ENVELOPE_VERSION = 1 + + +async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str) -> tuple[list[dict], list[str]]: + """종류 하나. 실패는 예외로 올리지 않고 빈 목록으로 돌려준다 — + 한 종류가 죽어도 나머지 넷은 채워야 한다.""" + body = { + "model": perplexity.DEFAULT_MODEL, + "messages": [ + {"role": "system", "content": prompts.SYSTEM_PROMPT}, + {"role": "user", "content": prompts.build_prompt(kind, region_label)}, + ], + "max_tokens": perplexity.DEFAULT_MAX_TOKENS, + } + try: + payload = await perplexity.call(body, client=client) + except perplexity.PerplexityNotConfigured: + return [], ["PERPLEXITY_API_KEY 미설정"] + except perplexity.PerplexityError as ex: + LOG.w(f"[story] {kind} 호출 실패 region={region_label}: {ex}") + return [], [f"호출 실패: {ex}"] + + items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind)) + LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림") + return items, dropped + + +async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict: + """지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다. + + ★ 기존 행을 먼저 지우지 않는다. 순번 키로 덮어쓰므로, 새로 받은 것이 적으면 뒤쪽 옛 행이 + 남는다 — 그건 의도다. 이번 검색이 부실했다고 지난번에 확인된 항목까지 날리지 않는다. + """ + wanted = kinds or prompts.kinds() + crud = LocalContentCRUD() + result: dict[str, int] = {} + notes: list[str] = [] + + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + for kind in wanted: + items, dropped = await _generate_kind(client, kind, region_label) + result[kind] = len(items) + notes.extend(f"{kind}: {d}" for d in dropped) + + if not items: + continue + + # ★ 한 지역 × 한 종류 = 한 행이다(`uq_local_contents_kind`, migrations/0004). + # 항목마다 행을 만들면 같은 곡이 두 번 서거나 재생성이 옛 행을 못 덮는다. + # 봉투 모양은 사장님이 붙여넣는 JSON 과 **같다** — 읽는 쪽이 둘을 구분하지 않는다. + label = prompts.label(kind) + values = { + "local_content_id": uuid.uuid4(), + "region_code": region_code, + "content_type": LocalContentType.STORY.value, + "kind": kind, + "source": LocalSource.LLM.value, + "title": label, + "body": {"kind": kind, "version": _ENVELOPE_VERSION, "title": label, "items": items}, + "status": LocalContentStatus.PUBLISHED.value, + "published_at": GTime.UTC(), + "display_end_at": _DISPLAY_END, + "collected_at": GTime.UTC(), + } + # ★ execute_lambda_run 이다(claim 아님). claim 은 func 이 (ErrorType, 행수)를 돌려주길 + # 기대하는데 upsert 는 ErrorType 만 준다 — sync_place 가 공용 콘텐츠를 넣는 방식과 같다. + err = await DB_SESSION_MNG.execute_lambda_run( + [area_contents.DBType()], [lambda s, v=values: crud.upsert_kind(s, v)], + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[story] 저장 실패 region={region_code} {kind}: {err.name}") + notes.append(f"{kind}: 저장 실패 {err.name}") + + LOG.i(f"[story] region={region_code}({region_label}) 완료: {result}") + return {"region_code": region_code, "counts": result, "notes": notes} + + +async def has_stories(region_code: str) -> bool: + """이 지역에 이미 이야기가 있나. cache-aside 판단용 — 한 건이라도 있으면 다시 부르지 않는다.""" + err, rows = await DB_SESSION_MNG.execute_lambda( + area_contents.DBType(), DBWRType.DB_READ.value, + lambda s: crud_list(s, region_code), + ) + return err == ErrorType.SUCCESS and bool(rows) + + +async def crud_list(session, region_code: str): + return await LocalContentCRUD().list_kinds(session, region_code) + + +async def run_local_sync(job: dict) -> dict: + """LOCAL_SYNC 잡 핸들러 — **에디터에 들어가기 전에 지역 데이터를 다 채운다.** + + payload: {place_id?, region_code, region_label, kinds?} + + ★ 왜 둘을 한 잡에 묶나 + 업장 반경(TourAPI 맛집·관광지·축제)과 지역 이야기(LLM)는 성격이 다르지만, 사장님에게는 + "주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고, + 하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다. + 사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패 + 비용이 컸지만, 이 둘은 합쳐 1분대이고 유료 재호출도 아래 가드가 막는다. + + ★ 업장 것과 지역 것의 반복 단위가 다르다 + 반경 수집은 **업장마다** 해야 한다(좌표가 다르다). 이야기는 **지역에 한 번**이면 된다 — + 같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. 그래서 이야기 쪽만 가드가 붙는다. + + ★ 멱등하다. 이야기는 순번이 아니라 (region_code, kind) 한 행을 덮어쓰고, 반경 수집은 + external_id 로 upsert 한다 — lease 만료로 다시 돌아도 행이 늘지 않는다. + """ + payload = job["payload"] + region_code = (payload.get("region_code") or "").strip() + region_label = (payload.get("region_label") or "").strip() + place_id = payload.get("place_id") + if not region_code or not region_label: + raise ValueError("LOCAL_SYNC payload 에 region_code/region_label 이 필요하다") + + out: dict = {"region_code": region_code} + + # ── 1. 업장 반경(TourAPI) — 맛집·관광지·축제 ────────────────────── + if place_id: + # 순환 import 회피 — local_content_service 가 이 모듈을 부른다(cache-aside 보험 경로). + from services.local_content_service import LocalContentService + + synced = await LocalContentService().sync_place_by_id(uuid.UUID(str(place_id))) + out["nearby"] = { + "festivals": synced.festivals, "attractions": synced.attractions, + "restaurants": synced.restaurants, "ok": bool(synced.result.success), + } + if not synced.result.success: + LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}") + + # ── 2. 지역 이야기(LLM) — 지역에 한 번 ──────────────────────────── + if not perplexity.is_configured(): + out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"} + return out + if await has_stories(region_code): + # ★ 같은 지역 두 번째 숙소다. 다시 부르면 같은 답에 요금만 두 번 낸다. + out["stories"] = {"skipped": "이미 있다"} + return out + + out["stories"] = await generate_region_stories(region_code, region_label, payload.get("kinds")) + return out + + +def region_label_of(place) -> str: + """프롬프트에 넣을 지명("전북특별자치도 군산시"). + + ★ region_code("52군산시")를 그대로 넣지 않는다 — 숫자가 붙은 문자열을 지명으로 주면 + 모델이 그걸 지명의 일부로 읽는다. 주소 앞 두 토큰이 사람이 부르는 이름이다. + ★ 주소가 없으면 빈 문자열이다. 지역을 모르면 부르지 않는다 — 어디 이야기인지 모르는 + 채로 물으면 모델이 아무 도시나 고른다. + """ + address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip() + if not address: + return "" + tokens = address.split() + return " ".join(tokens[:2]) if len(tokens) >= 2 else tokens[0] + + +async def enqueue_region_job(place) -> str | None: + """업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다. + + ★ dedupe 는 **업장 단위**다(`local:{place_id}`). 반경 수집이 업장마다 필요해서다 — + 지역 이야기의 중복 호출은 잡 안의 `has_stories` 가드가 막는다. + ★ 부르는 곳이 둘이다: 수집 완료 직후(collect_service)와 위저드의 생성 단계(place_service). + 먼저 넣은 잡이 아직 살아 있으면 enqueue_job 이 그 id 를 돌려준다 — 위저드는 그걸 기다린다. + """ + from crud.job_crud import JobQueue + from services.job_service import enqueue_job + + place_id = getattr(place, "place_id", None) + code = str(getattr(place, "region_code", None) or "").strip() + label = region_label_of(place) + if not place_id or not code or not label: + LOG.w(f"[story] place={place_id} 지역을 특정할 수 없어 지역 데이터 잡을 넣지 않는다") + return None + + job_id, created = await enqueue_job( + JobQueue(), JobType.LOCAL_SYNC, + {"place_id": str(place_id), "region_code": code, "region_label": label}, + dedupe_key=f"local:{place_id}", + ) + if created: + LOG.i(f"[story] place={place_id} region={code}({label}) 지역 데이터 잡 등록 job={job_id}") + return job_id diff --git a/solution/backend/tests/test_story_generation.py b/solution/backend/tests/test_story_generation.py new file mode 100644 index 0000000..c9267fa --- /dev/null +++ b/solution/backend/tests/test_story_generation.py @@ -0,0 +1,144 @@ +"""지역 이야기 생성 — 응답 해석과 payload 경계. + +★ 실호출은 하지 않는다. `APP_ENV=test` 면 .env 를 안 읽어 키가 비고, 이 테스트가 검증하는 건 + "모델이 뭐라고 답했을 때 무엇을 남기는가" 다 — 그건 고정 응답으로 전부 재현된다. +""" +import os + +os.environ.setdefault("APP_ENV", "test") + +from common.enums import LocalContentType # noqa: E402 +from services.grounding import story as grounding # noqa: E402 +from services.prompts import story as prompts # noqa: E402 +from services.site_payload import _local # noqa: E402 + + +def _reply(content: str, search_results=None) -> dict: + return { + "choices": [{"message": {"content": content}}], + "search_results": search_results or [], + } + + +# ── 프롬프트 ──────────────────────────────────────────────────────────── +def test_프롬프트는_shared_산출물에서_온다(): + """다섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다.""" + assert set(prompts.kinds()) == {"songs", "people", "chronicle", "postcard", "quiz"} + text = prompts.build_prompt("songs", "전북 군산시") + assert "[지역] 전북 군산시" in text + assert "[지역]을 노래한 대중가요" in text # task 원문 + assert "[공통 규칙]" in text + # ★ 빈칸이 남으면 모델이 그걸 지명으로 읽는다. + assert "(주소를" not in text and "(가게" not in text + + +def test_지역_생성은_업소를_가리키지_않는다(): + """지역 단위 값이라 특정 업소 문장을 못 쓰게 못박는다 — 옆집 사이트에도 실리는 값이다.""" + assert "업소가 지정되지 않았으므로" in prompts.build_prompt("songs", "전북 군산시") + + +# ── 응답 해석 ──────────────────────────────────────────────────────────── +def test_출처가_없으면_항목을_버린다(): + payload = _reply('{"kind":"songs","items":[{"title":"금강 나그네"}]}') + items, dropped = grounding.parse_items(payload, "songs", 8) + assert items == [] + assert any("출처가 없다" in d for d in dropped) + + +def test_검색결과를_대체_출처로_쓰되_확인필요로_내린다(): + """search_results 는 이번 **검색 전체**의 출처지 그 항목의 근거가 아니다.""" + payload = _reply( + '{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인"}]}', + search_results=[{"title": "세계일보", "url": "https://example.com/a"}], + ) + items, _ = grounding.parse_items(payload, "songs", 8) + assert len(items) == 1 + assert items[0]["source"]["url"] == "https://example.com/a" + assert items[0]["verified"] == "확인필요" + + +def test_항목_출처가_있으면_확인을_유지한다(): + payload = _reply( + '{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인",' + '"source":{"name":"세계일보","url":"https://example.com/song"}}]}' + ) + items, _ = grounding.parse_items(payload, "songs", 8) + assert items[0]["verified"] == "확인" + + +def test_열리지_않는_출처는_없는_것으로_친다(): + """"검색결과 참조" 같은 문자열이 링크가 되면 눌러도 아무 데도 안 간다.""" + payload = _reply( + '{"kind":"songs","items":[{"title":"금강 나그네","source":{"name":"검색","url":"검색결과 참조"}}]}' + ) + items, dropped = grounding.parse_items(payload, "songs", 8) + assert items == [] + assert any("출처가 없다" in d for d in dropped) + + +def test_이름칸이_없는_항목만_버리고_나머지는_살린다(): + """한 줄 때문에 지역 하나가 통째로 비면 다음 재생성까지 빈 채로 남는다.""" + payload = _reply( + '{"kind":"people","items":[' + '{"name":"채만식","source":{"name":"한국민족문화대백과","url":"https://example.com/1"}},' + '{"role":"소설가","source":{"name":"x","url":"https://example.com/2"}},' + '{"name":"고은","source":{"name":"y","url":"https://example.com/3"}}]}' + ) + items, dropped = grounding.parse_items(payload, "people", 10) + assert [i["name"] for i in items] == ["채만식", "고은"] + assert any("name 가 없다" in d for d in dropped) + + +def test_코드펜스를_둘러도_읽는다(): + """규칙 1 로 금지했지만 모델은 종종 어긴다.""" + payload = _reply( + '```json\n{"kind":"quiz","items":[{"question":"왜 군산에 일본식 가옥이 남았을까?",' + '"source":{"name":"군산시","url":"https://example.com/q"}}]}\n```' + ) + items, _ = grounding.parse_items(payload, "quiz", 12) + assert len(items) == 1 + + +def test_상한을_넘으면_자른다(): + rows = ",".join( + f'{{"line":"문장{i}","source":{{"name":"x","url":"https://example.com/{i}"}}}}' for i in range(20) + ) + items, _ = grounding.parse_items(_reply(f'{{"kind":"postcard","items":[{rows}]}}'), "postcard", 12) + assert len(items) == 12 + + +def test_JSON_이_아니면_전부_버리고_이유를_남긴다(): + items, dropped = grounding.parse_items(_reply("죄송합니다. 정보를 찾지 못했습니다."), "songs", 8) + assert items == [] + assert dropped and "JSON 이 아니다" in dropped[0] + + +# ── payload 경계 ───────────────────────────────────────────────────────── +def test_스냅샷의_이야기가_payload_로_나간다(): + """★ 항목 모양을 바꾸지 않는다 — 사장님이 붙여넣은 같은 종류의 JSON 과 한 배열로 이어진다.""" + snapshot = { + "region_code": "52군산시", + "contents": [ + { + "content_type": LocalContentType.STORY.value, + "kind": "songs", + "title": "가요 다방", + "body": { + "kind": "songs", + "version": 1, + "title": "가요 다방", + "items": [{"title": "금강 나그네", "artist": "이미자"}], + }, + "collected_at": "2026-09-09T00:00:00+00:00", + } + ], + } + local, synced_at = _local(snapshot, None, None) + assert local["story"]["songs"] == [{"title": "금강 나그네", "artist": "이미자"}] + assert synced_at == "2026-09-09T00:00:00+00:00" + + +def test_이야기가_없으면_story_키_자체가_없다(): + """빈 배열을 만들지 않는다 — 렌더러가 '있는데 비었다'와 '없다'를 구분한다.""" + local, _ = _local({"region_code": "52군산시", "contents": []}, None, None) + assert "story" not in local diff --git a/solution/backend/worker/handlers.py b/solution/backend/worker/handlers.py index 07c383a..696334b 100644 --- a/solution/backend/worker/handlers.py +++ b/solution/backend/worker/handlers.py @@ -14,7 +14,7 @@ JobType.VISION ✓ services/vision_service.run_vision — Gemini Vision 사진 분류 + alt JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거) JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트 - JobType.LOCAL_SYNC → local 모듈이 붙을 때 + JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회) JobType.AI_CHECK → reports 모듈이 붙을 때 """ @@ -79,6 +79,7 @@ def _register_builtin(): from services.collect_service import run_collect from services.build_service import run_build from services.copy_service import run_copy + from services.story_service import run_local_sync from services.vision_service import run_vision if JobType.COLLECT.value not in HANDLERS: @@ -89,6 +90,8 @@ def _register_builtin(): HANDLERS[JobType.COPY.value] = run_copy if JobType.BUILD.value not in HANDLERS: HANDLERS[JobType.BUILD.value] = run_build + if JobType.LOCAL_SYNC.value not in HANDLERS: + HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync _register_builtin() diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index 2f86688..ee1d587 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -8,6 +8,8 @@ * 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다. */ +import {SECTION_PROMPT_RULES, SECTION_PROMPTS} from '@o2o/shared'; + export interface SectionDataSpec { /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ kind: string; @@ -83,16 +85,7 @@ export function regionOf(location: string): string { return token ?? location.trim(); } -const PROMPT_RULES = ` -[공통 규칙] -1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. -2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다. -3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다. -4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다. -5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. -6. 설명 문장은 항목당 두 문장을 넘기지 않는다. -7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다. -`; +const PROMPT_RULES = SECTION_PROMPT_RULES; /** * 붙여넣으면 바로 답이 나오는 프롬프트. @@ -163,23 +156,8 @@ export const SECTION_DATA_SPEC: Record = { null, 2, ), - task: `[해야 할 일] -[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다. -1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다. - -[스키마] -{ "kind":"songs", "version":1, "title":"가요 다방", "subtitle":"...", "items":[ - { "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡", - "year":1966, "label":"음반사", "labelColor":"#d4551f", - "story":"곡의 배경 (두 문장 이내, 가사 없이)", - "connection":"[업소]와 이 곡을 잇는 한 문장", - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] }`, - rules: ` -[이 아이템만의 규칙] -· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. -· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. -· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.`, + task: SECTION_PROMPTS.songs.task, + rules: SECTION_PROMPTS.songs.rules, fields: [ {key: 'title', label: '곡 제목'}, {key: 'artist', label: '가수', half: true}, @@ -304,21 +282,8 @@ export const SECTION_DATA_SPEC: Record = { null, 2, ), - task: `[해야 할 일] -[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다. -문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다. - -[스키마] -{ "kind":"people", "version":1, "title":"인물 열전", "items":[ - { "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가", - "oneLine":"한 문장 소개", "imageQuery":"사진 검색어", - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] }`, - rules: ` -[이 아이템만의 규칙] -· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다. -· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다. -· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`, + task: SECTION_PROMPTS.people.task, + rules: SECTION_PROMPTS.people.rules, fields: [ {key: 'name', label: '이름'}, {key: 'aka', label: '호 · 예명', half: true}, @@ -379,21 +344,8 @@ export const SECTION_DATA_SPEC: Record = { null, 2, ), - task: `[해야 할 일] -[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다. -가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다. - -[스키마] -{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[ - { "year":1899, "title":"사건 이름", "summary":"두 문장 이내", - "place":"지금 가 볼 수 있는 자리", "turning":true, - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] }`, - rules: ` -[이 아이템만의 규칙] -· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. -· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. -· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`, + task: SECTION_PROMPTS.chronicle.task, + rules: SECTION_PROMPTS.chronicle.rules, fields: [ {key: 'year', label: '연도', type: 'number', half: true}, {key: 'place', label: '지금 이 자리', half: true}, @@ -443,21 +395,8 @@ export const SECTION_DATA_SPEC: Record = { null, 2, ), - task: `[해야 할 일] -[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다. -사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다. - -[스키마] -{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[ - { "line":"한 문장", "hashtags":["#태그"], "place":"장소", - "postmark":"소인에 찍을 짧은 지명", - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] }`, - rules: ` -[이 아이템만의 규칙] -· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. -· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. -· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`, + task: SECTION_PROMPTS.postcard.task, + rules: SECTION_PROMPTS.postcard.rules, fields: [ {key: 'line', label: '엽서 문장', type: 'area'}, {key: 'place', label: '장소', half: true}, @@ -506,21 +445,8 @@ export const SECTION_DATA_SPEC: Record = { null, 2, ), - task: `[해야 할 일] -[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다. -질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다. - -[스키마] -{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[ - { "question":"질문 한 문장", "hint":"두 문장 이내 힌트", - "topic":"관련 장소·주제", "level":"초등|중등|어른", - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] }`, - rules: ` -[이 아이템만의 규칙] -· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다. -· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다. -· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`, + task: SECTION_PROMPTS.quiz.task, + rules: SECTION_PROMPTS.quiz.rules, fields: [ {key: 'question', label: '질문', type: 'area'}, {key: 'hint', label: '힌트', type: 'area', hint: '정답은 두지 않습니다'}, diff --git a/solution/shared/scripts/export-prompts.mjs b/solution/shared/scripts/export-prompts.mjs new file mode 100644 index 0000000..b426ab3 --- /dev/null +++ b/solution/shared/scripts/export-prompts.mjs @@ -0,0 +1,64 @@ +/** + * 지역 이야기 프롬프트를 백엔드가 읽을 JSON 으로 뽑는다. + * + * npm run export:prompts (레포 루트에서) + * + * ★ 왜 산출물을 커밋하나 + * 백엔드 컨테이너에는 node 도 워크스페이스도 없다. 빌드 때 뽑게 하면 파이썬 이미지에 + * node 를 넣어야 하고, 그러면 두 런타임의 버전을 같이 맞춰야 한다. 산출물을 커밋해 두면 + * 백엔드는 파일 하나만 읽으면 된다 — `scripts/export_openapi.py` 가 반대 방향으로 하는 것과 같다. + * + * ★ 산출물(`services/prompts/section_prompts.json`)은 손으로 고치지 않는다. + * 고칠 자리는 `shared/src/lib/section-prompts.ts` 하나다. 어긋나면 사장님이 복사해 가는 + * 프롬프트와 서버가 도는 프롬프트가 갈린다. + */ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(here, '../../backend/services/prompts/section_prompts.json'); + +// TS 를 그대로 읽을 수 없으므로 원문에서 값을 떼어 낸다 — 이 파일은 순수 상수 선언이라 +// 파서를 세울 이유가 없다. 모양이 바뀌면 아래 검사에서 즉시 터진다. +const src = await import('node:fs').then((fs) => + fs.readFileSync(resolve(here, '../src/lib/section-prompts.ts'), 'utf8'), +); + +function block(name) { + const m = src.match(new RegExp(`export const ${name} = \`([\\s\\S]*?)\`;`)); + if (!m) throw new Error(`${name} 를 찾지 못했다 — section-prompts.ts 의 모양이 바뀌었다`); + return m[1]; +} + +const rules = block('SECTION_PROMPT_RULES'); + +const kinds = ['songs', 'people', 'chronicle', 'postcard', 'quiz']; +const specs = {}; +for (const kind of kinds) { + const body = src.match(new RegExp(`\\n ${kind}: \\{([\\s\\S]*?)\\n \\},`)); + if (!body) throw new Error(`${kind} 항목을 찾지 못했다`); + const take = (field) => { + const m = body[1].match(new RegExp(`${field}: \`([\\s\\S]*?)\`,`)); + if (!m) throw new Error(`${kind}.${field} 를 찾지 못했다`); + return m[1]; + }; + const label = body[1].match(/label: '([^']*)'/); + const maxItems = body[1].match(/maxItems: (\d+)/); + if (!label || !maxItems) throw new Error(`${kind} 의 label/maxItems 를 찾지 못했다`); + specs[kind] = { + kind, + label: label[1], + maxItems: Number(maxItems[1]), + task: take('task'), + rules: take('rules'), + }; +} + +mkdirSync(dirname(OUT), {recursive: true}); +writeFileSync( + OUT, + `${JSON.stringify({_generated: 'npm run export:prompts — 손으로 고치지 않는다', rules, specs}, null, 2)}\n`, + 'utf8', +); +console.log(`프롬프트 ${kinds.length}종 → ${OUT}`); diff --git a/solution/shared/src/index.ts b/solution/shared/src/index.ts index 7700236..a4d79db 100644 --- a/solution/shared/src/index.ts +++ b/solution/shared/src/index.ts @@ -3,4 +3,5 @@ export * from './lib/cn'; export * from './lib/slug'; export * from './lib/facts'; export * from './lib/section-data'; +export * from './lib/section-prompts'; export * from './lib/color'; diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts new file mode 100644 index 0000000..dbbd325 --- /dev/null +++ b/solution/shared/src/lib/section-prompts.ts @@ -0,0 +1,205 @@ +/** + * 지역 이야기 아이템의 **프롬프트** — "이 JSON 을 무엇으로 받아 오나". + * + * ★ 왜 shared 인가 + * 쓰는 쪽이 둘이 됐다. 사장님이 [콘텐츠] 탭에서 복사해 ChatGPT 에 붙여넣는 프롬프트와, + * 서버가 지역 단위로 한 번 돌려 채우는 생성 잡(`services/story_service.py`)이 **같은 문장**을 + * 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가 조용히 생긴다 + * — 읽는 쪽 계약을 shared 에 둔 것(`section-data.ts`)과 같은 이유다. + * + * ★ 백엔드는 이 파일을 직접 못 읽는다(파이썬이다). `npm run export:prompts` 가 + * `solution/backend/services/prompts/section_prompts.json` 으로 뽑고, 백엔드는 그 산출물을 읽는다. + * **손으로 고치지 않는다** — 고칠 자리는 여기 하나다. + * + * 폼 칸(`fields`)·라벨처럼 빌더 UI 만 쓰는 것은 여기 없다(`canvas/dataSpec.ts`). + */ + +/** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */ +export type StoryKind = 'songs' | 'people' | 'chronicle' | 'postcard' | 'quiz'; + +export const STORY_KINDS: StoryKind[] = ['songs', 'people', 'chronicle', 'postcard', 'quiz']; + +export interface SectionPromptSpec { + kind: StoryKind; + /** 화면에 쓰는 이름. 생성 잡의 로그·어드민 목록도 이 이름을 쓴다. */ + label: string; + /** 무엇을 시키나. 머리(업소·지역)와 공통 규칙은 `buildSectionPrompt` 가 붙인다. */ + task: string; + /** 그 아이템에만 걸리는 금지·형식 규칙. */ + rules: string; + /** 한 번에 받아 올 항목 수의 상한. 프롬프트의 숫자와 같아야 한다 — 어긋나면 잘라 버리게 된다. */ + maxItems: number; +} + +/** + * 모든 아이템에 걸리는 규칙. + * + * ★ 2번(빈 값을 지어내지 않는다)과 3번(열리는 출처)이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. + * 모델이 이걸 어기면 검증기가 뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다. + */ +export const SECTION_PROMPT_RULES = ` +[공통 규칙] +1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. +2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다. +3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다. +4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다. +5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. +6. 설명 문장은 항목당 두 문장을 넘기지 않는다. +7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다. +`; + +export const SECTION_PROMPTS: Record = { + songs: { + kind: 'songs', + label: '가요 다방', + maxItems: 8, + task: `[해야 할 일] +[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다. +1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다. + +[스키마] +{ "kind":"songs", "version":1, "title":"가요 다방", "subtitle":"...", "items":[ + { "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡", + "year":1966, "label":"음반사", "labelColor":"#d4551f", + "story":"곡의 배경 (두 문장 이내, 가사 없이)", + "connection":"[업소]와 이 곡을 잇는 한 문장", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. +· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. +· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다. +`, + }, + + people: { + kind: 'people', + label: '인물 열전', + maxItems: 10, + task: `[해야 할 일] +[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다. +문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다. + +[스키마] +{ "kind":"people", "version":1, "title":"인물 열전", "items":[ + { "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가", + "oneLine":"한 문장 소개", "imageQuery":"사진 검색어", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다. +· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다. +· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다. +`, + }, + + chronicle: { + kind: 'chronicle', + label: '시간의 골목', + maxItems: 14, + task: `[해야 할 일] +[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다. +가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다. + +[스키마] +{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[ + { "year":1899, "title":"사건 이름", "summary":"두 문장 이내", + "place":"지금 가 볼 수 있는 자리", "turning":true, + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. +· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. +· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다. +`, + }, + + postcard: { + kind: 'postcard', + label: '오늘의 엽서', + maxItems: 12, + task: `[해야 할 일] +[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다. +사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다. + +[스키마] +{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[ + { "line":"한 문장", "hashtags":["#태그"], "place":"장소", + "postmark":"소인에 찍을 짧은 지명", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. +· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. +· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다. +`, + }, + + quiz: { + kind: 'quiz', + label: '뒤집어 보는 질문', + maxItems: 12, + task: `[해야 할 일] +[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다. +질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다. + +[스키마] +{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[ + { "question":"질문 한 문장", "hint":"두 문장 이내 힌트", + "topic":"관련 장소·주제", "level":"초등|중등|어른", + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다. +· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다. +· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다. +`, + }, +}; + +export interface SectionPromptContext { + /** 업소 이름. 빈 값이면 [업소] 줄을 아예 빼고 지역만으로 묻는다. */ + storeName?: string; + /** 업종 라벨('숙박' · '카페'). 업소 이름이 있을 때만 쓴다. */ + industryLabel?: string; + /** 지명("전북 군산시"). 이 값이 없으면 프롬프트를 만들 수 없다. */ + region: string; + /** 도로명 주소. 지명과 다를 때만 한 줄 더 붙는다. */ + address?: string; +} + +/** + * 붙여넣으면 바로 답이 나오는 프롬프트. + * + * ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고, + * 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다. + * ★ 서버가 지역 단위로 돌 때는 업소가 없다(같은 지역 사이트가 나눠 쓰는 값이다). + * 그때는 [업소] 줄을 빼고 `connection` 같은 업소 연결 필드는 자연히 비게 둔다 — + * 업소 이름을 하나 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다. + */ +export function buildSectionPrompt(spec: SectionPromptSpec, ctx: SectionPromptContext): string { + const region = ctx.region.trim(); + const store = (ctx.storeName ?? '').trim(); + const address = (ctx.address ?? '').trim(); + + const lines = [`너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.`, '']; + if (store) lines.push(`[업소] ${store}${ctx.industryLabel ? ` (${ctx.industryLabel})` : ''}`); + lines.push(`[지역] ${region}`); + if (address && address !== region) lines.push(`[주소] ${address}`); + lines.push(''); + lines.push( + store + ? `아래 '해야 할 일'에서 [지역] = ${region}, [업소] = ${store}.` + : `아래 '해야 할 일'에서 [지역] = ${region}. 업소가 지정되지 않았으므로 특정 업소를 가리키는 문장(connection 등)은 쓰지 않는다.`, + ); + lines.push(''); + + return `${lines.join('\n')} +${spec.task} +${SECTION_PROMPT_RULES}${spec.rules}`; +} diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 5123770..5f730e9 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -164,7 +164,14 @@ export interface ChannelLink { confirmed: boolean; } -import type {ItineraryItem} from '../lib/section-data'; +import type { + ChronicleItem, + ItineraryItem, + PeopleItem, + PostcardItem, + QuizItem, + SongItem, +} from '../lib/section-data'; export interface LocalContents { weather?: WeatherSnapshot; @@ -180,10 +187,29 @@ export interface LocalContents { * ★ 저장하지 않는다. 재료(주변 정보)가 갱신되면 다음 빌드에서 저절로 최신이 된다. */ itineraries?: ItineraryItem[]; + /** + * 지역 이야기 — 서버가 지역 단위로 생성한 가요·인물·연표·엽서·퀴즈. + * + * ★ 사장님이 붙여넣는 같은 종류의 JSON(`theme.sections[].data`)과 **모양이 같다.** + * 화면은 둘을 한 배열로 이어 그린다(`StorySection`) — 사장님 값이 앞이다. + * 그래서 여기 항목을 다른 모양으로 바꾸면 안 된다(`site_payload._local` 주석). + * ★ 왜 업장이 아니라 여기인가: 군산 이야기는 군산 숙소가 같이 쓴다. 업장별로 복제하면 + * 같은 곡 목록이 사이트 수만큼 생긴다(`area_contents` 가 region_code 를 키로 두는 이유). + */ + story?: LocalStories; /** 지역 정보를 마지막으로 갱신한 시각. 화면에 그대로 노출한다(오래된 정보를 숨기지 않는다). */ syncedAt?: string; } +/** 지역 이야기 다섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ +export interface LocalStories { + songs?: SongItem[]; + people?: PeopleItem[]; + chronicle?: ChronicleItem[]; + postcard?: PostcardItem[]; + quiz?: QuizItem[]; +} + /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 8a95f7f..d853335 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -213,7 +213,22 @@ export function sectionBody(payload: SitePayload, id: string): string[] { */ export function sectionItems(payload: SitePayload, id: string) { const section = payload.theme.sections.find((entry) => entry.id === id); - return parseSectionData(id, section?.data); + const parsed = parseSectionData(id, section?.data); + + /* + * ★ 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** (2026-09-09) + * 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고 + * 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`). + * 그걸 사이트마다 `sections[].data` JSON 으로 복사해 두면 지역 하나 고칠 때 사이트 수만큼 + * 고쳐야 한다 — 그래서 payload 에서 자리를 나누고 **읽는 순간에만** 합친다. + * ★ 사장님 것이 앞이다. 자기 가게에 대해 자기가 고른 것이 우리가 모아 온 것보다 먼저다. + * ★ 합치는 자리가 여기 하나인 이유: 다섯 섹션이 모두 이 함수를 거친다. 각자 합치게 하면 + * 한 곳을 빠뜨렸을 때 그 탭만 조용히 사장님 것만 보인다. + */ + const shared = (payload.local.story as Record | undefined)?.[id]; + if (!Array.isArray(shared) || shared.length === 0) return parsed; + + return {...parsed, items: [...parsed.items, ...(shared as T[])]}; } export function unitSpec(payload: SitePayload) {