[feat] solution/backend: 일정 하루 시각을 체크인·체크아웃 흐름으로 강제한다

This commit is contained in:
김성경 2026-09-11 16:14:44 +09:00
parent ab158be16f
commit 0c2b915973
4 changed files with 215 additions and 13 deletions

View File

@ -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,73 @@ 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 _apply_schedule(course: dict, duration: str) -> dict | None:
"""`course["days"]` 를 DAY_SCHEDULE 시각으로 강제하고, 못 맞추는 하루가 있으면 코스를 버린다.
★ startTime·label 은 그대로 덮어쓴다(모델이 뭐라 적든). 종료는 뒤 정거장을 잘라 맞춘다.
★ 일수가 기간과 안 맞으면(둘째 날이 통째로 없다 등) 버린다 — 빈 날을 카드로 보여주지 않는다.
"""
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
day = dict(raw_day) if isinstance(raw_day, dict) else {}
day["label"] = slot["label"]
day["startTime"] = slot["start"]
day["stops"] = 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 = []
@ -103,7 +179,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)
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 +200,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

View File

@ -22,6 +22,18 @@
**정거장 집합이 완전히 같은 것만**(규칙 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` 이 뒤에서부터 잘라낸다. 이유는 위 컨셉
개수와 같다 — 모델의 시각 계산도 안정적이라고 믿을 근거가 없다.
""" """
# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다 # 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
@ -43,6 +55,34 @@ _CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주 4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주""" 5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
# 하루하루의 시작·종료 시각(2026-09-11, 사장님 지시). 체크인 15시·체크아웃 11~14시라는
# 실제 숙박 흐름에 맞췄다. 모델에게 이대로 지시하지만 **grounding 이 강제로 되돌린다** —
# `duration` 을 덮어쓰는 것과 같은 판단이다(모듈 docstring 참고).
# ★ 마지막 날은 "복귀"가 아니라 "종료(체크아웃)"다 — 그날은 업소로 돌아오지 않고 그대로 나간다.
DAY_SCHEDULE: dict[str, tuple[dict[str, str], ...]] = {
"1박 2일": (
{"label": "첫째 날", "start": "15:00", "end": "19:00"},
{"label": "둘째 날", "start": "09:00", "end": "14:00"},
),
"2박 3일": (
{"label": "첫째 날", "start": "15:00", "end": "19:00"},
{"label": "둘째 날", "start": "09:00", "end": "19:00"},
{"label": "셋째 날", "start": "09:00", "end": "14:00"},
),
}
def _schedule_text(duration: str) -> str:
"""DAY_SCHEDULE 을 프롬프트에 박을 문장으로. 마지막 날만 "종료(체크아웃)"이고
그 앞은 전부 "업소 복귀" — 실제 손님의 동선과 같다."""
slots = DAY_SCHEDULE[duration]
lines = []
for i, slot in enumerate(slots):
ending = "종료(체크아웃, 복귀 없음)" if i == len(slots) - 1 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 +96,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 +116,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 +138,8 @@ _TASK = """[업소] %(place)s
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다. 7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다. 각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다.
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다. 컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
8. 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다. 8. ★ 하루 종료 시각을 넘기는 정거장은 아예 적지 않는다 — 넘긴 값은 서버가 뒤에서부터
잘라내므로, 넘길 걸 알면서 적을 이유가 없다.
9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다.""" 9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다."""
@ -108,4 +152,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),
}

View File

@ -146,3 +146,68 @@ def test_returns_empty_when_items_is_not_a_list():
courses, dropped = grounding.parse_courses(payload, "1박 2일") courses, dropped = grounding.parse_courses(payload, "1박 2일")
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일")
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일")
assert dropped == []
assert [s["name"] for s in courses[0]["days"][0]["stops"]] == ["가"]
assert [s["name"] for s in courses[0]["days"][1]["stops"]] == ["다"]
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일")
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일")
assert courses == []
assert dropped and "시각표" in dropped[0]

View File

@ -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 {