[feat] solution/backend,shared: 지역 이야기에 사진을 붙인다 — 수집하는 그 자리에서 함께

지역 이야기(연표·엽서)가 활자만으로 서 있었다. 계약(`shared/section-data.ts`)과 렌더러는
`imageUrl` 을 이미 받는데 **아무도 채우지 않았다** — 생성 프롬프트가 사진을 묻지 않고,
붙이는 코드도 없었다.

★ 이미 DB 에 있는 사진을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이 아니라 "마침 우리가
  갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다. **이야기를 만드는 그 순간
  그 대상의 이름으로** 찾아온 것만 쓴다.

★ 사진을 모델에게 묻지 않는다. 모델이 주는 이미지 주소는 대개 존재하지 않거나 남의
  저작물이다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것만 쓴다.

- external/tour_api.find_image: 이름으로 사진 한 장. 권리 판정은 이 파일의 기존 규칙 그대로
  공공누리 **Type1·Type3 만**(발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을
  모르면 버린다). searchKeyword2 는 전국에서 이름만 맞으면 주므로 **주소에 지역 토막이
  없는 결과는 버린다** — '군산항' 을 찾다 다른 지역 동명 시설이 붙으면 그 사진은 이 이야기와
  아무 관계가 없다. 실패하면 None 이다(사진 한 장 때문에 생성을 실패시키지 않는다)
- story_service._attach_images: 연표(place→title) · 엽서(place→postmark)만 찾는다.
  **인물은 넣지 않았다** — 공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다.
  열 명을 찔러야 0건이고, 그 자리는 렌더러가 이니셜로 세우도록 이미 설계돼 있다
  (PeopleItem.imageUrl 주석). 가요·퀴즈도 시안에 사진이 없다. 종류당 6건 상한
- shared/section-prompts(chronicle): "찾아갈 수 있는 자리가 있으면 **반드시** 적는다" 를
  더했다. 기존 문구가 "없으면 뺀다" 뿐이라 모델이 행정 개편류에 place 를 통째로 비웠고
  (실측: 12건 전부 비었다), 그러면 붙일 사진을 찾을 키워드가 없다.
  프롬프트는 한 벌이라 `npm run export:prompts` 로 백엔드 JSON 도 함께 갱신했다

실측(전북 군산시): 연표 3장 · 엽서 6장이 발행본에 실렸다(공공누리 Type1/Type3).
site tsc·eslint 통과 · story·tour_api 테스트 59 passed.
This commit is contained in:
hbyang 2026-09-10 15:08:03 +09:00
parent ba90a193f7
commit 56ed951a6e
4 changed files with 100 additions and 1 deletions

View File

@ -167,6 +167,43 @@ def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT) return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
async def find_image(client: httpx.AsyncClient, keyword: str, region_token: str) -> Optional[str]:
"""이름으로 공공데이터 사진 한 장. 없으면 None.
지역 이야기(연표·엽서)의 항목에 사진을 붙이는 자리다. 이야기는 검색모델이 쓰지만
**사진은 모델에게 묻지 않는다** — 모델이 준 이미지 주소는 대개 존재하지 않거나
남의 저작물이다. 공공데이터가 그 장소의 사진으로 준 것만 쓴다.
★ 권리는 여기서 끝낸다. 이 파일의 규칙 그대로 공공누리 Type1·Type3 만 남긴다
(머리주석). 발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을 모르면 버린다.
★ 지역을 대조한다. `searchKeyword2` 는 전국에서 이름만 맞으면 주므로, 주소에 지역
토막이 없는 결과는 버린다 — '군산항' 을 찾다가 다른 지역 동명 시설 사진이 붙으면
그 사진은 이 지역 이야기와 아무 관계가 없다.
★ 실패는 None 이다. 사진이 없으면 렌더러가 활자만으로 세운다(설계된 폴백) —
사진 한 장 때문에 이야기 생성을 실패시키지 않는다.
"""
word = (keyword or "").strip()
token = (region_token or "").strip()
if not word:
return None
try:
items, _ = await _call(client, "searchKeyword2", keyword=word, arrange="O")
except Exception: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다
return None
for item in items:
image = str(item.get("firstimage") or "").strip()
if not image:
continue
if str(item.get("cpyrhtDivCd") or "").strip().lower() not in _COMMERCIAL_OK_LICENSES:
continue
addr = f"{item.get('addr1') or ''} {item.get('addr2') or ''}"
if token and token not in addr:
continue
return image
return None
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float, async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
*, radius_m: int, content_type_id: str) -> list[dict]: *, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.

