[feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다

가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/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) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-09 17:08:31 +09:00
parent 64ce467f21
commit c4af53613e
27 changed files with 1502 additions and 190 deletions

View File

@ -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회는 반대로 낭비다.

View File

@ -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` 를 예약 안내 섹션 안에 넣었다.

View File

@ -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"

View File

@ -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);

View File

@ -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;

View File

@ -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 는 아직 지우지 않는다. 읽는 코드가 옮겨 간 것을 확인한 뒤 별도 번호로 뗀다 —
-- 같은 마이그레이션에서 옮기고 지우면, 이관이 틀렸을 때 되돌릴 원본이 없다.

View File

@ -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):

View File

@ -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,

View File

@ -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(),
},
),
)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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 []

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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']}"

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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<string, SectionDataSpec> = {
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<string, SectionDataSpec> = {
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<string, SectionDataSpec> = {
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<string, SectionDataSpec> = {
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<string, SectionDataSpec> = {
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: '정답은 두지 않습니다'},

View File

@ -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}`);

View File

@ -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';

View File

@ -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<StoryKind, SectionPromptSpec> = {
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}`;
}

View File

@ -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 = '맑음' | '흐림' | '비' | '눈';

View File

@ -213,7 +213,22 @@ export function sectionBody(payload: SitePayload, id: string): string[] {
*/
export function sectionItems<T extends object>(payload: SitePayload, id: string) {
const section = payload.theme.sections.find((entry) => entry.id === id);
return parseSectionData<T>(id, section?.data);
const parsed = parseSectionData<T>(id, section?.data);
/*
* ★ 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** (2026-09-09)
* 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고
* 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`).
* 그걸 사이트마다 `sections[].data` JSON 으로 복사해 두면 지역 하나 고칠 때 사이트 수만큼
* 고쳐야 한다 — 그래서 payload 에서 자리를 나누고 **읽는 순간에만** 합친다.
* ★ 사장님 것이 앞이다. 자기 가게에 대해 자기가 고른 것이 우리가 모아 온 것보다 먼저다.
* ★ 합치는 자리가 여기 하나인 이유: 다섯 섹션이 모두 이 함수를 거친다. 각자 합치게 하면
* 한 곳을 빠뜨렸을 때 그 탭만 조용히 사장님 것만 보인다.
*/
const shared = (payload.local.story as Record<string, unknown[]> | undefined)?.[id];
if (!Array.isArray(shared) || shared.length === 0) return parsed;
return {...parsed, items: [...parsed.items, ...(shared as T[])]};
}
export function unitSpec(payload: SitePayload) {