Compare commits

..

2 Commits

5 changed files with 354 additions and 29 deletions

View File

@ -17,11 +17,20 @@
출처가 없어도 코스는 살린다
지역 이야기는 출처 없는 항목을 버린다 그건 '사실' 이라서다. 일정은 '제안' 이고,
출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 실제로 읽은 출처를 붙여 준다.
하루 시작·종료 시각을 우리가 강제한다 (2026-09-11 결정)
체크인 15·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 `DAY_SCHEDULE`(기간별 하루 시작·종료)
고정했다. 프롬프트로 시각표를 지시하지만(`prompts.itinerary._schedule_text`), duration
같은 이유로 **모델의 응답을 믿지 않는다** 실측(2026-09-11)에서 컨셉 개수 지시도 안정적으로
지켜졌다. 그래서 `startTime` 그대로 덮어쓰고, 종료 시각을 넘기는 정거장은 뒤에서부터
잘라낸다(`_apply_schedule`). 하루가 통째로 비면( 정거장부터 시간을 넘기면) 코스는 버린다
반쪽짜리 하루를 카드로 보여주지 않는다.
"""
import json
import re
from common.logger import LOG
from services.prompts.itinerary import DAY_SCHEDULE
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
@ -53,6 +62,100 @@ def _clean_source(value) -> dict | None:
return {"name": (value.get("name") or url).strip(), "url": url}
def _minutes(hhmm: str) -> int:
""""15:00" → 900. DAY_SCHEDULE 값에만 쓴다 — 형식이 고정이라 예외 처리를 하지 않는다."""
hour, minute = hhmm.split(":")
return int(hour) * 60 + int(minute)
def _as_positive_int(value, default: int) -> int:
"""모델이 준 분(minutes·moveMinutes)을 정수로. 못 읽으면 기본값 — 프론트 `planDay` 의
`Math.max(1, stop.minutes ?? 60)` 같은 방어다."""
try:
return max(0, int(value))
except (TypeError, ValueError):
return default
def _fit_stops(stops: list, start_minutes: int, end_minutes: int) -> list[dict]:
"""정거장을 순서대로 태워 보고, 종료 시각을 넘기는 지점부터 잘라낸다.
프론트 shared `planDay()` 같은 산수다(시작 + 이동 + 머무는 시간). 여기서 먼저 잘라
두면 화면은 이미 맞는 시간표만 받는다 발행본이 21 컷을 거는 이중 안전망일 뿐이다.
"""
clock = start_minutes
kept = []
for stop in stops:
if not isinstance(stop, dict):
continue
move = _as_positive_int(stop.get("moveMinutes"), 0)
stay = max(1, _as_positive_int(stop.get("minutes"), 60))
arrive = clock + move
if arrive + stay > end_minutes:
break # 이 뒤로는 다 늦다 — 순서가 있으므로 여기서 끊는다
kept.append(stop)
clock = arrive + stay
return kept
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")
if not isinstance(raw_days, list):
return None
new_days = []
for slot, raw_day in zip(schedule, raw_days):
stops = _fit_stops(
(raw_day.get("stops") or []) if isinstance(raw_day, dict) else [],
_minutes(slot["start"]), _minutes(slot["end"]),
)
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"] = full_stops
new_days.append(day)
if len(new_days) < len(schedule):
return None # 기간에 맞는 일수를 못 채웠다
return {**course, "days": new_days}
def _stop_names(course: dict) -> list[str]:
"""코스의 정거장 이름 — days 를 펴서 모은다."""
out = []
@ -67,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:
@ -103,7 +212,18 @@ def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]:
dropped.append("name 이 없다")
continue
stops = _stop_names(raw)
course = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
course["name"] = name
course["duration"] = duration
# ★ dedup 보다 먼저 적용한다 — 손님이 실제로 보는 것은 시각표를 통과한 뒤의 정거장이라,
# "같은 코스인가" 도 그 기준으로 판단해야 한다.
course = _apply_schedule(course, duration, place_name, place_lat, place_lng)
if course is None:
dropped.append(f"{name}: 하루 시각표를 못 채운다(정거장이 시간을 못 맞추거나 일수가 모자란다)")
continue
stops = _stop_names(course)
if not stops:
dropped.append(f"{name}: 정거장이 없다")
continue
@ -113,10 +233,6 @@ def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]:
dropped.append(f"{name}: 앞 코스와 정거장 집합이 같다")
continue
course = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
course["name"] = name
course["duration"] = duration
source = _clean_source(raw.get("source")) or fallback
if source is not None:
course["source"] = source

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

@ -22,6 +22,26 @@
**정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 채우면 컨셉은
1개만 낸다 지어내지 않는다 원칙의 연장이다.
`MAX_TOKENS` 12000 16000 으로 올렸다(완성 토큰이 코스 수에 비례해 늘어난다).
실측(2026-09-11): 컨셉당 2 지시를 모델이 안정적으로 따른다 같은 코드로
"웨스틴 조선 서울" 1 2일은 5(컨셉당 1개로 회귀), 2 3일은 10개가 나왔다.
grounding "0건 버림"이라 우리 드롭이 아니라 모델이 애초에 적게 것이다.
개수는 프롬프트만으로 완전히 보장되지 않는다 아래 시각 강제와 달리, 개수는
grounding 단계에서 강제할 방법이 없다(장소를 지어낼 수는 없다). 알려진 한계로 둔다.
하루 시각도 우리가 정한다 (2026-09-11, 사장님 지시)
체크인 15·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 하루의 시작·종료를 고정했다
(`DAY_SCHEDULE`). 모델에게는 시각표를 그대로 따르라고 지시하지만, **강제는 grounding
한다** `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`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
@ -43,6 +63,39 @@ _CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
4. 아이와 함께 체험·동물·놀이·넓은 공원 위주
5. 야외활동 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 위주"""
# 하루하루의 시작·종료 시각(2026-09-11, 사장님 지시). 체크인 15시·체크아웃 11~14시라는
# 실제 숙박 흐름에 맞췄다. 모델에게 이대로 지시하지만 **grounding 이 강제로 되돌린다** —
# `duration` 을 덮어쓰는 것과 같은 판단이다(모듈 docstring 참고).
# ★ "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", "returns": True},
{"label": "둘째 날", "start": "09:00", "end": "14:00", "returns": False},
),
"2박 3일": (
{"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 을 프롬프트에 박을 문장으로. returns=False 인 날만 "종료(체크아웃)"이고
나머지는 "업소 복귀" 실제 손님의 동선과 같다.
업소 자신을 정거장으로 넣으라는 지시는 여기 없다 그건 모델이 아니라 grounding
실제 DB (상호명·좌표)으로 직접 채운다. 모델에게는 여전히 "[업소] 자신은 정거장에
넣지 않는다"고 시킨다(아래 [해야 할 일]) — 지어낼 여지를 아예 안 준다."""
lines = []
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)
# ★ verified 를 요구하지 않는다. 화면에 안 나오고(`SourceLine` 이 `void verified`),
# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다.
#
@ -56,7 +109,7 @@ _SCHEMA = """{ "kind":"itinerary", "version":1, "title":"추천 일정", "items"
"audience":"누구에게 맞는 일정인가",
"why":"왜 이 일정인가 (두 문장 이내)",
"days":[
{ "label":"첫째 날", "startTime":"14:00", "stops":[
{ "label":"첫째 날", "startTime":"15:00", "stops":[
{ "name":"장소", "minutes":90, "moveMinutes":12,
"note":"한 줄 설명", "searchQuery":"지도 검색어",
"latitude":35.9908197, "longitude":126.7121231 } ] } ],
@ -76,7 +129,10 @@ _TASK = """[업소] %(place)s
채울 만큼 모자라면 컨셉은 1개만 낸다 억지로 채우지 않는다.
· 모든 일정의 duration "%(duration)s" 이다.
· days 날짜를 나눈다.
· 하루하루의 시작·종료 시각은 고정이다. days 순서·시각대로 나눈다.
%(schedule)s
· 마지막 정거장은 종료 시각 전에 끝나야 한다(머무는 시간까지 포함해서) 업소로
돌아오거나 체크아웃하러 이동하는 시간으로 30 정도는 남겨 둔다.
· 정거장은 하루에 3~5. 여섯 곳부터는 아무도 그대로 돈다.
· minutes 거기서 머무는 시간, moveMinutes 칸에서 오는 걸리는 시간이다.
· 정거장의 moveMinutes **업소에서 나서는 시간**이다.
@ -95,7 +151,8 @@ _TASK = """[업소] %(place)s
7. 코스 둘이 **같은 정거장 집합**이면 된다. 순서만 바꿔 놓은 것은 같은 코스다.
코스에는 다른 코스들에 없는 정거장이 **최소 ** 들어가야 한다.
컨셉에 맞는 장소가 모자라면 코스의 정거장을 줄인다 다른 코스의 장소를 빌려오지 않는다.
8. 9시를 넘기는 칸은 화면에서 빠진다 시각을 계산해 보고 넣는다.
8. 하루 종료 시각을 넘기는 정거장은 아예 적지 않는다 넘긴 값은 서버가 뒤에서부터
잘라내므로, 넘길 알면서 적을 이유가 없다.
9. source.url 실제로 열리는 공식·기관·언론 페이지여야 한다."""
@ -108,4 +165,9 @@ def build_prompt(place_name: str, region_label: str, duration: str) -> str:
if duration not in DURATIONS:
raise ValueError(f"모르는 기간: {duration}")
ctx = {"place": place_name, "region": region_label, "duration": duration}
return _TASK % {**ctx, "concepts": _CONCEPTS, "schema": _SCHEMA % ctx}
return _TASK % {
**ctx,
"concepts": _CONCEPTS,
"schema": _SCHEMA % ctx,
"schedule": _schedule_text(duration),
}

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,6 +147,112 @@ 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
# ── 하루 시각표 강제 (2026-09-11, 사장님 지시) ────────────────────────────
def _stop(name: str, minutes: int = 60, move: int = 10) -> dict:
return {"name": name, "minutes": minutes, "moveMinutes": move, "searchQuery": name,
"latitude": 35.99, "longitude": 126.71}
def test_overrides_start_time_and_label_regardless_of_model_values():
"""검증: 모델이 startTime·label 을 다르게 적어도.
기대결과: DAY_SCHEDULE (15:00/첫째 , 09:00/둘째 )으로 덮어쓴다 duration 같은 판단."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
{"label": "Day A", "startTime": "10:00", "stops": [_stop("")]},
{"label": "Day B", "startTime": "08:00", "stops": [_stop("")]},
],
}
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")
assert (days[1]["label"], days[1]["startTime"]) == ("둘째 날", "09:00")
def test_trims_stops_that_would_run_past_the_days_end_time():
"""검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다.
기대결과: 예산을 넘기는 뒤쪽 정거장만 잘린다 순서는 보존된다.
업소가 (출발), 복귀하는 (첫째 ) 뒤에도 선다 이건 시간 예산을
먹기 전에(트리밍 이후) 얹으므로 트리밍 자체에는 영향이 없다."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
# 15:10 도착·18:10 종료(180분) → 다음 정거장은 18:20 도착에 18:20+180=21:20 으로 19:00 초과.
{"stops": [_stop("", minutes=180), _stop("", minutes=180)]},
{"stops": [_stop("")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert dropped == []
# 첫째 날은 복귀가 있다 — 업소(출발) → 가 → 업소(복귀).
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():
"""검증: 첫째 날 첫 정거장부터 종료 시각(19:00)을 넘긴다.
기대결과: 날이 통째로 비어 코스 자체를 버린다 날을 카드로 보여주지 않는다."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
{"stops": [_stop("", minutes=300)]}, # 15:10 도착, 300분 → 20:10 종료(19:00 초과)
{"stops": [_stop("")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses == []
assert dropped and "시각표" in dropped[0]
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일", _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

@ -20,6 +20,7 @@ from crud.place_itinerary_crud import PlaceItineraryCRUD
from services import itinerary_llm_service as service
from services.llm import perplexity
from services.prompts import itinerary as prompts
from services.prompts.itinerary import DAY_SCHEDULE
class _FakePlace:
@ -33,12 +34,16 @@ class _FakePlace:
def _response(names: list[str], duration: str) -> dict:
"""가짜 Perplexity 응답. `_apply_schedule`(grounding)이 하루 수·시각을 검사하므로
DAY_SCHEDULE 요구하는 일수만큼 하루를 채운다 채우면 전부 드롭된다(실측 회귀)."""
items = [
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
"days": [{"label": "1일차", "startTime": "14:00",
"stops": [{"name": f"{n}-장소{i}", "minutes": 60, "moveMinutes": 10,
"searchQuery": f"{n}-장소{i}",
"latitude": 35.99, "longitude": 126.71} for i in range(3)]}]}
"days": [
{"stops": [{"name": f"{n}-{day}일차-장소{i}", "minutes": 60, "moveMinutes": 10,
"searchQuery": f"{n}-{day}일차-장소{i}",
"latitude": 35.99, "longitude": 126.71} for i in range(2)]}
for day in range(len(DAY_SCHEDULE[duration]))
]}
for n in names
]
return {
@ -244,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)