AI 설정(AI_PROVIDER · 모델 · effort · 하루 횟수 · 비용 상한 · 제한 시간)이 예시 파일에 없어서,
실제 env 에 무엇을 적을 수 있는지 코드를 읽어야 알 수 있었다. 키와 기본값 설명을 빈 값으로 적어 둔다.
비어 있으면 코드 기본값을 쓰므로 예시를 그대로 옮겨도 동작이 바뀌지 않는다.
설계 §11 은 이 설정들이 루트 .env 에서 compose 로 넘어간다고 적었지만 compose 는 넘기지 않는다.
backend/.env.{env} 에 적는 것으로 고쳤다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63 KiB
AI 코스 생성 설계 (v0.2, 2026-11-05)
사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다.
코드는 §14 순서로 만들고 있다(2026-11-12 기준 6단계 평가까지 — 코스 초안은 Sonnet 5.5, 조건 해석은 Haiku 5.5). 장소·축제 데이터는 장소 DB 설계를 그대로 쓰고, 평가는 docs/ai-eval/ 를 쓴다.
§13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다.
v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다. 조건 해석
issues3개 추가(§2), 별칭이 가리키는 장소를 중심으로(§2), 전시는 실내(§3.3), 비 올 때 실내 비율은 후보가 모자라면 알림으로(§3.3·§3.6), 코스를 못 만들면 이유를 함께(§3.5·§3.6·§4).
0. 전제 (이미 정한 것)
| 전제 | 근거 |
|---|---|
AI 후보는 자체 장소 DB(place_listing)와 festival_event 에서만 고른다. 카카오 응답은 LLM 에 보내지 않는다 |
장소 DB §0, docs/research/pre-dev-check/kakao_place_id.md |
| 후보 순위: 운영자가 추천·확인한 곳(1순위) → 관광공사 장소(2순위). 확인 안 된 상가정보 매장은 쓰지 않는다 | 장소 DB §3 ai_tier |
| DB 가 후보를 고르고, AI 는 ID 로만 선택하고, 코드가 검증하고 순서를 맞춘다 | 기획 검토 보고서(2026-10-28) |
| 횟수: 비로그인 하루 1~2회, 로그인하면 늘린다. 처음 만들기·다시 만들기만 차감하고 후보로 바꾸기는 차감하지 않는다 | MVP 결정(2026-10-28·29) |
| 결과에 고른 이유와 출처를 보여 준다. 바꾸기 후보는 생성 때 함께 받는다(추가 AI 호출 없음) | MVP 결정(2026-10-29), 디자인 결과 화면 |
| AI 가 만든 결과임을 표시한다 | 기획 검토 보고서(AI 기본법 생성물 표시) |
| 영업시간이 없는 곳은 "영업시간 미확인 · 방문 전 확인"으로 표시하고 AI 시간 검증에서 뺀다 | 착수 전 확인(2026-10-29) |
| 만들기·보기는 비로그인으로 되고, 저장은 로그인해야 한다 | MVP 결정 |
| 한국어·영어로 출시하고 4개 언어를 전제로 설계한다 | MVP 결정 |
| 기본 모델은 평가셋으로 정한다 | 기획 검토 보고서 미결 항목 |
1. 한눈에 보기
[화면 8 입력] [화면 9 만드는 중] [화면 10 결과]
한 문장 ─▶ 조건 해석 ─▶ 조건 확인·수정 ─▶ 생성 시작 ─▶ 진행 조회(1~2초마다) ─▶ 결과
(AI 1회, 차감 없음) (차감 대상) │
후보로 바꾸기(앱 안) · 평가 · 다시 만들기 · 저장(로그인)
생성 작업 (서버 백그라운드)
① 후보 찾기 반경 안 1·2순위 장소 + 그날 행사 → "수성못 근처에서 128곳을 찾았어요"
② 거르기 영업·반려동물·공휴일·행사 시간 (코드) → "토요일 영업·반려견 동반으로 31곳을 추렸어요"
③ 코스 짜기 후보 40곳 이하 → AI: 고르기·순서·머무는 시간·이유
→ 코드: 이동 시간·시간표·검증·고치기 → "날씨와 동선에 맞춰 시간표를 짜는 중"
④ 마무리 사실 값 붙이기 · 바꾸기 후보 검증 · 출처 → "고른 이유를 정리할게요"
원칙은 세 가지다.
- 사실은 DB 가, 말은 AI 가. 영업시간·반려동물 조건·행사 기간·출처처럼 화면에 나오는 사실 값은 모두 DB 에서 붙인다. AI 는 장소를 고르고 한 줄 이유와 요약만 쓴다.
- AI 는 후보 목록의 번호로만 고른다. 목록 밖 장소나 지어낸 장소는 검증에서 버린다(구조화 출력 + 코드 검증).
- 확인할 수 없는 것을 확인된 것처럼 보이지 않는다. 미확인 값은 '미확인'으로 표시한다. 위반을 고칠 수 없으면 장소 수를 줄이고 이유를 알린다.
| 일 | 맡는 쪽 |
|---|---|
| 문장에서 지역·날짜·시간 같은 조건 뽑기 | AI(작은 모델). 사용자가 확인·수정한다 |
| 후보 찾기·거르기 | 코드(SQL) |
| 어떤 곳을, 어떤 순서로, 몇 분씩 | AI |
| 이동 시간·도착 시각·영업 여부·반려 조건 검증 | 코드 |
| 순서 고치기·머무는 시간 조정 | 코드(장소가 6곳 이하라 모든 순서를 따져 볼 수 있다) |
| 이유·요약 문장 | AI |
| 화면의 사실 값·출처 | 코드(DB) |
2. 조건 해석 (입력 화면)
사용자가 문장을 다 쓰면(입력이 1초 멈추거나 키보드 '완료') 한 번 부른다. 글자마다 부르지 않는다. 횟수를 차감하지 않는다.
POST /api/course-conditions — 요청 { text, baseArea }, 응답 { conditions, inferred, issues, weather }
조건(CourseConditions)은 생성 요청에도 그대로 쓴다.
| 항목 | 값 | 비었을 때 기본값 |
|---|---|---|
area |
{type:'neighborhood', key}(downtown · suseongmot · apsan) · {type:'point', lat, lng}(현재 위치) · {type:'place', placeId}("서문시장 근처") |
앱의 기준 위치(baseArea) |
date |
YYYY-MM-DD, 오늘 ~ 30일 뒤 | 오늘(18시 이후면 내일) |
start · end |
HH:mm, 2~12시간, 자정을 넘기지 않는다(MVP) | 오늘이면 다음 정시, 아니면 13:00 · 시작 + 6시간(22시까지) |
party |
1~10 | 2 |
pet |
{kind: dog·cat·other, size: small·medium·large·null, count} 또는 null |
null |
mobility |
walk · transit · car | walk |
indoorIfRain |
boolean | true |
-
inferred는 문장에서 뽑은 항목 목록이다. 화면의 "적은 내용에서 자동으로 채웠어요" 에 쓴다. -
issues는 사용자에게 되묻거나 알릴 것이다. 앱이 이유를 보여 주고 그 조건을 고치게 한다. 평가셋의ask_clarification·decline_out_of_scope사례는 이 단계에서 다룬다(§9).값 경우 unsupported_area대구 밖 지역("성수동", "경주 황리단길"). 지금은 대구만 된다 unknown_place말한 장소를 장소 DB 에서 찾지 못했다. 지역은 기준 위치로 두었다(5단계에서 더함) past_date·date_out_of_range지난 날짜(오늘인데 이미 끝난 시간 포함) · 30일보다 먼 날짜("다음 달 크리스마스") multi_day"3박 4일", "1박" — 하루 코스만 된다 too_short·past_midnight2시간보다 짧다("점심시간 1시간만") · 자정을 넘긴다("밤 11시 이후") unclear_request코스 요청으로 읽을 수 없다("ㅁㄴㅇㄹ") -
지역 문구는 AI 가 뽑고, 코드가 동네 이름·별칭 → 장소 DB 이름 검색 순서로 찾는다. 대구 영역(
shared/utils/geo.ts의isInSido('27', …)) 밖이면unsupported_area다. 현재 위치가 대구 밖이면 지금 앱처럼 도심으로 대신하고 안내한다. -
별칭이 특정 장소를 가리키면(김광석길 → 김광석다시그리기길, 서문시장) 그 장소를 중심으로 한다(
{type:'place'}). 김광석다시그리기길은 도심 중심에서 1.55km 라, 동네 중심으로 잡으면 걸어서 반경(1.5km) 밖이 된다. -
weather는 고른 날짜·지역의 예보다(§3.3). 입력 화면의 "토요일 오후 비 60%" 에 쓴다. -
모델은 작은 모델(Haiku 5.5)에 구조화 출력을 쓴다. AI 호출이 실패하면 기본값만 채운 조건을 돌려준다(입력 화면을 막지 않는다).
-
AI 에는 문장, 오늘 날짜·요일, 지원 동네 목록만 보낸다. 좌표·기기 정보는 보내지 않는다.
-
5단계에서 정한 나눔(2026-11-11,
condition-extraction.ts·ai/condition-prompt.ts):- AI 는 문장에서 값만 뽑는다. 지역 언급(none · neighborhood · place · near_me · outside), 날짜, 시작·끝, 길이("2시간만"), 자정 넘김, 인원, 반려동물, 이동 수단, 비 오면 실내, 여러 날 일정, 읽을 수 없음이다. 근거가 없는 항목은 null 이다.
- 기본값 채우기와
issues판정은 코드(applyExtraction)가 한다. 지난 날짜·짧은 시간 같은 값도 그대로 돌려줘 앱이 그 항목을 짚어 고치게 한다. 자정을 넘기면 끝을 24:00 으로 두고, 12시간보다 길면 12시간으로 줄인다. 시작만 말하면 6시간(22시까지)이되 최소 2시간을 둔다. - 날짜는 사용자 메시지에 이번 주·다음 주·다다음 주 달력(월~일)을 붙여 모델이 셈하지 않고 고르게 한다.
- 장소는 별칭표(
PLACE_ALIASES: 팔공산 · 83타워 · 서문시장 · 김광석길) → 이름 검색 순서로 찾는다. 이름 검색은 공백을 무시하고, 이름이 같은 곳 → AI 후보 순위가 있는 곳 → 명소 분류 → 짧은 이름 순으로 고른다. - 조건 해석 비용도 하루 비용 상한에 들어간다. 생성 기록이 없어 서버 메모리에 더한다(서버 한 대, 다시 시작하면 0부터). 상한에 닿으면 AI 를 부르지 않고 기본값만 채운다.
- "비 오는 토요일"처럼 날씨를 묘사만 한 문장으로는
indoorIfRain을 채우지 않는다. 비 여부는 예보로 판단한다(첫 실제 호출에서 잘못 채워 규칙을 더했다).
3. 생성
3.1 단계와 진행 표시
생성은 서버의 백그라운드 작업이다. 앱은 GET /api/course-generations/{id} 를 1~2초마다 불러 단계와 숫자를 받고, 화면 문구는 앱이 언어별로 만든다.
stage |
서버가 하는 일 | 함께 주는 값 | 화면 문구(예) |
|---|---|---|---|
searching |
후보 찾기 | found |
"수성못 근처에서 128곳을 찾았어요" |
filtering |
거르기·줄이기 | kept |
"토요일 영업·반려견 동반으로 31곳을 추렸어요" |
planning |
AI 호출 → 시간표·검증·고치기 | — | "날씨와 동선에 맞춰 시간표를 짜는 중" |
finishing |
사실 값·바꾸기 후보·출처 붙이기 | — | "고른 이유를 정리할게요" |
- 스트리밍(SSE) 대신 조회를 쓰는 이유: 표준 응답 형식을 그대로 쓸 수 있고, 앱이 잠깐 백그라운드로 가도 같은 id 로 이어서 볼 수 있다.
앱 Dio 의 받기 제한(15초)에 걸리는 긴 요청도 없다. 생성은 10
20초라 조회 1020번이면 끝난다. - 작업 제한 시간은 45초다(AI 호출 30초 + 재시도 여유). 넘으면
failed+AI_002. - MVP 는 서버가 한 대라 프로세스 안에서 돌린다. 동시 AI 호출은 5건까지이고, 넘으면
queued로 기다린다. 서버를 늘리면 Redis 큐(BullMQ)로 옮긴다(nestjs 스킬의 Redis 확장 지점). - 종료할 때 진행 중인 작업은 AI 호출을 끊고
failed(차감 없음)로 남긴다(OnApplicationShutdown). 기동할 때는 2분 넘게running인 작업을failed로 바꾼다.- 구현(2026-11-08,
course-generation.runner.ts): 서버가 한 대라 기동할 때 남은queued·running은 모두 지난 프로세스의 것으로 보고failed(AI_002, 차감 없음)로 바꾼다. 1분마다 2분 넘게running인 작업도 정리한다. 종료할 때는 DB 연결이 닫히기 전(beforeApplicationShutdown)에 남은 작업을failed로 적는다.
- 구현(2026-11-08,
3.2 후보 찾기
- 장소:
place_listing에서is_searchable이고ai_tier가 1·2 인 곳. 숙박은 순위와 관계없이 뺀다. - 반경: 걸어서 1.5km(장소 목록 API 기본값과 같다) · 대중교통 6km · 자동차 20km(도심에서 팔공산까지). 중심은 조건의
area다. - 행사:
festival_event중 그날 진행하고 반경 안에 있으며 숨기지 않은 것. 종류별 기간 상한(장소 DB §2.4)을 적용한다. 축제·행사·전시만 넣는다. 공연(KOPIS)은 회차 시각을 아직 해석하지 않아 MVP 후보에서 뺀다(§13).
3.3 거르기 (코드)
| 조건 | 규칙 |
|---|---|
| 영업 | 그날 날짜 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. isOpenAt(hours, 날짜, 분) 으로 10분 간격 확인(브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간 포함) |
| 영업시간 미확인 | 머무는 시간 중 isOpenAt 이 null 인 때가 있으면 미확인이다(status 가 unknown · varies 이거나, 그 요일 시간이 없거나, 계절을 정할 수 없는 달에 두 계절 시간이 다를 때). 자연·관광지와 행사만 남기고(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 계절별 시간표는 2026-11-06 부터 날짜의 달로 고른다(대구 30곳). 달을 적지 않은 하절기·동절기(7곳)는 3·10·11월에 두 계절 시간이 같은 시각만 판단한다 |
| 공휴일 | 그날이 공휴일·명절이고 그 공휴일의 휴무 표시(holidayClosures)가 있으면 뺀다. '공휴일' 휴무는 모든 공휴일에, 설·추석 휴무는 그 연휴 내내(대체공휴일 포함) 쉬는 것으로 본다(당일만 쉬는지 알 수 없어서). 공휴일 표는 코드 상수(shared/utils/kr-holidays.ts, 2026–2027)로 두고 해마다 갱신한다. 2026년부터 노동절·제헌절도 공휴일이고 대체공휴일이 붙는다 |
| 반려동물 (조건에 있을 때) | not_allowed 와 크기 불일치(petSizes 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 allowed · partial 만 남긴다. 야외 분류와 축제·행사는 unknown 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 |
| 행사 시간 | playtime_text 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 |
| 다시 만들기 | 이전 결과의 장소는 남은 후보가 15곳 이상이면 빼고, 아니면 목록 뒤로 보낸다 |
| 비 | 거르지 않는다. 강수확률이 40% 이상이고 indoorIfRain 이면 AI 에 '실내 위주'를 알리고, 검증에서 실내 비율 50% 이상을 본다. 실내 후보가 모자라 채울 수 없으면 코스는 내고 notices 에 알린다 |
- 실내·실외: 큐레이션
setting을 쓰고, 없으면 분류로 추정한다(문화시설·식당·카페·쇼핑과 전시는 실내, 자연은 실외, 관광지·레저·축제·행사는 미확인). 실내·외가 함께 있는 곳(both)은 실내로 본다. 비를 피할 수 있고, 반려동물은 실내 규칙을 따라야 해서다. - 날씨: 기상청 단기예보(동네예보 격자). 오늘부터 3일 뒤까지 나오고, 17시 이후 발표분은 4일 뒤까지 나온다.
그 뒤 날짜는
weather: null이다. 입력 화면에 "예보 없음"을 보여 주고 비 조건은 쓰지 않는다. 격자·발표 시각별로 1시간 캐시한다. 2026-10-29 점검 스크립트로 이미 불러 본 API 다(docs/research/data-check/).- 구현(
backend/src/modules/weather/, 2026-11-08): 위경도를 5km 격자로 바꾸고, 발표 10분 뒤부터 그 발표를 쓴다. 최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다. - 강수확률은 시간 범위 중 가장 높은 값이고, 기온은 범위 안 최저·최고, 하늘은 가장 잦은 상태다. 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. 예보가 시간 범위 끝까지 닿지 않으면 null 이다.
- 입력 화면을 막지 않게 제한 시간 3초·2번 시도이고, 실패하면 던지지 않고 null 이다. 실패는 2분 동안 다시 부르지 않는다.
하루 호출은 1,000번까지 센다(캐시 덕에 실제로는 하루 수십 번). 공공데이터포털 키(
PUBLIC_DATA_SERVICE_KEY)를 장소 동기화와 함께 쓴다. - 실제 호출 확인:
docker exec wandergo-backend npm run weather:check -- [--lat=… --lng=…] [--start=10:00 --end=20:00](키가 필요해 사용자가 실행한다). 2026-11-08 15:38 확인: 대구 도심 격자 89,90, 14시 발표로 11-08~11-11 예보(11일은 3시간 간격), 11-12 는 예보 없음, 기상청 호출 1번
- 구현(
3.4 후보 줄이기 (최대 40곳)
AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다.
- 1순위(확인한 곳)를 먼저, 그다음 거리순으로 둔다.
- 분류마다 최대 10곳이다. 한 분류(예: 관광지)가 목록을 다 채우지 않게 한다.
- 행사는 가까운 순서로 최대 8개다. 장소와 행사를 합쳐 40곳 이하라, 장소는 40곳에서 행사 수를 뺀 만큼 넣는다.
- 다시 만들기 규칙(§3.3, 이전 장소를 빼거나 뒤로 보내기)도 여기서 적용한다.
- AI 에는 장소 id(uuid) 대신 짧은 번호(P01
P40, E01E08)를 준다. 번호와 id 의 대응은 서버만 가진다.
3.5 AI 호출
입력은 시스템 프롬프트(규칙과 출력 형식. 요청마다 같다)와 사용자 메시지다.
요청: "비 오는 토요일 오후, 강아지랑 수성못 데이트"
조건: 2026-11-07(토) 14:00–20:00 · 2명 · 소형견 1 · 걸어서(구간 최대 15분) · 실내 위주(비 60%)
후보:
P01 | 수성못 유원지 | 자연 | 실외 | 중심 0m | 상시 개방 | 반려: 가능(관광공사) | 관광공사
P02 | ○○카페 | 카페 | 실내 | 북동 420m | 10:00–22:00 | 반려: 소형 가능·테라스석만(11.02 확인) | 확인한 곳: "못이 보이는 2층 창가"
…
E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: 정보 없음 | 11.06–11.08
- 후보마다 이름(요청 언어), 분류, 실내·실외, 중심에서의 방향·거리, 그날 영업시간, 반려 조건, 출처, 확인한 곳의 추천 이유를 준다. 소개 앞 80자도 넣을지는 평가로 정한다(장소를 고르는 데 도움이 되지만 토큰이 늘어난다).
- 좌표·사용자 위치·기기 정보는 보내지 않는다. 거리와 방향은 서버가 계산한다.
출력은 구조화 출력(JSON 스키마)으로 받는다.
| 필드 | 내용 |
|---|---|
title |
코스 이름(20자 이내). 저장할 때 쓴다 |
summary |
코스 전체 설명 한두 문장(120자 이내) |
stops[] |
ref(P·E 번호) · minutes(머무는 시간) · reason(60자 이내) |
alternates[] |
forStop(몇 번째 대신) · ref · reason. 장소마다 최대 1개 |
notices[] |
조건을 다 맞추지 못한 이유("대형견 동반 실내 전시가 2곳뿐이라 2곳만 넣었어요"), 들어줄 수 없는 부분 |
프롬프트 규칙 (요지)
- 후보 목록의 번호만 쓴다. 같은 곳을 두 번 쓰지 않는다.
- 이유는 후보에 적힌 사실(분류·실내외·거리·영업시간·반려 조건·추천 이유·소개)로만 쓴다. 가격·분위기·메뉴·혼잡도처럼 데이터에 없는 내용("조용한", "저렴한" 등)은 쓰지 않는다.
- 이유에 순서 표현("먼저", "마지막으로")을 쓰지 않는다. 코드가 순서를 바꿀 수 있기 때문이다.
- 시간 범위가 식사 시간대(점심 11:30–14:00, 저녁 17:30–20:30)에 걸치면 식당을 그 시간에 둔다.
- 위험하거나 범위 밖인 부탁(음주 운전, 시스템 정보 요청 등)은 그 부분만 빼고
notices에 알린다. 코스는 조건대로 만든다. - 요청의 핵심(예: '카페 투어', '실내 전시만')을 맞출 후보가 없으면 다른 곳으로 채우지 않는다.
stops를 비우고notices에 이유를 쓴다(→AI_003, §3.6). 일부만 못 맞추면("산책하고 카페도") 맞출 수 있는 만큼 만들고 알린다. - 요청 언어(한국어·영어)로 쓴다.
호출 설정
- 모델과 effort 는 평가로 정한다(§8). 추론(adaptive thinking)은 effort 를 낮춰 지연을 줄이는 쪽부터 시험한다.
- 시스템 프롬프트는 프롬프트 캐시에 올린다. 트래픽이 적으면 캐시가 5분 안에 다시 쓰이지 않아 이득이 작다.
- 제한 시간 30초, SDK 재시도 1회. 프롬프트는 코드에 두고 버전(
PROMPT_VERSION)을 생성 기록에 남긴다.
프롬프트 v1 (5단계, 2026-11-11 · ai/course-prompt.ts · ai/anthropic-course-ai.client.ts)
- SDK 는
@anthropic-ai/sdk1.5.0 이다. 구조화 출력은output_config.format(JSON 스키마)으로 받고 코드가 형식을 다시 본다. 번호가 후보에 있는지는planCourse가 본다(unknown_ref). 추론은 adaptive, 깊이는AI_COURSE_EFFORT(기본 low)다. - 시스템 프롬프트에는 날짜·후보 같은 바뀌는 값을 넣지 않는다(캐시가 맞게). 분류별 머무는 시간과 식사 시간대는 코드 상수에서 만든다.
- 후보 한 줄:
번호 | 이름 | 분류 | 실내외 | 지역 중심에서 방향·거리 | 그날 영업 | (반려 조건) | 출처. 반려 조건은 요청에 반려동물이 있을 때만 넣는다. 출처는 확인한 곳 · 관광공사 · 행사 기간이다. - 사용자 메시지에 '답할 언어'(요청의 lang)를 적는다. 다시 부를 때는 '직전 초안의 문제'로 위반 목록을 붙인다.
- 응답을 받은 뒤 실패하면(거절 · 잘림 · JSON 이 아님 · 형식이 다름)
AI_002이고, 그 호출의 사용량을 생성 기록에 남긴다. 비용이 이미 나갔기 때문이다. - 화면을 지키려고 글자 수를 자른다. 제목은 40자, 요약은 200자, 이유는 120자, 알림은 3개·200자까지다. 프롬프트로는 20 · 120 · 60자 이내로 쓰게 한다.
- 아직 넣지 않은 것:
- 확인한 곳의 추천 이유(
place_curation.reason_ko) · 반려 조건 문구 · 소개 앞 80자 · 영어 장소 이름 - 확인한 곳이 0곳이라 뺐다. 큐레이션이 생기거나 평가(6단계)에서 필요하다고 나오면 넣는다.
- 확인한 곳의 추천 이유(
- 거절 대비(server-side fallback)는 넣지 않았다. Haiku 5.5 는 지원하지 않고, Sonnet 5.5 로 올리면 다시 본다.
- 날씨·예보는 앱이 보여 주므로 summary·notices 에 쓰지 않게 했다. 예보는 4일 뒤까지만 있어 없는 경우가 많은데, 첫 실제 호출에서 "예보가 없어…" 알림을 썼다.
- 첫 실제 호출(2026-11-11, Haiku 5.5 · effort low,
ai:check)의 결과다.- 조건 해석: 3.2초 · $0.0005
- 코스 초안: 후보 12곳, 13.3초, 출력 2,154토큰(대부분 추론), $0.0015. 결과는 장소 3곳(일부만 맞춤)이었다.
- 첫 호출이라 캐시 쓰기만 있었다. 지연·비용 분포는 6단계 평가로 본다.
프롬프트 v2 (6단계, 2026-11-11 · course-v2 · conditions-v2) — 기준선(§9.1)에서 본 실패를 고쳤다.
- 코스: 사용자 메시지에 '장소 수'(
stopCountRange— 1.5시간에 1곳 목표, 한 곳 적어도 된다)와 '식사'(mealSlots)를 코드가 계산해 적는다. 끼니는 시간 범위가 그 식사 시간대에 1시간 30분 이상 걸치고(MEAL_OVERLAP_MIN_MINUTES, 13시 시작이면 점심은 넣지 않는다 — 평가셋 끼니 기대와 같은 기준) 그 시간대에 식당 최소 60분을 머물 수 있을 때만 넣는다. 작은 모델이 시간을 셈하다 코스를 짧게 끝내거나 끼니를 빠뜨렸기 때문이다. 식사 줄은 후보에 식당이 있을 때만 넣는다(반려동물 조건 등으로 식당이 없는데 넣으면, 넣지 못한 식사를 요약에 썼다). - 코스 규칙에 더한 것: '실내외 모름'을 "실내"라고 쓰지 않기, 가격 · 무료 · 입장료 쓰지 않기, 이유에 도착 시각 쓰지 않기, 제목 · 요약은 넣은 곳만으로, 식당 이유는 '점심' · '저녁' 대신 '식사'(플래너가 순서를 바꾸면 끼니 이름이 뒤바뀌었다), 식당을 넣지 않았으면 식사를 말하지 않기, '반려: 일부 가능'은 그대로 옮기고 개가 아니면 동반을 단정하지 않기, 행사 · 축제 · 전시를 찾으면 행사 후보 먼저, 오래 걷기 힘든 동행이면 비슷한 방향 · 거리끼리 묶기, 들어줄 부분이 없으면(지시를 바꾸라는 말뿐) 조건만으로 무난하게 만들기(비우지 않는다).
- 비우기와 일부만 맞추기: 대부분은 맞출 수 있는 활동으로 만들고 알린다. 비우는 것은 요청 전체가 한 가지 조건이고 맞출 후보가 하나도 없을 때뿐이다. v2 첫 판에서 비우기 예시를 넓게 써서 Haiku 가 일부만 맞출 사례(P04 · P10 · G05)까지 비웠다.
- 조건 해석: 말한 시각이 때보다 먼저("밤 10시" 22:00), 하루 종일은 시작 · 끝을 모두, '오전에'는 시작만 · '오전에만'은 끝까지, '~까지'는 끝(저녁까지 21:00 — 18:00 이 아니다), 새벽 N시, 기념일은 날짜로, 영어 시간 표현, '~견' · 노견은 dog, '걷다가'는 이동 수단이 아님, 장소 이름 뒤의 '숲길' · '근처' 빼기, 지시문 주입은 unclear 가 아님, "실내 위주로" · "실내에서"는 indoorIfRain(사용자 결정 §9.1). v2 첫 판의 "영어로 I 만 쓰면 1명"은 뺐다 — Haiku 가 I 가 없는 영어 문장(G06)에도 1명을 넣었다.
- 동네 이름 표(
NEIGHBORHOOD_PLACE_NAMES)를 조건 해석 프롬프트와 코드가 같이 쓴다. AI 가 동네 이름("수성못")을 장소로 뽑아도 코드가 동네로 바꾸고, 장소 DB 에 없는 이름 앞에 동네가 붙으면("수성못 비엔날레") 그 동네로 둔다. - 프롬프트 예시를 평가셋에 없는 문장으로 바꿨다. v1 에는 평가셋 문장과 같거나 거의 같은 예시가 여럿 있어(조건 해석 D01 · K06 · G06 · X02 · F04 · X07, 코스 P02 · X01)
그 사례의 점수가 외운 답이 될 수 있었다. v2 를 다듬으며 넣은 예시에도 겹침이 있어(D06 · P07 · W04 · K01 등) 모두 바꿨다.
평가 실행기가 실행마다 프롬프트와 평가셋 요청이 겹치는 구절을 찾아 요약에 적는다(
prompt-overlap.ts, 두 낱말 이상 · 공백 빼고 7자 이상). - 장소 이름 찾기: 별칭 뒤에 말이 붙으면("팔공산 숲길") 별칭으로 찾는다(
aliasPrefix). 별칭이 아닌 앞말("대구")로는 찾지 않는다.
3.6 시간표·검증·고치기 (코드)
이동 시간 추정 — MVP 는 경로 API 없이 직선거리로 계산한다.
| 이동 | 계산 | 구간 최대 |
|---|---|---|
| 걸어서 | 직선거리 × 1.3 ÷ 분당 70m | 15분 |
| 대중교통 | 10분(걷기·대기) + 직선거리 × 1.4 ÷ 분당 300m | 30분 |
| 자동차 | 5분(주차) + 직선거리 × 1.4 ÷ 분당 400m | 30분 |
- 화면에는 "약 N분"으로 보이고 추정값임을 표시한다. 실제 경로 API(대중교통 ODsay 등)는 2단계다(§12).
- 계수와 속도는 상수로 둔다. 큐레이션 동네에서 실제로 걸어 본 시간과 비교해 맞춘다.
- 시간은 5분 단위로 올린다(시간표도 5분 단위다).
- 대중교통·자동차라도 걷는 편이 빠른 가까운 구간(대중교통 약 700m · 자동차 약 300m 미만)은 걷기로 두고, 구간에 실제 이동 수단을 적는다.
머무는 시간 범위 — AI 가 정한 값을 이 범위로 맞춘다. 카페 40–90분 · 식당 60–100분 · 문화시설 50–120분 · 관광지·자연 30–120분 · 레저 60–150분 · 행사 60–150분. 쇼핑 30–120분 · 술집 60–120분 · 기타 30–120분은 구현하며 정했다. 이 분류는 1순위(큐레이션) 장소만 후보가 된다.
검증 — 평가셋의 checks 와 같은 항목이다. 하나라도 어기면 고친다.
| 항목 | 기준 |
|---|---|
| 목록 안 장소 | 모든 ref 가 후보 목록에 있고 중복이 없다 |
| 장소 수 | 시간 범위 1.5시간당 1곳 기준. 최소 2곳, 최대 6곳 |
| 영업 | 도착부터 떠날 때까지 영업 중이다(브레이크 포함). 식당은 마지막 주문 30분 전까지 도착한다 |
| 시간 범위 | 마지막 장소가 끝 시각 + 10분 안에 끝난다 |
| 이동 | 구간마다 위 표의 최대 시간 이하다 |
| 반려동물 | §3.3 규칙을 지킨다. 미확인 곳에는 경고를 붙인다 |
| 실내 비율 | 비 조건이면 50% 이상이다. 실외 장소를 실내 바꾸기 후보로 바꿔 보고, 실내 후보가 모자라면 위반으로 고치지 않고 notices 로 알린다 |
| 다양성 | 후보가 허락하면 서로 다른 분류가 2개 이상이다. 고치지 않고 약한 위반으로 남긴다('카페 투어'처럼 일부러 한 분류만 고를 수 있다) |
| 식사 시간대 | 시간 범위가 식사 시간대에 걸치면 식당은 그 시간대 안에 도착한다. 약한 위반이다 — 고치기 2a 로 맞춰 보고, 안 되면 남긴다 |
고치는 순서
- 순서 바꾸기 · 2. 머무는 시간 조정 — 둘을 함께 찾는다.
- 순서(6곳이면 720가지)마다 위반 없는 머무는 시간을 5분 단위로 찾는다(동적 계획법).
- 그중 AI 가 정한 순서와 가장 비슷한 것(앞뒤가 바뀐 쌍이 적은 것), 이동이 짧은 것, 머무는 시간이 덜 바뀐 것 순으로 고른다. 요청에 담긴 순서("레스토랑으로 마무리")를 지키려고 AI 순서를 이동보다 앞에 둔다.
- 문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦춘다. 첫 장소가 늦게 열면 첫 도착을 1시간까지 늦춘다.
- AI 순서가 식사 시간대를 지켰으면 고친 순서도 지킨다.
- 그날 아예 머물 수 없는 곳(쉬는 날 등)은 순서를 찾기 전에 바로 3 으로 넘긴다.
- 2a. 식사 시간대 맞추기(2026-11-11): 식당이 식사 시간대 밖에 도착하면 앞 장소에 더 머물거나 첫 도착을 늦춰 시간대 안에 넣는다. 같은 순서로 안 되면 순서도 바꿔 본다. 그래도 안 되면 약한 위반으로 남긴다. 실내 비율을 맞출 때(비 조건)도 맞춘 식사 시간대를 먼저 지킨다. AI 는 시각을 계산하지 않아, '저녁 식사'로 고른 식당이 머무는 시간 합 때문에 17시 전에 도착하는 일이 기준선 실패의 3분의 2였다(§9.1).
- 한 코스에서 따지는 순서는 5,000개까지다. 서버가 한 대라 계산이 길면 다른 요청이 기다린다. 2026-11-06 측정으로 6곳 코스 고치기는 대부분 20ms 안이고, 가장 나쁜 경우(AI 순서가 거꾸로이면서 시간이 빠듯함)가 약 50ms 였다.
- 교체·제외: 위반한 장소를 바꾸기 후보로 바꾸고, 안 되면 뺀다(최소 2곳까지).
- AI 재호출: 그래도 안 되면 위반 목록을 붙여 AI 를 한 번 더 부른다(제한 시간이 남았을 때만). 생성 작업(§14 4단계)이 한다.
- 결과: 2곳 미만이면
failed+AI_003이다. AI 가 남긴 이유는failure.notices로 함께 준다("대형견 동반이 확인된 실내 전시가 없어요"). 장소를 줄였으면notices에 이유를 남기고partial로 낸다. AI 가 알림을 남긴 경우(일부만 맞춤 · 거절한 부분)도partial이다.
순수 로직은 backend/src/modules/course-generation/planner/ 에 있다(2026-11-06). 거르기(filter.ts), 줄이기(shortlist.ts), 이동(travel.ts), 영업 확인(availability.ts), 시간표·고치기(schedule.ts·plan-course.ts)로 나눴다.
평가 자동 채점(§9, 6단계)은 같은 검증 함수(timelineViolations)를 쓴다.
3.7 결과
GET /api/course-generations/{id} 의 result 다. 저장하기 전의 초안이고 코스(course)가 아니다.
| 필드 | 만드는 쪽 · 내용 |
|---|---|
outcome |
course · partial |
title, summary, notices |
AI |
planNotices |
코드 — 장소를 뺐음(stop_removed, 이름·이유) · 실내 후보가 모자람(indoor_ratio_unmet). 문구는 앱이 언어별로 만든다 |
weather |
기상청 — 날짜 · 강수확률(시간 범위 최고) · 강수 형태 · 하늘 · 기온(최저·최고) · 발표 시각(UTC) |
window, mobility, totals |
코드 — 장소 수 · 전체 시간 · 이동 시간 합계 · 반려 동반 확인한 곳 수 |
stops[] |
kind(place · event), id(장소·행사 id), 이름, 분류, 사진(라이선스 포함), arrive · leave(ISO 8601 UTC) · minutes, reason(AI), facts(그날 영업 구간 hoursOnDate·원문·출처·확인일, 반려 조건과 출처, 실내외, 행사 기간), warnings(hours_unknown · pet_unknown · event_time_unknown · event_last_day — 그날이 행사 마지막 날), legToNext(분 · 거리 · 이동 수단 · 추정 여부) |
alternates[] |
바꿀 장소의 자리(forIndex, stops 의 0부터)와 stop 과 같은 필드, 앞 장소에서 오는 이동(legFromPrev). 같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다 — 앱이 AI 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 |
sources |
출처 문구(한국관광공사 TourAPI · 공공누리 유형, 기상청 예보 발표 시각, KOPIS 등) |
- 결과의 사실 값은 생성 시점의 스냅샷이다. 저장한 코스는 그 뒤로 최신 장소 값을 읽는다(코스 설계에서 정한다).
- 결과 화면 아래 "AI가 공공데이터를 바탕으로 만든 코스예요 …" 문구가 AI 생성 표시다.
저장·공유한 코스에도 'AI가 만든 코스' 표시를 남긴다(코스의
origin = ai, 코스 설계).
4. API
비로그인으로 쓴다. 모든 요청에 X-Device-Id 헤더를 보낸다. 앱이 처음 실행할 때 만든 UUID 이고 flutter_secure_storage 에 둔다(헤더 이름은 상수 DEVICE_ID_HEADER).
로그인이 생기면 Bearer 토큰이 있을 때 회원 횟수를 쓴다(OptionalJwtAuthGuard).
| 엔드포인트 | 내용 | 실패 |
|---|---|---|
GET /api/course-generations/quota |
{limit, used, remaining, resetsAt, tier} — 입력 화면 "오늘 2번 만들 수 있어요" |
— |
POST /api/course-conditions |
조건 해석(§2). 차감 없음. AI 를 못 쓰면 기본값만 채워 돌려준다 | VAL_001 |
POST /api/course-generations |
{request, conditions, previousGenerationId?} → 201 {id, status, remaining} |
VAL_001, AI_001, AI_002, AI_004 |
GET /api/course-generations/{id} |
{status, stage, progress: {found, kept}, result, failure: {code, notices}, remaining}. 만든 기기·사용자만 볼 수 있다(다르면 RES_001) |
RES_001 |
POST /api/course-generations/{id}/cancellation |
취소. AI 호출을 끊는다. 차감 없음 | RES_001 |
PUT /api/course-generations/{id}/rating |
{value: like · dislike} — "이 코스, 마음에 드세요?" |
RES_001, VAL_001 |
status는queued·running·succeeded·failed·canceled다. 생성이 실패해도 조회 자체는 200 이고, 이유는failure.code로 준다(문구는 에러 코드 사전).- 구현(2026-11-08,
backend/src/modules/course-generation/)에서 정한 것:X-Device-Id가 없거나 UUID 가 아니면 VAL_001. 응답 언어는Accept-Language로 고른다(en 이면 영어, 그 밖은 한국어).- 생성 요청의 조건이 틀리면 VAL_001 이다 — 지난 날짜 · 30일보다 먼 날짜 · 2시간 미만 · 12시간 초과 · 오늘 이미 끝난 시간 · 대구 밖 현재 위치 · 없는 기준 장소 · 다른 기기의 이전 생성.
- 같은 기기의 동시 요청은 기기 단위 잠금(
pg_advisory_xact_lock)으로 하나씩 검사한다(진행 중 → AI_004, 횟수·시도 상한 → AI_001, 하루 비용 → AI_002). - 조회 응답에 내가 남긴 평가(
rating)도 준다. 평가는 결과가 나온 생성만 받는다(아니면 VAL_001). 취소는 끝난 생성이면 그대로 돌려준다.
- 저장은 코스 API(
POST /api/courses, 코스 설계)에generationId와 바꾼 후보를 함께 보낸다. 로그인이 필요하다. - 요청 제한: 생성은 분당 5회, 조건 해석은 분당 20회다. 진행 조회는 1~2초 간격이라 기본 한도(분당 100회) 대신 분당 300회로 따로 둔다. 통신사 NAT 뒤에서는 여러 사용자가 같은 IP 를 쓰기 때문이다.
- Swagger 는 기존 래퍼(
ApiStandardResponse·ApiErrorResponse)로 문서화한다.
5. 횟수와 비용 보호
| 장치 | 규칙 |
|---|---|
| 하루 횟수 | 비로그인 2회(기기 기준), 로그인 5회(사용자 기준)(§13). 한국 시간 자정에 초기화한다 |
| 차감 | 결과가 나온 생성(succeeded)만 차감한다. 실패·취소·후보로 바꾸기·조건 해석은 차감하지 않는다 |
| 동시 생성 | 기기·사용자마다 1건. 진행 중이면 AI_004 |
| 시도 상한 | 실패·취소를 포함한 하루 시도는 횟수의 3배까지다. 취소를 반복해 비용만 쓰는 것을 막는다 |
| 하루 비용 상한 | 그날 쓴 금액(토큰 × 가격 상수의 합. 조건 해석 포함)이 AI_DAILY_BUDGET_USD 를 넘으면 새 생성을 AI_002 로 막고 error 로그를 남긴다 |
| 월 한도 | Anthropic Console 에 월 사용 한도를 건다. 마지막 안전장치다(사용자 설정) |
- 앱을 지웠다 깔면 기기 ID 가 새로 생겨 비로그인 횟수를 다시 받을 수 있다. MVP 는 이 정도는 감수하고 하루 비용 상한으로 막는다. 앱 무결성 검사(Play Integrity · App Attest)는 2단계다.
- 기기 ID 는 원문이 아니라 SHA-256 해시로 저장한다.
6. 저장
6.1 ai_course_generation
| 컬럼 | 설명 |
|---|---|
id |
uuid PK |
user_id |
로그인 사용자(인증 모듈이 생기면 FK) 또는 null |
device_hash |
기기 ID 해시 |
lang |
ko · en |
request_text |
사용자가 쓴 문장. 전화번호·이메일 모양은 가린 뒤 저장하고 AI 에 보낸다. 보관 기간이 지나면 지운다(§10) |
conditions |
jsonb |
status, stage |
§3.1 |
found_count, kept_count |
진행 표시 숫자 |
result |
jsonb(§3.7) |
failure_notices |
jsonb — 실패 이유(AI 가 남긴 알림). result 대신 따로 둔다(2026-11-08) |
failure_code |
AI_002 · AI_003 |
counted |
횟수를 차감했는지 |
previous_id |
다시 만들기라면 이전 생성 |
model, prompt_version |
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd |
비용 계산 · 하루 상한 |
latency_ms, llm_calls, repairs |
품질 지표(어느 고치기 단계를 거쳤는지 포함) |
rating, rated_at |
like · dislike |
created_at, finished_at |
- 인덱스:
(device_hash, created_at),(user_id, created_at),(status). - 하루 사용량과 비용은 이 테이블에서 센다. 집계 테이블은 따로 두지 않는다.
- 구현하며 더한 것(2026-11-08):
started_at(멈춘 작업 찾기),failure_notices.conditions의 현재 위치(point)는 소수점 셋째 자리(약 100m)까지만 남긴다 — 생성이 끝나면 정확한 위치가 필요 없다. - 조건 해석은 이 테이블에 남지 않는다. 조건 해석 비용을 하루 상한에 넣는 기록은 5단계(Anthropic 연동)에서 더한다.
6.2 지원 지역
- 동네 3곳(도심 · 수성못 · 앞산)은 앱의
Neighborhood와 같은 키·좌표를 백엔드 상수로 둔다. 별칭(동성로 · 김광석길 · 대명동 카페거리 …)도 함께 둔다. - 대구 영역은
shared/utils/geo.ts를 그대로 쓴다. 지역을 넓히면 이 상수와 앱 목록을 함께 늘린다.
7. 에러 코드 (신규)
error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록하고 npm run sync:error-codes 를 실행한다.
| 코드 | HTTP | 상황 | 메시지(안) |
|---|---|---|---|
| AI_001 | 429 | 하루 횟수를 다 씀 | "오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요." |
| AI_002 | 503 | AI 를 쓸 수 없음(장애 · 제한 시간 초과 · 하루 비용 상한 · 키 없음) | "지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요." |
| AI_003 | 422 | 조건에 맞는 장소가 부족해 코스를 만들지 못함 | "조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요." |
| AI_004 | 409 | 이미 만드는 중 | "이미 코스를 만들고 있어요. 잠시만 기다려 주세요." |
- 앱은
AI_001을 받으면 비로그인일 때 로그인 안내를 함께 띄운다(코드로 분기하고 문구는 사전에서 가져온다). AI_002·AI_003이 생성 작업 안에서 생기면 응답 상태 코드가 아니라failure.code로 전달된다.AI_003에는 이유(failure.notices)가 붙을 수 있다.- 앱이 분기하는 코드(AI_001)는 앱 쪽 계약 테스트 목록에 넣는다(체크리스트 3절).
8. 모델과 비용
가격 (2026-11-05 Anthropic 공식 가격표, 1M 토큰당 USD)
| 모델 | 입력 | 캐시 읽기 | 출력 | 공식 설명 |
|---|---|---|---|---|
Claude Haiku 5.5 (claude-haiku-5-5) |
0.10 | 0.01 | 0.50 | 가장 빠르다. 분류·추출 같은 대량·저지연 작업용. 입력이 10만 토큰을 넘으면 비싸진다(우리는 해당 없음) |
Claude Sonnet 5.5 (claude-sonnet-5-5) |
2 | 0.10 | 10 | 빠르다. 속도와 지능의 균형 |
Claude Opus 5.5 (claude-opus-5-5) |
4 | 0.20 | 20 | 보통. 긴 에이전트 작업용 |
비용 추정
가정: 생성 1회는 입력 7,000 토큰(시스템 2,000 + 후보·조건 5,000)과 출력 2,500 토큰(추론 포함)이다. 조건 해석 1회는 입력 1,500 토큰과 출력 200 토큰이다. 환율은 1달러 = 1,400원으로 잡았다.
| Haiku 5.5 | Sonnet 5.5 | |
|---|---|---|
| 코스 생성 1회 | 약 $0.002 (3원) | 약 $0.04 (50원대) |
| 조건 해석 1회 | 약 $0.0003 (1원 미만) | — (Haiku 고정) |
| 생성 하루 100회 × 30일 | 약 $6 | 약 $120 |
| 생성 하루 1,000회 × 30일 | 약 $60 | 약 $1,200 |
평가로 잰 비용 (2026-11-12, 프롬프트 v2 · effort low, 다시 부르기 포함 평균 — §9.2)
| Haiku 5.5 | Sonnet 5.5 | |
|---|---|---|
| 코스 생성 1회 | $0.0006 (1원) | $0.0115 (16원) |
| 조건 해석 1회 | $0.00015 (1원 미만) | — (Haiku 고정) |
| 생성 하루 100회 × 30일 | 약 $2 | 약 $35 (약 4만 8천 원) |
| 생성 하루 1,000회 × 30일 | 약 $18 | 약 $345 (약 48만 원) |
- 추정보다 싼 것은 출력이 평균 600–700 토큰으로 짧고(effort low), 시스템 프롬프트를 캐시에서 읽기 때문이다(캐시 읽기가 새 입력의 약 2배).
모델 고르는 방법
- AI 가 맡는 일은 좁다. 40곳 안에서 고르기만 하고, 순서·시간·사실 검증은 코드가 한다. 그래서 Haiku 5.5 부터 평가하고, 목표(§9)에 못 미치면 Sonnet 5.5 로 올린다. Opus 는 쓰지 않는다(Sonnet 의 2배 비용에 더 느리다).
- 비교 항목은 자동 채점 통과율, 반려 조건 위반, 지어낸 정보, 이유의 자연스러움(AI 채점), 지연(중앙값 · 95%), 1회 비용이다.
- 지연 목표는 생성 시작부터 결과까지 95%가 20초 안이다(디자인의 "보통 10~20초 걸려요").
- 모델 이름은 설정(
AI_COURSE_MODEL)으로 바꾼다. 모델을 바꾸면 평가를 다시 돌린다.
결정 (사용자, 2026-11-12 — 근거는 §9.2): 코스 초안은 Sonnet 5.5 · effort low, 조건 해석은 Haiku 5.5 · effort low.
- 같은 프롬프트 v2 · 같은 후보로 자동 채점이 Sonnet 88%(46/52), Haiku 69~75%였다. 프롬프트 주입 · 음주 운전 같은 경계 사례는 Sonnet 6/6, Haiku 2/6 이다.
- Haiku 는 쓰는 규칙(고양이 동반 단정 · 넣지 않은 식사 · 영업시간 표현)을 실행마다 다르게 지켰다(지어낸 정보 1~7건). 반려 동반 · 영업시간을 잘못 단정하면 사용자가 헛걸음을 하므로, 비용이 더 들어도 Sonnet 으로 올렸다.
- 생성 1회 약 16원이다. 로그인 사용자가 하루 5회(§5)를 다 써도 1인 하루 약 80원이고, 하루 비용 상한(§5)이 마지막으로 막는다.
- effort 는 low 로 둔다. Sonnet low 의 95% 지연이 11.6초로 목표(20초) 안이고, 남은 실패(§9.2)는 추론 깊이보다 데이터 · 평가셋 · 규칙 쪽이다.
- 조건 해석은 문장에서 항목을 뽑는 일이라 Haiku 로 충분하다(95% 지연 1.8초).
9. 평가 (docs/ai-eval/)
- 평가셋을 대구로 옮겼다(v2, 2026-11-05 사용자 검토로 확정). v1 62개 중 60개가 서울·부산·제주 등 다른 지역이라 장소 DB(대구)로는 돌릴 수 없었다.
v1 은 지역을 넓힐 때 다시 쓴다. v2 는
course-eval-v2.jsonl62개다.- 지역을 대구 동네(도심 · 수성못 · 앞산)와 대표 장소(팔공산 · 동촌유원지 · 이월드 · 서문시장 등)로 바꾸고, 날짜를 2026-11-05 기준으로 다시 잡았다.
- 사례마다 사용자가 확인한 조건(
conditions)과 문장에서 뽑아야 할 항목(inferred)을 적어 조건 해석도 함께 채점한다. expected_behavior는 흐름에 맞춰 나눈다.ask_clarification·decline_out_of_scope(8개)는 조건 해석 단계의issues로,refuse_part·partial_with_notice·no_course는 생성 단계의notices로 시험한다.- 만들면서 확인한 데이터 한계(반려 동반 실내 0곳 · 시장은 큐레이션 필요 · 11월 대구 축제 없음)는 평가셋 README 에 적었다.
- 검토에서 정한 것: 요청의 핵심을 못 맞추면 만들지 않고(
no_course+ 이유), 일부만 못 맞추면 맞출 수 있는 만큼 만들고 알린다(§3.5). '돈 안 쓰는 코스'는 식당·카페를 뺀다.
- 후보 스냅샷을 고정한다. 장소 DB 는 동기화로 계속 바뀐다. 첫 실행 때 사례별 후보 목록을 파일로 저장하고, 이후 모델 비교는 같은 후보로 한다.
requires에 적은 장소가 후보에 없으면(큐레이션 전) 그 사례는 '데이터 준비 안 됨'으로 따로 센다. - 채점: 자동 채점(코드. §3.6 검증과 같은 함수) → AI 채점자(이유가 데이터와 맞는지 · 자연스러운지, 1–5점) → 사람 검토(무작위 10개).
- 목표 (v1 README 그대로): 자동 채점 통과 95% 이상, 반려 조건 위반 0건, 지어낸 정보(영업시간 · 동반 여부 · 가격) 0건.
- 실행:
npm run ai:eval -- --model=<id> --judge(2026-11-11 만듦, 쓰는 법은 평가셋 README '실행'). 키가 필요해 사용자가!로 실행한다.- 후보 스냅샷은
docs/ai-eval/snapshots/에 커밋한다(2026-11-11 개발 DB, 후보 699곳). F05 · G02 는 서문시장이 AI 후보가 아니라 '준비 안 됨'이다. - 생성은 서버와 같은 함수(
course-planning.ts의planSetup·draftAndPlan)를 돈다 — 파이프라인 ·ai:check· 평가가 한 경로를 쓴다. - AI 채점자는 Sonnet 5.5(effort medium)가 기본이다. 코스 AI 에 준 후보 목록을 그대로 받아, 목록에 없는 사실을 단정한 글을 지어낸 정보로 센다.
- 실제 비용(2026-11-11): Haiku 5.5 실행 $0.42 · Sonnet 5.5 실행 $0.93(둘 다 채점 약 $0.38 포함). 2026-11-12 프롬프트 v2: Haiku $0.42 · Sonnet $0.97.
실행마다
--budget(기본 $3)에 닿으면 남은 사례를 돌리지 않는다. - 실행마다 결과가 흔들린다. 같은 v2 로 Haiku 를 두 번 돌리니 자동 채점 52개 중 13개가 통과와 실패를 오갔다. 3~4개 차이는 한 번 실행으로 가리지 않고, 빠듯하면 채점자 없이 몇 번 더 돌려 평균을 본다.
- 후보 스냅샷은
9.1 기준선 (2026-11-11, 프롬프트 course-v1 · conditions-v1, effort low, 채점자 Sonnet 5.5 medium)
| 항목 | Haiku 5.5 | Sonnet 5.5 | 목표 |
|---|---|---|---|
| 조건 해석 통과 | 38/62 (61%) | 38/62 (61%) | — |
| 자동 채점 통과 | 22/52 (42%) | 20/52 (38%) | 95% 이상 |
| 반려 조건 위반 | 0건 | 0건 | 0건 |
| 지어낸 정보(영업시간·동반·가격) | 8건 | 0건 | 0건 |
| AI 채점 평균(요청 · 글 · 근거) | 3.23 · 4.50 · 3.62 | 3.67 · 4.71 · 3.96 | — |
| 생성 지연(AI 호출~시간표) | 중앙값 3.0초 · 95% 10.9초 | 중앙값 4.2초 · 95% 11.6초 | 95% 20초 안 |
| 생성 1회 비용 | $0.0006 | $0.0106 | — |
- 조건 해석은 두 모델이 같은 38개를 맞혔다(조건 해석은 둘 다 Haiku 5.5 —
--model은 코스 초안만 바꾼다). - Haiku 의 지어낸 정보는 채점자 첫 판(13건)에서 식사 시간대를 영업시간으로 오판한 5건을 뺀 값이다. 채점자에 식사 시간대를 알려 고쳤고 Sonnet 실행부터 적용했다. 남은 8건: '돈 안 쓰는 코스'를 "무료"로 단정(X09, 3건), '반려: 가능'을 고양이 동반 확인으로 단정(P03, 3건), 영업시간을 다르게 씀(D01 · P07).
- 자동 채점 실패의 3분의 2(Haiku 21/30 · Sonnet 23/32)가 '식사 시간대에 식당 없음'이고, 원인은 모델이 아니라 시간표 코드다. AI 는 식당을 '저녁 식사'로 넣지만, 플래너가 AI 가 정한 머무는 시간대로 이어 붙여 식당에 16:30 · 17:00 에 도착한다. 식사 시간대는 약한 위반이라 고치기(강한 위반이 있을 때만 돈다)에 걸리지 않는다. 앞 장소에 더 머물거나 첫 도착을 늦추면 맞출 수 있는 경우다. AI 가 시간 범위보다 짧게 짜는 것(D05 '하루 종일'인데 15:55 에 끝남)도 같이 보인다.
- 그 밖에 모델 쪽: 만들어야 할 코스를 안 만듦(Haiku D08 · G06, Sonnet F01), 만들지 말아야 할 코스를 만듦(Haiku P07), 2곳 · 1분류로 짧게 끝냄(Haiku D12 · F08 · K04 · G05), '실내외 모름'을 "실내"로 씀, 조건 해석에서 "저녁까지"를 18:00 으로 · 영어 "full day" 를 못 읽음 · 프롬프트 주입 문장을 unclear 로 봄.
- 평가셋 기대값을 사용자가 정했다(다음 실행부터 반영): 가족 표현의 인원(F02 · K01 · K02 · K04 · K06 · K08)은 문장에서 뽑는 항목이다. "실내 위주로" · "실내에서" · "실내 놀거리" · "실내 전시만"(D13 · P07 · K02 · X01)은 indoorIfRain 으로 읽는 것이 맞다 — 날씨와 상관없는 '실내만' 조건은 MVP 뒤 과제다. 영어 'I'만으로 혼자라고 보지 않는다(G01 의 inferred 에서 party 를 뺐다).
- 모델 결정은 미뤘다(§9.2 에서 정함). Sonnet 은 18배 비싸고 자동 채점은 낫지 않다. 지어낸 정보는 Sonnet 이 낫지만 프롬프트로 막을 수 있는 종류다. 다음 순서: 플래너 식사 시간대 고치기 → 프롬프트 v2(장소 수 · 끼니를 코드가 계산해 알려 주기, 실내외 모름 · 가격 · 반려 표현 규칙, 조건 해석 시간 표현) → 평가셋 고침 → 두 모델 다시 재기.
9.2 프롬프트 v2 와 모델 결정 (2026-11-12, course-v2 · conditions-v2, effort low, 채점자 Sonnet 5.5 medium)
| 항목 | Haiku 5.5 | Sonnet 5.5 | 목표 |
|---|---|---|---|
| 조건 해석 통과(둘 다 Haiku) | 55/62 (89%) | 53/62 (85%) | — |
| 자동 채점 통과 | 36/52 (69%) | 46/52 (88%) | 95% 이상 |
| 경계 사례(주입 · 음주 운전 · 지출 없음 등) | 2/6 | 6/6 | — |
| 반려 조건 위반 | 0건 | 0건 | 0건 |
| 지어낸 정보(영업시간·동반·가격) | 7건 | 3건(채점자 오판 2건, 실제 1건) | 0건 |
| 그 밖의 근거 없는 말(채점자) | 36건 | 18건 | — |
| AI 채점 평균(요청 · 글 · 근거) | 3.35 · 4.52 · 3.69 | 3.69 · 4.75 · 4.13 | — |
| 생성 지연(AI 호출~시간표) | 중앙값 2.7초 · 95% 9.8초 | 중앙값 4.0초 · 95% 11.6초 | 95% 20초 안 |
| 생성 1회 비용 | $0.0006 | $0.0115 | — |
- 3차 다듬기(끼니는 90분 이상 걸칠 때만 · 조건 해석 시간 표현 · 평가셋과 겹치는 구절 정리) 전 v2 로는 Haiku 가 조건 해석 57/62 · 자동 채점 39/52(75%) · 지어낸 정보 1건이었다. 다듬기가 노린 사례(저녁 식당 D09 · D11 · K06 · X03, 조건 해석 F04 · W04)는 고쳐졌고 다른 8개가 실패로 바뀌었다. 두 실행 사이에 13개가 통과와 실패를 오갔으니 Haiku 의 차이(39 → 36)는 흔들림 안이고, Sonnet 과의 차이(10개 이상)는 흔들림보다 크다.
- 기준선(§9.1)의 가장 큰 실패였던 '식사 시간대에 식당 없음'은 플래너 고치기(§3.6 2a)와 끼니 줄(§3.5)로 거의 없어졌다(Sonnet 1건 — K04 가 저녁 식당을 13시에 넣음).
- Sonnet 의 남은 실패 6개는 대부분 모델 밖에 있다. 무엇을 고칠지는 다음 판에서 정한다(평가셋 README '다음 버전에서 볼 것').
- 반려 데이터 부족(P04 · P09): 반려견과 갈 수 있는 곳이 자연뿐이라 분류 2가지를 채울 수 없다.
- 이동 한도(P06 10분 · P09 8분): 평가셋이 앱의 도보 구간 한도(15분)보다 좁게 기대한다. 걷기를 줄여야 하는 동행은 조건에 항목이 없다.
- 장소 수(P08 6곳 · W01 4곳): 프롬프트의 '1.5시간에 1곳'이 평가셋 기대보다 많다.
- 위험 날씨 알림(W01 태풍): 프롬프트가 날씨 말을 막아 알림이 없다. 위험 기상 알림은 앱이 붙이는 쪽을 본다.
- 지어낸 정보: Sonnet P03 2건은 프롬프트가 알려 준 "반려 정보는 개 기준"을 옮겨 쓴 것인데, 채점자는 그 규칙을 몰라 셌다(채점자 다음 판에서 알린다). 실제는 P10 1건이다(반려 정보 없는 곳을 반려견과 걸을 수 있는 곳이라고 요약). Haiku 7건은 고양이 동반 단정(P03) · 넣지 않은 식사를 요약에 씀(P04) · 방문 시간을 영업시간처럼 씀(D04) 등으로, 모두 프롬프트가 막는 것인데 실행마다 다르게 지켰다.
- 고칠 수 없는 초안(D14 '오전에만'): 2시간 코스에서 첫 초안이 서로 먼 두 곳을 골랐다. 장소가 2곳이면 플래너가 뺄 수 없어 다시 부르기에 맡긴다.
Sonnet 은 다시 불러 고쳤고 Haiku 는 못 고쳤다(P01 도 같다). 실행기가 AI 호출마다 고른 곳과 위반 목록을 남겨(
course.attempts, review.md 'AI 초안') 되짚을 수 있게 했다. - 3차에 넣은 "들어줄 부분이 없으면 조건만으로 만든다"(코스 규칙 11)는 '비우기' 규칙과 겹쳐 읽힐 수 있다. Haiku P07(반려견과 실내에만)이 비워야 할 코스를 만들었고, 그 문장이 노린 X04 는 Haiku 가 여전히 비웠다. 다음 판에서 범위를 좁힌다.
- 결정(사용자): 코스 초안 Sonnet 5.5 · low, 조건 해석 Haiku 5.5 · low(§8). 평가셋 K08 의 기대 인원을 2명으로 고쳤다("아버지와 함께"는 두 사람). 같은 평가셋으로 프롬프트를 더 다듬으면 평가셋에 맞춰질 위험이 커져 v2 다듬기는 여기서 멈췄다.
10. 개인정보·법·안전
| 항목 | 정한 것 |
|---|---|
| AI 에 보내는 것 | 요청 문장(전화번호·이메일 모양은 가림), 조건(날짜·시간·인원·반려동물·이동 수단), 후보 정보(공공데이터 · 우리가 쓴 추천 이유), 날씨 |
| AI 에 보내지 않는 것 | 좌표(사용자 위치 포함), 기기 ID, 사용자 ID, 카카오 데이터 |
| 국외 이전 | 요청 문장에 개인정보가 섞일 수 있다. 처리방침에 'AI 처리 위탁 · 국외 이전(Anthropic, 미국)'을 적는다. Anthropic API 의 데이터 보존·학습 정책은 처리방침을 쓸 때 확인한다 |
| AI 생성 표시 | 입력 화면에서 AI 가 코스를 짠다고 알리고, 결과 화면과 저장한 코스에 AI 생성 표시를 한다. 세부 방식(문구·위치)은 출시 전에 AI 기본법 시행령·안내서로 확인한다 |
| 보관 | request_text 와 result 는 90일 보관한 뒤 문장은 지우고 지표만 남긴다(§13). 매일 정리 작업을 돌린다 |
| 로그 | 생성마다 id · 모델 · 토큰 · 비용 · 지연 · 결과만 남긴다. 요청 문장과 프롬프트 전체는 로그에 남기지 않는다(로깅 규칙) |
| 프롬프트 주입 | 요청 문장은 '사용자 요청' 칸에만 넣는다. 출력은 스키마로 묶여 있고 장소는 번호로만 고르므로, 문장으로 목록 밖 장소를 넣게 만들 수 없다. 평가셋 X04 로 시험한다 |
| 키 | ANTHROPIC_API_KEY 는 backend/.env.{env} 에만 둔다. 로그·응답에 남기지 않는다 |
11. 설정
| 키 | 위치 | 기본값 | 설명 |
|---|---|---|---|
ANTHROPIC_API_KEY |
backend/.env.{env} |
— | 운영에서는 필수(없으면 기동 실패). 개발에서 없으면 AI API 가 AI_002 를 준다 |
PUBLIC_DATA_SERVICE_KEY |
backend/.env.{env} |
— | 이미 있는 공공데이터포털 키(장소 동기화). 날씨도 쓴다. 없으면 날씨만 '예보 없음'이다 |
AI_PROVIDER |
backend/.env.{env} |
anthropic |
fake 면 키 없이 흐름만 돌리는 가짜 AI(개발용). 운영에서는 fake 면 기동 실패 |
AI_COURSE_MODEL |
backend/.env.{env} |
claude-sonnet-5-5(평가로 정함, §8) |
코스 생성 모델. 가격표(shared/utils/ai-pricing.ts)에 있는 모델만 받는다 |
AI_CONDITION_MODEL |
backend/.env.{env} |
claude-haiku-5-5 |
조건 해석 모델(같은 가격표) |
AI_COURSE_EFFORT · AI_CONDITION_EFFORT |
backend/.env.{env} |
low · low |
추론 깊이 low · medium · high. 평가(6단계)로 둘 다 low 로 정했다(§8) |
AI_DAILY_LIMIT_GUEST · AI_DAILY_LIMIT_MEMBER |
backend/.env.{env} |
2 · 5 | §5 |
AI_DAILY_BUDGET_USD |
backend/.env.{env} |
개발 3 | 하루 비용 상한(정수 달러) |
AI_TIMEOUT_MS |
backend/.env.{env} |
30000 | AI 호출 제한 시간 |
- 숫자는
parsePositiveInt로 읽는다(빈 문자열 방어). 새 필수 값은env.validation.ts와 테스트에 함께 추가한다. - AI 설정은 모두
ConfigService로 읽고, 없거나 비어 있으면 위 기본값을 쓴다(형식은env.validation.ts가 본다). compose 가 넘기지 않으므로backend/.env.{env}에 적는다. 예시 파일(backend/.env.{env}.example)에 빈 값으로 키를 모두 적어 두었다(2026-11-12). 값을 적으면 코드 기본값이 바뀌어도 그 값이 이기므로, 바꿀 때만 적는다. - SDK(
@anthropic-ai/sdk)는 컨테이너 안에서 설치하고, 잠금 파일을 npm 10 으로 확인한다(nestjs 스킬의 의존성 주의 항목). 5단계(2026-11-11)에 1.5.0 을 넣었다. 컨테이너 npm 11 이 지운 선택 peer 항목(chokidar 3개)은 이전 잠금 파일에서 되살렸다. - 모델 값은 가격표에 있어야 기동한다. 하루 비용 상한이 그 가격으로 계산되기 때문이다. 모델을 더하면 가격표와 테스트를 함께 고친다.
- 기본 모델은 코드(
course-generation.constants.ts)에 있다.backend/.env.{env}에AI_COURSE_MODEL을 적어 두면 그 값이 이긴다. - 실제 호출 확인은
npm run ai:check -- --text="..." [--course]다. 키를 쓰므로 사용자가!로 실행한다. 결과는 저장하지 않고 날씨는 부르지 않는다. - 평가는
npm run ai:eval이다(§9). 개발 compose 가docs/ai-eval을 백엔드 컨테이너의/eval에 붙인다.--model·--effort로 설정을 바꾸지 않고 비교한다.
12. MVP 에서 하지 않는 것
- 여러 날 코스, 숙소 추천(숙박은 AI 대상이 아니다 — 장소 DB §2.1)
- 공연 회차 시각에 맞춘 코스(KOPIS 공연은 후보에서 뺀다)
- 실제 경로 API(대중교통·자동차 길찾기). 직선거리 추정으로 시작한다
- 대화로 고치기("카페만 바꿔 줘"). 지금은 후보로 바꾸기 · 조건 수정 · 다시 만들기로 대신한다
- 사용 기록 기반 개인화, 카카오 검색 장소를 AI 후보로 쓰기(금지)
- 스트리밍(SSE) 진행 표시, 별도 큐 서버(Redis). 서버를 늘릴 때 다시 본다
13. 정한 것 (2026-11-05)
v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
| # | 항목 | 결정 | 이유 |
|---|---|---|---|
| 1 | 하루 횟수 | 비로그인 2회 · 로그인 5회, 결과가 나온 생성만 차감 | 기존 합의(비로그인 1~2회, 로그인하면 확대) 안에서 가장 단순하다. Haiku 라면 1회 3원이라 여유가 있다(코스 모델을 Sonnet 으로 정한 뒤 실측 1회 약 16원, 로그인 하루 최대 약 80원 — §8) |
| 2 | 비용 상한 | 개발은 하루 $3, Console 월 한도는 $50 부터. 운영 값은 출시 전에 정한다 | 키 유출이나 버그로 비용이 튀는 것을 막는다 |
| 3 | 영업시간 미확인 장소 | 자연·관광지·행사만 후보로 두고 식당·카페·문화시설은 뺀다 | 닫힌 식당으로 보내는 것이 가장 큰 실패다. 대구 관광공사 장소의 시간표 해석률은 87%다 |
| 4 | 반려동물 엄격도 | 실내는 동반이 확인된 곳만, 야외는 미확인도 허용(경고 표시), 불가·크기 불일치는 뺀다 | 평가셋 pet_policy: strict(확인된 곳만 '동반 가능' 표시)와 맞는다. 대구 반려 데이터가 적어 야외까지 막으면 코스가 거의 나오지 않는다 |
| 5 | 이동 시간 | MVP 는 직선거리 추정('약 N분'), 경로 API 는 2단계 | 키·비용·약관 검토 없이 시작할 수 있다. 걷기는 오차가 작다 |
| 6 | 공연 | MVP AI 후보에서 뺀다 | 회차 시각을 해석하기 전에는 그 시간에 공연한다고 보장할 수 없다 |
| 7 | 요청 문장 보관 | 90일 | 품질 검토에 충분하고, 필요 이상 오래 갖고 있지 않는다 |
| 8 | 기본 모델 | 평가로 정하되 Haiku 5.5 부터 시험 → 2026-11-12 코스 초안 Sonnet 5.5 · 조건 해석 Haiku 5.5(둘 다 effort low)로 정함 | §8 · §9.2 |
함께 필요한 준비(사용자): Anthropic Console 에서 API 키를 만들고 월 한도를 건 뒤, backend/.env.development 에 ANTHROPIC_API_KEY 를 넣는다.
14. 구현 순서
- 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
- 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝)
- 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가
!로). 2026-11-08 반영(§3.3) - 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다). 2026-11-08 반영 — 5단계 전까지 서버는 가짜 AI(
FakeCourseAiClient)로 코스를 만든다 - Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석. 2026-11-11 반영(§2 끝 · §3.5 끝 · §11) — 서버 기본 AI 가 Anthropic 이 됐다(
AI_PROVIDER=fake로 되돌린다) - 평가 실행(사용자가
!) → 모델 · effort 결정 → 이 문서 갱신. 실행기와 기준선은 2026-11-11 반영(§9 · §9.1). 식사 시간대 고치기(§3.6 2a) · 프롬프트 v2(§3.5) 뒤 다시 재서 2026-11-12 정함(§8 · §9.2) — 코스 초안 Sonnet 5.5, 조건 해석 Haiku 5.5, 둘 다 effort low - Flutter 화면 8–10 연결
- 출시 준비 — 처리방침(국외 이전) · AI 표시 문구 · Console 한도 · 보관 정리 작업