"""일정 응답 해석 — 모델이 준 JSON 에서 **화면에 설 수 있는 코스만** 남긴다.""" import json import re from common.logger import LOG from services.prompts.itinerary import DAY_SCHEDULE # 코드펜스를 두르고 오는 경우가 있다. _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 가 실제로 읽은 첫 출처.""" 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.""" 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)을 정수로.""" try: return max(0, int(value)) except (TypeError, ValueError): return default def _fit_stops(stops: list, start_minutes: int, end_minutes: int) -> list[dict]: """정거장을 순서대로 태워 보고, 종료 시각을 넘기는 지점부터 잘라낸다.""" 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: """업소 자신을 정거장 모양으로.""" 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 시각으로 강제하고, 못 맞추는 하루가 있으면 코스를 버린다.""" 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]: """코스의 정거장 집합 — 중복 판정 기준.""" 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]]: """(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.""" 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