From 8dba6912747648ac20e15b86633d93ee5c4560a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 11 Sep 2026 11:49:51 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=EB=8B=A4=20=E2=80=94=20=EC=A6=89=EC=84=9D=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=9D=84=20=EC=9E=A0=EC=8B=9C=20=EC=89=AC?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solution/backend/services/site_payload.py | 23 +++---- solution/backend/services/snapshot.py | 15 +++- .../backend/tests/test_itinerary_payload.py | 69 +++++++++++++++++++ 3 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 solution/backend/tests/test_itinerary_payload.py diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 1f2de94..8db80de 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -31,7 +31,6 @@ from common.enums import ( SourceType, ) from common.logger import LOG -from services.itinerary import build_itineraries # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 @@ -649,15 +648,17 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) fact·사진과 같은 분업이다. ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도 payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다. - ★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 — - 재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py). - 렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다.""" + ★ itineraries(1박2일·2박3일 각 5개)는 **스냅샷에서 읽는다.** 예전에는 이 자리에서 + 거리 기반으로 즉석 계산했다(services/itinerary.py) — 거리 계산은 공짜라 그게 맞았다. + LLM 생성으로 바뀌면서 건당 20~50초·유료가 되어 표에 저장하고 그걸 읽는다 + (services/itinerary_llm_service · tmp/superpowers/specs/2026-09-11-llm-itinerary-design.md). + ★ 여기서 DB 를 읽지 않는다. 읽는 곳은 services/snapshot._local_contents 하나다 — + 이 함수가 순수해야 "스냅샷과 다른 페이지"가 생기지 않는다(파일 상단 원칙). + ★ services/itinerary.py 는 지우지 않았다. 고도화해서 되살릴 때 이 블록을 되돌린다.""" contents = (snapshot_local or {}).get("contents") or [] # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07). local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []} synced_at = None - # 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다. - raw = {"attractions": [], "restaurants": [], "festivals": []} for row in contents: if not isinstance(row, dict): @@ -678,17 +679,14 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) entry = _festival(row) if entry: local["festivals"].append(entry) - raw["festivals"].append(row) elif content_type == LocalContentType.ATTRACTION.value: entry = _local_place(row, "관광지") if entry: local["attractions"].append(entry) - raw["attractions"].append(row) elif content_type == LocalContentType.RESTAURANT.value: entry = _local_place(row, "맛집") if entry: local["restaurants"].append(entry) - raw["restaurants"].append(row) elif content_type == LocalContentType.COURSE.value: entry = _local_place(row, "여행코스") if entry: @@ -704,9 +702,10 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) local.setdefault("story", {})[kind] = [i for i in items if isinstance(i, dict)] # 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다. - itineraries = build_itineraries( - base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"] - ) + itineraries = [ + course for course in ((snapshot_local or {}).get("itineraries") or []) + if isinstance(course, dict) + ] if itineraries: local["itineraries"] = itineraries diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index bea1ef3..975aa4f 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -273,7 +273,20 @@ async def _local_contents(place) -> dict: [_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value ) - return {"region_code": region_code or None, "contents": contents} + # ── 여행 일정(place_itineraries) — 업장마다 기간당 한 행 ────────── + # ★ 여기서 **읽기만** 한다. 생성은 잡·빌드가 한다(services/itinerary_llm_service). + # 스냅샷 조립 안에서 LLM 을 부르면 발행 한 번이 1분 늘고, 캔버스 조회도 같은 길을 탄다. + itineraries: list[dict] = [] + if place_id is not None: + from services.itinerary_llm_service import get_itineraries + + itineraries = await get_itineraries(place_id) + + return { + "region_code": region_code or None, + "contents": contents, + "itineraries": itineraries, + } class _Row: diff --git a/solution/backend/tests/test_itinerary_payload.py b/solution/backend/tests/test_itinerary_payload.py new file mode 100644 index 0000000..b08f0e2 --- /dev/null +++ b/solution/backend/tests/test_itinerary_payload.py @@ -0,0 +1,69 @@ +"""스냅샷의 일정이 payload 로 그대로 나가는가. + +★ `site_payload._local` 은 **순수 함수**다(DB 도 파일도 안 건드린다). 그래서 여기서는 + 스냅샷 dict 를 손으로 만들어 넣는다 — 발행본이 스냅샷과 다른 페이지가 되지 않게 하는 + 그 원칙(`site_payload.to_site_payload` 머리주석)을 테스트도 같은 방식으로 따른다. +""" +from services.site_payload import _local + + +def _course(name: str, duration: str) -> dict: + return { + "name": name, "duration": duration, "audience": "누구에게나", "why": "이유.", + "days": [{"label": "1일차", "startTime": "14:00", + "stops": [{"name": "장소", "minutes": 60, "moveMinutes": 10, + "searchQuery": "장소", "latitude": 35.99, "longitude": 126.71}]}], + } + + +def test_itineraries_pass_through_untouched(): + """검증: 스냅샷에 일정이 있을 때. + 기대결과: payload.local.itineraries 로 모양 그대로 나간다 — 렌더러 계약을 여기서 바꾸지 않는다.""" + snapshot_local = { + "region_code": "52군산시", + "contents": [], + "itineraries": [_course("코스A", "1박 2일"), _course("코스B", "2박 3일")], + } + local, _synced = _local(snapshot_local, 35.98642, 126.70612) + + assert [i["name"] for i in local["itineraries"]] == ["코스A", "코스B"] + assert local["itineraries"][0]["days"][0]["stops"][0]["latitude"] == 35.99 + + +def test_no_itineraries_key_when_empty(): + """검증: 일정이 없을 때. + 기대결과: 키를 만들지 않는다 — 렌더러는 없는 필드를 무시하고, 빈 배열은 '만들었는데 비었다'로 읽힌다.""" + local, _synced = _local({"region_code": "52군산시", "contents": [], "itineraries": []}, 35.98, 126.70) + assert "itineraries" not in local + + +def test_old_snapshot_without_the_key_does_not_break(): + """검증: 이 기능 전에 구운 스냅샷(키가 아예 없다). + 기대결과: 빈 채로 지나간다 — 다음 빌드에서 채워진다.""" + local, _synced = _local({"region_code": "52군산시", "contents": []}, 35.98, 126.70) + assert "itineraries" not in local + + +def test_non_dict_entries_are_dropped(): + """검증: body 에 dict 가 아닌 것이 섞였을 때. + 기대결과: 그것만 버리고 나머지는 살린다.""" + snapshot_local = {"contents": [], "itineraries": [_course("코스A", "1박 2일"), "깨진 값", None]} + local, _synced = _local(snapshot_local, 35.98, 126.70) + assert [i["name"] for i in local["itineraries"]] == ["코스A"] + + +def test_distance_based_algorithm_is_not_used_anymore(): + """검증: 좌표와 주변 관광지·맛집이 있어도 알고리즘 일정이 끼지 않는다. + 기대결과: 스냅샷의 itineraries 만 나간다 — services/itinerary.py 는 잠시 쓰지 않는다 + (2026-09-11 결정). 되살릴 때 이 테스트를 함께 고친다.""" + snapshot_local = { + "contents": [ + {"content_type": 3, "title": "관광지", "latitude": 35.99, "longitude": 126.71, + "body": {"name": "관광지", "category": "관광지"}}, + {"content_type": 4, "title": "맛집", "latitude": 35.98, "longitude": 126.70, + "body": {"name": "맛집", "category": "맛집"}}, + ], + "itineraries": [], + } + local, _synced = _local(snapshot_local, 35.98642, 126.70612) + assert "itineraries" not in local