o2o-site-AEO/solution/backend/services/story_service.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

264 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다."""
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