diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md new file mode 100644 index 0000000..e8dd5ba --- /dev/null +++ b/docs/design/ai-course.md @@ -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=`(예정). 키가 필요해 사용자가 `!` 로 실행한다. 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 한도 · 보관 정리 작업