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

100 lines
5.9 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개 컨셉으로".
★ 왜 shared 에 두지 않나
`shared/src/lib/section-prompts.ts` 의 단일 출처 규칙은 "사장님이 복사해 가는 프롬프트와
서버가 도는 프롬프트가 **같은 문장**일 때" 의 규칙이다. 이 둘은 하는 일이 다르다 —
사장님용(`canvas/dataSpec.ts`)은 "3~5개, 반나절·1박2일·2박3일 섞기" 고, 이쪽은
"기간 고정 + 지정된 컨셉 5개" 다. 같은 문장을 두 벌 두는 게 아니므로 daily 때의 분기 사고와
성격이 다르다. 문장 자체는 사장님용에서 가져와 변형했다.
★ 컨셉을 **우리가** 준다 (스파이크 2026-09-11, 스테이,머뭄)
모델에 맡기면 2박 3일에서 5개 코스 중 **4개가 정거장 집합 100% 동일**이었다(고유 장소 13곳).
이름만 '가족 체험'·'사진 골목'·'도보 미식' 이고 내용은 같은 8곳의 재배열이다.
컨셉을 지정하자 고유 장소가 24곳으로 늘고 같은 집합이 사라졌다.
★ 컨셉 축은 **테마**여야 한다
'차 없이 걸어서' 를 축으로 넣었더니 '처음 온 손님(대표 명소)' 과 91% 겹쳤다 —
원도심이 곧 도보권이라 같은 장소로 수렴한다. 제약은 축이 될 수 없다.
"""
# 화면 탭이 되는 값이다(`ItineraryItem.duration`). 표기를 바꾸면 사장님이 적은 일정과 탭이 갈린다
# — `canvas/dataSpec.ts` 의 options(['반나절','1박 2일','2박 3일'])와 같은 문자열이어야 한다.
DURATIONS: tuple[str, str] = ("1박 2일", "2박 3일")
# 2박 3일 5코스가 completion 3,922 토큰까지 갔다(실측). 기본값 2048 로는 잘린다.
MAX_TOKENS = 12000
SYSTEM_PROMPT = (
"너는 지역 여행 코스 플래너다. 검색으로 확인한 실제 장소만 쓰고, "
"확인하지 못한 값은 필드를 통째로 뺀다. JSON 하나만 출력한다."
)
_CONCEPTS = """1. 역사·근대건축 — 박물관·옛 건물·유적 위주
2. 자연 풍경 — 바다·산·호수·공원 중 그 지역에 실제로 있는 것 위주
3. 미식 — 시장·맛집·지역 음식 위주
4. 아이와 함께 — 체험·동물·놀이·넓은 공원 위주
5. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
# ★ 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":"14: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개 컨셉으로 하나씩** 만든다.
[컨셉]
%(concepts)s
· 모든 일정의 duration 은 "%(duration)s" 이다.
· days 로 날짜를 나눈다.
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.
· [업소] 자신은 정거장에 넣지 않는다 — 일정은 업소에서 출발하는 것이다.
[스키마]
%(schema)s
[규칙]
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
3. 검색으로 실제 존재가 확인된 장소만 쓴다. 폐업·휴업한 곳은 넣지 않는다.
4. 순위를 매기지 않는다. rank 같은 칸은 없다.
5. 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
6. 모든 정거장에 latitude·longitude 를 적는다.
7. ★ 코스 둘이 **같은 정거장 집합**이면 안 된다. 순서만 바꿔 놓은 것은 같은 코스다.
각 코스에는 다른 네 코스에 없는 정거장이 **최소 두 곳** 들어가야 한다.
컨셉에 맞는 장소가 모자라면 그 코스의 정거장을 줄인다 — 다른 코스의 장소를 빌려오지 않는다.
8. 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.
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}