diff --git a/solution/backend/services/grounding/itinerary.py b/solution/backend/services/grounding/itinerary.py index d5f3ba6..454b957 100644 --- a/solution/backend/services/grounding/itinerary.py +++ b/solution/backend/services/grounding/itinerary.py @@ -98,11 +98,34 @@ def _fit_stops(stops: list, start_minutes: int, end_minutes: int) -> list[dict]: return kept -def _apply_schedule(course: dict, duration: str) -> dict | None: +def _lodging_stop(place_name: str, place_lat: float | None, place_lng: float | None) -> dict: + """업소 자신을 정거장 모양으로. 모델이 지어낼 값이 아니다 — `places` 테이블 값 그대로다. + + ★ minutes·moveMinutes 는 0 이다. 여기 "머무는" 게 아니라 출발·복귀 지점일 뿐이고, + 실제 이동 시간은 이미 다음 정거장의 moveMinutes 에 있다(그게 "업소에서 나서는 시간"이다 — + prompts.itinerary 규칙). 복귀 쪽은 걸린 시간을 모르니 지어내지 않고 0으로 둔다 — + 대신 프롬프트가 종료 시각 30분 전에는 마지막 정거장을 끝내라고 미리 시킨다. + ★ 좌표는 **아는 곳만** 싣는다(`PlannerStop.latitude` 주석과 같은 규칙) — 업장에 좌표가 + 없으면 그 칸은 시간표에만 서고 지도에는 안 찍힌다. + """ + stop = {"name": place_name, "minutes": 0, "moveMinutes": 0, "searchQuery": place_name} + if place_lat is not None and place_lng is not None: + stop["latitude"] = place_lat + stop["longitude"] = place_lng + return stop + + +def _apply_schedule( + course: dict, duration: str, place_name: str, + place_lat: float | None, place_lng: float | None, +) -> dict | None: """`course["days"]` 를 DAY_SCHEDULE 시각으로 강제하고, 못 맞추는 하루가 있으면 코스를 버린다. ★ startTime·label 은 그대로 덮어쓴다(모델이 뭐라 적든). 종료는 뒤 정거장을 잘라 맞춘다. ★ 일수가 기간과 안 맞으면(둘째 날이 통째로 없다 등) 버린다 — 빈 날을 카드로 보여주지 않는다. + ★ 업소를 정거장 맨 앞(출발)에 넣는다. `returns` 인 날은 맨 뒤(복귀)에도 넣는다 + (2026-09-11 결정 — "하루 3~5곳" 규칙과는 별개로 얹는다. 모델이 고른 정거장 수를 + 세는 쪽(`_fit_stops`)은 이 값을 더하기 **전**의 것만 본다). """ schedule = DAY_SCHEDULE[duration] raw_days = course.get("days") @@ -117,10 +140,14 @@ def _apply_schedule(course: dict, duration: str) -> dict | None: ) if not stops: return None + departure = _lodging_stop(place_name, place_lat, place_lng) + full_stops = [departure, *stops] + if slot["returns"]: + full_stops.append(_lodging_stop(place_name, place_lat, place_lng)) day = dict(raw_day) if isinstance(raw_day, dict) else {} day["label"] = slot["label"] day["startTime"] = slot["start"] - day["stops"] = stops + day["stops"] = full_stops new_days.append(day) if len(new_days) < len(schedule): @@ -143,11 +170,17 @@ def _stop_names(course: dict) -> list[str]: return out -def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]: +def parse_courses( + payload: dict, duration: str, place_name: str, + place_lat: float | None = None, place_lng: float | None = None, +) -> tuple[list[dict], list[str]]: """(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. 한 코스가 잘못돼도 나머지를 살린다. 기간당 5개인데 한 줄 때문에 전부 버리면 그 업장은 다음 재생성까지 빈 채로 남는다. + + ★ place_name·place_lat·place_lng 는 업소를 정거장으로 넣을 때 쓴다(`_apply_schedule`) — + 모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 전) 이름만 들어가고 핀은 안 찍힌다. """ text = _FENCE_RE.sub("", _payload_text(payload)).strip() if not text: @@ -185,7 +218,7 @@ def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]: # ★ dedup 보다 먼저 적용한다 — 손님이 실제로 보는 것은 시각표를 통과한 뒤의 정거장이라, # "같은 코스인가" 도 그 기준으로 판단해야 한다. - course = _apply_schedule(course, duration) + course = _apply_schedule(course, duration, place_name, place_lat, place_lng) if course is None: dropped.append(f"{name}: 하루 시각표를 못 채운다(정거장이 시간을 못 맞추거나 일수가 모자란다)") continue diff --git a/solution/backend/services/itinerary_llm_service.py b/solution/backend/services/itinerary_llm_service.py index 629704b..73b4196 100644 --- a/solution/backend/services/itinerary_llm_service.py +++ b/solution/backend/services/itinerary_llm_service.py @@ -87,8 +87,21 @@ async def get_itineraries(place_id) -> list[dict]: return out -async def _generate_one(client: httpx.AsyncClient, place_name: str, region: str, duration: str): - """기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다.""" +def _as_float(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None + + +async def _generate_one( + client: httpx.AsyncClient, place_name: str, region: str, duration: str, + place_lat: float | None = None, place_lng: float | None = None, +): + """기간 하나. 실패는 예외로 올리지 않고 (빈 목록, 이유) 로 돌려준다. + + ★ place_lat·place_lng 는 업소를 정거장(출발·복귀)으로 넣을 때 쓴다(`grounding.parse_courses`) — + 모델에게 묻지 않는다. 없으면 이름만 들어가고 지도 핀은 안 찍힌다.""" body = { "model": perplexity.DEFAULT_MODEL, "messages": [ @@ -105,7 +118,7 @@ async def _generate_one(client: httpx.AsyncClient, place_name: str, region: str, LOG.w(f"[itinerary] {duration} 호출 실패 place={place_name}: {ex}") return [], [f"호출 실패: {ex}"] - courses, dropped = grounding.parse_courses(payload, duration) + courses, dropped = grounding.parse_courses(payload, duration, place_name, place_lat, place_lng) LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림") return courses, dropped @@ -132,6 +145,9 @@ async def ensure_generated(place) -> dict: result["notes"].append("상호나 지역을 특정할 수 없어 부르지 않는다") return result + lat = _as_float(getattr(place, "latitude", None)) + lng = _as_float(getattr(place, "longitude", None)) + wanted = await missing_durations(place_id) if not wanted: result["notes"].append("이미 있다") @@ -139,7 +155,7 @@ async def ensure_generated(place) -> dict: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: for duration in wanted: - courses, dropped = await _generate_one(client, name, region, duration) + courses, dropped = await _generate_one(client, name, region, duration, lat, lng) result["notes"] += [f"{duration}: {d}" for d in dropped] if not courses: continue diff --git a/solution/backend/services/prompts/itinerary.py b/solution/backend/services/prompts/itinerary.py index c250b8b..11efff7 100644 --- a/solution/backend/services/prompts/itinerary.py +++ b/solution/backend/services/prompts/itinerary.py @@ -34,6 +34,14 @@ 한다** — `duration` 을 덮어쓰는 것과 같은 판단이다. 종료 시각을 넘기는 정거장은 `services/grounding/itinerary._apply_schedule` 이 뒤에서부터 잘라낸다. 이유는 위 컨셉 개수와 같다 — 모델의 시각 계산도 안정적이라고 믿을 근거가 없다. + +★ 업소는 출발지에 포함이다 (2026-09-11, 사장님 지시: "업소는 출발지에 포함이야") + 업소 자신을 화면 라벨이 아니라 **정거장(stops[])** 으로 넣는다 — 지도·경로에도 실려야 + 하기 때문이다. 다만 모델에게 만들라고 시키지 않는다. 상호명·좌표는 이미 DB 에 있는 값이라 + 모델이 지어낼 이유가 없다 — grounding 이 `places` 값을 그대로 꽂는다(2026-09-11 결정, + 스키마 검증 없이 셋만 본다는 원칙과 같은 결로: 알 수 있는 값은 우리가 채운다). + `returns=True` 인 날은 정거장 맨 앞(출발)과 맨 뒤(복귀) 둘 다, 마지막 날(체크아웃)은 + 맨 앞(출발)만 넣는다. LLM 이 고른 3~5곳과는 별도로 얹는다 — "하루 3~5곳" 규칙을 세지 않는다. """ # 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다 @@ -58,27 +66,32 @@ _CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주 # 하루하루의 시작·종료 시각(2026-09-11, 사장님 지시). 체크인 15시·체크아웃 11~14시라는 # 실제 숙박 흐름에 맞췄다. 모델에게 이대로 지시하지만 **grounding 이 강제로 되돌린다** — # `duration` 을 덮어쓰는 것과 같은 판단이다(모듈 docstring 참고). -# ★ 마지막 날은 "복귀"가 아니라 "종료(체크아웃)"다 — 그날은 업소로 돌아오지 않고 그대로 나간다. -DAY_SCHEDULE: dict[str, tuple[dict[str, str], ...]] = { +# ★ "returns" — 그 날이 끝날 때 업소로 돌아오는가. 마지막 날(체크아웃)만 False 다. +# `grounding/itinerary._apply_schedule` 이 이 값을 보고 업소를 정거장 맨 앞(출발)에, +# returns=True 인 날은 맨 뒤(복귀)에도 넣는다(2026-09-11, 사장님 지시: "업소는 출발지에 포함"). +DAY_SCHEDULE: dict[str, tuple[dict[str, object], ...]] = { "1박 2일": ( - {"label": "첫째 날", "start": "15:00", "end": "19:00"}, - {"label": "둘째 날", "start": "09:00", "end": "14:00"}, + {"label": "첫째 날", "start": "15:00", "end": "19:00", "returns": True}, + {"label": "둘째 날", "start": "09:00", "end": "14:00", "returns": False}, ), "2박 3일": ( - {"label": "첫째 날", "start": "15:00", "end": "19:00"}, - {"label": "둘째 날", "start": "09:00", "end": "19:00"}, - {"label": "셋째 날", "start": "09:00", "end": "14:00"}, + {"label": "첫째 날", "start": "15:00", "end": "19:00", "returns": True}, + {"label": "둘째 날", "start": "09:00", "end": "19:00", "returns": True}, + {"label": "셋째 날", "start": "09:00", "end": "14:00", "returns": False}, ), } def _schedule_text(duration: str) -> str: - """DAY_SCHEDULE 을 프롬프트에 박을 문장으로. 마지막 날만 "종료(체크아웃)"이고 - 그 앞은 전부 "업소 복귀" — 실제 손님의 동선과 같다.""" - slots = DAY_SCHEDULE[duration] + """DAY_SCHEDULE 을 프롬프트에 박을 문장으로. returns=False 인 날만 "종료(체크아웃)"이고 + 나머지는 "업소 복귀" — 실제 손님의 동선과 같다. + + ★ 업소 자신을 정거장으로 넣으라는 지시는 여기 없다 — 그건 모델이 아니라 grounding 이 + 실제 DB 값(상호명·좌표)으로 직접 채운다. 모델에게는 여전히 "[업소] 자신은 정거장에 + 넣지 않는다"고 시킨다(아래 [해야 할 일]) — 지어낼 여지를 아예 안 준다.""" lines = [] - for i, slot in enumerate(slots): - ending = "종료(체크아웃, 복귀 없음)" if i == len(slots) - 1 else "업소 복귀" + for slot in DAY_SCHEDULE[duration]: + ending = "업소 복귀" if slot["returns"] else "종료(체크아웃, 복귀 없음)" lines.append(f"· {slot['label']}: {slot['start']} 시작 ~ {slot['end']} {ending}") return "\n".join(lines) diff --git a/solution/backend/tests/test_itinerary_grounding.py b/solution/backend/tests/test_itinerary_grounding.py index c1022af..0b7ae19 100644 --- a/solution/backend/tests/test_itinerary_grounding.py +++ b/solution/backend/tests/test_itinerary_grounding.py @@ -10,6 +10,10 @@ import json from services.grounding import itinerary as grounding +# 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로 +# 통과시키는지만 본다). 테스트 실제 좌표가 필요한 곳은 개별 테스트가 지정한다. +_PLACE = "테스트업소" + def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict: envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items} @@ -51,7 +55,7 @@ def test_accepts_five_distinct_courses(): """검증: 정상 응답 5개. 기대결과: 전부 채택되고 버린 것이 없다.""" items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(5)] - courses, dropped = grounding.parse_courses(_payload(items), "1박 2일") + courses, dropped = grounding.parse_courses(_payload(items), "1박 2일", _PLACE) assert len(courses) == 5 assert dropped == [] assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)] @@ -61,7 +65,7 @@ def test_strips_code_fence(): """검증: 코드펜스를 두르고 온 응답. 기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다.""" items = [_course("코스", ["가", "나"])] - courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일") + courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일", _PLACE) assert len(courses) == 1 @@ -70,7 +74,7 @@ def test_forces_duration_even_if_model_wrote_something_else(): 기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다.""" item = _course("코스", ["가", "나"]) item["duration"] = "반나절" - courses, _ = grounding.parse_courses(_payload([item]), "1박 2일") + courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses[0]["duration"] == "1박 2일" @@ -78,7 +82,7 @@ def test_drops_course_without_name(): """검증: 이름 없는 코스. 기대결과: 버린다 — 제목 없는 카드가 된다.""" item = _course("", ["가", "나"]) - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "name" in dropped[0] @@ -87,7 +91,7 @@ def test_drops_course_without_stops(): """검증: days 가 있어도 정거장이 하나도 없는 코스. 기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다.""" item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]} - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped @@ -99,7 +103,7 @@ def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one(): first = _course("원도심 코스", ["가", "나", "다", "라"]) same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다 overlap = _course("자연 코스", ["가", "나", "마", "바"]) # 절반 겹치지만 다른 코스다 - courses, dropped = grounding.parse_courses(_payload([first, same, overlap]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([first, same, overlap]), "1박 2일", _PLACE) assert [c["name"] for c in courses] == ["원도심 코스", "자연 코스"] assert dropped and "미식 코스" in dropped[0] @@ -108,7 +112,7 @@ def test_attaches_search_result_as_source_when_model_gave_none(): """검증: 코스에 source 가 없을 때. 기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다. ★ source 가 아예 없으면 발행본 카드 하단에 빈 점선 띠가 남는다(ItinerarySection 껍데기).""" - courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일") + courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일", _PLACE) assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/tour/" @@ -117,7 +121,7 @@ def test_keeps_model_source_when_it_is_a_real_url(): 기대결과: 그걸 쓴다.""" item = _course("코스", ["가", "나"]) item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"} - courses, _ = grounding.parse_courses(_payload([item]), "1박 2일") + courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/index.gunsan" @@ -126,7 +130,7 @@ def test_course_survives_without_any_source(): 기대결과: 코스는 살린다 — 출처가 없다고 일정을 버리면 화면이 통째로 빈다. (지역 이야기와 다른 판단이다. 저쪽은 '사실' 이고 이쪽은 '제안' 이다.)""" items = [_course("코스", ["가", "나"])] - courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일") + courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일", _PLACE) assert len(courses) == 1 assert "source" not in courses[0] @@ -135,7 +139,7 @@ def test_returns_empty_on_non_json(): """검증: JSON 이 아닌 응답. 기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다.""" payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]} - courses, dropped = grounding.parse_courses(payload, "1박 2일") + courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE) assert courses == [] assert dropped @@ -143,7 +147,7 @@ def test_returns_empty_on_non_json(): def test_returns_empty_when_items_is_not_a_list(): """검증: items 가 배열이 아닌 응답.""" payload = {"choices": [{"message": {"content": json.dumps({"items": "없음"})}}]} - courses, dropped = grounding.parse_courses(payload, "1박 2일") + courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE) assert courses == [] assert dropped @@ -165,7 +169,7 @@ def test_overrides_start_time_and_label_regardless_of_model_values(): {"label": "Day B", "startTime": "08:00", "stops": [_stop("나")]}, ], } - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert dropped == [] days = courses[0]["days"] assert (days[0]["label"], days[0]["startTime"]) == ("첫째 날", "15:00") @@ -174,7 +178,9 @@ def test_overrides_start_time_and_label_regardless_of_model_values(): def test_trims_stops_that_would_run_past_the_days_end_time(): """검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다. - 기대결과: 예산을 넘기는 뒤쪽 정거장만 잘린다 — 순서는 보존된다.""" + 기대결과: 예산을 넘기는 뒤쪽 정거장만 잘린다 — 순서는 보존된다. + ★ 업소가 각 날 맨 앞(출발)에, 복귀하는 날(첫째 날)은 맨 뒤에도 선다 — 이건 시간 예산을 + 먹기 전에(트리밍 이후) 얹으므로 트리밍 자체에는 영향이 없다.""" item = { "name": "코스", "duration": "1박 2일", "days": [ @@ -183,10 +189,12 @@ def test_trims_stops_that_would_run_past_the_days_end_time(): {"stops": [_stop("다")]}, ], } - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert dropped == [] - assert [s["name"] for s in courses[0]["days"][0]["stops"]] == ["가"] - assert [s["name"] for s in courses[0]["days"][1]["stops"]] == ["다"] + # 첫째 날은 복귀가 있다 — 업소(출발) → 가 → 업소(복귀). + assert [s["name"] for s in courses[0]["days"][0]["stops"]] == [_PLACE, "가", _PLACE] + # 둘째 날(마지막 날)은 체크아웃이라 복귀가 없다 — 업소(출발) → 다. + assert [s["name"] for s in courses[0]["days"][1]["stops"]] == [_PLACE, "다"] def test_drops_course_when_first_stop_of_a_day_already_overruns(): @@ -199,7 +207,7 @@ def test_drops_course_when_first_stop_of_a_day_already_overruns(): {"stops": [_stop("나")]}, ], } - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "시각표" in dropped[0] @@ -208,6 +216,43 @@ def test_drops_course_whose_day_count_is_short_of_the_duration(): """검증: '1박 2일'인데 하루치 밖에 없다. 기대결과: 버린다 — 둘째 날이 아예 없는 일정을 내보내지 않는다.""" item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}]} - courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일") + courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "시각표" in dropped[0] + + +def test_lodging_is_the_departure_and_return_point(): + """검증: 업소는 실제 정거장(stops[])이다(2026-09-11 결정: "업소는 출발지에 포함"). + 기대결과: 복귀하는 날은 맨 앞·맨 뒤 둘 다, 체크아웃 날(마지막 날)은 맨 앞만 — LLM 이 고른 + 정거장 수("하루 3~5곳")와는 별개로 얹는다.""" + item = { + "name": "코스", "duration": "2박 3일", + "days": [ + {"stops": [_stop("가"), _stop("나")]}, + {"stops": [_stop("다"), _stop("라")]}, + {"stops": [_stop("마")]}, + ], + } + courses, dropped = grounding.parse_courses(_payload([item]), "2박 3일", "군산모텔", 35.98642, 126.70612) + assert dropped == [] + days = courses[0]["days"] + assert [s["name"] for s in days[0]["stops"]] == ["군산모텔", "가", "나", "군산모텔"] + assert [s["name"] for s in days[1]["stops"]] == ["군산모텔", "다", "라", "군산모텔"] + assert [s["name"] for s in days[2]["stops"]] == ["군산모텔", "마"] # 마지막 날 — 복귀 없음 + + +def test_lodging_stop_carries_coordinates_only_when_known(): + """검증: 업소 좌표가 있을 때/없을 때. + 기대결과: 있으면 싣고, 없으면 이름만 — 틀린 핀을 찍느니 안 찍는다(PlannerStop 과 같은 규칙).""" + item = {"name": "코스", "duration": "1박 2일", + "days": [{"stops": [_stop("가")]}, {"stops": [_stop("나")]}]} + + with_coords, _ = grounding.parse_courses( + _payload([item]), "1박 2일", "군산모텔", 35.98642, 126.70612) + lodging = with_coords[0]["days"][0]["stops"][0] + assert lodging["name"] == "군산모텔" + assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612) + + without_coords, _ = grounding.parse_courses(_payload([item]), "1박 2일", "군산모텔") + lodging2 = without_coords[0]["days"][0]["stops"][0] + assert "latitude" not in lodging2 and "longitude" not in lodging2 diff --git a/solution/backend/tests/test_itinerary_llm_service.py b/solution/backend/tests/test_itinerary_llm_service.py index e2b02b6..6463012 100644 --- a/solution/backend/tests/test_itinerary_llm_service.py +++ b/solution/backend/tests/test_itinerary_llm_service.py @@ -249,3 +249,19 @@ async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity assert calls == [] assert out["counts"] == {} assert out["notes"] + + +async def test_lodging_coordinates_flow_from_place_to_generated_stops(db_engine, spy_perplexity): + """검증: place 에 좌표가 있으면 저장된 일정의 업소 정거장에도 실린다(2026-09-11 결정: + "업소는 출발지에 포함"). 기대결과: ensure_generated 가 place.latitude/longitude 를 + grounding 의 _apply_schedule 까지 넘긴다 — 모델에게 묻지 않는다.""" + place = _FakePlace(uuid.uuid4()) + place.latitude = 35.98642 + place.longitude = 126.70612 + + await service.ensure_generated(place) + items = await service.get_itineraries(place.place_id) + + lodging = items[0]["days"][0]["stops"][0] + assert lodging["name"] == "스테이,머뭄" + assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612)