[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)
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,
*, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.

View File

@ -21,7 +21,7 @@
"label": "시간의 골목",
"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://...\"} } ] }",
"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": {
"kind": "postcard",

View File

@ -31,6 +31,7 @@ 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
from services.llm import perplexity
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}"]
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)}건 버림")
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:
"""지역 하나의 이야기를 생성해 `area_contents` 에 넣는다. 종류별 채택 건수를 돌려준다.

View File

@ -114,6 +114,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
· turning true . 3~4 .
· . .
· place . .
· ** .**
( , , ).
.
`,
},