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

112 lines
7.0 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 으로 올렸다(완성 토큰이 코스 수에 비례해 늘어난다).
"""
# 화면 탭이 되는 값이다(`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. 야외활동 — 걷기·자전거·물놀이·전망처럼 몸으로 즐기는 것 위주"""
# ★ 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개 컨셉마다 2개씩, 총 10개** 만든다.
[컨셉]
%(concepts)s
· 같은 컨셉 안의 두 코스도 서로 다른 일정이어야 한다 — 정거장이 겹치는 것은 괜찮지만
(규칙 7 참고), 같은 장소를 그대로 두 번 우려내지 않는다. 컨셉에 맞는 장소가 둘째 코스를
못 채울 만큼 모자라면 그 컨셉은 1개만 낸다 — 억지로 채우지 않는다.
· 모든 일정의 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}