[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:
parent
64ce467f21
commit
c4af53613e
@ -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회는 반대로 낭비다.
|
||||
|
||||
@ -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` 를 예약 안내 섹션 안에 넣었다.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
18
postgres-init/migrations/0007_story_rows_per_kind.sql
Normal file
18
postgres-init/migrations/0007_story_rows_per_kind.sql
Normal 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;
|
||||
@ -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 는 아직 지우지 않는다. 읽는 코드가 옮겨 간 것을 확인한 뒤 별도 번호로 뗀다 —
|
||||
-- 같은 마이그레이션에서 옮기고 지우면, 이관이 틀렸을 때 되돌릴 원본이 없다.
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
50
solution/backend/crud/site_section_crud.py
Normal file
50
solution/backend/crud/site_section_crud.py
Normal 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(),
|
||||
},
|
||||
),
|
||||
)
|
||||
@ -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
|
||||
|
||||
76
solution/backend/services/external/tour_api.py
vendored
76
solution/backend/services/external/tour_api.py
vendored
@ -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:
|
||||
|
||||
118
solution/backend/services/grounding/story.py
Normal file
118
solution/backend/services/grounding/story.py
Normal 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
|
||||
@ -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 []
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
41
solution/backend/services/prompts/section_prompts.json
Normal file
41
solution/backend/services/prompts/section_prompts.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
solution/backend/services/prompts/story.py
Normal file
61
solution/backend/services/prompts/story.py
Normal 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']}"
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
237
solution/backend/services/story_service.py
Normal file
237
solution/backend/services/story_service.py
Normal 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
|
||||
144
solution/backend/tests/test_story_generation.py
Normal file
144
solution/backend/tests/test_story_generation.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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: '정답은 두지 않습니다'},
|
||||
|
||||
64
solution/shared/scripts/export-prompts.mjs
Normal file
64
solution/shared/scripts/export-prompts.mjs
Normal 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}`);
|
||||
@ -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';
|
||||
|
||||
205
solution/shared/src/lib/section-prompts.ts
Normal file
205
solution/shared/src/lib/section-prompts.ts
Normal 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}`;
|
||||
}
|
||||
@ -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 = '맑음' | '흐림' | '비' | '눈';
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user