# AI 코스 생성 설계 (v0.2, 2026-11-05) 사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다. 코드는 §14 순서로 만들고 있다(2026-11-08 기준 4단계 API 골격까지, AI 는 가짜). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다. §13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다. > v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다. > 조건 해석 `issues` 3개 추가(§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: 고르기·순서·머무는 시간·이유 → 코드: 이동 시간·시간표·검증·고치기 → "날씨와 동선에 맞춰 시간표를 짜는 중" ④ 마무리 사실 값 붙이기 · 바꾸기 후보 검증 · 출처 → "고른 이유를 정리할게요" ``` 원칙은 세 가지다. 1. **사실은 DB 가, 말은 AI 가.** 영업시간·반려동물 조건·행사 기간·출처처럼 화면에 나오는 사실 값은 모두 DB 에서 붙인다. AI 는 장소를 고르고 한 줄 이유와 요약만 쓴다. 2. **AI 는 후보 목록의 번호로만 고른다.** 목록 밖 장소나 지어낸 장소는 검증에서 버린다(구조화 출력 + 코드 검증). 3. **확인할 수 없는 것을 확인된 것처럼 보이지 않는다.** 미확인 값은 '미확인'으로 표시한다. 위반을 고칠 수 없으면 장소 수를 줄이고 이유를 알린다. | 일 | 맡는 쪽 | | --- | --- | | 문장에서 지역·날짜·시간 같은 조건 뽑기 | 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_midnight` | 2시간보다 짧다("점심시간 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초라 조회 10~20번이면 끝난다. - 작업 제한 시간은 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` 로 적는다. ### 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, E01~E08)를 준다. 번호와 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/sdk` 1.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단계 평가로 본다. ### 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개 이상이다. 고치지 않고 약한 위반으로 남긴다('카페 투어'처럼 일부러 한 분류만 고를 수 있다) | | 식사 시간대 | 시간 범위가 식사 시간대에 걸치면 식당은 그 시간대 안에 도착한다. 약한 위반이다 — 이것만 어긋나면 고치지 않고, 고칠 때 나빠지지 않게만 본다 | **고치는 순서** 1. **순서 바꾸기 · 2. 머무는 시간 조정** — 둘을 함께 찾는다. - 순서(6곳이면 720가지)마다 위반 없는 머무는 시간을 5분 단위로 찾는다(동적 계획법). - 그중 AI 가 정한 순서와 가장 비슷한 것(앞뒤가 바뀐 쌍이 적은 것), 이동이 짧은 것, 머무는 시간이 덜 바뀐 것 순으로 고른다. 요청에 담긴 순서("레스토랑으로 마무리")를 지키려고 AI 순서를 이동보다 앞에 둔다. - 문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦춘다. 첫 장소가 늦게 열면 첫 도착을 1시간까지 늦춘다. - AI 순서가 식사 시간대를 지켰으면 고친 순서도 지킨다. - 그날 아예 머물 수 없는 곳(쉬는 날 등)은 순서를 찾기 전에 바로 3 으로 넘긴다. - 한 코스에서 따지는 순서는 5,000개까지다. 서버가 한 대라 계산이 길면 다른 요청이 기다린다. 2026-11-06 측정으로 6곳 코스 고치기는 대부분 20ms 안이고, 가장 나쁜 경우(AI 순서가 거꾸로이면서 시간이 빠듯함)가 약 50ms 였다. 3. **교체·제외**: 위반한 장소를 바꾸기 후보로 바꾸고, 안 되면 뺀다(최소 2곳까지). 4. **AI 재호출**: 그래도 안 되면 위반 목록을 붙여 AI 를 한 번 더 부른다(제한 시간이 남았을 때만). 생성 작업(§14 4단계)이 한다. 5. **결과**: 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 | **모델 고르는 방법** - AI 가 맡는 일은 좁다. 40곳 안에서 고르기만 하고, 순서·시간·사실 검증은 코드가 한다. 그래서 **Haiku 5.5 부터 평가하고, 목표(§9)에 못 미치면 Sonnet 5.5** 로 올린다. Opus 는 쓰지 않는다(Sonnet 의 2배 비용에 더 느리다). - 비교 항목은 자동 채점 통과율, 반려 조건 위반, 지어낸 정보, 이유의 자연스러움(AI 채점), 지연(중앙값 · 95%), 1회 비용이다. - 지연 목표는 생성 시작부터 결과까지 95%가 20초 안이다(디자인의 "보통 10~20초 걸려요"). - 모델 이름은 설정(`AI_COURSE_MODEL`)으로 바꾼다. 모델을 바꾸면 평가를 다시 돌린다. ## 9. 평가 (`docs/ai-eval/`) - **평가셋을 대구로 옮겼다(v2, 2026-11-05 사용자 검토로 확정).** v1 62개 중 60개가 서울·부산·제주 등 다른 지역이라 장소 DB(대구)로는 돌릴 수 없었다. v1 은 지역을 넓힐 때 다시 쓴다. v2 는 [`course-eval-v2.jsonl`](../ai-eval/course-eval-v2.jsonl) 62개다. - 지역을 대구 동네(도심 · 수성못 · 앞산)와 대표 장소(팔공산 · 동촌유원지 · 이월드 · 서문시장 등)로 바꾸고, 날짜를 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= --judge`(2026-11-11 만듦, 쓰는 법은 [평가셋 README](../ai-eval/README.md) '실행'). 키가 필요해 사용자가 `!` 로 실행한다. - 후보 스냅샷은 `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 포함). 실행마다 `--budget`(기본 $3)에 닿으면 남은 사례를 돌리지 않는다. ### 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)는 indoorIfRain 으로 읽는 것이 맞다 — 날씨와 상관없는 '실내만' 조건은 MVP 뒤 과제다. - **모델 결정은 미룬다.** Sonnet 은 18배 비싸고 자동 채점은 낫지 않다. 지어낸 정보는 Sonnet 이 낫지만 프롬프트로 막을 수 있는 종류다. 다음 순서: 플래너 식사 시간대 고치기 → 프롬프트 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` | 루트 `.env` → compose | `claude-haiku-5-5`(평가 뒤 결정) | 코스 생성 모델. 가격표(`shared/utils/ai-pricing.ts`)에 있는 모델만 받는다 | | `AI_CONDITION_MODEL` | 루트 `.env` → compose | `claude-haiku-5-5` | 조건 해석 모델(같은 가격표) | | `AI_COURSE_EFFORT` · `AI_CONDITION_EFFORT` | `backend/.env.{env}` | `low` · `low` | 추론 깊이 low · medium · high. 평가(6단계)로 정한다 | | `AI_DAILY_LIMIT_GUEST` · `AI_DAILY_LIMIT_MEMBER` | 루트 `.env` → compose | 2 · 5 | §5 | | `AI_DAILY_BUDGET_USD` | 루트 `.env` → compose | 개발 3 | 하루 비용 상한(정수 달러) | | `AI_TIMEOUT_MS` | 루트 `.env` → compose | 30000 | AI 호출 제한 시간 | - 숫자는 `parsePositiveInt` 로 읽는다(빈 문자열 방어). 새 필수 값은 `env.validation.ts` 와 테스트에 함께 추가한다. - 4단계(2026-11-08)는 `AI_DAILY_LIMIT_*` · `AI_DAILY_BUDGET_USD` · `AI_TIMEOUT_MS` 를 `ConfigService` 로 읽고 없으면 위 기본값을 쓴다(형식은 `env.validation.ts` 가 본다). compose 로 넘기는 설정은 값을 바꿀 일이 생길 때 더한다 — 그 전에는 `backend/.env.{env}` 에 적으면 된다. - SDK(`@anthropic-ai/sdk`)는 컨테이너 안에서 설치하고, 잠금 파일을 npm 10 으로 확인한다(nestjs 스킬의 의존성 주의 항목). 5단계(2026-11-11)에 1.5.0 을 넣었다. 컨테이너 npm 11 이 지운 선택 peer 항목(chokidar 3개)은 이전 잠금 파일에서 되살렸다. - 모델 값은 가격표에 있어야 기동한다. 하루 비용 상한이 그 가격으로 계산되기 때문이다. 모델을 더하면 가격표와 테스트를 함께 고친다. - 실제 호출 확인은 `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원이라 여유가 있다 | | 2 | 비용 상한 | 개발은 하루 $3, Console 월 한도는 $50 부터. 운영 값은 출시 전에 정한다 | 키 유출이나 버그로 비용이 튀는 것을 막는다 | | 3 | 영업시간 미확인 장소 | 자연·관광지·행사만 후보로 두고 식당·카페·문화시설은 뺀다 | 닫힌 식당으로 보내는 것이 가장 큰 실패다. 대구 관광공사 장소의 시간표 해석률은 87%다 | | 4 | 반려동물 엄격도 | 실내는 동반이 확인된 곳만, 야외는 미확인도 허용(경고 표시), 불가·크기 불일치는 뺀다 | 평가셋 `pet_policy: strict`(확인된 곳만 '동반 가능' 표시)와 맞는다. 대구 반려 데이터가 적어 야외까지 막으면 코스가 거의 나오지 않는다 | | 5 | 이동 시간 | MVP 는 직선거리 추정('약 N분'), 경로 API 는 2단계 | 키·비용·약관 검토 없이 시작할 수 있다. 걷기는 오차가 작다 | | 6 | 공연 | MVP AI 후보에서 뺀다 | 회차 시각을 해석하기 전에는 그 시간에 공연한다고 보장할 수 없다 | | 7 | 요청 문장 보관 | 90일 | 품질 검토에 충분하고, 필요 이상 오래 갖고 있지 않는다 | | 8 | 기본 모델 | 평가로 정하되 Haiku 5.5 부터 시험 | §8 | 함께 필요한 준비(사용자): Anthropic Console 에서 API 키를 만들고 월 한도를 건 뒤, `backend/.env.development` 에 `ANTHROPIC_API_KEY` 를 넣는다. ## 14. 구현 순서 1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정 2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝) 3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로). 2026-11-08 반영(§3.3) 4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다). 2026-11-08 반영 — 5단계 전까지 서버는 가짜 AI(`FakeCourseAiClient`)로 코스를 만든다 5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석. 2026-11-11 반영(§2 끝 · §3.5 끝 · §11) — 서버 기본 AI 가 Anthropic 이 됐다(`AI_PROVIDER=fake` 로 되돌린다) 6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신. 실행기와 기준선은 2026-11-11 반영(§9 · §9.1) — 식사 시간대 고치기 · 프롬프트 v2 뒤 다시 재고 모델 · effort 를 정한다 7. Flutter 화면 8–10 연결 8. 출시 준비 — 처리방침(국외 이전) · AI 표시 문구 · Console 한도 · 보관 정리 작업