Compare commits
2 Commits
ab158be16f
...
4184cbe665
| Author | SHA1 | Date | |
|---|---|---|---|
| 4184cbe665 | |||
| 0c2b915973 |
@ -17,11 +17,20 @@
|
|||||||
★ 출처가 없어도 코스는 살린다
|
★ 출처가 없어도 코스는 살린다
|
||||||
지역 이야기는 출처 없는 항목을 버린다 — 그건 '사실' 이라서다. 일정은 '제안' 이고,
|
지역 이야기는 출처 없는 항목을 버린다 — 그건 '사실' 이라서다. 일정은 '제안' 이고,
|
||||||
출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 가 실제로 읽은 첫 출처를 붙여 준다.
|
출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 가 실제로 읽은 첫 출처를 붙여 준다.
|
||||||
|
|
||||||
|
★ 하루 시작·종료 시각을 우리가 강제한다 (2026-09-11 결정)
|
||||||
|
체크인 15시·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 `DAY_SCHEDULE`(기간별 하루 시작·종료)을
|
||||||
|
고정했다. 프롬프트로 이 시각표를 지시하지만(`prompts.itinerary._schedule_text`), duration 과
|
||||||
|
같은 이유로 **모델의 응답을 믿지 않는다** — 실측(2026-09-11)에서 컨셉 개수 지시도 안정적으로
|
||||||
|
안 지켜졌다. 그래서 `startTime`은 그대로 덮어쓰고, 종료 시각을 넘기는 정거장은 뒤에서부터
|
||||||
|
잘라낸다(`_apply_schedule`). 하루가 통째로 비면(첫 정거장부터 시간을 넘기면) 그 코스는 버린다
|
||||||
|
— 반쪽짜리 하루를 빈 카드로 보여주지 않는다.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
from services.prompts.itinerary import DAY_SCHEDULE
|
||||||
|
|
||||||
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
|
# 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다.
|
||||||
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
|
_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}
|
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]:
|
def _stop_names(course: dict) -> list[str]:
|
||||||
"""코스의 정거장 이름 — days 를 펴서 모은다."""
|
"""코스의 정거장 이름 — days 를 펴서 모은다."""
|
||||||
out = []
|
out = []
|
||||||
@ -67,11 +170,17 @@ def _stop_names(course: dict) -> list[str]:
|
|||||||
return out
|
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개인데 한 줄 때문에 전부 버리면
|
한 코스가 잘못돼도 나머지를 살린다. 기간당 5개인데 한 줄 때문에 전부 버리면
|
||||||
그 업장은 다음 재생성까지 빈 채로 남는다.
|
그 업장은 다음 재생성까지 빈 채로 남는다.
|
||||||
|
|
||||||
|
★ place_name·place_lat·place_lng 는 업소를 정거장으로 넣을 때 쓴다(`_apply_schedule`) —
|
||||||
|
모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 전) 이름만 들어가고 핀은 안 찍힌다.
|
||||||
"""
|
"""
|
||||||
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
|
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
|
||||||
if not text:
|
if not text:
|
||||||
@ -103,7 +212,18 @@ def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]:
|
|||||||
dropped.append("name 이 없다")
|
dropped.append("name 이 없다")
|
||||||
continue
|
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:
|
if not stops:
|
||||||
dropped.append(f"{name}: 정거장이 없다")
|
dropped.append(f"{name}: 정거장이 없다")
|
||||||
continue
|
continue
|
||||||
@ -113,10 +233,6 @@ def parse_courses(payload: dict, duration: str) -> tuple[list[dict], list[str]]:
|
|||||||
dropped.append(f"{name}: 앞 코스와 정거장 집합이 같다")
|
dropped.append(f"{name}: 앞 코스와 정거장 집합이 같다")
|
||||||
continue
|
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
|
source = _clean_source(raw.get("source")) or fallback
|
||||||
if source is not None:
|
if source is not None:
|
||||||
course["source"] = source
|
course["source"] = source
|
||||||
|
|||||||
@ -87,8 +87,21 @@ async def get_itineraries(place_id) -> list[dict]:
|
|||||||
return out
|
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 = {
|
body = {
|
||||||
"model": perplexity.DEFAULT_MODEL,
|
"model": perplexity.DEFAULT_MODEL,
|
||||||
"messages": [
|
"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}")
|
LOG.w(f"[itinerary] {duration} 호출 실패 place={place_name}: {ex}")
|
||||||
return [], [f"호출 실패: {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)}건 버림")
|
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림")
|
||||||
return courses, dropped
|
return courses, dropped
|
||||||
|
|
||||||
@ -132,6 +145,9 @@ async def ensure_generated(place) -> dict:
|
|||||||
result["notes"].append("상호나 지역을 특정할 수 없어 부르지 않는다")
|
result["notes"].append("상호나 지역을 특정할 수 없어 부르지 않는다")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
lat = _as_float(getattr(place, "latitude", None))
|
||||||
|
lng = _as_float(getattr(place, "longitude", None))
|
||||||
|
|
||||||
wanted = await missing_durations(place_id)
|
wanted = await missing_durations(place_id)
|
||||||
if not wanted:
|
if not wanted:
|
||||||
result["notes"].append("이미 있다")
|
result["notes"].append("이미 있다")
|
||||||
@ -139,7 +155,7 @@ async def ensure_generated(place) -> dict:
|
|||||||
|
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
for duration in wanted:
|
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]
|
result["notes"] += [f"{duration}: {d}" for d in dropped]
|
||||||
if not courses:
|
if not courses:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -22,6 +22,26 @@
|
|||||||
**정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 못 채우면 그 컨셉은
|
**정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 못 채우면 그 컨셉은
|
||||||
1개만 낸다 — 지어내지 않는다 원칙의 연장이다.
|
1개만 낸다 — 지어내지 않는다 원칙의 연장이다.
|
||||||
`MAX_TOKENS` 도 12000 → 16000 으로 올렸다(완성 토큰이 코스 수에 비례해 늘어난다).
|
`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`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
|
# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
|
||||||
@ -43,6 +63,39 @@ _CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
|
|||||||
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
|
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
|
||||||
5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
|
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`),
|
# ★ verified 를 요구하지 않는다. 화면에 안 나오고(`SourceLine` 이 `void verified`),
|
||||||
# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다.
|
# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다.
|
||||||
#
|
#
|
||||||
@ -56,7 +109,7 @@ _SCHEMA = """{ "kind":"itinerary", "version":1, "title":"추천 일정", "items"
|
|||||||
"audience":"누구에게 맞는 일정인가",
|
"audience":"누구에게 맞는 일정인가",
|
||||||
"why":"왜 이 일정인가 (두 문장 이내)",
|
"why":"왜 이 일정인가 (두 문장 이내)",
|
||||||
"days":[
|
"days":[
|
||||||
{ "label":"첫째 날", "startTime":"14:00", "stops":[
|
{ "label":"첫째 날", "startTime":"15:00", "stops":[
|
||||||
{ "name":"장소", "minutes":90, "moveMinutes":12,
|
{ "name":"장소", "minutes":90, "moveMinutes":12,
|
||||||
"note":"한 줄 설명", "searchQuery":"지도 검색어",
|
"note":"한 줄 설명", "searchQuery":"지도 검색어",
|
||||||
"latitude":35.9908197, "longitude":126.7121231 } ] } ],
|
"latitude":35.9908197, "longitude":126.7121231 } ] } ],
|
||||||
@ -76,7 +129,10 @@ _TASK = """[업소] %(place)s
|
|||||||
못 채울 만큼 모자라면 그 컨셉은 1개만 낸다 — 억지로 채우지 않는다.
|
못 채울 만큼 모자라면 그 컨셉은 1개만 낸다 — 억지로 채우지 않는다.
|
||||||
|
|
||||||
· 모든 일정의 duration 은 "%(duration)s" 이다.
|
· 모든 일정의 duration 은 "%(duration)s" 이다.
|
||||||
· days 로 날짜를 나눈다.
|
· 하루하루의 시작·종료 시각은 고정이다. days 는 이 순서·시각대로 나눈다.
|
||||||
|
%(schedule)s
|
||||||
|
· 마지막 정거장은 그 날 종료 시각 전에 끝나야 한다(머무는 시간까지 포함해서) — 업소로
|
||||||
|
돌아오거나 체크아웃하러 이동하는 시간으로 30분 정도는 남겨 둔다.
|
||||||
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
|
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
|
||||||
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
|
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
|
||||||
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.
|
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.
|
||||||
@ -95,7 +151,8 @@ _TASK = """[업소] %(place)s
|
|||||||
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
|
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
|
||||||
각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다.
|
각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다.
|
||||||
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
|
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
|
||||||
8. 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.
|
8. ★ 하루 종료 시각을 넘기는 정거장은 아예 적지 않는다 — 넘긴 값은 서버가 뒤에서부터
|
||||||
|
잘라내므로, 넘길 걸 알면서 적을 이유가 없다.
|
||||||
9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다."""
|
9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다."""
|
||||||
|
|
||||||
|
|
||||||
@ -108,4 +165,9 @@ def build_prompt(place_name: str, region_label: str, duration: str) -> str:
|
|||||||
if duration not in DURATIONS:
|
if duration not in DURATIONS:
|
||||||
raise ValueError(f"모르는 기간: {duration}")
|
raise ValueError(f"모르는 기간: {duration}")
|
||||||
ctx = {"place": place_name, "region": region_label, "duration": 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),
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,10 @@ import json
|
|||||||
|
|
||||||
from services.grounding import itinerary as grounding
|
from services.grounding import itinerary as grounding
|
||||||
|
|
||||||
|
# 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로
|
||||||
|
# 통과시키는지만 본다). 테스트 실제 좌표가 필요한 곳은 개별 테스트가 지정한다.
|
||||||
|
_PLACE = "테스트업소"
|
||||||
|
|
||||||
|
|
||||||
def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict:
|
def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict:
|
||||||
envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items}
|
envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items}
|
||||||
@ -51,7 +55,7 @@ def test_accepts_five_distinct_courses():
|
|||||||
"""검증: 정상 응답 5개.
|
"""검증: 정상 응답 5개.
|
||||||
기대결과: 전부 채택되고 버린 것이 없다."""
|
기대결과: 전부 채택되고 버린 것이 없다."""
|
||||||
items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(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 len(courses) == 5
|
||||||
assert dropped == []
|
assert dropped == []
|
||||||
assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)]
|
assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)]
|
||||||
@ -61,7 +65,7 @@ def test_strips_code_fence():
|
|||||||
"""검증: 코드펜스를 두르고 온 응답.
|
"""검증: 코드펜스를 두르고 온 응답.
|
||||||
기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다."""
|
기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다."""
|
||||||
items = [_course("코스", ["가", "나"])]
|
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
|
assert len(courses) == 1
|
||||||
|
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ def test_forces_duration_even_if_model_wrote_something_else():
|
|||||||
기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다."""
|
기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다."""
|
||||||
item = _course("코스", ["가", "나"])
|
item = _course("코스", ["가", "나"])
|
||||||
item["duration"] = "반나절"
|
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일"
|
assert courses[0]["duration"] == "1박 2일"
|
||||||
|
|
||||||
|
|
||||||
@ -78,7 +82,7 @@ def test_drops_course_without_name():
|
|||||||
"""검증: 이름 없는 코스.
|
"""검증: 이름 없는 코스.
|
||||||
기대결과: 버린다 — 제목 없는 카드가 된다."""
|
기대결과: 버린다 — 제목 없는 카드가 된다."""
|
||||||
item = _course("", ["가", "나"])
|
item = _course("", ["가", "나"])
|
||||||
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일")
|
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
|
||||||
assert courses == []
|
assert courses == []
|
||||||
assert dropped and "name" in dropped[0]
|
assert dropped and "name" in dropped[0]
|
||||||
|
|
||||||
@ -87,7 +91,7 @@ def test_drops_course_without_stops():
|
|||||||
"""검증: days 가 있어도 정거장이 하나도 없는 코스.
|
"""검증: days 가 있어도 정거장이 하나도 없는 코스.
|
||||||
기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다."""
|
기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다."""
|
||||||
item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]}
|
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 courses == []
|
||||||
assert dropped
|
assert dropped
|
||||||
|
|
||||||
@ -99,7 +103,7 @@ def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one():
|
|||||||
first = _course("원도심 코스", ["가", "나", "다", "라"])
|
first = _course("원도심 코스", ["가", "나", "다", "라"])
|
||||||
same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다
|
same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다
|
||||||
overlap = _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 [c["name"] for c in courses] == ["원도심 코스", "자연 코스"]
|
||||||
assert dropped and "미식 코스" in dropped[0]
|
assert dropped and "미식 코스" in dropped[0]
|
||||||
|
|
||||||
@ -108,7 +112,7 @@ def test_attaches_search_result_as_source_when_model_gave_none():
|
|||||||
"""검증: 코스에 source 가 없을 때.
|
"""검증: 코스에 source 가 없을 때.
|
||||||
기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다.
|
기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다.
|
||||||
★ source 가 아예 없으면 발행본 카드 하단에 빈 점선 띠가 남는다(ItinerarySection 껍데기)."""
|
★ 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/"
|
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 = _course("코스", ["가", "나"])
|
||||||
item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"}
|
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"
|
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("코스", ["가", "나"])]
|
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 len(courses) == 1
|
||||||
assert "source" not in courses[0]
|
assert "source" not in courses[0]
|
||||||
|
|
||||||
@ -135,7 +139,7 @@ def test_returns_empty_on_non_json():
|
|||||||
"""검증: JSON 이 아닌 응답.
|
"""검증: JSON 이 아닌 응답.
|
||||||
기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다."""
|
기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다."""
|
||||||
payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]}
|
payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]}
|
||||||
courses, dropped = grounding.parse_courses(payload, "1박 2일")
|
courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE)
|
||||||
assert courses == []
|
assert courses == []
|
||||||
assert dropped
|
assert dropped
|
||||||
|
|
||||||
@ -143,6 +147,112 @@ def test_returns_empty_on_non_json():
|
|||||||
def test_returns_empty_when_items_is_not_a_list():
|
def test_returns_empty_when_items_is_not_a_list():
|
||||||
"""검증: items 가 배열이 아닌 응답."""
|
"""검증: items 가 배열이 아닌 응답."""
|
||||||
payload = {"choices": [{"message": {"content": json.dumps({"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 courses == []
|
||||||
assert dropped
|
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
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from crud.place_itinerary_crud import PlaceItineraryCRUD
|
|||||||
from services import itinerary_llm_service as service
|
from services import itinerary_llm_service as service
|
||||||
from services.llm import perplexity
|
from services.llm import perplexity
|
||||||
from services.prompts import itinerary as prompts
|
from services.prompts import itinerary as prompts
|
||||||
|
from services.prompts.itinerary import DAY_SCHEDULE
|
||||||
|
|
||||||
|
|
||||||
class _FakePlace:
|
class _FakePlace:
|
||||||
@ -33,12 +34,16 @@ class _FakePlace:
|
|||||||
|
|
||||||
|
|
||||||
def _response(names: list[str], duration: str) -> dict:
|
def _response(names: list[str], duration: str) -> dict:
|
||||||
|
"""가짜 Perplexity 응답. `_apply_schedule`(grounding)이 하루 수·시각을 검사하므로
|
||||||
|
DAY_SCHEDULE 이 요구하는 일수만큼 하루를 채운다 — 안 채우면 전부 드롭된다(실측 회귀)."""
|
||||||
items = [
|
items = [
|
||||||
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
|
{"name": n, "duration": duration, "audience": "누구에게나", "why": "이유.",
|
||||||
"days": [{"label": "1일차", "startTime": "14:00",
|
"days": [
|
||||||
"stops": [{"name": f"{n}-장소{i}", "minutes": 60, "moveMinutes": 10,
|
{"stops": [{"name": f"{n}-{day}일차-장소{i}", "minutes": 60, "moveMinutes": 10,
|
||||||
"searchQuery": f"{n}-장소{i}",
|
"searchQuery": f"{n}-{day}일차-장소{i}",
|
||||||
"latitude": 35.99, "longitude": 126.71} for i in range(3)]}]}
|
"latitude": 35.99, "longitude": 126.71} for i in range(2)]}
|
||||||
|
for day in range(len(DAY_SCHEDULE[duration]))
|
||||||
|
]}
|
||||||
for n in names
|
for n in names
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
@ -244,3 +249,19 @@ async def test_ensure_generated_by_id_on_unknown_place(db_engine, spy_perplexity
|
|||||||
assert calls == []
|
assert calls == []
|
||||||
assert out["counts"] == {}
|
assert out["counts"] == {}
|
||||||
assert out["notes"]
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user