o2o-site-AEO/solution/backend/services/grounding/itinerary.py

254 lines
12 KiB
Python

"""일정 응답 해석 — 모델이 준 JSON 에서 **화면에 설 수 있는 코스만** 남긴다.
★ 스키마 검증을 하지 않는다
항목 모양의 단일 출처는 `shared/lib/section-data.ts` 다. 그 모양을 파이썬에 한 벌 더 적으면
프론트가 필드를 하나 늘린 날 서버가 그걸 조용히 떨어뜨린다(`grounding/story.py` 와 같은 판단).
여기서 보는 것은 셋뿐이다 — 이름이 있나 · 정거장이 하나라도 있나 · 앞 코스와 같은 코스인가.
★ 같은 코스를 버린다 (2026-09-11 결정)
정거장 **겹침은 허용**이다. 다만 정거장 집합이 완전히 같으면 순서만 바꾼 것이고, 손님 눈에는
같은 코스 둘이다. 프롬프트 규칙 7 로도 막지만 그건 부탁이지 보장이 아니다 —
실제로 컨셉을 지정하기 전에는 5개 중 4개가 같은 집합이었다(스파이크 실측).
★ duration 은 우리가 덮어쓴다
이 값이 화면 탭을 가른다(`ItinerarySection` 이 `duration` 으로 탭을 세운다).
모델이 "반나절" 이라고 적어 버리면 1박 2일을 요청해 받은 코스가 엉뚱한 탭에 선다.
★ 출처가 없어도 코스는 살린다
지역 이야기는 출처 없는 항목을 버린다 — 그건 '사실' 이라서다. 일정은 '제안' 이고,
출처를 이유로 버리면 화면이 통째로 빈다. 대신 Perplexity 가 실제로 읽은 첫 출처를 붙여 준다.
★ 하루 시작·종료 시각을 우리가 강제한다 (2026-09-11 결정)
체크인 15시·체크아웃 11~14시라는 실제 숙박 흐름에 맞춰 `DAY_SCHEDULE`(기간별 하루 시작·종료)을
고정했다. 프롬프트로 이 시각표를 지시하지만(`prompts.itinerary._schedule_text`), duration 과
같은 이유로 **모델의 응답을 믿지 않는다** — 실측(2026-09-11)에서 컨셉 개수 지시도 안정적으로
안 지켜졌다. 그래서 `startTime`은 그대로 덮어쓰고, 종료 시각을 넘기는 정거장은 뒤에서부터
잘라낸다(`_apply_schedule`). 하루가 통째로 비면(첫 정거장부터 시간을 넘기면) 그 코스는 버린다
— 반쪽짜리 하루를 빈 카드로 보여주지 않는다.
★ 하루 시작·종료 시각을 우리가 강제한다 (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)
def _payload_text(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
return ((choices[0].get("message") or {}).get("content")) or ""
def _first_source(payload: dict) -> dict | None:
"""Perplexity 가 실제로 읽은 첫 출처. 코스에 source 가 없을 때의 대체값."""
for row in payload.get("search_results") or []:
if isinstance(row, dict) and (row.get("url") or "").startswith("http"):
return {"name": row.get("title") or row["url"], "url": row["url"]}
return None
def _clean_source(value) -> dict | None:
"""모델이 준 source. url 이 http 로 시작하지 않으면 없는 것으로 친다 —
"검색결과 참조" 같은 문자열이 그대로 링크가 되면 눌러도 아무 데도 안 간다."""
if not isinstance(value, dict):
return None
url = (value.get("url") or "").strip()
if not url.startswith("http"):
return 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 = []
for day in course.get("days") or []:
if not isinstance(day, dict):
continue
for stop in day.get("stops") or []:
if isinstance(stop, dict):
name = (stop.get("name") or "").strip() if isinstance(stop.get("name"), str) else ""
if name:
out.append(name)
return out
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:
return [], ["응답이 비었다"]
try:
envelope = json.loads(text)
except (json.JSONDecodeError, ValueError) as ex:
LOG.w(f"[itinerary] {duration} JSON 파싱 실패: {ex}")
return [], [f"JSON 이 아니다: {ex}"]
if not isinstance(envelope, dict):
return [], ["최상위가 객체가 아니다"]
raw_items = envelope.get("items")
if not isinstance(raw_items, list):
return [], ["items 가 배열이 아니다"]
fallback = _first_source(payload)
out: list[dict] = []
dropped: list[str] = []
seen_stop_sets: list[frozenset[str]] = []
for raw in raw_items:
if not isinstance(raw, dict):
dropped.append("코스가 객체가 아니다")
continue
name = (raw.get("name") or "").strip() if isinstance(raw.get("name"), str) else ""
if not name:
dropped.append("name 이 없다")
continue
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
stop_set = frozenset(stops)
if stop_set in seen_stop_sets:
dropped.append(f"{name}: 앞 코스와 정거장 집합이 같다")
continue
source = _clean_source(raw.get("source")) or fallback
if source is not None:
course["source"] = source
else:
course.pop("source", None)
seen_stop_sets.append(stop_set)
out.append(course)
return out, dropped