[feat] solution/backend: 일정을 스냅샷에서 읽는다 — 즉석 계산을 잠시 쉬게 한다

This commit is contained in:
김성경 2026-09-11 11:49:51 +09:00
parent 2824701030
commit 8dba691274
3 changed files with 94 additions and 13 deletions

View File

@ -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

View File

@ -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:

View File

@ -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