generated from ttipo/project-base
feat(backend): AI 코스 시간표를 짜고 검증·고치는 순수 로직 추가 #11
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<PlanContext, 'end'>): 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<ExcludeReason, number>>;
|
||||
}
|
||||
|
||||
/** 그날 진행하는 행사인가('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<Record<ExcludeReason, number>> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Candidate>,
|
||||
): { 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<string, Candidate>,
|
||||
items: PlanItem[],
|
||||
): Map<string, PlanItem> {
|
||||
const out = new Map<string, PlanItem>();
|
||||
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<string, PlanItem>;
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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<Mobility, number> = {
|
||||
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<Mobility, TravelModel> = {
|
||||
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<Mobility, number> = {
|
||||
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<PlaceCategory, 'lodging'> | 'event';
|
||||
|
||||
export interface StayRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분류별 머무는 시간(분) — AI 가 정한 값을 이 범위로 맞춘다.
|
||||
* 쇼핑·술집·기타는 설계 표에 없어 정했다(1순위 큐레이션 장소만 후보가 된다).
|
||||
*/
|
||||
export const STAY_MINUTES: Record<CandidateCategory, StayRange> = {
|
||||
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;
|
||||
@@ -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<Candidate> {
|
||||
/** 영업시간 원문(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> = {}): PlanContext {
|
||||
return {
|
||||
date: SAT,
|
||||
start: at(14),
|
||||
end: at(20),
|
||||
mobility: 'walk',
|
||||
center: CENTER,
|
||||
pet: null,
|
||||
holiday: false,
|
||||
preferIndoor: false,
|
||||
previousIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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<PlanContext> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, AvailabilityGrid>();
|
||||
private readonly legs = new Map<string, Leg>();
|
||||
private readonly windows = new Map<string, ArrivalWindow | null>();
|
||||
private evaluations = 0;
|
||||
|
||||
constructor(readonly ctx: PlanContext) {
|
||||
this.end = scheduleEnd(ctx);
|
||||
}
|
||||
|
||||
grid(item: Pick<PlanItem, 'ref' | 'candidate'>): 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<PlanItem, 'ref' | 'candidate'>,
|
||||
): 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<PlanItem, 'ref' | 'candidate'>,
|
||||
to: Pick<PlanItem, 'ref' | 'candidate'>,
|
||||
): 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<PlanContext, 'start' | 'end'>,
|
||||
): 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<PlanContext, 'start' | 'end'>,
|
||||
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<number>([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<PlanItem, 'ref' | 'candidate'>,
|
||||
): 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<number>(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<number>(n);
|
||||
const leave: number[] = new Array<number>(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<number, { order: number[]; swaps: number }[]>();
|
||||
|
||||
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<PlanItem, 'ref' | 'candidate'>,
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<ShortlistEntry, 'ref'>;
|
||||
|
||||
/** 순위가 없을 때의 정렬값(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<PlanContext, 'center' | 'previousIds'>,
|
||||
): 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<string, number>();
|
||||
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 })),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 이거나 위도·경도가 뒤바뀐 항목이 섞여 있다.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.fixtures.ts"]
|
||||
}
|
||||
|
||||
+27
-11
@@ -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, 조건 해석
|
||||
|
||||
Reference in New Issue
Block a user