o2o-site-AEO/solution/backend/services/prompts/itinerary.py

174 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""여행 일정 프롬프트 — "1박 2일·2박 3일을 각각 5개 컨셉 × 2개씩, 총 10개로".
★ 왜 shared 에 두지 않나
`shared/src/lib/section-prompts.ts` 의 단일 출처 규칙은 "사장님이 복사해 가는 프롬프트와
서버가 도는 프롬프트가 **같은 문장**일 때" 의 규칙이다. 이 둘은 하는 일이 다르다 —
사장님용(`canvas/dataSpec.ts`)은 "3~5개, 반나절·1박2일·2박3일 섞기" 고, 이쪽은
"기간 고정 + 지정된 컨셉 5개 × 2개" 다. 같은 문장을 두 벌 두는 게 아니므로 daily 때의 분기
사고와 성격이 다르다. 문장 자체는 사장님용에서 가져와 변형했다.
★ 컨셉을 **우리가** 준다 (스파이크 2026-09-11, 스테이,머뭄)
모델에 맡기면 2박 3일에서 5개 코스 중 **4개가 정거장 집합 100% 동일**이었다(고유 장소 13곳).
이름만 '가족 체험'·'사진 골목'·'도보 미식' 이고 내용은 같은 8곳의 재배열이다.
컨셉을 지정하자 고유 장소가 24곳으로 늘고 같은 집합이 사라졌다.
★ 컨셉 축은 **테마**여야 한다
'차 없이 걸어서' 를 축으로 넣었더니 '처음 온 손님(대표 명소)' 과 91% 겹쳤다 —
원도심이 곧 도보권이라 같은 장소로 수렴한다. 제약은 축이 될 수 없다.
★ 10개(컨셉당 2개)로 늘렸다 (2026-09-11, 사장님 지시: "중복 허용하고 10개로")
컨셉을 5개 더 늘려 서로 안 겹치게 짜는 대신, 기존 5개 테마 각각에서 2개씩 뽑기로 했다 —
같은 테마 안에서의 겹침(예: 자연 풍경 코스 둘이 비슷한 종류의 장소를 쓰는 것)은 허용하고,
**정거장 집합이 완전히 같은 것만**(규칙 7) 막는다. 장소가 둘째 코스를 못 채우면 그 컨셉은
1개만 낸다 — 지어내지 않는다 원칙의 연장이다.
`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` 이 뒤에서부터 잘라낸다. 이유는 위 컨셉
개수와 같다 — 모델의 시각 계산도 안정적이라고 믿을 근거가 없다.
★ 업소는 출발지에 포함이다 (2026-09-11, 사장님 지시: "업소는 출발지에 포함이야")
업소 자신을 화면 라벨이 아니라 **정거장(stops[])** 으로 넣는다 — 지도·경로에도 실려야
하기 때문이다. 다만 모델에게 만들라고 시키지 않는다. 상호명·좌표는 이미 DB 에 있는 값이라
모델이 지어낼 이유가 없다 — grounding 이 `places` 값을 그대로 꽂는다(2026-09-11 결정,
스키마 검증 없이 셋만 본다는 원칙과 같은 결로: 알 수 있는 값은 우리가 채운다).
`returns=True` 인 날은 정거장 맨 앞(출발)과 맨 뒤(복귀) 둘 다, 마지막 날(체크아웃)은
맨 앞(출발)만 넣는다. LLM 이 고른 3~5곳과는 별도로 얹는다 — "하루 3~5곳" 규칙을 세지 않는다.
"""
# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
# — `canvas/dataSpec.ts` 의 options(['반나절','1박 2일','2박 3일'])와 같은 문자열이어야 한다.
DURATIONS: tuple[str, str] = ("1박 2일", "2박 3일")
# 2박 3일 5코스가 completion 3,922 토큰까지 갔다(실측). 10개(컨셉당 2개)면 그 두 배 안팎이라
# 여유를 넉넉히 둔다 — 기본값 2048 은 물론 12000 도 10개에서는 잘릴 수 있다.
MAX_TOKENS = 16000
SYSTEM_PROMPT = (
"너는 지역 여행 코스 플래너다. 검색으로 확인한 실제 장소만 쓰고, "
"확인하지 못한 값은 필드를 통째로 뺀다. JSON 하나만 출력한다."
)
_CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
2. 자연 풍경 — 바다·산·호수·공원 중 그 지역에 실제로 있는 것 위주
3. 미식 — 시장·맛집·지역 음식 위주
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
# 하루하루의 시작·종료 시각(2026-09-11, 사장님 지시). 체크인 15시·체크아웃 11~14시라는
# 실제 숙박 흐름에 맞췄다. 모델에게 이대로 지시하지만 **grounding 이 강제로 되돌린다** —
# `duration` 을 덮어쓰는 것과 같은 판단이다(모듈 docstring 참고).
# ★ "returns" — 그 날이 끝날 때 업소로 돌아오는가. 마지막 날(체크아웃)만 False 다.
# `grounding/itinerary._apply_schedule` 이 이 값을 보고 업소를 정거장 맨 앞(출발)에,
# returns=True 인 날은 맨 뒤(복귀)에도 넣는다(2026-09-11, 사장님 지시: "업소는 출발지에 포함").
DAY_SCHEDULE: dict[str, tuple[dict[str, object], ...]] = {
"1박 2일": (
{"label": "첫째 날", "start": "15:00", "end": "19:00", "returns": True},
{"label": "둘째 날", "start": "09:00", "end": "14:00", "returns": False},
),
"2박 3일": (
{"label": "첫째 날", "start": "15:00", "end": "19:00", "returns": True},
{"label": "둘째 날", "start": "09:00", "end": "19:00", "returns": True},
{"label": "셋째 날", "start": "09:00", "end": "14:00", "returns": False},
),
}
def _schedule_text(duration: str) -> str:
"""DAY_SCHEDULE 을 프롬프트에 박을 문장으로. returns=False 인 날만 "종료(체크아웃)"이고
나머지는 "업소 복귀" — 실제 손님의 동선과 같다.
★ 업소 자신을 정거장으로 넣으라는 지시는 여기 없다 — 그건 모델이 아니라 grounding 이
실제 DB 값(상호명·좌표)으로 직접 채운다. 모델에게는 여전히 "[업소] 자신은 정거장에
넣지 않는다"고 시킨다(아래 [해야 할 일]) — 지어낼 여지를 아예 안 준다."""
lines = []
for slot in DAY_SCHEDULE[duration]:
ending = "업소 복귀" if slot["returns"] else "종료(체크아웃, 복귀 없음)"
lines.append(f"· {slot['label']}: {slot['start']} 시작 ~ {slot['end']} {ending}")
return "\n".join(lines)
# ★ verified 를 요구하지 않는다. 화면에 안 나오고(`SourceLine` 이 `void verified`),
# 모델은 좌표가 1.7km 틀린 항목에도 "확인" 을 붙였다 — 자기 신고는 믿을 값이 아니다.
#
# ★ `str.format` 을 쓰지 않고 `%` 치환을 쓴다. 이 문자열은 JSON 이라 리터럴 중괄호가 가득한데,
# format 은 그것을 필드명으로 읽고 터진다(`{ "kind":"itinerary"` 를 키로 해석한다).
# 중괄호를 `{{`/`}}` 로 이중화하는 길도 있지만, 프롬프트 본문이 눈으로 읽히지 않게 된다 —
# 스파이크가 검증한 방식(%)을 그대로 써서 보내는 바이트를 같게 유지한다.
_SCHEMA = """{ "kind":"itinerary", "version":1, "title":"추천 일정", "items":[
{ "name":"코스 이름(컨셉이 드러나게)",
"duration":"%(duration)s",
"audience":"누구에게 맞는 일정인가",
"why":"왜 이 일정인가 (두 문장 이내)",
"days":[
{ "label":"첫째 날", "startTime":"15:00", "stops":[
{ "name":"장소", "minutes":90, "moveMinutes":12,
"note":"한 줄 설명", "searchQuery":"지도 검색어",
"latitude":35.9908197, "longitude":126.7121231 } ] } ],
"source":{ "name":"출처 이름", "url":"https://..." } } ] }"""
_TASK = """[업소] %(place)s
[지역] %(region)s
[해야 할 일]
[업소]에 숙박하는 손님을 위한 %(duration)s 여행 일정을 **아래 5개 컨셉마다 2개씩, 총 10개** 만든다.
[컨셉]
%(concepts)s
· 같은 컨셉 안의 두 코스도 서로 다른 일정이어야 한다 — 정거장이 겹치는 것은 괜찮지만
(규칙 7 참고), 같은 장소를 그대로 두 번 우려내지 않는다. 컨셉에 맞는 장소가 둘째 코스를
못 채울 만큼 모자라면 그 컨셉은 1개만 낸다 — 억지로 채우지 않는다.
· 모든 일정의 duration 은 "%(duration)s" 이다.
· 하루하루의 시작·종료 시각은 고정이다. days 는 이 순서·시각대로 나눈다.
%(schedule)s
· 마지막 정거장은 그 날 종료 시각 전에 끝나야 한다(머무는 시간까지 포함해서) — 업소로
돌아오거나 체크아웃하러 이동하는 시간으로 30분 정도는 남겨 둔다.
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.
· [업소] 자신은 정거장에 넣지 않는다 — 일정은 업소에서 출발하는 것이다.
[스키마]
%(schema)s
[규칙]
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
3. 검색으로 실제 존재가 확인된 장소만 쓴다. 폐업·휴업한 곳은 넣지 않는다.
4. 순위를 매기지 않는다. rank 같은 칸은 없다.
5. 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
6. 모든 정거장에 latitude·longitude 를 적는다.
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
각 코스에는 다른 코스들에 없는 정거장이 **최소 두 곳** 들어가야 한다.
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
8. ★ 하루 종료 시각을 넘기는 정거장은 아예 적지 않는다 — 넘긴 값은 서버가 뒤에서부터
잘라내므로, 넘길 걸 알면서 적을 이유가 없다.
9. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다."""
def build_prompt(place_name: str, region_label: str, duration: str) -> str:
"""업소 하나 × 기간 하나의 프롬프트.
★ region_label 을 반드시 넣는다. 지역을 모른 채로 물으면 모델이 아무 도시나 고른다
(`story_service.region_label_of` 와 같은 판단). 부르는 쪽이 빈 지역을 걸러 준다.
"""
if duration not in DURATIONS:
raise ValueError(f"모르는 기간: {duration}")
ctx = {"place": place_name, "region": region_label, "duration": duration}
return _TASK % {
**ctx,
"concepts": _CONCEPTS,
"schema": _SCHEMA % ctx,
"schedule": _schedule_text(duration),
}