세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
그 종류를 몰랐던 것과 같은 사고).
· shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
(프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
· backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
· site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
· 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다
② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
`tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
(실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
· `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
· 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
· `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
· 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")
③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
· `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
· `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
· 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다
문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.
검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
357 lines
19 KiB
Python
357 lines
19 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": 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
|
||
|
||
|
||
# 사진을 무엇으로 찾을지. 종류마다 출처가 다르다.
|
||
#
|
||
# 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
|