[feat] solution/backend: 일정 프롬프트 — 컨셉을 모델에게 맡기지 않는다

This commit is contained in:
김성경 2026-09-11 10:41:00 +09:00
parent 25f7e5c0c8
commit 411a4e7d55
3 changed files with 176 additions and 1 deletions

View File

@ -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 로 실행하세요."
)

View 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}

View 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일")