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