"""여행 일정 프롬프트 — "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), }