수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
266 lines
13 KiB
Python
266 lines
13 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 stop_signature(course: dict) -> frozenset[str]:
|
|
"""코스의 정거장 집합 — 중복 판정 기준. 호출 하나를 넘어 여러 번의 재시도에 걸쳐
|
|
같은 코스를 다시 채택하지 않으려면, 이전에 채택한 코스의 signature 를 다음 호출의
|
|
`already_seen` 에 실어 보내야 한다(`itinerary_llm_service` 가 그렇게 누적한다)."""
|
|
return frozenset(_stop_names(course))
|
|
|
|
|
|
def parse_courses(
|
|
payload: dict, duration: str, place_name: str,
|
|
place_lat: float | None = None, place_lng: float | None = None,
|
|
already_seen: set[frozenset[str]] | None = None,
|
|
) -> tuple[list[dict], list[str]]:
|
|
"""(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.
|
|
|
|
한 코스가 잘못돼도 나머지를 살린다. 기간당 5개인데 한 줄 때문에 전부 버리면
|
|
그 업장은 다음 재생성까지 빈 채로 남는다.
|
|
|
|
★ place_name·place_lat·place_lng 는 업소를 정거장으로 넣을 때 쓴다(`_apply_schedule`) —
|
|
모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 전) 이름만 들어가고 핀은 안 찍힌다.
|
|
|
|
★ already_seen 은 **이전 호출**(재시도)에서 이미 채택한 코스들의 `stop_signature` 다.
|
|
10개를 채우려고 같은 프롬프트로 다시 부르면 모델이 앞서 낸 것과 겹치는 코스를 또
|
|
낼 수 있다 — 이 payload 안에서만 중복을 보면 그걸 새로 채택해 버린다.
|
|
"""
|
|
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]] = list(already_seen or ())
|
|
|
|
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
|