"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.""" 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 # 다섯을 **순차로** 부른다. # 채널 발견(90s)보다 길게 잡는다. _TIMEOUT = httpx.Timeout(240.0, connect=10.0) # 생성분에는 노출 종료가 없다. _DISPLAY_END = None # 봉투 버전. _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 # 종류별 응답 토큰 상한. _MAX_TOKENS = {"reading": 8000} # 사진을 무엇으로 찾을지. _PLACE_IMAGE_KEYS = { "chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름 "postcard": ("place", "postmark"), # 엽서 앞면이 될 장소 } # 한 종류에서 사진을 찾아볼 항목 수 상한. _IMAGE_LOOKUP_LIMIT = 12 # 사진이 없으면 항목 자체를 버리는 종류. _IMAGE_REQUIRED_KINDS = {"postcard"} def _region_token(region_label: str) -> str: """주소 대조에 쓸 지역 토막("전북 군산시" → "군산").""" 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]: """항목에 사진을 붙이고, 사진이 본체인 종류는 못 붙은 항목을 버린다.""" 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). 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 아님). 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]: """이 지역에 아직 없는 이야기 종류.""" 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 = 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 + 네이버) — 업장마다 ───────────── out["restaurant_enrichment"] = await enrich_place_restaurants( uuid.UUID(str(place_id)), region_label, region_code, ) # ── 2. 여행 일정(LLM) — 업장마다 ────────────────────────────────── 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: """프롬프트에 넣을 지명("전북특별자치도 군산시").""" 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 를 돌려준다.""" 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