[feat] solution/backend: 일정 프롬프트 — 컨셉을 모델에게 맡기지 않는다
This commit is contained in:
parent
25f7e5c0c8
commit
411a4e7d55
@ -5,6 +5,13 @@ import os
|
||||
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""순수 단위 테스트(test_itinerary_prompt.py 등)는 DB 없이 실행될 수 있도록 표시한다."""
|
||||
args = " ".join(config.invocation_params.args)
|
||||
# test_itinerary_prompt.py 만 실행 중인지 확인
|
||||
config.SKIP_DB_SETUP = "test_itinerary_prompt.py" in args
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
@ -56,13 +63,19 @@ async def _drop_test_db(*, recreate: bool):
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _test_db_lifecycle():
|
||||
async def _test_db_lifecycle(request):
|
||||
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다.
|
||||
|
||||
매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다.
|
||||
(테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.)
|
||||
안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호).
|
||||
|
||||
순수 단위 테스트(DB 필요 없음)는 SKIP_DB_SETUP 플래그로 이 fixture 를 건너뛴다.
|
||||
"""
|
||||
if getattr(request.config, "SKIP_DB_SETUP", False):
|
||||
yield
|
||||
return
|
||||
|
||||
assert "test" in main_db_config.name, (
|
||||
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
|
||||
)
|
||||
|
||||
99
solution/backend/services/prompts/itinerary.py
Normal file
99
solution/backend/services/prompts/itinerary.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""여행 일정 프롬프트 — "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}
|
||||
63
solution/backend/tests/test_itinerary_prompt.py
Normal file
63
solution/backend/tests/test_itinerary_prompt.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""여행 일정 프롬프트 — 무엇을 반드시 물어야 하는가.
|
||||
|
||||
DB 도 네트워크도 쓰지 않는 순수 단위 테스트다.
|
||||
|
||||
이 파일이 지키는 것(스파이크 2026-09-11 에서 실측으로 얻은 것들):
|
||||
- 컨셉 5개를 **우리가** 준다. 모델에 맡기면 2박 3일에서 5개 중 4개가 정거장 집합 100% 동일이 된다
|
||||
- 업소 자신을 정거장에 넣지 말라고 못 박는다. 안 적으면 "스테이,머뭄" 이 정거장으로 들어온다
|
||||
- 지역명을 넣는다. 없으면 모델이 아무 도시나 고른다(story_service.region_label_of 와 같은 이유)
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from services.prompts import itinerary as prompts
|
||||
|
||||
|
||||
def test_durations_are_two_render_contract_strings():
|
||||
"""검증: 기간 문자열.
|
||||
기대결과: 화면 탭·ItineraryItem.duration 이 되는 값이라 표기가 정확해야 한다."""
|
||||
assert prompts.DURATIONS == ("1박 2일", "2박 3일")
|
||||
|
||||
|
||||
def test_prompt_carries_place_region_and_duration():
|
||||
"""검증: 변수 세 개가 프롬프트에 들어간다."""
|
||||
text = prompts.build_prompt("스테이,머뭄", "전북특별자치도 군산시", "1박 2일")
|
||||
assert "스테이,머뭄" in text
|
||||
assert "전북특별자치도 군산시" in text
|
||||
assert '"1박 2일"' in text # 스키마의 duration 고정값
|
||||
assert "1박 2일 여행 일정" in text # 해야 할 일
|
||||
|
||||
|
||||
def test_prompt_fixes_five_concepts():
|
||||
"""검증: 컨셉 5개가 프롬프트에 박혀 있다.
|
||||
기대결과: 모델이 컨셉을 고르지 않는다 — 고르게 하면 장소가 수렴한다."""
|
||||
text = prompts.build_prompt("업소", "지역", "2박 3일")
|
||||
for concept in ("역사·근대건축", "자연 풍경", "미식", "아이와 함께", "야외활동"):
|
||||
assert concept in text
|
||||
|
||||
|
||||
def test_prompt_forbids_the_lodging_itself_as_a_stop():
|
||||
"""검증: 업소를 정거장에 넣지 말라는 규칙."""
|
||||
text = prompts.build_prompt("업소", "지역", "1박 2일")
|
||||
assert "[업소] 자신은 정거장에 넣지 않는다" in text
|
||||
|
||||
|
||||
def test_prompt_requires_coordinates_and_forbids_fences():
|
||||
"""검증: 좌표는 항상 요구하고, 코드펜스는 금지한다.
|
||||
기대결과: 좌표를 '아는 곳만' 으로 두면 정거장 30곳 중 8곳이 좌표 없이 온다(실측)."""
|
||||
text = prompts.build_prompt("업소", "지역", "1박 2일")
|
||||
assert "모든 정거장에 latitude·longitude 를 적는다" in text
|
||||
assert "코드펜스를 붙이지 않는다" in text
|
||||
|
||||
|
||||
def test_prompt_forbids_identical_courses():
|
||||
"""검증: 같은 정거장 집합 금지 규칙.
|
||||
기대결과: 정거장 겹침 자체는 허용이고, '같은 코스' 만 막는다(2026-09-11 결정)."""
|
||||
text = prompts.build_prompt("업소", "지역", "2박 3일")
|
||||
assert "같은 정거장 집합" in text
|
||||
|
||||
|
||||
def test_unknown_duration_is_rejected():
|
||||
"""검증: 모르는 기간.
|
||||
기대결과: ValueError — 화면에 없는 탭을 만드는 값이 조용히 저장되면 안 된다."""
|
||||
with pytest.raises(ValueError):
|
||||
prompts.build_prompt("업소", "지역", "3박 4일")
|
||||
Loading…
Reference in New Issue
Block a user