From d2cd5cd96e10559c85d950c28465bbfbcf1e3480 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 11:49:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20AI=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=ED=91=9C=EB=A5=BC=20=EC=A7=9C=EA=B3=A0=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=C2=B7=EA=B3=A0=EC=B9=98=EB=8A=94=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 코스 생성(설계 §3.3–§3.7)에서 코드가 맡는 부분이다. AI 는 후보 번호로 장소와 머무는 시간만 고르고, 영업·이동·반려동물 조건 검증과 순서 고치기는 코드가 해야 닫힌 곳으로 보내거나 지어낸 사실이 화면에 나가지 않는다. API·AI 연동(4·5단계) 전에 키 없이 시험할 수 있게 DB·AI 와 떨어진 순수 함수로 먼저 만든다. - 거르기: 반경·행사 기간·공휴일 휴무·반려동물·그날 영업(최소 머무는 시간만큼) - 줄이기: 장소와 행사를 합쳐 40곳(행사 8개까지, 분류마다 10곳), 다시 만들기면 이전 장소를 빼거나 뒤로 - 시간표·검증: 직선거리 이동 추정, 영업·마지막 주문·끝 시각·구간 이동·반려동물 - 고치기: 순서와 머무는 시간을 함께 찾고(AI 순서와 가까운 것 우선), 안 되면 바꾸기 후보로 바꾸거나 뺀다 - 비 조건 실내 비율, 바꾸기 후보를 같은 시간대로 검증, 장소별 경고 6곳 코스 고치기는 대부분 20ms 안이고 가장 나쁜 경우 약 50ms 였다(서버가 한 대라 따지는 순서를 5,000개로 제한). 구현하며 정한 기준은 설계 §3.4·§3.6·§3.7 에 적었다. Co-Authored-By: Claude Opus 5.5 --- .../planner/availability.spec.ts | 87 ++++ .../course-generation/planner/availability.ts | 135 +++++ .../planner/candidate.spec.ts | 129 +++++ .../course-generation/planner/candidate.ts | 112 ++++ .../course-generation/planner/filter.spec.ts | 112 ++++ .../course-generation/planner/filter.ts | 89 ++++ .../planner/plan-course.spec.ts | 382 ++++++++++++++ .../course-generation/planner/plan-course.ts | 489 +++++++++++++++++ .../planner/planner.constants.ts | 130 +++++ .../planner/planner.fixtures.ts | 106 ++++ .../planner/planner.types.ts | 204 ++++++++ .../planner/schedule.spec.ts | 218 ++++++++ .../course-generation/planner/schedule.ts | 493 ++++++++++++++++++ .../planner/shortlist.spec.ts | 94 ++++ .../course-generation/planner/shortlist.ts | 74 +++ .../course-generation/planner/travel.spec.ts | 52 ++ .../course-generation/planner/travel.ts | 50 ++ backend/src/shared/utils/geo.spec.ts | 27 +- backend/src/shared/utils/geo.ts | 22 + backend/tsconfig.build.json | 2 +- docs/design/ai-course.md | 38 +- 21 files changed, 3032 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/course-generation/planner/availability.spec.ts create mode 100644 backend/src/modules/course-generation/planner/availability.ts create mode 100644 backend/src/modules/course-generation/planner/candidate.spec.ts create mode 100644 backend/src/modules/course-generation/planner/candidate.ts create mode 100644 backend/src/modules/course-generation/planner/filter.spec.ts create mode 100644 backend/src/modules/course-generation/planner/filter.ts create mode 100644 backend/src/modules/course-generation/planner/plan-course.spec.ts create mode 100644 backend/src/modules/course-generation/planner/plan-course.ts create mode 100644 backend/src/modules/course-generation/planner/planner.constants.ts create mode 100644 backend/src/modules/course-generation/planner/planner.fixtures.ts create mode 100644 backend/src/modules/course-generation/planner/planner.types.ts create mode 100644 backend/src/modules/course-generation/planner/schedule.spec.ts create mode 100644 backend/src/modules/course-generation/planner/schedule.ts create mode 100644 backend/src/modules/course-generation/planner/shortlist.spec.ts create mode 100644 backend/src/modules/course-generation/planner/shortlist.ts create mode 100644 backend/src/modules/course-generation/planner/travel.spec.ts create mode 100644 backend/src/modules/course-generation/planner/travel.ts diff --git a/backend/src/modules/course-generation/planner/availability.spec.ts b/backend/src/modules/course-generation/planner/availability.spec.ts new file mode 100644 index 0000000..1609124 --- /dev/null +++ b/backend/src/modules/course-generation/planner/availability.spec.ts @@ -0,0 +1,87 @@ +import { + buildGrid, + canStay, + hasUnknownHours, + openStateAt, + scheduleEnd, + stayAvailability, +} from './availability'; +import { at, MON, place, SAT } from './planner.fixtures'; + +describe('openStateAt', () => { + it('영업시간이 없으면 미확인, 쉬는 요일이면 닫힘이다', () => { + expect(openStateAt(place({ hoursText: null }), SAT, at(15))).toBe( + 'unknown', + ); + const closedSaturday = place({ restText: '매주 토요일' }); + expect(openStateAt(closedSaturday, SAT, at(15))).toBe('closed'); + expect(openStateAt(closedSaturday, MON, at(15))).toBe('open'); + }); +}); + +describe('scheduleEnd', () => { + it('끝 시각 + 10분까지 끝나면 되고, 자정을 넘기지 않는다', () => { + expect(scheduleEnd({ end: at(20) })).toBe(at(20, 10)); + expect(scheduleEnd({ end: at(23, 55) })).toBe(1440); + }); +}); + +describe('stayAvailability', () => { + it('시간 범위 안에서 최소 머무는 시간만큼 이어서 열려 있어야 한다', () => { + const opensAtSeven = place({ hoursText: '19:00~23:00' }); + // 14:00~19:40 범위에서는 19:00~19:40 의 40분만 열려 있다 + expect(stayAvailability(opensAtSeven, SAT, at(14), at(19, 40), 40)).toBe( + 'open', + ); + expect(stayAvailability(opensAtSeven, SAT, at(14), at(19, 40), 50)).toBe( + 'closed', + ); + }); + + it('영업시간이 없으면 미확인이다', () => { + expect( + stayAvailability(place({ hoursText: null }), SAT, at(14), at(20), 50), + ).toBe('unknown'); + }); + + it('달을 적지 않은 하절기·동절기는 11월에 두 계절 시간이 다른 시각을 미확인으로 본다(수창청춘맨숀)', () => { + const mansion = place({ + hoursText: '하절기 10:00~19:00동절기 10:00~18:00', + }); + expect(stayAvailability(mansion, SAT, at(14), at(16), 50)).toBe('open'); + // 18:00~19:00 은 하절기면 열고 동절기면 닫는다 + expect(stayAvailability(mansion, SAT, at(18), at(20), 50)).toBe('unknown'); + }); +}); + +describe('buildGrid · canStay · hasUnknownHours', () => { + it('브레이크 시간에는 머물 수 없다', () => { + const restaurant = place({ + category: 'food', + hoursText: '11:00~22:00 (브레이크타임 15:00~17:00)', + }); + const grid = buildGrid(restaurant, SAT, at(14), at(20, 10)); + expect(canStay(grid, at(14), at(15))).toBe(true); + expect(canStay(grid, at(14, 30), at(15, 30))).toBe(false); + expect(canStay(grid, at(17), at(18, 30))).toBe(true); + expect(hasUnknownHours(grid, at(14), at(15))).toBe(false); + }); + + it('미확인은 자연·관광지·행사에만 머물러도 되는 시간으로 본다', () => { + const park = place({ category: 'nature', hoursText: null }); + const parkGrid = buildGrid(park, SAT, at(14), at(20, 10)); + expect(canStay(parkGrid, at(14), at(16))).toBe(true); + expect(hasUnknownHours(parkGrid, at(14), at(16))).toBe(true); + + const cafe = place({ category: 'cafe', hoursText: null }); + expect( + canStay(buildGrid(cafe, SAT, at(14), at(20, 10)), at(14), at(15)), + ).toBe(false); + }); + + it('격자 밖 시간은 머물 수 없다', () => { + const grid = buildGrid(place(), SAT, at(14), at(20, 10)); + expect(canStay(grid, at(13, 50), at(15))).toBe(false); + expect(canStay(grid, at(19), at(20, 20))).toBe(false); + }); +}); diff --git a/backend/src/modules/course-generation/planner/availability.ts b/backend/src/modules/course-generation/planner/availability.ts new file mode 100644 index 0000000..6d110a7 --- /dev/null +++ b/backend/src/modules/course-generation/planner/availability.ts @@ -0,0 +1,135 @@ +/** + * 영업 확인 — 그날 날짜·시각으로 isOpenAt 을 불러 머물 수 있는지 본다(설계 §3.3·§3.6). + * 브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간은 isOpenAt 이 이미 반영한다. + */ +import { isOpenAt } from '@/shared/utils/opening-hours'; + +import { allowsUnknownHours } from './candidate'; +import { + DAY_END_MINUTES, + OPEN_CHECK_STEP_MINUTES, + TIME_STEP_MINUTES, + WINDOW_GRACE_MINUTES, +} from './planner.constants'; +import type { Candidate, PlanContext } from './planner.types'; + +export type OpenState = 'open' | 'closed' | 'unknown'; + +/** 그날 한 시각의 영업 상태. 영업시간이 없거나 판단할 수 없으면 미확인 */ +export function openStateAt( + c: Candidate, + date: string, + minute: number, +): OpenState { + if (!c.hours) return 'unknown'; + const open = isOpenAt(c.hours, date, minute); + if (open === null) return 'unknown'; + return open ? 'open' : 'closed'; +} + +/** 코스가 끝나야 하는 시각 — 끝 시각 + 10분(자정을 넘기지 않는다) */ +export function scheduleEnd(ctx: Pick): number { + return Math.min(ctx.end + WINDOW_GRACE_MINUTES, DAY_END_MINUTES); +} + +/** + * 거르기 — [from, to) 안에 최소 머무는 시간만큼 이어서 머물 수 있는 때가 있는가(10분 간격 확인). + * open: 영업이 확인된 때가 있다 · unknown: 미확인인 시각을 끼워야만 머물 수 있다 · closed: 머물 수 없다 + */ +export function stayAvailability( + c: Candidate, + date: string, + from: number, + to: number, + minStay: number, + step = OPEN_CHECK_STEP_MINUTES, +): OpenState { + let openFrom: number | null = null; + let usableFrom: number | null = null; + let unknownFits = false; + for (let t = from; t < to; t += step) { + const state = openStateAt(c, date, t); + openFrom = state === 'open' ? (openFrom ?? t) : null; + usableFrom = state === 'closed' ? null : (usableFrom ?? t); + const covered = Math.min(t + step, to); + if (openFrom !== null && covered - openFrom >= minStay) return 'open'; + if (usableFrom !== null && covered - usableFrom >= minStay) { + unknownFits = true; + } + } + return unknownFits ? 'unknown' : 'closed'; +} + +/** + * 코스 시간 범위를 5분 칸으로 나눠, 칸마다 머물러도 되는지와 영업 여부가 미확인인지를 누적 합으로 둔다. + * 머물러도 되는 칸 = 영업 중, 또는 미확인을 허용하는 분류(자연·관광지·행사)의 미확인. + * 시간표를 고칠 때 같은 장소를 수백 번 확인하므로 장소마다 한 번만 계산한다. + */ +export interface AvailabilityGrid { + /** 첫 칸 시각(분) */ + from: number; + /** 마지막 칸이 끝나는 시각(분) */ + to: number; + step: number; + /** allowed[i] = 앞 i 칸 중 머물러도 되는 칸 수 */ + allowed: number[]; + /** unknown[i] = 앞 i 칸 중 미확인 칸 수 */ + unknown: number[]; +} + +export function buildGrid( + c: Candidate, + date: string, + from: number, + to: number, + step = TIME_STEP_MINUTES, +): AvailabilityGrid { + const slots = Math.max(0, Math.floor((to - from) / step)); + const unknownOk = allowsUnknownHours(c); + const allowed = [0]; + const unknown = [0]; + for (let i = 0; i < slots; i++) { + const state = openStateAt(c, date, from + i * step); + const usable = state === 'open' || (state === 'unknown' && unknownOk); + allowed.push(allowed[i] + (usable ? 1 : 0)); + unknown.push(unknown[i] + (state === 'unknown' ? 1 : 0)); + } + return { from, to: from + slots * step, step, allowed, unknown }; +} + +/** [start, end) 를 덮는 칸 범위. 격자 밖이면 null */ +function slotRange( + grid: AvailabilityGrid, + start: number, + end: number, +): [number, number] | null { + if (start < grid.from || end > grid.to || end <= start) return null; + return [ + Math.floor((start - grid.from) / grid.step), + Math.ceil((end - grid.from) / grid.step), + ]; +} + +/** [start, end) 동안 내내 머물러도 되는가 */ +export function canStay( + grid: AvailabilityGrid, + start: number, + end: number, +): boolean { + const range = slotRange(grid, start, end); + if (!range) return false; + const [a, b] = range; + return grid.allowed[b] - grid.allowed[a] === b - a; +} + +/** [start, end) 중에 영업 여부가 미확인인 때가 있는가 */ +export function hasUnknownHours( + grid: AvailabilityGrid, + start: number, + end: number, +): boolean { + const range = slotRange(grid, start, end); + if (!range) return false; + const [a, b] = range; + return grid.unknown[b] - grid.unknown[a] > 0; +} diff --git a/backend/src/modules/course-generation/planner/candidate.spec.ts b/backend/src/modules/course-generation/planner/candidate.spec.ts new file mode 100644 index 0000000..54b29c9 --- /dev/null +++ b/backend/src/modules/course-generation/planner/candidate.spec.ts @@ -0,0 +1,129 @@ +import { + allowsUnknownHours, + diversityKey, + isEligible, + petExclusion, + prefersIndoor, + settingOf, + stayRange, +} from './candidate'; +import { event, place } from './planner.fixtures'; +import type { PetCondition } from './planner.types'; + +const smallDog: PetCondition = { kind: 'dog', size: 'small', count: 1 }; + +describe('isEligible', () => { + it('AI 순위가 있는 장소와 공연이 아닌 행사만 후보다', () => { + expect(isEligible(place())).toBe(true); + expect(isEligible(place({ tier: null }))).toBe(false); + expect(isEligible(place({ tier: 1, category: 'lodging' }))).toBe(false); + expect(isEligible(event({ eventKind: 'exhibition' }))).toBe(true); + expect(isEligible(event({ eventKind: 'performance' }))).toBe(false); + }); +}); + +describe('stayRange', () => { + it('분류별 머무는 시간 범위를 쓰고, 행사는 60~150분이다', () => { + expect(stayRange(place({ category: 'cafe' }))).toEqual({ + min: 40, + max: 90, + }); + expect(stayRange(place({ category: 'food' }))).toEqual({ + min: 60, + max: 100, + }); + expect(stayRange(event({ eventKind: 'exhibition' }))).toEqual({ + min: 60, + max: 150, + }); + }); +}); + +describe('settingOf', () => { + it('큐레이션 값이 없으면 분류로 추정한다(문화시설·식당·카페는 실내, 자연은 실외, 관광지는 미확인)', () => { + expect(settingOf(place({ category: 'culture' }))).toBe('indoor'); + expect(settingOf(place({ category: 'cafe' }))).toBe('indoor'); + expect(settingOf(place({ category: 'nature' }))).toBe('outdoor'); + expect(settingOf(place({ category: 'attraction' }))).toBe('unknown'); + }); + + it('큐레이션 값을 먼저 쓰고, 실내·외가 함께 있는 곳은 실내로 본다', () => { + expect(settingOf(place({ category: 'cafe', setting: 'outdoor' }))).toBe( + 'outdoor', + ); + expect(settingOf(place({ category: 'nature', setting: 'both' }))).toBe( + 'indoor', + ); + }); + + it('행사 중 전시는 실내, 축제·행사는 미확인이다', () => { + expect(settingOf(event({ eventKind: 'exhibition' }))).toBe('indoor'); + expect(settingOf(event({ eventKind: 'festival' }))).toBe('unknown'); + }); +}); + +describe('diversityKey', () => { + it('전시 행사는 문화시설로 센다', () => { + expect(diversityKey(event({ eventKind: 'exhibition' }))).toBe('culture'); + expect(diversityKey(event({ eventKind: 'festival' }))).toBe('event'); + expect(diversityKey(place({ category: 'cafe' }))).toBe('cafe'); + }); +}); + +describe('allowsUnknownHours', () => { + it('영업시간이 미확인이어도 자연·관광지와 행사는 남긴다', () => { + expect(allowsUnknownHours(place({ category: 'nature' }))).toBe(true); + expect(allowsUnknownHours(place({ category: 'attraction' }))).toBe(true); + expect(allowsUnknownHours(event())).toBe(true); + expect(allowsUnknownHours(place({ category: 'cafe' }))).toBe(false); + expect(allowsUnknownHours(place({ category: 'culture' }))).toBe(false); + }); +}); + +describe('petExclusion', () => { + it('반려동물 조건이 없으면 거르지 않는다', () => { + expect( + petExclusion(place({ category: 'cafe', petPolicy: 'not_allowed' }), null), + ).toBeNull(); + }); + + it('동반 불가와 크기 불일치는 뺀다', () => { + expect(petExclusion(place({ petPolicy: 'not_allowed' }), smallDog)).toBe( + 'pet_not_allowed', + ); + const largeOnlyCafe = place({ + category: 'cafe', + petPolicy: 'allowed', + petSizes: ['medium', 'large'], + }); + expect(petExclusion(largeOnlyCafe, smallDog)).toBe('pet_size'); + // 크기를 모르면 크기로는 거르지 않는다 + expect( + petExclusion(largeOnlyCafe, { kind: 'dog', size: null, count: 1 }), + ).toBeNull(); + }); + + it('실내는 동반이 확인된 곳만 남기고, 야외·축제는 미확인도 남긴다', () => { + expect(petExclusion(place({ category: 'cafe' }), smallDog)).toBe( + 'pet_indoor_unknown', + ); + expect( + petExclusion(place({ category: 'cafe', petPolicy: 'partial' }), smallDog), + ).toBeNull(); + expect(petExclusion(place({ category: 'nature' }), smallDog)).toBeNull(); + expect(petExclusion(event({ eventKind: 'festival' }), smallDog)).toBeNull(); + expect(petExclusion(event({ eventKind: 'exhibition' }), smallDog)).toBe( + 'pet_indoor_unknown', + ); + }); +}); + +describe('prefersIndoor', () => { + it('강수확률 40% 이상이고 비 오면 실내를 골랐을 때만 실내 위주다', () => { + expect(prefersIndoor(60, true)).toBe(true); + expect(prefersIndoor(40, true)).toBe(true); + expect(prefersIndoor(39, true)).toBe(false); + expect(prefersIndoor(80, false)).toBe(false); + expect(prefersIndoor(null, true)).toBe(false); + }); +}); diff --git a/backend/src/modules/course-generation/planner/candidate.ts b/backend/src/modules/course-generation/planner/candidate.ts new file mode 100644 index 0000000..bae0c51 --- /dev/null +++ b/backend/src/modules/course-generation/planner/candidate.ts @@ -0,0 +1,112 @@ +/** + * 후보 하나를 보는 규칙 — 분류·실내외·머무는 시간·반려동물(설계 §3.3·§3.6). + */ +import { + type CandidateCategory, + EXCLUDED_EVENT_KINDS, + HOURS_UNKNOWN_ALLOWED, + INDOOR_CATEGORIES, + OUTDOOR_CATEGORIES, + RAIN_PROBABILITY_MIN, + STAY_MINUTES, + type StayRange, +} from './planner.constants'; +import type { Candidate, PetCondition } from './planner.types'; + +/** 시간표에서 쓰는 분류 — 행사는 'event'. 숙박은 후보가 아니라 거르기에서 빠진다 */ +export function candidateCategory(c: Candidate): CandidateCategory { + if (c.kind === 'event' || c.category === 'event') return 'event'; + return c.category === 'lodging' ? 'other' : c.category; +} + +/** AI 후보가 될 수 있는가 — 장소는 AI 순위(1·2)가 있고 숙박이 아니어야 하고, 행사는 공연이 아니어야 한다 */ +export function isEligible(c: Candidate): boolean { + if (c.kind === 'event') { + return c.eventKind !== null && !EXCLUDED_EVENT_KINDS.includes(c.eventKind); + } + return c.tier !== null && c.category !== 'lodging' && c.category !== 'event'; +} + +/** 분류별 머무는 시간 범위(분) */ +export function stayRange(c: Candidate): StayRange { + return STAY_MINUTES[candidateCategory(c)]; +} + +export type SettingClass = 'indoor' | 'outdoor' | 'unknown'; + +/** + * 실내·실외 — 큐레이션 값을 쓰고, 없으면 분류로 추정한다(§3.3). + * 실내·외가 함께 있는 곳(both)은 실내로 본다 — 비를 피할 수 있고, 반려동물은 실내 규칙을 따라야 해서다. + */ +export function settingOf(c: Candidate): SettingClass { + if (c.setting === 'indoor' || c.setting === 'both') return 'indoor'; + if (c.setting === 'outdoor') return 'outdoor'; + if (c.kind === 'event') { + return c.eventKind === 'exhibition' ? 'indoor' : 'unknown'; + } + const category = candidateCategory(c); + if (INDOOR_CATEGORIES.includes(category)) return 'indoor'; + if (OUTDOOR_CATEGORIES.includes(category)) return 'outdoor'; + return 'unknown'; +} + +export function isIndoor(c: Candidate): boolean { + return settingOf(c) === 'indoor'; +} + +/** 다양성을 셀 때의 분류 — 전시 행사는 문화시설로 센다(평가셋 must_include 와 같다) */ +export function diversityKey(c: Candidate): string { + if (c.kind === 'event') { + return c.eventKind === 'exhibition' ? 'culture' : 'event'; + } + return candidateCategory(c); +} + +/** 영업시간이 미확인이어도 남기는가 — 자연·관광지와 행사(§13) */ +export function allowsUnknownHours(c: Candidate): boolean { + return ( + c.kind === 'event' || HOURS_UNKNOWN_ALLOWED.includes(candidateCategory(c)) + ); +} + +export type PetExclusion = + | 'pet_not_allowed' + | 'pet_size' + | 'pet_indoor_unknown'; + +/** + * 반려동물 조건으로 빼야 하는 이유(§3.3·§13). 빼지 않으면 null. + * 동반 불가와 크기 불일치는 뺀다. 실내는 동반이 확인된 곳(가능·일부 가능)만 남기고, + * 야외 분류와 축제·행사는 미확인도 남긴다(결과에 '동반 정보 없음' 경고). + */ +export function petExclusion( + c: Candidate, + pet: PetCondition | null, +): PetExclusion | null { + if (!pet) return null; + if (c.petPolicy === 'not_allowed') return 'pet_not_allowed'; + if (pet.size && c.petSizes && !c.petSizes.includes(pet.size)) { + return 'pet_size'; + } + if (c.petPolicy === 'unknown' && settingOf(c) === 'indoor') { + return 'pet_indoor_unknown'; + } + return null; +} + +/** 반려동물 동반이 확인된 곳(가능·일부 가능) */ +export function isPetConfirmed(c: Candidate): boolean { + return c.petPolicy === 'allowed' || c.petPolicy === 'partial'; +} + +/** 비 조건 — 강수확률이 40% 이상이고 사용자가 '비 오면 실내'를 골랐을 때. 예보가 없으면 쓰지 않는다 */ +export function prefersIndoor( + precipitationProbability: number | null, + indoorIfRain: boolean, +): boolean { + return ( + indoorIfRain && + precipitationProbability !== null && + precipitationProbability >= RAIN_PROBABILITY_MIN + ); +} diff --git a/backend/src/modules/course-generation/planner/filter.spec.ts b/backend/src/modules/course-generation/planner/filter.spec.ts new file mode 100644 index 0000000..0feaf20 --- /dev/null +++ b/backend/src/modules/course-generation/planner/filter.spec.ts @@ -0,0 +1,112 @@ +import { exclusionReason, filterCandidates } from './filter'; +import { context, event, place } from './planner.fixtures'; +import type { PetCondition } from './planner.types'; + +const smallDog: PetCondition = { kind: 'dog', size: 'small', count: 1 }; + +describe('exclusionReason', () => { + const ctx = context(); + + it('영업 중인 관광공사 장소는 남긴다', () => { + expect( + exclusionReason(place({ category: 'cafe', east: 300 }), ctx), + ).toBeNull(); + }); + + it('후보가 아닌 장소(순위 없음·숙박)와 공연은 뺀다', () => { + expect(exclusionReason(place({ tier: null }), ctx)).toBe('not_candidate'); + expect(exclusionReason(place({ tier: 1, category: 'lodging' }), ctx)).toBe( + 'not_candidate', + ); + expect(exclusionReason(event({ eventKind: 'performance' }), ctx)).toBe( + 'not_candidate', + ); + }); + + it('이동 수단별 반경 밖은 뺀다(걸어서 1.5km · 대중교통 6km)', () => { + const far = place({ east: 1600 }); + expect(exclusionReason(far, ctx)).toBe('out_of_radius'); + expect(exclusionReason(far, context({ mobility: 'transit' }))).toBeNull(); + }); + + it('그날 진행하지 않는 행사는 뺀다', () => { + expect( + exclusionReason( + event({ startDate: '2026-10-27', endDate: '2026-11-06' }), + ctx, + ), + ).toBe('event_not_running'); + expect( + exclusionReason( + event({ startDate: '2026-11-07', endDate: '2026-11-07' }), + ctx, + ), + ).toBeNull(); + }); + + it('공휴일·명절이고 명절·공휴일 휴무 표시가 있으면 뺀다', () => { + const museum = place({ restText: '매주 월요일 / 설날·추석 당일' }); + expect(exclusionReason(museum, context({ holiday: true }))).toBe('holiday'); + expect(exclusionReason(museum, ctx)).toBeNull(); + }); + + it('반려동물 조건이면 실내 미확인·동반 불가·크기 불일치를 빼고 야외 미확인은 남긴다', () => { + const petCtx = context({ pet: smallDog }); + expect(exclusionReason(place({ category: 'cafe' }), petCtx)).toBe( + 'pet_indoor_unknown', + ); + expect( + exclusionReason( + place({ category: 'cafe', petPolicy: 'not_allowed' }), + petCtx, + ), + ).toBe('pet_not_allowed'); + expect( + exclusionReason( + place({ category: 'cafe', petPolicy: 'allowed', petSizes: ['large'] }), + petCtx, + ), + ).toBe('pet_size'); + expect(exclusionReason(place({ category: 'nature' }), petCtx)).toBeNull(); + }); + + it('시간 범위 안에 최소 머무는 시간만큼 열지 않으면 뺀다', () => { + expect(exclusionReason(place({ restText: '매주 토요일' }), ctx)).toBe( + 'closed', + ); + // 행사 시간이 범위(14:00~20:00)와 겹치지 않는다 + expect(exclusionReason(event({ hoursText: '10:00~13:00' }), ctx)).toBe( + 'closed', + ); + }); + + it('영업시간 미확인은 자연·관광지와 행사만 남긴다', () => { + expect( + exclusionReason(place({ category: 'cafe', hoursText: null }), ctx), + ).toBe('hours_unknown'); + expect( + exclusionReason(place({ category: 'culture', hoursText: null }), ctx), + ).toBe('hours_unknown'); + expect( + exclusionReason(place({ category: 'nature', hoursText: null }), ctx), + ).toBeNull(); + expect(exclusionReason(event({ hoursText: null }), ctx)).toBeNull(); + }); +}); + +describe('filterCandidates', () => { + it('남긴 후보와 뺀 이유별 개수를 돌려준다', () => { + const kept = place(); + const result = filterCandidates( + [ + kept, + place({ east: 2000 }), + place({ east: 1800 }), + place({ restText: '매주 토요일' }), + ], + context(), + ); + expect(result.kept).toEqual([kept]); + expect(result.excluded).toEqual({ out_of_radius: 2, closed: 1 }); + }); +}); diff --git a/backend/src/modules/course-generation/planner/filter.ts b/backend/src/modules/course-generation/planner/filter.ts new file mode 100644 index 0000000..e723bf2 --- /dev/null +++ b/backend/src/modules/course-generation/planner/filter.ts @@ -0,0 +1,89 @@ +/** + * 후보 거르기(설계 §3.3) — 반경·행사 기간·공휴일·반려동물·영업을 코드로 본다. + * 비는 거르지 않는다(시간표 검증에서 실내 비율을 본다). + */ +import { distanceMeters } from '@/shared/utils/geo'; + +import { scheduleEnd, stayAvailability } from './availability'; +import { + allowsUnknownHours, + isEligible, + type PetExclusion, + petExclusion, + stayRange, +} from './candidate'; +import { SEARCH_RADIUS_M } from './planner.constants'; +import type { Candidate, PlanContext } from './planner.types'; + +export type ExcludeReason = + | 'not_candidate' + | 'out_of_radius' + | 'event_not_running' + | 'holiday' + | PetExclusion + | 'closed' + | 'hours_unknown'; + +export interface FilterResult { + kept: Candidate[]; + /** 뺀 이유별 개수 — 진행 표시와 코스를 못 만든 이유에 쓴다 */ + excluded: Partial>; +} + +/** 그날 진행하는 행사인가('YYYY-MM-DD' 는 글자 순서가 날짜 순서와 같다) */ +function isRunningOn(c: Candidate, date: string): boolean { + return ( + c.startDate !== null && + c.endDate !== null && + c.startDate <= date && + date <= c.endDate + ); +} + +/** + * 후보에서 빼야 하는 이유. 남기면 null. 비싼 영업 확인을 마지막에 한다. + * - 영업: 시간 범위 안에서 분류별 최소 머무는 시간만큼 열려 있어야 한다 + * - 영업시간 미확인: 미확인인 시각을 끼워야만 머물 수 있으면, 자연·관광지와 행사만 남긴다 + * - 공휴일: 그날이 공휴일·명절이고 휴무 표시(명절·공휴일 휴무)가 있으면 뺀다 + */ +export function exclusionReason( + c: Candidate, + ctx: PlanContext, +): ExcludeReason | null { + if (!isEligible(c)) return 'not_candidate'; + if (distanceMeters(ctx.center, c) > SEARCH_RADIUS_M[ctx.mobility]) { + return 'out_of_radius'; + } + if (c.kind === 'event' && !isRunningOn(c, ctx.date)) { + return 'event_not_running'; + } + if (ctx.holiday && (c.hours?.holidayClosures.length ?? 0) > 0) { + return 'holiday'; + } + const pet = petExclusion(c, ctx.pet); + if (pet) return pet; + const state = stayAvailability( + c, + ctx.date, + ctx.start, + scheduleEnd(ctx), + stayRange(c).min, + ); + if (state === 'closed') return 'closed'; + if (state === 'unknown' && !allowsUnknownHours(c)) return 'hours_unknown'; + return null; +} + +export function filterCandidates( + candidates: Candidate[], + ctx: PlanContext, +): FilterResult { + const kept: Candidate[] = []; + const excluded: Partial> = {}; + for (const c of candidates) { + const reason = exclusionReason(c, ctx); + if (reason === null) kept.push(c); + else excluded[reason] = (excluded[reason] ?? 0) + 1; + } + return { kept, excluded }; +} diff --git a/backend/src/modules/course-generation/planner/plan-course.spec.ts b/backend/src/modules/course-generation/planner/plan-course.spec.ts new file mode 100644 index 0000000..d601b52 --- /dev/null +++ b/backend/src/modules/course-generation/planner/plan-course.spec.ts @@ -0,0 +1,382 @@ +import { planCourse } from './plan-course'; +import { at, context, event, place, SAT } from './planner.fixtures'; +import type { + Candidate, + CourseDraft, + DraftAlternate, + DraftStop, + PlanResult, + ShortlistEntry, +} from './planner.types'; + +/** 후보마다 순서대로 P01·P02… 번호를 붙인 목록(행사는 E01…) */ +function shortlist(...candidates: Candidate[]): ShortlistEntry[] { + let p = 0; + let e = 0; + return candidates.map((candidate) => { + const isEvent = candidate.kind === 'event'; + const n = isEvent ? ++e : ++p; + return { + ref: `${isEvent ? 'E' : 'P'}${String(n).padStart(2, '0')}`, + candidate, + distanceM: 0, + }; + }); +} + +function draft( + stops: [string, number][], + alternates: DraftAlternate[] = [], + notices: string[] = [], +): CourseDraft { + return { + stops: stops.map( + ([ref, minutes]): DraftStop => ({ ref, minutes, reason: `${ref} 이유` }), + ), + alternates, + notices, + }; +} + +function expectCourse(result: PlanResult) { + if (result.outcome === 'failed') { + throw new Error(`코스를 만들지 못했다: ${JSON.stringify(result)}`); + } + return result; +} + +const cafe = (east = 0, north = 0) => + place({ category: 'cafe', east, north, hoursText: '10:00~22:00' }); +const museum = (east = 0, north = 0, hoursText = '09:00~18:00') => + place({ category: 'culture', east, north, hoursText }); +const park = (east = 0, north = 0) => + place({ + category: 'nature', + east, + north, + hoursText: null, + setting: 'outdoor', + }); + +describe('planCourse', () => { + it('AI 순서대로 위반 없이 짜지면 그대로 내고 이동·합계를 붙인다', () => { + const list = shortlist( + cafe(), + museum(300), + place({ category: 'food', east: 600, hoursText: '11:00~22:00' }), + ); + const result = expectCourse( + planCourse( + draft([ + ['P01', 70], + ['P02', 120], + ['P03', 90], + ]), + list, + context(), + ), + ); + expect(result.outcome).toBe('course'); + expect(result.repairs).toEqual([]); + expect(result.softViolations).toEqual([]); + expect(result.stops.map((s) => [s.ref, s.arrive, s.leave])).toEqual([ + ['P01', at(14), at(15, 10)], + ['P02', at(15, 20), at(17, 20)], + ['P03', at(17, 30), at(19)], + ]); + expect(result.stops[0].legToNext).toEqual({ + minutes: 10, + meters: 300, + mode: 'walk', + }); + expect(result.stops[2].legToNext).toBeNull(); + expect(result.stops[1].reason).toBe('P02 이유'); + expect(result.totals).toEqual({ + stops: 3, + totalMinutes: 300, + travelMinutes: 20, + petConfirmed: 0, + }); + }); + + it('바꾸기 후보를 같은 시간대에 넣어 검증하고, 맞지 않는 후보는 버린다', () => { + const list = shortlist(cafe(), museum(300), cafe(0, 300), cafe(2000)); + const result = expectCourse( + planCourse( + draft( + [ + ['P01', 70], + ['P02', 120], + ], + [ + { forStop: 1, ref: 'P03', reason: '다른 카페' }, + // 박물관에서 2km 라 걸어서 15분을 넘는다 + { forStop: 2, ref: 'P04', reason: '먼 카페' }, + ], + ), + list, + context(), + ), + ); + expect(result.alternates).toHaveLength(1); + const [alt] = result.alternates; + expect(alt).toMatchObject({ + forRef: 'P01', + ref: 'P03', + arrive: at(14), + // 박물관 도착(15:20) - 이동 10분 = 15:10 까지 + leave: at(15, 10), + legFromPrev: null, + reason: '다른 카페', + }); + expect(alt.legToNext?.minutes).toBe(10); + }); + + it('목록 밖 번호와 중복은 버린다', () => { + const list = shortlist(cafe(), museum(300)); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['P99', 60], + ['P01', 60], + ['P02', 60], + ]), + list, + context(), + ), + ); + expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P02']); + expect(result.repairs).toEqual(['ref_dropped']); + }); + + it('AI 가 장소를 고르지 않았으면 no_course, 쓸 수 있는 장소가 2곳 미만이면 unrepairable', () => { + const list = shortlist(cafe(), museum(300)); + expect(planCourse(draft([]), list, context())).toEqual({ + outcome: 'failed', + cause: 'no_course', + violations: [], + repairs: [], + }); + const one = planCourse( + draft([ + ['P01', 60], + ['X01', 60], + ]), + list, + context(), + ); + expect(one).toMatchObject({ + outcome: 'failed', + cause: 'unrepairable', + violations: [ + { code: 'unknown_ref', ref: 'X01' }, + { code: 'too_few_stops' }, + ], + }); + }); + + it('일찍 닫는 곳이 뒤에 있으면 순서를 바꾼다', () => { + const list = shortlist(cafe(), park(300), museum(0, 300, '09:00~16:00')); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['P02', 60], + ['P03', 60], + ]), + list, + context({ end: at(18) }), + ), + ); + expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P03', 'P02']); + expect(result.repairs).toContain('reordered'); + expect(result.outcome).toBe('course'); + }); + + it('끝 시각을 넘기면 머무는 시간을 줄인다', () => { + const list = shortlist(museum(), cafe()); + const result = expectCourse( + planCourse( + draft([ + ['P01', 120], + ['P02', 90], + ]), + list, + context({ end: at(17) }), + ), + ); + expect(result.repairs).toEqual(['durations']); + expect(result.stops[1].leave).toBeLessThanOrEqual(at(17, 10)); + }); + + it('그날 열지 않는 곳은 바꾸기 후보로 바꾼다', () => { + const closed = museum(300, 0); + closed.hours = place({ restText: '매주 토요일' }).hours; + const list = shortlist(cafe(), closed, museum(0, 300)); + const result = expectCourse( + planCourse( + draft( + [ + ['P01', 60], + ['P02', 90], + ], + [{ forStop: 2, ref: 'P03', reason: '대신 갈 박물관' }], + ), + list, + context(), + ), + ); + expect(result.stops.map((s) => [s.ref, s.reason])).toEqual([ + ['P01', 'P01 이유'], + ['P03', '대신 갈 박물관'], + ]); + expect(result.repairs).toEqual(['replaced']); + expect(result.outcome).toBe('course'); + expect(result.alternates).toEqual([]); + }); + + it('바꿀 후보가 없으면 빼고 이유를 알린다(partial)', () => { + const closed = museum(300, 0); + closed.hours = place({ restText: '매주 토요일' }).hours; + const list = shortlist(cafe(), closed, park(0, 300)); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['P02', 90], + ['P03', 60], + ]), + list, + context(), + ), + ); + expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P03']); + expect(result.outcome).toBe('partial'); + expect(result.notices).toEqual([ + { code: 'stop_removed', ref: 'P02', cause: 'closed' }, + ]); + expect(result.repairs).toContain('removed'); + }); + + it('고쳐도 2곳을 채우지 못하면 위반 목록과 함께 unrepairable', () => { + const closed = place({ category: 'culture', restText: '매주 토요일' }); + const list = shortlist(cafe(), closed); + const result = planCourse( + draft([ + ['P01', 60], + ['P02', 60], + ]), + list, + context(), + ); + expect(result).toMatchObject({ + outcome: 'failed', + cause: 'unrepairable', + violations: [{ code: 'closed', ref: 'P02' }], + }); + }); + + it('비 조건이면 실외 장소를 실내 바꾸기 후보로 바꿔 실내 비율 50% 를 맞춘다', () => { + const list = shortlist( + park(), + place({ category: 'attraction', east: 300 }), + cafe(600), + museum(0, 300), + ); + const result = expectCourse( + planCourse( + draft( + [ + ['P01', 60], + ['P02', 60], + ['P03', 60], + ], + [{ forStop: 1, ref: 'P04', reason: '비 오면 박물관' }], + ), + list, + context({ preferIndoor: true }), + ), + ); + expect(result.stops.map((s) => s.ref)).toEqual(['P04', 'P02', 'P03']); + expect(result.repairs).toEqual(['indoor_swap']); + expect(result.notices).toEqual([]); + }); + + it('실내 후보가 모자라면 코스는 내고 알린다', () => { + const list = shortlist( + park(), + place({ category: 'attraction', east: 300 }), + cafe(600), + ); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['P02', 60], + ['P03', 60], + ]), + list, + context({ preferIndoor: true }), + ), + ); + expect(result.outcome).toBe('course'); + expect(result.notices).toEqual([{ code: 'indoor_ratio_unmet' }]); + }); + + it('미확인 영업시간·동반 정보·행사 마지막 날을 경고로 붙인다', () => { + const list = shortlist( + park(), + event({ east: 300, hoursText: null, endDate: SAT }), + ); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['E01', 90], + ]), + list, + context({ pet: { kind: 'dog', size: 'small', count: 1 } }), + ), + ); + expect(result.stops.map((s) => s.warnings)).toEqual([ + ['hours_unknown', 'pet_unknown'], + ['event_time_unknown', 'pet_unknown', 'event_last_day'], + ]); + }); + + it('AI 가 알림을 남겼으면 partial 이다', () => { + const list = shortlist(park(), park(300)); + const result = expectCourse( + planCourse( + draft( + [ + ['P01', 60], + ['P02', 60], + ], + [], + ['반려동물 동반이 확인된 카페가 없어 산책 위주로 만들었어요'], + ), + list, + context(), + ), + ); + expect(result.outcome).toBe('partial'); + expect(result.softViolations).toEqual([]); + }); + + it('후보에 다른 분류가 있는데 한 분류만 고르면 다양성 약한 위반으로 남긴다', () => { + const list = shortlist(cafe(), cafe(300), museum(600)); + const result = expectCourse( + planCourse( + draft([ + ['P01', 60], + ['P02', 60], + ]), + list, + context(), + ), + ); + expect(result.softViolations).toEqual([{ code: 'diversity' }]); + }); +}); diff --git a/backend/src/modules/course-generation/planner/plan-course.ts b/backend/src/modules/course-generation/planner/plan-course.ts new file mode 100644 index 0000000..da75270 --- /dev/null +++ b/backend/src/modules/course-generation/planner/plan-course.ts @@ -0,0 +1,489 @@ +/** + * AI 가 고른 장소로 시간표를 짜고, 검증하고, 고친다(설계 §3.6). 바꾸기 후보도 같은 시간대로 검증해 둔다(§3.7). + * AI 재호출(고치기 4)은 생성 작업이 한다 — 여기서는 고칠 수 없으면 위반 목록과 함께 failed 를 돌려준다. + */ +import { canStay } from './availability'; +import { diversityKey, isIndoor, isPetConfirmed, stayRange } from './candidate'; +import { + RAIN_INDOOR_RATIO_MIN, + STOPS_MAX, + STOPS_MIN, + TIME_STEP_MINUTES, +} from './planner.constants'; +import type { + Candidate, + CourseDraft, + DraftStop, + PlanContext, + PlannedAlternate, + PlannedStop, + PlanNotice, + PlanResult, + PlanTotals, + RepairStep, + ShortlistEntry, + Violation, + ViolationCode, +} from './planner.types'; +import { + arrivesBeforeLastOrder, + chain, + fitMinutes, + isBetterChoice, + isHardViolation, + isReachable, + mealsOk, + type OrderChoice, + type PlanItem, + PlanEngine, + type SchedulePlan, + searchOrders, + stopWarnings, + timelineViolations, +} from './schedule'; +import { isLegWithinLimit } from './travel'; + +/** 고친 단계를 순서대로, 한 번씩만 모은다 */ +class RepairLog { + private readonly steps: RepairStep[] = []; + + add(step: RepairStep): void { + if (!this.steps.includes(step)) this.steps.push(step); + } + + list(): RepairStep[] { + return [...this.steps]; + } +} + +/** + * AI 가 고른 번호를 시간표 항목으로 바꾼다. 목록 밖 번호·중복은 버리고, 6곳을 넘으면 앞의 6곳만 쓴다. + */ +function toPlanItems( + stops: DraftStop[], + byRef: Map, +): { items: PlanItem[]; violations: Violation[] } { + const items: PlanItem[] = []; + const violations: Violation[] = []; + for (const stop of stops) { + const candidate = byRef.get(stop.ref); + if (!candidate) { + violations.push({ code: 'unknown_ref', ref: stop.ref }); + } else if (items.some((item) => item.ref === stop.ref)) { + violations.push({ code: 'duplicate_ref', ref: stop.ref }); + } else if (items.length >= STOPS_MAX) { + violations.push({ code: 'too_many_stops', ref: stop.ref }); + } else { + items.push({ + ref: stop.ref, + candidate, + desired: fitMinutes(candidate, stop.minutes), + reason: stop.reason, + aiIndex: items.length, + }); + } + } + return { items, violations }; +} + +/** 장소별 바꾸기 후보(목록 안 번호, 장소마다 첫 번째 하나) — 키는 바꿀 장소의 번호 */ +function alternatesByStop( + draft: CourseDraft, + byRef: Map, + items: PlanItem[], +): Map { + const out = new Map(); + for (const alt of draft.alternates) { + const target = draft.stops[alt.forStop - 1]?.ref; + const item = items.find((it) => it.ref === target); + const candidate = byRef.get(alt.ref); + if (!item || !candidate || out.has(item.ref)) continue; + if (items.some((it) => it.ref === alt.ref)) continue; + out.set(item.ref, { + ref: alt.ref, + candidate, + desired: fitMinutes(candidate, item.desired), + reason: alt.reason, + aiIndex: item.aiIndex, + }); + } + return out; +} + +/** + * 교체·제외 대상 — 위반한 장소(이동이 길면 양 끝 장소). 끝 시각을 넘기거나 짚이는 장소가 없으면 + * 나머지 장소도 뒤에서부터 넣는다. + */ +function repairTargets(items: PlanItem[], violations: Violation[]): string[] { + const targets: string[] = []; + const add = (ref: string | undefined) => { + if (ref && !targets.includes(ref)) targets.push(ref); + }; + for (const v of violations) { + if (v.code === 'window') continue; + add(v.ref); + if (v.code === 'leg_too_long') { + add(items[items.findIndex((it) => it.ref === v.ref) + 1]?.ref); + } + } + if (violations.some((v) => v.code === 'window') || targets.length === 0) { + [...items].reverse().forEach((it) => add(it.ref)); + } + return targets; +} + +function causeOf( + ref: string, + violations: Violation[], + fallback: ViolationCode = 'window', +): ViolationCode { + return ( + violations.find((v) => v.ref === ref && v.code !== 'window')?.code ?? + fallback + ); +} + +interface RepairState { + engine: PlanEngine; + alternates: Map; + keepMeals: boolean; + repairs: RepairLog; + notices: PlanNotice[]; +} + +/** 시도 중 위반 없이 짤 수 있는 가장 나은 것. 없으면 null */ +function bestOf( + state: RepairState, + trials: { items: PlanItem[]; ref: string }[], +): { plan: OrderChoice; ref: string } | null { + let best: { plan: OrderChoice; ref: string } | null = null; + for (const trial of trials) { + const found = searchOrders(state.engine, trial.items, state.keepMeals); + if (found && (!best || isBetterChoice(found, best.plan, state.keepMeals))) { + best = { plan: found, ref: trial.ref }; + } + } + return best; +} + +/** + * 그날 시간 범위에 아예 머물 수 없는 곳(쉬는 날 등)은 순서를 찾기 전에 바꾸기 후보로 바꾸거나 뺀다. + * 순서를 바꿔도 소용없는데, 그대로 두면 모든 순서를 헛되이 따진다. 2곳 미만이 되면 null. + */ +function dropUnreachable( + state: RepairState, + items: PlanItem[], + violations: Violation[], +): PlanItem[] | null { + let current = items; + for (const item of items) { + if (isReachable(state.engine, item)) continue; + const alt = state.alternates.get(item.ref); + state.alternates.delete(item.ref); + if ( + alt && + isReachable(state.engine, alt) && + !current.some((it) => it.ref === alt.ref) + ) { + current = current.map((it) => (it.ref === item.ref ? alt : it)); + state.repairs.add('replaced'); + continue; + } + if (current.length <= STOPS_MIN) return null; + current = current.filter((it) => it.ref !== item.ref); + state.notices.push({ + code: 'stop_removed', + ref: item.ref, + cause: causeOf(item.ref, violations, 'closed'), + }); + state.repairs.add('removed'); + } + return current; +} + +/** + * 고치기 1~3(§3.6): 순서와 머무는 시간을 다시 찾고, 안 되면 위반한 장소를 바꾸기 후보로 바꾸고, + * 그래도 안 되면 뺀다(최소 2곳까지). 고칠 수 없으면 null. + */ +function repair( + state: RepairState, + items: PlanItem[], + violations: Violation[], +): SchedulePlan | null { + const reachable = dropUnreachable(state, items, violations); + if (!reachable) return null; + let current = reachable; + let flagged = violations; + for (;;) { + const found = searchOrders(state.engine, current, state.keepMeals); + if (found) return found; + if (state.engine.exhausted) return null; + const targets = repairTargets(current, flagged); + + const replacements = targets.flatMap((ref) => { + const alt = state.alternates.get(ref); + if (!alt || current.some((it) => it.ref === alt.ref)) return []; + return [{ ref, items: current.map((it) => (it.ref === ref ? alt : it)) }]; + }); + const replaced = bestOf(state, replacements); + if (replaced) { + state.alternates.delete(replaced.ref); + state.repairs.add('replaced'); + return replaced.plan; + } + + if (current.length <= STOPS_MIN) return null; + const removals = targets.map((ref) => ({ + ref, + items: current.filter((it) => it.ref !== ref), + })); + const removed = bestOf(state, removals); + const drop = removed?.ref ?? targets[0]; + state.notices.push({ + code: 'stop_removed', + ref: drop, + cause: causeOf(drop, flagged), + }); + state.repairs.add('removed'); + state.alternates.delete(drop); + if (removed) return removed.plan; + + // 하나를 바꾸거나 빼서는 안 되면 첫 대상을 빼고 다시 찾는다 + current = current.filter((it) => it.ref !== drop); + if (current.length < STOPS_MIN) return null; + flagged = timelineViolations( + state.engine, + current, + chain( + state.engine, + current, + current.map((it) => it.desired), + ), + ).filter(isHardViolation); + } +} + +function indoorRatio(items: PlanItem[]): number { + return items.filter((it) => isIndoor(it.candidate)).length / items.length; +} + +/** + * 비 조건이면 실내 비율 50% 이상을 맞춘다(§3.3·§3.6). 실외 장소를 실내 바꾸기 후보로 바꿔 보고, + * 실내 후보가 모자라 채울 수 없으면 코스는 그대로 내고 알린다. + */ +function fillIndoor(state: RepairState, plan: SchedulePlan): SchedulePlan { + let current = plan; + for (const ref of plan.items.map((it) => it.ref)) { + if (indoorRatio(current.items) >= RAIN_INDOOR_RATIO_MIN) break; + const item = current.items.find((it) => it.ref === ref); + const alt = state.alternates.get(ref); + if (!item || isIndoor(item.candidate) || !alt || !isIndoor(alt.candidate)) { + continue; + } + if (current.items.some((it) => it.ref === alt.ref)) continue; + const found = searchOrders( + state.engine, + current.items.map((it) => (it.ref === ref ? alt : it)), + mealsOk(state.engine.ctx, current.items, current.timeline), + ); + if (!found) continue; + current = found; + state.alternates.delete(ref); + state.repairs.add('indoor_swap'); + } + if (indoorRatio(current.items) < RAIN_INDOOR_RATIO_MIN) { + state.notices.push({ code: 'indoor_ratio_unmet' }); + } + return current; +} + +/** + * 바꾸기 후보를 바꿀 장소의 시간대에 넣어 본다 — 앞 장소에서 오는 이동과 다음 장소로 가는 이동을 다시 계산하고, + * 다음 장소 도착 시각은 그대로 둔다. 머무는 시간은 그 시간대와 분류 범위 안에서 영업 중인 가장 긴 시간이다. + * 마지막 장소면 원래 머무는 시간까지(끝 시각 + 10분 안). 맞지 않으면 null. + */ +function fitAlternate( + engine: PlanEngine, + plan: SchedulePlan, + index: number, + alt: PlanItem, +): PlannedAlternate | null { + const { items, timeline } = plan; + const prev = items[index - 1]; + const next = items[index + 1]; + const legFromPrev = prev ? engine.leg(prev, alt) : null; + const legToNext = next ? engine.leg(alt, next) : null; + for (const leg of [legFromPrev, legToNext]) { + if (leg && !isLegWithinLimit(leg, engine.ctx.mobility)) return null; + } + const arrive = + prev && legFromPrev + ? timeline.leave[index - 1] + legFromPrev.minutes + : timeline.arrive[index]; + if (!arrivesBeforeLastOrder(alt.candidate, arrive)) return null; + const { min, max } = stayRange(alt.candidate); + const original = timeline.leave[index] - timeline.arrive[index]; + const cap = + next && legToNext + ? Math.min(max, timeline.arrive[index + 1] - legToNext.minutes - arrive) + : Math.min(max, Math.max(min, original), engine.end - arrive); + const grid = engine.grid(alt); + for (let minutes = cap; minutes >= min; minutes -= TIME_STEP_MINUTES) { + const leave = arrive + minutes; + if (!canStay(grid, arrive, leave)) continue; + return { + forRef: items[index].ref, + ref: alt.ref, + candidate: alt.candidate, + arrive, + leave, + reason: alt.reason, + warnings: stopWarnings(engine, alt, arrive, leave), + legFromPrev, + legToNext, + }; + } + return null; +} + +function toPlannedStops(engine: PlanEngine, plan: SchedulePlan): PlannedStop[] { + return plan.items.map((item, i) => ({ + ref: item.ref, + candidate: item.candidate, + arrive: plan.timeline.arrive[i], + leave: plan.timeline.leave[i], + reason: item.reason, + warnings: stopWarnings( + engine, + item, + plan.timeline.arrive[i], + plan.timeline.leave[i], + ), + legToNext: plan.timeline.legs[i] ?? null, + })); +} + +function totalsOf(stops: PlannedStop[]): PlanTotals { + return { + stops: stops.length, + totalMinutes: stops[stops.length - 1].leave - stops[0].arrive, + travelMinutes: stops.reduce( + (sum, s) => sum + (s.legToNext?.minutes ?? 0), + 0, + ), + petConfirmed: stops.filter((s) => isPetConfirmed(s.candidate)).length, + }; +} + +/** 고친 결과가 AI 의 순서·머무는 시간과 다른지 기록한다 */ +function noteChanges( + plan: SchedulePlan, + ctx: PlanContext, + repairs: RepairLog, +): void { + const { items, timeline } = plan; + if (items.some((it, i) => i > 0 && items[i - 1].aiIndex > it.aiIndex)) { + repairs.add('reordered'); + } + const changed = + timeline.arrive[0] !== ctx.start || + items.some( + (it, i) => timeline.leave[i] - timeline.arrive[i] !== it.desired, + ); + if (changed) repairs.add('durations'); +} + +/** + * AI 응답(고른 번호·머무는 시간·바꾸기 후보)으로 코스 초안을 만든다. + * 1) 목록 밖·중복 번호를 버린다 2) AI 순서대로 시간표를 짜고 검증한다 3) 위반이 있으면 고친다 + * 4) 비 조건이면 실내 비율을 맞춘다 5) 바꾸기 후보를 검증한다. + * 장소를 뺐거나 AI 가 알림을 남겼으면 partial, 2곳 미만이면 failed(AI_003) 다. + */ +export function planCourse( + draft: CourseDraft, + shortlist: ShortlistEntry[], + ctx: PlanContext, +): PlanResult { + const repairs = new RepairLog(); + if (draft.stops.length === 0) { + return { + outcome: 'failed', + cause: 'no_course', + violations: [], + repairs: [], + }; + } + const byRef = new Map(shortlist.map((e) => [e.ref, e.candidate])); + const { items, violations: refViolations } = toPlanItems(draft.stops, byRef); + if (refViolations.length > 0) repairs.add('ref_dropped'); + if (items.length < STOPS_MIN) { + return { + outcome: 'failed', + cause: 'unrepairable', + violations: [...refViolations, { code: 'too_few_stops' }], + repairs: repairs.list(), + }; + } + + const engine = new PlanEngine(ctx); + const initial = chain( + engine, + items, + items.map((it) => it.desired), + ); + const initialViolations = timelineViolations(engine, items, initial); + const hard = initialViolations.filter(isHardViolation); + const state: RepairState = { + engine, + alternates: alternatesByStop(draft, byRef, items), + keepMeals: !initialViolations.some((v) => v.code === 'meal_time'), + repairs, + notices: [], + }; + + let plan: SchedulePlan | null = { items, timeline: initial }; + if (hard.length > 0) { + plan = repair(state, items, hard); + if (!plan) { + return { + outcome: 'failed', + cause: 'unrepairable', + violations: [...refViolations, ...hard], + repairs: repairs.list(), + }; + } + noteChanges(plan, ctx, repairs); + } + if (ctx.preferIndoor) plan = fillIndoor(state, plan); + + const stops = toPlannedStops(engine, plan); + const used = new Set(stops.map((s) => s.ref)); + const alternates: PlannedAlternate[] = []; + for (const [forRef, alt] of state.alternates) { + const index = plan.items.findIndex((it) => it.ref === forRef); + if (index < 0 || used.has(alt.ref)) continue; + const fitted = fitAlternate(engine, plan, index, alt); + if (fitted) alternates.push(fitted); + } + + const softViolations: Violation[] = []; + if (!mealsOk(ctx, plan.items, plan.timeline)) { + softViolations.push({ code: 'meal_time' }); + } + const kinds = new Set(plan.items.map((it) => diversityKey(it.candidate))); + const available = new Set(shortlist.map((e) => diversityKey(e.candidate))); + if (kinds.size < 2 && available.size >= 2) { + softViolations.push({ code: 'diversity' }); + } + + const reduced = state.notices.some((n) => n.code === 'stop_removed'); + return { + outcome: reduced || draft.notices.length > 0 ? 'partial' : 'course', + stops, + alternates, + notices: state.notices, + repairs: repairs.list(), + softViolations, + totals: totalsOf(stops), + }; +} diff --git a/backend/src/modules/course-generation/planner/planner.constants.ts b/backend/src/modules/course-generation/planner/planner.constants.ts new file mode 100644 index 0000000..f4694c4 --- /dev/null +++ b/backend/src/modules/course-generation/planner/planner.constants.ts @@ -0,0 +1,130 @@ +/** + * AI 코스 시간표 짜기의 기준값 — 설계 docs/design/ai-course.md §3.2–§3.6. + * 이동 계수·속도와 머무는 시간은 큐레이션 동네에서 실제로 걸어 본 시간과 비교해 맞춘다. + */ +import { PLACE_RADIUS_DEFAULT_M } from '@/modules/place/dto/place-search-query.dto'; +import type { EventKind, PlaceCategory } from '@/modules/place/place.constants'; + +export const MOBILITIES = ['walk', 'transit', 'car'] as const; +export type Mobility = (typeof MOBILITIES)[number]; + +/** 후보를 찾는 반경(m). 걸어서는 장소 목록 API 기본값과 같고, 자동차는 도심에서 팔공산까지다 */ +export const SEARCH_RADIUS_M: Record = { + walk: PLACE_RADIUS_DEFAULT_M, + transit: 6_000, + car: 20_000, +}; + +/** 직선거리로 이동 시간을 추정하는 식 — 고정 시간 + 직선거리 × 우회 계수 ÷ 분당 거리 */ +export interface TravelModel { + /** 거리와 상관없이 드는 시간(분) — 대중교통은 걷기·대기, 자동차는 주차 */ + fixedMinutes: number; + /** 직선거리를 실제 길 거리로 늘리는 계수 */ + detourFactor: number; + /** 분당 이동 거리(m) */ + metersPerMinute: number; +} + +export const TRAVEL_MODELS: Record = { + walk: { fixedMinutes: 0, detourFactor: 1.3, metersPerMinute: 70 }, + transit: { fixedMinutes: 10, detourFactor: 1.4, metersPerMinute: 300 }, + car: { fixedMinutes: 5, detourFactor: 1.4, metersPerMinute: 400 }, +}; + +/** 구간 최대 이동 시간(분) */ +export const MAX_LEG_MINUTES: Record = { + walk: 15, + transit: 30, + car: 30, +}; + +/** 시간표의 최소 단위(분). 이동 시간은 이 단위로 올리고 머무는 시간은 이 단위로 맞춘다 */ +export const TIME_STEP_MINUTES = 5; +/** 머무는 시간을 고칠 때 따져 보는 간격(분). AI 가 정한 값은 따로 넣는다 */ +export const DURATION_STEP_MINUTES = 10; +/** 거르기에서 영업 여부를 확인하는 간격(분) */ +export const OPEN_CHECK_STEP_MINUTES = 10; + +/** 후보가 될 수 있는 분류 — 숙박은 순위와 관계없이 뺀다(§3.2). 행사는 'event' */ +export type CandidateCategory = Exclude | 'event'; + +export interface StayRange { + min: number; + max: number; +} + +/** + * 분류별 머무는 시간(분) — AI 가 정한 값을 이 범위로 맞춘다. + * 쇼핑·술집·기타는 설계 표에 없어 정했다(1순위 큐레이션 장소만 후보가 된다). + */ +export const STAY_MINUTES: Record = { + cafe: { min: 40, max: 90 }, + food: { min: 60, max: 100 }, + culture: { min: 50, max: 120 }, + attraction: { min: 30, max: 120 }, + nature: { min: 30, max: 120 }, + leisure: { min: 60, max: 150 }, + event: { min: 60, max: 150 }, + shopping: { min: 30, max: 120 }, + bar: { min: 60, max: 120 }, + other: { min: 30, max: 120 }, +}; + +/** 마지막 장소가 끝나도 되는 끝 시각 뒤 여유(분) */ +export const WINDOW_GRACE_MINUTES = 10; +/** 하루의 끝(분). 코스는 자정을 넘기지 않는다(MVP) */ +export const DAY_END_MINUTES = 1440; +/** 첫 장소가 문을 늦게 열면 시작 시각보다 이만큼(분)까지 늦게 도착해도 된다 */ +export const FIRST_ARRIVAL_DELAY_MAX_MINUTES = 60; +/** 식당은 마지막 주문 이만큼(분) 전까지 도착한다 */ +export const LAST_ORDER_ARRIVAL_MARGIN_MINUTES = 30; + +export const STOPS_MIN = 2; +export const STOPS_MAX = 6; + +/** 식사 시간대(분). 시간 범위가 걸치면 식당을 그 시간에 둔다 */ +export const MEAL_WINDOWS = { + lunch: { start: 11 * 60 + 30, end: 14 * 60 }, + dinner: { start: 17 * 60 + 30, end: 20 * 60 + 30 }, +} as const; +export type Meal = keyof typeof MEAL_WINDOWS; + +/** 강수확률이 이 값(%) 이상이고 indoorIfRain 이면 실내 위주로 짠다 */ +export const RAIN_PROBABILITY_MIN = 40; +/** 비 조건일 때 실내 장소 비율 하한 */ +export const RAIN_INDOOR_RATIO_MIN = 0.5; + +/** 큐레이션 실내·외 값이 없을 때 실내로 보는 분류. 행사 중에는 전시가 실내다 */ +export const INDOOR_CATEGORIES: readonly CandidateCategory[] = [ + 'culture', + 'food', + 'cafe', + 'shopping', + 'bar', +]; +/** 큐레이션 값이 없을 때 실외로 보는 분류. 관광지·레저·축제·행사는 미확인이다 */ +export const OUTDOOR_CATEGORIES: readonly CandidateCategory[] = ['nature']; +/** 영업시간이 미확인이어도 남기는 분류(야외라 시간 영향이 작다). 행사는 늘 남긴다 */ +export const HOURS_UNKNOWN_ALLOWED: readonly CandidateCategory[] = [ + 'nature', + 'attraction', +]; + +/** AI 후보에서 빼는 행사 종류 — 공연은 회차 시각을 아직 해석하지 않는다(§13) */ +export const EXCLUDED_EVENT_KINDS: readonly EventKind[] = ['performance']; + +/** 후보 줄이기 — 장소와 행사를 합쳐 이 수 이하(§3.4) */ +export const SHORTLIST_MAX = 40; +/** 한 분류가 목록을 다 채우지 않게 하는 분류별 상한 */ +export const SHORTLIST_PER_CATEGORY = 10; +/** 행사 상한(가까운 순) */ +export const SHORTLIST_EVENTS_MAX = 8; +/** 다시 만들기 — 이전 결과의 장소를 빼도 이만큼 남으면 빼고, 아니면 목록 뒤로 보낸다 */ +export const REGENERATE_EXCLUDE_MIN_REMAINING = 15; + +/** + * 순서·시간 찾기에서 따져 볼 최대 순서 수(코스 하나 기준) — 서버의 이벤트 루프를 오래 막지 않게 한다. + * 2026-11-06 측정: 6곳 코스 고치기는 대부분 20ms 안이고, AI 순서가 거꾸로이면서 시간이 빠듯한 경우가 약 50ms(순서 약 700개)였다. + * 상한에 닿으면 고칠 수 없는 것으로 보고 위반 목록을 돌려준다(생성 작업이 AI 를 다시 부르거나 AI_003). + */ +export const MAX_ORDER_EVALUATIONS = 5_000; diff --git a/backend/src/modules/course-generation/planner/planner.fixtures.ts b/backend/src/modules/course-generation/planner/planner.fixtures.ts new file mode 100644 index 0000000..7a1ccb3 --- /dev/null +++ b/backend/src/modules/course-generation/planner/planner.fixtures.ts @@ -0,0 +1,106 @@ +/** + * 시간표 짜기 단위 테스트용 후보·조건 만들기. 운영 빌드에서는 빠진다(tsconfig.build.json 의 *.fixtures.ts). + */ +import type { LatLng } from '@/shared/utils/geo'; +import { parseOpeningHours } from '@/shared/utils/opening-hours'; + +import type { Candidate, PlanContext } from './planner.types'; + +/** 기준 중심 — 대구 도심(앱의 downtown 과 같은 좌표) */ +export const CENTER: LatLng = { lat: 35.87021, lng: 128.59409 }; +/** 위도 1도의 거리(m, 지구 반지름 6,371km) */ +const METERS_PER_LAT_DEGREE = 111_195; + +/** 2026-11-07 토요일 · 2026-11-09 월요일 */ +export const SAT = '2026-11-07'; +export const MON = '2026-11-09'; + +/** 시·분 → 00:00 부터의 분 */ +export const at = (hour: number, minute = 0): number => hour * 60 + minute; + +/** 중심에서 동쪽 east m, 북쪽 north m 떨어진 좌표 */ +export function offset(east: number, north: number, from = CENTER): LatLng { + const metersPerLngDegree = + METERS_PER_LAT_DEGREE * Math.cos((from.lat * Math.PI) / 180); + return { + lat: from.lat + north / METERS_PER_LAT_DEGREE, + lng: from.lng + east / metersPerLngDegree, + }; +} + +interface CandidateOptions extends Partial { + /** 영업시간 원문(null 이면 영업시간 없음) */ + hoursText?: string | null; + restText?: string | null; + /** 중심에서 동쪽·북쪽 거리(m) */ + east?: number; + north?: number; +} + +let sequence = 0; + +/** 장소 후보 — 기본값은 관광공사 문화시설(09:00~22:00, 반려 정보 없음), 중심 */ +export function place(options: CandidateOptions = {}): Candidate { + const { + hoursText = '09:00~22:00', + restText = null, + east = 0, + north = 0, + ...rest + } = options; + sequence += 1; + return { + kind: 'place', + id: `place-${sequence}`, + name: `장소 ${sequence}`, + category: 'culture', + eventKind: null, + ...offset(east, north), + tier: 2, + setting: null, + hours: hoursText === null ? null : parseOpeningHours(hoursText, restText), + petPolicy: 'unknown', + petSizes: null, + startDate: null, + endDate: null, + ...rest, + }; +} + +/** 행사 후보 — 기본값은 11월 한 달 하는 행사(운영 시간 10:00~21:00) */ +export function event(options: CandidateOptions = {}): Candidate { + const { hoursText = '10:00~21:00', east = 0, north = 0, ...rest } = options; + sequence += 1; + return { + kind: 'event', + id: `event-${sequence}`, + name: `행사 ${sequence}`, + category: 'event', + eventKind: 'event', + ...offset(east, north), + tier: null, + setting: null, + hours: hoursText === null ? null : parseOpeningHours(hoursText), + petPolicy: 'unknown', + petSizes: null, + startDate: '2026-11-01', + endDate: '2026-11-30', + ...rest, + }; +} + +/** 조건 — 기본값은 토요일 14:00~20:00, 도심에서 걸어서, 반려동물·비 없음 */ +export function context(overrides: Partial = {}): PlanContext { + return { + date: SAT, + start: at(14), + end: at(20), + mobility: 'walk', + center: CENTER, + pet: null, + holiday: false, + preferIndoor: false, + previousIds: [], + ...overrides, + }; +} diff --git a/backend/src/modules/course-generation/planner/planner.types.ts b/backend/src/modules/course-generation/planner/planner.types.ts new file mode 100644 index 0000000..8fd5697 --- /dev/null +++ b/backend/src/modules/course-generation/planner/planner.types.ts @@ -0,0 +1,204 @@ +/** + * AI 코스 시간표 짜기(순수 로직)의 입력·출력 형식 — 설계 docs/design/ai-course.md §3.3–§3.7. + * DB 행·AI 응답은 생성 작업(4단계)이 이 형식으로 바꿔 넘긴다. + */ +import type { + EventKind, + PetPolicy, + PetSize, + PlaceCategory, + PlaceSetting, +} from '@/modules/place/place.constants'; +import type { LatLng } from '@/shared/utils/geo'; +import type { ParsedOpeningHours } from '@/shared/utils/opening-hours'; + +import type { Mobility } from './planner.constants'; + +/** AI 후보 하나 — place_listing 의 장소 또는 festival_event 의 행사 */ +export interface Candidate { + kind: 'place' | 'event'; + id: string; + name: string; + /** 장소 분류. 행사는 'event' */ + category: PlaceCategory | 'event'; + /** 행사 종류(장소는 null) */ + eventKind: EventKind | null; + lat: number; + lng: number; + /** AI 후보 순위 — 1 확인한 곳 · 2 관광공사 장소. 행사와 후보가 아닌 장소는 null */ + tier: 1 | 2 | null; + /** 큐레이션의 실내·외. 없으면 분류로 추정한다 */ + setting: PlaceSetting | null; + /** 영업시간. 행사는 운영 시간 문구(playtime)를 같은 파서로 읽은 값. 없으면 null */ + hours: ParsedOpeningHours | null; + petPolicy: PetPolicy; + /** 큐레이션에서 확인한 동반 가능 크기(없으면 null) */ + petSizes: PetSize[] | null; + /** 행사 기간 'YYYY-MM-DD'(장소는 null) */ + startDate: string | null; + endDate: string | null; +} + +export interface PetCondition { + kind: 'dog' | 'cat' | 'other'; + size: PetSize | null; + count: number; +} + +/** 코스를 짤 조건 — 사용자가 확인한 조건(CourseConditions)과 서버가 아는 값 */ +export interface PlanContext { + /** 한국 날짜 'YYYY-MM-DD' */ + date: string; + /** 시작·끝 시각(00:00 부터 분). 자정을 넘기지 않는다 */ + start: number; + end: number; + mobility: Mobility; + /** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */ + center: LatLng; + pet: PetCondition | null; + /** 그날이 공휴일·명절인가(공휴일 표는 3단계에서 붙인다) */ + holiday: boolean; + /** 비 조건 — 강수확률 40% 이상이고 indoorIfRain(prefersIndoor) */ + preferIndoor: boolean; + /** 다시 만들기 — 이전 결과의 장소·행사 id */ + previousIds: string[]; +} + +/** AI 에 넘기는 후보 — 짧은 번호(P01·E01)로만 고르게 한다 */ +export interface ShortlistEntry { + ref: string; + candidate: Candidate; + /** 지역 중심에서의 직선거리(m) */ + distanceM: number; +} + +/** AI 가 고른 장소 하나 */ +export interface DraftStop { + ref: string; + /** 머무는 시간(분) */ + minutes: number; + reason: string; +} + +/** AI 가 낸 바꾸기 후보 */ +export interface DraftAlternate { + /** 몇 번째 장소 대신인가(1부터) */ + forStop: number; + ref: string; + reason: string; +} + +/** AI 응답 중 시간표에 쓰는 부분(제목·요약은 생성 작업이 그대로 쓴다) */ +export interface CourseDraft { + stops: DraftStop[]; + alternates: DraftAlternate[]; + /** AI 가 남긴 알림 — 조건을 다 맞추지 못한 이유, 들어줄 수 없는 부분 */ + notices: string[]; +} + +/** 장소 사이 이동(추정) */ +export interface Leg { + minutes: number; + meters: number; + /** 실제 이동 수단 — 대중교통·자동차라도 걷는 편이 빠르면 걷기 */ + mode: Mobility; +} + +export type StopWarning = + | 'hours_unknown' + | 'event_time_unknown' + | 'pet_unknown' + | 'event_last_day'; + +export interface PlannedStop { + ref: string; + candidate: Candidate; + /** 도착·떠나는 시각(00:00 부터 분) */ + arrive: number; + leave: number; + reason: string; + warnings: StopWarning[]; + legToNext: Leg | null; +} + +/** 검증한 바꾸기 후보 — 앱이 AI 호출 없이 바꾼다 */ +export interface PlannedAlternate { + /** 바꿀 장소의 번호 */ + forRef: string; + ref: string; + candidate: Candidate; + arrive: number; + leave: number; + reason: string; + warnings: StopWarning[]; + legFromPrev: Leg | null; + legToNext: Leg | null; +} + +export type ViolationCode = + | 'unknown_ref' + | 'duplicate_ref' + | 'too_many_stops' + | 'too_few_stops' + | 'closed' + | 'last_order' + | 'window' + | 'leg_too_long' + | 'pet' + | 'meal_time' + | 'indoor_ratio' + | 'diversity'; + +export interface Violation { + code: ViolationCode; + ref?: string; +} + +/** 코드가 남기는 알림 — 문구는 앱이 언어별로 만든다 */ +export type PlanNoticeCode = 'stop_removed' | 'indoor_ratio_unmet'; + +export interface PlanNotice { + code: PlanNoticeCode; + /** 뺀 장소의 번호 */ + ref?: string; + /** 뺀 이유 */ + cause?: ViolationCode; +} + +/** 거친 고치기 단계 — 품질 지표(ai_course_generation.repairs) */ +export type RepairStep = + | 'ref_dropped' + | 'reordered' + | 'durations' + | 'replaced' + | 'removed' + | 'indoor_swap'; + +export interface PlanTotals { + stops: number; + /** 첫 도착부터 마지막 떠남까지(분) */ + totalMinutes: number; + travelMinutes: number; + /** 반려동물 동반이 확인된(가능·일부 가능) 장소 수 */ + petConfirmed: number; +} + +export type PlanResult = + | { + outcome: 'course' | 'partial'; + stops: PlannedStop[]; + alternates: PlannedAlternate[]; + notices: PlanNotice[]; + repairs: RepairStep[]; + /** 고치지 않고 둔 약한 위반(다양성·식사 시간대) */ + softViolations: Violation[]; + totals: PlanTotals; + } + | { + outcome: 'failed'; + /** no_course: AI 가 요청의 핵심을 맞출 후보가 없다고 했다 · unrepairable: 고쳐도 2곳 미만 */ + cause: 'no_course' | 'unrepairable'; + /** AI 를 다시 부를 때 붙일 위반 목록 */ + violations: Violation[]; + repairs: RepairStep[]; + }; diff --git a/backend/src/modules/course-generation/planner/schedule.spec.ts b/backend/src/modules/course-generation/planner/schedule.spec.ts new file mode 100644 index 0000000..ad957bb --- /dev/null +++ b/backend/src/modules/course-generation/planner/schedule.spec.ts @@ -0,0 +1,218 @@ +import { at, context, place } from './planner.fixtures'; +import type { Candidate, PlanContext } from './planner.types'; +import { + chain, + fitMinutes, + fitTimes, + mealsOk, + overlappingMeals, + type PlanItem, + PlanEngine, + searchOrders, + timelineViolations, +} from './schedule'; + +let order = 0; +function item(ref: string, candidate: Candidate, desired: number): PlanItem { + return { ref, candidate, desired, reason: '', aiIndex: order++ }; +} + +function engineFor(overrides: Partial = {}): PlanEngine { + order = 0; + return new PlanEngine(context(overrides)); +} + +const cafe = (east = 0, north = 0, hoursText = '10:00~22:00') => + place({ category: 'cafe', east, north, hoursText }); + +describe('fitMinutes', () => { + it('분류 범위와 5분 단위로 맞춘다', () => { + const c = cafe(); + expect(fitMinutes(c, 47)).toBe(45); + expect(fitMinutes(c, 20)).toBe(40); + expect(fitMinutes(c, 200)).toBe(90); + expect(fitMinutes(c, Number.NaN)).toBe(40); + }); +}); + +describe('chain', () => { + it('정한 머무는 시간과 이동 시간을 시작 시각부터 이어 붙인다', () => { + const engine = engineFor(); + const items = [item('A', place(), 60), item('B', cafe(700), 60)]; + const timeline = chain(engine, items, [60, 60]); + expect(timeline.arrive).toEqual([at(14), at(15, 15)]); + expect(timeline.leave).toEqual([at(15), at(16, 15)]); + expect(timeline.legs).toEqual([{ minutes: 15, meters: 700, mode: 'walk' }]); + }); +}); + +describe('timelineViolations', () => { + it('떠나기 전에 문을 닫으면 closed', () => { + const engine = engineFor(); + const items = [ + item('C', cafe(), 90), + item('M', place({ hoursText: '09:00~17:00' }), 120), + ]; + // 카페 14:00~15:30 → 박물관 15:30~17:30(17:00 에 닫는다) + const v = timelineViolations( + engine, + items, + chain(engine, items, [90, 120]), + ); + expect(v).toEqual([{ code: 'closed', ref: 'M' }]); + }); + + it('끝 시각 + 10분을 넘기면 넘긴 첫 장소에 window 를 한 번만 붙인다', () => { + const engine = engineFor({ end: at(17) }); + const items = [ + item('A', place(), 120), + item('B', place(), 120), + item('C', place(), 60), + ]; + const v = timelineViolations( + engine, + items, + chain(engine, items, [120, 120, 60]), + ); + expect(v).toEqual([{ code: 'window', ref: 'B' }]); + }); + + it('구간이 최대 이동 시간을 넘으면 leg_too_long', () => { + const engine = engineFor(); + const items = [item('A', place(), 60), item('B', cafe(900), 60)]; + const v = timelineViolations(engine, items, chain(engine, items, [60, 60])); + expect(v).toEqual([{ code: 'leg_too_long', ref: 'A' }]); + }); + + it('식당은 마지막 주문 30분 전까지 도착해야 한다', () => { + const engine = engineFor({ start: at(19), end: at(22, 30) }); + const restaurant = place({ + category: 'food', + hoursText: '10:00~23:00 (마지막 주문 21:00)', + }); + const items = [item('M', place(), 120), item('F', restaurant, 60)]; + // 박물관 19:00~21:00 → 식당 21:00 도착(20:30 까지 와야 한다) + const v = timelineViolations( + engine, + items, + chain(engine, items, [120, 60]), + ); + expect(v).toContainEqual({ code: 'last_order', ref: 'F' }); + }); + + it('반려동물 조건을 어긴 장소는 pet', () => { + const engine = engineFor({ pet: { kind: 'dog', size: null, count: 1 } }); + const items = [item('C', cafe(), 60), item('M', place(), 60)]; + const v = timelineViolations(engine, items, chain(engine, items, [60, 60])); + expect(v).toEqual([ + { code: 'pet', ref: 'C' }, + { code: 'pet', ref: 'M' }, + ]); + }); +}); + +describe('overlappingMeals · mealsOk', () => { + it('시간 범위에 걸치는 식사 시간대만 따진다', () => { + expect(overlappingMeals({ start: at(11), end: at(15) })).toEqual(['lunch']); + expect(overlappingMeals({ start: at(14), end: at(22) })).toEqual([ + 'dinner', + ]); + expect(overlappingMeals({ start: at(14, 30), end: at(17) })).toEqual([]); + }); + + it('식당이 걸치는 식사 시간대 안에 도착해야 한다', () => { + const engine = engineFor({ start: at(11), end: at(15) }); + const restaurant = place({ category: 'food', hoursText: '11:00~21:00' }); + const early = [item('F', restaurant, 60), item('C', cafe(), 60)]; + expect(mealsOk(engine.ctx, early, chain(engine, early, [60, 60]))).toBe( + false, + ); + const late = [item('C', cafe(), 60), item('F', restaurant, 60)]; + expect(mealsOk(engine.ctx, late, chain(engine, late, [60, 60]))).toBe(true); + }); +}); + +describe('fitTimes', () => { + it('문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦춘다', () => { + const engine = engineFor({ start: at(15), end: at(21) }); + const items = [ + item('P', place({ category: 'nature', hoursText: null }), 60), + item('C', cafe(300), 60), + item( + 'F', + place({ category: 'food', east: 600, hoursText: '17:30~22:00' }), + 90, + ), + ]; + // 그대로 이으면 식당에 17:20 에 도착한다(17:30 에 연다) + expect(chain(engine, items, [60, 60, 90]).arrive[2]).toBe(at(17, 20)); + const fit = fitTimes(engine, items); + expect(fit?.timeline.arrive[2]).toBe(at(17, 30)); + expect(fit?.deviation).toBe(10); + expect(timelineViolations(engine, items, fit!.timeline)).toEqual([]); + }); + + it('첫 장소가 늦게 열면 첫 도착을 늦춘다', () => { + const engine = engineFor({ start: at(10), end: at(14) }); + const items = [ + item('M', place({ hoursText: '10:30~18:00' }), 60), + item('C', cafe(), 60), + ]; + const fit = fitTimes(engine, items); + expect(fit?.timeline.arrive).toEqual([at(10, 30), at(11, 30)]); + expect(fit?.deviation).toBe(30); + }); + + it('끝 시각을 넘기면 범위 안에서 머무는 시간을 줄인다', () => { + const engine = engineFor({ end: at(17) }); + const items = [item('M', place(), 120), item('C', cafe(), 90)]; + const fit = fitTimes(engine, items); + expect(fit?.timeline.leave[1]).toBe(at(17, 10)); + expect(fit?.deviation).toBe(20); + }); + + it('그날 열지 않는 곳이 있으면 찾지 못한다', () => { + const engine = engineFor(); + const items = [ + item('C', cafe(), 60), + item('M', place({ restText: '매주 토요일' }), 60), + ]; + expect(fitTimes(engine, items)).toBeNull(); + }); +}); + +describe('searchOrders', () => { + it('AI 순서로 짤 수 있으면 순서를 바꾸지 않는다', () => { + const engine = engineFor(); + const items = [item('A', place(), 60), item('B', cafe(300), 60)]; + const found = searchOrders(engine, items, true); + expect(found?.items.map((it) => it.ref)).toEqual(['A', 'B']); + expect(found?.inversions).toBe(0); + }); + + it('일찍 닫는 곳을 앞으로 옮기되 AI 순서와 가장 비슷한 순서를 고른다', () => { + const engine = engineFor({ end: at(18) }); + const items = [ + item('C', cafe(), 60), + item('P', place({ category: 'nature', east: 300, hoursText: null }), 60), + item('M', place({ north: 300, hoursText: '09:00~16:00' }), 60), + ]; + const found = searchOrders(engine, items, true); + // 박물관을 맨 앞(바뀐 쌍 2개)이 아니라 둘째(1개)에 둔다 + expect(found?.items.map((it) => it.ref)).toEqual(['C', 'M', 'P']); + expect(found?.inversions).toBe(1); + expect(timelineViolations(engine, found!.items, found!.timeline)).toEqual( + [], + ); + }); + + it('어떤 순서로도 짤 수 없으면 null', () => { + const engine = engineFor({ end: at(16) }); + const items = [ + item('A', place(), 120), + item('B', place(), 120), + item('C', place(), 120), + ]; + expect(searchOrders(engine, items, true)).toBeNull(); + }); +}); diff --git a/backend/src/modules/course-generation/planner/schedule.ts b/backend/src/modules/course-generation/planner/schedule.ts new file mode 100644 index 0000000..99a4571 --- /dev/null +++ b/backend/src/modules/course-generation/planner/schedule.ts @@ -0,0 +1,493 @@ +/** + * 시간표·검증·순서와 머무는 시간 고치기(설계 §3.6). + * 장소가 6곳 이하라 모든 순서를 따져 볼 수 있다. 시각은 00:00 부터의 분이고 5분 단위다. + */ +import { + type AvailabilityGrid, + buildGrid, + canStay, + hasUnknownHours, + scheduleEnd, +} from './availability'; +import { candidateCategory, petExclusion, stayRange } from './candidate'; +import { + DURATION_STEP_MINUTES, + FIRST_ARRIVAL_DELAY_MAX_MINUTES, + LAST_ORDER_ARRIVAL_MARGIN_MINUTES, + MAX_ORDER_EVALUATIONS, + type Meal, + MEAL_WINDOWS, + TIME_STEP_MINUTES, +} from './planner.constants'; +import type { + Candidate, + Leg, + PlanContext, + StopWarning, + Violation, + ViolationCode, +} from './planner.types'; +import { estimateLeg, isLegWithinLimit } from './travel'; + +/** 시간표에 올릴 장소 하나 */ +export interface PlanItem { + ref: string; + candidate: Candidate; + /** AI 가 정한 머무는 시간을 분류 범위·5분 단위로 맞춘 값 */ + desired: number; + reason: string; + /** AI 가 정한 순서(0부터). 바꾸기 후보로 바꾼 곳은 원래 장소의 순서를 이어받는다 */ + aiIndex: number; +} + +/** 순서대로 이어 붙인 시각과 구간 */ +export interface Timeline { + arrive: number[]; + leave: number[]; + /** legs[i] = i 번째에서 다음 장소로 가는 이동 */ + legs: Leg[]; +} + +export interface SchedulePlan { + items: PlanItem[]; + timeline: Timeline; +} + +/** 순서·시간 찾기 결과 */ +export interface OrderChoice extends SchedulePlan { + /** AI 순서에서 앞뒤가 바뀐 쌍의 수 */ + inversions: number; + /** 이동 시간 합계(분) */ + travel: number; + /** AI 가 정한 머무는 시간에서 바뀐 정도(분) + 첫 도착을 늦춘 시간(분) */ + deviation: number; + mealOk: boolean; +} + +/** 위반을 고쳐야 하는 것(강한 위반). 식사 시간대·실내 비율·다양성은 약한 위반이다 */ +const HARD_VIOLATIONS: readonly ViolationCode[] = [ + 'closed', + 'last_order', + 'window', + 'leg_too_long', + 'pet', +]; + +export function isHardViolation(v: Violation): boolean { + return HARD_VIOLATIONS.includes(v.code); +} + +/** 도착할 수 있는 시각 범위(분) */ +export interface ArrivalWindow { + earliest: number; + latest: number; +} + +/** + * 코스 하나를 짜는 동안 같은 계산(장소별 영업 칸·구간 이동)을 다시 하지 않게 모아 둔다. + * 순서·시간 찾기 횟수도 센다 — 서버의 이벤트 루프를 오래 막지 않게 상한을 둔다. + */ +export class PlanEngine { + /** 코스가 끝나야 하는 시각(끝 시각 + 10분) */ + readonly end: number; + private readonly grids = new Map(); + private readonly legs = new Map(); + private readonly windows = new Map(); + private evaluations = 0; + + constructor(readonly ctx: PlanContext) { + this.end = scheduleEnd(ctx); + } + + grid(item: Pick): AvailabilityGrid { + let grid = this.grids.get(item.ref); + if (!grid) { + grid = buildGrid(item.candidate, this.ctx.date, this.ctx.start, this.end); + this.grids.set(item.ref, grid); + } + return grid; + } + + /** + * 그 장소에 도착할 수 있는 가장 이른·늦은 시각 — 최소 머무는 시간만큼 머물 수 있고, 식당은 마지막 주문 30분 전까지. + * 시간 범위 어디에도 머물 수 없으면 null(그날 쉬는 곳 등). + */ + arrivalWindow( + item: Pick, + ): ArrivalWindow | null { + if (this.windows.has(item.ref)) return this.windows.get(item.ref) ?? null; + const grid = this.grid(item); + const { min } = stayRange(item.candidate); + let earliest: number | null = null; + let latest = 0; + for (let t = this.ctx.start; t + min <= this.end; t += TIME_STEP_MINUTES) { + if ( + canStay(grid, t, t + min) && + arrivesBeforeLastOrder(item.candidate, t) + ) { + earliest ??= t; + latest = t; + } + } + const window = earliest === null ? null : { earliest, latest }; + this.windows.set(item.ref, window); + return window; + } + + leg( + from: Pick, + to: Pick, + ): Leg { + const key = `${from.ref}>${to.ref}`; + let leg = this.legs.get(key); + if (!leg) { + leg = estimateLeg(from.candidate, to.candidate, this.ctx.mobility); + this.legs.set(key, leg); + } + return leg; + } + + /** 순서 하나를 따져 볼 수 있으면 센다. 상한에 닿았으면 false */ + takeEvaluation(): boolean { + if (this.evaluations >= MAX_ORDER_EVALUATIONS) return false; + this.evaluations += 1; + return true; + } + + get exhausted(): boolean { + return this.evaluations >= MAX_ORDER_EVALUATIONS; + } +} + +/** AI 가 정한 머무는 시간을 분류 범위와 5분 단위로 맞춘다 */ +export function fitMinutes(c: Candidate, minutes: number): number { + const { min, max } = stayRange(c); + if (!Number.isFinite(minutes)) return min; + const rounded = Math.round(minutes / TIME_STEP_MINUTES) * TIME_STEP_MINUTES; + return Math.min(max, Math.max(min, rounded)); +} + +/** 식당이 마지막 주문 30분 전까지 도착하는가(마지막 주문을 모르면 따지지 않는다) */ +export function arrivesBeforeLastOrder(c: Candidate, arrive: number): boolean { + const lastOrder = c.hours?.lastOrder ?? null; + if (candidateCategory(c) !== 'food' || lastOrder === null) return true; + return arrive <= lastOrder - LAST_ORDER_ARRIVAL_MARGIN_MINUTES; +} + +/** 시간 범위에 걸치는 식사 시간대 */ +export function overlappingMeals( + ctx: Pick, +): Meal[] { + return (Object.keys(MEAL_WINDOWS) as Meal[]).filter( + (meal) => + MEAL_WINDOWS[meal].start < ctx.end && ctx.start < MEAL_WINDOWS[meal].end, + ); +} + +/** 식당마다 시간 범위에 걸치는 식사 시간대 안에 도착하는가(§3.5). 걸치는 시간대가 없으면 따지지 않는다 */ +export function mealsOk( + ctx: Pick, + items: PlanItem[], + timeline: Timeline, +): boolean { + const meals = overlappingMeals(ctx); + if (meals.length === 0) return true; + return items.every( + (item, i) => + candidateCategory(item.candidate) !== 'food' || + meals.some( + (meal) => + timeline.arrive[i] >= MEAL_WINDOWS[meal].start && + timeline.arrive[i] < MEAL_WINDOWS[meal].end, + ), + ); +} + +/** 정한 순서·머무는 시간으로 첫 도착 시각부터 이어 붙인다 */ +export function chain( + engine: PlanEngine, + items: PlanItem[], + minutes: number[], + firstArrive = engine.ctx.start, +): Timeline { + const timeline: Timeline = { arrive: [], leave: [], legs: [] }; + let t = firstArrive; + items.forEach((item, i) => { + timeline.arrive.push(t); + t += minutes[i]; + timeline.leave.push(t); + const next = items[i + 1]; + if (next) { + const leg = engine.leg(item, next); + timeline.legs.push(leg); + t += leg.minutes; + } + }); + return timeline; +} + +/** + * 시간표 검증(§3.6) — 영업(브레이크 포함, 도착부터 떠날 때까지) · 식당 마지막 주문 · 끝 시각 + 10분 · + * 구간 최대 이동 시간 · 반려동물 · 식사 시간대(약한 위반). + */ +export function timelineViolations( + engine: PlanEngine, + items: PlanItem[], + timeline: Timeline, +): Violation[] { + const { ctx } = engine; + const out: Violation[] = []; + let overrun = false; + items.forEach((item, i) => { + const c = item.candidate; + const arrive = timeline.arrive[i]; + const leave = timeline.leave[i]; + if (petExclusion(c, ctx.pet)) out.push({ code: 'pet', ref: item.ref }); + if (leave > engine.end) { + // 끝 시각을 넘긴 첫 장소에만 붙인다(뒤 장소는 모두 넘긴다) + if (!overrun) out.push({ code: 'window', ref: item.ref }); + overrun = true; + } else if (!canStay(engine.grid(item), arrive, leave)) { + out.push({ code: 'closed', ref: item.ref }); + } + if (!arrivesBeforeLastOrder(c, arrive)) { + out.push({ code: 'last_order', ref: item.ref }); + } + const leg = timeline.legs[i]; + if (leg && !isLegWithinLimit(leg, ctx.mobility)) { + out.push({ code: 'leg_too_long', ref: item.ref }); + } + }); + if (!mealsOk(ctx, items, timeline)) out.push({ code: 'meal_time' }); + return out; +} + +/** 머무는 시간 후보 — 범위 안 10분 간격 + 최대 + AI 가 정한 값(오름차순) */ +function durationOptions(item: PlanItem): number[] { + const { min, max } = stayRange(item.candidate); + const options = new Set([item.desired, max]); + for (let d = min; d <= max; d += DURATION_STEP_MINUTES) options.add(d); + return [...options].sort((a, b) => a - b); +} + +/** 시간 범위 어디에도 최소 머무는 시간만큼 머물 수 없는 곳인가(그날 쉬는 곳 등) — 순서를 바꿔도 소용없다 */ +export function isReachable( + engine: PlanEngine, + item: Pick, +): boolean { + return engine.arrivalWindow(item) !== null; +} + +/** + * 동적 계획법 전에 안 되는 순서를 싸게 거른다 — 장소마다 도착할 수 있는 시각 범위만 보고, + * 기다리는 시간은 제한 없이 둔 채 가장 짧게 머물며 이어 본다. 이것도 안 되면 머무는 시간을 어떻게 바꿔도 안 된다. + */ +function mayFit(engine: PlanEngine, items: PlanItem[], legs: Leg[]): boolean { + let t = engine.ctx.start; + for (let i = 0; i < items.length; i++) { + const window = engine.arrivalWindow(items[i]); + if (!window) return false; + t = Math.max(t, window.earliest); + if (t > window.latest) return false; + t += stayRange(items[i].candidate).min + (legs[i]?.minutes ?? 0); + } + return true; +} + +/** + * 정한 순서에서 위반 없는 머무는 시간을 찾는다(§3.6 고치기 2, 5분 단위 동적 계획법). + * 장소마다 분류 범위 안에서 영업 중인 동안만 머물고, 식당은 마지막 주문 전에 도착하고, 끝 시각 + 10분 안에 끝나야 한다. + * 문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦추고, 첫 장소가 늦게 열면 첫 도착을 1시간까지 늦춘다. + * 그중 AI 가 정한 시간과 가장 가까운 것(같으면 일찍 끝나는 것)을 고른다. 없으면 null. + * 순서마다 불리므로(최대 720번) 시각을 시작 시각부터의 5분 칸 번호로 두고 배열로 계산한다. + */ +export function fitTimes( + engine: PlanEngine, + items: PlanItem[], +): { timeline: Timeline; deviation: number } | null { + const { ctx } = engine; + const step = TIME_STEP_MINUTES; + const n = items.length; + if (n === 0) return null; + const legs: Leg[] = []; + for (let i = 0; i + 1 < n; i++) { + const leg = engine.leg(items[i], items[i + 1]); + if (!isLegWithinLimit(leg, ctx.mobility)) return null; + legs.push(leg); + } + if (!mayFit(engine, items, legs)) return null; + // restMin[i] = i 번째에 도착한 뒤 남은 장소를 가장 짧게 돌 때 걸리는 시간(가지치기) + const restMin: number[] = new Array(n + 1).fill(0); + for (let i = n - 1; i >= 0; i--) { + const legAfter = i < n - 1 ? legs[i].minutes : 0; + restMin[i] = stayRange(items[i].candidate).min + legAfter + restMin[i + 1]; + } + if (ctx.start + restMin[0] > engine.end) return null; + + // cost[k] = 이 단계 장소에 (시작 + 5분 × k) 에 도착하는 가장 작은 비용(머무는 시간 변경 + 첫 도착 늦춤, 분) + const slots = Math.floor((engine.end - ctx.start) / step); + let cost = new Float64Array(slots + 1).fill(Infinity); + // 도착할 수 있는 칸의 범위 — 그 밖은 볼 필요가 없다 + let lo = 0; + let hi = 0; + for (let delay = 0; delay <= FIRST_ARRIVAL_DELAY_MAX_MINUTES; delay += step) { + if (ctx.start + delay + restMin[0] > engine.end) break; + cost[delay / step] = delay; + hi = delay / step; + } + // 되짚기용 — 단계 i 에서 다음 칸 k 로 온 앞 칸과 머문 시간 + const fromSlot: Int16Array[] = []; + const stayed: Int16Array[] = []; + + for (let i = 0; i < n; i++) { + const item = items[i]; + const grid = engine.grid(item); + const options = durationOptions(item); + const legAfter = i < n - 1 ? legs[i].minutes : 0; + const after = i < n - 1 ? legAfter + restMin[i + 1] : 0; + const next = new Float64Array(slots + 1).fill(Infinity); + const prev = new Int16Array(slots + 1).fill(-1); + const minutesAt = new Int16Array(slots + 1); + let nextLo = slots; + let nextHi = 0; + let reached = false; + for (let k = lo; k <= hi; k++) { + const base = cost[k]; + if (base === Infinity) continue; + const arrive = ctx.start + k * step; + if (!arrivesBeforeLastOrder(item.candidate, arrive)) continue; + for (const minutes of options) { + const leave = arrive + minutes; + // 더 오래 머물면 더 늦어지고, 닫힌 때도 그대로 지나므로 멈춘다 + if (leave + after > engine.end) break; + if (!canStay(grid, arrive, leave)) break; + const target = (leave + legAfter - ctx.start) / step; + const total = base + Math.abs(minutes - item.desired); + if (total < next[target]) { + next[target] = total; + prev[target] = k; + minutesAt[target] = minutes; + reached = true; + nextLo = Math.min(nextLo, target); + nextHi = Math.max(nextHi, target); + } + } + } + if (!reached) return null; + fromSlot.push(prev); + stayed.push(minutesAt); + cost = next; + lo = nextLo; + hi = nextHi; + } + + // 비용이 가장 작은 칸(같으면 일찍 끝나는 칸) + let best = -1; + for (let k = 0; k <= slots; k++) { + if (cost[k] < (best < 0 ? Infinity : cost[best])) best = k; + } + if (best < 0) return null; + const arrive: number[] = new Array(n); + const leave: number[] = new Array(n); + for (let i = n - 1, k = best; i >= 0; i--) { + const from = fromSlot[i][k]; + arrive[i] = ctx.start + from * step; + leave[i] = arrive[i] + stayed[i][k]; + k = from; + } + return { timeline: { arrive, leave, legs }, deviation: cost[best] }; +} + +/** n 곳의 모든 순서(자리 번호), 앞뒤가 바뀐 쌍이 적은 순 — 한 번 만들어 둔다 */ +const ORDERS_BY_SWAPS = new Map(); + +function ordersBySwaps(n: number): { order: number[]; swaps: number }[] { + const cached = ORDERS_BY_SWAPS.get(n); + if (cached) return cached; + const out: { order: number[]; swaps: number }[] = []; + const index = Array.from({ length: n }, (_, i) => i); + for (;;) { + let swaps = 0; + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) if (index[i] > index[j]) swaps += 1; + } + out.push({ order: [...index], swaps }); + // 다음 순열(사전 순): 뒤에서부터 처음으로 오름차순이 깨지는 곳을 찾아 바꾸고 뒤를 뒤집는다 + let i = n - 2; + while (i >= 0 && index[i] >= index[i + 1]) i -= 1; + if (i < 0) break; + let j = n - 1; + while (index[j] <= index[i]) j -= 1; + [index[i], index[j]] = [index[j], index[i]]; + index.splice(i + 1, n - i - 1, ...index.slice(i + 1).reverse()); + } + out.sort((a, b) => a.swaps - b.swaps); + ORDERS_BY_SWAPS.set(n, out); + return out; +} + +/** 순서·시간 찾기 결과 비교 — 식사 시간대(지켜야 할 때) → 순서 변경 → 이동 → 시간 변경 */ +export function isBetterChoice( + a: OrderChoice, + b: OrderChoice, + keepMeals: boolean, +): boolean { + if (keepMeals && a.mealOk !== b.mealOk) return a.mealOk; + if (a.inversions !== b.inversions) return a.inversions < b.inversions; + if (a.travel !== b.travel) return a.travel < b.travel; + return a.deviation < b.deviation; +} + +/** + * 순서 바꾸기·머무는 시간 조정(§3.6 고치기 1·2)을 함께 찾는다. + * 순서(6곳이면 720가지)마다 위반 없는 머무는 시간을 찾고, 그중 + * ① AI 순서가 식사 시간대를 지켰으면(keepMeals) 계속 지키는 것 ② AI 순서와 가장 비슷한 것(앞뒤가 바뀐 쌍이 적은 것) + * ③ 이동이 짧은 것 ④ 머무는 시간이 덜 바뀐 것을 고른다. 요청에 담긴 순서("레스토랑으로 마무리")를 지키려고 AI 순서를 앞에 둔다. + * AI 순서와 가까운 순서부터 따지므로, 답을 찾으면 앞뒤가 더 많이 바뀐 순서는 보지 않는다. + */ +export function searchOrders( + engine: PlanEngine, + items: PlanItem[], + keepMeals: boolean, +): OrderChoice | null { + const byAi = [...items].sort((a, b) => a.aiIndex - b.aiIndex); + let best: OrderChoice | null = null; + for (const { order, swaps } of ordersBySwaps(byAi.length)) { + // 식사 시간대로 앞설 수 있을 때만 앞뒤가 더 많이 바뀐 순서를 본다 + if (best && swaps > best.inversions && !(keepMeals && !best.mealOk)) break; + if (!engine.takeEvaluation()) break; + const ordered = order.map((i) => byAi[i]); + const fit = fitTimes(engine, ordered); + if (!fit) continue; + const choice: OrderChoice = { + items: ordered, + timeline: fit.timeline, + inversions: swaps, + travel: fit.timeline.legs.reduce((sum, leg) => sum + leg.minutes, 0), + deviation: fit.deviation, + mealOk: mealsOk(engine.ctx, ordered, fit.timeline), + }; + if (!best || isBetterChoice(choice, best, keepMeals)) best = choice; + } + return best; +} + +/** 장소 하나의 경고(§3.7) */ +export function stopWarnings( + engine: PlanEngine, + item: Pick, + arrive: number, + leave: number, +): StopWarning[] { + const { ctx } = engine; + const c = item.candidate; + const warnings: StopWarning[] = []; + if (hasUnknownHours(engine.grid(item), arrive, leave)) { + warnings.push(c.kind === 'event' ? 'event_time_unknown' : 'hours_unknown'); + } + if (ctx.pet && c.petPolicy === 'unknown') warnings.push('pet_unknown'); + if (c.kind === 'event' && c.endDate === ctx.date) { + warnings.push('event_last_day'); + } + return warnings; +} diff --git a/backend/src/modules/course-generation/planner/shortlist.spec.ts b/backend/src/modules/course-generation/planner/shortlist.spec.ts new file mode 100644 index 0000000..060fe2e --- /dev/null +++ b/backend/src/modules/course-generation/planner/shortlist.spec.ts @@ -0,0 +1,94 @@ +import { CENTER, event, place } from './planner.fixtures'; +import type { Candidate } from './planner.types'; +import { shortlistCandidates } from './shortlist'; + +const ctx = { center: CENTER, previousIds: [] as string[] }; + +/** east m 간격으로 늘어선 같은 분류 장소 count 곳 */ +function row( + count: number, + category: Candidate['category'], + start = 100, +): Candidate[] { + return Array.from({ length: count }, (_, i) => + place({ category, east: start + i * 10 }), + ); +} + +describe('shortlistCandidates', () => { + it('1순위(확인한 곳)를 먼저, 그다음 가까운 순으로 두고 P01 부터 번호를 붙인다', () => { + const near = place({ category: 'cafe', east: 100 }); + const far = place({ category: 'food', east: 900 }); + const curated = place({ category: 'cafe', east: 1200, tier: 1 }); + const list = shortlistCandidates([near, far, curated], ctx); + expect(list.map((e) => [e.ref, e.candidate.id])).toEqual([ + ['P01', curated.id], + ['P02', near.id], + ['P03', far.id], + ]); + expect(Math.round(list[1].distanceM)).toBe(100); + }); + + it('한 분류는 10곳까지만 넣는다', () => { + const list = shortlistCandidates( + [...row(12, 'attraction'), ...row(3, 'cafe', 500)], + ctx, + ); + const count = (category: string) => + list.filter((e) => e.candidate.category === category).length; + expect(count('attraction')).toBe(10); + expect(count('cafe')).toBe(3); + }); + + it('행사는 가까운 8개만 E01 부터 넣고, 장소와 합쳐 40곳 이하다', () => { + const events = Array.from({ length: 10 }, (_, i) => + event({ east: 100 + i * 50 }), + ); + const places = [ + ...row(10, 'attraction'), + ...row(10, 'culture'), + ...row(10, 'cafe'), + ...row(10, 'food'), + ...row(10, 'nature'), + ]; + const list = shortlistCandidates([...events, ...places], ctx); + const refs = list.map((e) => e.ref); + expect(list).toHaveLength(40); + expect(refs.filter((r) => r.startsWith('E'))).toEqual([ + 'E01', + 'E02', + 'E03', + 'E04', + 'E05', + 'E06', + 'E07', + 'E08', + ]); + expect(refs.filter((r) => r.startsWith('P'))).toHaveLength(32); + // 가장 먼 행사 둘은 빠진다 + expect(list.some((e) => e.candidate.id === events[9].id)).toBe(false); + }); + + it('다시 만들기 — 이전 장소를 빼도 15곳 이상 남으면 뺀다', () => { + const fresh = row(15, 'attraction'); + const previous = place({ category: 'cafe', east: 10 }); + const list = shortlistCandidates([previous, ...fresh], { + center: CENTER, + previousIds: [previous.id], + }); + expect(list.some((e) => e.candidate.id === previous.id)).toBe(false); + }); + + it('다시 만들기 — 남는 후보가 15곳보다 적으면 이전 장소를 목록 뒤로 보낸다', () => { + const fresh = row(3, 'attraction', 500); + const previous = place({ category: 'attraction', east: 10, tier: 1 }); + const list = shortlistCandidates([previous, ...fresh], { + center: CENTER, + previousIds: [previous.id], + }); + expect(list.map((e) => e.candidate.id)).toEqual([ + ...fresh.map((c) => c.id), + previous.id, + ]); + }); +}); diff --git a/backend/src/modules/course-generation/planner/shortlist.ts b/backend/src/modules/course-generation/planner/shortlist.ts new file mode 100644 index 0000000..fe39c22 --- /dev/null +++ b/backend/src/modules/course-generation/planner/shortlist.ts @@ -0,0 +1,74 @@ +/** + * 후보 줄이기(설계 §3.4) — AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다. + * 장소와 행사를 합쳐 40곳 이하로 줄이고 짧은 번호(P01·E01)를 붙인다. 번호와 id 의 대응은 서버만 가진다. + */ +import { distanceMeters } from '@/shared/utils/geo'; + +import { + REGENERATE_EXCLUDE_MIN_REMAINING, + SHORTLIST_EVENTS_MAX, + SHORTLIST_MAX, + SHORTLIST_PER_CATEGORY, +} from './planner.constants'; +import type { Candidate, PlanContext, ShortlistEntry } from './planner.types'; + +type Entry = Omit; + +/** 순위가 없을 때의 정렬값(1·2순위 뒤) */ +const NO_TIER = 3; + +const byDistance = (a: Entry, b: Entry) => a.distanceM - b.distanceM; +const byTierThenDistance = (a: Entry, b: Entry) => + (a.candidate.tier ?? NO_TIER) - (b.candidate.tier ?? NO_TIER) || + a.distanceM - b.distanceM; + +const refOf = (prefix: 'P' | 'E', index: number) => + `${prefix}${String(index + 1).padStart(2, '0')}`; + +/** + * - 장소: 1순위(확인한 곳)를 먼저, 그다음 가까운 순. 분류마다 최대 10곳 + * - 행사: 가까운 순으로 최대 8개. 장소는 40곳에서 행사 수를 뺀 만큼 + * - 다시 만들기: 이전 결과의 장소를 빼도 15곳 이상 남으면 빼고, 아니면 목록 뒤로 보낸다 + */ +export function shortlistCandidates( + kept: Candidate[], + ctx: Pick, +): ShortlistEntry[] { + const previous = new Set(ctx.previousIds); + const entries: Entry[] = kept.map((candidate) => ({ + candidate, + distanceM: distanceMeters(ctx.center, candidate), + })); + const fresh = entries.filter((e) => !previous.has(e.candidate.id)); + const repeated = + fresh.length >= REGENERATE_EXCLUDE_MIN_REMAINING + ? [] + : entries.filter((e) => previous.has(e.candidate.id)); + + const isEvent = (e: Entry) => e.candidate.kind === 'event'; + const isPlace = (e: Entry) => e.candidate.kind === 'place'; + + const events = [ + ...fresh.filter(isEvent).sort(byDistance), + ...repeated.filter(isEvent).sort(byDistance), + ].slice(0, SHORTLIST_EVENTS_MAX); + + const placeLimit = SHORTLIST_MAX - events.length; + const perCategory = new Map(); + const places: Entry[] = []; + for (const e of [ + ...fresh.filter(isPlace).sort(byTierThenDistance), + ...repeated.filter(isPlace).sort(byTierThenDistance), + ]) { + if (places.length >= placeLimit) break; + const count = perCategory.get(e.candidate.category) ?? 0; + if (count >= SHORTLIST_PER_CATEGORY) continue; + perCategory.set(e.candidate.category, count + 1); + places.push(e); + } + + return [ + ...places.map((e, i) => ({ ref: refOf('P', i), ...e })), + ...events.map((e, i) => ({ ref: refOf('E', i), ...e })), + ]; +} diff --git a/backend/src/modules/course-generation/planner/travel.spec.ts b/backend/src/modules/course-generation/planner/travel.spec.ts new file mode 100644 index 0000000..b0341c9 --- /dev/null +++ b/backend/src/modules/course-generation/planner/travel.spec.ts @@ -0,0 +1,52 @@ +import { CENTER, offset } from './planner.fixtures'; +import { ceilToStep, estimateLeg, isLegWithinLimit } from './travel'; + +describe('ceilToStep', () => { + it('5분 단위로 올리되, 부동소수점 오차로 한 칸 더 올리지 않는다', () => { + expect(ceilToStep(0)).toBe(0); + expect(ceilToStep(0.1)).toBe(5); + expect(ceilToStep(15)).toBe(15); + expect(ceilToStep(15.0000000001)).toBe(15); + expect(ceilToStep(15.01)).toBe(20); + }); +}); + +describe('estimateLeg', () => { + it('걸어서는 직선거리 × 1.3 ÷ 분당 70m 다(700m → 13분 → 15분)', () => { + const leg = estimateLeg(CENTER, offset(700, 0), 'walk'); + expect(leg).toEqual({ minutes: 15, meters: 700, mode: 'walk' }); + expect(isLegWithinLimit(leg, 'walk')).toBe(true); + }); + + it('걸어서 15분을 넘는 구간은 최대 이동 시간을 넘는다(810m → 15.04분 → 20분)', () => { + const leg = estimateLeg(CENTER, offset(810, 0), 'walk'); + expect(leg.minutes).toBe(20); + expect(isLegWithinLimit(leg, 'walk')).toBe(false); + }); + + it('대중교통은 10분 + 직선거리 × 1.4 ÷ 분당 300m 다(3km → 24분 → 25분)', () => { + const leg = estimateLeg(CENTER, offset(0, 3000), 'transit'); + expect(leg).toEqual({ minutes: 25, meters: 3000, mode: 'transit' }); + expect(isLegWithinLimit(leg, 'transit')).toBe(true); + }); + + it('대중교통·자동차라도 가까우면 걷는다', () => { + expect(estimateLeg(CENTER, offset(300, 0), 'transit')).toEqual({ + minutes: 10, + meters: 300, + mode: 'walk', + }); + expect(estimateLeg(CENTER, offset(200, 0), 'car')).toEqual({ + minutes: 5, + meters: 200, + mode: 'walk', + }); + }); + + it('자동차는 5분 + 직선거리 × 1.4 ÷ 분당 400m 이고 30분을 넘으면 너무 멀다(10km → 40분)', () => { + const leg = estimateLeg(CENTER, offset(10_000, 0), 'car'); + expect(leg.mode).toBe('car'); + expect(leg.minutes).toBe(40); + expect(isLegWithinLimit(leg, 'car')).toBe(false); + }); +}); diff --git a/backend/src/modules/course-generation/planner/travel.ts b/backend/src/modules/course-generation/planner/travel.ts new file mode 100644 index 0000000..8105619 --- /dev/null +++ b/backend/src/modules/course-generation/planner/travel.ts @@ -0,0 +1,50 @@ +/** + * 장소 사이 이동 시간 추정(설계 §3.6) — MVP 는 경로 API 없이 직선거리로 계산하고 화면에 '약 N분'으로 보인다. + */ +import { distanceMeters, type LatLng } from '@/shared/utils/geo'; + +import { + MAX_LEG_MINUTES, + type Mobility, + TIME_STEP_MINUTES, + TRAVEL_MODELS, +} from './planner.constants'; +import type { Leg } from './planner.types'; + +/** 부동소수점 오차로 15.000001분이 20분으로 올라가지 않게 빼는 아주 작은 값 */ +const EPSILON = 1e-9; + +/** 시간표 단위(5분)로 올린다 */ +export function ceilToStep(minutes: number, step = TIME_STEP_MINUTES): number { + return Math.max(0, Math.ceil(minutes / step - EPSILON) * step); +} + +function estimateMinutes(meters: number, mode: Mobility): number { + const model = TRAVEL_MODELS[mode]; + return ( + model.fixedMinutes + (meters * model.detourFactor) / model.metersPerMinute + ); +} + +/** + * 두 곳 사이 이동(추정). 시간은 5분 단위로 올린다. + * 대중교통·자동차라도 걷는 편이 빠른 가까운 거리(대중교통 약 700m · 자동차 약 300m 미만)는 걷기로 둔다. + */ +export function estimateLeg(from: LatLng, to: LatLng, mobility: Mobility): Leg { + const meters = distanceMeters(from, to); + let mode = mobility; + let minutes = estimateMinutes(meters, mobility); + if (mobility !== 'walk') { + const walk = estimateMinutes(meters, 'walk'); + if (walk <= minutes) { + mode = 'walk'; + minutes = walk; + } + } + return { minutes: ceilToStep(minutes), meters: Math.round(meters), mode }; +} + +/** 구간이 이동 수단의 최대 시간(걸어서 15분 · 대중교통·자동차 30분) 이하인가 */ +export function isLegWithinLimit(leg: Leg, mobility: Mobility): boolean { + return leg.minutes <= MAX_LEG_MINUTES[mobility]; +} diff --git a/backend/src/shared/utils/geo.spec.ts b/backend/src/shared/utils/geo.spec.ts index 4210aa6..23ab924 100644 --- a/backend/src/shared/utils/geo.spec.ts +++ b/backend/src/shared/utils/geo.spec.ts @@ -1,4 +1,9 @@ -import { isInKorea, isInSido, sidoCodeFromAddress } from './geo'; +import { + distanceMeters, + isInKorea, + isInSido, + sidoCodeFromAddress, +} from './geo'; describe('isInKorea', () => { it('대구 좌표는 한국 안이다', () => { @@ -44,3 +49,23 @@ describe('isInSido', () => { expect(isInSido('26', 0, 0)).toBe(false); }); }); + +describe('distanceMeters', () => { + it('위도 1도는 약 111.2km 다', () => { + expect( + distanceMeters({ lat: 35, lng: 128 }, { lat: 36, lng: 128 }), + ).toBeCloseTo(111_195, -1); + }); + + it('같은 좌표는 0 이고, 순서를 바꿔도 같다', () => { + const downtown = { lat: 35.87021, lng: 128.59409 }; + const suseongmot = { lat: 35.8276, lng: 128.6177 }; + expect(distanceMeters(downtown, downtown)).toBe(0); + expect(distanceMeters(downtown, suseongmot)).toBeCloseTo( + distanceMeters(suseongmot, downtown), + 6, + ); + // 대구 도심 → 수성못 약 5.2km + expect(Math.round(distanceMeters(downtown, suseongmot) / 100)).toBe(52); + }); +}); diff --git a/backend/src/shared/utils/geo.ts b/backend/src/shared/utils/geo.ts index 51b2633..c62ff97 100644 --- a/backend/src/shared/utils/geo.ts +++ b/backend/src/shared/utils/geo.ts @@ -5,6 +5,28 @@ /** 한국 영역 대략값(제주~백령도·독도 포함) */ const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 }; +/** 지구 반지름(m) — 장소 반경 검색 SQL(place.sql.ts 의 sqlDistanceM)과 같은 값 */ +const EARTH_RADIUS_M = 6_371_000; + +export interface LatLng { + lat: number; + lng: number; +} + +/** + * 두 좌표 사이 직선거리(m) — 하버사인. 장소 반경 검색 SQL 과 같은 식이라 결과가 맞는다. + * 부동소수점 오차로 asin 범위를 넘지 않게 1 로 자른다. + */ +export function distanceMeters(a: LatLng, b: LatLng): number { + const rad = (deg: number) => (deg * Math.PI) / 180; + const h = + Math.sin(rad(b.lat - a.lat) / 2) ** 2 + + Math.cos(rad(a.lat)) * + Math.cos(rad(b.lat)) * + Math.sin(rad(b.lng - a.lng) / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h))); +} + /** * 위도·경도가 한국 안의 정상 좌표인지 본다. * 공공데이터에는 좌표가 0 이거나 위도·경도가 뒤바뀐 항목이 섞여 있다. diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json index 64f86c6..e21627b 100644 --- a/backend/tsconfig.build.json +++ b/backend/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.fixtures.ts"] } diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md index 720bfb6..2ee4cb2 100644 --- a/docs/design/ai-course.md +++ b/docs/design/ai-course.md @@ -1,7 +1,7 @@ # AI 코스 생성 설계 (v0.2, 2026-11-05) 사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다. -아직 코드는 없다. 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다. +코드는 §14 순서로 만들고 있다(2026-11-06 기준 2단계 순수 로직까지). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다. §13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다. > v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다. @@ -134,6 +134,7 @@ | 비 | 거르지 않는다. 강수확률이 40% 이상이고 `indoorIfRain` 이면 AI 에 '실내 위주'를 알리고, 검증에서 실내 비율 50% 이상을 본다. 실내 후보가 모자라 채울 수 없으면 코스는 내고 `notices` 에 알린다 | - **실내·실외**: 큐레이션 `setting` 을 쓰고, 없으면 분류로 추정한다(문화시설·식당·카페·쇼핑과 전시는 실내, 자연은 실외, 관광지·레저·축제·행사는 미확인). + 실내·외가 함께 있는 곳(`both`)은 실내로 본다. 비를 피할 수 있고, 반려동물은 실내 규칙을 따라야 해서다. - **날씨**: 기상청 단기예보(동네예보 격자). 오늘부터 3일 뒤까지 나오고, 17시 이후 발표분은 4일 뒤까지 나온다. 그 뒤 날짜는 `weather: null` 이다. 입력 화면에 "예보 없음"을 보여 주고 비 조건은 쓰지 않는다. 격자·발표 시각별로 1시간 캐시한다. 2026-10-29 점검 스크립트로 이미 불러 본 API 다(`docs/research/data-check/`). @@ -144,7 +145,8 @@ AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다. - 1순위(확인한 곳)를 먼저, 그다음 거리순으로 둔다. - 분류마다 최대 10곳이다. 한 분류(예: 관광지)가 목록을 다 채우지 않게 한다. -- 행사는 가까운 순서로 최대 8개다. +- 행사는 가까운 순서로 최대 8개다. 장소와 행사를 합쳐 40곳 이하라, 장소는 40곳에서 행사 수를 뺀 만큼 넣는다. +- 다시 만들기 규칙(§3.3, 이전 장소를 빼거나 뒤로 보내기)도 여기서 적용한다. - AI 에는 장소 id(uuid) 대신 짧은 번호(P01~P40, E01~E08)를 준다. 번호와 id 의 대응은 서버만 가진다. ### 3.5 AI 호출 @@ -205,9 +207,12 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: - 화면에는 "약 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` 와 같은 항목이다. 하나라도 어기면 고친다. @@ -219,17 +224,28 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: | 시간 범위 | 마지막 장소가 끝 시각 + 10분 안에 끝난다 | | 이동 | 구간마다 위 표의 최대 시간 이하다 | | 반려동물 | §3.3 규칙을 지킨다. 미확인 곳에는 경고를 붙인다 | -| 실내 비율 | 비 조건이면 50% 이상이다. 실내 후보가 모자라면 위반으로 고치지 않고 `notices` 로 알린다 | -| 다양성 | 후보가 허락하면 서로 다른 분류가 2개 이상이다 | +| 실내 비율 | 비 조건이면 50% 이상이다. 실외 장소를 실내 바꾸기 후보로 바꿔 보고, 실내 후보가 모자라면 위반으로 고치지 않고 `notices` 로 알린다 | +| 다양성 | 후보가 허락하면 서로 다른 분류가 2개 이상이다. 고치지 않고 약한 위반으로 남긴다('카페 투어'처럼 일부러 한 분류만 고를 수 있다) | +| 식사 시간대 | 시간 범위가 식사 시간대에 걸치면 식당은 그 시간대 안에 도착한다. 약한 위반이다 — 이것만 어긋나면 고치지 않고, 고칠 때 나빠지지 않게만 본다 | **고치는 순서** -1. **순서 바꾸기**: 모든 순서(6곳이면 720가지)를 따져, 위반이 없고 이동이 짧으며 AI 가 정한 순서와 가장 비슷한 것을 고른다. 식사 시간대 규칙을 지킨다. -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 를 한 번 더 부른다(제한 시간이 남았을 때만). +4. **AI 재호출**: 그래도 안 되면 위반 목록을 붙여 AI 를 한 번 더 부른다(제한 시간이 남았을 때만). 생성 작업(§14 4단계)이 한다. 5. **결과**: 2곳 미만이면 `failed` + `AI_003` 이다. AI 가 남긴 이유는 `failure.notices` 로 함께 준다("대형견 동반이 확인된 실내 전시가 없어요"). - 장소를 줄였으면 `notices` 에 이유를 남기고 `partial` 로 낸다. + 장소를 줄였으면 `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 결과 @@ -241,8 +257,8 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: | `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 호출 없이 바꾼다 | +| `stops[]` | `kind`(place · event), `placeId` / `eventId`, 이름, 분류, 사진(라이선스 포함), `arrive` · `leave` · `minutes`, `reason`(AI), `facts`(그날 영업시간과 출처·확인일, 반려 조건과 확인일, 실내외, 행사 기간), `warnings`(hours_unknown · pet_unknown · event_time_unknown · event_last_day — 그날이 행사 마지막 날), `legToNext`(분 · 이동 수단 · 추정 여부) | +| `alternates[]` | 바꿀 장소 번호와 stop 과 같은 필드. **같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다** — 앱이 AI 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 | | `sources` | 출처 문구(한국관광공사 TourAPI · 공공누리 유형, 기상청 예보 발표 시각, KOPIS 등) | - 결과의 사실 값은 생성 시점의 스냅샷이다. 저장한 코스는 그 뒤로 최신 장소 값을 읽는다(코스 설계에서 정한다). @@ -432,7 +448,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다. ## 14. 구현 순서 1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정 -2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3, 2026-11-06 반영) → 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요) +2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝) 3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로) 4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다) 5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석