o2o-site-AEO/solution/backend/services/story_service.py
Mina Choi 328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00

252 lines
14 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.

"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.
★ 왜 지역 단위인가
이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다.
키를 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 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)는 성격이 다르지만, 사장님에게는
"주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고,
하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
사진 분석(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
# ★ 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). 아니면 **없는 것만** 채운다 —
# 같은 지역 두 번째 숙소는 부를 것이 없어 곧바로 빠져나간다.
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