o2o-site-AEO/solution/backend/services/snapshot.py
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

375 lines
19 KiB
Python
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.

"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다.
★ 정적 빌드의 경계다. DB 는 **빌드 시점에만** 읽고, 방문자는 DB 와 만나지 않는다.
여기서 만든 스냅샷이 site_versions.snapshot 에 박제되고, 그 뒤로는 그것만 렌더된다.
★ 필터링이 여기 한 곳에만 있다:
fact — VERIFIED / CORRECTED 만
사진 — APPROVED 만 (Vision 신뢰도 미달은 PENDING_REVIEW 로 남아 여기서 빠진다)
FAQ — VERIFIED / CORRECTED 만
지역 — PUBLISHED 만 + 노출 기간 안에 있는 것만 (운영자가 검수해 발행한 것만 나간다)
게이트(publish_gate)가 뒤에서 한 번 더 보지만, 애초에 미검증 값이 스냅샷에 들어오면 안 된다.
★ 지역 정보가 왜 여기서 읽히나(services/site_payload 가 아니라).
site_payload 는 "DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐"이 원칙이다. 거기서 지역 캐시를
읽으면 발행 시점과 렌더 시점 사이에 지역 정보가 바뀌었을 때 '스냅샷과 다른 페이지'가 나온다.
그래서 지역 정보도 다른 재료와 똑같이 여기서 걸러 스냅샷에 박제하고, site_payload 는 모양만 바꾼다.
"""
import uuid
from datetime import datetime, timezone
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,
site_sections, sites,
)
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
LocalSource,
MediaStatus,
PlaceCategory,
)
from common.logger import LOG
from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
# ★ 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004
# `uq_local_contents_kind`). 그래서 다섯 종류가 위 상한 안에서 나란히 선다.
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
# ★ TourAPI 원본을 통째로 담은 필드라 정규화된 값과 100% 중복이고, 축제 1건의 크기를 두 배로 만든다.
# site_payload 는 정규화된 키만 읽는다.
_LOCAL_BODY_DROP = ("raw",)
async def build_snapshot(place) -> dict:
"""사업장 1건의 빌드 스냅샷을 만든다. 노출 가능한 것만 담는다."""
pid = place.place_id if isinstance(place.place_id, uuid.UUID) else uuid.UUID(str(place.place_id))
category = PlaceCategory(place.category)
schema = get_schema(category)
fact_rows = await _select(
select(place_facts).where(
place_facts.place_id == pid,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHABLE),
)
)
unit_rows = await _select(
select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc())
)
faq_rows = await _select(
select(place_faqs).where(
place_faqs.place_id == pid,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_(_PUBLISHABLE),
).order_by(place_faqs.sort_order.asc())
)
# ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다.
media_rows = await _select(
select(place_photos).where(
place_photos.place_id == pid,
place_photos.deleted == False, # noqa: E712
place_photos.status == MediaStatus.APPROVED.value,
).order_by(place_photos.sort_order.asc())
)
local_rows = await _local_contents(place)
snapshot = {
"place": {
"name": place.name,
"category": category.value,
"category_name": schema.label,
"road_address": place.road_address,
"address": place.address,
"phone": place.phone,
"latitude": str(place.latitude) if place.latitude is not None else None,
"longitude": str(place.longitude) if place.longitude is not None else None,
},
"facts": [
{
"key": r.key,
"label": (schema.get(r.key).label if schema.get(r.key) else r.key),
"value": r.value,
"unit": r.unit,
"scope": (schema.get(r.key).scope if schema.get(r.key) else "place"),
"unit_id": str(r.unit_id) if r.unit_id else None,
# 게이트가 다시 볼 수 있게 상태를 함께 싣는다(스냅샷은 감사 기록이기도 하다).
"status": r.status,
# ★ 출처와 확인 시각도 박제한다. 발행 payload(FactEntry)가 이 값을 그대로 싣고,
# 화면은 "언제 무엇으로 확인된 값인지"를 보여준다 — 출처 없는 사실은 우리 규칙 위반이다.
"source_type": r.source_type,
"source_url": r.source_url,
"collected_at": _iso(r.collected_at),
"verified_at": _iso(r.verified_at),
}
for r in fact_rows
],
# sort_order 를 함께 싣는다 — 객실·메뉴 순서는 사장님이 정한 것이고, 발행본도 그 순서를 따른다.
"units": [{"unit_id": str(r.unit_id), "name": r.name, "sort_order": r.sort_order} for r in unit_rows],
"faqs": [
{
"faq_id": str(r.faq_id),
"question": r.question,
"answer": r.answer,
# 렌더러가 노출 필터를 한 번 더 걸 수 있게 상태·출처를 싣는다(fact 와 같은 규칙).
"status": r.status,
"generated_by": r.generated_by,
"sort_order": r.sort_order,
}
for r in faq_rows
],
# ★ alt 가 없는 사진은 넣지 않는다 — 빌더가 렌더하지 않고, 접근성·AI 검색 신호도 잃는다.
# ★ source_type/origin_url 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라
# (docs/DECISIONS.md 1-2) 결론이 나면 출처로 걸러내야 한다. 여기서 버리면 재수집밖에 답이 없다.
"media": [
{
"media_id": str(r.media_id),
"url": r.url,
"origin_url": r.origin_url,
"source_type": r.source_type,
"label": r.label,
"alt_text": r.alt_text,
"width": r.width,
"height": r.height,
"sort_order": r.sort_order,
"unit_id": str(r.unit_id) if r.unit_id else None,
}
for r in media_rows
if (r.alt_text or "").strip()
],
# ★ 지역 정보. 캐시 키가 place_id 가 아니라 region_code 라 사업장의 지역 코드로 찾는다
# (같은 지역 사이트 50개여도 외부 조회는 1회 — 그게 이 테이블이 region_code 로 묶인 이유다).
# region_code 가 비어 있으면 조회할 키가 없으므로 빈 목록이다. 그 경우 지어내지 않는다 —
# 지역 코드는 수집 파이프라인이 채우는 값이고, 없으면 아직 지역을 특정하지 못한 사업장이다.
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
"local": local_rows,
}
LOG.i(
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
f"지역 {len(snapshot['local']['contents'])}"
)
return snapshot
async def _local_contents(place) -> dict:
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
두 캐시를 합친다 —
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외
★ 노출 가능 = PUBLISHED + 노출 기간 안.
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
display_start_at/display_end_at 은 운영자가 정한 노출 창이다. 기간이 지난 축제를
"이번 주말 행사"로 걸어두는 것도 틀린 정보라 여기서 함께 막는다.
★ expires_at 은 보지 않는다. 모델 주석대로 그건 '갱신 대상'이라는 표시지 '못 쓰는 값'이 아니다
(외부 API 가 죽어도 직전 값을 유지하는 게 이 캐시의 규약이다). 게다가 날씨는 렌더러가
하이드레이션 뒤 최신값으로 덮어쓴다(solution/site/src/lib/use-live-weather.ts).
"""
# ★ getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다.
region_code = str(getattr(place, "region_code", None) or "").strip()
if not region_code:
# ★ 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다.
# places.region_code 를 채우는 곳은 신원 확정(place_service.verify) 한 곳뿐이라,
# 그 코드가 생기기 전에 만들어진 사업장은 영영 NULL 로 남는다(실측: 28곳 중 25곳).
# 그 사업장은 날씨·축제·주변 관광지가 통째로 비고, 발행본에서 날씨 섹션이 아예
# 사라진다 — 에디터에는 보이는데(폴백값을 그리므로) 사이트에는 없는 그 자리다.
# 여기서 유도하면 신원을 다시 확정하지 않아도 다음 발행부터 지역 정보가 붙는다.
# ★ 지어내지 않는 규칙은 그대로다. region_key 는 주소에서 뽑을 뿐이고,
# 주소가 없거나 형식이 다르면 None 이다(그때는 비는 게 맞다).
region_code = region_key(
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
) or ""
now = datetime.now(timezone.utc)
contents: list[dict] = []
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
# ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
if region_code:
query = (
select(area_contents)
.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),
)
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen)
# ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ──
# ★ 2026-09-09 에 자리를 갈랐다. 공용 실체는 지역이 나눠 쓰고(거리를 담을 수 없다),
# 거리·숨김은 사이트마다 다르다. 그래서 관계 테이블이 아니라 **사이트 섹션**에서 읽는다.
# 정렬은 여기가 한다 — 사진 있는 것 먼저, 그다음 가까운 순(2026-09-07 결정).
# 저장 쪽에 정렬을 구워 두면 기준이 바뀔 때 전 사이트를 다시 써야 한다.
place_id = getattr(place, "place_id", None)
if place_id is not None:
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),
)
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
)
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 {}
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
def _iso(value) -> str | None:
"""datetime → ISO8601 문자열.
★ 스냅샷은 JSONB 컬럼에 그대로 들어간다 — datetime 을 그대로 넣으면 직렬화에서 터진다.
DB 의 timestamptz 는 naive UTC 로 올라오므로(GTime 규약) UTC 를 명시해 둔다."""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
async def _select(query) -> list:
err, rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query),
)
return list(rows) if err == ErrorType.SUCCESS else []