[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:
parent
ba90a193f7
commit
56ed951a6e
37
solution/backend/services/external/tour_api.py
vendored
37
solution/backend/services/external/tour_api.py
vendored
@ -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]:
|
||||
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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` 에 넣는다. 종류별 채택 건수를 돌려준다.
|
||||
|
||||
|
||||
@ -114,6 +114,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
||||
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
|
||||
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
|
||||
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.
|
||||
· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는
|
||||
사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).
|
||||
이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.
|
||||
`,
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user