"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다. ★ 왜 지역 단위인가 이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다. 키를 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) usage = perplexity.read_usage(payload) LOG.i( f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림 · " f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}" ) 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