o2o-site-AEO/postgres-init/migrations/0008_personalization_to_site_sections.sql
Mina Choi c4af53613e [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>
2026-09-09 17:08:31 +09:00

82 lines
5.7 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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