[feat] solution/backend: 업소를 여행 일정의 출발·복귀 정거장으로 넣는다

This commit is contained in:
김성경 2026-09-11 16:56:05 +09:00
parent 0c2b915973
commit 4184cbe665
5 changed files with 161 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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