지역 이야기(연표·엽서)가 활자만으로 서 있었다. 계약(`shared/section-data.ts`)과 렌더러는 `imageUrl` 을 이미 받는데 **아무도 채우지 않았다** — 생성 프롬프트가 사진을 묻지 않고, 붙이는 코드도 없었다. ★ 이미 DB 에 있는 사진을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이 아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다. **이야기를 만드는 그 순간 그 대상의 이름으로** 찾아온 것만 쓴다. ★ 사진을 모델에게 묻지 않는다. 모델이 주는 이미지 주소는 대개 존재하지 않거나 남의 저작물이다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것만 쓴다. - external/tour_api.find_image: 이름으로 사진 한 장. 권리 판정은 이 파일의 기존 규칙 그대로 공공누리 **Type1·Type3 만**(발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을 모르면 버린다). searchKeyword2 는 전국에서 이름만 맞으면 주므로 **주소에 지역 토막이 없는 결과는 버린다** — '군산항' 을 찾다 다른 지역 동명 시설이 붙으면 그 사진은 이 이야기와 아무 관계가 없다. 실패하면 None 이다(사진 한 장 때문에 생성을 실패시키지 않는다) - story_service._attach_images: 연표(place→title) · 엽서(place→postmark)만 찾는다. **인물은 넣지 않았다** — 공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 열 명을 찔러야 0건이고, 그 자리는 렌더러가 이니셜로 세우도록 이미 설계돼 있다 (PeopleItem.imageUrl 주석). 가요·퀴즈도 시안에 사진이 없다. 종류당 6건 상한 - shared/section-prompts(chronicle): "찾아갈 수 있는 자리가 있으면 **반드시** 적는다" 를 더했다. 기존 문구가 "없으면 뺀다" 뿐이라 모델이 행정 개편류에 place 를 통째로 비웠고 (실측: 12건 전부 비었다), 그러면 붙일 사진을 찾을 키워드가 없다. 프롬프트는 한 벌이라 `npm run export:prompts` 로 백엔드 JSON 도 함께 갱신했다 실측(전북 군산시): 연표 3장 · 엽서 6장이 발행본에 실렸다(공공누리 Type1/Type3). site tsc·eslint 통과 · story·tour_api 테스트 59 passed.
297 lines
16 KiB
Python
297 lines
16 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
|
||
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))
|
||
await _attach_images(kind, items, region_label)
|
||
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
|
||
return items, dropped
|
||
|
||
|
||
# 사진을 찾아볼 만한 종류와, 무엇으로 찾을지.
|
||
#
|
||
# ★ 인물(people)은 넣지 않는다. 공공데이터는 관광지·시설의 사진을 주지 그 사람의 얼굴을
|
||
# 주지 않는다 — 열 명을 다 찔러 봐야 0건이고 호출만 열 번이다. 인물 자리는 렌더러가
|
||
# 이니셜 활자로 세우도록 이미 설계돼 있고(PeopleItem.imageUrl 주석), 모델이 남긴
|
||
# `imageQuery` 가 "어디서 찾을지" 를 대신 말한다.
|
||
# ★ 가요(songs)·퀴즈(quiz)도 넣지 않는다. 시안에도 그 자리에 사진이 없다.
|
||
_IMAGE_KEYS = {
|
||
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
|
||
"postcard": ("place", "postmark"), # 엽서 앞면이 될 장소
|
||
}
|
||
|
||
# 한 종류에서 사진을 찾아볼 항목 수 상한. TourAPI 는 건당 0.3초 안팎이지만,
|
||
# 종류마다 열두 개를 다 찌르면 생성 시간이 두 배가 된다 — 앞쪽 항목부터 채운다.
|
||
_IMAGE_LOOKUP_LIMIT = 6
|
||
|
||
|
||
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) -> None:
|
||
"""항목에 공공데이터 사진을 붙인다. **수집하는 그 자리에서 함께 가져온다.**
|
||
|
||
★ 왜 여기인가
|
||
이미 DB 에 있는 사진(주변 관광지 등)을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이
|
||
아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다.
|
||
이야기를 만드는 그 순간 그 대상의 이름으로 찾아온 것만 쓴다.
|
||
★ 권리 판정은 `tour_api.find_image` 한 곳이다(공공누리 Type1·Type3).
|
||
★ 실패해도 조용히 넘어간다 — 사진이 없으면 활자만으로 서는 것이 설계된 모습이다.
|
||
"""
|
||
fields = _IMAGE_KEYS.get(kind)
|
||
if not fields or not items:
|
||
return
|
||
|
||
token = _region_token(region_label)
|
||
found = 0
|
||
async with tour_api.make_client() as client:
|
||
for item in items[:_IMAGE_LOOKUP_LIMIT]:
|
||
if item.get("imageUrl"):
|
||
continue
|
||
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 url:
|
||
item["imageUrl"] = url
|
||
found += 1
|
||
if found:
|
||
LOG.i(f"[story] {region_label} {kind}: 공공데이터 사진 {found}장 붙임")
|
||
|
||
|
||
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
|