docs: AI 코스 생성 설계안과 대구 평가셋 #9

Merged
ttipo merged 3 commits from docs/ai-course-design into main 2026-11-06 00:13:54 +09:00
Showing only changes of commit 29e9611bd9 - Show all commits
+440
View File
@@ -0,0 +1,440 @@
# AI 코스 생성 설계 (v0.2, 2026-11-05)
사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: 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` | 대구 밖 지역("성수동", "경주 황리단길"). 지금은 대구만 된다 |
| `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 에는 문장, 오늘 날짜·요일, 지원 동네 목록만 보낸다. 좌표·기기 정보는 보내지 않는다.
## 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` 로 바꾼다.
### 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` 으로 10분 간격 확인(브레이크 포함). 휴무 요일이면 뺀다 |
| 영업시간 미확인 | `status` 가 unknown · varies 이거나 `seasonal` 이면 미확인이다. **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 하절기·동절기처럼 계절별 시간표는 지금 미확인으로 읽힌다(AI 후보 23곳, 문화시설 10곳 — 수창청춘맨숀·대구예술발전소·향촌문화관 등). 달 범위가 적혀 있으면 생성 날짜로 고르도록 2단계에서 고친다 |
| 공휴일 | 그날이 공휴일·명절이고 `holidayClosures` 가 있으면 뺀다. 공휴일 표는 코드 상수로 두고 해마다 갱신한다 |
| 반려동물 (조건에 있을 때) | `not_allowed` 와 크기 불일치(`petSizes` 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 `allowed` · `partial` 만 남긴다. 야외 분류와 축제·행사는 `unknown` 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 |
| 행사 시간 | `playtime_text` 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 |
| 다시 만들기 | 이전 결과의 장소는 남은 후보가 15곳 이상이면 빼고, 아니면 목록 뒤로 보낸다 |
| 비 | 거르지 않는다. 강수확률이 40% 이상이고 `indoorIfRain` 이면 AI 에 '실내 위주'를 알리고, 검증에서 실내 비율 50% 이상을 본다. 실내 후보가 모자라 채울 수 없으면 코스는 내고 `notices` 에 알린다 |
- **실내·실외**: 큐레이션 `setting` 을 쓰고, 없으면 분류로 추정한다(문화시설·식당·카페·쇼핑과 전시는 실내, 자연은 실외, 관광지·레저·축제·행사는 미확인).
- **날씨**: 기상청 단기예보(동네예보 격자). 오늘부터 3일 뒤까지 나오고, 17시 이후 발표분은 4일 뒤까지 나온다.
그 뒤 날짜는 `weather: null` 이다. 입력 화면에 "예보 없음"을 보여 주고 비 조건은 쓰지 않는다.
격자·발표 시각별로 1시간 캐시한다. 2026-10-29 점검 스크립트로 이미 불러 본 API 다(`docs/research/data-check/`).
### 3.4 후보 줄이기 (최대 40곳)
AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다.
- 1순위(확인한 곳)를 먼저, 그다음 거리순으로 둔다.
- 분류마다 최대 10곳이다. 한 분류(예: 관광지)가 목록을 다 채우지 않게 한다.
- 행사는 가까운 순서로 최대 8개다.
- 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`)을 생성 기록에 남긴다.
### 3.6 시간표·검증·고치기 (코드)
**이동 시간 추정** — MVP 는 경로 API 없이 직선거리로 계산한다.
| 이동 | 계산 | 구간 최대 |
| --- | --- | --- |
| 걸어서 | 직선거리 × 1.3 ÷ 분당 70m | 15분 |
| 대중교통 | 10분(걷기·대기) + 직선거리 × 1.4 ÷ 분당 300m | 30분 |
| 자동차 | 5분(주차) + 직선거리 × 1.4 ÷ 분당 400m | 30분 |
- 화면에는 "약 N분"으로 보이고 추정값임을 표시한다. 실제 경로 API(대중교통 ODsay 등)는 2단계다(§12).
- 계수와 속도는 상수로 둔다. 큐레이션 동네에서 실제로 걸어 본 시간과 비교해 맞춘다.
**머무는 시간 범위** — AI 가 정한 값을 이 범위로 맞춘다.
카페 40–90분 · 식당 60–100분 · 문화시설 50–120분 · 관광지·자연 30–120분 · 레저 60–150분 · 행사 60–150분.
**검증** — 평가셋의 `checks` 와 같은 항목이다. 하나라도 어기면 고친다.
| 항목 | 기준 |
| --- | --- |
| 목록 안 장소 | 모든 `ref` 가 후보 목록에 있고 중복이 없다 |
| 장소 수 | 시간 범위 1.5시간당 1곳 기준. 최소 2곳, 최대 6곳 |
| 영업 | 도착부터 떠날 때까지 영업 중이다(브레이크 포함). 식당은 마지막 주문 30분 전까지 도착한다 |
| 시간 범위 | 마지막 장소가 끝 시각 + 10분 안에 끝난다 |
| 이동 | 구간마다 위 표의 최대 시간 이하다 |
| 반려동물 | §3.3 규칙을 지킨다. 미확인 곳에는 경고를 붙인다 |
| 실내 비율 | 비 조건이면 50% 이상이다. 실내 후보가 모자라면 위반으로 고치지 않고 `notices` 로 알린다 |
| 다양성 | 후보가 허락하면 서로 다른 분류가 2개 이상이다 |
**고치는 순서**
1. **순서 바꾸기**: 모든 순서(6곳이면 720가지)를 따져, 위반이 없고 이동이 짧으며 AI 가 정한 순서와 가장 비슷한 것을 고른다. 식사 시간대 규칙을 지킨다.
2. **머무는 시간 조정**: 범위 안에서 늘리거나 줄여 시간 범위에 맞춘다.
3. **교체·제외**: 위반한 장소를 바꾸기 후보로 바꾸고, 안 되면 뺀다(최소 2곳까지).
4. **AI 재호출**: 그래도 안 되면 위반 목록을 붙여 AI 를 한 번 더 부른다(제한 시간이 남았을 때만).
5. **결과**: 2곳 미만이면 `failed` + `AI_003` 이다. AI 가 남긴 이유는 `failure.notices` 로 함께 준다("대형견 동반이 확인된 실내 전시가 없어요").
장소를 줄였으면 `notices` 에 이유를 남기고 `partial` 로 낸다.
### 3.7 결과
`GET /api/course-generations/{id}` 의 `result` 다. 저장하기 전의 **초안**이고 코스(`course`)가 아니다.
| 필드 | 만드는 쪽 · 내용 |
| --- | --- |
| `outcome` | `course` · `partial` |
| `title`, `summary`, `notices` | AI |
| `weather` | 기상청 — 날짜 · 강수확률 · 기온 · 발표 시각 |
| `window`, `mobility`, `totals` | 코드 — 장소 수 · 전체 시간 · 이동 시간 합계 · 반려 동반 확인한 곳 수 |
| `stops[]` | `kind`(place · event), `placeId` / `eventId`, 이름, 분류, 사진(라이선스 포함), `arrive` · `leave` · `minutes`, `reason`(AI), `facts`(그날 영업시간과 출처·확인일, 반려 조건과 확인일, 실내외, 행사 기간), `warnings`(hours_unknown · pet_unknown · event_time_unknown · event_ends_early), `legToNext`(분 · 이동 수단 · 추정 여부) |
| `alternates[]` | 바꿀 장소 번호와 stop 과 같은 필드. **같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다** — 앱이 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` 로 준다(문구는 에러 코드 사전).
- 저장은 코스 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). 실패해도 AI 가 남긴 `notices` 는 여기에 둔다 |
| `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)`.
- 하루 사용량과 비용은 이 테이블에서 센다. 집계 테이블은 따로 두지 않는다.
### 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 에 적었다.
- 사용자가 사는 지역이므로 실제로 할 법한 요청인지 사용자 검토를 받는다.
- **후보 스냅샷을 고정한다.** 장소 DB 는 동기화로 계속 바뀐다. 첫 실행 때 사례별 후보 목록을 파일로 저장하고, 이후 모델 비교는 같은 후보로 한다.
`requires` 에 적은 장소가 후보에 없으면(큐레이션 전) 그 사례는 '데이터 준비 안 됨'으로 따로 센다.
- **채점**: 자동 채점(코드. §3.6 검증과 같은 함수) → AI 채점자(이유가 데이터와 맞는지 · 자연스러운지, 1–5점) → 사람 검토(무작위 10개).
- **목표** (v1 README 그대로): 자동 채점 통과 95% 이상, 반려 조건 위반 0건, 지어낸 정보(영업시간 · 동반 여부 · 가격) 0건.
- **실행**: `npm run ai:eval -- --model=<id>`(예정). 키가 필요해 사용자가 `!` 로 실행한다. 62개 × 두 모델에 채점자까지 수 달러 수준이다.
## 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` 를 준다 |
| `AI_COURSE_MODEL` | 루트 `.env` → compose | 평가 뒤 결정(Haiku 5.5 부터 시험) | 코스 생성 모델 |
| `AI_CONDITION_MODEL` | 루트 `.env` → compose | `claude-haiku-5-5` | 조건 해석 모델 |
| `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` 와 테스트에 함께 추가한다.
- SDK(`@anthropic-ai/sdk`)는 컨테이너 안에서 설치하고, 잠금 파일을 npm 10 으로 확인한다(nestjs 스킬의 의존성 주의 항목).
## 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) (키 불필요)
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로)
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신
7. Flutter 화면 8–10 연결
8. 출시 준비 — 처리방침(국외 이전) · AI 표시 문구 · Console 한도 · 보관 정리 작업