여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다."""
|
||
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
|