View File

@ -21,7 +21,7 @@
"label": "시간의 골목", "label": "시간의 골목",
"maxItems": 14, "maxItems": 14,
"task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", "task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n" "rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\n"
}, },
"postcard": { "postcard": {
"kind": "postcard", "kind": "postcard",

View File

@ -31,6 +31,7 @@ from common.utils.gtime import GTime
from common.logger import LOG from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD from crud.local_content_crud import LocalContentCRUD
from services.grounding import story as grounding from services.grounding import story as grounding
from services.external import tour_api
from services.llm import perplexity from services.llm import perplexity
from services.prompts import story as prompts from services.prompts import story as prompts
@ -73,10 +74,68 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str
return [], [f"호출 실패: {ex}"] return [], [f"호출 실패: {ex}"]
items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind)) items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind))
await _attach_images(kind, items, region_label)
LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림") LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림")
return items, dropped return items, dropped
# 사진을 찾아볼 만한 종류와, 무엇으로 찾을지.
#
# ★ 인물(people)은 넣지 않는다. 공공데이터는 관광지·시설의 사진을 주지 그 사람의 얼굴을
# 주지 않는다 — 열 명을 다 찔러 봐야 0건이고 호출만 열 번이다. 인물 자리는 렌더러가
# 이니셜 활자로 세우도록 이미 설계돼 있고(PeopleItem.imageUrl 주석), 모델이 남긴
# `imageQuery` 가 "어디서 찾을지" 를 대신 말한다.
# ★ 가요(songs)·퀴즈(quiz)도 넣지 않는다. 시안에도 그 자리에 사진이 없다.
_IMAGE_KEYS = {
"chronicle": ("place", "title"), # 그 해의 장소 → 없으면 사건 이름
"postcard": ("place", "postmark"), # 엽서 앞면이 될 장소
}
# 한 종류에서 사진을 찾아볼 항목 수 상한. TourAPI 는 건당 0.3초 안팎이지만,
# 종류마다 열두 개를 다 찌르면 생성 시간이 두 배가 된다 — 앞쪽 항목부터 채운다.
_IMAGE_LOOKUP_LIMIT = 6
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) -> None:
"""항목에 공공데이터 사진을 붙인다. **수집하는 그 자리에서 함께 가져온다.**
★ 왜 여기인가
이미 DB 에 있는 사진(주변 관광지 등)을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이
아니라 "마침 우리가 갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다.
이야기를 만드는 그 순간 그 대상의 이름으로 찾아온 것만 쓴다.
★ 권리 판정은 `tour_api.find_image` 한 곳이다(공공누리 Type1·Type3).
★ 실패해도 조용히 넘어간다 — 사진이 없으면 활자만으로 서는 것이 설계된 모습이다.
"""
fields = _IMAGE_KEYS.get(kind)
if not fields or not items:
return
token = _region_token(region_label)
found = 0
async with tour_api.make_client() as client:
for item in items[:_IMAGE_LOOKUP_LIMIT]:
if item.get("imageUrl"):
continue
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 url:
item["imageUrl"] = url
found += 1
if found:
LOG.i(f"[story] {region_label} {kind}: 공공데이터 사진 {found}장 붙임")
async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict: async def generate_region_stories(region_code: str, region_label: str, kinds: list[str] | None = None) -> dict:
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다. """지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.

View File

@ -114,6 +114,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. · turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. · 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다. · place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.
· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는
사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).
이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.
`, `,
}, },