여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
196 lines
7.0 KiB
Python
196 lines
7.0 KiB
Python
"""일정 응답 해석 — 모델이 준 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
|