From 56ed951a6e647d295878e28205b72c1fcd10e8f6 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 15:08:03 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,shared:=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=9D=B4=EC=95=BC=EA=B8=B0=EC=97=90=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=EC=9D=84=20=EB=B6=99=EC=9D=B8=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=ED=95=98=EB=8A=94=20=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=84=9C=20=ED=95=A8=EA=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지역 이야기(연표·엽서)가 활자만으로 서 있었다. 계약(`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. --- .../backend/services/external/tour_api.py | 37 ++++++++++++ .../services/prompts/section_prompts.json | 2 +- solution/backend/services/story_service.py | 59 +++++++++++++++++++ solution/shared/src/lib/section-prompts.ts | 3 + 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index db90265..a6b066d 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -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]: """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 9535833..16c1264 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -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", diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index ee34a61..56604b1 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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` 에 넣는다. 종류별 채택 건수를 돌려준다. diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index dbbd325..73f7800 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -114,6 +114,9 @@ export const SECTION_PROMPTS: Record = { · turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다. · 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다. · place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다. +· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는 + 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물). + 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다. `, },