o2o-site-AEO/solution/backend/services/story_service.py
hbyang a7fcc14e7d [feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
2026-09-10 15:19:23 +09:00

322 lines
17 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.

"""지역 이야기 생성 — 가요·인물·연표·엽서·퀴즈를 **지역 단위로 한 번** 채운다.
★ 왜 지역 단위인가
이 다섯은 업장의 사실이 아니라 도시의 사실이다. 군산 이야기는 군산 숙소가 같이 쓴다.
키를 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.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))
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 has_stories(region_code: str) -> bool:
"""이 지역에 이미 이야기가 있나. cache-aside 판단용 — 한 건이라도 있으면 다시 부르지 않는다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: crud_list(s, region_code),
)
return err == ErrorType.SUCCESS and bool(rows)
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)는 성격이 다르지만, 사장님에게는
"주변 이야기가 채워졌나" 하나다. 잡을 둘로 나누면 위저드가 둘을 따로 기다려야 하고,
하나만 끝난 상태로 에디터에 들어가면 절반만 그려진 화면을 보게 된다.
사진 분석(VISION)을 수집에서 떼어 낸 것과는 사정이 다르다 — 그건 각각 몇 분이라 실패
비용이 컸지만, 이 둘은 합쳐 1분대이고 유료 재호출도 아래 가드가 막는다.
★ 업장 것과 지역 것의 반복 단위가 다르다
반경 수집은 **업장마다** 해야 한다(좌표가 다르다). 이야기는 **지역에 한 번**이면 된다 —
같은 지역 두 번째 숙소는 이미 있는 것을 그대로 쓴다. 그래서 이야기 쪽만 가드가 붙는다.
★ 멱등하다. 이야기는 순번이 아니라 (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}")
# ── 2. 지역 이야기(LLM) — 지역에 한 번 ────────────────────────────
if not perplexity.is_configured():
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
return out
if await has_stories(region_code):
# ★ 같은 지역 두 번째 숙소다. 다시 부르면 같은 답에 요금만 두 번 낸다.
out["stories"] = {"skipped": "이미 있다"}
return out
out["stories"] = await generate_region_stories(region_code, region_label, payload.get("kinds"))
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}`). 반경 수집이 업장마다 필요해서다 —
지역 이야기의 중복 호출은 잡 안의 `has_stories` 가드가 막는다.
★ 부르는 곳이 둘이다: 수집 완료 직후(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