상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다. 사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
366 lines
20 KiB
Python
366 lines
20 KiB
Python
"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.
|
||
|
||
★ 왜 지역 단위인가
|
||
이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다.
|
||
키를 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.external import tour_api, wikimedia
|
||
from services.llm import perplexity
|
||
from services.local_restaurant_enrichment import enrich_place_restaurants
|
||
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": _MAX_TOKENS.get(kind, 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), region_label)
|
||
items = await _attach_images(kind, items, region_label)
|
||
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
|
||
return items, dropped
|
||
|
||
|
||
# 종류별 응답 토큰 상한. 기본(2048)으로 모자란 종류만 적는다.
|
||
#
|
||
# ★ 왜 (실측 2026-09-15) `reading` 은 30~34꼭지 × 서너 문장이라 다른 종류의 서너 배다.
|
||
# 2048 로 부르면 응답이 **문장 한가운데서 잘려** 오고, 파서는 그걸 "JSON 이 아니다" 로
|
||
# 통째로 버린다 — 여섯 지역 전부 0건이었다. 잘린 것은 재시도해도 같은 자리에서 잘린다.
|
||
# ★ 상한만 올린다. 꼭지 수를 줄이면 화면이 매번 5~6개만 뽑는 의미가 없어진다
|
||
# (`site/sections/items/ReadingSection.tsx`).
|
||
_MAX_TOKENS = {"reading": 8000}
|
||
|
||
# 사진을 무엇으로 찾을지. 종류마다 출처가 다르다.
|
||
#
|
||
# chronicle · postcard 공공데이터(TourAPI) — 장소·시설 사진. 공공누리 Type1/Type3 만
|
||
# people 위키미디어 — 사람 얼굴. 상업 이용 가능 라이선스만
|
||
#
|
||
# ★ 가요·퀴즈는 찾지 않는다. 시안에도 그 자리에 사진이 없다.
|
||
_PLACE_IMAGE_KEYS = {
|
||
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
|
||
"postcard": ("place", "postmark"), # 엽서 앞면이 될 장소
|
||
}
|
||
|
||
# 한 종류에서 사진을 찾아볼 항목 수 상한. 건당 0.3~1초라 열두 개를 다 찌르면 생성이 두 배 걸린다.
|
||
_IMAGE_LOOKUP_LIMIT = 12
|
||
|
||
# ★ 사진이 없으면 항목 자체를 버리는 종류.
|
||
# 엽서는 **앞면 사진이 본체**다. 사진이 없으면 뒷면(문장·우표·소인)만 남아 카드가 반쪽이 되고,
|
||
# 시안과 나란히 놓으면 빈 카드로 보인다(실측 2026-09-10). 연표는 다르다 — 활자만으로도
|
||
# 레일 위에 서므로 사진 없는 항목을 버리면 연표에 구멍이 난다.
|
||
_IMAGE_REQUIRED_KINDS = {"postcard"}
|
||
|
||
|
||
def _region_token(region_label: str) -> str:
|
||
"""주소 대조에 쓸 지역 토막("전북 군산시" → "군산"). 시/군/구 접미사를 뗀다 —
|
||
TourAPI 주소는 '전북특별자치도 군산시 …' 라 표기가 우리와 다를 수 있다."""
|
||
for tok in reversed((region_label or "").split()):
|
||
if tok.endswith(("시", "군", "구")) and len(tok) > 1:
|
||
return tok[:-1]
|
||
return (region_label or "").split()[-1] if region_label else ""
|
||
|
||
|
||
async def _attach_images(kind: str, items: list[dict], region_label: str) -> list[dict]:
|
||
"""항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다.
|
||
|
||
★ **수집하는 그 자리에서 함께 가져온다.** 이미 DB 에 있는 사진을 가져다 쓰지 않는다 —
|
||
그건 "이 항목의 사진" 이 아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가
|
||
그 해의 사진으로 붙는다.
|
||
★ **같은 사진을 두 번 쓰지 않는다.** 네 항목이 같은 시설을 말하면 검색이 같은 사진을
|
||
네 번 준다 — 화면에는 같은 그림 넷이 늘어선다(실측 2026-09-10, 군산근대역사박물관).
|
||
두 번째부터는 사진 없는 것으로 친다.
|
||
★ 권리 판정은 부르는 쪽이 아니라 각 출처 모듈이 한다(tour_api.find_image · wikimedia).
|
||
"""
|
||
found = 0
|
||
used: set[str] = set()
|
||
|
||
if kind == "people":
|
||
async with wikimedia.make_client() as client:
|
||
for item in items[:_IMAGE_LOOKUP_LIMIT]:
|
||
hit = await wikimedia.find_person_image(client, str(item.get("name") or ""))
|
||
if not hit or hit.url in used:
|
||
continue
|
||
item["imageUrl"], item["imageCredit"] = hit.url, hit.credit
|
||
used.add(hit.url)
|
||
found += 1
|
||
elif kind in _PLACE_IMAGE_KEYS:
|
||
fields = _PLACE_IMAGE_KEYS[kind]
|
||
token = _region_token(region_label)
|
||
async with tour_api.make_client() as client:
|
||
for item in items[:_IMAGE_LOOKUP_LIMIT]:
|
||
keyword = next((str(item.get(f) or "").strip() for f in fields if item.get(f)), "")
|
||
if not keyword:
|
||
continue
|
||
url = await tour_api.find_image(client, keyword, token)
|
||
if not url or url in used:
|
||
continue
|
||
item["imageUrl"] = url
|
||
# 공공누리 제1유형도 출처 표시가 조건이다. 사진을 준 곳을 그대로 적는다.
|
||
item["imageCredit"] = "한국관광공사"
|
||
used.add(url)
|
||
found += 1
|
||
|
||
if kind in _IMAGE_REQUIRED_KINDS:
|
||
kept = [i for i in items if i.get("imageUrl")]
|
||
if len(kept) != len(items):
|
||
LOG.i(f"[story] {region_label} {kind}: 사진 없는 {len(items) - len(kept)}건 제외 "
|
||
f"(이 종류는 사진이 본체다)")
|
||
items = kept
|
||
|
||
if found:
|
||
LOG.i(f"[story] {region_label} {kind}: 사진 {found}장 붙임")
|
||
return items
|
||
|
||
|
||
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 missing_kinds(region_code: str) -> list[str]:
|
||
"""이 지역에 아직 없는 이야기 종류. cache-aside 판단용.
|
||
|
||
★ 예전엔 `has_stories` 하나였다 — **한 건이라도 있으면** 다시 부르지 않았다.
|
||
그 가드는 "같은 지역 두 번째 숙소"만 생각한 것이라, **종류가 늘어난 날** 정확히 반대로
|
||
동작한다: 이미 다섯이 들어 있는 지역은 여섯 번째(`daily`)를 영영 못 받는다.
|
||
새 지역에서만 여섯이 채워지고 기존 지역은 다섯에 멈춰, 같은 템플릿을 골라도 지역에
|
||
따라 탭 수가 다른 상태가 된다(실측 2026-09-10, 52군산시).
|
||
★ 요금 가드는 그대로다 — 없는 종류만 부른다. 이미 있는 종류는 여전히 한 번도 다시 안 부른다.
|
||
"""
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
area_contents.DBType(), DBWRType.DB_READ.value,
|
||
lambda s: crud_list(s, region_code),
|
||
)
|
||
if err != ErrorType.SUCCESS:
|
||
# 읽지 못했으면 "없다"고 단정하지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다.
|
||
return []
|
||
have = {str(getattr(row, "kind", "") or "") for row in rows}
|
||
return [kind for kind in prompts.kinds() if kind not in have]
|
||
|
||
|
||
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)·지역 이야기(LLM)는 성격이 다르지만,
|
||
사장님에게는 "주변 이야기가 채워졌나" 하나다. 잡을 나누면 위저드가 여럿을 따로 기다려야
|
||
하고, 하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
|
||
사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패
|
||
비용이 컸지만, 이 셋은 합쳐 1~2분대이고 유료 재호출도 아래 가드가 막는다.
|
||
|
||
★ 업장 것과 지역 것의 반복 단위가 다르다
|
||
반경 수집·여행 일정은 **업장마다** 해야 한다(좌표·업소 이름이 다르다). 이야기는
|
||
**지역에 한 번**이면 된다 — 같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다.
|
||
그래서 이야기 쪽만 가드가 붙는다.
|
||
|
||
★ 멱등하다. 이야기는 순번이 아니라 (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}")
|
||
|
||
# ── 1.5 주변 맛집 보강(Perplexity + 네이버) — 업장마다 ─────────────
|
||
# ★ TourAPI 블록 바로 뒤다. 그쪽이 이미 만든 place_area_refs 개수를 기준으로
|
||
# "10건 미만이면 채운다"를 판단하기 때문이다(services/local_restaurant_enrichment.py).
|
||
out["restaurant_enrichment"] = await enrich_place_restaurants(
|
||
uuid.UUID(str(place_id)), region_label, region_code,
|
||
)
|
||
|
||
# ── 2. 여행 일정(LLM) — 업장마다 ──────────────────────────────────
|
||
# ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라
|
||
# 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다.
|
||
# ★ 이야기는 지역에 한 번이면 되지만 일정은 **업장마다** 필요하다(업소 이름이 프롬프트에 든다)
|
||
# — 반경 수집과 같은 반복 단위다.
|
||
# ★ 이야기 블록 **앞**이다. 저쪽은 "이미 있다" 로 조기 반환하므로, 뒤에 두면 이야기가 다 찬
|
||
# 업장이 일정을 영영 못 받는다.
|
||
if place_id:
|
||
from services.itinerary_llm_service import ensure_generated_by_id
|
||
|
||
out["itineraries"] = await ensure_generated_by_id(uuid.UUID(str(place_id)))
|
||
|
||
# ── 3. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
|
||
if not perplexity.is_configured():
|
||
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
|
||
return out
|
||
# ★ 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). 아니면 **없는 것만** 채운다 —
|
||
# 같은 지역 두 번째 숙소는 부를 것이 없어 곧바로 빠져나간다.
|
||
wanted = payload.get("kinds") or await missing_kinds(region_code)
|
||
if not wanted:
|
||
out["stories"] = {"skipped": "이미 있다"}
|
||
return out
|
||
|
||
out["stories"] = await generate_region_stories(region_code, region_label, wanted)
|
||
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}`). 반경 수집이 업장마다 필요해서다 —
|
||
지역 이야기의 중복 호출은 잡 안의 `missing_kinds` 가드가 막는다.
|
||
★ 부르는 곳이 둘이다: 수집 완료 직후(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
|