generated from ttipo/project-base
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed3411abb | |||
| 09c8decb15 | |||
| 187a99ca7f | |||
| d2cd5cd96e | |||
| f752bf28f5 | |||
| 542732257c |
@@ -27,7 +27,8 @@
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"migration:show": "npm run typeorm -- migration:show",
|
||||
"place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
|
||||
"place:sync:prod": "node dist/commands/place-sync.js"
|
||||
"place:sync:prod": "node dist/commands/place-sync.js",
|
||||
"weather:check": "ts-node -r tsconfig-paths/register src/commands/weather-check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { CourseGenerationModule } from '@/modules/course-generation/course-generation.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { PlaceModule } from '@/modules/place/place.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
@@ -60,6 +61,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
}),
|
||||
HealthModule,
|
||||
PlaceModule,
|
||||
CourseGenerationModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { WeatherModule } from '@/modules/weather/weather.module';
|
||||
import { validateEnv } from '@/shared/config/env.validation';
|
||||
|
||||
/**
|
||||
* 날씨 확인 CLI 전용 루트 모듈 — HTTP·DB 없이 설정·캐시·날씨만 올린다.
|
||||
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
CacheModule.register({ isGlobal: true }),
|
||||
WeatherModule,
|
||||
],
|
||||
})
|
||||
export class WeatherCheckCliModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
|
||||
import { WeatherService } from '@/modules/weather/weather.service';
|
||||
import { toKmaGrid } from '@/modules/weather/kma-grid';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import { addDaysIso } from '@/shared/utils/iso-date';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
import { WeatherCheckCliModule } from './weather-check.cli.module';
|
||||
|
||||
/**
|
||||
* 기상청 단기예보 실제 호출 확인 (설계: docs/design/ai-course.md §3.3).
|
||||
* 공공데이터포털 키(PUBLIC_DATA_SERVICE_KEY)를 쓰므로 키가 있는 환경(개발 컨테이너)에서 실행한다. 호출은 1~2번이다.
|
||||
*
|
||||
* docker exec wandergo-backend npm run weather:check -- [--lat=35.87021] [--lng=128.59409] [--start=10:00] [--end=20:00]
|
||||
*/
|
||||
const USAGE = `사용법: weather:check [옵션]
|
||||
오늘부터 4일 뒤까지, 날마다 시간 범위의 강수확률·하늘·기온을 보여 준다(예보가 닿지 않는 날은 '예보 없음')
|
||||
--lat=35.87021 --lng=128.59409 위치 (기본: 대구 도심)
|
||||
--start=10:00 --end=20:00 시간 범위 (기본 10:00~20:00)`;
|
||||
|
||||
/** 기본 위치 — 대구 도심(앱 동네 'downtown' 의 중심) */
|
||||
const DEFAULT_POINT = { lat: 35.87021, lng: 128.59409 };
|
||||
const DEFAULT_START = '10:00';
|
||||
const DEFAULT_END = '20:00';
|
||||
/** 오늘부터 며칠 뒤까지 보여 줄지 — 단기예보는 길어야 4일 뒤까지다 */
|
||||
const DAYS_TO_SHOW = 5;
|
||||
|
||||
function option(argv: string[], name: string): string | undefined {
|
||||
return argv
|
||||
.find((a) => a.startsWith(`--${name}=`))
|
||||
?.slice(name.length + 3)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toMinutes(hhmm: string): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm);
|
||||
if (!m) return null;
|
||||
const minutes = Number(m[1]) * 60 + Number(m[2]);
|
||||
return minutes <= 1440 ? minutes : null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const lat = Number(option(argv, 'lat') ?? DEFAULT_POINT.lat);
|
||||
const lng = Number(option(argv, 'lng') ?? DEFAULT_POINT.lng);
|
||||
const start = toMinutes(option(argv, 'start') ?? DEFAULT_START);
|
||||
const end = toMinutes(option(argv, 'end') ?? DEFAULT_END);
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng) ||
|
||||
start === null ||
|
||||
end === null ||
|
||||
end <= start
|
||||
) {
|
||||
Logger.error(USAGE, 'WeatherCheck');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await NestFactory.createApplicationContext(
|
||||
WeatherCheckCliModule,
|
||||
{
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
},
|
||||
);
|
||||
try {
|
||||
const weather = app.get(WeatherService);
|
||||
const now = new Date();
|
||||
const grid = toKmaGrid(lat, lng);
|
||||
Logger.log(`격자 ${grid.nx},${grid.ny}`, 'WeatherCheck');
|
||||
let found = 0;
|
||||
for (let day = 0; day < DAYS_TO_SHOW; day++) {
|
||||
const date = addDaysIso(kstDateString(now), day) ?? kstDateString(now);
|
||||
const forecast = await weather.forecastWindow(
|
||||
{ lat, lng },
|
||||
date,
|
||||
start,
|
||||
end,
|
||||
now,
|
||||
);
|
||||
if (!forecast) {
|
||||
Logger.log(`${date} 예보 없음`, 'WeatherCheck');
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
Logger.log(
|
||||
`${date} 강수확률 ${forecast.precipitationProbability}% (${forecast.precipitationType}) · ` +
|
||||
`하늘 ${forecast.sky ?? '미확인'} · 기온 ${forecast.temperatureMin ?? '?'}~${forecast.temperatureMax ?? '?'}℃ · ` +
|
||||
`발표 ${forecast.issuedAt}`,
|
||||
'WeatherCheck',
|
||||
);
|
||||
}
|
||||
if (found === 0) {
|
||||
// 키 없음·호출 실패면 위 경고 로그에 이유가 있다
|
||||
Logger.error('예보를 하나도 받지 못했다', 'WeatherCheck');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
|
||||
Logger.error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'WeatherCheck',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -73,6 +73,29 @@ const DOMAIN_ERROR_CODES = {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
},
|
||||
// AI 코스 생성 (docs/design/ai-course.md §7)
|
||||
AI_001: {
|
||||
// 하루 횟수를 다 썼다(실패·취소를 포함한 시도 상한도 같다)
|
||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message:
|
||||
'오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
|
||||
},
|
||||
AI_002: {
|
||||
// AI 를 쓸 수 없다 — 장애 · 제한 시간 초과 · 하루 비용 상한 · 키 없음
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
AI_003: {
|
||||
// 조건에 맞는 장소가 부족해 코스를 만들지 못했다(이유는 failure.notices 로 함께 준다)
|
||||
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
message:
|
||||
'조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
|
||||
},
|
||||
AI_004: {
|
||||
// 같은 기기·사용자가 이미 만드는 중이다
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* AI 코스 생성 기록(설계 docs/design/ai-course.md §6.1) — 진행 상태·결과 초안·하루 횟수·비용·품질 지표.
|
||||
* 하루 사용량과 비용은 이 테이블에서 센다(집계 테이블은 따로 두지 않는다).
|
||||
*/
|
||||
export class AddAiCourseGeneration1794127708706 implements MigrationInterface {
|
||||
name = 'AddAiCourseGeneration1794127708706';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ai_course_generation" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "user_id" uuid, "device_hash" text NOT NULL, "lang" text NOT NULL DEFAULT 'ko', "request_text" text, "conditions" jsonb NOT NULL, "status" text NOT NULL, "stage" text, "found_count" integer, "kept_count" integer, "result" jsonb, "failure_code" text, "failure_notices" jsonb, "counted" boolean NOT NULL DEFAULT false, "previous_id" uuid, "model" text, "prompt_version" text, "input_tokens" integer NOT NULL DEFAULT '0', "output_tokens" integer NOT NULL DEFAULT '0', "cache_read_tokens" integer NOT NULL DEFAULT '0', "cache_write_tokens" integer NOT NULL DEFAULT '0', "cost_usd" double precision NOT NULL DEFAULT '0', "latency_ms" integer, "llm_calls" integer NOT NULL DEFAULT '0', "repairs" jsonb NOT NULL DEFAULT '[]', "rating" text, "rated_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "started_at" TIMESTAMP WITH TIME ZONE, "finished_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "ck_ai_course_generation_rating" CHECK ("rating" IS NULL OR "rating" IN ('like', 'dislike')), CONSTRAINT "ck_ai_course_generation_failure_code" CHECK ("failure_code" IS NULL OR "failure_code" IN ('AI_002', 'AI_003')), CONSTRAINT "ck_ai_course_generation_lang" CHECK ("lang" IN ('ko', 'en')), CONSTRAINT "ck_ai_course_generation_stage" CHECK ("stage" IS NULL OR "stage" IN ('searching', 'filtering', 'planning', 'finishing')), CONSTRAINT "ck_ai_course_generation_status" CHECK ("status" IN ('queued', 'running', 'succeeded', 'failed', 'canceled')), CONSTRAINT "PK_103a7aa94bb0747a58962c4dbf9" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "ix_ai_course_generation_status" ON "ai_course_generation" ("status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "ix_ai_course_generation_user" ON "ai_course_generation" ("user_id", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "ix_ai_course_generation_device" ON "ai_course_generation" ("device_hash", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."ix_ai_course_generation_device"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."ix_ai_course_generation_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."ix_ai_course_generation_status"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ai_course_generation"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* AI 코스 생성이 부르는 AI 창구(설계 §2·§3.5). 4단계는 가짜 AI(FakeCourseAiClient)를 쓰고,
|
||||
* 5단계에서 Anthropic 클라이언트를 붙인다. 테스트는 계속 가짜를 쓴다.
|
||||
*/
|
||||
import type { WindowForecast } from '@/modules/weather/kma-forecast';
|
||||
|
||||
import type { CourseLang } from '../course-generation.constants';
|
||||
import type {
|
||||
CourseConditions,
|
||||
ResolvedConditions,
|
||||
} from '../course-generation.types';
|
||||
import type {
|
||||
CourseDraft,
|
||||
ShortlistEntry,
|
||||
Violation,
|
||||
} from '../planner/planner.types';
|
||||
|
||||
export const COURSE_AI_CLIENT = Symbol('COURSE_AI_CLIENT');
|
||||
|
||||
/** 호출 한 번의 사용량 — 하루 비용 상한과 품질 지표(ai_course_generation)에 쓴다 */
|
||||
export interface AiUsage {
|
||||
model: string;
|
||||
promptVersion: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
export interface CourseDraftInput {
|
||||
lang: CourseLang;
|
||||
/** 사용자가 쓴 문장(전화번호·이메일 모양은 가렸다) */
|
||||
requestText: string;
|
||||
resolved: ResolvedConditions;
|
||||
/** 비 조건(강수확률 40% 이상 + 비 오면 실내) — AI 에 '실내 위주'를 알린다 */
|
||||
preferIndoor: boolean;
|
||||
weather: WindowForecast | null;
|
||||
/** 그날이 공휴일이면 이름 */
|
||||
holidayName: string | null;
|
||||
/** 짧은 번호(P01·E01)를 붙인 후보 — AI 는 이 번호로만 고른다 */
|
||||
shortlist: ShortlistEntry[];
|
||||
/** 다시 부를 때 붙이는 위반 목록(고치기 4) */
|
||||
violations: Violation[];
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CourseDraftOutput {
|
||||
/** 코스 이름(20자 이내) · 요약(120자 이내) */
|
||||
title: string;
|
||||
summary: string;
|
||||
draft: CourseDraft;
|
||||
usage: AiUsage;
|
||||
}
|
||||
|
||||
/** 조건 해석이 되묻거나 알릴 것(§2) */
|
||||
export const CONDITION_ISSUES = [
|
||||
'unsupported_area',
|
||||
'past_date',
|
||||
'date_out_of_range',
|
||||
'multi_day',
|
||||
'too_short',
|
||||
'past_midnight',
|
||||
'unclear_request',
|
||||
] as const;
|
||||
export type ConditionIssue = (typeof CONDITION_ISSUES)[number];
|
||||
|
||||
export interface ConditionParseInput {
|
||||
text: string;
|
||||
lang: CourseLang;
|
||||
/** 오늘 한국 날짜 'YYYY-MM-DD' 와 지금 시각(분) — AI 에는 날짜·요일만 보낸다(§2) */
|
||||
today: string;
|
||||
nowMinutes: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ConditionParseOutput {
|
||||
/** 문장에서 뽑은 조건 — 없는 항목은 기본값을 쓴다 */
|
||||
extracted: Partial<CourseConditions>;
|
||||
inferred: (keyof CourseConditions)[];
|
||||
issues: ConditionIssue[];
|
||||
usage: AiUsage;
|
||||
}
|
||||
|
||||
export interface CourseAiClient {
|
||||
/** 후보 목록에서 장소·순서·머무는 시간·이유를 고른다 */
|
||||
draftCourse(input: CourseDraftInput): Promise<CourseDraftOutput>;
|
||||
/** 문장에서 지역·날짜·시간 같은 조건을 뽑는다(작은 모델) */
|
||||
parseConditions(input: ConditionParseInput): Promise<ConditionParseOutput>;
|
||||
}
|
||||
|
||||
/** AI 를 쓸 수 없음 — 장애 · 제한 시간 초과 · 키 없음 → AI_002 */
|
||||
export class AiUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AiUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ResolvedConditions } from '../course-generation.types';
|
||||
import { CENTER, event, place } from '../planner/planner.fixtures';
|
||||
import type { Candidate, ShortlistEntry } from '../planner/planner.types';
|
||||
import { FakeCourseAiClient } from './fake-course-ai.client';
|
||||
|
||||
function shortlist(...candidates: Candidate[]): ShortlistEntry[] {
|
||||
return candidates.map((candidate, i) => ({
|
||||
ref: `P${String(i + 1).padStart(2, '0')}`,
|
||||
candidate,
|
||||
distanceM: i * 100,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolved(start: number, end: number): ResolvedConditions {
|
||||
return {
|
||||
conditions: {
|
||||
area: { type: 'neighborhood', key: 'downtown' },
|
||||
date: '2026-11-14',
|
||||
start: '13:00',
|
||||
end: '19:00',
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
},
|
||||
center: CENTER,
|
||||
placeName: null,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
const input = (list: ShortlistEntry[], start = 13 * 60, end = 19 * 60) => ({
|
||||
lang: 'ko' as const,
|
||||
requestText: '',
|
||||
resolved: resolved(start, end),
|
||||
preferIndoor: false,
|
||||
weather: null,
|
||||
holidayName: null,
|
||||
shortlist: list,
|
||||
violations: [],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
describe('FakeCourseAiClient.draftCourse', () => {
|
||||
const ai = new FakeCourseAiClient();
|
||||
|
||||
it('1.5시간당 1곳을, 분류가 겹치지 않게 목록 앞쪽부터 고른다', async () => {
|
||||
const list = shortlist(
|
||||
place({ category: 'cafe' }),
|
||||
place({ category: 'cafe' }),
|
||||
place({ category: 'culture' }),
|
||||
place({ category: 'nature' }),
|
||||
place({ category: 'food' }),
|
||||
place({ category: 'food' }),
|
||||
);
|
||||
// 6시간 → 4곳
|
||||
const out = await ai.draftCourse(input(list));
|
||||
expect(out.draft.stops.map((s) => s.ref)).toEqual([
|
||||
'P01',
|
||||
'P03',
|
||||
'P04',
|
||||
'P05',
|
||||
]);
|
||||
// 같은 분류의 다음 후보를 바꾸기 후보로 둔다
|
||||
expect(out.draft.alternates).toEqual([
|
||||
expect.objectContaining({ forStop: 1, ref: 'P02' }),
|
||||
expect.objectContaining({ forStop: 4, ref: 'P06' }),
|
||||
]);
|
||||
expect(out.usage).toMatchObject({ model: 'fake', costUsd: 0 });
|
||||
});
|
||||
|
||||
it('최소 2곳·최대 6곳이고, 분류가 모자라면 같은 분류로 채운다', async () => {
|
||||
const cafes = shortlist(
|
||||
...Array.from({ length: 8 }, () => place({ category: 'cafe' })),
|
||||
);
|
||||
const short = await ai.draftCourse(input(cafes, 13 * 60, 15 * 60));
|
||||
expect(short.draft.stops).toHaveLength(2);
|
||||
const long = await ai.draftCourse(input(cafes, 10 * 60, 22 * 60));
|
||||
expect(long.draft.stops).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('머무는 시간은 분류 최소 + 20분이다(행사 60–150 → 80)', async () => {
|
||||
const out = await ai.draftCourse(
|
||||
input(shortlist(event(), place({ category: 'cafe' }))),
|
||||
);
|
||||
expect(out.draft.stops.map((s) => s.minutes)).toEqual([80, 60]);
|
||||
});
|
||||
|
||||
it('끊긴 호출은 던진다', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('취소'));
|
||||
await expect(
|
||||
ai.draftCourse({
|
||||
...input(shortlist(place())),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow('취소');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FakeCourseAiClient.parseConditions', () => {
|
||||
it('문장을 읽지 않고 빈 조건을 돌려준다(기본값만 채운다)', async () => {
|
||||
const out = await new FakeCourseAiClient().parseConditions({
|
||||
text: '비 오는 토요일 수성못',
|
||||
lang: 'ko',
|
||||
today: '2026-11-08',
|
||||
nowMinutes: 600,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(out).toMatchObject({ extracted: {}, inferred: [], issues: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
candidateCategory,
|
||||
diversityKey,
|
||||
stayRange,
|
||||
} from '../planner/candidate';
|
||||
import { STOPS_MAX, STOPS_MIN } from '../planner/planner.constants';
|
||||
import type {
|
||||
Candidate,
|
||||
DraftAlternate,
|
||||
DraftStop,
|
||||
ShortlistEntry,
|
||||
} from '../planner/planner.types';
|
||||
import type {
|
||||
AiUsage,
|
||||
ConditionParseInput,
|
||||
ConditionParseOutput,
|
||||
CourseAiClient,
|
||||
CourseDraftInput,
|
||||
CourseDraftOutput,
|
||||
} from './course-ai.client';
|
||||
|
||||
export const FAKE_MODEL = 'fake';
|
||||
export const FAKE_PROMPT_VERSION = 'fake-1';
|
||||
/** 1.5시간당 1곳(설계 §3.6) */
|
||||
const MINUTES_PER_STOP = 90;
|
||||
/** 머무는 시간 — 분류 최소 + 이만큼(범위 안) */
|
||||
const EXTRA_STAY_MINUTES = 20;
|
||||
|
||||
const ZERO_USAGE: AiUsage = {
|
||||
model: FAKE_MODEL,
|
||||
promptVersion: FAKE_PROMPT_VERSION,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
|
||||
/** 끊긴 호출이면 그 이유 — 호출부가 늘 거부된 Promise 를 받도록 던지지 않고 돌려준다 */
|
||||
function abortReason(signal: AbortSignal): Error | null {
|
||||
if (!signal.aborted) return null;
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason));
|
||||
}
|
||||
|
||||
function stayMinutes(c: Candidate): number {
|
||||
const { min, max } = stayRange(c);
|
||||
return Math.min(max, min + EXTRA_STAY_MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가짜 AI(개발·테스트용) — 키 없이 생성 흐름을 끝까지 돌린다. 비용은 0 이다.
|
||||
* 후보 목록 앞쪽(1순위 → 가까운 순)에서 분류가 겹치지 않게 1.5시간당 1곳을 고르고,
|
||||
* 같은 분류의 다음 후보를 바꾸기 후보로 둔다. 순서·시간 검증과 고치기는 실제 로직(planCourse)이 한다.
|
||||
* 문장은 읽지 않는다 — 조건 해석은 기본값만 채운다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FakeCourseAiClient implements CourseAiClient {
|
||||
draftCourse(input: CourseDraftInput): Promise<CourseDraftOutput> {
|
||||
const aborted = abortReason(input.signal);
|
||||
if (aborted) return Promise.reject(aborted);
|
||||
const { resolved, shortlist, lang } = input;
|
||||
const target = Math.min(
|
||||
STOPS_MAX,
|
||||
Math.max(
|
||||
STOPS_MIN,
|
||||
Math.round((resolved.end - resolved.start) / MINUTES_PER_STOP),
|
||||
),
|
||||
);
|
||||
const picked: ShortlistEntry[] = [];
|
||||
const kinds = new Set<string>();
|
||||
for (const entry of shortlist) {
|
||||
if (picked.length >= target) break;
|
||||
const kind = diversityKey(entry.candidate);
|
||||
if (kinds.has(kind)) continue;
|
||||
picked.push(entry);
|
||||
kinds.add(kind);
|
||||
}
|
||||
for (const entry of shortlist) {
|
||||
if (picked.length >= target) break;
|
||||
if (!picked.includes(entry)) picked.push(entry);
|
||||
}
|
||||
|
||||
const reason = (e: ShortlistEntry) =>
|
||||
lang === 'en'
|
||||
? `Picked by the development fake AI (${candidateCategory(e.candidate)})`
|
||||
: `개발용 가짜 AI 가 고른 곳(${candidateCategory(e.candidate)})`;
|
||||
const stops: DraftStop[] = picked.map((e) => ({
|
||||
ref: e.ref,
|
||||
minutes: stayMinutes(e.candidate),
|
||||
reason: reason(e),
|
||||
}));
|
||||
const alternates: DraftAlternate[] = [];
|
||||
picked.forEach((e, i) => {
|
||||
const alt = shortlist.find(
|
||||
(other) =>
|
||||
!picked.includes(other) &&
|
||||
!alternates.some((a) => a.ref === other.ref) &&
|
||||
diversityKey(other.candidate) === diversityKey(e.candidate),
|
||||
);
|
||||
if (alt) {
|
||||
alternates.push({ forStop: i + 1, ref: alt.ref, reason: reason(alt) });
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve({
|
||||
title: lang === 'en' ? 'Fake AI course' : '가짜 AI 코스',
|
||||
summary:
|
||||
lang === 'en'
|
||||
? 'A development course picked from the top of the candidate list.'
|
||||
: '개발용 가짜 AI 가 후보 목록 앞쪽에서 고른 코스예요.',
|
||||
draft: { stops, alternates, notices: [] },
|
||||
usage: ZERO_USAGE,
|
||||
});
|
||||
}
|
||||
|
||||
parseConditions(input: ConditionParseInput): Promise<ConditionParseOutput> {
|
||||
const aborted = abortReason(input.signal);
|
||||
if (aborted) return Promise.reject(aborted);
|
||||
return Promise.resolve({
|
||||
extracted: {},
|
||||
inferred: [],
|
||||
issues: [],
|
||||
usage: ZERO_USAGE,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { PlaceListing } from '@/modules/place/entities/place-listing.entity';
|
||||
import { isInSido, type LatLng } from '@/shared/utils/geo';
|
||||
|
||||
import { NEIGHBORHOODS, SUPPORTED_SIDO } from './course-generation.constants';
|
||||
import type { CourseArea } from './course-generation.types';
|
||||
|
||||
export interface AreaCenter {
|
||||
center: LatLng;
|
||||
/** 지역이 기준 장소면 그 이름 */
|
||||
placeName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지역 → 중심 좌표(설계 §2). 동네는 앱과 같은 좌표, 현재 위치는 그대로, 기준 장소는 장소 DB 의 좌표다.
|
||||
* 대구 밖이거나 찾을 수 없는 장소면 VAL_001 — 조건 해석이 먼저 걸러 주므로 앱이 고친 값만 온다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseAreaService {
|
||||
constructor(
|
||||
@InjectRepository(PlaceListing)
|
||||
private readonly listings: Repository<PlaceListing>,
|
||||
) {}
|
||||
|
||||
async resolve(area: CourseArea): Promise<AreaCenter> {
|
||||
if (area.type === 'neighborhood') {
|
||||
return { center: NEIGHBORHOODS[area.key], placeName: null };
|
||||
}
|
||||
if (area.type === 'point') {
|
||||
this.assertSupported(area.lat, area.lng);
|
||||
return { center: { lat: area.lat, lng: area.lng }, placeName: null };
|
||||
}
|
||||
const listing = await this.listings.findOneBy({ id: area.placeId });
|
||||
if (!listing?.isSearchable) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'area_place_not_found', placeId: area.placeId },
|
||||
});
|
||||
}
|
||||
this.assertSupported(listing.lat, listing.lng);
|
||||
return {
|
||||
center: { lat: listing.lat, lng: listing.lng },
|
||||
placeName: listing.name,
|
||||
};
|
||||
}
|
||||
|
||||
private assertSupported(lat: number, lng: number): void {
|
||||
if (!isInSido(SUPPORTED_SIDO, lat, lng)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'unsupported_area' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PlaceListing,
|
||||
type ValueOrigin,
|
||||
} from '@/modules/place/entities/place-listing.entity';
|
||||
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
|
||||
import type {
|
||||
EventSource,
|
||||
ImageLicense,
|
||||
PlaceSource,
|
||||
} from '@/modules/place/place.constants';
|
||||
import {
|
||||
boundingBox,
|
||||
sqlDistanceM,
|
||||
sqlEventWithinMaxSpan,
|
||||
} from '@/modules/place/place.sql';
|
||||
import type { LatLng } from '@/shared/utils/geo';
|
||||
import { parseOpeningHours } from '@/shared/utils/opening-hours';
|
||||
|
||||
import {
|
||||
EXCLUDED_EVENT_KINDS,
|
||||
type Mobility,
|
||||
SEARCH_RADIUS_M,
|
||||
} from './planner/planner.constants';
|
||||
import type { Candidate } from './planner/planner.types';
|
||||
|
||||
/** AI 후보 순위(설계 §0) — 1 확인한 곳 · 2 관광공사 장소 */
|
||||
const AI_TIERS = [1, 2];
|
||||
|
||||
/** 출처 표기(장소 상세·행사 상세와 같다) */
|
||||
const PLACE_PROVIDERS: Record<PlaceSource, string> = {
|
||||
tour: '한국관광공사',
|
||||
store: '소상공인시장진흥공단 상가(상권)정보',
|
||||
};
|
||||
const EVENT_PROVIDERS: Record<EventSource, string> = {
|
||||
tour: '한국관광공사',
|
||||
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
|
||||
kcisa: '한국문화정보원 문화포털',
|
||||
dgfca: '대구문화예술진흥원',
|
||||
};
|
||||
|
||||
/** 결과 화면에 붙일 사실 값 — 후보를 고를 때가 아니라 결과를 만들 때 쓴다 */
|
||||
export interface CandidateFacts {
|
||||
hoursText: string | null;
|
||||
hoursOrigin: ValueOrigin;
|
||||
petOrigin: ValueOrigin;
|
||||
verifiedAt: string | null;
|
||||
provider: string;
|
||||
photo: { thumbUrl: string; license: ImageLicense | null } | null;
|
||||
eventPeriod: { start: string; end: string } | null;
|
||||
}
|
||||
|
||||
export interface FoundCandidates {
|
||||
candidates: Candidate[];
|
||||
/** 후보 id → 사실 값 */
|
||||
facts: Map<string, CandidateFacts>;
|
||||
}
|
||||
|
||||
function photoOf(
|
||||
thumbUrl: string | null,
|
||||
license: ImageLicense | null,
|
||||
): CandidateFacts['photo'] {
|
||||
return thumbUrl ? { thumbUrl, license } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 찾기(설계 §3.2) — 이동 수단별 반경 안의
|
||||
* - 장소: place_listing 에서 노출 중이고 AI 순위(1·2)가 있는 곳. 숙박은 뺀다
|
||||
* - 행사: 그날 진행하고 숨기지 않았으며 종류별 기간 상한 안인 것. 공연(KOPIS)은 뺀다
|
||||
* 거르기·줄이기는 순수 로직(planner/filter·shortlist)이 한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseCandidatesService {
|
||||
constructor(
|
||||
@InjectRepository(PlaceListing)
|
||||
private readonly listings: Repository<PlaceListing>,
|
||||
@InjectRepository(FestivalEvent)
|
||||
private readonly events: Repository<FestivalEvent>,
|
||||
) {}
|
||||
|
||||
async find(
|
||||
center: LatLng,
|
||||
date: string,
|
||||
mobility: Mobility,
|
||||
): Promise<FoundCandidates> {
|
||||
const radius = SEARCH_RADIUS_M[mobility];
|
||||
const box = boundingBox(center.lat, center.lng, radius);
|
||||
const near = { lat: center.lat, lng: center.lng, radius };
|
||||
const [places, events] = await Promise.all([
|
||||
this.listings
|
||||
.createQueryBuilder('v')
|
||||
.where('v.is_searchable = true')
|
||||
.andWhere('v.ai_tier IN (:...tiers)', { tiers: AI_TIERS })
|
||||
.andWhere('v.category <> :lodging', { lodging: 'lodging' })
|
||||
.andWhere('v.lat BETWEEN :latMin AND :latMax', box)
|
||||
.andWhere('v.lng BETWEEN :lngMin AND :lngMax', box)
|
||||
.andWhere(
|
||||
`${sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng')} <= :radius`,
|
||||
)
|
||||
.setParameters(near)
|
||||
.getMany(),
|
||||
this.events
|
||||
.createQueryBuilder('e')
|
||||
.where('e.status = :active', { active: 'active' })
|
||||
.andWhere('e.start_date <= :date AND e.end_date >= :date', { date })
|
||||
.andWhere(sqlEventWithinMaxSpan('e'))
|
||||
.andWhere('e.kind NOT IN (:...excluded)', {
|
||||
excluded: EXCLUDED_EVENT_KINDS,
|
||||
})
|
||||
.andWhere('e.lat BETWEEN :latMin AND :latMax', box)
|
||||
.andWhere('e.lng BETWEEN :lngMin AND :lngMax', box)
|
||||
.andWhere(
|
||||
`${sqlDistanceM(':lat', ':lng', 'e.lat', 'e.lng')} <= :radius`,
|
||||
)
|
||||
.setParameters(near)
|
||||
.getMany(),
|
||||
]);
|
||||
|
||||
const candidates: Candidate[] = [];
|
||||
const facts = new Map<string, CandidateFacts>();
|
||||
for (const v of places) {
|
||||
candidates.push({
|
||||
kind: 'place',
|
||||
id: v.id,
|
||||
name: v.branchName ? `${v.name} ${v.branchName}` : v.name,
|
||||
category: v.category,
|
||||
eventKind: null,
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
tier: v.aiTier,
|
||||
setting: v.setting,
|
||||
hours: v.hours,
|
||||
petPolicy: v.petPolicy,
|
||||
petSizes: v.petSizes,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
facts.set(v.id, {
|
||||
hoursText: v.hoursText,
|
||||
hoursOrigin: v.hoursOrigin,
|
||||
petOrigin: v.petOrigin,
|
||||
verifiedAt: v.verifiedAt,
|
||||
provider: PLACE_PROVIDERS[v.source],
|
||||
photo: photoOf(v.imageThumbUrl, v.imageLicense),
|
||||
eventPeriod: null,
|
||||
});
|
||||
}
|
||||
for (const e of events) {
|
||||
if (e.lat === null || e.lng === null) continue;
|
||||
// 운영 시간 문구를 영업시간 파서로 읽는다 — 못 읽으면 '행사 시간 확인' 경고와 함께 남긴다(§3.3)
|
||||
const hours = e.playtimeText ? parseOpeningHours(e.playtimeText) : null;
|
||||
candidates.push({
|
||||
kind: 'event',
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
category: 'event',
|
||||
eventKind: e.kind,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
tier: null,
|
||||
setting: null,
|
||||
hours,
|
||||
petPolicy: 'unknown',
|
||||
petSizes: null,
|
||||
startDate: e.startDate,
|
||||
endDate: e.endDate,
|
||||
});
|
||||
facts.set(e.id, {
|
||||
hoursText: e.playtimeText,
|
||||
hoursOrigin:
|
||||
hours && hours.status !== 'unknown' && hours.status !== 'varies'
|
||||
? 'public'
|
||||
: 'unknown',
|
||||
petOrigin: 'unknown',
|
||||
verifiedAt: null,
|
||||
provider: EVENT_PROVIDERS[e.source],
|
||||
photo: photoOf(e.imageThumbUrl, e.imageLicense),
|
||||
eventPeriod: { start: e.startDate, end: e.endDate },
|
||||
});
|
||||
}
|
||||
return { candidates, facts };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { WeatherService } from '@/modules/weather/weather.service';
|
||||
import { kstClock } from '@/shared/utils/kst';
|
||||
import { maskContact } from '@/shared/utils/mask-contact';
|
||||
|
||||
import {
|
||||
COURSE_AI_CLIENT,
|
||||
type ConditionParseOutput,
|
||||
type CourseAiClient,
|
||||
} from './ai/course-ai.client';
|
||||
import { CourseAreaService } from './course-area.service';
|
||||
import {
|
||||
defaultConditions,
|
||||
parseHhmm,
|
||||
toCourseArea,
|
||||
} from './course-conditions';
|
||||
import type { CourseLang } from './course-generation.constants';
|
||||
import type { CourseConditions } from './course-generation.types';
|
||||
import { CourseSettings } from './course-settings';
|
||||
import type {
|
||||
ParseConditionsRequestDto,
|
||||
ParseConditionsResponseDto,
|
||||
} from './dto/course-conditions.dto';
|
||||
|
||||
/**
|
||||
* 조건 해석(설계 §2) — 문장에서 뽑은 조건에 기본값을 채우고 그 날짜·지역의 예보를 붙인다. 횟수를 차감하지 않는다.
|
||||
* AI 호출이 실패하면 기본값만 채워 돌려준다(입력 화면을 막지 않는다). 4단계 가짜 AI 는 문장을 읽지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseConditionsService {
|
||||
private readonly logger = new Logger(CourseConditionsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly areas: CourseAreaService,
|
||||
private readonly weather: WeatherService,
|
||||
private readonly settings: CourseSettings,
|
||||
@Inject(COURSE_AI_CLIENT) private readonly ai: CourseAiClient,
|
||||
) {}
|
||||
|
||||
async parse(
|
||||
dto: ParseConditionsRequestDto,
|
||||
lang: CourseLang,
|
||||
now = new Date(),
|
||||
): Promise<ParseConditionsResponseDto> {
|
||||
const baseArea = toCourseArea(dto.baseArea);
|
||||
// 앱의 기준 위치가 대구 밖이면 앱이 이미 도심으로 바꿔 보낸다 — 그래도 밖이면 VAL_001
|
||||
await this.areas.resolve(baseArea);
|
||||
const parsed = await this.parseText(dto.text, lang, now);
|
||||
const conditions: CourseConditions = {
|
||||
...defaultConditions(baseArea, now),
|
||||
...parsed?.extracted,
|
||||
};
|
||||
const { center } = await this.areas.resolve(conditions.area);
|
||||
const start = parseHhmm(conditions.start);
|
||||
const end = parseHhmm(conditions.end);
|
||||
const weather =
|
||||
start !== null && end !== null
|
||||
? await this.weather.forecastWindow(
|
||||
center,
|
||||
conditions.date,
|
||||
start,
|
||||
end,
|
||||
now,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
conditions,
|
||||
inferred: parsed?.inferred ?? [],
|
||||
issues: parsed?.issues ?? [],
|
||||
weather,
|
||||
};
|
||||
}
|
||||
|
||||
private async parseText(
|
||||
text: string,
|
||||
lang: CourseLang,
|
||||
now: Date,
|
||||
): Promise<ConditionParseOutput | null> {
|
||||
const masked = maskContact(text.trim());
|
||||
if (!masked) return null;
|
||||
const { date, minutes } = kstClock(now);
|
||||
try {
|
||||
return await this.ai.parseConditions({
|
||||
text: masked,
|
||||
lang,
|
||||
today: date,
|
||||
nowMinutes: minutes,
|
||||
signal: AbortSignal.timeout(this.settings.aiTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[AI_002] 조건 해석을 하지 못해 기본값만 채운다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
conditionsProblem,
|
||||
defaultConditions,
|
||||
formatHhmm,
|
||||
parseHhmm,
|
||||
toStoredConditions,
|
||||
} from './course-conditions';
|
||||
import type { CourseConditions } from './course-generation.types';
|
||||
|
||||
const kst = (iso: string) => new Date(`${iso}+09:00`);
|
||||
const DOWNTOWN = { type: 'neighborhood', key: 'downtown' } as const;
|
||||
|
||||
function conditions(
|
||||
overrides: Partial<CourseConditions> = {},
|
||||
): CourseConditions {
|
||||
return {
|
||||
area: DOWNTOWN,
|
||||
date: '2026-11-14',
|
||||
start: '13:00',
|
||||
end: '19:00',
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatHhmm · parseHhmm', () => {
|
||||
it('분과 HH:mm 을 오간다(24:00 까지)', () => {
|
||||
expect(formatHhmm(13 * 60 + 5)).toBe('13:05');
|
||||
expect(formatHhmm(1440)).toBe('24:00');
|
||||
expect(parseHhmm('09:30')).toBe(570);
|
||||
expect(parseHhmm('24:00')).toBe(1440);
|
||||
expect(parseHhmm('24:30')).toBeNull();
|
||||
expect(parseHhmm('9:30')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultConditions', () => {
|
||||
it('오늘이면 다음 정시부터 6시간이다', () => {
|
||||
const c = defaultConditions(DOWNTOWN, kst('2026-11-08T10:20:00'));
|
||||
expect([c.date, c.start, c.end]).toEqual(['2026-11-08', '11:00', '17:00']);
|
||||
expect(c).toMatchObject({
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('끝은 22시까지이고, 18시 이후면 내일 13시부터다', () => {
|
||||
const late = defaultConditions(DOWNTOWN, kst('2026-11-08T17:30:00'));
|
||||
expect([late.date, late.start, late.end]).toEqual([
|
||||
'2026-11-08',
|
||||
'18:00',
|
||||
'22:00',
|
||||
]);
|
||||
const evening = defaultConditions(DOWNTOWN, kst('2026-11-08T18:00:00'));
|
||||
expect([evening.date, evening.start, evening.end]).toEqual([
|
||||
'2026-11-09',
|
||||
'13:00',
|
||||
'19:00',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionsProblem', () => {
|
||||
const now = kst('2026-11-08T15:00:00');
|
||||
|
||||
it('오늘~30일 뒤, 2~12시간, 자정 전이면 문제없다', () => {
|
||||
expect(conditionsProblem(conditions(), now)).toBeNull();
|
||||
expect(
|
||||
conditionsProblem(conditions({ date: '2026-12-08', end: '24:00' }), now),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('지난 날짜·30일보다 먼 날짜·없는 날짜를 거른다', () => {
|
||||
expect(conditionsProblem(conditions({ date: '2026-11-07' }), now)).toBe(
|
||||
'past_date',
|
||||
);
|
||||
expect(conditionsProblem(conditions({ date: '2026-12-09' }), now)).toBe(
|
||||
'date_out_of_range',
|
||||
);
|
||||
expect(conditionsProblem(conditions({ date: '2026-02-30' }), now)).toBe(
|
||||
'invalid_date',
|
||||
);
|
||||
});
|
||||
|
||||
it('2시간보다 짧거나 12시간보다 길거나 끝이 시작보다 이르면 거른다', () => {
|
||||
expect(conditionsProblem(conditions({ end: '14:30' }), now)).toBe(
|
||||
'too_short',
|
||||
);
|
||||
expect(
|
||||
conditionsProblem(conditions({ start: '09:00', end: '21:30' }), now),
|
||||
).toBe('too_long');
|
||||
expect(
|
||||
conditionsProblem(conditions({ start: '19:00', end: '13:00' }), now),
|
||||
).toBe('too_short');
|
||||
});
|
||||
|
||||
it('오늘 이미 끝난 시간과 대구 밖 현재 위치를 거른다', () => {
|
||||
expect(
|
||||
conditionsProblem(
|
||||
conditions({ date: '2026-11-08', start: '10:00', end: '14:00' }),
|
||||
now,
|
||||
),
|
||||
).toBe('past_time');
|
||||
expect(
|
||||
conditionsProblem(
|
||||
conditions({ area: { type: 'point', lat: 37.5665, lng: 126.978 } }),
|
||||
now,
|
||||
),
|
||||
).toBe('unsupported_area');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toStoredConditions', () => {
|
||||
it('현재 위치는 약 100m(소수점 셋째 자리)까지만 남긴다', () => {
|
||||
const stored = toStoredConditions(
|
||||
conditions({ area: { type: 'point', lat: 35.871234, lng: 128.594567 } }),
|
||||
);
|
||||
expect(stored.area).toEqual({ type: 'point', lat: 35.871, lng: 128.595 });
|
||||
expect(toStoredConditions(conditions()).area).toEqual(DOWNTOWN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 코스 조건 기본값·검사·변환 (순수 함수) — 설계 §2.
|
||||
*/
|
||||
import { isInSido } from '@/shared/utils/geo';
|
||||
import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date';
|
||||
import { kstClock } from '@/shared/utils/kst';
|
||||
|
||||
import {
|
||||
DATE_RANGE_DAYS,
|
||||
DEFAULT_END_LIMIT_MINUTES,
|
||||
DEFAULT_LENGTH_MINUTES,
|
||||
DEFAULT_PARTY,
|
||||
DEFAULT_START_MINUTES,
|
||||
SUPPORTED_SIDO,
|
||||
TOMORROW_FROM_MINUTES,
|
||||
WINDOW_MAX_MINUTES,
|
||||
WINDOW_MIN_MINUTES,
|
||||
} from './course-generation.constants';
|
||||
import type { CourseArea, CourseConditions } from './course-generation.types';
|
||||
import type {
|
||||
CourseAreaDto,
|
||||
CourseConditionsDto,
|
||||
} from './dto/course-conditions.dto';
|
||||
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
/** 저장할 때 현재 위치를 남기는 자릿수(소수점 셋째 자리 ≈ 100m) */
|
||||
const STORED_POINT_DECIMALS = 3;
|
||||
|
||||
/** 분 → 'HH:mm'('24:00' 까지) */
|
||||
export function formatHhmm(minutes: number): string {
|
||||
const h = Math.floor(minutes / MINUTES_PER_HOUR);
|
||||
const m = minutes % MINUTES_PER_HOUR;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 'HH:mm' → 분. 형식이 다르면 null('24:00' 은 1440) */
|
||||
export function parseHhmm(text: string): number | null {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(text);
|
||||
if (!m) return null;
|
||||
const minutes = Number(m[1]) * MINUTES_PER_HOUR + Number(m[2]);
|
||||
return Number(m[2]) < MINUTES_PER_HOUR && minutes <= MINUTES_PER_DAY
|
||||
? minutes
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문장에서 뽑지 못한 항목의 기본값(§2).
|
||||
* 날짜는 오늘(18시 이후면 내일), 시작은 오늘이면 다음 정시·아니면 13:00, 끝은 시작 + 6시간(22시까지).
|
||||
*/
|
||||
export function defaultConditions(
|
||||
area: CourseArea,
|
||||
now: Date,
|
||||
): CourseConditions {
|
||||
const { date: today, minutes } = kstClock(now);
|
||||
const tomorrow = minutes >= TOMORROW_FROM_MINUTES;
|
||||
const date = tomorrow ? (addDaysIso(today, 1) ?? today) : today;
|
||||
const start = tomorrow
|
||||
? DEFAULT_START_MINUTES
|
||||
: (Math.floor(minutes / MINUTES_PER_HOUR) + 1) * MINUTES_PER_HOUR;
|
||||
const end = Math.min(
|
||||
start + DEFAULT_LENGTH_MINUTES,
|
||||
DEFAULT_END_LIMIT_MINUTES,
|
||||
);
|
||||
return {
|
||||
area,
|
||||
date,
|
||||
start: formatHhmm(start),
|
||||
end: formatHhmm(end),
|
||||
party: DEFAULT_PARTY,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성할 수 없는 조건이면 이유(로그용), 괜찮으면 null.
|
||||
* 날짜는 오늘~30일 뒤, 시간은 2~12시간에 자정을 넘기지 않고, 오늘이면 아직 끝나지 않은 시간이어야 한다.
|
||||
* 현재 위치는 대구 안이어야 한다(장소·동네는 서버가 찾는다).
|
||||
*/
|
||||
export function conditionsProblem(
|
||||
c: CourseConditions,
|
||||
now: Date,
|
||||
): string | null {
|
||||
const { date: today, minutes } = kstClock(now);
|
||||
const ahead = daysBetweenIso(today, c.date);
|
||||
if (!Number.isFinite(ahead) || addDaysIso(c.date, 0) !== c.date) {
|
||||
return 'invalid_date';
|
||||
}
|
||||
if (ahead < 0) return 'past_date';
|
||||
if (ahead > DATE_RANGE_DAYS) return 'date_out_of_range';
|
||||
const start = parseHhmm(c.start);
|
||||
const end = parseHhmm(c.end);
|
||||
if (start === null || end === null) return 'invalid_time';
|
||||
const length = end - start;
|
||||
if (length < WINDOW_MIN_MINUTES) return 'too_short';
|
||||
if (length > WINDOW_MAX_MINUTES) return 'too_long';
|
||||
if (ahead === 0 && end <= minutes) return 'past_time';
|
||||
if (
|
||||
c.area.type === 'point' &&
|
||||
!isInSido(SUPPORTED_SIDO, c.area.lat, c.area.lng)
|
||||
) {
|
||||
return 'unsupported_area';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toCourseArea(dto: CourseAreaDto): CourseArea {
|
||||
if (dto.type === 'neighborhood') {
|
||||
return { type: 'neighborhood', key: dto.key ?? 'downtown' };
|
||||
}
|
||||
if (dto.type === 'point') {
|
||||
return { type: 'point', lat: dto.lat ?? 0, lng: dto.lng ?? 0 };
|
||||
}
|
||||
return { type: 'place', placeId: dto.placeId ?? '' };
|
||||
}
|
||||
|
||||
export function toCourseConditions(dto: CourseConditionsDto): CourseConditions {
|
||||
return {
|
||||
area: toCourseArea(dto.area),
|
||||
date: dto.date,
|
||||
start: dto.start,
|
||||
end: dto.end,
|
||||
party: dto.party,
|
||||
pet: dto.pet
|
||||
? { kind: dto.pet.kind, size: dto.pet.size ?? null, count: dto.pet.count }
|
||||
: null,
|
||||
mobility: dto.mobility,
|
||||
indoorIfRain: dto.indoorIfRain,
|
||||
};
|
||||
}
|
||||
|
||||
/** 생성 기록에 남길 조건 — 현재 위치는 약 100m 단위로만 남긴다 */
|
||||
export function toStoredConditions(c: CourseConditions): CourseConditions {
|
||||
if (c.area.type !== 'point') return c;
|
||||
const round = (v: number) => Number(v.toFixed(STORED_POINT_DECIMALS));
|
||||
return {
|
||||
...c,
|
||||
area: { type: 'point', lat: round(c.area.lat), lng: round(c.area.lng) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* AI 코스 생성 API 의 기준값 — 설계 docs/design/ai-course.md §2·§3.1·§4·§5.
|
||||
*/
|
||||
import type { LatLng } from '@/shared/utils/geo';
|
||||
|
||||
/** 모든 AI 코스 요청에 앱이 싣는 기기 ID(앱이 처음 실행할 때 만든 UUID, flutter_secure_storage 에 둔다) */
|
||||
export const DEVICE_ID_HEADER = 'x-device-id';
|
||||
|
||||
/** 지원 동네 — 앱의 Neighborhood(flutter/lib/features/location/domain/base_location.dart)와 같은 키·좌표 */
|
||||
export const NEIGHBORHOODS = {
|
||||
downtown: { lat: 35.87021, lng: 128.59409 },
|
||||
suseongmot: { lat: 35.8276, lng: 128.6177 },
|
||||
apsan: { lat: 35.83582, lng: 128.57911 },
|
||||
} as const satisfies Record<string, LatLng>;
|
||||
export type NeighborhoodKey = keyof typeof NEIGHBORHOODS;
|
||||
export const NEIGHBORHOOD_KEYS = Object.keys(
|
||||
NEIGHBORHOODS,
|
||||
) as NeighborhoodKey[];
|
||||
/** 대구 밖 위치를 대신하는 동네(앱과 같다) */
|
||||
export const FALLBACK_NEIGHBORHOOD: NeighborhoodKey = 'downtown';
|
||||
/** 지원 지역 — 법정동 시·도 코드(대구) */
|
||||
export const SUPPORTED_SIDO = '27';
|
||||
|
||||
export const AREA_TYPES = ['neighborhood', 'point', 'place'] as const;
|
||||
export type AreaType = (typeof AREA_TYPES)[number];
|
||||
|
||||
export const PET_KINDS = ['dog', 'cat', 'other'] as const;
|
||||
export const COURSE_LANGS = ['ko', 'en'] as const;
|
||||
export type CourseLang = (typeof COURSE_LANGS)[number];
|
||||
|
||||
export const GENERATION_STATUSES = [
|
||||
'queued',
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'canceled',
|
||||
] as const;
|
||||
export type GenerationStatus = (typeof GENERATION_STATUSES)[number];
|
||||
/** 끝나지 않은 상태 — 기기·사용자마다 1건만 둔다(§5) */
|
||||
export const ACTIVE_STATUSES: readonly GenerationStatus[] = [
|
||||
'queued',
|
||||
'running',
|
||||
];
|
||||
|
||||
export const GENERATION_STAGES = [
|
||||
'searching',
|
||||
'filtering',
|
||||
'planning',
|
||||
'finishing',
|
||||
] as const;
|
||||
export type GenerationStage = (typeof GENERATION_STAGES)[number];
|
||||
|
||||
/** 생성 작업 안에서 생긴 실패 코드(응답 상태가 아니라 failure.code 로 준다, §7) */
|
||||
export const FAILURE_CODES = ['AI_002', 'AI_003'] as const;
|
||||
export type FailureCode = (typeof FAILURE_CODES)[number];
|
||||
|
||||
export const RATING_VALUES = ['like', 'dislike'] as const;
|
||||
export type RatingValue = (typeof RATING_VALUES)[number];
|
||||
|
||||
/** 하루 횟수 기본값(§13) — AI_DAILY_LIMIT_GUEST · AI_DAILY_LIMIT_MEMBER 로 바꾼다 */
|
||||
export const DAILY_LIMIT_GUEST_DEFAULT = 2;
|
||||
export const DAILY_LIMIT_MEMBER_DEFAULT = 5;
|
||||
/** 실패·취소를 포함한 하루 시도는 횟수의 이 배수까지(취소를 반복해 비용만 쓰는 것을 막는다) */
|
||||
export const ATTEMPT_LIMIT_MULTIPLIER = 3;
|
||||
/** 하루 비용 상한(정수 달러, 조건 해석 포함) — AI_DAILY_BUDGET_USD 로 바꾼다. 개발 3달러(§13) */
|
||||
export const DAILY_BUDGET_USD_DEFAULT = 3;
|
||||
/** AI 호출 제한 시간(ms) — AI_TIMEOUT_MS 로 바꾼다 */
|
||||
export const AI_TIMEOUT_MS_DEFAULT = 30_000;
|
||||
|
||||
/** 생성 작업 전체 제한 시간(AI 호출 30초 + 재시도 여유) — 넘으면 failed + AI_002 */
|
||||
export const JOB_TIMEOUT_MS = 45_000;
|
||||
/** 동시에 도는 생성 작업 수 — 넘으면 queued 로 기다린다 */
|
||||
export const MAX_CONCURRENT_JOBS = 5;
|
||||
/** 이보다 오래 running 인 작업은 멈춘 것으로 보고 failed 로 바꾼다 */
|
||||
export const STALE_RUNNING_MS = 2 * 60_000;
|
||||
/** 멈춘 작업을 찾는 간격 */
|
||||
export const STALE_SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/** 요청 문장 최대 길이 */
|
||||
export const REQUEST_TEXT_MAX = 300;
|
||||
/** 날짜는 오늘부터 이만큼 뒤까지(§2) */
|
||||
export const DATE_RANGE_DAYS = 30;
|
||||
/** 시간 범위 길이(분) — 2~12시간(§2) */
|
||||
export const WINDOW_MIN_MINUTES = 120;
|
||||
export const WINDOW_MAX_MINUTES = 720;
|
||||
/** 기본 시간(§2) — 오늘이면 다음 정시, 아니면 13:00 부터 6시간, 22시까지. 18시 이후면 내일 */
|
||||
export const DEFAULT_START_MINUTES = 13 * 60;
|
||||
export const DEFAULT_LENGTH_MINUTES = 6 * 60;
|
||||
export const DEFAULT_END_LIMIT_MINUTES = 22 * 60;
|
||||
export const TOMORROW_FROM_MINUTES = 18 * 60;
|
||||
export const DEFAULT_PARTY = 2;
|
||||
export const PARTY_MAX = 10;
|
||||
export const PET_COUNT_MAX = 5;
|
||||
|
||||
/** 요청 제한(분당, §4) — 통신사 NAT 뒤에서는 여러 사용자가 같은 IP 를 쓴다 */
|
||||
export const THROTTLE_WINDOW_MS = 60_000;
|
||||
export const THROTTLE_GENERATE = 5;
|
||||
export const THROTTLE_CONDITIONS = 20;
|
||||
export const THROTTLE_POLLING = 300;
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
|
||||
import {
|
||||
type CourseLang,
|
||||
THROTTLE_CONDITIONS,
|
||||
THROTTLE_GENERATE,
|
||||
THROTTLE_POLLING,
|
||||
THROTTLE_WINDOW_MS,
|
||||
} from './course-generation.constants';
|
||||
import { CourseConditionsService } from './course-conditions.service';
|
||||
import { CourseGenerationService } from './course-generation.service';
|
||||
import { DeviceHash } from './device-id.decorator';
|
||||
import {
|
||||
ParseConditionsRequestDto,
|
||||
ParseConditionsResponseDto,
|
||||
} from './dto/course-conditions.dto';
|
||||
import {
|
||||
CourseGenerationCreatedDto,
|
||||
CourseGenerationDto,
|
||||
CourseQuotaDto,
|
||||
CreateCourseGenerationDto,
|
||||
RateCourseGenerationDto,
|
||||
} from './dto/course-generation.dto';
|
||||
|
||||
const DEVICE_HEADER_DOC = {
|
||||
name: 'X-Device-Id',
|
||||
required: true,
|
||||
description:
|
||||
'앱이 처음 실행할 때 만든 기기 UUID — 비로그인 하루 횟수와 "만든 기기만 보기"에 쓴다(원문은 저장하지 않는다)',
|
||||
};
|
||||
|
||||
/** Accept-Language 가 영어면 영어로 쓴다(그 밖은 한국어) */
|
||||
function langOf(acceptLanguage: string | undefined): CourseLang {
|
||||
return acceptLanguage?.trim().toLowerCase().startsWith('en') ? 'en' : 'ko';
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 코스 생성(설계 docs/design/ai-course.md §4) — 로그인 없이 쓴다. 라우팅과 입출력만 맡는다.
|
||||
* 생성은 백그라운드 작업이고 앱이 1~2초마다 진행을 조회한다(스트리밍 대신 조회, §3.1).
|
||||
*/
|
||||
@ApiTags('CourseGeneration')
|
||||
@ApiHeader(DEVICE_HEADER_DOC)
|
||||
@Controller('course-generations')
|
||||
export class CourseGenerationController {
|
||||
constructor(private readonly generations: CourseGenerationService) {}
|
||||
|
||||
// ':id' 보다 먼저 둔다(경로가 겹친다)
|
||||
@Get('quota')
|
||||
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_POLLING } })
|
||||
@ApiOperation({
|
||||
summary: '오늘 남은 횟수',
|
||||
description:
|
||||
'입력 화면의 "오늘 2번 만들 수 있어요". 결과가 나온 생성만 차감한다.',
|
||||
})
|
||||
@ApiStandardResponse(CourseQuotaDto, { description: '하루 횟수' })
|
||||
@ApiErrorResponse('VAL_001', 'X-Device-Id 가 없거나 UUID 가 아님')
|
||||
quota(@DeviceHash() deviceHash: string) {
|
||||
return this.generations.quotaFor(deviceHash);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_GENERATE } })
|
||||
@ApiOperation({
|
||||
summary: '코스 생성 시작',
|
||||
description:
|
||||
'조건을 확인하고 백그라운드 작업을 시작한다. 진행은 GET /course-generations/{id} 로 조회한다.',
|
||||
})
|
||||
@ApiStandardResponse(CourseGenerationCreatedDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '생성 접수',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '조건 오류(날짜·시간·지역·이전 생성 id)')
|
||||
@ApiErrorResponse('AI_001', '하루 횟수를 다 씀')
|
||||
@ApiErrorResponse('AI_002', 'AI 를 쓸 수 없음(하루 비용 상한 등)')
|
||||
@ApiErrorResponse('AI_004', '이미 만드는 중')
|
||||
create(
|
||||
@DeviceHash() deviceHash: string,
|
||||
@Body() dto: CreateCourseGenerationDto,
|
||||
@Headers('accept-language') acceptLanguage?: string,
|
||||
) {
|
||||
return this.generations.create(deviceHash, dto, langOf(acceptLanguage));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_POLLING } })
|
||||
@ApiOperation({
|
||||
summary: '생성 진행·결과 조회',
|
||||
description:
|
||||
'단계(stage)와 숫자, 결과(succeeded)·실패 이유(failure)를 준다. 생성이 실패해도 조회는 200 이다. 만든 기기만 볼 수 있다.',
|
||||
})
|
||||
@ApiStandardResponse(CourseGenerationDto, { description: '생성 상태' })
|
||||
@ApiErrorResponse('VAL_001', 'id 형식 오류 · X-Device-Id 오류')
|
||||
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
|
||||
get(
|
||||
@DeviceHash() deviceHash: string,
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
) {
|
||||
return this.generations.get(deviceHash, id);
|
||||
}
|
||||
|
||||
@Post(':id/cancellation')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '생성 취소',
|
||||
description:
|
||||
'AI 호출을 끊는다. 차감하지 않는다. 이미 끝난 생성은 그대로 돌려준다.',
|
||||
})
|
||||
@ApiStandardResponse(CourseGenerationDto, { description: '취소한 생성' })
|
||||
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
|
||||
cancel(
|
||||
@DeviceHash() deviceHash: string,
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
) {
|
||||
return this.generations.cancel(deviceHash, id);
|
||||
}
|
||||
|
||||
@Put(':id/rating')
|
||||
@ApiOperation({
|
||||
summary: '코스 평가',
|
||||
description: '"이 코스, 마음에 드세요?" — 결과가 나온 생성만 평가한다.',
|
||||
})
|
||||
@ApiStandardResponse(CourseGenerationDto, { description: '평가한 생성' })
|
||||
@ApiErrorResponse('VAL_001', '평가 값 오류 · 결과가 나오지 않은 생성')
|
||||
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
|
||||
rate(
|
||||
@DeviceHash() deviceHash: string,
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: RateCourseGenerationDto,
|
||||
) {
|
||||
return this.generations.rate(deviceHash, id, dto.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 조건 해석(설계 §2) — 입력이 1초 멈추거나 키보드 '완료'일 때 한 번 부른다. 횟수를 차감하지 않는다 */
|
||||
@ApiTags('CourseGeneration')
|
||||
@ApiHeader(DEVICE_HEADER_DOC)
|
||||
@Controller('course-conditions')
|
||||
export class CourseConditionsController {
|
||||
constructor(private readonly conditions: CourseConditionsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({
|
||||
default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_CONDITIONS },
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: '조건 해석',
|
||||
description:
|
||||
'문장에서 지역·날짜·시간 같은 조건을 뽑고 비어 있는 항목은 기본값으로 채운다. 예보를 함께 준다. AI 를 못 쓰면 기본값만 채운다.',
|
||||
})
|
||||
@ApiStandardResponse(ParseConditionsResponseDto, { description: '채운 조건' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'문장·기준 위치 오류(대구 밖 등) · X-Device-Id 오류',
|
||||
)
|
||||
parse(
|
||||
@DeviceHash() _deviceHash: string,
|
||||
@Body() dto: ParseConditionsRequestDto,
|
||||
@Headers('accept-language') acceptLanguage?: string,
|
||||
) {
|
||||
return this.conditions.parse(dto, langOf(acceptLanguage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
|
||||
import { PlaceListing } from '@/modules/place/entities/place-listing.entity';
|
||||
import { WeatherModule } from '@/modules/weather/weather.module';
|
||||
|
||||
import { COURSE_AI_CLIENT } from './ai/course-ai.client';
|
||||
import { FakeCourseAiClient } from './ai/fake-course-ai.client';
|
||||
import { CourseAreaService } from './course-area.service';
|
||||
import { CourseCandidatesService } from './course-candidates.service';
|
||||
import { CourseConditionsService } from './course-conditions.service';
|
||||
import {
|
||||
CourseConditionsController,
|
||||
CourseGenerationController,
|
||||
} from './course-generation.controller';
|
||||
import { CourseGenerationPipeline } from './course-generation.pipeline';
|
||||
import { CourseGenerationRunner } from './course-generation.runner';
|
||||
import { CourseGenerationService } from './course-generation.service';
|
||||
import { CourseQuotaService } from './course-quota.service';
|
||||
import { CourseSettings } from './course-settings';
|
||||
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
|
||||
|
||||
/**
|
||||
* AI 코스 생성(설계 docs/design/ai-course.md). 시간표 짜기는 순수 로직(planner/), 날씨는 WeatherModule 이다.
|
||||
* AI 는 4단계에서 가짜(FakeCourseAiClient)를 쓴다 — 5단계에서 Anthropic 클라이언트로 바꾸고, 테스트는 계속 가짜를 쓴다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiCourseGeneration, PlaceListing, FestivalEvent]),
|
||||
WeatherModule,
|
||||
],
|
||||
controllers: [CourseGenerationController, CourseConditionsController],
|
||||
providers: [
|
||||
CourseSettings,
|
||||
CourseAreaService,
|
||||
CourseCandidatesService,
|
||||
CourseQuotaService,
|
||||
CourseConditionsService,
|
||||
CourseGenerationPipeline,
|
||||
CourseGenerationRunner,
|
||||
CourseGenerationService,
|
||||
{ provide: COURSE_AI_CLIENT, useClass: FakeCourseAiClient },
|
||||
],
|
||||
})
|
||||
export class CourseGenerationModule {}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { WeatherService } from '@/modules/weather/weather.service';
|
||||
import { holidayOn, isHolidayTableYear } from '@/shared/utils/kr-holidays';
|
||||
|
||||
import {
|
||||
type AiUsage,
|
||||
COURSE_AI_CLIENT,
|
||||
type CourseAiClient,
|
||||
type CourseDraftOutput,
|
||||
} from './ai/course-ai.client';
|
||||
import { CourseAreaService } from './course-area.service';
|
||||
import { CourseCandidatesService } from './course-candidates.service';
|
||||
import { parseHhmm } from './course-conditions';
|
||||
import {
|
||||
type GenerationStage,
|
||||
JOB_TIMEOUT_MS,
|
||||
} from './course-generation.constants';
|
||||
import type { ResolvedConditions } from './course-generation.types';
|
||||
import { buildCourseResult } from './course-result.builder';
|
||||
import { CourseSettings } from './course-settings';
|
||||
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
|
||||
import { prefersIndoor } from './planner/candidate';
|
||||
import { filterCandidates } from './planner/filter';
|
||||
import { planCourse } from './planner/plan-course';
|
||||
import { STOPS_MIN } from './planner/planner.constants';
|
||||
import type { PlanContext, PlanResult } from './planner/planner.types';
|
||||
import { shortlistCandidates } from './planner/shortlist';
|
||||
|
||||
/** 생성 작업이 끝나지 못한 이유 — 러너가 상태와 실패 코드로 바꾼다 */
|
||||
export class CourseFailure extends Error {
|
||||
constructor(
|
||||
readonly code: 'AI_002' | 'AI_003',
|
||||
readonly notices: string[] = [],
|
||||
message: string = code,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CourseFailure';
|
||||
}
|
||||
}
|
||||
|
||||
interface UsageTotals {
|
||||
model: string | null;
|
||||
promptVersion: string | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
costUsd: number;
|
||||
llmCalls: number;
|
||||
}
|
||||
|
||||
function addUsage(totals: UsageTotals, usage: AiUsage): UsageTotals {
|
||||
return {
|
||||
model: usage.model,
|
||||
promptVersion: usage.promptVersion,
|
||||
inputTokens: totals.inputTokens + usage.inputTokens,
|
||||
outputTokens: totals.outputTokens + usage.outputTokens,
|
||||
cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
|
||||
cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
|
||||
costUsd: totals.costUsd + usage.costUsd,
|
||||
llmCalls: totals.llmCalls + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 작업 한 번의 단계(설계 §3.1) — 후보 찾기 → 거르기·줄이기 → AI 호출 → 시간표·검증·고치기 → 마무리.
|
||||
* 단계와 숫자를 DB 에 남겨 앱이 진행을 조회한다. 상태 변경은 running 일 때만 해서, 그사이 취소된 생성을 덮어쓰지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseGenerationPipeline {
|
||||
private readonly logger = new Logger(CourseGenerationPipeline.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiCourseGeneration)
|
||||
private readonly generations: Repository<AiCourseGeneration>,
|
||||
private readonly areas: CourseAreaService,
|
||||
private readonly candidates: CourseCandidatesService,
|
||||
private readonly weather: WeatherService,
|
||||
private readonly settings: CourseSettings,
|
||||
@Inject(COURSE_AI_CLIENT) private readonly ai: CourseAiClient,
|
||||
) {}
|
||||
|
||||
async run(row: AiCourseGeneration, signal: AbortSignal): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const conditions = row.conditions;
|
||||
const start = parseHhmm(conditions.start) ?? 0;
|
||||
const end = parseHhmm(conditions.end) ?? 0;
|
||||
|
||||
await this.stage(row.id, 'searching');
|
||||
const area = await this.areas.resolve(conditions.area);
|
||||
const resolved: ResolvedConditions = {
|
||||
conditions,
|
||||
center: area.center,
|
||||
placeName: area.placeName,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
const [found, weather, previousIds] = await Promise.all([
|
||||
this.candidates.find(area.center, conditions.date, conditions.mobility),
|
||||
this.weather.forecastWindow(area.center, conditions.date, start, end),
|
||||
this.previousIds(row),
|
||||
]);
|
||||
await this.update(row.id, { foundCount: found.candidates.length });
|
||||
throwIfAborted(signal);
|
||||
|
||||
await this.stage(row.id, 'filtering');
|
||||
if (!isHolidayTableYear(conditions.date)) {
|
||||
this.logger.warn(
|
||||
`공휴일 표에 ${conditions.date.slice(0, 4)}년이 없다 — kr-holidays.ts 를 갱신해야 한다`,
|
||||
);
|
||||
}
|
||||
const holiday = holidayOn(conditions.date);
|
||||
const ctx: PlanContext = {
|
||||
date: conditions.date,
|
||||
start,
|
||||
end,
|
||||
mobility: conditions.mobility,
|
||||
center: area.center,
|
||||
pet: conditions.pet,
|
||||
holiday: holiday?.kind ?? null,
|
||||
preferIndoor: prefersIndoor(
|
||||
weather?.precipitationProbability ?? null,
|
||||
conditions.indoorIfRain,
|
||||
),
|
||||
previousIds,
|
||||
};
|
||||
const { kept } = filterCandidates(found.candidates, ctx);
|
||||
const shortlist = shortlistCandidates(kept, ctx);
|
||||
await this.update(row.id, { keptCount: kept.length });
|
||||
if (shortlist.length < STOPS_MIN) throw new CourseFailure('AI_003');
|
||||
throwIfAborted(signal);
|
||||
|
||||
await this.stage(row.id, 'planning');
|
||||
let usage: UsageTotals = {
|
||||
model: null,
|
||||
promptVersion: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
costUsd: 0,
|
||||
llmCalls: 0,
|
||||
};
|
||||
const input = {
|
||||
lang: row.lang,
|
||||
requestText: row.requestText ?? '',
|
||||
resolved,
|
||||
preferIndoor: ctx.preferIndoor,
|
||||
weather,
|
||||
holidayName: holiday?.name ?? null,
|
||||
shortlist,
|
||||
violations: [],
|
||||
signal,
|
||||
};
|
||||
let draft: CourseDraftOutput = await this.ai.draftCourse(input);
|
||||
usage = addUsage(usage, draft.usage);
|
||||
let plan: PlanResult = planCourse(draft.draft, shortlist, ctx);
|
||||
// 고치기 4 — 고칠 수 없으면 위반 목록을 붙여 한 번 더 부른다(제한 시간이 남았을 때만)
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (
|
||||
plan.outcome === 'failed' &&
|
||||
plan.cause === 'unrepairable' &&
|
||||
elapsed + this.settings.aiTimeoutMs < JOB_TIMEOUT_MS
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
draft = await this.ai.draftCourse({
|
||||
...input,
|
||||
violations: plan.violations,
|
||||
});
|
||||
usage = addUsage(usage, draft.usage);
|
||||
plan = planCourse(draft.draft, shortlist, ctx);
|
||||
}
|
||||
await this.update(row.id, {
|
||||
model: usage.model,
|
||||
promptVersion: usage.promptVersion,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
costUsd: usage.costUsd,
|
||||
llmCalls: usage.llmCalls,
|
||||
repairs: plan.repairs,
|
||||
});
|
||||
if (plan.outcome === 'failed') {
|
||||
throw new CourseFailure('AI_003', draft.draft.notices);
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
await this.stage(row.id, 'finishing');
|
||||
const namesByRef = new Map(shortlist.map((e) => [e.ref, e.candidate.name]));
|
||||
const result = buildCourseResult({
|
||||
plan,
|
||||
title: draft.title,
|
||||
summary: draft.summary,
|
||||
notices: draft.draft.notices,
|
||||
resolved,
|
||||
weather,
|
||||
facts: found.facts,
|
||||
namesByRef,
|
||||
});
|
||||
await this.update(row.id, {
|
||||
status: 'succeeded',
|
||||
stage: null,
|
||||
result,
|
||||
counted: true,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
this.logger.log(
|
||||
`코스 생성 ${row.id} 성공 · ${result.outcome} · 장소 ${result.stops.length} · ` +
|
||||
`모델 ${usage.model} · 토큰 ${usage.inputTokens}/${usage.outputTokens} · $${usage.costUsd.toFixed(4)} · ` +
|
||||
`${Date.now() - startedAt}ms · 고치기 ${plan.repairs.join(',') || '없음'}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 다시 만들기 — 이전 결과의 장소·행사 id */
|
||||
private async previousIds(row: AiCourseGeneration): Promise<string[]> {
|
||||
if (!row.previousId) return [];
|
||||
const previous = await this.generations.findOneBy({
|
||||
id: row.previousId,
|
||||
deviceHash: row.deviceHash,
|
||||
});
|
||||
return previous?.result?.stops.map((s) => s.id) ?? [];
|
||||
}
|
||||
|
||||
private stage(id: string, stage: GenerationStage): Promise<void> {
|
||||
return this.update(id, { stage });
|
||||
}
|
||||
|
||||
/** running 일 때만 바꾼다 — 취소된 생성을 덮어쓰지 않는다 */
|
||||
private async update(
|
||||
id: string,
|
||||
values: QueryDeepPartialEntity<AiCourseGeneration>,
|
||||
): Promise<void> {
|
||||
await this.generations.update({ id, status: 'running' }, values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
BeforeApplicationShutdown,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { AiUnavailableError } from './ai/course-ai.client';
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
JOB_TIMEOUT_MS,
|
||||
MAX_CONCURRENT_JOBS,
|
||||
STALE_RUNNING_MS,
|
||||
STALE_SWEEP_INTERVAL_MS,
|
||||
} from './course-generation.constants';
|
||||
import {
|
||||
CourseFailure,
|
||||
CourseGenerationPipeline,
|
||||
} from './course-generation.pipeline';
|
||||
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
|
||||
|
||||
/** 작업을 끊은 이유 */
|
||||
type AbortReason = 'cancel' | 'timeout' | 'shutdown';
|
||||
|
||||
class JobAbort extends Error {
|
||||
constructor(readonly reason: AbortReason) {
|
||||
super(`생성 작업을 끊었다: ${reason}`);
|
||||
this.name = 'JobAbort';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 작업 실행기(설계 §3.1) — MVP 는 서버가 한 대라 프로세스 안에서 돌린다.
|
||||
* - 동시에 5건까지 돌리고 나머지는 queued 로 기다린다
|
||||
* - 작업마다 45초 제한(넘으면 failed + AI_002), 취소하면 AI 호출을 끊는다
|
||||
* - 종료할 때 진행 중인 작업은 failed(차감 없음)로 남기고, 기동할 때 남은 작업도 failed 로 바꾼다
|
||||
* 서버를 늘리면 Redis 큐(BullMQ)로 옮긴다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseGenerationRunner
|
||||
implements OnApplicationBootstrap, BeforeApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(CourseGenerationRunner.name);
|
||||
private readonly queue: string[] = [];
|
||||
private readonly active = new Map<string, AbortController>();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiCourseGeneration)
|
||||
private readonly generations: Repository<AiCourseGeneration>,
|
||||
private readonly pipeline: CourseGenerationPipeline,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다.
|
||||
* 정리일 뿐이라 실패해도 기동은 막지 않는다 — 마이그레이션 전이라 테이블이 없을 때 서버 전체가 뜨지 않았다(2026-11-11)
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const result = await this.generations.update(
|
||||
{ status: In([...ACTIVE_STATUSES]) },
|
||||
this.failedValues(),
|
||||
);
|
||||
if (result.affected) {
|
||||
this.logger.warn(
|
||||
`지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 끝나지 못한 코스 생성을 정리하지 못했다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** DB 연결이 닫히기 전(onApplicationShutdown 앞)에 남은 작업을 실패로 적는다 */
|
||||
async beforeApplicationShutdown(): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
const ids = [...this.queue, ...this.active.keys()];
|
||||
this.queue.length = 0;
|
||||
for (const controller of this.active.values()) {
|
||||
controller.abort(new JobAbort('shutdown'));
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
await this.generations
|
||||
.update(
|
||||
{ id: In(ids), status: In([...ACTIVE_STATUSES]) },
|
||||
this.failedValues(),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** 멈춘 작업(2분 넘게 running) 정리 — 작업 제한 시간(45초)이 있어 정상이면 생기지 않는다 */
|
||||
@Interval(STALE_SWEEP_INTERVAL_MS)
|
||||
async sweepStale(): Promise<void> {
|
||||
const before = new Date(Date.now() - STALE_RUNNING_MS);
|
||||
const result = await this.generations.update(
|
||||
{ status: 'running', startedAt: LessThan(before) },
|
||||
this.failedValues(),
|
||||
);
|
||||
if (result.affected) {
|
||||
this.logger.warn(`멈춘 코스 생성 ${result.affected}건을 실패로 바꿨다`);
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(id: string): void {
|
||||
if (this.shuttingDown) return;
|
||||
this.queue.push(id);
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/** 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다. 이 서버에 없으면 false */
|
||||
cancel(id: string): boolean {
|
||||
const index = this.queue.indexOf(id);
|
||||
if (index >= 0) {
|
||||
this.queue.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
const controller = this.active.get(id);
|
||||
controller?.abort(new JobAbort('cancel'));
|
||||
return Boolean(controller);
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
while (this.active.size < MAX_CONCURRENT_JOBS && this.queue.length > 0) {
|
||||
const id = this.queue.shift();
|
||||
if (!id) break;
|
||||
const controller = new AbortController();
|
||||
this.active.set(id, controller);
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new JobAbort('timeout')),
|
||||
JOB_TIMEOUT_MS,
|
||||
);
|
||||
// 시작 기록(DB) 실패처럼 execute 밖으로 나온 오류도 여기서 받는다 — 처리되지 않은 거부는 프로세스를 내린다
|
||||
void this.execute(id, controller.signal)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.error(
|
||||
`[SYS_001] 코스 생성 ${id} 를 시작하지 못했다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
)
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
this.active.delete(id);
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(id: string, signal: AbortSignal): Promise<void> {
|
||||
const started = await this.generations.update(
|
||||
{ id, status: 'queued' },
|
||||
{ status: 'running', startedAt: new Date() },
|
||||
);
|
||||
if (!started.affected) return; // 그사이 취소됐다
|
||||
const row = await this.generations.findOneBy({ id });
|
||||
if (!row) return;
|
||||
try {
|
||||
await this.pipeline.run(row, signal);
|
||||
} catch (error) {
|
||||
await this.finishFailed(id, error, signal).catch((e: unknown) =>
|
||||
this.logger.error(
|
||||
`[SYS_001] 코스 생성 ${id} 의 실패를 기록하지 못했다: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async finishFailed(
|
||||
id: string,
|
||||
error: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reason: unknown = signal.aborted ? signal.reason : error;
|
||||
if (reason instanceof JobAbort && reason.reason === 'cancel') {
|
||||
await this.generations.update(
|
||||
{ id, status: 'running' },
|
||||
{ status: 'canceled', stage: null, finishedAt: new Date() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reason instanceof JobAbort && reason.reason === 'shutdown') return;
|
||||
const detail = reason instanceof Error ? reason.message : 'unknown error';
|
||||
const failure =
|
||||
reason instanceof CourseFailure
|
||||
? reason
|
||||
: new CourseFailure('AI_002', [], detail);
|
||||
if (!(reason instanceof CourseFailure)) {
|
||||
if (reason instanceof AiUnavailableError || reason instanceof JobAbort) {
|
||||
this.logger.warn(`[AI_002] 코스 생성 ${id}: ${detail}`);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[AI_002] 코스 생성 ${id} 중 예상하지 못한 오류: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.generations.update(
|
||||
{ id, status: 'running' },
|
||||
{
|
||||
status: 'failed',
|
||||
stage: null,
|
||||
failureCode: failure.code,
|
||||
failureNotices: failure.notices,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private failedValues() {
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
stage: null,
|
||||
failureCode: 'AI_002' as const,
|
||||
finishedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { maskContact } from '@/shared/utils/mask-contact';
|
||||
|
||||
import { CourseAreaService } from './course-area.service';
|
||||
import {
|
||||
conditionsProblem,
|
||||
toCourseConditions,
|
||||
toStoredConditions,
|
||||
} from './course-conditions';
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type CourseLang,
|
||||
type RatingValue,
|
||||
} from './course-generation.constants';
|
||||
import { CourseGenerationRunner } from './course-generation.runner';
|
||||
import { CourseQuotaService } from './course-quota.service';
|
||||
import type {
|
||||
CourseGenerationCreatedDto,
|
||||
CourseGenerationDto,
|
||||
CourseQuotaDto,
|
||||
CreateCourseGenerationDto,
|
||||
} from './dto/course-generation.dto';
|
||||
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
|
||||
|
||||
/**
|
||||
* AI 코스 생성 API(설계 §4) — 횟수 확인, 생성 접수, 진행 조회, 취소, 평가.
|
||||
* 만든 기기만 볼 수 있다(다르면 RES_001). 생성 작업 자체는 러너가 백그라운드로 돌린다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseGenerationService {
|
||||
constructor(
|
||||
@InjectRepository(AiCourseGeneration)
|
||||
private readonly generations: Repository<AiCourseGeneration>,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly quota: CourseQuotaService,
|
||||
private readonly areas: CourseAreaService,
|
||||
private readonly runner: CourseGenerationRunner,
|
||||
) {}
|
||||
|
||||
quotaFor(deviceHash: string, now = new Date()): Promise<CourseQuotaDto> {
|
||||
return this.quota.view(deviceHash, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 접수 — 조건을 확인하고, 같은 기기의 요청을 직렬화한 트랜잭션에서 횟수·진행 중·비용을 본 뒤 기록을 만든다.
|
||||
* 차감은 결과가 나왔을 때(succeeded) 한다.
|
||||
*/
|
||||
async create(
|
||||
deviceHash: string,
|
||||
dto: CreateCourseGenerationDto,
|
||||
lang: CourseLang,
|
||||
now = new Date(),
|
||||
): Promise<CourseGenerationCreatedDto> {
|
||||
const conditions = toCourseConditions(dto.conditions);
|
||||
const problem = conditionsProblem(conditions, now);
|
||||
if (problem) {
|
||||
throw new BusinessException('VAL_001', { context: { reason: problem } });
|
||||
}
|
||||
// 동네·장소가 실제로 있는지 접수할 때 확인한다(작업 안에서 다시 찾는다)
|
||||
await this.areas.resolve(conditions.area);
|
||||
if (dto.previousGenerationId) {
|
||||
const previous = await this.generations.findOneBy({
|
||||
id: dto.previousGenerationId,
|
||||
deviceHash,
|
||||
});
|
||||
if (!previous) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'previous_not_found' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.dataSource.transaction(async (manager) => {
|
||||
// 같은 기기의 동시 요청이 함께 검사를 통과하지 않게 기기 단위로 잠근다(트랜잭션이 끝나면 풀린다)
|
||||
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
|
||||
deviceHash,
|
||||
]);
|
||||
await this.quota.assertCanCreate(manager, deviceHash, now);
|
||||
const repo = manager.getRepository(AiCourseGeneration);
|
||||
return repo.save(
|
||||
repo.create({
|
||||
deviceHash,
|
||||
userId: null,
|
||||
lang,
|
||||
requestText: maskContact(dto.request.trim()),
|
||||
conditions: toStoredConditions(conditions),
|
||||
status: 'queued',
|
||||
stage: null,
|
||||
previousId: dto.previousGenerationId ?? null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
this.runner.enqueue(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
remaining: await this.quota.remaining(deviceHash, now),
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
deviceHash: string,
|
||||
id: string,
|
||||
now = new Date(),
|
||||
): Promise<CourseGenerationDto> {
|
||||
return this.toView(await this.owned(deviceHash, id), now);
|
||||
}
|
||||
|
||||
/** 취소 — 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다. 차감하지 않는다. 이미 끝났으면 그대로 돌려준다 */
|
||||
async cancel(
|
||||
deviceHash: string,
|
||||
id: string,
|
||||
now = new Date(),
|
||||
): Promise<CourseGenerationDto> {
|
||||
const row = await this.owned(deviceHash, id);
|
||||
if (ACTIVE_STATUSES.includes(row.status)) {
|
||||
this.runner.cancel(id);
|
||||
await this.generations.update(
|
||||
{ id, status: In([...ACTIVE_STATUSES]) },
|
||||
{ status: 'canceled', stage: null, finishedAt: new Date() },
|
||||
);
|
||||
}
|
||||
return this.get(deviceHash, id, now);
|
||||
}
|
||||
|
||||
/** "이 코스, 마음에 드세요?" — 결과가 나온 생성만 평가한다 */
|
||||
async rate(
|
||||
deviceHash: string,
|
||||
id: string,
|
||||
value: RatingValue,
|
||||
now = new Date(),
|
||||
): Promise<CourseGenerationDto> {
|
||||
const row = await this.owned(deviceHash, id);
|
||||
if (row.status !== 'succeeded') {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'not_succeeded', status: row.status },
|
||||
});
|
||||
}
|
||||
await this.generations.update(
|
||||
{ id },
|
||||
{ rating: value, ratedAt: new Date() },
|
||||
);
|
||||
return this.get(deviceHash, id, now);
|
||||
}
|
||||
|
||||
private async owned(
|
||||
deviceHash: string,
|
||||
id: string,
|
||||
): Promise<AiCourseGeneration> {
|
||||
const row = await this.generations.findOneBy({ id, deviceHash });
|
||||
if (!row) {
|
||||
throw new BusinessException('RES_001', { context: { generationId: id } });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async toView(
|
||||
row: AiCourseGeneration,
|
||||
now: Date,
|
||||
): Promise<CourseGenerationDto> {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
stage: row.stage,
|
||||
progress: { found: row.foundCount, kept: row.keptCount },
|
||||
result: row.status === 'succeeded' ? row.result : null,
|
||||
failure:
|
||||
row.status === 'failed' && row.failureCode
|
||||
? { code: row.failureCode, notices: row.failureNotices ?? [] }
|
||||
: null,
|
||||
remaining: await this.quota.remaining(row.deviceHash, now),
|
||||
rating: row.rating,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AI 코스 생성의 조건 형식 — API 입력(CourseConditions, 설계 §2)과 생성 작업 안에서 쓰는 해석된 조건.
|
||||
*/
|
||||
import type { PetSize } from '@/modules/place/place.constants';
|
||||
import type { LatLng } from '@/shared/utils/geo';
|
||||
|
||||
import type { Mobility } from './planner/planner.constants';
|
||||
import type { PetCondition } from './planner/planner.types';
|
||||
import type { NeighborhoodKey } from './course-generation.constants';
|
||||
|
||||
export type CourseArea =
|
||||
| { type: 'neighborhood'; key: NeighborhoodKey }
|
||||
| { type: 'point'; lat: number; lng: number }
|
||||
| { type: 'place'; placeId: string };
|
||||
|
||||
/** 사용자가 화면에서 확인한 조건 — 조건 해석 응답과 생성 요청이 같은 형식을 쓴다 */
|
||||
export interface CourseConditions {
|
||||
area: CourseArea;
|
||||
/** 'YYYY-MM-DD' — 오늘부터 30일 뒤까지 */
|
||||
date: string;
|
||||
/** 'HH:mm' — 2~12시간, 자정을 넘기지 않는다 */
|
||||
start: string;
|
||||
end: string;
|
||||
party: number;
|
||||
pet: {
|
||||
kind: PetCondition['kind'];
|
||||
size: PetSize | null;
|
||||
count: number;
|
||||
} | null;
|
||||
mobility: Mobility;
|
||||
indoorIfRain: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 기록에 남기는 조건. 현재 위치(point)는 소수점 셋째 자리(약 100m)까지만 남긴다 —
|
||||
* 생성이 끝나면 정확한 위치가 필요 없다.
|
||||
*/
|
||||
export type StoredConditions = CourseConditions;
|
||||
|
||||
/** 생성 작업이 쓰는 해석된 조건 */
|
||||
export interface ResolvedConditions {
|
||||
conditions: CourseConditions;
|
||||
/** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */
|
||||
center: LatLng;
|
||||
/** 기준 장소의 이름(지역이 장소일 때) */
|
||||
placeName: string | null;
|
||||
/** 00:00 부터의 분 */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { kstStartOfDay } from '@/shared/utils/kst';
|
||||
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
ATTEMPT_LIMIT_MULTIPLIER,
|
||||
} from './course-generation.constants';
|
||||
import { CourseSettings } from './course-settings';
|
||||
import type { CourseQuotaDto } from './dto/course-generation.dto';
|
||||
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* 하루 횟수와 비용 보호(설계 §5). 하루 사용량과 비용은 ai_course_generation 에서 센다(한국 시간 자정에 초기화).
|
||||
* 로그인이 아직 없어 모두 비로그인(기기 기준)이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseQuotaService {
|
||||
private readonly logger = new Logger(CourseQuotaService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiCourseGeneration)
|
||||
private readonly generations: Repository<AiCourseGeneration>,
|
||||
private readonly settings: CourseSettings,
|
||||
) {}
|
||||
|
||||
/** 입력 화면의 "오늘 2번 만들 수 있어요" */
|
||||
async view(deviceHash: string, now: Date): Promise<CourseQuotaDto> {
|
||||
const limit = this.settings.dailyLimitGuest;
|
||||
const used = await this.countUsed(
|
||||
this.generations.manager,
|
||||
deviceHash,
|
||||
now,
|
||||
);
|
||||
const dayStart = kstStartOfDay(now);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
resetsAt: new Date(dayStart.getTime() + DAY_MS).toISOString(),
|
||||
tier: 'guest',
|
||||
};
|
||||
}
|
||||
|
||||
async remaining(deviceHash: string, now: Date): Promise<number> {
|
||||
return (await this.view(deviceHash, now)).remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 생성을 받아도 되는지 — 같은 기기의 요청을 직렬화한 트랜잭션 안에서 부른다.
|
||||
* 진행 중이 있으면 AI_004, 하루 횟수나 시도 상한(횟수의 3배)을 넘으면 AI_001, 하루 비용 상한을 넘으면 AI_002.
|
||||
*/
|
||||
async assertCanCreate(
|
||||
manager: EntityManager,
|
||||
deviceHash: string,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
const dayStart = kstStartOfDay(now);
|
||||
const repo = manager.getRepository(AiCourseGeneration);
|
||||
const active = await repo
|
||||
.createQueryBuilder('g')
|
||||
.where('g.device_hash = :deviceHash', { deviceHash })
|
||||
.andWhere('g.status IN (:...active)', { active: ACTIVE_STATUSES })
|
||||
.getCount();
|
||||
if (active > 0) {
|
||||
throw new BusinessException('AI_004', { context: { reason: 'active' } });
|
||||
}
|
||||
|
||||
const limit = this.settings.dailyLimitGuest;
|
||||
const used = await this.countUsed(manager, deviceHash, now);
|
||||
const attempts = await repo
|
||||
.createQueryBuilder('g')
|
||||
.where('g.device_hash = :deviceHash', { deviceHash })
|
||||
.andWhere('g.created_at >= :dayStart', { dayStart })
|
||||
.getCount();
|
||||
if (used >= limit || attempts >= limit * ATTEMPT_LIMIT_MULTIPLIER) {
|
||||
throw new BusinessException('AI_001', {
|
||||
context: { used, attempts, limit },
|
||||
});
|
||||
}
|
||||
|
||||
const raw = await repo
|
||||
.createQueryBuilder('g')
|
||||
.select('COALESCE(SUM(g.cost_usd), 0)', 'spent')
|
||||
.where('g.created_at >= :dayStart', { dayStart })
|
||||
.getRawOne<{ spent: string | number }>();
|
||||
const spent = Number(raw?.spent ?? 0);
|
||||
if (spent >= this.settings.dailyBudgetUsd) {
|
||||
// 키 유출·버그로 비용이 튀는 것을 막는 장치라 운영자가 바로 봐야 한다
|
||||
this.logger.error(
|
||||
`[AI_002] 하루 AI 비용 상한에 닿았다: $${spent.toFixed(4)} / $${this.settings.dailyBudgetUsd}`,
|
||||
);
|
||||
throw new BusinessException('AI_002', {
|
||||
context: { reason: 'daily_budget', spent },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private countUsed(
|
||||
manager: EntityManager,
|
||||
deviceHash: string,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
return manager
|
||||
.getRepository(AiCourseGeneration)
|
||||
.createQueryBuilder('g')
|
||||
.where('g.device_hash = :deviceHash', { deviceHash })
|
||||
.andWhere('g.counted = true')
|
||||
.andWhere('g.created_at >= :dayStart', { dayStart: kstStartOfDay(now) })
|
||||
.getCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { CandidateFacts } from './course-candidates.service';
|
||||
import type { ResolvedConditions } from './course-generation.types';
|
||||
import { buildCourseResult, kstIso } from './course-result.builder';
|
||||
import { planCourse } from './planner/plan-course';
|
||||
import { at, CENTER, context, place } from './planner/planner.fixtures';
|
||||
import type { Candidate, ShortlistEntry } from './planner/planner.types';
|
||||
|
||||
const facts = (provider: string): CandidateFacts => ({
|
||||
hoursText: '10:00~22:00',
|
||||
hoursOrigin: 'public',
|
||||
petOrigin: 'unknown',
|
||||
verifiedAt: null,
|
||||
provider,
|
||||
photo: { thumbUrl: 'https://example.test/a.jpg', license: 'Type3' },
|
||||
eventPeriod: null,
|
||||
});
|
||||
|
||||
describe('kstIso', () => {
|
||||
it('그날 한국 시각을 UTC ISO 로 바꾼다', () => {
|
||||
expect(kstIso('2026-11-07', at(14))).toBe('2026-11-07T05:00:00.000Z');
|
||||
expect(kstIso('2026-11-07', at(8))).toBe('2026-11-06T23:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCourseResult', () => {
|
||||
it('시간표에 DB 의 사실 값·출처와 AI 의 제목·이유를 붙인다', () => {
|
||||
const cafe: Candidate = place({
|
||||
category: 'cafe',
|
||||
hoursText: '10:00~22:00',
|
||||
restText: '매주 월요일',
|
||||
});
|
||||
const museum = place({ category: 'culture', east: 300 });
|
||||
const other = place({ category: 'cafe', north: 300 });
|
||||
const list: ShortlistEntry[] = [
|
||||
{ ref: 'P01', candidate: cafe, distanceM: 0 },
|
||||
{ ref: 'P02', candidate: museum, distanceM: 300 },
|
||||
{ ref: 'P03', candidate: other, distanceM: 300 },
|
||||
];
|
||||
const ctx = context();
|
||||
const plan = planCourse(
|
||||
{
|
||||
stops: [
|
||||
{ ref: 'P01', minutes: 60, reason: '창가 자리' },
|
||||
{ ref: 'P02', minutes: 90, reason: '전시' },
|
||||
],
|
||||
alternates: [{ forStop: 1, ref: 'P03', reason: '다른 카페' }],
|
||||
notices: [],
|
||||
},
|
||||
list,
|
||||
ctx,
|
||||
);
|
||||
if (plan.outcome === 'failed') throw new Error('코스를 만들지 못했다');
|
||||
const resolved: ResolvedConditions = {
|
||||
conditions: {
|
||||
area: { type: 'neighborhood', key: 'downtown' },
|
||||
date: ctx.date,
|
||||
start: '14:00',
|
||||
end: '20:00',
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
},
|
||||
center: CENTER,
|
||||
placeName: null,
|
||||
start: ctx.start,
|
||||
end: ctx.end,
|
||||
};
|
||||
const result = buildCourseResult({
|
||||
plan,
|
||||
title: '도심 산책',
|
||||
summary: '카페와 전시',
|
||||
notices: [],
|
||||
resolved,
|
||||
weather: {
|
||||
date: ctx.date,
|
||||
precipitationProbability: 20,
|
||||
precipitationType: 'none',
|
||||
sky: 'clear',
|
||||
temperatureMin: 10,
|
||||
temperatureMax: 15,
|
||||
issuedAt: '2026-11-07T02:00:00.000Z',
|
||||
},
|
||||
facts: new Map([
|
||||
[cafe.id, facts('한국관광공사')],
|
||||
[museum.id, facts('한국관광공사')],
|
||||
[other.id, facts('소상공인시장진흥공단 상가(상권)정보')],
|
||||
]),
|
||||
namesByRef: new Map(list.map((e) => [e.ref, e.candidate.name])),
|
||||
});
|
||||
|
||||
expect(result.window).toEqual({
|
||||
date: ctx.date,
|
||||
start: '14:00',
|
||||
end: '20:00',
|
||||
});
|
||||
expect(
|
||||
result.stops.map((s) => [s.id, s.arrive, s.minutes, s.reason]),
|
||||
).toEqual([
|
||||
[cafe.id, '2026-11-07T05:00:00.000Z', 60, '창가 자리'],
|
||||
[museum.id, '2026-11-07T06:10:00.000Z', 90, '전시'],
|
||||
]);
|
||||
expect(result.stops[0].facts).toMatchObject({
|
||||
// 2026-11-07 은 토요일이라 연다
|
||||
hoursOnDate: [{ start: '10:00', end: '22:00' }],
|
||||
setting: 'indoor',
|
||||
provider: '한국관광공사',
|
||||
});
|
||||
expect(result.stops[0].legToNext).toEqual({
|
||||
minutes: 10,
|
||||
meters: 300,
|
||||
mode: 'walk',
|
||||
estimated: true,
|
||||
});
|
||||
expect(result.alternates).toEqual([
|
||||
expect.objectContaining({
|
||||
id: other.id,
|
||||
forIndex: 0,
|
||||
reason: '다른 카페',
|
||||
}),
|
||||
]);
|
||||
expect(result.sources).toEqual([
|
||||
'한국관광공사',
|
||||
'소상공인시장진흥공단 상가(상권)정보',
|
||||
'기상청 단기예보',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 생성 결과(설계 §3.7) 만들기 (순수 함수) — 시간표(planCourse)에 DB 의 사실 값·출처와 AI 의 제목·요약·이유를 붙인다.
|
||||
* 화면에 보이는 사실 값은 모두 DB 에서 오고, AI 는 이유·요약만 쓴다(§1).
|
||||
*/
|
||||
import type { WindowForecast } from '@/modules/weather/kma-forecast';
|
||||
import { hoursOn } from '@/shared/utils/opening-hours';
|
||||
|
||||
import { formatHhmm } from './course-conditions';
|
||||
import type { CandidateFacts } from './course-candidates.service';
|
||||
import type { ResolvedConditions } from './course-generation.types';
|
||||
import type {
|
||||
CourseAlternateDto,
|
||||
CourseFactsDto,
|
||||
CourseLegDto,
|
||||
CourseResultDto,
|
||||
CourseStopDto,
|
||||
} from './dto/course-result.dto';
|
||||
import { settingOf } from './planner/candidate';
|
||||
import type {
|
||||
Candidate,
|
||||
Leg,
|
||||
PlannedAlternate,
|
||||
PlannedStop,
|
||||
PlanResult,
|
||||
StopWarning,
|
||||
} from './planner/planner.types';
|
||||
|
||||
const KST_OFFSET_MINUTES = 9 * 60;
|
||||
const WEATHER_SOURCE = '기상청 단기예보';
|
||||
|
||||
type SucceededPlan = Exclude<PlanResult, { outcome: 'failed' }>;
|
||||
|
||||
export interface ResultInput {
|
||||
plan: SucceededPlan;
|
||||
title: string;
|
||||
summary: string;
|
||||
/** AI 가 남긴 알림 */
|
||||
notices: string[];
|
||||
resolved: ResolvedConditions;
|
||||
weather: WindowForecast | null;
|
||||
facts: Map<string, CandidateFacts>;
|
||||
/** 짧은 번호 → 이름(뺀 장소 알림에 쓴다) */
|
||||
namesByRef: Map<string, string>;
|
||||
}
|
||||
|
||||
/** 그날 한국 시각(분) → ISO 8601 UTC */
|
||||
export function kstIso(date: string, minutes: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
return new Date(
|
||||
Date.UTC(y, m - 1, d, 0, minutes - KST_OFFSET_MINUTES),
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
function toLeg(leg: Leg | null): CourseLegDto | null {
|
||||
return leg
|
||||
? {
|
||||
minutes: leg.minutes,
|
||||
meters: leg.meters,
|
||||
mode: leg.mode,
|
||||
estimated: true,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function toFacts(
|
||||
candidate: Candidate,
|
||||
facts: CandidateFacts | undefined,
|
||||
date: string,
|
||||
): CourseFactsDto {
|
||||
const ranges = candidate.hours ? hoursOn(candidate.hours, date) : null;
|
||||
return {
|
||||
hoursOnDate: ranges
|
||||
? ranges.map((r) => ({
|
||||
start: formatHhmm(r.start),
|
||||
end: formatHhmm(r.end),
|
||||
}))
|
||||
: null,
|
||||
hoursText: facts?.hoursText ?? null,
|
||||
hoursOrigin: facts?.hoursOrigin ?? 'unknown',
|
||||
petPolicy: candidate.petPolicy,
|
||||
petOrigin: facts?.petOrigin ?? 'unknown',
|
||||
petSizes: candidate.petSizes,
|
||||
verifiedAt: facts?.verifiedAt
|
||||
? new Date(facts.verifiedAt).toISOString()
|
||||
: null,
|
||||
setting: settingOf(candidate),
|
||||
eventPeriod: facts?.eventPeriod ?? null,
|
||||
provider: facts?.provider ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function toStop(
|
||||
stop: Pick<PlannedStop, 'candidate' | 'arrive' | 'leave' | 'reason'> & {
|
||||
warnings: StopWarning[];
|
||||
},
|
||||
input: ResultInput,
|
||||
): Omit<CourseStopDto, 'legToNext'> {
|
||||
const { candidate } = stop;
|
||||
const facts = input.facts.get(candidate.id);
|
||||
const date = input.resolved.conditions.date;
|
||||
return {
|
||||
kind: candidate.kind,
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
category: candidate.category,
|
||||
eventKind: candidate.eventKind,
|
||||
photo: facts?.photo ?? null,
|
||||
arrive: kstIso(date, stop.arrive),
|
||||
leave: kstIso(date, stop.leave),
|
||||
minutes: stop.leave - stop.arrive,
|
||||
reason: stop.reason,
|
||||
facts: toFacts(candidate, facts, date),
|
||||
warnings: stop.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function toAlternate(
|
||||
alt: PlannedAlternate,
|
||||
stops: PlannedStop[],
|
||||
input: ResultInput,
|
||||
): CourseAlternateDto {
|
||||
return {
|
||||
...toStop(alt, input),
|
||||
legToNext: toLeg(alt.legToNext),
|
||||
forIndex: stops.findIndex((s) => s.ref === alt.forRef),
|
||||
legFromPrev: toLeg(alt.legFromPrev),
|
||||
};
|
||||
}
|
||||
|
||||
/** 출처 문구 — 코스에 쓴 장소·행사의 출처와 예보 */
|
||||
function sourcesOf(
|
||||
stops: CourseStopDto[],
|
||||
alternates: CourseAlternateDto[],
|
||||
weather: WindowForecast | null,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const s of [...stops, ...alternates]) {
|
||||
if (s.facts.provider && !out.includes(s.facts.provider)) {
|
||||
out.push(s.facts.provider);
|
||||
}
|
||||
}
|
||||
if (weather) out.push(WEATHER_SOURCE);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildCourseResult(input: ResultInput): CourseResultDto {
|
||||
const { plan, resolved } = input;
|
||||
const stops: CourseStopDto[] = plan.stops.map((s) => ({
|
||||
...toStop(s, input),
|
||||
legToNext: toLeg(s.legToNext),
|
||||
}));
|
||||
const alternates = plan.alternates
|
||||
.map((a) => toAlternate(a, plan.stops, input))
|
||||
.filter((a) => a.forIndex >= 0);
|
||||
return {
|
||||
outcome: plan.outcome,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
notices: input.notices,
|
||||
planNotices: plan.notices.map((n) => ({
|
||||
code: n.code,
|
||||
name: n.ref ? (input.namesByRef.get(n.ref) ?? null) : null,
|
||||
cause: n.cause ?? null,
|
||||
})),
|
||||
weather: input.weather,
|
||||
window: {
|
||||
date: resolved.conditions.date,
|
||||
start: formatHhmm(resolved.start),
|
||||
end: formatHhmm(resolved.end),
|
||||
},
|
||||
mobility: resolved.conditions.mobility,
|
||||
totals: plan.totals,
|
||||
stops,
|
||||
alternates,
|
||||
sources: sourcesOf(stops, alternates, input.weather),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
import {
|
||||
AI_TIMEOUT_MS_DEFAULT,
|
||||
DAILY_BUDGET_USD_DEFAULT,
|
||||
DAILY_LIMIT_GUEST_DEFAULT,
|
||||
DAILY_LIMIT_MEMBER_DEFAULT,
|
||||
} from './course-generation.constants';
|
||||
|
||||
/**
|
||||
* AI 코스 설정(설계 §11). 값이 없으면 코드 기본값을 쓴다 — 숫자 형식은 기동할 때 env.validation 이 본다.
|
||||
* 키는 backend/.env.{환경} 이나 compose 로 넘긴다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourseSettings {
|
||||
readonly dailyLimitGuest: number;
|
||||
readonly dailyLimitMember: number;
|
||||
readonly dailyBudgetUsd: number;
|
||||
readonly aiTimeoutMs: number;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const read = (key: string, fallback: number) =>
|
||||
parsePositiveInt(config.get<string>(key), fallback);
|
||||
this.dailyLimitGuest = read(
|
||||
'AI_DAILY_LIMIT_GUEST',
|
||||
DAILY_LIMIT_GUEST_DEFAULT,
|
||||
);
|
||||
this.dailyLimitMember = read(
|
||||
'AI_DAILY_LIMIT_MEMBER',
|
||||
DAILY_LIMIT_MEMBER_DEFAULT,
|
||||
);
|
||||
this.dailyBudgetUsd = read('AI_DAILY_BUDGET_USD', DAILY_BUDGET_USD_DEFAULT);
|
||||
this.aiTimeoutMs = read('AI_TIMEOUT_MS', AI_TIMEOUT_MS_DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { hashDeviceId } from './device-id.decorator';
|
||||
|
||||
describe('hashDeviceId', () => {
|
||||
it('같은 기기 ID 는 대소문자와 상관없이 같은 SHA-256 해시가 된다', () => {
|
||||
const id = '3F1C0A2E-0F1B-4F2A-9A1E-6C9F0A1B2C3D';
|
||||
expect(hashDeviceId(id)).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hashDeviceId(id)).toBe(hashDeviceId(id.toLowerCase()));
|
||||
expect(hashDeviceId(id)).not.toBe(
|
||||
hashDeviceId('00000000-0000-4000-8000-000000000000'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
import { DEVICE_ID_HEADER } from './course-generation.constants';
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** 기기 ID → SHA-256 해시(hex). 원문은 저장하지 않는다(설계 §5) */
|
||||
export function hashDeviceId(deviceId: string): string {
|
||||
return createHash('sha256').update(deviceId.toLowerCase()).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* X-Device-Id 헤더(앱이 처음 실행할 때 만든 UUID)를 해시로 바꿔 넘긴다. 없거나 UUID 가 아니면 VAL_001.
|
||||
* 비로그인 횟수와 '만든 기기만 볼 수 있음'을 이 값으로 가른다.
|
||||
*/
|
||||
export const DeviceHash = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
const deviceId = request.header(DEVICE_ID_HEADER)?.trim() ?? '';
|
||||
if (!UUID_RE.test(deviceId)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'device_id' },
|
||||
});
|
||||
}
|
||||
return hashDeviceId(deviceId);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDefined,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PET_SIZES } from '@/modules/place/place.constants';
|
||||
import type { PetSize } from '@/modules/place/place.constants';
|
||||
|
||||
import {
|
||||
AREA_TYPES,
|
||||
type AreaType,
|
||||
NEIGHBORHOOD_KEYS,
|
||||
type NeighborhoodKey,
|
||||
PARTY_MAX,
|
||||
PET_COUNT_MAX,
|
||||
PET_KINDS,
|
||||
REQUEST_TEXT_MAX,
|
||||
} from '../course-generation.constants';
|
||||
import { MOBILITIES, type Mobility } from '../planner/planner.constants';
|
||||
import type { PetCondition } from '../planner/planner.types';
|
||||
import { CourseWeatherDto } from './course-result.dto';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
/** 'HH:mm' — 끝 시각은 자정('24:00')까지 쓸 수 있다 */
|
||||
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/;
|
||||
|
||||
/** 지역 — 동네 · 현재 위치 · 기준 장소 중 하나(§2) */
|
||||
export class CourseAreaDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'지역 종류 — neighborhood 동네 · point 현재 위치 · place 기준 장소("서문시장 근처")',
|
||||
enum: AREA_TYPES,
|
||||
example: 'neighborhood',
|
||||
})
|
||||
@IsIn(AREA_TYPES, { message: '지역 종류가 올바르지 않습니다.' })
|
||||
type!: AreaType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '동네 키(type=neighborhood)',
|
||||
enum: NEIGHBORHOOD_KEYS,
|
||||
example: 'suseongmot',
|
||||
})
|
||||
@ValidateIf((o: CourseAreaDto) => o.type === 'neighborhood')
|
||||
@IsIn(NEIGHBORHOOD_KEYS, { message: '지원하지 않는 동네입니다.' })
|
||||
key?: NeighborhoodKey;
|
||||
|
||||
@ApiPropertyOptional({ description: '위도(type=point)', example: 35.8276 })
|
||||
@ValidateIf((o: CourseAreaDto) => o.type === 'point')
|
||||
@IsLatitude({ message: '위도가 올바르지 않습니다.' })
|
||||
lat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '경도(type=point)', example: 128.6177 })
|
||||
@ValidateIf((o: CourseAreaDto) => o.type === 'point')
|
||||
@IsLongitude({ message: '경도가 올바르지 않습니다.' })
|
||||
lng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '기준 장소 id(type=place)' })
|
||||
@ValidateIf((o: CourseAreaDto) => o.type === 'place')
|
||||
@IsUUID('all', { message: '장소 id 형식이 올바르지 않습니다.' })
|
||||
placeId?: string;
|
||||
}
|
||||
|
||||
/** 반려동물 */
|
||||
export class CoursePetDto {
|
||||
@ApiProperty({ description: '종류', enum: ['dog', 'cat', 'other'] })
|
||||
@IsIn(PET_KINDS, { message: '반려동물 종류가 올바르지 않습니다.' })
|
||||
kind!: PetCondition['kind'];
|
||||
|
||||
@ApiProperty({
|
||||
description: '크기(모르면 null)',
|
||||
enum: PET_SIZES,
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(PET_SIZES, { message: '반려동물 크기가 올바르지 않습니다.' })
|
||||
size!: PetSize | null;
|
||||
|
||||
@ApiProperty({ description: '마리 수', example: 1 })
|
||||
@IsInt({ message: '반려동물 수는 정수로 입력해 주세요.' })
|
||||
@Min(1, { message: '반려동물 수는 1 이상이어야 합니다.' })
|
||||
@Max(PET_COUNT_MAX, {
|
||||
message: `반려동물 수는 ${PET_COUNT_MAX} 이하여야 합니다.`,
|
||||
})
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** 코스 조건(CourseConditions, §2) — 조건 해석 응답과 생성 요청이 같은 형식을 쓴다 */
|
||||
export class CourseConditionsDto {
|
||||
@ApiProperty({ description: '지역', type: CourseAreaDto })
|
||||
@IsDefined({ message: '지역을 골라 주세요.' })
|
||||
@ValidateNested()
|
||||
@Type(() => CourseAreaDto)
|
||||
area!: CourseAreaDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '날짜(YYYY-MM-DD, 오늘~30일 뒤)',
|
||||
example: '2026-11-14',
|
||||
})
|
||||
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ description: '시작 시각(HH:mm)', example: '13:00' })
|
||||
@Matches(TIME_RE, { message: '시각은 HH:mm 형식으로 입력해 주세요.' })
|
||||
start!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '끝 시각(HH:mm, 시작 + 2~12시간, 자정까지)',
|
||||
example: '19:00',
|
||||
})
|
||||
@Matches(TIME_RE, { message: '시각은 HH:mm 형식으로 입력해 주세요.' })
|
||||
end!: string;
|
||||
|
||||
@ApiProperty({ description: '인원(1~10)', example: 2 })
|
||||
@IsInt({ message: '인원은 정수로 입력해 주세요.' })
|
||||
@Min(1, { message: '인원은 1명 이상이어야 합니다.' })
|
||||
@Max(PARTY_MAX, { message: `인원은 ${PARTY_MAX}명 이하여야 합니다.` })
|
||||
party!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '반려동물(없으면 null)',
|
||||
type: CoursePetDto,
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CoursePetDto)
|
||||
pet!: CoursePetDto | null;
|
||||
|
||||
@ApiProperty({ description: '이동 수단', enum: MOBILITIES, example: 'walk' })
|
||||
@IsIn(MOBILITIES, { message: '이동 수단이 올바르지 않습니다.' })
|
||||
mobility!: Mobility;
|
||||
|
||||
@ApiProperty({ description: '비가 오면 실내 위주로', example: true })
|
||||
@IsBoolean({ message: 'indoorIfRain 은 true 또는 false 로 입력해 주세요.' })
|
||||
indoorIfRain!: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/course-conditions 요청 */
|
||||
export class ParseConditionsRequestDto {
|
||||
@ApiProperty({
|
||||
description: `사용자가 쓴 문장(${REQUEST_TEXT_MAX}자 이내, 비어도 된다)`,
|
||||
example: '비 오는 토요일 오후, 강아지랑 수성못 데이트',
|
||||
})
|
||||
@IsString({ message: '문장은 문자열로 입력해 주세요.' })
|
||||
@MaxLength(REQUEST_TEXT_MAX, {
|
||||
message: `문장은 ${REQUEST_TEXT_MAX}자 이내로 입력해 주세요.`,
|
||||
})
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({ description: '앱의 기준 위치', type: CourseAreaDto })
|
||||
@IsDefined({ message: '기준 위치를 보내 주세요.' })
|
||||
@ValidateNested()
|
||||
@Type(() => CourseAreaDto)
|
||||
baseArea!: CourseAreaDto;
|
||||
}
|
||||
|
||||
/** POST /api/course-conditions 응답 */
|
||||
export class ParseConditionsResponseDto {
|
||||
@ApiProperty({ description: '채운 조건', type: CourseConditionsDto })
|
||||
conditions!: CourseConditionsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'문장에서 뽑은 항목(화면의 "적은 내용에서 자동으로 채웠어요") — area · date · start · end · party · pet · mobility · indoorIfRain',
|
||||
type: [String],
|
||||
example: ['date', 'pet'],
|
||||
})
|
||||
inferred!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'되묻거나 알릴 것 — unsupported_area · past_date · date_out_of_range · multi_day · too_short · past_midnight · unclear_request',
|
||||
type: [String],
|
||||
example: [],
|
||||
})
|
||||
issues!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '그 날짜·지역의 예보(4일 뒤 이후·실패면 null)',
|
||||
type: CourseWeatherDto,
|
||||
nullable: true,
|
||||
})
|
||||
weather!: CourseWeatherDto | null;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDefined,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
FAILURE_CODES,
|
||||
type FailureCode,
|
||||
GENERATION_STAGES,
|
||||
GENERATION_STATUSES,
|
||||
type GenerationStage,
|
||||
type GenerationStatus,
|
||||
RATING_VALUES,
|
||||
type RatingValue,
|
||||
REQUEST_TEXT_MAX,
|
||||
} from '../course-generation.constants';
|
||||
import { CourseConditionsDto } from './course-conditions.dto';
|
||||
import { CourseResultDto } from './course-result.dto';
|
||||
|
||||
/** POST /api/course-generations 요청 */
|
||||
export class CreateCourseGenerationDto {
|
||||
@ApiProperty({
|
||||
description: `사용자가 쓴 문장(${REQUEST_TEXT_MAX}자 이내, 비어도 된다). 전화번호·이메일 모양은 가린 뒤 저장한다`,
|
||||
example: '비 오는 토요일 오후, 강아지랑 수성못 데이트',
|
||||
})
|
||||
@IsString({ message: '문장은 문자열로 입력해 주세요.' })
|
||||
@MaxLength(REQUEST_TEXT_MAX, {
|
||||
message: `문장은 ${REQUEST_TEXT_MAX}자 이내로 입력해 주세요.`,
|
||||
})
|
||||
request!: string;
|
||||
|
||||
@ApiProperty({ description: '확인한 조건', type: CourseConditionsDto })
|
||||
@IsDefined({ message: '조건을 보내 주세요.' })
|
||||
@ValidateNested()
|
||||
@Type(() => CourseConditionsDto)
|
||||
conditions!: CourseConditionsDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '다시 만들기라면 이전 생성 id(같은 기기의 생성만)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('all', { message: '이전 생성 id 형식이 올바르지 않습니다.' })
|
||||
previousGenerationId?: string;
|
||||
}
|
||||
|
||||
/** POST /api/course-generations 응답(201) */
|
||||
export class CourseGenerationCreatedDto {
|
||||
@ApiProperty({ description: '생성 id — 진행 조회에 쓴다' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: GENERATION_STATUSES, example: 'queued' })
|
||||
status!: GenerationStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: '오늘 남은 횟수(결과가 나온 생성만 차감한다)',
|
||||
example: 2,
|
||||
})
|
||||
remaining!: number;
|
||||
}
|
||||
|
||||
export class CourseGenerationProgressDto {
|
||||
@ApiProperty({ description: '찾은 후보 수', nullable: true, example: 128 })
|
||||
found!: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '거른 뒤 남은 후보 수',
|
||||
nullable: true,
|
||||
example: 31,
|
||||
})
|
||||
kept!: number | null;
|
||||
}
|
||||
|
||||
export class CourseGenerationFailureDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'AI_002 AI 를 쓸 수 없음 · AI_003 조건에 맞는 장소 부족(문구는 에러 코드 사전)',
|
||||
enum: FAILURE_CODES,
|
||||
})
|
||||
code!: FailureCode;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이유("대형견 동반이 확인된 실내 전시가 없어요")',
|
||||
type: [String],
|
||||
})
|
||||
notices!: string[];
|
||||
}
|
||||
|
||||
/** GET /api/course-generations/{id} 응답 — 생성이 실패해도 조회는 200 이다 */
|
||||
export class CourseGenerationDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: GENERATION_STATUSES })
|
||||
status!: GenerationStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: '진행 단계 — 화면 문구는 앱이 언어별로 만든다(§3.1)',
|
||||
enum: GENERATION_STAGES,
|
||||
nullable: true,
|
||||
})
|
||||
stage!: GenerationStage | null;
|
||||
|
||||
@ApiProperty({ type: CourseGenerationProgressDto })
|
||||
progress!: CourseGenerationProgressDto;
|
||||
|
||||
@ApiProperty({ type: CourseResultDto, nullable: true })
|
||||
result!: CourseResultDto | null;
|
||||
|
||||
@ApiProperty({ type: CourseGenerationFailureDto, nullable: true })
|
||||
failure!: CourseGenerationFailureDto | null;
|
||||
|
||||
@ApiProperty({ description: '오늘 남은 횟수', example: 1 })
|
||||
remaining!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '내가 남긴 평가',
|
||||
enum: RATING_VALUES,
|
||||
nullable: true,
|
||||
})
|
||||
rating!: RatingValue | null;
|
||||
}
|
||||
|
||||
/** GET /api/course-generations/quota 응답 — 입력 화면의 "오늘 2번 만들 수 있어요" */
|
||||
export class CourseQuotaDto {
|
||||
@ApiProperty({ description: '하루 횟수', example: 2 })
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty({ description: '오늘 쓴 횟수(결과가 나온 생성)', example: 0 })
|
||||
used!: number;
|
||||
|
||||
@ApiProperty({ example: 2 })
|
||||
remaining!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '다시 채워지는 시각(한국 시간 자정, ISO 8601 UTC)',
|
||||
example: '2026-11-08T15:00:00.000Z',
|
||||
})
|
||||
resetsAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'guest 비로그인 · member 로그인',
|
||||
enum: ['guest', 'member'],
|
||||
})
|
||||
tier!: 'guest' | 'member';
|
||||
}
|
||||
|
||||
/** PUT /api/course-generations/{id}/rating 요청 */
|
||||
export class RateCourseGenerationDto {
|
||||
@ApiProperty({
|
||||
description: '"이 코스, 마음에 드세요?"',
|
||||
enum: RATING_VALUES,
|
||||
})
|
||||
@IsIn(RATING_VALUES, { message: '평가는 like 또는 dislike 로 보내 주세요.' })
|
||||
value!: RatingValue;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
IMAGE_LICENSES,
|
||||
PET_POLICIES,
|
||||
PET_SIZES,
|
||||
} from '@/modules/place/place.constants';
|
||||
import type {
|
||||
ImageLicense,
|
||||
PetPolicy,
|
||||
PetSize,
|
||||
} from '@/modules/place/place.constants';
|
||||
import type { ValueOrigin } from '@/modules/place/entities/place-listing.entity';
|
||||
|
||||
import { MOBILITIES, type Mobility } from '../planner/planner.constants';
|
||||
import type { PlanNoticeCode, StopWarning } from '../planner/planner.types';
|
||||
|
||||
/** 기상청 단기예보 요약(§3.7) */
|
||||
export class CourseWeatherDto {
|
||||
@ApiProperty({ description: '날짜(YYYY-MM-DD)', example: '2026-11-14' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시간 범위 중 가장 높은 강수확률(%)',
|
||||
example: 60,
|
||||
})
|
||||
precipitationProbability!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '강수 형태(강수확률이 가장 높은 때)',
|
||||
enum: ['none', 'rain', 'rain_snow', 'snow', 'shower'],
|
||||
})
|
||||
precipitationType!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '하늘 상태(가장 잦은 것)',
|
||||
enum: ['clear', 'partly_cloudy', 'cloudy'],
|
||||
nullable: true,
|
||||
})
|
||||
sky!: string | null;
|
||||
|
||||
@ApiProperty({ description: '최저 기온(℃)', nullable: true, example: 9 })
|
||||
temperatureMin!: number | null;
|
||||
|
||||
@ApiProperty({ description: '최고 기온(℃)', nullable: true, example: 14 })
|
||||
temperatureMax!: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '발표 시각(ISO 8601 UTC)',
|
||||
example: '2026-11-14T02:00:00.000Z',
|
||||
})
|
||||
issuedAt!: string;
|
||||
}
|
||||
|
||||
/** 영업 구간 하나 — 'HH:mm', 자정을 넘기면 끝이 '24:00' 보다 크다('26:00' = 다음 날 02:00) */
|
||||
export class CourseTimeRangeDto {
|
||||
@ApiProperty({ example: '10:00' })
|
||||
start!: string;
|
||||
|
||||
@ApiProperty({ example: '22:00' })
|
||||
end!: string;
|
||||
}
|
||||
|
||||
/** 화면에 보이는 사실 값 — 모두 DB 에서 붙인다(AI 가 지어낼 수 없다, §1) */
|
||||
export class CourseFactsDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그날 영업 구간. 쉬는 날이면 빈 배열, 정할 수 없으면 null(영업시간 미확인)',
|
||||
type: [CourseTimeRangeDto],
|
||||
nullable: true,
|
||||
})
|
||||
hoursOnDate!: CourseTimeRangeDto[] | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '영업시간 원문(행사는 운영 시간 문구)',
|
||||
nullable: true,
|
||||
})
|
||||
hoursText!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'영업시간 출처(curation 확인한 곳 · public 공공데이터 · unknown 미확인)',
|
||||
enum: ['curation', 'public', 'unknown'],
|
||||
})
|
||||
hoursOrigin!: ValueOrigin;
|
||||
|
||||
@ApiProperty({ description: '반려동물 동반', enum: PET_POLICIES })
|
||||
petPolicy!: PetPolicy;
|
||||
|
||||
@ApiProperty({
|
||||
description: '반려동물 정보 출처',
|
||||
enum: ['curation', 'public', 'unknown'],
|
||||
})
|
||||
petOrigin!: ValueOrigin;
|
||||
|
||||
@ApiProperty({
|
||||
description: '동반 가능 크기(확인한 곳만)',
|
||||
enum: PET_SIZES,
|
||||
isArray: true,
|
||||
nullable: true,
|
||||
})
|
||||
petSizes!: PetSize[] | null;
|
||||
|
||||
@ApiProperty({ description: '운영자가 확인한 날(ISO 8601)', nullable: true })
|
||||
verifiedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '실내·외(큐레이션 값, 없으면 분류로 추정)',
|
||||
enum: ['indoor', 'outdoor', 'unknown'],
|
||||
})
|
||||
setting!: 'indoor' | 'outdoor' | 'unknown';
|
||||
|
||||
@ApiProperty({
|
||||
description: '행사 기간(YYYY-MM-DD, 장소는 null)',
|
||||
nullable: true,
|
||||
example: { start: '2026-10-27', end: '2026-11-22' },
|
||||
})
|
||||
eventPeriod!: { start: string; end: string } | null;
|
||||
|
||||
@ApiProperty({ description: '출처', example: '한국관광공사' })
|
||||
provider!: string;
|
||||
}
|
||||
|
||||
/** 장소 사이 이동(직선거리 추정, '약 N분') */
|
||||
export class CourseLegDto {
|
||||
@ApiProperty({ example: 10 })
|
||||
minutes!: number;
|
||||
|
||||
@ApiProperty({ description: '직선거리(m)', example: 640 })
|
||||
meters!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '실제 이동 수단(대중교통·자동차라도 가까우면 walk)',
|
||||
enum: MOBILITIES,
|
||||
})
|
||||
mode!: Mobility;
|
||||
|
||||
@ApiProperty({
|
||||
description: '추정값인가(경로 API 전에는 늘 true)',
|
||||
example: true,
|
||||
})
|
||||
estimated!: boolean;
|
||||
}
|
||||
|
||||
/** 사진 */
|
||||
export class CoursePhotoDto {
|
||||
@ApiProperty()
|
||||
thumbUrl!: string;
|
||||
|
||||
@ApiProperty({ enum: IMAGE_LICENSES, nullable: true })
|
||||
license!: ImageLicense | null;
|
||||
}
|
||||
|
||||
/** 코스의 장소·행사 하나 */
|
||||
export class CourseStopDto {
|
||||
@ApiProperty({ enum: ['place', 'event'] })
|
||||
kind!: 'place' | 'event';
|
||||
|
||||
@ApiProperty({ description: '장소 id 또는 행사 id' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: '수성못' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '분류(행사는 event)', example: 'nature' })
|
||||
category!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '행사 종류(장소는 null)',
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
eventKind!: string | null;
|
||||
|
||||
@ApiProperty({ type: CoursePhotoDto, nullable: true })
|
||||
photo!: CoursePhotoDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '도착(ISO 8601 UTC)',
|
||||
example: '2026-11-14T05:00:00.000Z',
|
||||
})
|
||||
arrive!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '떠남(ISO 8601 UTC)',
|
||||
example: '2026-11-14T06:00:00.000Z',
|
||||
})
|
||||
leave!: string;
|
||||
|
||||
@ApiProperty({ description: '머무는 시간(분)', example: 60 })
|
||||
minutes!: number;
|
||||
|
||||
@ApiProperty({ description: '고른 이유(AI, 60자 이내)' })
|
||||
reason!: string;
|
||||
|
||||
@ApiProperty({ type: CourseFactsDto })
|
||||
facts!: CourseFactsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'경고 — hours_unknown 영업시간 미확인 · event_time_unknown 행사 시간 확인 · pet_unknown 동반 정보 없음 · event_last_day 행사 마지막 날',
|
||||
enum: [
|
||||
'hours_unknown',
|
||||
'event_time_unknown',
|
||||
'pet_unknown',
|
||||
'event_last_day',
|
||||
],
|
||||
isArray: true,
|
||||
})
|
||||
warnings!: StopWarning[];
|
||||
|
||||
@ApiProperty({ type: CourseLegDto, nullable: true })
|
||||
legToNext!: CourseLegDto | null;
|
||||
}
|
||||
|
||||
/** 바꾸기 후보 — 같은 시간대로 검증했고 앞뒤 이동도 다시 계산했다(앱이 AI 호출 없이 바꾼다) */
|
||||
export class CourseAlternateDto extends CourseStopDto {
|
||||
@ApiProperty({
|
||||
description: '몇 번째 장소 대신인가(stops 의 0부터 자리)',
|
||||
example: 1,
|
||||
})
|
||||
forIndex!: number;
|
||||
|
||||
@ApiProperty({ type: CourseLegDto, nullable: true })
|
||||
legFromPrev!: CourseLegDto | null;
|
||||
}
|
||||
|
||||
/** 코드가 남긴 알림 — 문구는 앱이 언어별로 만든다 */
|
||||
export class CoursePlanNoticeDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'stop_removed 장소를 뺐음 · indoor_ratio_unmet 실내 후보가 모자람',
|
||||
enum: ['stop_removed', 'indoor_ratio_unmet'],
|
||||
})
|
||||
code!: PlanNoticeCode;
|
||||
|
||||
@ApiProperty({ description: '뺀 장소 이름', nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '뺀 이유(closed · last_order · window · leg_too_long · pet)',
|
||||
nullable: true,
|
||||
})
|
||||
cause!: string | null;
|
||||
}
|
||||
|
||||
export class CourseWindowDto {
|
||||
@ApiProperty({ example: '2026-11-14' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ example: '13:00' })
|
||||
start!: string;
|
||||
|
||||
@ApiProperty({ example: '19:00' })
|
||||
end!: string;
|
||||
}
|
||||
|
||||
export class CourseTotalsDto {
|
||||
@ApiProperty({ example: 4 })
|
||||
stops!: number;
|
||||
|
||||
@ApiProperty({ description: '첫 도착부터 마지막 떠남까지(분)', example: 330 })
|
||||
totalMinutes!: number;
|
||||
|
||||
@ApiProperty({ description: '이동 시간 합계(분)', example: 35 })
|
||||
travelMinutes!: number;
|
||||
|
||||
@ApiProperty({ description: '반려동물 동반이 확인된 곳 수', example: 0 })
|
||||
petConfirmed!: number;
|
||||
}
|
||||
|
||||
/** 생성 결과(§3.7) — 저장하기 전의 초안이고 사실 값은 생성 시점의 스냅샷이다 */
|
||||
export class CourseResultDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'course 조건대로 · partial 일부만 맞춤(장소를 뺐거나 AI 가 알림을 남김)',
|
||||
enum: ['course', 'partial'],
|
||||
})
|
||||
outcome!: 'course' | 'partial';
|
||||
|
||||
@ApiProperty({ description: '코스 이름(AI, 20자 이내)' })
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: '요약(AI, 120자 이내)' })
|
||||
summary!: string;
|
||||
|
||||
@ApiProperty({ description: 'AI 가 남긴 알림', type: [String] })
|
||||
notices!: string[];
|
||||
|
||||
@ApiProperty({ type: [CoursePlanNoticeDto] })
|
||||
planNotices!: CoursePlanNoticeDto[];
|
||||
|
||||
@ApiProperty({ type: CourseWeatherDto, nullable: true })
|
||||
weather!: CourseWeatherDto | null;
|
||||
|
||||
@ApiProperty({ type: CourseWindowDto })
|
||||
window!: CourseWindowDto;
|
||||
|
||||
@ApiProperty({ enum: MOBILITIES })
|
||||
mobility!: Mobility;
|
||||
|
||||
@ApiProperty({ type: CourseTotalsDto })
|
||||
totals!: CourseTotalsDto;
|
||||
|
||||
@ApiProperty({ type: [CourseStopDto] })
|
||||
stops!: CourseStopDto[];
|
||||
|
||||
@ApiProperty({ type: [CourseAlternateDto] })
|
||||
alternates!: CourseAlternateDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '출처 문구',
|
||||
type: [String],
|
||||
example: ['한국관광공사 TourAPI', '기상청 단기예보'],
|
||||
})
|
||||
sources!: string[];
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { sqlIn } from '@/modules/place/place.constants';
|
||||
|
||||
import {
|
||||
COURSE_LANGS,
|
||||
type CourseLang,
|
||||
FAILURE_CODES,
|
||||
type FailureCode,
|
||||
GENERATION_STAGES,
|
||||
GENERATION_STATUSES,
|
||||
type GenerationStage,
|
||||
type GenerationStatus,
|
||||
RATING_VALUES,
|
||||
type RatingValue,
|
||||
} from '../course-generation.constants';
|
||||
import type { StoredConditions } from '../course-generation.types';
|
||||
import type { CourseResultDto } from '../dto/course-result.dto';
|
||||
|
||||
/**
|
||||
* AI 코스 생성 한 번(설계 docs/design/ai-course.md §6.1). 하루 횟수·비용도 이 테이블에서 센다(집계 테이블을 따로 두지 않는다).
|
||||
* 요청 문장과 결과는 90일 보관한 뒤 문장은 지우고 지표만 남긴다(§10, 정리 작업은 출시 준비 단계).
|
||||
*/
|
||||
@Entity({ name: 'ai_course_generation' })
|
||||
@Index('ix_ai_course_generation_device', ['deviceHash', 'createdAt'])
|
||||
@Index('ix_ai_course_generation_user', ['userId', 'createdAt'])
|
||||
@Index('ix_ai_course_generation_status', ['status'])
|
||||
@Check('ck_ai_course_generation_status', sqlIn('status', GENERATION_STATUSES))
|
||||
@Check('ck_ai_course_generation_stage', sqlIn('stage', GENERATION_STAGES, true))
|
||||
@Check('ck_ai_course_generation_lang', sqlIn('lang', COURSE_LANGS))
|
||||
@Check(
|
||||
'ck_ai_course_generation_failure_code',
|
||||
sqlIn('failure_code', FAILURE_CODES, true),
|
||||
)
|
||||
@Check('ck_ai_course_generation_rating', sqlIn('rating', RATING_VALUES, true))
|
||||
export class AiCourseGeneration {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
/** 로그인 사용자(인증 모듈이 생기면 FK). 비로그인이면 null */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
userId!: string | null;
|
||||
|
||||
/** 기기 ID 의 SHA-256 해시 — 원문은 저장하지 않는다(§5) */
|
||||
@Column({ type: 'text' })
|
||||
deviceHash!: string;
|
||||
|
||||
@Column({ type: 'text', default: 'ko' })
|
||||
lang!: CourseLang;
|
||||
|
||||
/** 사용자가 쓴 문장(전화번호·이메일 모양은 가린 뒤). 보관 기간이 지나면 지운다 */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
requestText!: string | null;
|
||||
|
||||
/** 사용자가 확인한 조건(CourseConditions) */
|
||||
@Column({ type: 'jsonb' })
|
||||
conditions!: StoredConditions;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: GenerationStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
stage!: GenerationStage | null;
|
||||
|
||||
/** 진행 표시 숫자 — 찾은 후보 · 거른 뒤 남은 후보 */
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
foundCount!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
keptCount!: number | null;
|
||||
|
||||
/** 결과 초안(§3.7). 실패해도 AI 가 남긴 알림은 failureNotices 에 둔다 */
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
result!: CourseResultDto | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
failureCode!: FailureCode | null;
|
||||
|
||||
/** 실패 이유(AI 가 남긴 알림 · 코드가 남긴 이유) */
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
failureNotices!: string[] | null;
|
||||
|
||||
/** 하루 횟수를 차감했는지 — 결과가 나온 생성(succeeded)만 차감한다 */
|
||||
@Column({ type: 'boolean', default: false })
|
||||
counted!: boolean;
|
||||
|
||||
/** 다시 만들기라면 이전 생성 */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
previousId!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
model!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
promptVersion!: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
inputTokens!: number;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
outputTokens!: number;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
cacheReadTokens!: number;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
cacheWriteTokens!: number;
|
||||
|
||||
/** 이 생성에 쓴 금액(토큰 × 가격 상수) — 하루 비용 상한을 센다 */
|
||||
@Column({ type: 'double precision', default: 0 })
|
||||
costUsd!: number;
|
||||
|
||||
/** 생성 시작부터 끝까지(ms) */
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
latencyMs!: number | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
llmCalls!: number;
|
||||
|
||||
/** 거친 고치기 단계(품질 지표) */
|
||||
@Column({ type: 'jsonb', default: () => `'[]'` })
|
||||
repairs!: string[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rating!: RatingValue | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
ratedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 작업을 시작한 시각 — 멈춘 작업을 찾는다 */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
startedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
finishedAt!: Date | null;
|
||||
}
|
||||
@@ -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,120 @@
|
||||
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: 'seollal' }))).toBe(
|
||||
'holiday',
|
||||
);
|
||||
// 어린이날에는 설·추석 휴무인 곳을 빼지 않는다
|
||||
expect(exclusionReason(museum, context({ holiday: 'public' }))).toBeNull();
|
||||
expect(exclusionReason(museum, ctx)).toBeNull();
|
||||
const closedOnHolidays = place({ restText: '매주 월요일 / 공휴일' });
|
||||
expect(
|
||||
exclusionReason(closedOnHolidays, context({ holiday: 'public' })),
|
||||
).toBe('holiday');
|
||||
});
|
||||
|
||||
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,94 @@
|
||||
/**
|
||||
* 후보 거르기(설계 §3.3) — 반경·행사 기간·공휴일·반려동물·영업을 코드로 본다.
|
||||
* 비는 거르지 않는다(시간표 검증에서 실내 비율을 본다).
|
||||
*/
|
||||
import { distanceMeters } from '@/shared/utils/geo';
|
||||
import { closedOnHoliday } from '@/shared/utils/kr-holidays';
|
||||
|
||||
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 &&
|
||||
closedOnHoliday(c.hours.holidayClosures, ctx.holiday)
|
||||
) {
|
||||
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: null,
|
||||
preferIndoor: false,
|
||||
previousIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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 { HolidayKind } from '@/shared/utils/kr-holidays';
|
||||
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;
|
||||
/** 그날의 공휴일 종류(shared/utils/kr-holidays 의 holidayOn). 공휴일이 아니면 null */
|
||||
holiday: HolidayKind | null;
|
||||
/** 비 조건 — 강수확률 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];
|
||||
}
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
EventSearchQueryDto,
|
||||
} from './dto/event-search-query.dto';
|
||||
import { FestivalEvent } from './entities/festival-event.entity';
|
||||
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
|
||||
import type { EventSource } from './place.constants';
|
||||
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
|
||||
import { boundingBox, sqlDistanceM } from './place.sql';
|
||||
import { boundingBox, sqlDistanceM, sqlEventWithinMaxSpan } from './place.sql';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
@@ -32,11 +31,6 @@ const PROVIDER_LABEL: Record<EventSource, string> = {
|
||||
dgfca: '대구문화예술진흥원',
|
||||
};
|
||||
|
||||
/** 종류별 기간 상한 SQL — 상수(EVENT_MAX_SPAN_DAYS)로만 만든다 */
|
||||
const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS)
|
||||
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
|
||||
.join(' ')} END`;
|
||||
|
||||
/**
|
||||
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
|
||||
* 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다.
|
||||
@@ -84,7 +78,7 @@ export class FestivalService {
|
||||
.where('e.status = :active', { active: 'active' })
|
||||
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
|
||||
// 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게)
|
||||
.andWhere(`(e.end_date - e.start_date) <= ${MAX_SPAN_SQL}`);
|
||||
.andWhere(sqlEventWithinMaxSpan('e'));
|
||||
if (query.kind?.length) {
|
||||
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export class PlaceService {
|
||||
private toListItem(
|
||||
v: PlaceListing,
|
||||
distance: number | null,
|
||||
clock: { weekday: number; minutes: number },
|
||||
clock: { date: string; minutes: number },
|
||||
): PlaceListItemDto {
|
||||
return {
|
||||
id: v.id,
|
||||
@@ -237,7 +237,7 @@ export class PlaceService {
|
||||
imageThumbUrl: v.imageThumbUrl,
|
||||
imageLicense: v.imageLicense,
|
||||
hoursOrigin: v.hoursOrigin,
|
||||
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
|
||||
openNow: v.hours ? isOpenAt(v.hours, clock.date, clock.minutes) : null,
|
||||
petPolicy: v.petPolicy,
|
||||
petOrigin: v.petOrigin,
|
||||
setting: v.setting,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
|
||||
*/
|
||||
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
|
||||
|
||||
/** 위도 1도의 거리(m) */
|
||||
export const METERS_PER_LAT_DEGREE = 111_320;
|
||||
@@ -39,3 +40,14 @@ export function boundingBox(
|
||||
lngMax: lng + dLng,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 종류별 기간 상한 안의 행사인가(상시 프로그램 제외) — 상수(EVENT_MAX_SPAN_DAYS)로만 만드는 SQL 조건.
|
||||
* @param alias festival_event 별칭
|
||||
*/
|
||||
export function sqlEventWithinMaxSpan(alias: string): string {
|
||||
const cases = Object.entries(EVENT_MAX_SPAN_DAYS)
|
||||
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
|
||||
.join(' ');
|
||||
return `(${alias}.end_date - ${alias}.start_date) <= CASE ${alias}.kind ${cases} END`;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface PublicDataClientSettings {
|
||||
keyParam?: string;
|
||||
/** 연속 호출 최소 간격(ms) */
|
||||
intervalMs?: number;
|
||||
/** 호출 하나의 제한 시간(ms) — 사용자 요청 중에 부르는 API(날씨)는 짧게 둔다 */
|
||||
timeoutMs?: number;
|
||||
/** 네트워크 오류·5xx 때 몇 번까지 시도할지(처음 호출 포함) */
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
@@ -88,6 +92,8 @@ export abstract class PublicDataClient<Page> {
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
private readonly keyParam: string;
|
||||
private readonly intervalMs: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
private lastCallAt = 0;
|
||||
|
||||
protected constructor(
|
||||
@@ -100,6 +106,8 @@ export abstract class PublicDataClient<Page> {
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
this.keyParam = settings.keyParam ?? 'serviceKey';
|
||||
this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS;
|
||||
this.timeoutMs = settings.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
this.maxAttempts = settings.maxAttempts ?? MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
/** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */
|
||||
@@ -124,12 +132,12 @@ export abstract class PublicDataClient<Page> {
|
||||
const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
|
||||
this.options.budget.take(operation);
|
||||
await this.pace();
|
||||
try {
|
||||
const res = await this.fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
const body = await res.text();
|
||||
if (res.status >= 500) {
|
||||
@@ -153,13 +161,15 @@ export abstract class PublicDataClient<Page> {
|
||||
}
|
||||
lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
|
||||
if (attempt < this.maxAttempts) {
|
||||
await this.sleep(RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
const reason =
|
||||
lastError instanceof Error ? lastError.message : String(lastError);
|
||||
throw this.mask(
|
||||
new PublicDataApiError(
|
||||
`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`,
|
||||
`${operation}: ${this.maxAttempts}회 시도 실패 — ${reason}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
BudgetExceededError,
|
||||
CallBudget,
|
||||
} from '@/modules/place/sync/call-budget';
|
||||
import {
|
||||
type FetchLike,
|
||||
PublicDataApiError,
|
||||
} from '@/modules/place/sync/public-data.client';
|
||||
|
||||
import { KmaForecastClient, parseKmaPage } from './kma-forecast.client';
|
||||
|
||||
const KEY = 'abc+def/ghi==';
|
||||
|
||||
const body = (resultCode: string, item?: unknown, totalCount = 1) =>
|
||||
JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode, resultMsg: 'X' },
|
||||
body: item === undefined ? undefined : { items: { item }, totalCount },
|
||||
},
|
||||
});
|
||||
|
||||
function client(
|
||||
responses: Array<{ status?: number; body?: string; throws?: Error }>,
|
||||
) {
|
||||
const urls: string[] = [];
|
||||
const fetchImpl: FetchLike = (url) => {
|
||||
urls.push(url);
|
||||
const next = responses.shift();
|
||||
if (!next) throw new Error('예상보다 많이 호출했다');
|
||||
if (next.throws) return Promise.reject(next.throws);
|
||||
const status = next.status ?? 200;
|
||||
return Promise.resolve({
|
||||
ok: status < 400,
|
||||
status,
|
||||
text: () => Promise.resolve(next.body ?? ''),
|
||||
});
|
||||
};
|
||||
const api = new KmaForecastClient({
|
||||
serviceKey: KEY,
|
||||
budget: new CallBudget(100),
|
||||
fetchImpl,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
return { api, urls };
|
||||
}
|
||||
|
||||
describe('parseKmaPage', () => {
|
||||
it('정상(00)이면 항목을, 03(NODATA)이면 자료 없음을 돌려준다', () => {
|
||||
const item = {
|
||||
category: 'POP',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1500',
|
||||
fcstValue: '60',
|
||||
};
|
||||
expect(parseKmaPage(body('00', [item], 1), 'getVilageFcst')).toEqual({
|
||||
items: [item],
|
||||
totalCount: 1,
|
||||
noData: false,
|
||||
});
|
||||
expect(parseKmaPage(body('03'), 'getVilageFcst')).toEqual({
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
noData: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('22 는 한도 초과, 나머지 코드는 오류다', () => {
|
||||
expect(() => parseKmaPage(body('22'), 'getVilageFcst')).toThrow(
|
||||
BudgetExceededError,
|
||||
);
|
||||
expect(() => parseKmaPage(body('10'), 'getVilageFcst')).toThrow(
|
||||
/getVilageFcst: 결과 코드 10/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KmaForecastClient', () => {
|
||||
it('발표 시각·격자·JSON 을 실어 부른다', async () => {
|
||||
const { api, urls } = client([{ body: body('00', [], 0) }]);
|
||||
await api.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 });
|
||||
const url = new URL(urls[0]);
|
||||
expect(url.pathname).toBe(
|
||||
'/1360000/VilageFcstInfoService_2.0/getVilageFcst',
|
||||
);
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
serviceKey: KEY,
|
||||
dataType: 'JSON',
|
||||
base_date: '20261108',
|
||||
base_time: '1100',
|
||||
nx: '89',
|
||||
ny: '90',
|
||||
});
|
||||
});
|
||||
|
||||
it('입력 화면을 막지 않게 두 번까지만 시도하고, 오류에 키를 남기지 않는다', async () => {
|
||||
const { api, urls } = client([
|
||||
{ throws: new Error(`connect ETIMEDOUT ${KEY}`) },
|
||||
{ throws: new Error('connect ETIMEDOUT') },
|
||||
]);
|
||||
const error = await api
|
||||
.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 })
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(PublicDataApiError);
|
||||
expect((error as Error).message).toContain('2회 시도 실패');
|
||||
expect((error as Error).message).not.toContain(KEY);
|
||||
expect(urls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 기상청 단기예보 조회서비스(공공데이터포털 VilageFcstInfoService_2.0) 호출.
|
||||
* 공공데이터 공통 클라이언트(키 가리기·한도·재시도)를 쓰되, 사용자 요청 중에 부르므로 제한 시간을 짧게 둔다.
|
||||
*/
|
||||
import { BudgetExceededError } from '@/modules/place/sync/call-budget';
|
||||
import {
|
||||
asRecord,
|
||||
parseJsonOrThrow,
|
||||
PublicDataApiError,
|
||||
PublicDataClient,
|
||||
type PublicDataClientOptions,
|
||||
type PublicDataItem,
|
||||
toItems,
|
||||
} from '@/modules/place/sync/public-data.client';
|
||||
|
||||
import type { KmaGrid } from './kma-grid';
|
||||
import { issueParams, type KmaIssue } from './kma-forecast';
|
||||
|
||||
export const KMA_FORECAST_BASE_URL =
|
||||
'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
|
||||
export const KMA_FORECAST_OPERATION = 'getVilageFcst';
|
||||
|
||||
/** 한 번에 받을 행 수 — 발표 하나가 약 850~1,000행이다(시간당 12개 요소 × 최대 4일) */
|
||||
const ROWS_PER_PAGE = 1500;
|
||||
/** 입력 화면을 오래 막지 않게 짧게 두고, 한 번만 다시 시도한다 */
|
||||
const TIMEOUT_MS = 3_000;
|
||||
const MAX_ATTEMPTS = 2;
|
||||
|
||||
const RESULT_OK = '00';
|
||||
const RESULT_NO_DATA = '03';
|
||||
const RESULT_QUOTA_EXCEEDED = '22';
|
||||
|
||||
export interface KmaPage {
|
||||
items: PublicDataItem[];
|
||||
totalCount: number;
|
||||
/** 결과 코드 03(NODATA_ERROR) — 발표 직후라 아직 자료가 없을 때 온다 */
|
||||
noData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 → 항목. `{ response: { header: { resultCode }, body: { items: { item }, totalCount } } }` 이고 정상 코드는 '00'.
|
||||
* 03 은 오류가 아니라 '아직 자료 없음'으로 돌려준다(앞 발표로 다시 받는다).
|
||||
*/
|
||||
export function parseKmaPage(body: string, operation: string): KmaPage {
|
||||
const response = asRecord(
|
||||
asRecord(parseJsonOrThrow(body, operation))?.response,
|
||||
);
|
||||
const header = asRecord(response?.header);
|
||||
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
|
||||
if (code === RESULT_NO_DATA)
|
||||
return { items: [], totalCount: 0, noData: true };
|
||||
if (code !== RESULT_OK) {
|
||||
if (code === RESULT_QUOTA_EXCEEDED) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
|
||||
throw new PublicDataApiError(
|
||||
`${operation}: 결과 코드 ${code || '없음'} ${msg}`.trim(),
|
||||
);
|
||||
}
|
||||
const resBody = asRecord(response?.body);
|
||||
const items = toItems(asRecord(resBody?.items)?.item);
|
||||
const totalCount = Number(resBody?.totalCount ?? items.length);
|
||||
return {
|
||||
items,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
|
||||
noData: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 날씨 서비스가 쓰는 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
|
||||
export interface KmaForecastApi {
|
||||
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage>;
|
||||
}
|
||||
|
||||
export class KmaForecastClient
|
||||
extends PublicDataClient<KmaPage>
|
||||
implements KmaForecastApi
|
||||
{
|
||||
constructor(options: PublicDataClientOptions) {
|
||||
super(
|
||||
options,
|
||||
KMA_FORECAST_BASE_URL,
|
||||
{ dataType: 'JSON' },
|
||||
{ timeoutMs: TIMEOUT_MS, maxAttempts: MAX_ATTEMPTS },
|
||||
);
|
||||
}
|
||||
|
||||
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage> {
|
||||
return this.get(KMA_FORECAST_OPERATION, {
|
||||
pageNo: 1,
|
||||
numOfRows: ROWS_PER_PAGE,
|
||||
...issueParams(issue),
|
||||
nx: grid.nx,
|
||||
ny: grid.ny,
|
||||
});
|
||||
}
|
||||
|
||||
protected parse(body: string, operation: string): KmaPage {
|
||||
return parseKmaPage(body, operation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
issuedAtIso,
|
||||
issueParams,
|
||||
type KmaIssue,
|
||||
lastForecastDate,
|
||||
latestIssue,
|
||||
parseKmaItems,
|
||||
previousIssue,
|
||||
summarizeWindow,
|
||||
} from './kma-forecast';
|
||||
|
||||
const kst = (iso: string) => new Date(`${iso}+09:00`);
|
||||
const at = (hour: number) => hour * 60;
|
||||
|
||||
/** 그날 hours 시각마다 강수확률·기온·하늘·강수 형태 항목을 만든다 */
|
||||
function items(
|
||||
date: string,
|
||||
hours: number[],
|
||||
value: (hour: number) => {
|
||||
pop: number;
|
||||
tmp: number;
|
||||
sky: number;
|
||||
pty: number;
|
||||
},
|
||||
) {
|
||||
const out: Record<string, string>[] = [];
|
||||
for (const hour of hours) {
|
||||
const v = value(hour);
|
||||
const base = {
|
||||
fcstDate: date.replace(/-/g, ''),
|
||||
fcstTime: `${String(hour).padStart(2, '0')}00`,
|
||||
};
|
||||
out.push({ ...base, category: 'POP', fcstValue: String(v.pop) });
|
||||
out.push({ ...base, category: 'TMP', fcstValue: String(v.tmp) });
|
||||
out.push({ ...base, category: 'SKY', fcstValue: String(v.sky) });
|
||||
out.push({ ...base, category: 'PTY', fcstValue: String(v.pty) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const range = (from: number, to: number, step = 1) =>
|
||||
Array.from(
|
||||
{ length: Math.floor((to - from) / step) + 1 },
|
||||
(_, i) => from + i * step,
|
||||
);
|
||||
|
||||
describe('latestIssue · previousIssue', () => {
|
||||
it('발표 10분 뒤부터 그 발표를 쓴다', () => {
|
||||
expect(latestIssue(kst('2026-11-08T11:09:00'))).toEqual({
|
||||
date: '2026-11-08',
|
||||
hour: 8,
|
||||
});
|
||||
expect(latestIssue(kst('2026-11-08T11:10:00'))).toEqual({
|
||||
date: '2026-11-08',
|
||||
hour: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it('02:10 전에는 전날 23시 발표다', () => {
|
||||
expect(latestIssue(kst('2026-11-08T02:09:00'))).toEqual({
|
||||
date: '2026-11-07',
|
||||
hour: 23,
|
||||
});
|
||||
expect(latestIssue(kst('2026-11-08T02:10:00'))).toEqual({
|
||||
date: '2026-11-08',
|
||||
hour: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('앞 발표 — 02시 앞은 전날 23시', () => {
|
||||
expect(previousIssue({ date: '2026-11-08', hour: 11 })).toEqual({
|
||||
date: '2026-11-08',
|
||||
hour: 8,
|
||||
});
|
||||
expect(previousIssue({ date: '2026-11-01', hour: 2 })).toEqual({
|
||||
date: '2026-10-31',
|
||||
hour: 23,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueParams · lastForecastDate · issuedAtIso', () => {
|
||||
it('요청 값은 YYYYMMDD · HH00 이다', () => {
|
||||
expect(issueParams({ date: '2026-11-08', hour: 2 })).toEqual({
|
||||
base_date: '20261108',
|
||||
base_time: '0200',
|
||||
});
|
||||
});
|
||||
|
||||
it('02~14시 발표는 글피(3일 뒤), 17~23시 발표는 그글피(4일 뒤)까지 나온다', () => {
|
||||
expect(lastForecastDate({ date: '2026-11-08', hour: 14 })).toBe(
|
||||
'2026-11-11',
|
||||
);
|
||||
expect(lastForecastDate({ date: '2026-11-08', hour: 17 })).toBe(
|
||||
'2026-11-12',
|
||||
);
|
||||
});
|
||||
|
||||
it('발표 시각을 UTC 로 낸다', () => {
|
||||
expect(issuedAtIso({ date: '2026-11-08', hour: 11 })).toBe(
|
||||
'2026-11-08T02:00:00.000Z',
|
||||
);
|
||||
expect(issuedAtIso({ date: '2026-11-08', hour: 2 })).toBe(
|
||||
'2026-11-07T17:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseKmaItems', () => {
|
||||
it('쓰는 요소만 시각별로 모으고 결측값·형식이 다른 값은 버린다', () => {
|
||||
const forecast = parseKmaItems([
|
||||
{
|
||||
category: 'TMP',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1500',
|
||||
fcstValue: '14',
|
||||
},
|
||||
{
|
||||
category: 'POP',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1500',
|
||||
fcstValue: '60',
|
||||
},
|
||||
{
|
||||
category: 'REH',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1500',
|
||||
fcstValue: '80',
|
||||
},
|
||||
{
|
||||
category: 'POP',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1400',
|
||||
fcstValue: '30',
|
||||
},
|
||||
{
|
||||
category: 'TMP',
|
||||
fcstDate: '20261109',
|
||||
fcstTime: '1400',
|
||||
fcstValue: '-999',
|
||||
},
|
||||
{
|
||||
category: 'SKY',
|
||||
fcstDate: '2026119',
|
||||
fcstTime: '1400',
|
||||
fcstValue: '4',
|
||||
},
|
||||
]);
|
||||
expect(forecast).toEqual({
|
||||
'2026-11-09': [
|
||||
{ minute: at(14), pop: 30, tmp: null, sky: null, pty: null },
|
||||
{ minute: at(15), pop: 60, tmp: 14, sky: null, pty: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeWindow', () => {
|
||||
const issue: KmaIssue = { date: '2026-11-08', hour: 11 };
|
||||
|
||||
it('시간 범위 안의 가장 높은 강수확률과 그때의 강수 형태, 기온 범위, 가장 잦은 하늘을 낸다', () => {
|
||||
const forecast = parseKmaItems([
|
||||
...items('2026-11-09', range(0, 23), (h) => ({
|
||||
pop: h === 17 ? 70 : h === 21 ? 90 : 20,
|
||||
tmp: 10 + (h % 6),
|
||||
sky: h < 16 ? 3 : 4,
|
||||
pty: h === 17 ? 1 : h === 21 ? 3 : 0,
|
||||
})),
|
||||
]);
|
||||
expect(
|
||||
summarizeWindow(forecast, issue, '2026-11-09', at(14), at(20)),
|
||||
).toEqual({
|
||||
date: '2026-11-09',
|
||||
// 21시 90% 는 범위 밖이다
|
||||
precipitationProbability: 70,
|
||||
precipitationType: 'rain',
|
||||
// 14·15시 구름많음, 16~19시 흐림
|
||||
sky: 'cloudy',
|
||||
temperatureMin: 10,
|
||||
temperatureMax: 15,
|
||||
issuedAt: '2026-11-08T02:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('3시간 간격 예보는 한 시각이 3시간을 맡는다(12시 값이 14시를 덮는다)', () => {
|
||||
const forecast = parseKmaItems([
|
||||
...items('2026-11-11', range(0, 21, 3), (h) => ({
|
||||
pop: h === 12 ? 60 : 10,
|
||||
tmp: 12,
|
||||
sky: 1,
|
||||
pty: 0,
|
||||
})),
|
||||
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
|
||||
]);
|
||||
const summary = summarizeWindow(
|
||||
forecast,
|
||||
issue,
|
||||
'2026-11-11',
|
||||
at(14),
|
||||
at(20),
|
||||
);
|
||||
expect(summary?.precipitationProbability).toBe(60);
|
||||
expect(summary?.sky).toBe('clear');
|
||||
});
|
||||
|
||||
it('예보가 시간 범위 끝까지 닿지 않으면 null', () => {
|
||||
const forecast = parseKmaItems([
|
||||
// 글피 다음 날은 0시 값만 온다
|
||||
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
|
||||
...items('2026-11-10', range(0, 18), () => ({
|
||||
pop: 0,
|
||||
tmp: 8,
|
||||
sky: 1,
|
||||
pty: 0,
|
||||
})),
|
||||
]);
|
||||
expect(
|
||||
summarizeWindow(forecast, issue, '2026-11-12', at(14), at(20)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
summarizeWindow(forecast, issue, '2026-11-10', at(14), at(20)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
summarizeWindow(forecast, issue, '2026-11-13', at(14), at(20)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('발표 당일은 이미 지난 시각의 예보가 없어도 된다', () => {
|
||||
const forecast = parseKmaItems([
|
||||
...items('2026-11-08', range(12, 23), () => ({
|
||||
pop: 30,
|
||||
tmp: 15,
|
||||
sky: 1,
|
||||
pty: 0,
|
||||
})),
|
||||
]);
|
||||
expect(
|
||||
summarizeWindow(forecast, issue, '2026-11-08', at(11), at(17))
|
||||
?.precipitationProbability,
|
||||
).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 기상청 단기예보(getVilageFcst) 발표 시각 고르기·응답 해석·시간 범위 요약 (순수 함수).
|
||||
* 매뉴얼 검토: docs/research/data-openapi-review/store_and_weather.md §4·§5. 설계: docs/design/ai-course.md §3.3
|
||||
*/
|
||||
import type { PublicDataItem } from '@/modules/place/sync/public-data.client';
|
||||
import { addDaysIso } from '@/shared/utils/iso-date';
|
||||
import { kstClock } from '@/shared/utils/kst';
|
||||
|
||||
/** 단기예보 발표 시각(시, 한국 시간) — 하루 8번 */
|
||||
export const KMA_ISSUE_HOURS: readonly number[] = [2, 5, 8, 11, 14, 17, 20, 23];
|
||||
/** 발표 뒤 이만큼(분) 지나야 받을 수 있다 */
|
||||
const AVAILABLE_AFTER_MINUTES = 10;
|
||||
/** 이 시각 이후 발표는 그글피(4일 뒤)까지, 앞의 발표는 글피(3일 뒤)까지 나온다 */
|
||||
const LONG_RANGE_FROM_HOUR = 17;
|
||||
const SHORT_RANGE_DAYS = 3;
|
||||
const LONG_RANGE_DAYS = 4;
|
||||
/** 기상청 결측값 — 절댓값이 이보다 크면 버린다(매뉴얼: +900 이상, -900 이하) */
|
||||
const MISSING_VALUE_ABS = 900;
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const MINUTES_PER_DAY = 1440;
|
||||
const KST_OFFSET_HOURS = 9;
|
||||
|
||||
/** 단기예보 발표 하나 — 한국 날짜 'YYYY-MM-DD' 와 시 */
|
||||
export interface KmaIssue {
|
||||
date: string;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
/** 지금 받을 수 있는 가장 최근 발표 */
|
||||
export function latestIssue(now: Date): KmaIssue {
|
||||
const { date, minutes } = kstClock(now);
|
||||
for (let i = KMA_ISSUE_HOURS.length - 1; i >= 0; i--) {
|
||||
const hour = KMA_ISSUE_HOURS[i];
|
||||
if (minutes >= hour * MINUTES_PER_HOUR + AVAILABLE_AFTER_MINUTES) {
|
||||
return { date, hour };
|
||||
}
|
||||
}
|
||||
return previousIssue({ date, hour: KMA_ISSUE_HOURS[0] });
|
||||
}
|
||||
|
||||
/** 바로 앞 발표 — 최근 발표 자료가 아직 없을 때(발표가 늦어질 때) 쓴다 */
|
||||
export function previousIssue(issue: KmaIssue): KmaIssue {
|
||||
const index = KMA_ISSUE_HOURS.indexOf(issue.hour);
|
||||
if (index > 0) return { date: issue.date, hour: KMA_ISSUE_HOURS[index - 1] };
|
||||
return {
|
||||
date: addDaysIso(issue.date, -1) ?? issue.date,
|
||||
hour: KMA_ISSUE_HOURS[KMA_ISSUE_HOURS.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** API 요청 값 base_date('YYYYMMDD')·base_time('HH00') */
|
||||
export function issueParams(issue: KmaIssue): {
|
||||
base_date: string;
|
||||
base_time: string;
|
||||
} {
|
||||
return {
|
||||
base_date: issue.date.replace(/-/g, ''),
|
||||
base_time: `${String(issue.hour).padStart(2, '0')}00`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 발표가 예보를 내는 마지막 날 — 02~14시 발표는 글피, 17~23시 발표는 그글피 */
|
||||
export function lastForecastDate(issue: KmaIssue): string {
|
||||
const days =
|
||||
issue.hour >= LONG_RANGE_FROM_HOUR ? LONG_RANGE_DAYS : SHORT_RANGE_DAYS;
|
||||
return addDaysIso(issue.date, days) ?? issue.date;
|
||||
}
|
||||
|
||||
/** 발표 시각(ISO 8601 UTC) — API 규약에 맞춰 UTC 로 낸다 */
|
||||
export function issuedAtIso(issue: KmaIssue): string {
|
||||
const [y, m, d] = issue.date.split('-').map(Number);
|
||||
return new Date(
|
||||
Date.UTC(y, m - 1, d, issue.hour - KST_OFFSET_HOURS),
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
/** 예보 시각 하나의 값. 받지 못한 요소는 null */
|
||||
export interface ForecastPoint {
|
||||
/** 그날 00:00 부터의 분 */
|
||||
minute: number;
|
||||
/** 강수확률(%) */
|
||||
pop: number | null;
|
||||
/** 기온(℃) */
|
||||
tmp: number | null;
|
||||
/** 하늘 상태 코드 — 1 맑음 · 3 구름많음 · 4 흐림 */
|
||||
sky: number | null;
|
||||
/** 강수 형태 코드 — 0 없음 · 1 비 · 2 비/눈 · 3 눈 · 4 소나기 */
|
||||
pty: number | null;
|
||||
}
|
||||
|
||||
/** 날짜('YYYY-MM-DD')별 예보 시각(시각 순) */
|
||||
export type KmaForecast = Record<string, ForecastPoint[]>;
|
||||
|
||||
const CATEGORY_FIELDS: Readonly<
|
||||
Record<string, keyof Omit<ForecastPoint, 'minute'>>
|
||||
> = {
|
||||
POP: 'pop',
|
||||
TMP: 'tmp',
|
||||
SKY: 'sky',
|
||||
PTY: 'pty',
|
||||
};
|
||||
|
||||
/** 응답 값(문자열·숫자로 온다)을 글자로. 다른 형식이면 빈 문자열 */
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? String(value)
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 항목({category, fcstDate 'YYYYMMDD', fcstTime 'HHMM', fcstValue}) → 날짜별 예보 시각.
|
||||
* 쓰는 요소(강수확률·기온·하늘·강수 형태)만 남기고, 결측값·형식이 다른 값은 버린다.
|
||||
*/
|
||||
export function parseKmaItems(items: PublicDataItem[]): KmaForecast {
|
||||
const byDate = new Map<string, Map<number, ForecastPoint>>();
|
||||
for (const item of items) {
|
||||
const field = CATEGORY_FIELDS[text(item.category)];
|
||||
const date = /^(\d{4})(\d{2})(\d{2})$/.exec(text(item.fcstDate));
|
||||
const time = /^(\d{2})(\d{2})$/.exec(text(item.fcstTime));
|
||||
const value = Number(text(item.fcstValue) || Number.NaN);
|
||||
if (!field || !date || !time || !Number.isFinite(value)) continue;
|
||||
if (Math.abs(value) >= MISSING_VALUE_ABS) continue;
|
||||
const day = `${date[1]}-${date[2]}-${date[3]}`;
|
||||
const minute = Number(time[1]) * MINUTES_PER_HOUR + Number(time[2]);
|
||||
const points = byDate.get(day) ?? new Map<number, ForecastPoint>();
|
||||
const point = points.get(minute) ?? {
|
||||
minute,
|
||||
pop: null,
|
||||
tmp: null,
|
||||
sky: null,
|
||||
pty: null,
|
||||
};
|
||||
point[field] = value;
|
||||
points.set(minute, point);
|
||||
byDate.set(day, points);
|
||||
}
|
||||
const out: KmaForecast = {};
|
||||
for (const [day, points] of byDate) {
|
||||
out[day] = [...points.values()].sort((a, b) => a.minute - b.minute);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PrecipitationType =
|
||||
| 'none'
|
||||
| 'rain'
|
||||
| 'rain_snow'
|
||||
| 'snow'
|
||||
| 'shower';
|
||||
export type SkyState = 'clear' | 'partly_cloudy' | 'cloudy';
|
||||
|
||||
const PRECIPITATION_TYPES: Readonly<Record<number, PrecipitationType>> = {
|
||||
0: 'none',
|
||||
1: 'rain',
|
||||
2: 'rain_snow',
|
||||
3: 'snow',
|
||||
4: 'shower',
|
||||
};
|
||||
const SKY_STATES: Readonly<Record<number, SkyState>> = {
|
||||
1: 'clear',
|
||||
3: 'partly_cloudy',
|
||||
4: 'cloudy',
|
||||
};
|
||||
|
||||
/** 코스 시간 범위의 예보 요약 — 입력 화면("토요일 오후 비 60%")과 결과의 날씨에 쓴다(설계 §3.7) */
|
||||
export interface WindowForecast {
|
||||
date: string;
|
||||
/** 시간 범위 중 가장 높은 강수확률(%) — 40% 이상이면 비 조건(prefersIndoor) */
|
||||
precipitationProbability: number;
|
||||
/** 강수확률이 가장 높은 때의 강수 형태 */
|
||||
precipitationType: PrecipitationType;
|
||||
/** 시간 범위에서 가장 잦은 하늘 상태(같으면 더 흐린 쪽) */
|
||||
sky: SkyState | null;
|
||||
temperatureMin: number | null;
|
||||
temperatureMax: number | null;
|
||||
/** 발표 시각(ISO 8601 UTC) — 화면에 "11시 발표"로 보인다 */
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/** 예보 시각마다 그 값이 맡는 구간 [minute, 다음 예보 시각) — 마지막 시각은 다음 날 0시나 앞 간격만큼 */
|
||||
function intervals(
|
||||
points: ForecastPoint[],
|
||||
nextDayFirst: ForecastPoint | undefined,
|
||||
): { point: ForecastPoint; from: number; to: number }[] {
|
||||
return points.map((point, i) => {
|
||||
const next = points[i + 1]?.minute;
|
||||
let to: number;
|
||||
if (next !== undefined) {
|
||||
to = next;
|
||||
} else if (nextDayFirst?.minute === 0) {
|
||||
to = MINUTES_PER_DAY;
|
||||
} else {
|
||||
const gap =
|
||||
i > 0 ? point.minute - points[i - 1].minute : MINUTES_PER_HOUR;
|
||||
to = Math.min(point.minute + gap, MINUTES_PER_DAY);
|
||||
}
|
||||
return { point, from: point.minute, to };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 그날 [start, end) 의 예보 요약. 예보가 시간 범위 끝까지 닿지 않으면 null(입력 화면에 '예보 없음').
|
||||
* 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. 발표 당일은 이미 지난 시각의 예보가 없어도 된다.
|
||||
*/
|
||||
export function summarizeWindow(
|
||||
forecast: KmaForecast,
|
||||
issue: KmaIssue,
|
||||
date: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): WindowForecast | null {
|
||||
const points = forecast[date] ?? [];
|
||||
if (points.length === 0 || end <= start) return null;
|
||||
const nextDay = addDaysIso(date, 1);
|
||||
const spans = intervals(points, nextDay ? forecast[nextDay]?.[0] : undefined);
|
||||
const covered = spans.filter((s) => s.from < end && start < s.to);
|
||||
if (covered.length === 0) return null;
|
||||
if (spans[spans.length - 1].to < end) return null;
|
||||
if (date !== issue.date && spans[0].from > start) return null;
|
||||
|
||||
const withPop = covered.filter((s) => s.point.pop !== null);
|
||||
if (withPop.length === 0) return null;
|
||||
const wettest = withPop.reduce((a, b) =>
|
||||
(b.point.pop ?? 0) > (a.point.pop ?? 0) ? b : a,
|
||||
);
|
||||
const temps = covered
|
||||
.map((s) => s.point.tmp)
|
||||
.filter((t): t is number => t !== null);
|
||||
const skyCounts = new Map<number, number>();
|
||||
for (const s of covered) {
|
||||
if (s.point.sky !== null) {
|
||||
skyCounts.set(s.point.sky, (skyCounts.get(s.point.sky) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
let sky: number | null = null;
|
||||
for (const [code, count] of skyCounts) {
|
||||
const best = sky === null ? 0 : (skyCounts.get(sky) ?? 0);
|
||||
if (count > best || (count === best && sky !== null && code > sky)) {
|
||||
sky = code;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
precipitationProbability: wettest.point.pop ?? 0,
|
||||
precipitationType: PRECIPITATION_TYPES[wettest.point.pty ?? 0] ?? 'none',
|
||||
sky: sky === null ? null : (SKY_STATES[sky] ?? null),
|
||||
temperatureMin: temps.length > 0 ? Math.min(...temps) : null,
|
||||
temperatureMax: temps.length > 0 ? Math.max(...temps) : null,
|
||||
issuedAt: issuedAtIso(issue),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { toKmaGrid } from './kma-grid';
|
||||
|
||||
describe('toKmaGrid', () => {
|
||||
it('서울시청은 기상청 격자 (60, 127) 이다', () => {
|
||||
expect(toKmaGrid(37.5665, 126.978)).toEqual({ nx: 60, ny: 127 });
|
||||
});
|
||||
|
||||
it('대구 도심은 (89, 90) 이다(2026-10-29 실제 호출과 같다)', () => {
|
||||
expect(toKmaGrid(35.87021, 128.59409)).toEqual({ nx: 89, ny: 90 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 위도·경도 → 기상청 단기예보 격자(nx, ny) (순수 함수).
|
||||
* 기상청이 공개한 람베르트 정각원추도법(LCC) 공식이다. 2026-10-29 격자표 3,801행과 대조해 98.9% 가 정확히 맞고
|
||||
* 나머지 43행도 1칸 차이였다(docs/research/data-openapi-review/store_and_weather.md §6).
|
||||
*/
|
||||
|
||||
/** 지구 반지름(km)과 격자 간격(km) */
|
||||
const EARTH_RADIUS_KM = 6371.00877;
|
||||
const GRID_SPACING_KM = 5.0;
|
||||
/** 표준 위도 1·2, 기준점 경도·위도 */
|
||||
const STANDARD_LAT_1 = 30.0;
|
||||
const STANDARD_LAT_2 = 60.0;
|
||||
const ORIGIN_LNG = 126.0;
|
||||
const ORIGIN_LAT = 38.0;
|
||||
/** 기준점의 격자 좌표 */
|
||||
const ORIGIN_X = 43;
|
||||
const ORIGIN_Y = 136;
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const QUARTER_PI = Math.PI / 4;
|
||||
|
||||
export interface KmaGrid {
|
||||
nx: number;
|
||||
ny: number;
|
||||
}
|
||||
|
||||
export function toKmaGrid(lat: number, lng: number): KmaGrid {
|
||||
const re = EARTH_RADIUS_KM / GRID_SPACING_KM;
|
||||
const s1 = STANDARD_LAT_1 * DEG;
|
||||
const s2 = STANDARD_LAT_2 * DEG;
|
||||
const sn =
|
||||
Math.log(Math.cos(s1) / Math.cos(s2)) /
|
||||
Math.log(Math.tan(QUARTER_PI + s2 / 2) / Math.tan(QUARTER_PI + s1 / 2));
|
||||
const sf = (Math.tan(QUARTER_PI + s1 / 2) ** sn * Math.cos(s1)) / sn;
|
||||
const ro = (re * sf) / Math.tan(QUARTER_PI + (ORIGIN_LAT * DEG) / 2) ** sn;
|
||||
const ra = (re * sf) / Math.tan(QUARTER_PI + (lat * DEG) / 2) ** sn;
|
||||
let theta = lng * DEG - ORIGIN_LNG * DEG;
|
||||
if (theta > Math.PI) theta -= 2 * Math.PI;
|
||||
if (theta < -Math.PI) theta += 2 * Math.PI;
|
||||
theta *= sn;
|
||||
return {
|
||||
nx: Math.floor(ra * Math.sin(theta) + ORIGIN_X + 0.5),
|
||||
ny: Math.floor(ro - ra * Math.cos(theta) + ORIGIN_Y + 0.5),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
createKmaApi,
|
||||
KMA_API_FACTORY,
|
||||
WeatherService,
|
||||
} from './weather.service';
|
||||
|
||||
/**
|
||||
* 날씨(기상청 단기예보). AI 코스 조건 해석·생성(설계 §3.3)이 WeatherService 를 쓴다.
|
||||
* 설정(ConfigModule)과 캐시(CacheModule)는 앱 전역 모듈을 쓴다.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
WeatherService,
|
||||
{ provide: KMA_API_FACTORY, useValue: createKmaApi },
|
||||
],
|
||||
exports: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { Cache } from 'cache-manager';
|
||||
|
||||
import type { CallBudget } from '@/modules/place/sync/call-budget';
|
||||
import { PublicDataApiError } from '@/modules/place/sync/public-data.client';
|
||||
|
||||
import type { KmaForecastApi, KmaPage } from './kma-forecast.client';
|
||||
import type { KmaIssue } from './kma-forecast';
|
||||
import type { KmaGrid } from './kma-grid';
|
||||
import { WeatherService } from './weather.service';
|
||||
|
||||
const DOWNTOWN = { lat: 35.87021, lng: 128.59409 };
|
||||
/** 2026-11-08(일) 11:30 KST — 최근 발표는 11시, 예보는 글피(11-11)까지 */
|
||||
const NOW = new Date('2026-11-08T11:30:00+09:00');
|
||||
const at = (hour: number) => hour * 60;
|
||||
|
||||
/** 11-08 ~ 11-11 매시, 11-12 0시의 강수확률이 pop 인 응답 */
|
||||
function page(pop: number): KmaPage {
|
||||
const items: Record<string, string>[] = [];
|
||||
const dates = ['20261108', '20261109', '20261110', '20261111', '20261112'];
|
||||
for (const date of dates) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
if (date === '20261112' && h > 0) break;
|
||||
const base = {
|
||||
fcstDate: date,
|
||||
fcstTime: `${String(h).padStart(2, '0')}00`,
|
||||
};
|
||||
items.push({ ...base, category: 'POP', fcstValue: String(pop) });
|
||||
items.push({ ...base, category: 'TMP', fcstValue: '12' });
|
||||
items.push({ ...base, category: 'SKY', fcstValue: '4' });
|
||||
items.push({ ...base, category: 'PTY', fcstValue: pop > 0 ? '1' : '0' });
|
||||
}
|
||||
}
|
||||
return { items, totalCount: items.length, noData: false };
|
||||
}
|
||||
|
||||
const NO_DATA: KmaPage = { items: [], totalCount: 0, noData: true };
|
||||
|
||||
function memoryCache(): Cache {
|
||||
const store = new Map<string, unknown>();
|
||||
return {
|
||||
get: (key: string) => Promise.resolve(store.get(key)),
|
||||
set: (key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
} as unknown as Cache;
|
||||
}
|
||||
|
||||
function setup(
|
||||
respond: (issue: KmaIssue, grid: KmaGrid) => Promise<KmaPage>,
|
||||
key = 'test-key',
|
||||
) {
|
||||
const calls: { issue: KmaIssue; grid: KmaGrid }[] = [];
|
||||
const api: KmaForecastApi = {
|
||||
forecast: (issue, grid) => {
|
||||
calls.push({ issue, grid });
|
||||
return respond(issue, grid);
|
||||
},
|
||||
};
|
||||
const budgets: CallBudget[] = [];
|
||||
const config = { get: () => key } as unknown as ConfigService;
|
||||
const service = new WeatherService(config, memoryCache(), (_key, budget) => {
|
||||
budgets.push(budget);
|
||||
return api;
|
||||
});
|
||||
return { service, calls, budgets };
|
||||
}
|
||||
|
||||
describe('WeatherService.forecastWindow', () => {
|
||||
it('최근 발표를 격자로 불러 시간 범위를 요약하고, 같은 격자·발표는 다시 부르지 않는다', async () => {
|
||||
const { service, calls } = setup(() => Promise.resolve(page(60)));
|
||||
const first = await service.forecastWindow(
|
||||
DOWNTOWN,
|
||||
'2026-11-09',
|
||||
at(14),
|
||||
at(20),
|
||||
NOW,
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
date: '2026-11-09',
|
||||
precipitationProbability: 60,
|
||||
precipitationType: 'rain',
|
||||
sky: 'cloudy',
|
||||
issuedAt: '2026-11-08T02:00:00.000Z',
|
||||
});
|
||||
await service.forecastWindow(DOWNTOWN, '2026-11-10', at(10), at(16), NOW);
|
||||
expect(calls).toEqual([
|
||||
{ issue: { date: '2026-11-08', hour: 11 }, grid: { nx: 89, ny: 90 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다', async () => {
|
||||
const { service, calls } = setup((issue) =>
|
||||
Promise.resolve(issue.hour === 11 ? NO_DATA : page(10)),
|
||||
);
|
||||
const summary = await service.forecastWindow(
|
||||
DOWNTOWN,
|
||||
'2026-11-09',
|
||||
at(14),
|
||||
at(20),
|
||||
NOW,
|
||||
);
|
||||
expect(summary?.issuedAt).toBe('2026-11-07T23:00:00.000Z');
|
||||
expect(calls.map((c) => c.issue.hour)).toEqual([11, 8]);
|
||||
});
|
||||
|
||||
it('기상청 장애면 null 이고, 잠깐 동안은 다시 부르지 않는다', async () => {
|
||||
const { service, calls } = setup(() =>
|
||||
Promise.reject(new PublicDataApiError('getVilageFcst: HTTP 502')),
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(
|
||||
await service.forecastWindow(
|
||||
DOWNTOWN,
|
||||
'2026-11-09',
|
||||
at(14),
|
||||
at(20),
|
||||
NOW,
|
||||
),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('지난 날짜·예보가 닿지 않는 날짜·한국 밖은 부르지 않고 null', async () => {
|
||||
const { service, calls } = setup(() => Promise.resolve(page(0)));
|
||||
const window = [at(14), at(20), NOW] as const;
|
||||
expect(
|
||||
await service.forecastWindow(DOWNTOWN, '2026-11-07', ...window),
|
||||
).toBeNull();
|
||||
// 11시 발표는 글피(11-11)까지다
|
||||
expect(
|
||||
await service.forecastWindow(DOWNTOWN, '2026-11-12', ...window),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await service.forecastWindow({ lat: 0, lng: 0 }, '2026-11-09', ...window),
|
||||
).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('키가 없으면 부르지 않고 null', async () => {
|
||||
const { service, calls, budgets } = setup(
|
||||
() => Promise.resolve(page(0)),
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW),
|
||||
).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(budgets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('한국 날짜가 바뀌면 하루 호출 수를 새로 센다', async () => {
|
||||
const { service, budgets } = setup(() => Promise.resolve(page(0)));
|
||||
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW);
|
||||
await service.forecastWindow(
|
||||
DOWNTOWN,
|
||||
'2026-11-10',
|
||||
at(14),
|
||||
at(20),
|
||||
new Date('2026-11-09T09:00:00+09:00'),
|
||||
);
|
||||
expect(budgets).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Cache } from 'cache-manager';
|
||||
|
||||
import { CallBudget } from '@/modules/place/sync/call-budget';
|
||||
import { isInKorea, type LatLng } from '@/shared/utils/geo';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
|
||||
import { KmaForecastClient, type KmaForecastApi } from './kma-forecast.client';
|
||||
import {
|
||||
type KmaForecast,
|
||||
type KmaIssue,
|
||||
lastForecastDate,
|
||||
latestIssue,
|
||||
parseKmaItems,
|
||||
previousIssue,
|
||||
summarizeWindow,
|
||||
type WindowForecast,
|
||||
} from './kma-forecast';
|
||||
import { type KmaGrid, toKmaGrid } from './kma-grid';
|
||||
|
||||
/** 기상청 클라이언트를 만든다 — 테스트에서는 가짜로 바꿔 끼운다 */
|
||||
export const KMA_API_FACTORY = Symbol('KMA_API_FACTORY');
|
||||
export type KmaApiFactory = (
|
||||
serviceKey: string,
|
||||
budget: CallBudget,
|
||||
) => KmaForecastApi;
|
||||
|
||||
export const createKmaApi: KmaApiFactory = (serviceKey, budget) =>
|
||||
new KmaForecastClient({ serviceKey, budget });
|
||||
|
||||
const SERVICE_KEY_NAME = 'PUBLIC_DATA_SERVICE_KEY';
|
||||
/** 격자·발표 시각별 예보를 이만큼(ms) 다시 부르지 않는다(설계 §3.3) */
|
||||
const FORECAST_TTL_MS = 60 * 60 * 1000;
|
||||
/** 실패·자료 없음은 짧게 기억한다 — 장애 때 요청마다 제한 시간만큼 기다리지 않게 */
|
||||
const FAILURE_TTL_MS = 2 * 60 * 1000;
|
||||
/**
|
||||
* 하루 호출 상한(한국 시간 자정에 다시 센다) — 캐시가 있어 실제로는 하루 수십 번이다.
|
||||
* 공공데이터포털 개발 계정 한도보다 낮게 두어 키 오류·버그로 한도를 다 쓰지 않게 한다.
|
||||
*/
|
||||
const DAILY_CALL_LIMIT = 1_000;
|
||||
|
||||
type CachedForecast =
|
||||
| { kind: 'ok'; forecast: KmaForecast }
|
||||
| { kind: 'no_data' }
|
||||
| { kind: 'failed' };
|
||||
|
||||
function cacheKey(grid: KmaGrid, issue: KmaIssue): string {
|
||||
return `weather:kma:${grid.nx},${grid.ny}:${issue.date}T${issue.hour}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코스 날짜·지역의 단기예보(설계 §3.3). 입력 화면을 막지 않도록 **실패하면 던지지 않고 null** 을 돌려준다
|
||||
* (키 없음 · 예보가 없는 날짜 · 한국 밖 · 기상청 장애). null 이면 화면에 '예보 없음'을 보이고 비 조건은 쓰지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
private readonly logger = new Logger(WeatherService.name);
|
||||
private daily: { date: string; api: KmaForecastApi } | null = null;
|
||||
private readonly inflight = new Map<string, Promise<CachedForecast>>();
|
||||
private warnedMissingKey = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
@Inject(KMA_API_FACTORY) private readonly createApi: KmaApiFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 그날 [start, end)(00:00 부터 분) 의 예보 요약. 지난 날짜, 예보가 닿지 않는 날짜(4일 뒤 이후)는 null.
|
||||
* @param now 지금 시각 — 발표 시각을 고른다(테스트에서 고정)
|
||||
*/
|
||||
async forecastWindow(
|
||||
point: LatLng,
|
||||
date: string,
|
||||
start: number,
|
||||
end: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<WindowForecast | null> {
|
||||
if (date < kstDateString(now)) return null;
|
||||
if (!isInKorea(point.lat, point.lng)) return null;
|
||||
const latest = latestIssue(now);
|
||||
if (date > lastForecastDate(latest)) return null;
|
||||
const grid = toKmaGrid(point.lat, point.lng);
|
||||
// 최근 발표 자료가 아직 없으면(발표가 늦어질 때) 앞 발표를 쓴다
|
||||
for (const issue of [latest, previousIssue(latest)]) {
|
||||
const cached = await this.load(grid, issue, now);
|
||||
if (cached.kind === 'failed') return null;
|
||||
if (cached.kind === 'ok') {
|
||||
return summarizeWindow(cached.forecast, issue, date, start, end);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async load(
|
||||
grid: KmaGrid,
|
||||
issue: KmaIssue,
|
||||
now: Date,
|
||||
): Promise<CachedForecast> {
|
||||
const key = cacheKey(grid, issue);
|
||||
const cached = await this.cache.get<CachedForecast>(key);
|
||||
if (cached) return cached;
|
||||
// 같은 격자를 동시에 물으면 한 번만 부른다
|
||||
let pending = this.inflight.get(key);
|
||||
if (!pending) {
|
||||
pending = this.fetch(grid, issue, now).finally(() =>
|
||||
this.inflight.delete(key),
|
||||
);
|
||||
this.inflight.set(key, pending);
|
||||
}
|
||||
const result = await pending;
|
||||
await this.cache.set(
|
||||
key,
|
||||
result,
|
||||
result.kind === 'ok' ? FORECAST_TTL_MS : FAILURE_TTL_MS,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
grid: KmaGrid,
|
||||
issue: KmaIssue,
|
||||
now: Date,
|
||||
): Promise<CachedForecast> {
|
||||
const api = this.api(now);
|
||||
if (!api) return { kind: 'failed' };
|
||||
try {
|
||||
const page = await api.forecast(issue, grid);
|
||||
if (page.noData) return { kind: 'no_data' };
|
||||
if (page.totalCount > page.items.length) {
|
||||
this.logger.warn(
|
||||
`기상청 단기예보가 잘려서 왔다(격자 ${grid.nx},${grid.ny} · ${page.items.length}/${page.totalCount}행)`,
|
||||
);
|
||||
}
|
||||
return { kind: 'ok', forecast: parseKmaItems(page.items) };
|
||||
} catch (error) {
|
||||
// 클라이언트가 오류 메시지의 키를 가린다. 사용자 위치 대신 5km 격자만 남긴다
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`기상청 단기예보를 받지 못했다(격자 ${grid.nx},${grid.ny} · ${issue.date} ${issue.hour}시 발표): ${reason}`,
|
||||
);
|
||||
return { kind: 'failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 오늘 쓸 클라이언트 — 한국 날짜가 바뀌면 하루 호출 수를 다시 센다. 키가 없으면 null */
|
||||
private api(now: Date): KmaForecastApi | null {
|
||||
const today = kstDateString(now);
|
||||
if (this.daily?.date === today) return this.daily.api;
|
||||
const key = this.config.get<string>(SERVICE_KEY_NAME)?.trim();
|
||||
if (!key || /change[-_]?me/i.test(key)) {
|
||||
if (!this.warnedMissingKey) {
|
||||
// 키 값은 어떤 경우에도 남기지 않는다
|
||||
this.logger.warn(
|
||||
`${SERVICE_KEY_NAME} 가 없어 날씨 예보를 쓰지 않는다(입력 화면은 '예보 없음')`,
|
||||
);
|
||||
this.warnedMissingKey = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const budget = new CallBudget(DAILY_CALL_LIMIT);
|
||||
this.daily = { date: today, api: this.createApi(key, budget) };
|
||||
return this.daily.api;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ const POSITIVE_INT_KEYS = [
|
||||
'THROTTLE_LIMIT',
|
||||
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
|
||||
'TOUR_API_DAILY_LIMIT',
|
||||
// AI 코스 생성 — 하루 횟수(비로그인·로그인), 하루 비용 상한(정수 달러), AI 호출 제한 시간(ms). 없으면 코드 기본값
|
||||
'AI_DAILY_LIMIT_GUEST',
|
||||
'AI_DAILY_LIMIT_MEMBER',
|
||||
'AI_DAILY_BUDGET_USD',
|
||||
'AI_TIMEOUT_MS',
|
||||
] as const;
|
||||
|
||||
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
|
||||
|
||||
@@ -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 이거나 위도·경도가 뒤바뀐 항목이 섞여 있다.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
closedOnHoliday,
|
||||
HOLIDAY_TABLE_YEARS,
|
||||
holidayOn,
|
||||
holidaysIn,
|
||||
isHolidayTableYear,
|
||||
} from './kr-holidays';
|
||||
|
||||
/** 'YYYY-MM-DD' 의 요일(0=일요일) */
|
||||
const weekday = (date: string) => new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||
const addDays = (date: string, days: number) =>
|
||||
new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
/** 토·일요일과 겹치면 대체공휴일이 붙는 공휴일(현충일·신정·선거일은 붙지 않는다) */
|
||||
const SUBSTITUTED = [
|
||||
'삼일절',
|
||||
'노동절',
|
||||
'어린이날',
|
||||
'부처님오신날',
|
||||
'제헌절',
|
||||
'광복절',
|
||||
'개천절',
|
||||
'한글날',
|
||||
'성탄절',
|
||||
];
|
||||
|
||||
describe('holidayOn', () => {
|
||||
it('설·추석 연휴와 대체공휴일을 담는다', () => {
|
||||
expect(holidayOn('2026-02-17')).toEqual({ name: '설날', kind: 'seollal' });
|
||||
expect(holidayOn('2026-09-26')?.kind).toBe('chuseok');
|
||||
// 2027 설날(2월 7일)이 일요일이라 2월 9일이 대체공휴일이다 — 설 연휴로 본다
|
||||
expect(holidayOn('2027-02-09')).toEqual({
|
||||
name: '대체공휴일(설날)',
|
||||
kind: 'seollal',
|
||||
});
|
||||
expect(holidayOn('2026-10-05')?.name).toBe('대체공휴일(개천절)');
|
||||
});
|
||||
|
||||
it('2026년부터 노동절·제헌절이 공휴일이고, 주말과 겹치면 대체공휴일이 있다', () => {
|
||||
expect(holidayOn('2026-05-01')?.name).toBe('노동절');
|
||||
expect(holidayOn('2026-07-17')?.name).toBe('제헌절');
|
||||
expect(holidayOn('2027-05-03')?.name).toBe('대체공휴일(노동절)');
|
||||
expect(holidayOn('2027-07-19')?.name).toBe('대체공휴일(제헌절)');
|
||||
});
|
||||
|
||||
it('현충일은 주말과 겹쳐도 대체공휴일이 없고, 선거일은 공휴일이다', () => {
|
||||
expect(holidayOn('2026-06-06')?.name).toBe('현충일');
|
||||
expect(holidayOn('2026-06-08')).toBeNull();
|
||||
expect(holidayOn('2026-06-03')?.name).toBe('전국동시지방선거');
|
||||
});
|
||||
|
||||
it('평일·주말은 null 이다', () => {
|
||||
expect(holidayOn('2026-11-09')).toBeNull();
|
||||
expect(holidayOn('2026-11-08')).toBeNull();
|
||||
});
|
||||
|
||||
it('표가 담은 해만 공휴일 여부를 안다', () => {
|
||||
expect(HOLIDAY_TABLE_YEARS).toEqual([2026, 2027]);
|
||||
expect(isHolidayTableYear('2027-12-31')).toBe(true);
|
||||
expect(isHolidayTableYear('2028-01-01')).toBe(false);
|
||||
// 공식 발표와 같은 개수(2026년 22일 · 2027년 24일, 주말과 겹친 날 포함)
|
||||
expect(holidaysIn(2026)).toHaveLength(22);
|
||||
expect(holidaysIn(2027)).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('표의 날짜는 실제 있는 날이다', () => {
|
||||
for (const year of HOLIDAY_TABLE_YEARS) {
|
||||
for (const { date } of holidaysIn(year)) {
|
||||
expect(addDays(date, 0)).toBe(date);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('주말과 겹친 공휴일은 그다음 첫 평일(다른 공휴일이 아닌 날)이 대체공휴일이다', () => {
|
||||
for (const year of HOLIDAY_TABLE_YEARS) {
|
||||
for (const { date, name } of holidaysIn(year)) {
|
||||
if (!SUBSTITUTED.includes(name)) continue;
|
||||
if (weekday(date) !== 0 && weekday(date) !== 6) continue;
|
||||
const substituteName = `대체공휴일(${name})`;
|
||||
let expected = addDays(date, 1);
|
||||
while (
|
||||
weekday(expected) === 0 ||
|
||||
weekday(expected) === 6 ||
|
||||
(holidayOn(expected) && holidayOn(expected)?.name !== substituteName)
|
||||
) {
|
||||
expected = addDays(expected, 1);
|
||||
}
|
||||
expect(holidayOn(expected)?.name).toBe(substituteName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('설·추석 연휴가 일요일과 겹치면 연휴 다음 첫 평일이 대체공휴일이다', () => {
|
||||
for (const year of HOLIDAY_TABLE_YEARS) {
|
||||
for (const [kind, label] of [
|
||||
['seollal', '설날'],
|
||||
['chuseok', '추석'],
|
||||
] as const) {
|
||||
const days = holidaysIn(year).filter(
|
||||
(h) => h.kind === kind && !h.name.startsWith('대체공휴일'),
|
||||
);
|
||||
const substitute = holidaysIn(year).find(
|
||||
(h) => h.name === `대체공휴일(${label})`,
|
||||
);
|
||||
const overlapsSunday = days.some((h) => weekday(h.date) === 0);
|
||||
expect(Boolean(substitute)).toBe(overlapsSunday);
|
||||
if (substitute) {
|
||||
expect(substitute.date).toBe(addDays(days[days.length - 1].date, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('closedOnHoliday', () => {
|
||||
it("'공휴일' 휴무는 모든 공휴일에 쉰다", () => {
|
||||
expect(closedOnHoliday(['공휴일'], 'public')).toBe(true);
|
||||
expect(closedOnHoliday(['공휴일'], 'seollal')).toBe(true);
|
||||
});
|
||||
|
||||
it('설·추석·신정 휴무는 그 명절에만 쉰다', () => {
|
||||
expect(closedOnHoliday(['설', '추석'], 'seollal')).toBe(true);
|
||||
expect(closedOnHoliday(['설', '추석'], 'chuseok')).toBe(true);
|
||||
expect(closedOnHoliday(['설', '추석'], 'public')).toBe(false);
|
||||
expect(closedOnHoliday(['1월 1일'], 'new_year')).toBe(true);
|
||||
expect(closedOnHoliday(['1월 1일'], 'public')).toBe(false);
|
||||
expect(closedOnHoliday([], 'seollal')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 대한민국 관공서 공휴일 표 (순수 데이터·함수) — AI 코스 거르기에서 명절·공휴일 휴무를 본다(설계 §3.3).
|
||||
*
|
||||
* **해마다 갱신한다.** 표에 없는 해는 공휴일 여부를 모르므로(isHolidayTableYear) 쓰는 쪽이 경고를 남긴다.
|
||||
* 근거(2026-11-08 확인): 「관공서의 공휴일에 관한 규정」(2026-04-28 국무회의 의결 — 노동절·제헌절을 공휴일로,
|
||||
* 두 날도 주말·다른 공휴일과 겹치면 대체공휴일), 우주항공청 월력요항(2027년 설날 2월 7일 · 추석 9월 15일).
|
||||
* 대체공휴일: 설·추석 연휴는 일요일·다른 공휴일과 겹칠 때, 나머지(현충일·신정 제외)는 토·일요일과 겹칠 때.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 공휴일 종류 — 휴무 표시와 맞춰 보려고 나눈다.
|
||||
* 설·추석 연휴의 대체공휴일은 연휴로 본다(쉬는 곳이 연휴 내내 쉬는지 당일만 쉬는지 알 수 없어서).
|
||||
*/
|
||||
export type HolidayKind = 'new_year' | 'seollal' | 'chuseok' | 'public';
|
||||
|
||||
export interface Holiday {
|
||||
name: string;
|
||||
kind: HolidayKind;
|
||||
}
|
||||
|
||||
const HOLIDAYS: Readonly<Record<string, Holiday>> = {
|
||||
// 2026
|
||||
'2026-01-01': { name: '신정', kind: 'new_year' },
|
||||
'2026-02-16': { name: '설날 연휴', kind: 'seollal' },
|
||||
'2026-02-17': { name: '설날', kind: 'seollal' },
|
||||
'2026-02-18': { name: '설날 연휴', kind: 'seollal' },
|
||||
'2026-03-01': { name: '삼일절', kind: 'public' },
|
||||
'2026-03-02': { name: '대체공휴일(삼일절)', kind: 'public' },
|
||||
'2026-05-01': { name: '노동절', kind: 'public' },
|
||||
'2026-05-05': { name: '어린이날', kind: 'public' },
|
||||
'2026-05-24': { name: '부처님오신날', kind: 'public' },
|
||||
'2026-05-25': { name: '대체공휴일(부처님오신날)', kind: 'public' },
|
||||
'2026-06-03': { name: '전국동시지방선거', kind: 'public' },
|
||||
'2026-06-06': { name: '현충일', kind: 'public' },
|
||||
'2026-07-17': { name: '제헌절', kind: 'public' },
|
||||
'2026-08-15': { name: '광복절', kind: 'public' },
|
||||
'2026-08-17': { name: '대체공휴일(광복절)', kind: 'public' },
|
||||
'2026-09-24': { name: '추석 연휴', kind: 'chuseok' },
|
||||
'2026-09-25': { name: '추석', kind: 'chuseok' },
|
||||
'2026-09-26': { name: '추석 연휴', kind: 'chuseok' },
|
||||
'2026-10-03': { name: '개천절', kind: 'public' },
|
||||
'2026-10-05': { name: '대체공휴일(개천절)', kind: 'public' },
|
||||
'2026-10-09': { name: '한글날', kind: 'public' },
|
||||
'2026-12-25': { name: '성탄절', kind: 'public' },
|
||||
// 2027
|
||||
'2027-01-01': { name: '신정', kind: 'new_year' },
|
||||
'2027-02-06': { name: '설날 연휴', kind: 'seollal' },
|
||||
'2027-02-07': { name: '설날', kind: 'seollal' },
|
||||
'2027-02-08': { name: '설날 연휴', kind: 'seollal' },
|
||||
'2027-02-09': { name: '대체공휴일(설날)', kind: 'seollal' },
|
||||
'2027-03-01': { name: '삼일절', kind: 'public' },
|
||||
'2027-05-01': { name: '노동절', kind: 'public' },
|
||||
'2027-05-03': { name: '대체공휴일(노동절)', kind: 'public' },
|
||||
'2027-05-05': { name: '어린이날', kind: 'public' },
|
||||
'2027-05-13': { name: '부처님오신날', kind: 'public' },
|
||||
'2027-06-06': { name: '현충일', kind: 'public' },
|
||||
'2027-07-17': { name: '제헌절', kind: 'public' },
|
||||
'2027-07-19': { name: '대체공휴일(제헌절)', kind: 'public' },
|
||||
'2027-08-15': { name: '광복절', kind: 'public' },
|
||||
'2027-08-16': { name: '대체공휴일(광복절)', kind: 'public' },
|
||||
'2027-09-14': { name: '추석 연휴', kind: 'chuseok' },
|
||||
'2027-09-15': { name: '추석', kind: 'chuseok' },
|
||||
'2027-09-16': { name: '추석 연휴', kind: 'chuseok' },
|
||||
'2027-10-03': { name: '개천절', kind: 'public' },
|
||||
'2027-10-04': { name: '대체공휴일(개천절)', kind: 'public' },
|
||||
'2027-10-09': { name: '한글날', kind: 'public' },
|
||||
'2027-10-11': { name: '대체공휴일(한글날)', kind: 'public' },
|
||||
'2027-12-25': { name: '성탄절', kind: 'public' },
|
||||
'2027-12-27': { name: '대체공휴일(성탄절)', kind: 'public' },
|
||||
};
|
||||
|
||||
/** 표가 담은 해 — 해마다 다음 해를 더한다 */
|
||||
export const HOLIDAY_TABLE_YEARS: readonly number[] = [2026, 2027];
|
||||
|
||||
/**
|
||||
* 영업시간 파서가 쉬는날 문장에서 뽑는 휴무 표시(ParsedOpeningHours.holidayClosures)와 공휴일 종류의 대응.
|
||||
* '공휴일' 휴무는 모든 공휴일에 쉰다.
|
||||
*/
|
||||
const ANY_HOLIDAY_CLOSURE = '공휴일';
|
||||
const CLOSURES_BY_KIND: Readonly<Record<HolidayKind, readonly string[]>> = {
|
||||
new_year: ['1월 1일'],
|
||||
seollal: ['설'],
|
||||
chuseok: ['추석'],
|
||||
public: [],
|
||||
};
|
||||
|
||||
/** 그날의 공휴일('YYYY-MM-DD'). 아니거나 표에 없는 해면 null */
|
||||
export function holidayOn(date: string): Holiday | null {
|
||||
return HOLIDAYS[date] ?? null;
|
||||
}
|
||||
|
||||
/** 그해 공휴일 목록(날짜 순) */
|
||||
export function holidaysIn(year: number): (Holiday & { date: string })[] {
|
||||
return Object.entries(HOLIDAYS)
|
||||
.filter(([date]) => date.startsWith(`${year}-`))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, holiday]) => ({ date, ...holiday }));
|
||||
}
|
||||
|
||||
/** 표가 그날의 해를 담고 있는가 — false 면 공휴일 여부를 모른다 */
|
||||
export function isHolidayTableYear(date: string): boolean {
|
||||
return HOLIDAY_TABLE_YEARS.includes(Number(date.slice(0, 4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴무 표시로 보아 그 공휴일에 쉬는가.
|
||||
* 설·추석은 당일만 쉬는지 연휴 내내 쉬는지 문장마다 달라 연휴 전체를 쉬는 것으로 본다(닫힌 곳으로 보내지 않게).
|
||||
*/
|
||||
export function closedOnHoliday(
|
||||
holidayClosures: readonly string[],
|
||||
kind: HolidayKind,
|
||||
): boolean {
|
||||
return holidayClosures.some(
|
||||
(closure) =>
|
||||
closure === ANY_HOLIDAY_CLOSURE ||
|
||||
CLOSURES_BY_KIND[kind].includes(closure),
|
||||
);
|
||||
}
|
||||
@@ -43,10 +43,10 @@ describe('parseKstTimestamp', () => {
|
||||
});
|
||||
|
||||
describe('kstClock', () => {
|
||||
it('한국 시간의 요일과 자정 이후 분을 돌려준다', () => {
|
||||
it('한국 시간의 날짜와 자정 이후 분을 돌려준다', () => {
|
||||
// 2026-11-01(일) 15:30 UTC = 2026-11-02(월) 00:30 KST
|
||||
expect(kstClock(new Date('2026-11-01T15:30:00Z'))).toEqual({
|
||||
weekday: 1,
|
||||
date: '2026-11-02',
|
||||
minutes: 30,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,11 +35,11 @@ export function parseKstTimestamp(value: unknown): Date | null {
|
||||
return back.getUTCDate() === d ? date : null;
|
||||
}
|
||||
|
||||
/** 한국 시간 기준 요일(0=일요일 … 6=토요일)과 자정 이후 분 — "지금 영업 중" 판단에 쓴다 */
|
||||
export function kstClock(now: Date): { weekday: number; minutes: number } {
|
||||
/** 한국 시간 기준 날짜('YYYY-MM-DD')와 자정 이후 분 — "지금 영업 중" 판단에 쓴다(계절별 시간표는 날짜의 달로 고른다) */
|
||||
export function kstClock(now: Date): { date: string; minutes: number } {
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return {
|
||||
weekday: kst.getUTCDay(),
|
||||
date: kst.toISOString().slice(0, 10),
|
||||
minutes: kst.getUTCHours() * 60 + kst.getUTCMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { maskContact } from './mask-contact';
|
||||
|
||||
describe('maskContact', () => {
|
||||
it('이메일을 가린다', () => {
|
||||
expect(maskContact('결과는 me.test+1@example.co.kr 로 보내 줘')).toBe(
|
||||
'결과는 [이메일] 로 보내 줘',
|
||||
);
|
||||
});
|
||||
|
||||
it('여러 모양의 전화번호를 가린다', () => {
|
||||
expect(maskContact('010-1234-5678 로 연락')).toBe('[전화번호] 로 연락');
|
||||
expect(maskContact('01012345678')).toBe('[전화번호]');
|
||||
expect(maskContact('+82 10 1234 5678')).toBe('[전화번호]');
|
||||
expect(maskContact('식당 053-123-4567, 서울 02-123-4567')).toBe(
|
||||
'식당 [전화번호], 서울 [전화번호]',
|
||||
);
|
||||
expect(maskContact('(053) 123-4567')).toBe('[전화번호]');
|
||||
expect(maskContact('1588-1234')).toBe('[전화번호]');
|
||||
});
|
||||
|
||||
it('날짜·시각·금액·연도는 그대로 둔다', () => {
|
||||
const text =
|
||||
'2026-11-09 토요일 10:00~18:00, 1인 15000원 이하, 1950 2020 년대 감성';
|
||||
expect(maskContact(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 문장 속 전화번호·이메일 모양을 가린다 (순수 함수).
|
||||
* AI 코스 요청 문장은 가린 뒤 저장하고 AI 에 보낸다(docs/design/ai-course.md §6.1·§10).
|
||||
*/
|
||||
|
||||
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
||||
/**
|
||||
* 전화번호 — 국제 표기 휴대전화(+82 10 1234 5678), 국내 번호(010-1234-5678 · 01012345678 · 02-123-4567 · (053) 123-4567),
|
||||
* 대표번호(1588-1234, 하이픈이 있을 때만 — "1950 2020" 같은 숫자를 가리지 않게).
|
||||
* 앞뒤가 숫자인 긴 숫자열 속은 보지 않는다.
|
||||
*/
|
||||
const PHONE =
|
||||
/(?<!\d)(?:\+82[\s.-]?1\d[\s.-]?\d{3,4}[\s.-]?\d{4}|\(?0\d{1,2}\)?[\s.-]?\d{3,4}[\s.-]?\d{4}|1[5-9]\d{2}-\d{4})(?!\d)/g;
|
||||
|
||||
export const MASKED_EMAIL = '[이메일]';
|
||||
export const MASKED_PHONE = '[전화번호]';
|
||||
|
||||
export function maskContact(text: string): string {
|
||||
return text.replace(EMAIL, MASKED_EMAIL).replace(PHONE, MASKED_PHONE);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { isOpenAt, parseOpeningHours } from './opening-hours';
|
||||
import {
|
||||
hoursOn,
|
||||
isOpenAt,
|
||||
parseOpeningHours,
|
||||
type ParsedOpeningHours,
|
||||
} from './opening-hours';
|
||||
|
||||
// 요일 번호: 0=일요일 … 6=토요일
|
||||
const SUN = 0;
|
||||
@@ -9,6 +14,12 @@ const THU = 4;
|
||||
const FRI = 5;
|
||||
const SAT = 6;
|
||||
const at = (h: number, m = 0) => h * 60 + m;
|
||||
/** 2026년 그 달(기본 11월)의 첫 번째 그 요일 — 'YYYY-MM-DD' */
|
||||
const on = (weekday: number, month = 11): string => {
|
||||
const firstWeekday = new Date(Date.UTC(2026, month - 1, 1)).getUTCDay();
|
||||
const day = 1 + ((weekday - firstWeekday + 7) % 7);
|
||||
return `2026-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다
|
||||
describe('parseOpeningHours', () => {
|
||||
@@ -27,8 +38,8 @@ describe('parseOpeningHours', () => {
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => {
|
||||
@@ -39,7 +50,7 @@ describe('parseOpeningHours', () => {
|
||||
expect(a.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
|
||||
const b = parseOpeningHours('10:00~21:30 (마지막 주문 21:00)', '연중무휴');
|
||||
expect(b.lastOrder).toBe(at(21));
|
||||
expect(isOpenAt(b, FRI, at(21, 15))).toBe(true);
|
||||
expect(isOpenAt(b, on(FRI), at(21, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => {
|
||||
@@ -49,8 +60,8 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.breaksWeekdaysOnly).toBe(true);
|
||||
expect(p.lastOrder).toBe(at(20, 30));
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(16))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(16))).toBe(true);
|
||||
expect(p.holidayClosures).toEqual(
|
||||
expect.arrayContaining(['설', '추석', '1월 1일']),
|
||||
);
|
||||
@@ -63,57 +74,65 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(p.week[SUN]).toEqual([]);
|
||||
expect(isOpenAt(p, SUN, at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(false);
|
||||
expect(p.notes).toContain('재료 소진 시 조기 마감');
|
||||
});
|
||||
|
||||
it('평일·주말 시간이 다르면 요일별로 나눈다', () => {
|
||||
const p = parseOpeningHours('평일 11:00~22:00 / 주말 10:00~22:00', '');
|
||||
expect(isOpenAt(p, SAT, at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(10, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(10, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('요일 범위(화~일)와 월요일 휴관을 읽는다', () => {
|
||||
const p = parseOpeningHours('화~일 10:00~18:00 (월요일 휴관)', '');
|
||||
expect(p.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(p, SUN, at(17))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(17))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('자정을 넘기는 영업은 다음 날 새벽까지 이어진다', () => {
|
||||
const p = parseOpeningHours('18:00~02:00', '매주 월요일');
|
||||
expect(p.week[SUN]).toEqual([{ start: at(18), end: at(26) }]);
|
||||
// 일요일 밤에 열어 월요일 새벽 1시까지 — 월요일이 휴무여도 전날 영업은 이어진다
|
||||
expect(isOpenAt(p, MON, at(1))).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(19))).toBe(false);
|
||||
expect(isOpenAt(p, on(MON), at(1))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(19))).toBe(false);
|
||||
});
|
||||
|
||||
it('상시 개방과 24시간은 항상 연 것으로 본다', () => {
|
||||
expect(parseOpeningHours('상시 개방').status).toBe('always_open');
|
||||
const p = parseOpeningHours('24시간', '연중무휴');
|
||||
expect(p.status).toBe('always_open');
|
||||
expect(isOpenAt(p, WED, at(3))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(3))).toBe(true);
|
||||
});
|
||||
|
||||
it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => {
|
||||
expect(parseOpeningHours('점포별 상이').status).toBe('varies');
|
||||
const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고');
|
||||
expect(p.status).toBe('varies');
|
||||
expect(isOpenAt(p, SAT, at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('값이 없으면 미확인으로 둔다', () => {
|
||||
expect(parseOpeningHours(null).status).toBe('unknown');
|
||||
expect(parseOpeningHours('').status).toBe('unknown');
|
||||
expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull();
|
||||
expect(isOpenAt(parseOpeningHours(undefined), on(SAT), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => {
|
||||
it('달을 적지 않은 하절기·동절기는 확실한 달에 그 계절 시간을 쓰고, 3·10·11월에는 두 시간이 같을 때만 답한다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(12))).toBeNull();
|
||||
expect(p.seasons).toHaveLength(2);
|
||||
expect(isOpenAt(p, on(WED, 7), at(10, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(10, 30))).toBe(false);
|
||||
// 11월은 어느 계절인지 기관마다 다르다 — 두 계절 모두 여는 시각만 연다고 답한다
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(10, 30))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED), at(18))).toBe(false);
|
||||
// 월요일은 어느 계절에도 시간이 없다
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('줄바꿈 태그와 안내 문구를 정리한다', () => {
|
||||
@@ -142,14 +161,14 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
|
||||
expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
|
||||
expect(isOpenAt(p, MON, at(20, 15))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(20, 15))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(20, 15))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(20, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => {
|
||||
const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00');
|
||||
expect(isOpenAt(p, MON, at(17, 30))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(MON), at(17, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(17, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('줄바꿈은 구간 구분으로 본다', () => {
|
||||
@@ -163,8 +182,8 @@ describe('parseOpeningHours', () => {
|
||||
it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => {
|
||||
const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)');
|
||||
expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(16))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => {
|
||||
@@ -191,15 +210,18 @@ describe('parseOpeningHours', () => {
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.breaksWeekdaysOnly).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(15))).toBe(false);
|
||||
expect(isOpenAt(p, SAT, at(15))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(15))).toBe(false);
|
||||
expect(isOpenAt(p, on(SAT), at(15))).toBe(true);
|
||||
expect(p.lastOrder).toBe(at(21, 40));
|
||||
});
|
||||
|
||||
it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => {
|
||||
it('달 머리말("[1월]")은 월요일로 읽지 않고 그 달의 시간표로 둔다 — 적히지 않은 달은 판단하지 않는다', () => {
|
||||
const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00');
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(isOpenAt(p, MON, at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(18, 45))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 2), at(18, 45))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 3), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => {
|
||||
@@ -222,8 +244,8 @@ describe('parseOpeningHours', () => {
|
||||
expect(p.week[WED]).toEqual([{ start: at(11), end: at(21) }]);
|
||||
expect(p.breaks).toEqual([{ start: at(16), end: at(17) }]);
|
||||
expect(p.lastOrder).toBe(at(20, 20));
|
||||
expect(isOpenAt(p, WED, at(12, 30))).toBe(true);
|
||||
expect(isOpenAt(p, WED, at(16, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(16, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('붙은 글머리표 뒤의 마지막 주문을 여는 시각으로 읽지 않는다(서민갈비)', () => {
|
||||
@@ -259,17 +281,17 @@ describe('parseOpeningHours', () => {
|
||||
{ start: at(5), end: at(20) },
|
||||
{ start: at(19), end: at(23, 30) },
|
||||
]);
|
||||
expect(isOpenAt(p, SUN, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(22))).toBe(true);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(22))).toBe(true);
|
||||
});
|
||||
|
||||
it('시설마다 평일·주말 시간이 다르면 어느 시설이든 여는 시간을 영업으로 본다(스파밸리)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[실내]- 09:00~18:00[야외]- 평일 09:00~17:00- 주말 09:30~17:30',
|
||||
);
|
||||
expect(isOpenAt(p, WED, at(17, 45))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(9, 15))).toBe(true);
|
||||
expect(isOpenAt(p, SAT, at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(17, 45))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(9, 15))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(18, 30))).toBe(false);
|
||||
});
|
||||
|
||||
it('요일 사이의 / 는 구간 구분이 아니라 나열이다(영생곱창)', () => {
|
||||
@@ -289,14 +311,14 @@ describe('parseOpeningHours', () => {
|
||||
expect(p.week[WED]).toEqual([{ start: at(9), end: at(20) }]);
|
||||
expect(p.week[SAT]).toEqual([{ start: at(9), end: at(18) }]);
|
||||
expect(p.week[MON]).toBeNull();
|
||||
expect(isOpenAt(p, WED, at(12, 30))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
it('2시간 이하의 점심시간은 쉬는 시간, 그보다 길면 점심 영업으로 읽는다', () => {
|
||||
const office = parseOpeningHours('09:00~18:00 (점심시간 13:00~14:00)');
|
||||
expect(office.breaks).toEqual([{ start: at(13), end: at(14) }]);
|
||||
expect(isOpenAt(office, WED, at(13, 30))).toBe(false);
|
||||
expect(isOpenAt(office, on(WED), at(13, 30))).toBe(false);
|
||||
const restaurant = parseOpeningHours(
|
||||
'점심시간 11:30~14:30 / 저녁시간 17:00~21:00',
|
||||
);
|
||||
@@ -313,14 +335,14 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.breaks).toEqual([]);
|
||||
expect(p.week[WED]).toEqual([{ start: at(10), end: at(17) }]);
|
||||
expect(isOpenAt(p, WED, at(14))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
|
||||
});
|
||||
|
||||
// 2026-11-05 관광공사 대구 상세 점검 — 쉬는날을 잘못 읽어 영업 중 표시가 틀린 17곳
|
||||
it('쉬는날의 요일 범위는 가운데 요일까지 휴무로 본다(북성로공구빵·엘리몽·대구 북구문화원)', () => {
|
||||
const range = parseOpeningHours('10:00~20:00', '매주 월요일~목요일');
|
||||
expect(range.closedWeekdays).toEqual([MON, TUE, WED, THU]);
|
||||
expect(isOpenAt(range, TUE, at(12))).toBe(false);
|
||||
expect(isOpenAt(range, on(TUE), at(12))).toBe(false);
|
||||
const weekdays = parseOpeningHours('10:00~18:00', '매주 평일(월~금)');
|
||||
expect(weekdays.closedWeekdays).toEqual([MON, TUE, WED, THU, FRI]);
|
||||
const overSunday = parseOpeningHours(
|
||||
@@ -336,8 +358,8 @@ describe('parseOpeningHours', () => {
|
||||
'주말 / 공휴일',
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN, SAT]);
|
||||
expect(isOpenAt(p, SAT, at(10))).toBe(false);
|
||||
expect(isOpenAt(p, WED, at(10))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(10))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(10))).toBe(true);
|
||||
expect(parseOpeningHours('10:30~20:30', '주말').closedWeekdays).toEqual([
|
||||
SUN,
|
||||
SAT,
|
||||
@@ -350,8 +372,8 @@ describe('parseOpeningHours', () => {
|
||||
'매주 월요일 (단, 월요일이 공휴일 때에는 공휴일 다음의 첫번째 평일) / 1월 1일 / 설날·추석 당일',
|
||||
);
|
||||
expect(museum.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(museum, MON, at(12))).toBe(false);
|
||||
expect(isOpenAt(museum, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(museum, on(MON), at(12))).toBe(false);
|
||||
expect(isOpenAt(museum, on(TUE), at(12))).toBe(true);
|
||||
const village = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설날·추석 연휴일 다음의 첫 번째 평일',
|
||||
@@ -404,7 +426,7 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(p.notes[0]).toContain('마지막주 월요일');
|
||||
expect(isOpenAt(p, MON, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(true);
|
||||
// '정기휴무' 같은 말머리는 시설 이름이 아니다
|
||||
const labeled = parseOpeningHours(
|
||||
'10:00~20:00',
|
||||
@@ -420,7 +442,7 @@ describe('parseOpeningHours', () => {
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
expect(p.notes[0]).toContain('첫째주');
|
||||
expect(isOpenAt(p, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(TUE), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호 안의 주기는 앞 구절을 꾸민다 — "매주 월요일 (매월 2주, 4주)" 는 매주 휴무가 아니다(대구광역시립남부도서관)', () => {
|
||||
@@ -449,28 +471,268 @@ describe('parseOpeningHours', () => {
|
||||
'[북카페]- 일요일[북키즈 / 다목적홀]- 주말',
|
||||
);
|
||||
expect(p.closedWeekdays).toEqual([SUN]);
|
||||
expect(isOpenAt(p, SAT, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('시기마다 쉬는날이 다르면 어느 시기인지 몰라 시각 판단을 하지 않는다(대구창의융합교육원)', () => {
|
||||
it('시기마다 쉬는날이 다르면 날짜의 달로 그 시기의 휴무 요일을 고른다(대구창의융합교육원)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'[3월~7월 / 9월~12월] - 월요일 / 공휴일[1월 / 2월 / 8월] - 일요일 / 공휴일',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(p.closedWeekdays).toEqual([SUN, MON]);
|
||||
expect(isOpenAt(p, MON, at(12))).toBeNull();
|
||||
expect(p.closedWeekdays).toEqual([]);
|
||||
expect(p.restSeasons.map((r) => r.closedWeekdays)).toEqual([[MON], [SUN]]);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON, 8), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SUN, 8), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('영업시간 문장 속 휴무 구절은 같은 구절의 여는 요일 범위를 휴무로 읽지 않는다', () => {
|
||||
const p = parseOpeningHours('월~금 10:00~18:00 토요일 휴무', '');
|
||||
expect(p.closedWeekdays).toEqual([SAT]);
|
||||
expect(isOpenAt(p, WED, at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
|
||||
// 괄호 안 조건 속 요일도 휴무로 읽지 않는다
|
||||
const q = parseOpeningHours(
|
||||
'화~일 10:00~18:00 (월요일 휴관, 단 월요일이 공휴일이면 화요일 휴관)',
|
||||
);
|
||||
expect(q.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(q, TUE, at(12))).toBe(true);
|
||||
expect(isOpenAt(q, on(TUE), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
// 2026-11-06 관광공사 대구 원문 — 계절별 시간표 32곳(그때까지 영업시간 미확인 23곳, 1년 내내 같은 시간으로 읽힌 9곳)
|
||||
describe('계절별 시간표', () => {
|
||||
it('달을 적은 계절 머리말은 그 달에 그 계절 시간을 쓴다(대구미술관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~10월)]- 10:00~19:00- 입장 마감 18:00[동절기(11월~3월)]- 10:00~18:00- 입장 마감 17:00',
|
||||
'매주 월요일 (단, 월요일이 공휴일인 경우 그 다음 평일 휴관) / 1월 1일 / 설·추석',
|
||||
);
|
||||
expect(p.seasons.map((s) => s.months)).toEqual([
|
||||
[4, 5, 6, 7, 8, 9, 10],
|
||||
[1, 2, 3, 11, 12],
|
||||
]);
|
||||
expect(isOpenAt(p, on(WED), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 6), at(18, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
|
||||
expect(p.notes).toContain('입장 마감 17:00');
|
||||
});
|
||||
|
||||
it('시각 바로 뒤에 붙은 계절 머리말을 나눠 읽는다(수창청춘맨숀·대구문화예술회관)', () => {
|
||||
const undated = parseOpeningHours(
|
||||
'하절기 10:00~19:00동절기 10:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설날·추석 당일',
|
||||
);
|
||||
expect(isOpenAt(undated, on(WED), at(14))).toBe(true);
|
||||
expect(isOpenAt(undated, on(WED), at(18, 30))).toBeNull();
|
||||
expect(isOpenAt(undated, on(WED, 1), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(undated, on(WED, 7), at(18, 30))).toBe(true);
|
||||
const dated = parseOpeningHours(
|
||||
'하절기(4월1일~10월31일) 10:00~19:00동절기(11월1일~3월31일) 10:00~18:00',
|
||||
);
|
||||
expect(isOpenAt(dated, on(WED), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(dated, on(WED, 10), at(18, 30))).toBe(true);
|
||||
});
|
||||
|
||||
it('달 중간에 바뀌는 계절은 그 달을 확실하지 않은 달로 둔다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'하절기(3월 15일~10월) 09:00~18:00 / 동절기(11월~3월 14일) 09:00~17:00',
|
||||
);
|
||||
expect(p.seasons[0].sureMonths).not.toContain(3);
|
||||
expect(isOpenAt(p, on(WED, 3), at(17, 30))).toBeNull();
|
||||
expect(isOpenAt(p, on(WED, 3), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('"4~10월"·글머리표 뒤의 달 머리말도 계절로 읽는다(향촌문화관·달성 한일우호관·화본마을)', () => {
|
||||
const short = parseOpeningHours(
|
||||
'- 하절기(4~10월) 09:00~19:00- 동절기(11~3월) 09:00~18:00※ 30분 전까지 입장 가능',
|
||||
);
|
||||
expect(isOpenAt(short, on(WED, 3), at(18, 30))).toBe(false);
|
||||
expect(isOpenAt(short, on(WED, 4), at(18, 30))).toBe(true);
|
||||
const months = parseOpeningHours(
|
||||
'- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
|
||||
);
|
||||
expect(isOpenAt(months, on(WED), at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(months, on(WED, 10), at(17, 30))).toBe(true);
|
||||
const museum = parseOpeningHours(
|
||||
'[박물관]- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
|
||||
);
|
||||
expect(isOpenAt(museum, on(WED), at(17, 30))).toBe(false);
|
||||
expect(isOpenAt(museum, on(WED, 3), at(17, 30))).toBe(true);
|
||||
});
|
||||
|
||||
it('대괄호 안에 달을 나열한 머리말도 계절로 읽는다(대구수목원)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[1월~4월/9월~12월]09:00~18:00[5월~8월]08:00~19:00',
|
||||
'매주 월요일',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED), at(8, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 7), at(8, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('머리말 없는 기본 시간은 계절 시간이 없는 달과 요일에 쓴다(숨쉴꽃 허브 체험농장)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'- 10:00~17:00[하절기(6~8월)]- 10:00~13:00',
|
||||
'매주 토요일',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED, 7), at(15))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED), at(15))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(false);
|
||||
const weekendOnly = parseOpeningHours(
|
||||
'09:00~18:00 / 하절기 주말 09:00~20:00',
|
||||
);
|
||||
expect(isOpenAt(weekendOnly, on(SAT, 7), at(19))).toBe(true);
|
||||
expect(isOpenAt(weekendOnly, on(WED, 7), at(17))).toBe(true);
|
||||
expect(isOpenAt(weekendOnly, on(WED, 7), at(19))).toBe(false);
|
||||
});
|
||||
|
||||
it('계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~11월)]\n\n- 평일 09:00~18:00\n\n- 주말 10:00~18:00\n\n[동절기(12월~3월)]\n\n- 평일 09:00~18:00\n\n- 주말 미개방\n\n※ 자세한 사항은 전화문의 요망',
|
||||
'연중무휴',
|
||||
);
|
||||
expect(p.status).toBe('parsed');
|
||||
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
|
||||
expect(isOpenAt(p, on(SAT, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('괄호에 달만 적은 구간은 그 기간의 시간이고, 다른 시설 시간은 계절과 함께 연다(디아크문화관)', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[B1-2F 전시실]- 10:00~17:30[3F카페&전망대]- 10:00~23:00 (4월~11월)- 10:00~22:00 (12월~3월)[B1 자전거종주인증]- 10:00~22:00',
|
||||
'1월 1일 / 매주 월요일 (단, 공휴일 제외)',
|
||||
);
|
||||
expect(isOpenAt(p, on(WED, 1), at(22, 30))).toBe(false);
|
||||
expect(isOpenAt(p, on(WED, 7), at(22, 30))).toBe(true);
|
||||
expect(isOpenAt(p, on(WED, 1), at(11))).toBe(true);
|
||||
expect(p.notes).not.toContain('4월~11월');
|
||||
});
|
||||
|
||||
it('쉬는날에 기간만 적으면 그 기간에는 문을 열지 않는다(상주식당·강변축구장·사문진피크닉장)', () => {
|
||||
const restaurant = parseOpeningHours('10:00~19:00', '매년 12월~3월');
|
||||
expect(restaurant.closedPeriods.map((c) => c.months)).toEqual([
|
||||
[1, 2, 3, 12],
|
||||
]);
|
||||
expect(isOpenAt(restaurant, on(WED, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(restaurant, on(WED), at(12))).toBe(true);
|
||||
const field = parseOpeningHours(
|
||||
'[하절기 (4~9월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 19:00 - 야간 19:00 ~ 22:00 [동절기 (10~3월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 18:00 - 야간 18:00 ~ 22:00',
|
||||
'동절기 (11월~2월)',
|
||||
);
|
||||
expect(isOpenAt(field, on(WED, 12), at(12))).toBe(false);
|
||||
expect(isOpenAt(field, on(WED, 10), at(21))).toBe(true);
|
||||
expect(isOpenAt(field, on(WED, 7), at(7, 30))).toBe(true);
|
||||
const picnic = parseOpeningHours(
|
||||
'10:00~18:00※ 3월~11월에 운영함으로 자세한 사항은 홈페이지 참조',
|
||||
'매주 월요일 (단, 월요일이 법정공휴일인 경우 다음날 휴무) / 12월~2월',
|
||||
);
|
||||
expect(picnic.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(picnic, on(WED, 2), at(12))).toBe(false);
|
||||
expect(isOpenAt(picnic, on(WED), at(12))).toBe(true);
|
||||
});
|
||||
|
||||
it('"12월~2월 휴장"처럼 쉰다는 말이 붙은 기간도 문을 열지 않는 기간이다(영업시간·쉬는날 모두)', () => {
|
||||
const hours = parseOpeningHours('10:00~18:00 / 12월~2월 휴장');
|
||||
expect(isOpenAt(hours, on(WED, 1), at(12))).toBe(false);
|
||||
expect(isOpenAt(hours, on(WED), at(12))).toBe(true);
|
||||
const rest = parseOpeningHours(
|
||||
'10:00~18:00',
|
||||
'매주 월요일 / 1월~2월 휴장',
|
||||
);
|
||||
expect(rest.closedPeriods.map((c) => c.months)).toEqual([[1, 2]]);
|
||||
expect(rest.closedWeekdays).toEqual([MON]);
|
||||
expect(isOpenAt(rest, on(WED, 2), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('날짜("1월 1일")와 예외 문구 속 달은 문을 닫는 기간이 아니다(달성 한일우호관·도학야영장)', () => {
|
||||
const museum = parseOpeningHours(
|
||||
'09:00~18:00',
|
||||
'매주 월요일 / 1월 1일 / 설 / 추석 당일',
|
||||
);
|
||||
expect(museum.closedPeriods).toEqual([]);
|
||||
expect(museum.holidayClosures).toContain('1월 1일');
|
||||
const camp = parseOpeningHours(
|
||||
'14:00~익일 12:00',
|
||||
'매주 수요일 (※ 성수기 7월~8월, 10월~11월 15일은 제외)',
|
||||
);
|
||||
expect(camp.closedPeriods).toEqual([]);
|
||||
expect(camp.closedWeekdays).toEqual([WED]);
|
||||
expect(camp.seasonal).toBe(false);
|
||||
});
|
||||
|
||||
it('계절을 말하지만 기간을 읽지 못하면 시각 판단을 하지 않는다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'10:00~18:00 (여름철 19:00까지)',
|
||||
'매주 월요일',
|
||||
);
|
||||
expect(p.seasonal).toBe(true);
|
||||
expect(p.seasons).toEqual([]);
|
||||
expect(isOpenAt(p, on(WED, 7), at(12))).toBeNull();
|
||||
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
|
||||
});
|
||||
|
||||
it('계절별 시간표를 두기 전에 저장한 해석 결과는 판단하지 않는다', () => {
|
||||
const stored = {
|
||||
...parseOpeningHours('09:00~18:00'),
|
||||
seasonal: true,
|
||||
seasons: undefined,
|
||||
} as unknown as ParsedOpeningHours;
|
||||
expect(isOpenAt(stored, on(WED), at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it('자정을 넘긴 계절 영업은 다음 날 새벽까지 이어진다', () => {
|
||||
const p = parseOpeningHours(
|
||||
'[하절기(4월~10월)] 18:00~02:00 [동절기(11월~3월)] 18:00~24:00',
|
||||
);
|
||||
// 7월 첫 목요일 새벽 1시 — 수요일 밤 영업이 이어진다
|
||||
expect(isOpenAt(p, on(THU, 7), at(1))).toBe(true);
|
||||
expect(isOpenAt(p, on(THU, 1), at(1))).toBe(false);
|
||||
});
|
||||
|
||||
it('날짜 형식이 아니면 판단하지 않는다', () => {
|
||||
expect(
|
||||
isOpenAt(parseOpeningHours('09:00~18:00'), '11/06', at(12)),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hoursOn', () => {
|
||||
it('그 날짜의 영업 구간을 주고, 쉬는 요일이면 빈 배열이다', () => {
|
||||
const p = parseOpeningHours('10:00~22:00', '매주 월요일');
|
||||
expect(hoursOn(p, on(TUE))).toEqual([{ start: at(10), end: at(22) }]);
|
||||
expect(hoursOn(p, on(MON))).toEqual([]);
|
||||
});
|
||||
|
||||
it('상시 개방은 0~24시, 미확인·점포별 상이는 null 이다', () => {
|
||||
expect(hoursOn(parseOpeningHours('상시 개방'), on(WED))).toEqual([
|
||||
{ start: 0, end: at(24) },
|
||||
]);
|
||||
expect(hoursOn(parseOpeningHours(null), on(WED))).toBeNull();
|
||||
expect(hoursOn(parseOpeningHours('점포별 상이'), on(WED))).toBeNull();
|
||||
});
|
||||
|
||||
it('계절별 시간표는 날짜의 달로 고르고, 정할 수 없으면 null 이다', () => {
|
||||
const museum = parseOpeningHours(
|
||||
'[1월~4월/9월~12월]09:00~18:00[5월~8월]08:00~19:00',
|
||||
);
|
||||
expect(hoursOn(museum, on(WED, 11))).toEqual([
|
||||
{ start: at(9), end: at(18) },
|
||||
]);
|
||||
expect(hoursOn(museum, on(WED, 7))).toEqual([
|
||||
{ start: at(8), end: at(19) },
|
||||
]);
|
||||
// 달을 적지 않은 하절기·동절기는 11월에 두 시간표가 달라 정할 수 없다
|
||||
const mansion = parseOpeningHours('하절기 10:00~19:00동절기 10:00~18:00');
|
||||
expect(hoursOn(mansion, on(WED, 11))).toBeNull();
|
||||
expect(hoursOn(mansion, on(WED, 7))).toEqual([
|
||||
{ start: at(10), end: at(19) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('문을 열지 않는 기간이면 빈 배열이다', () => {
|
||||
const p = parseOpeningHours('10:00~19:00', '매년 12월~3월');
|
||||
expect(hoursOn(p, on(WED, 1))).toEqual([]);
|
||||
expect(hoursOn(p, on(WED, 5))).toEqual([{ start: at(10), end: at(19) }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00",
|
||||
* "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일".
|
||||
* AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다.
|
||||
* "하절기(4월~10월) … 동절기(11월~3월) …" 처럼 계절마다 다르면 계절별 시간표를 두고 날짜의 달로 고른다.
|
||||
* 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다.
|
||||
*/
|
||||
|
||||
@@ -26,22 +27,58 @@ export type OpeningHoursStatus =
|
||||
| 'varies'
|
||||
| 'unknown';
|
||||
|
||||
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
|
||||
export type WeekHours = (TimeRange[] | null)[];
|
||||
|
||||
/** 1년 중 일부 기간(달 단위) — 계절·시기 머리말 하나 */
|
||||
export interface MonthPeriod {
|
||||
/** 머리말 원문("하절기(4월~10월)"). 머리말 없는 기본 시간은 빈 문자열 */
|
||||
label: string;
|
||||
/** 이 기간일 수 있는 달(1~12) */
|
||||
months: number[];
|
||||
/**
|
||||
* 이 기간이 확실한 달. 달을 적지 않은 하절기·동절기는 어느 범위로 보든 그 계절인 달만 넣고,
|
||||
* "4월 15일~" 처럼 달 중간에 바뀌는 달은 뺀다
|
||||
*/
|
||||
sureMonths: number[];
|
||||
}
|
||||
|
||||
/** 계절·시기별 시간표 */
|
||||
export interface SeasonHours extends MonthPeriod {
|
||||
/** 그 기간의 요일별 영업 구간(정기 휴무 반영) */
|
||||
week: WeekHours;
|
||||
}
|
||||
|
||||
/** 시기별 정기 휴무 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
|
||||
export interface SeasonRest extends MonthPeriod {
|
||||
closedWeekdays: number[];
|
||||
}
|
||||
|
||||
export interface ParsedOpeningHours {
|
||||
status: OpeningHoursStatus;
|
||||
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
|
||||
week: (TimeRange[] | null)[];
|
||||
/** 1년 내내 같은 요일별 영업 구간. 계절별 시간표(seasons)가 있으면 정기 휴무만 남기고 비운다 */
|
||||
week: WeekHours;
|
||||
/** 준비시간(브레이크 타임) */
|
||||
breaks: TimeRange[];
|
||||
/** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */
|
||||
breaksWeekdaysOnly: boolean;
|
||||
/** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */
|
||||
lastOrder: number | null;
|
||||
/** 정기 휴무 요일(0=일요일) */
|
||||
/** 1년 내내 쉬는 정기 휴무 요일(0=일요일) */
|
||||
closedWeekdays: number[];
|
||||
/** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */
|
||||
holidayClosures: string[];
|
||||
/** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */
|
||||
/**
|
||||
* 계절·시기마다 다르다(seasons·restSeasons·closedPeriods 중 하나가 있다).
|
||||
* 계절을 말하는데 기간을 읽지 못했으면 셋 다 비고 week 도 비워 시각 판단을 하지 않는다
|
||||
*/
|
||||
seasonal: boolean;
|
||||
/** 계절·시기별 시간표 — 날짜의 달로 고른다 */
|
||||
seasons: SeasonHours[];
|
||||
/** 시기별 정기 휴무(closedWeekdays 에 더한다) */
|
||||
restSeasons: SeasonRest[];
|
||||
/** 문을 열지 않는 기간 — 쉬는날 "매년 12월~3월", "동절기 (11월~2월)" */
|
||||
closedPeriods: MonthPeriod[];
|
||||
/** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */
|
||||
notes: string[];
|
||||
}
|
||||
@@ -65,12 +102,19 @@ const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/;
|
||||
const SEGMENT_AFTER_TIME =
|
||||
/(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g;
|
||||
/**
|
||||
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이 오는 곳.
|
||||
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20".
|
||||
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
|
||||
* "11:00- 21:00" 처럼 뒤에 숫자가 오면 시간 범위라 나누지 않는다.
|
||||
* 시각 뒤에 이어지는 계절·달 머리말 — "10:00~19:00동절기 10:00~18:00"(수창청춘맨숀),
|
||||
* "09:00~18:00 11월~2월 09:00~17:00"
|
||||
*/
|
||||
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—])/g;
|
||||
const SEASON_AFTER_TIME =
|
||||
/(\d{1,2}:\d{2})\s*(?=하절기|동절기|\d{1,2}\s*월\s*~)/g;
|
||||
/**
|
||||
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이나 달 머리말이 오는 곳.
|
||||
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20",
|
||||
* 2026-11-06: "- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00"(달성 한일우호관).
|
||||
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
|
||||
* "11:00- 21:00" 처럼 뒤에 시각이 오면 시간 범위라 나누지 않는다.
|
||||
*/
|
||||
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—]|\d{1,2}\s*월)/g;
|
||||
/** 쉬는 시간(브레이크)을 뜻하는 말 */
|
||||
const BREAK_WORDS = /준비\s*시간|브레이크|휴식\s*시간|휴게\s*시간|break/i;
|
||||
/**
|
||||
@@ -85,6 +129,58 @@ const BEFORE_BRACKET = /(\S)\s*(?=\[)/g;
|
||||
const SLASH_BETWEEN_DAYS =
|
||||
/([월화수목금토일](?:요일)?)\s*\/\s*(?=[월화수목금토일](?:요일)?(?:[\s,·~]|$))/g;
|
||||
|
||||
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
/** 달마다 날 수 — 그 달 끝날까지 적었는지 본다(2월 28일까지면 2월 전체로 본다) */
|
||||
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
/** 계절을 뜻하는 말 */
|
||||
const SEASON_WORDS_SOURCE = '하절기|동절기|여름철?|겨울철?|성수기|비수기';
|
||||
const SEASON_WORD = new RegExp(SEASON_WORDS_SOURCE);
|
||||
const SEASON_WORDS = new RegExp(SEASON_WORDS_SOURCE, 'g');
|
||||
const SUMMER_WORD = /하절기|여름/;
|
||||
const WINTER_WORD = /동절기|겨울/;
|
||||
/** 달 범위 — "4월~10월", "4~10월", "4월1일~10월31일". 묶음: 시작 달·시작 날·끝 달·끝 날 */
|
||||
const MONTH_SPAN_SOURCE = String.raw`(\d{1,2})\s*월?\s*(?:(\d{1,2})\s*일)?\s*~\s*(\d{1,2})\s*월\s*(?:(\d{1,2})\s*일)?`;
|
||||
/** 달 하나 — "8월". "1월 1일" 같은 날짜는 기간이 아니다 */
|
||||
const SINGLE_MONTH_SOURCE = String.raw`(\d{1,2})\s*월(?!\s*\d{1,2}\s*일)`;
|
||||
const MONTH_SPAN = new RegExp(MONTH_SPAN_SOURCE, 'g');
|
||||
const SINGLE_MONTH = new RegExp(SINGLE_MONTH_SOURCE, 'g');
|
||||
const MONTH_ITEM_SOURCE = `(?:${MONTH_SPAN_SOURCE}|${SINGLE_MONTH_SOURCE})`;
|
||||
const MONTH_LIST_SOURCE = String.raw`${MONTH_ITEM_SOURCE}(?:\s*[,·;]\s*${MONTH_ITEM_SOURCE})*`;
|
||||
/** 줄 앞의 계절·달 머리말 — "하절기(4월~10월) …", "하절기 (4월~9월) …", "3월~10월 …", "하절기 화~일 …" */
|
||||
const LEADING_PERIOD = new RegExp(
|
||||
String.raw`^(?:(?:${SEASON_WORDS_SOURCE})(?:\s*\(\s*${MONTH_LIST_SOURCE}\s*\))?|${MONTH_LIST_SOURCE})(?=[\s::(\d-]|$)\s*[::-]?`,
|
||||
);
|
||||
/** 기간 표현 사이에 올 수 있는 말 — 나열·괄호·'매년' */
|
||||
const PERIOD_FILLER = /[\s,·;~()[\]]|및|매년/g;
|
||||
/** 계절을 말하는 문장 — 기간을 읽지 못하면 시각 판단을 하지 않는다 */
|
||||
const SEASON_HINT =
|
||||
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/;
|
||||
/**
|
||||
* 달을 적지 않은 계절의 기간. 2026-11-06 대구 관광공사 원문 15곳은 하절기를 3~10월·4~9월·4~10월·4~11월 등으로 달리 쓴다.
|
||||
* 그래서 해당할 수 있는 달은 넓게, 확실한 달은 어느 범위로 보든 그 계절인 달만 둔다(하절기 4~9월, 동절기 12~2월).
|
||||
* 3·10·11월에는 두 계절의 시간표가 같은 답을 낼 때만 답한다
|
||||
*/
|
||||
const UNDATED_SUMMER = {
|
||||
months: [3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
sureMonths: [4, 5, 6, 7, 8, 9],
|
||||
};
|
||||
const UNDATED_WINTER = {
|
||||
months: [1, 2, 3, 10, 11, 12],
|
||||
sureMonths: [1, 2, 12],
|
||||
};
|
||||
/** 달을 적지 않은 성수기·비수기는 언제인지 알 수 없다 */
|
||||
const UNDATED_ANY = { months: ALL_MONTHS, sureMonths: [] };
|
||||
/** 문을 열지 않는다는 말 — "12월~2월 휴장" */
|
||||
const CLOSED_WORDS_SOURCE = String.raw`휴장|휴무|휴관|휴업|미운영|운영\s*(?:안\s*함|하지\s*않음|중단)`;
|
||||
const CLOSED_WORDS = new RegExp(`^(?:${CLOSED_WORDS_SOURCE})$`);
|
||||
/** 구절 끝의 문을 열지 않는다는 말 — 쉬는날 "1월~2월 휴장" */
|
||||
const CLOSED_SUFFIX = new RegExp(String.raw`\s*(?:${CLOSED_WORDS_SOURCE})$`);
|
||||
/** 요일과 쉰다는 말만 있는 구절 — "주말 미개방"(영남제일관 동절기), "평일 휴관" */
|
||||
const CLOSED_DAYS = /^(.+?)\s*(?:미개방|휴무|휴관|휴장|미운영)$/;
|
||||
/** 요일을 나타내는 말(쉬는 요일 구절이 요일만으로 이뤄졌는지 볼 때 지운다) */
|
||||
const DAY_WORDS = /평일|주말|주중|[일월화수목금토](?:요일)?|및|[\s~,·]/g;
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */
|
||||
function protectInnerSlashes(text: string): string {
|
||||
return text
|
||||
@@ -105,6 +201,7 @@ function normalize(text: string): string {
|
||||
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
|
||||
.replace(BULLET_AFTER_TIME, '$1 / ')
|
||||
.replace(SEGMENT_AFTER_TIME, '$1 / ')
|
||||
.replace(SEASON_AFTER_TIME, '$1 / ')
|
||||
.replace(BEFORE_BRACKET, '$1 / ')
|
||||
.replace(/(^|\/)\s*-\s*/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -116,6 +213,14 @@ function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
|
||||
return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0);
|
||||
}
|
||||
|
||||
/** 시간 범위가 하나라도 있는가 */
|
||||
function hasTimeRange(text: string): boolean {
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
const found = TIME_RANGE.test(text);
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseRanges(segment: string): TimeRange[] {
|
||||
const ranges: TimeRange[] = [];
|
||||
for (const m of segment.matchAll(TIME_RANGE)) {
|
||||
@@ -168,6 +273,67 @@ function parseDays(prefix: string): number[] | null {
|
||||
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
||||
}
|
||||
|
||||
/** 요일과 쉰다는 말만 있는 구절이면 쉬는 요일 — "주말 미개방" → [0, 6]. 아니면 null */
|
||||
function daysClosedBy(part: string): number[] | null {
|
||||
const m = CLOSED_DAYS.exec(part.trim());
|
||||
if (!m || m[1].replace(DAY_WORDS, '') !== '') return null;
|
||||
return parseDays(m[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 머리말에서 기간(달)을 읽는다 — "하절기(4월~10월)", "4~10월", "[1월~4월 ; 9월~12월]", "하절기".
|
||||
* 달을 적었으면 그 달, 계절 이름만 있으면 흔히 쓰는 범위로 둔다. 달도 계절도 없으면 null
|
||||
*/
|
||||
function parsePeriod(label: string): MonthPeriod | null {
|
||||
const months = new Set<number>();
|
||||
const partial = new Set<number>();
|
||||
const isMonth = (m: number) => m >= 1 && m <= 12;
|
||||
for (const m of label.matchAll(MONTH_SPAN)) {
|
||||
const from = Number(m[1]);
|
||||
const to = Number(m[3]);
|
||||
if (!isMonth(from) || !isMonth(to)) continue;
|
||||
for (let month = from; ; month = (month % 12) + 1) {
|
||||
months.add(month);
|
||||
if (month === to) break;
|
||||
}
|
||||
// 달 중간에 시작하거나 끝나면 그 달은 어느 쪽인지 날짜를 봐야 한다
|
||||
if (m[2] && Number(m[2]) > 1) partial.add(from);
|
||||
if (m[4] && Number(m[4]) < DAYS_IN_MONTH[to - 1]) partial.add(to);
|
||||
}
|
||||
for (const m of label.replace(MONTH_SPAN, ' ').matchAll(SINGLE_MONTH)) {
|
||||
if (isMonth(Number(m[1]))) months.add(Number(m[1]));
|
||||
}
|
||||
if (months.size > 0) {
|
||||
const sorted = [...months].sort((a, b) => a - b);
|
||||
return {
|
||||
label,
|
||||
months: sorted,
|
||||
sureMonths: sorted.filter((m) => !partial.has(m)),
|
||||
};
|
||||
}
|
||||
if (!SEASON_WORD.test(label)) return null;
|
||||
const range = SUMMER_WORD.test(label)
|
||||
? UNDATED_SUMMER
|
||||
: WINTER_WORD.test(label)
|
||||
? UNDATED_WINTER
|
||||
: UNDATED_ANY;
|
||||
return {
|
||||
label,
|
||||
months: [...range.months],
|
||||
sureMonths: [...range.sureMonths],
|
||||
};
|
||||
}
|
||||
|
||||
/** 문장 전체가 기간이면 그 기간 — "4월~11월", "동절기 (11월~2월)", "매년 12월~3월". 아니면 null */
|
||||
function periodOnly(text: string): MonthPeriod | null {
|
||||
const leftover = text
|
||||
.replace(MONTH_SPAN, ' ')
|
||||
.replace(SINGLE_MONTH, ' ')
|
||||
.replace(SEASON_WORDS, ' ')
|
||||
.replace(PERIOD_FILLER, '');
|
||||
return leftover === '' ? parsePeriod(text.trim()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉬는날의 조건·예외 문구 — 여기 나온 요일·평일은 정기 휴무가 아니다.
|
||||
* 2026-11-05 대구 관광공사 원문: "매주 월요일 (월요일이 공휴일인 경우 화요일 휴무)", "일요일을 제외한 법정공휴일",
|
||||
@@ -180,8 +346,6 @@ const REST_CONDITION =
|
||||
* ("공휴일 다음 첫 번째 평일" 의 '번째' 를 몇째 주로 읽어 매주 월요일 휴무까지 버렸다, 2026-11-05 국립대구박물관 등 4곳)
|
||||
*/
|
||||
const REST_NOT_WEEKLY = /매월|매달|격주|째|마지막\s*주|\d\s*주차/;
|
||||
/** 시기 머리말 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
|
||||
const PERIOD_HEADER = /\d{1,2}\s*월|하절기|동절기|여름|겨울|성수기|비수기/;
|
||||
/** 대괄호 없이 구절 앞에 붙인 시설 이름 — "서문시장 매월 첫째주·셋째주 일요일 / 대구서문 야시장 매주 월요일~목요일" */
|
||||
const INLINE_FACILITY = /^\s*(.{2,}?)\s*[::]?\s+(?=매주|매월|매달|격주)/;
|
||||
/** 시설 이름이 아닌 말머리 — "정기휴무 매주 월요일", "단, 매주 …", "매월 격주 일요일" */
|
||||
@@ -291,7 +455,7 @@ function restWeekdays(clauses: string[], withRanges: boolean): Set<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다.
|
||||
* 쉬는날 문장에서 정기 휴무 요일·시기별 휴무·문을 열지 않는 기간·명절 휴무·기타 안내를 뽑는다.
|
||||
* @param restField 쉬는날 원문(restdatefood 등)이면 true, 영업시간 문장 속 "월요일 휴관" 구절이면 false
|
||||
*/
|
||||
function parseRestDays(
|
||||
@@ -307,61 +471,100 @@ function parseRestDays(
|
||||
if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일');
|
||||
if (/공휴일/.test(text)) out.holidayClosures.push('공휴일');
|
||||
|
||||
if (restField) {
|
||||
// 기간만 적은 구절은 그 기간에 문을 열지 않는다는 뜻이다 — "매년 12월~3월"(상주식당), "동절기 (11월~2월)"(강변축구장),
|
||||
// "1월~2월 휴장". 대괄호 머리말("[3월~7월]")은 뒤 구절에 붙는 시기라 뺀다
|
||||
for (const clause of text.split(/\s*\/\s*/)) {
|
||||
const period = clause.includes('[')
|
||||
? null
|
||||
: periodOnly(clause.replace(CLOSED_SUFFIX, ''));
|
||||
if (period) out.closedPeriods.push(period);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = restField
|
||||
? splitRestSections(text)
|
||||
: [{ header: '', body: text }];
|
||||
const closedBySection: Set<number>[] = [];
|
||||
let notWeekly = false;
|
||||
let byPeriod = false;
|
||||
for (const { header, body } of sections) {
|
||||
const clauses = regularRestClauses(body, restField);
|
||||
// 조건·예외만 있는 묶음은 휴무 요일을 따지지 않는다
|
||||
if (clauses.length === 0) continue;
|
||||
if (PERIOD_HEADER.test(header)) byPeriod = true;
|
||||
// "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다.
|
||||
// 구절마다 본다 — "매주 일요일 / 매달 마지막주 월요일" 의 매주 일요일은 휴무다(광명반점)
|
||||
const weekly = clauses.filter((clause) => !REST_NOT_WEEKLY.test(clause));
|
||||
if (weekly.length < clauses.length) notWeekly = true;
|
||||
closedBySection.push(restWeekdays(weekly, restField));
|
||||
const days = restWeekdays(weekly, restField);
|
||||
// 시기 머리말("[3월~7월 / 9월~12월]")이면 그 시기에만 쉰다 — 날짜의 달로 고른다
|
||||
const period = header ? parsePeriod(header) : null;
|
||||
if (period) {
|
||||
out.restSeasons.push({
|
||||
...period,
|
||||
closedWeekdays: [...days].sort((a, b) => a - b),
|
||||
});
|
||||
} else {
|
||||
closedBySection.push(days);
|
||||
}
|
||||
}
|
||||
if (notWeekly) out.notes.push(`휴무: ${text}`);
|
||||
if (closedBySection.length === 0) return;
|
||||
|
||||
// 시기마다 다르면 어느 시기인지 몰라 모두 휴무로 두고 시각 판단을 하지 않는다(영업시간의 하절기·동절기와 같다).
|
||||
// 시설마다 다르면 모든 시설이 쉬는 요일만 휴무다 — 어느 시설이든 열면 영업 중이다(영업시간의 시설 묶음과 같다)
|
||||
const closed = byPeriod
|
||||
? closedBySection.flatMap((days) => [...days])
|
||||
: [...closedBySection[0]].filter((d) =>
|
||||
closedBySection.every((days) => days.has(d)),
|
||||
);
|
||||
if (byPeriod) out.seasonal = true;
|
||||
const closed = [...closedBySection[0]].filter((d) =>
|
||||
closedBySection.every((days) => days.has(d)),
|
||||
);
|
||||
for (const d of closed) {
|
||||
if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d);
|
||||
}
|
||||
out.closedWeekdays.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 시설·계절 머리말 하나에 딸린 시간들 */
|
||||
/**
|
||||
* 머리말 하나에 딸린 시간들.
|
||||
* - base: 첫 머리말 앞의 시간
|
||||
* - facility: 시설 머리말("[칠성시장]") 아래 시간
|
||||
* - season: 계절·달 머리말("[하절기(4월~10월)]", "3월~10월") 아래 시간
|
||||
*/
|
||||
type SectionKind = 'base' | 'facility' | 'season';
|
||||
|
||||
interface Section {
|
||||
kind: SectionKind;
|
||||
/** 계절·달 머리말의 기간 */
|
||||
period: MonthPeriod | null;
|
||||
/** 요일 지정이 없는(매일) 구간 */
|
||||
general: TimeRange[];
|
||||
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다 */
|
||||
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다. 빈 구간은 그 요일 휴무 */
|
||||
specific: { days: number[]; ranges: TimeRange[] }[];
|
||||
}
|
||||
|
||||
const emptySection = (): Section => ({ general: [], specific: [] });
|
||||
const newSection = (
|
||||
kind: SectionKind,
|
||||
period: MonthPeriod | null = null,
|
||||
): Section => ({ kind, period, general: [], specific: [] });
|
||||
|
||||
/** 시설·계절 머리말에서 새 묶음을 연다. 지금 묶음이 비어 있으면 그대로 쓴다 */
|
||||
function openSection(sections: Section[]): void {
|
||||
const last = sections[sections.length - 1];
|
||||
if (last.general.length > 0 || last.specific.length > 0) {
|
||||
sections.push(emptySection());
|
||||
}
|
||||
const isEmptySection = (s: Section): boolean =>
|
||||
s.general.length === 0 && s.specific.length === 0;
|
||||
|
||||
const periodKey = (p: MonthPeriod): string =>
|
||||
`${p.months.join(',')}|${p.sureMonths.join(',')}`;
|
||||
|
||||
/** 같은 기간의 계절 묶음 — "[평일] 하절기 … [주말] 하절기 …" 처럼 같은 계절이 다시 나오면 이어서 쓴다 */
|
||||
function findSeason(
|
||||
sections: Section[],
|
||||
period: MonthPeriod,
|
||||
): Section | undefined {
|
||||
const key = periodKey(period);
|
||||
return sections.find(
|
||||
(s) => s.kind === 'season' && s.period && periodKey(s.period) === key,
|
||||
);
|
||||
}
|
||||
|
||||
const emptyWeek = (): WeekHours => Array.from({ length: 7 }, () => null);
|
||||
|
||||
/** 묶음 하나의 요일별 시간표 — 매일 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다 */
|
||||
function sectionWeek(section: Section): (TimeRange[] | null)[] {
|
||||
const week: (TimeRange[] | null)[] = Array.from({ length: 7 }, () =>
|
||||
function sectionWeek(section: Section): WeekHours {
|
||||
const week: WeekHours = Array.from({ length: 7 }, () =>
|
||||
section.general.length > 0 ? [...section.general] : null,
|
||||
);
|
||||
for (const { days, ranges } of section.specific) {
|
||||
@@ -371,17 +574,16 @@ function sectionWeek(section: Section): (TimeRange[] | null)[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶음끼리는 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
|
||||
* 시간표끼리 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
|
||||
* 일요일은 두 구간 모두 연다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설을 지우지 않게 한다.
|
||||
*/
|
||||
function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
|
||||
const weeks = sections.map(sectionWeek);
|
||||
function mergeWeeks(weeks: WeekHours[]): WeekHours {
|
||||
return Array.from({ length: 7 }, (_, d) => {
|
||||
const known = weeks
|
||||
.map((w) => w[d])
|
||||
.filter((ranges): ranges is TimeRange[] => ranges !== null);
|
||||
if (known.length === 0) return null;
|
||||
if (known.length === 1) return known[0];
|
||||
if (known.length === 1) return [...known[0]];
|
||||
const seen = new Set<string>();
|
||||
return known.flat().filter((r) => {
|
||||
const key = `${r.start}-${r.end}`;
|
||||
@@ -392,16 +594,186 @@ function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 계절 묶음마다 시간표를 만든다.
|
||||
* - 첫 머리말 앞의 기본 시간은 계절 시간이 적히지 않은 요일에 쓴다("10:00~17:00 [하절기(6~8월)] 10:00~13:00")
|
||||
* - 시설 묶음("[전시실] … [카페] 10:00~23:00 (4월~11월)")은 어느 계절이든 함께 연다(어느 시설이든 열려 있으면 영업 중)
|
||||
* - 기본·시설 시간이 있으면 계절이 확실하지 않은 달에 쓸 시간표를 하나 더 둔다
|
||||
*/
|
||||
function seasonHours(sections: Section[]): SeasonHours[] {
|
||||
const shared = sections.filter((s) => s.kind !== 'season');
|
||||
const baseWeek = mergeWeeks(
|
||||
sections.filter((s) => s.kind === 'base').map(sectionWeek),
|
||||
);
|
||||
const facilityWeeks = sections
|
||||
.filter((s) => s.kind === 'facility')
|
||||
.map(sectionWeek);
|
||||
const seasons: SeasonHours[] = [];
|
||||
for (const s of sections) {
|
||||
if (s.kind !== 'season' || !s.period) continue;
|
||||
const own = sectionWeek(s).map((day, d) => day ?? baseWeek[d]);
|
||||
seasons.push({ ...s.period, week: mergeWeeks([own, ...facilityWeeks]) });
|
||||
}
|
||||
if (shared.some((s) => !isEmptySection(s))) {
|
||||
const sure = new Set(seasons.flatMap((s) => s.sureMonths));
|
||||
const possible = new Set(seasons.flatMap((s) => s.months));
|
||||
const months = ALL_MONTHS.filter((m) => !sure.has(m));
|
||||
if (months.length > 0) {
|
||||
seasons.push({
|
||||
label: '',
|
||||
months,
|
||||
sureMonths: months.filter((m) => !possible.has(m)),
|
||||
week: mergeWeeks(shared.map(sectionWeek)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return seasons;
|
||||
}
|
||||
|
||||
/** 영업시간 문장을 시설·계절 묶음으로 나눠 읽고 시간표를 만든다 */
|
||||
function parseHoursText(text: string, result: ParsedOpeningHours): void {
|
||||
const sections: Section[] = [newSection('base')];
|
||||
let current = sections[0];
|
||||
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
|
||||
let headerDays: number[] | null = null;
|
||||
|
||||
/** 머리말에서 묶음을 연다. 같은 계절 묶음이 있으면 이어 쓰고, 지금 묶음이 비어 있으면 그대로 쓴다 */
|
||||
const open = (kind: SectionKind, period: MonthPeriod | null): void => {
|
||||
const same = period ? findSeason(sections, period) : undefined;
|
||||
if (same) {
|
||||
current = same;
|
||||
} else if (isEmptySection(current)) {
|
||||
current.kind = kind;
|
||||
current.period = period;
|
||||
} else {
|
||||
current = newSection(kind, period);
|
||||
sections.push(current);
|
||||
}
|
||||
};
|
||||
/** 지금 묶음은 그대로 두고 그 기간의 계절 묶음을 찾거나 만든다 — "10:00~23:00 (4월~11월)" */
|
||||
const seasonOf = (period: MonthPeriod): Section => {
|
||||
const same = findSeason(sections, period);
|
||||
if (same) return same;
|
||||
const created = newSection('season', period);
|
||||
sections.push(created);
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
|
||||
let segment = raw.trim();
|
||||
if (!segment) continue;
|
||||
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
|
||||
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
|
||||
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
|
||||
// 계절·달 머리말("[하절기(4월~10월)]", "[1월]")이면 계절 묶음을 연다
|
||||
const period = parsePeriod(header[1].trim());
|
||||
// 달 머리말의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
|
||||
headerDays = period
|
||||
? null
|
||||
: parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
|
||||
// 요일이 아닌 머리말이면 시설 묶음을 연다
|
||||
if (period) open('season', period);
|
||||
else if (headerDays === null) open('facility', null);
|
||||
segment = segment.slice(header[0].length).trim();
|
||||
if (!segment) continue;
|
||||
}
|
||||
// 줄 앞의 계절·달 머리말 — "하절기(4월~10월) 10:00~19:00", "3월~10월 09:00~18:00"
|
||||
const lead = LEADING_PERIOD.exec(segment);
|
||||
const leadPeriod = lead
|
||||
? parsePeriod(lead[0].replace(/[\s::-]+$/, ''))
|
||||
: null;
|
||||
if (lead && leadPeriod) {
|
||||
const after = segment.slice(lead[0].length).trim();
|
||||
if (!after || hasTimeRange(after)) {
|
||||
open('season', leadPeriod);
|
||||
segment = after;
|
||||
if (!segment) continue;
|
||||
} else if (CLOSED_WORDS.test(after)) {
|
||||
// "12월~2월 휴장" — 그 기간에는 문을 열지 않는다
|
||||
result.closedPeriods.push(leadPeriod);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다).
|
||||
// 괄호에 기간만 적었으면 그 구간은 그 기간의 시간이다 — "10:00~23:00 (4월~11월)"(디아크문화관)
|
||||
const parens = [...segment.matchAll(/\(([^)]*)\)/g)].map((m) => ({
|
||||
text: m[1],
|
||||
period: periodOnly(m[1]),
|
||||
}));
|
||||
const qualifier = parens.find((p) => p.period !== null)?.period ?? null;
|
||||
const target = qualifier ? seasonOf(qualifier) : current;
|
||||
const inner = parens
|
||||
.filter((p) => p.period === null)
|
||||
.flatMap((p) => p.text.split(/\s*;\s*/));
|
||||
const body = segment.replace(/\([^)]*\)/g, ' ');
|
||||
for (const part of [body, ...inner]) {
|
||||
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
|
||||
result.breaks.push(...parseRanges(part));
|
||||
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
|
||||
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
|
||||
const weekdaysOnly =
|
||||
/평일|주중/.test(part) ||
|
||||
/^(평일|주중)/.test(segment) ||
|
||||
(part === body && /\(\s*평일\s*\)/.test(segment));
|
||||
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
|
||||
continue;
|
||||
}
|
||||
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
|
||||
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
|
||||
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
|
||||
const m = SINGLE_TIME.exec(part);
|
||||
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
|
||||
continue;
|
||||
}
|
||||
const closedInHours =
|
||||
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
|
||||
if (closedInHours) parseRestDays(part, result, false);
|
||||
const ranges = parseRanges(part);
|
||||
if (ranges.length === 0) {
|
||||
// "주말 미개방" — 그 묶음(계절)에서 그 요일은 쉰다
|
||||
const closedDays = closedInHours ? null : daysClosedBy(part);
|
||||
if (closedDays) {
|
||||
target.specific.push({ days: closedDays, ranges: [] });
|
||||
continue;
|
||||
}
|
||||
if (part !== body || !closedInHours) result.notes.push(part.trim());
|
||||
continue;
|
||||
}
|
||||
const prefix = part.slice(0, part.search(/\d/));
|
||||
const days = parseDays(prefix) ?? headerDays;
|
||||
if (days) target.specific.push({ days, ranges });
|
||||
else target.general.push(...ranges);
|
||||
}
|
||||
}
|
||||
result.notes = result.notes.filter((n) => n.length > 0);
|
||||
|
||||
if (sections.some((s) => s.kind === 'season')) {
|
||||
result.seasons = seasonHours(sections);
|
||||
result.week = emptyWeek();
|
||||
return;
|
||||
}
|
||||
result.week = mergeWeeks(sections.map(sectionWeek));
|
||||
// 계절을 말하는데 기간을 읽지 못했다("여름철에는 연장") — 시간표를 비워 시각 판단을 하지 않는다
|
||||
if (SEASON_HINT.test(text)) {
|
||||
result.seasonal = true;
|
||||
result.week = emptyWeek();
|
||||
}
|
||||
}
|
||||
|
||||
function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours {
|
||||
return {
|
||||
status,
|
||||
week: Array.from({ length: 7 }, () => null),
|
||||
week: emptyWeek(),
|
||||
breaks: [],
|
||||
breaksWeekdaysOnly: false,
|
||||
lastOrder: null,
|
||||
closedWeekdays: [],
|
||||
holidayClosures: [],
|
||||
seasonal: false,
|
||||
seasons: [],
|
||||
restSeasons: [],
|
||||
closedPeriods: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
@@ -417,8 +789,7 @@ export function parseOpeningHours(
|
||||
): ParsedOpeningHours {
|
||||
const text = normalize(hours ?? '');
|
||||
const rest = normalize(restDays ?? '');
|
||||
const hasTime = TIME_RANGE.test(text);
|
||||
TIME_RANGE.lastIndex = 0;
|
||||
const hasTime = hasTimeRange(text);
|
||||
|
||||
let result: ParsedOpeningHours;
|
||||
if (!text) {
|
||||
@@ -432,105 +803,201 @@ export function parseOpeningHours(
|
||||
);
|
||||
} else {
|
||||
result = emptyResult('parsed');
|
||||
// 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다
|
||||
result.seasonal =
|
||||
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text);
|
||||
// 시설·계절 머리말("[칠성시장]", "[하절기]")마다 묶음을 나눈다. 머리말이 없으면 묶음은 하나다
|
||||
const sections: Section[] = [emptySection()];
|
||||
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
|
||||
let headerDays: number[] | null = null;
|
||||
|
||||
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
|
||||
let segment = raw.trim();
|
||||
if (!segment) continue;
|
||||
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
|
||||
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
|
||||
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
|
||||
// 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
|
||||
headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
|
||||
// 요일이 아닌 머리말(시설·계절)이면 새 묶음을 연다
|
||||
if (headerDays === null) openSection(sections);
|
||||
segment = segment.slice(header[0].length).trim();
|
||||
if (!segment) continue;
|
||||
}
|
||||
const section = sections[sections.length - 1];
|
||||
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다)
|
||||
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) =>
|
||||
m[1].split(/\s*;\s*/),
|
||||
);
|
||||
const body = segment.replace(/\([^)]*\)/g, ' ');
|
||||
for (const part of [body, ...inner]) {
|
||||
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
|
||||
result.breaks.push(...parseRanges(part));
|
||||
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
|
||||
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
|
||||
const weekdaysOnly =
|
||||
/평일|주중/.test(part) ||
|
||||
/^(평일|주중)/.test(segment) ||
|
||||
(part === body && /\(\s*평일\s*\)/.test(segment));
|
||||
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
|
||||
continue;
|
||||
}
|
||||
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
|
||||
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
|
||||
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
|
||||
const m = SINGLE_TIME.exec(part);
|
||||
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
|
||||
continue;
|
||||
}
|
||||
const closedInHours =
|
||||
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
|
||||
if (closedInHours) parseRestDays(part, result, false);
|
||||
const ranges = parseRanges(part);
|
||||
if (ranges.length === 0) {
|
||||
if (part !== body || !closedInHours) result.notes.push(part.trim());
|
||||
continue;
|
||||
}
|
||||
const prefix = part.slice(0, part.search(/\d/));
|
||||
const days = parseDays(prefix) ?? headerDays;
|
||||
if (days) section.specific.push({ days, ranges });
|
||||
else section.general.push(...ranges);
|
||||
}
|
||||
}
|
||||
result.week = mergeSectionWeeks(sections);
|
||||
result.notes = result.notes.filter((n) => n.length > 0);
|
||||
parseHoursText(text, result);
|
||||
}
|
||||
|
||||
parseRestDays(rest, result, true);
|
||||
for (const d of result.closedWeekdays) result.week[d] = [];
|
||||
for (const d of result.closedWeekdays) {
|
||||
result.week[d] = [];
|
||||
for (const season of result.seasons) season.week[d] = [];
|
||||
}
|
||||
if (
|
||||
result.seasons.length > 0 ||
|
||||
result.restSeasons.length > 0 ||
|
||||
result.closedPeriods.length > 0
|
||||
) {
|
||||
result.seasonal = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 요일·시각에 영업 중인지 판단한다.
|
||||
* @param weekday 0=일요일 … 6=토요일
|
||||
* @param minutes 00:00 부터의 분
|
||||
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
|
||||
*/
|
||||
export function isOpenAt(
|
||||
/** 한 날짜에 쓰는 시간표 하나 */
|
||||
interface DaySchedule {
|
||||
week: WeekHours;
|
||||
/** 1년 내내 쉬는 요일 + 그 시기에 쉬는 요일 */
|
||||
closedWeekdays: number[];
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' 에서 offset 일 떨어진 날의 달·요일. 형식이 다르면 null */
|
||||
function calendarDay(
|
||||
date: string,
|
||||
offset = 0,
|
||||
): { month: number; weekday: number } | null {
|
||||
const m = ISO_DATE.exec(date);
|
||||
if (!m) return null;
|
||||
const day = new Date(
|
||||
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + offset),
|
||||
);
|
||||
return { month: day.getUTCMonth() + 1, weekday: day.getUTCDay() };
|
||||
}
|
||||
|
||||
/** 그 달에 해당할 수 있는 기간들. 그 달이 확실한 기간이 있으면 그것만 */
|
||||
function forMonth<T extends MonthPeriod>(periods: T[], month: number): T[] {
|
||||
const possible = periods.filter((p) => p.months.includes(month));
|
||||
const sure = possible.filter((p) => p.sureMonths.includes(month));
|
||||
return sure.length > 0 ? sure : possible;
|
||||
}
|
||||
|
||||
/** 그 달에 쓸 수 있는 시간표들 — 어느 계절인지 모르면 여럿, 그 달의 시간표가 없으면 빈 목록 */
|
||||
function schedulesFor(
|
||||
parsed: ParsedOpeningHours,
|
||||
month: number,
|
||||
): DaySchedule[] {
|
||||
const seasons = parsed.seasons ?? [];
|
||||
const weeks =
|
||||
seasons.length > 0
|
||||
? forMonth(seasons, month).map((s) => s.week)
|
||||
: [parsed.week];
|
||||
const restSeasons = forMonth(parsed.restSeasons ?? [], month);
|
||||
const extraClosed =
|
||||
restSeasons.length > 0 ? restSeasons.map((r) => r.closedWeekdays) : [[]];
|
||||
return weeks.flatMap((week) =>
|
||||
extraClosed.map((extra) => ({
|
||||
week,
|
||||
closedWeekdays: [...parsed.closedWeekdays, ...extra],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 해당할 수 있는 시간표들의 답이 모두 같을 때만 그 답. 다르거나 시간표가 없으면 null */
|
||||
function agree(answers: (boolean | null)[]): boolean | null {
|
||||
if (answers.length === 0) return null;
|
||||
return answers.every((a) => a === answers[0]) ? answers[0] : null;
|
||||
}
|
||||
|
||||
/** 문을 열지 않는 기간이면 닫힘, 그 기간일 수 있으면 열렸다고 단정하지 않는다 */
|
||||
function applyClosedPeriods(
|
||||
parsed: ParsedOpeningHours,
|
||||
month: number,
|
||||
open: boolean | null,
|
||||
): boolean | null {
|
||||
const periods = (parsed.closedPeriods ?? []).filter((p) =>
|
||||
p.months.includes(month),
|
||||
);
|
||||
if (periods.some((p) => p.sureMonths.includes(month))) return false;
|
||||
if (periods.length > 0 && open === true) return null;
|
||||
return open;
|
||||
}
|
||||
|
||||
/** 자정을 넘기는 영업 구간이 있는가 — 없으면 전날 영업이 오늘로 이어질 일이 없다 */
|
||||
function hasOvernight(parsed: ParsedOpeningHours): boolean {
|
||||
const weeks = [parsed.week, ...(parsed.seasons ?? []).map((s) => s.week)];
|
||||
return weeks.some((week) =>
|
||||
week.some((day) => (day ?? []).some((r) => r.end > MINUTES_PER_DAY)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 전날(weekday) 밤에 시작한 영업이 오늘 minutes 까지 이어지는가 */
|
||||
function carriesOver(
|
||||
schedule: DaySchedule,
|
||||
weekday: number,
|
||||
minutes: number,
|
||||
): boolean {
|
||||
if (schedule.closedWeekdays.includes(weekday)) return false;
|
||||
return (schedule.week[weekday] ?? []).some(
|
||||
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
|
||||
);
|
||||
}
|
||||
|
||||
/** 시간표 하나로 본 그날(weekday) minutes 의 영업 여부 */
|
||||
function openOn(
|
||||
schedule: DaySchedule,
|
||||
parsed: ParsedOpeningHours,
|
||||
weekday: number,
|
||||
minutes: number,
|
||||
): boolean | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
if (parsed.seasonal) return null;
|
||||
|
||||
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
|
||||
const yesterday = parsed.week[(weekday + 6) % 7];
|
||||
const fromYesterday = (yesterday ?? []).some(
|
||||
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
|
||||
);
|
||||
if (fromYesterday) return true;
|
||||
if (parsed.closedWeekdays.includes(weekday)) return false;
|
||||
|
||||
if (schedule.closedWeekdays.includes(weekday)) return false;
|
||||
const inBreak = parsed.breaks.some(
|
||||
(b) => minutes >= b.start && minutes < b.end,
|
||||
);
|
||||
const breakApplies =
|
||||
!parsed.breaksWeekdaysOnly || (weekday >= 1 && weekday <= 5);
|
||||
if (inBreak && breakApplies) return false;
|
||||
|
||||
const today = parsed.week[weekday];
|
||||
const today = schedule.week[weekday];
|
||||
if (today === null) return null;
|
||||
return today.some((r) => minutes >= r.start && minutes < r.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 날짜·시각에 영업 중인지 판단한다. 계절별 시간표면 날짜의 달로 고르고,
|
||||
* 어느 계절인지 달로 정할 수 없으면(달을 적지 않은 하절기·동절기의 3·10·11월) 해당할 수 있는 시간표가 모두 같은 답일 때만 답한다.
|
||||
* @param date 한국 날짜 'YYYY-MM-DD'
|
||||
* @param minutes 00:00 부터의 분
|
||||
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
|
||||
*/
|
||||
export function isOpenAt(
|
||||
parsed: ParsedOpeningHours,
|
||||
date: string,
|
||||
minutes: number,
|
||||
): boolean | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
// 계절별 시간표를 두기 전(2026-11-06)에 저장한 해석 결과 — reparse-hours 로 다시 해석하기 전까지는 판단하지 않는다
|
||||
if (parsed.seasonal && !parsed.seasons) return null;
|
||||
const today = calendarDay(date);
|
||||
const yesterday = calendarDay(date, -1);
|
||||
if (!today || !yesterday) return null;
|
||||
|
||||
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
|
||||
const carried = hasOvernight(parsed)
|
||||
? applyClosedPeriods(
|
||||
parsed,
|
||||
yesterday.month,
|
||||
agree(
|
||||
schedulesFor(parsed, yesterday.month).map((s) =>
|
||||
carriesOver(s, yesterday.weekday, minutes),
|
||||
),
|
||||
),
|
||||
)
|
||||
: false;
|
||||
if (carried === true) return true;
|
||||
const open = applyClosedPeriods(
|
||||
parsed,
|
||||
today.month,
|
||||
agree(
|
||||
schedulesFor(parsed, today.month).map((s) =>
|
||||
openOn(s, parsed, today.weekday, minutes),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (open === true) return true;
|
||||
return carried === null || open === null ? null : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 날짜의 영업 구간(준비시간은 빼지 않은 원래 구간) — AI 코스 결과의 '그날 영업시간'과 프롬프트에 쓴다.
|
||||
* 쉬는 날이면 빈 배열, 정할 수 없으면 null(상태가 미확인·점포별 상이, 그 요일 시간이 없음,
|
||||
* 계절을 달로 정할 수 없는데 시간표마다 다름, 문을 열지 않는 기간일 수 있음). 상시 개방은 0~24시 하나다.
|
||||
*/
|
||||
export function hoursOn(
|
||||
parsed: ParsedOpeningHours,
|
||||
date: string,
|
||||
): TimeRange[] | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
if (parsed.seasonal && !parsed.seasons) return null;
|
||||
const day = calendarDay(date);
|
||||
if (!day) return null;
|
||||
const periods = (parsed.closedPeriods ?? []).filter((p) =>
|
||||
p.months.includes(day.month),
|
||||
);
|
||||
if (periods.some((p) => p.sureMonths.includes(day.month))) return [];
|
||||
const answers = schedulesFor(parsed, day.month).map((s) =>
|
||||
s.closedWeekdays.includes(day.weekday) ? [] : s.week[day.weekday],
|
||||
);
|
||||
if (answers.length === 0) return null;
|
||||
const first = JSON.stringify(answers[0]);
|
||||
if (answers.some((a) => JSON.stringify(a) !== first)) return null;
|
||||
const ranges = answers[0];
|
||||
if (ranges === null) return null;
|
||||
if (periods.length > 0 && ranges.length > 0) return null;
|
||||
return ranges.map((r) => ({ start: r.start, end: r.end }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getStorageToken } from '@nestjs/throttler';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import {
|
||||
AiUnavailableError,
|
||||
COURSE_AI_CLIENT,
|
||||
type CourseAiClient,
|
||||
type CourseDraftInput,
|
||||
} from '@/modules/course-generation/ai/course-ai.client';
|
||||
import { FakeCourseAiClient } from '@/modules/course-generation/ai/fake-course-ai.client';
|
||||
import { hashDeviceId } from '@/modules/course-generation/device-id.decorator';
|
||||
import { Place } from '@/modules/place/entities/place.entity';
|
||||
import { WeatherService } from '@/modules/weather/weather.service';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
import { parseOpeningHours } from '@/shared/utils/opening-hours';
|
||||
|
||||
/**
|
||||
* AI 코스 생성 API 계약 — 횟수·접수·진행 조회·결과·취소·평가·실패 코드를 실제 HTTP·DB 로 검증한다.
|
||||
* 시험 장소는 대구 영역 사각형(geo.ts) 남서쪽 끝(실제로는 대구 밖이라 장소 데이터가 없는 곳)에 만들고(source_id 'e2e-cg-'),
|
||||
* 끝나면 지운다. AI 는 가짜(조절 가능), 날씨는 null 로 바꿔 키 없이 돈다.
|
||||
*/
|
||||
const C = { lat: 35.56, lng: 128.31 };
|
||||
/** 후보가 없는 곳(5km 떨어짐) */
|
||||
const EMPTY = { lat: 35.6, lng: 128.36 };
|
||||
const DEVICE_HEADER = 'X-Device-Id';
|
||||
const TERMINAL = ['succeeded', 'failed', 'canceled'];
|
||||
|
||||
type Mode = 'normal' | 'block' | 'unavailable';
|
||||
|
||||
/** 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 한다 */
|
||||
class ControllableAi implements CourseAiClient {
|
||||
mode: Mode = 'normal';
|
||||
private readonly fake = new FakeCourseAiClient();
|
||||
|
||||
draftCourse(input: CourseDraftInput) {
|
||||
if (this.mode === 'unavailable') {
|
||||
return Promise.reject(new AiUnavailableError('시험: AI 장애'));
|
||||
}
|
||||
if (this.mode === 'block') {
|
||||
return new Promise<never>((_, reject) => {
|
||||
input.signal.addEventListener('abort', () =>
|
||||
reject(new Error('시험: 끊김')),
|
||||
);
|
||||
});
|
||||
}
|
||||
return this.fake.draftCourse(input);
|
||||
}
|
||||
|
||||
parseConditions(input: Parameters<CourseAiClient['parseConditions']>[0]) {
|
||||
return this.fake.parseConditions(input);
|
||||
}
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: { code: string };
|
||||
}
|
||||
interface GenerationBody {
|
||||
id: string;
|
||||
status: string;
|
||||
stage: string | null;
|
||||
progress: { found: number | null; kept: number | null };
|
||||
remaining: number;
|
||||
rating: string | null;
|
||||
failure: { code: string; notices: string[] } | null;
|
||||
result: {
|
||||
outcome: string;
|
||||
window: { date: string; start: string; end: string };
|
||||
totals: { stops: number };
|
||||
stops: {
|
||||
id: string;
|
||||
arrive: string;
|
||||
leave: string;
|
||||
minutes: number;
|
||||
legToNext: { minutes: number; mode: string } | null;
|
||||
}[];
|
||||
alternates: { forIndex: number }[];
|
||||
} | null;
|
||||
}
|
||||
const body = <T>(res: Response) => res.body as Envelope<T>;
|
||||
|
||||
describe('AI 코스 생성 API (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let ds: DataSource;
|
||||
const ai = new ControllableAi();
|
||||
const devices: string[] = [];
|
||||
const placeIds: string[] = [];
|
||||
const date = kstDateString(new Date(), 1);
|
||||
|
||||
const device = () => {
|
||||
const id = randomUUID();
|
||||
devices.push(id);
|
||||
return id;
|
||||
};
|
||||
const conditions = (overrides: Record<string, unknown> = {}) => ({
|
||||
area: { type: 'point', ...C },
|
||||
date,
|
||||
start: '13:00',
|
||||
end: '19:00',
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
...overrides,
|
||||
});
|
||||
const create = (deviceId: string, overrides: Record<string, unknown> = {}) =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, deviceId)
|
||||
.send({ request: '', conditions: conditions(overrides) });
|
||||
const get = (deviceId: string, id: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/course-generations/${id}`)
|
||||
.set(DEVICE_HEADER, deviceId);
|
||||
|
||||
/** 끝날 때까지(또는 원하는 상태까지) 조회한다 */
|
||||
async function waitFor(
|
||||
deviceId: string,
|
||||
id: string,
|
||||
until: (g: GenerationBody) => boolean = (g) => TERMINAL.includes(g.status),
|
||||
): Promise<GenerationBody> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res = await get(deviceId, id).expect(200);
|
||||
const generation = body<GenerationBody>(res).data;
|
||||
if (until(generation)) return generation;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`생성 ${id} 이 끝나지 않았다`);
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (devices.length > 0) {
|
||||
await ds.query(
|
||||
`DELETE FROM ai_course_generation WHERE device_hash = ANY($1)`,
|
||||
[devices.map(hashDeviceId)],
|
||||
);
|
||||
}
|
||||
await ds.query(`DELETE FROM place WHERE source_id LIKE 'e2e-cg-%'`);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(COURSE_AI_CLIENT)
|
||||
.useValue(ai)
|
||||
.overrideProvider(WeatherService)
|
||||
.useValue({ forecastWindow: () => Promise.resolve(null) })
|
||||
// 시험 요청은 모두 같은 주소에서 온다 — 분당 요청 제한(생성 5회)을 세지 않는다
|
||||
.overrideProvider(getStorageToken())
|
||||
.useValue({
|
||||
increment: () =>
|
||||
Promise.resolve({
|
||||
totalHits: 1,
|
||||
timeToExpire: 60,
|
||||
isBlocked: false,
|
||||
timeToBlockExpire: 0,
|
||||
}),
|
||||
})
|
||||
.compile();
|
||||
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 실행기의 기동 정리가 테이블을 찾는다
|
||||
ds = moduleFixture.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
await app.init();
|
||||
await cleanup();
|
||||
|
||||
const places = ds.getRepository(Place);
|
||||
const rows: [string, Place['category'], string, number][] = [
|
||||
['1', 'attraction', '상시 개방', 0],
|
||||
['2', 'cafe', '10:00~22:00', 0.002],
|
||||
['3', 'culture', '09:00~22:00', 0.004],
|
||||
['4', 'food', '11:00~22:00', 0.006],
|
||||
['5', 'nature', '상시 개방', -0.002],
|
||||
['6', 'cafe', '10:00~22:00', -0.004],
|
||||
];
|
||||
for (const [key, category, hours, dLat] of rows) {
|
||||
const saved = await places.save(
|
||||
places.create({
|
||||
source: 'tour',
|
||||
sourceId: `e2e-cg-${key}`,
|
||||
category,
|
||||
name: `이투이 코스 ${key}`,
|
||||
lat: C.lat + dLat,
|
||||
lng: C.lng,
|
||||
publicHoursText: hours,
|
||||
publicHours: parseOpeningHours(hours, '연중무휴'),
|
||||
}),
|
||||
);
|
||||
placeIds.push(saved.id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다
|
||||
try {
|
||||
await cleanup();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ai.mode = 'normal';
|
||||
});
|
||||
|
||||
it('X-Device-Id 가 없거나 UUID 가 아니면 VAL_001', async () => {
|
||||
const missing = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.expect(400);
|
||||
expect(body(missing).error?.code).toBe('VAL_001');
|
||||
const wrong = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.set(DEVICE_HEADER, 'not-a-uuid')
|
||||
.expect(400);
|
||||
expect(body(wrong).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('오늘 남은 횟수 — 비로그인 하루 2번', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.expect(200);
|
||||
expect(body(res).data).toMatchObject({
|
||||
limit: 2,
|
||||
used: 0,
|
||||
remaining: 2,
|
||||
tier: 'guest',
|
||||
});
|
||||
});
|
||||
|
||||
it('조건 해석 — 비어 있는 항목은 기본값으로 채우고, 대구 밖 기준 위치는 VAL_001', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/course-conditions')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.send({
|
||||
text: '토요일에 걷기 좋은 곳',
|
||||
baseArea: { type: 'point', ...C },
|
||||
})
|
||||
.expect(200);
|
||||
expect(body(res).data).toMatchObject({
|
||||
conditions: {
|
||||
area: { type: 'point', ...C },
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
},
|
||||
inferred: [],
|
||||
issues: [],
|
||||
weather: null,
|
||||
});
|
||||
const outside = await request(app.getHttpServer())
|
||||
.post('/api/course-conditions')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.send({
|
||||
text: '',
|
||||
baseArea: { type: 'point', lat: 37.5665, lng: 126.978 },
|
||||
})
|
||||
.expect(400);
|
||||
expect(body(outside).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('생성을 접수하고 진행을 조회하면 결과가 나오고, 그때 횟수를 차감한다', async () => {
|
||||
const me = device();
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({
|
||||
request: '연락은 010-1234-5678 로, 산책하고 카페',
|
||||
conditions: conditions(),
|
||||
})
|
||||
.expect(201);
|
||||
const { id, status, remaining } = body<{
|
||||
id: string;
|
||||
status: string;
|
||||
remaining: number;
|
||||
}>(created).data;
|
||||
expect(status).toBe('queued');
|
||||
expect(remaining).toBe(2);
|
||||
|
||||
const done = await waitFor(me, id);
|
||||
expect(done.status).toBe('succeeded');
|
||||
expect(done.remaining).toBe(1);
|
||||
expect(done.progress.found).toBe(6);
|
||||
const result = done.result!;
|
||||
expect(result.window).toEqual({ date, start: '13:00', end: '19:00' });
|
||||
expect(result.stops.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.totals.stops).toBe(result.stops.length);
|
||||
for (const [i, stop] of result.stops.entries()) {
|
||||
expect(placeIds).toContain(stop.id);
|
||||
expect(Date.parse(stop.leave) - Date.parse(stop.arrive)).toBe(
|
||||
stop.minutes * 60_000,
|
||||
);
|
||||
const next = result.stops[i + 1];
|
||||
if (next) {
|
||||
expect(Date.parse(next.arrive)).toBe(
|
||||
Date.parse(stop.leave) + stop.legToNext!.minutes * 60_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 문장의 전화번호는 가려서, 현재 위치는 약 100m 단위로 저장한다
|
||||
const [row] = await ds.query<
|
||||
{
|
||||
request_text: string;
|
||||
counted: boolean;
|
||||
conditions: { area: unknown };
|
||||
}[]
|
||||
>(
|
||||
`SELECT request_text, counted, conditions FROM ai_course_generation WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
expect(row.request_text).toBe('연락은 [전화번호] 로, 산책하고 카페');
|
||||
expect(row.counted).toBe(true);
|
||||
expect(row.conditions.area).toEqual({
|
||||
type: 'point',
|
||||
lat: 35.56,
|
||||
lng: 128.31,
|
||||
});
|
||||
|
||||
// 다른 기기는 볼 수 없다
|
||||
const other = await get(device(), id).expect(404);
|
||||
expect(body(other).error?.code).toBe('RES_001');
|
||||
|
||||
// 평가 — 결과가 나온 생성만
|
||||
const rated = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'like' })
|
||||
.expect(200);
|
||||
expect(body<GenerationBody>(rated).data.rating).toBe('like');
|
||||
const wrongValue = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'love' })
|
||||
.expect(400);
|
||||
expect(body(wrongValue).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('만드는 중이면 AI_004, 취소하면 차감하지 않는다', async () => {
|
||||
const me = device();
|
||||
ai.mode = 'block';
|
||||
const created = await create(me).expect(201);
|
||||
const { id } = body<{ id: string }>(created).data;
|
||||
await waitFor(me, id, (g) => g.stage === 'planning');
|
||||
|
||||
const second = await create(me).expect(409);
|
||||
expect(body(second).error?.code).toBe('AI_004');
|
||||
|
||||
const canceled = await request(app.getHttpServer())
|
||||
.post(`/api/course-generations/${id}/cancellation`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.expect(200);
|
||||
expect(body<GenerationBody>(canceled).data).toMatchObject({
|
||||
status: 'canceled',
|
||||
remaining: 2,
|
||||
});
|
||||
// 취소한 생성은 평가할 수 없다
|
||||
const rate = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'dislike' })
|
||||
.expect(400);
|
||||
expect(body(rate).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('하루 2번을 다 쓰면 AI_001', async () => {
|
||||
const me = device();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const { id } = body<{ id: string }>(await create(me).expect(201)).data;
|
||||
expect((await waitFor(me, id)).status).toBe('succeeded');
|
||||
}
|
||||
const third = await create(me).expect(429);
|
||||
expect(body(third).error?.code).toBe('AI_001');
|
||||
});
|
||||
|
||||
it('조건에 맞는 후보가 없으면 failed + AI_003 이고 차감하지 않는다', async () => {
|
||||
const me = device();
|
||||
const created = await create(me, {
|
||||
area: { type: 'point', ...EMPTY },
|
||||
}).expect(201);
|
||||
const done = await waitFor(me, body<{ id: string }>(created).data.id);
|
||||
expect(done).toMatchObject({
|
||||
status: 'failed',
|
||||
failure: { code: 'AI_003', notices: [] },
|
||||
result: null,
|
||||
remaining: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('AI 를 쓸 수 없으면 failed + AI_002', async () => {
|
||||
const me = device();
|
||||
ai.mode = 'unavailable';
|
||||
const created = await create(me).expect(201);
|
||||
const done = await waitFor(me, body<{ id: string }>(created).data.id);
|
||||
expect(done).toMatchObject({
|
||||
status: 'failed',
|
||||
failure: { code: 'AI_002' },
|
||||
});
|
||||
});
|
||||
|
||||
it('지난 날짜·짧은 시간·남의 이전 생성은 VAL_001', async () => {
|
||||
const me = device();
|
||||
const past = await create(me, {
|
||||
date: kstDateString(new Date(), -1),
|
||||
}).expect(400);
|
||||
expect(body(past).error?.code).toBe('VAL_001');
|
||||
const short = await create(me, { start: '13:00', end: '14:00' }).expect(
|
||||
400,
|
||||
);
|
||||
expect(body(short).error?.code).toBe('VAL_001');
|
||||
const previous = await request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({
|
||||
request: '',
|
||||
conditions: conditions(),
|
||||
previousGenerationId: randomUUID(),
|
||||
})
|
||||
.expect(400);
|
||||
expect(body(previous).error?.code).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,9 @@ describe('축제·행사 조회 API (e2e)', () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다
|
||||
ds = moduleFixture.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
@@ -79,9 +82,6 @@ describe('축제·행사 조회 API (e2e)', () => {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
await app.init();
|
||||
|
||||
ds = app.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
await cleanup();
|
||||
|
||||
const repo = ds.getRepository(FestivalEvent);
|
||||
@@ -156,8 +156,12 @@ describe('축제·행사 조회 API (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await app.close();
|
||||
// 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다
|
||||
try {
|
||||
await cleanup();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
const get = (path: string) => request(app.getHttpServer()).get(path);
|
||||
|
||||
@@ -83,6 +83,9 @@ describe('장소 조회 API (e2e)', () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다
|
||||
ds = moduleFixture.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
@@ -96,9 +99,6 @@ describe('장소 조회 API (e2e)', () => {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
await app.init();
|
||||
|
||||
ds = app.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
await cleanup();
|
||||
|
||||
const places = ds.getRepository(Place);
|
||||
@@ -245,8 +245,12 @@ describe('장소 조회 API (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await app.close();
|
||||
// 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다
|
||||
try {
|
||||
await cleanup();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
const get = (path: string) => request(app.getHttpServer()).get(path);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.fixtures.ts"]
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,9 +1,9 @@
|
||||
# AI 코스 생성 평가셋 v2 (대구, 초안)
|
||||
# AI 코스 생성 평가셋 v2 (대구)
|
||||
|
||||
WanderGo AI 코스 생성이 **조건을 지키는지**, **없는 정보를 지어내지 않는지** 확인하는 평가용 예시입니다.
|
||||
모델·프롬프트를 바꿀 때마다 같은 예시로 돌려 점수를 비교합니다. 흐름과 규칙은 [AI 코스 생성 설계](../design/ai-course.md) §2–§3·§9 를 따릅니다.
|
||||
|
||||
- `course-eval-v2.jsonl` — 대구판 62개. 2026-11-05 초안이며 검토 후 확정합니다.
|
||||
- `course-eval-v2.jsonl` — 대구판 62개. 2026-11-05 사용자 검토를 거쳐 확정했습니다(아래 '검토에서 정한 것').
|
||||
- `course-eval-v1.jsonl` — 2026-10-29 초안(서울·부산·제주 등). 장소 DB 가 대구만 있어 지금은 쓰지 않고, 지역을 넓힐 때 다시 씁니다.
|
||||
|
||||
## 구성 (62개)
|
||||
@@ -87,13 +87,22 @@ WanderGo AI 코스 생성이 **조건을 지키는지**, **없는 정보를 지
|
||||
그래서 반려동물 사례 대부분이 `partial_with_notice`·`no_course` 다. 큐레이션으로 동반 카페·식당을 확인하면 기대 결과를 다시 본다.
|
||||
- **전통시장은 AI 후보가 아니다.** 쇼핑 분류라 큐레이션에서 추천(O)·확인해야 후보가 된다(장소 DB §3). 서문시장 야시장 사례(F05·G02)는 `requires` 로 표시했다.
|
||||
- **11월 대구에 축제가 없다.** 행사는 수성국제비엔날레(수성못, 10-27~11-22)와 전시뿐이다. 축제를 찾는 F01 은 '없다고 말하기'를 본다.
|
||||
- **계절별 시간표는 미확인이다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 지금 영업시간 미확인이라 문화시설이면 후보에서 빠진다. 구현 2단계에서 날짜로 고르게 고친다(설계 §3.3).
|
||||
- **계절별 시간표는 미확인이었다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 영업시간 미확인이라 문화시설이면 후보에서 빠졌다.
|
||||
2026-11-06 날짜의 달로 고르게 고쳤다(설계 §3.3). 달을 적지 않은 곳(수창청춘맨숀 등 7곳)은 11월에 두 계절 시간이 같은 시각만 판단한다.
|
||||
- 생성 사례는 모두 거른 뒤 후보가 8곳 이상 남는다(설계 §3.2–3.3 규칙으로 근사 계산).
|
||||
|
||||
정답 코스는 적지 않았습니다. 평가는 장소 DB 스냅샷 위에서 돌리므로, 정답은 "조건을 지켰는가"로 판단합니다.
|
||||
|
||||
## 검토할 때 봐 주세요
|
||||
## 검토에서 정한 것 (2026-11-05)
|
||||
|
||||
- **요청의 핵심을 맞출 후보가 없으면 만들지 않는다.** "실내에서만"(P07), "실내 전시만"(X01)인데 그런 곳이 없으면
|
||||
야외 장소로 채우지 않고 이유를 알린다(`no_course`). 요청과 다른 코스를 대신 내놓지 않는다.
|
||||
- **일부만 못 맞추면 맞출 수 있는 만큼 만들고 알린다.** "산책하고 카페도"(P02), "근대골목 걷다가 카페"(P10)는
|
||||
산책 코스를 만들고 동반 카페가 없다고 알린다(`partial_with_notice`).
|
||||
- **'돈 안 쓰는 코스'(X09)는 식당·카페를 뺀다.** 입장료가 무료로 확인되지 않은 곳을 무료라고 하지 않는다.
|
||||
|
||||
## 다음 버전에서 볼 것
|
||||
|
||||
- 대구에 사는 사람이 실제로 할 법한 요청인지, 빠진 상황이 있는지
|
||||
- `checks` 기준값(이동 시간, 장소 수, 끼니)이 너무 빡빡하거나 느슨하지 않은지
|
||||
- `expected_behavior` 가 의도한 서비스 정책과 맞는지 (특히 반려동물 P01–P10 과 경계 X01–X10)
|
||||
- 큐레이션으로 반려 동반 실내 장소나 전통시장이 후보가 되면 반려동물 사례와 F05·G02 의 기대 결과를 다시 본다
|
||||
|
||||
+60
-21
@@ -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-08 기준 4단계 API 골격까지, AI 는 가짜). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다.
|
||||
§13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다.
|
||||
|
||||
> v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다.
|
||||
@@ -113,6 +113,8 @@
|
||||
서버를 늘리면 Redis 큐(BullMQ)로 옮긴다(nestjs 스킬의 Redis 확장 지점).
|
||||
- 종료할 때 진행 중인 작업은 AI 호출을 끊고 `failed`(차감 없음)로 남긴다(`OnApplicationShutdown`).
|
||||
기동할 때는 2분 넘게 `running` 인 작업을 `failed` 로 바꾼다.
|
||||
- 구현(2026-11-08, `course-generation.runner.ts`): 서버가 한 대라 기동할 때 남은 `queued`·`running` 은 모두 지난 프로세스의 것으로 보고 `failed`(AI_002, 차감 없음)로 바꾼다.
|
||||
1분마다 2분 넘게 `running` 인 작업도 정리한다. 종료할 때는 DB 연결이 닫히기 전(`beforeApplicationShutdown`)에 남은 작업을 `failed` 로 적는다.
|
||||
|
||||
### 3.2 후보 찾기
|
||||
|
||||
@@ -125,18 +127,26 @@
|
||||
|
||||
| 조건 | 규칙 |
|
||||
| --- | --- |
|
||||
| 영업 | 그날 요일 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt` 으로 10분 간격 확인(브레이크 포함). 휴무 요일이면 뺀다 |
|
||||
| 영업시간 미확인 | `status` 가 unknown · varies 이거나 `seasonal` 이면 미확인이다. **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 하절기·동절기처럼 계절별 시간표는 지금 미확인으로 읽힌다(AI 후보 23곳, 문화시설 10곳 — 수창청춘맨숀·대구예술발전소·향촌문화관 등). 달 범위가 적혀 있으면 생성 날짜로 고르도록 2단계에서 고친다 |
|
||||
| 공휴일 | 그날이 공휴일·명절이고 `holidayClosures` 가 있으면 뺀다. 공휴일 표는 코드 상수로 두고 해마다 갱신한다 |
|
||||
| 영업 | 그날 날짜 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt(hours, 날짜, 분)` 으로 10분 간격 확인(브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간 포함) |
|
||||
| 영업시간 미확인 | 머무는 시간 중 `isOpenAt` 이 null 인 때가 있으면 미확인이다(`status` 가 unknown · varies 이거나, 그 요일 시간이 없거나, 계절을 정할 수 없는 달에 두 계절 시간이 다를 때). **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 계절별 시간표는 2026-11-06 부터 날짜의 달로 고른다(대구 30곳). 달을 적지 않은 하절기·동절기(7곳)는 3·10·11월에 두 계절 시간이 같은 시각만 판단한다 |
|
||||
| 공휴일 | 그날이 공휴일·명절이고 그 공휴일의 휴무 표시(`holidayClosures`)가 있으면 뺀다. '공휴일' 휴무는 모든 공휴일에, 설·추석 휴무는 그 연휴 내내(대체공휴일 포함) 쉬는 것으로 본다(당일만 쉬는지 알 수 없어서). 공휴일 표는 코드 상수(`shared/utils/kr-holidays.ts`, 2026–2027)로 두고 해마다 갱신한다. 2026년부터 노동절·제헌절도 공휴일이고 대체공휴일이 붙는다 |
|
||||
| 반려동물 (조건에 있을 때) | `not_allowed` 와 크기 불일치(`petSizes` 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 `allowed` · `partial` 만 남긴다. 야외 분류와 축제·행사는 `unknown` 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 |
|
||||
| 행사 시간 | `playtime_text` 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 |
|
||||
| 다시 만들기 | 이전 결과의 장소는 남은 후보가 15곳 이상이면 빼고, 아니면 목록 뒤로 보낸다 |
|
||||
| 비 | 거르지 않는다. 강수확률이 40% 이상이고 `indoorIfRain` 이면 AI 에 '실내 위주'를 알리고, 검증에서 실내 비율 50% 이상을 본다. 실내 후보가 모자라 채울 수 없으면 코스는 내고 `notices` 에 알린다 |
|
||||
|
||||
- **실내·실외**: 큐레이션 `setting` 을 쓰고, 없으면 분류로 추정한다(문화시설·식당·카페·쇼핑과 전시는 실내, 자연은 실외, 관광지·레저·축제·행사는 미확인).
|
||||
실내·외가 함께 있는 곳(`both`)은 실내로 본다. 비를 피할 수 있고, 반려동물은 실내 규칙을 따라야 해서다.
|
||||
- **날씨**: 기상청 단기예보(동네예보 격자). 오늘부터 3일 뒤까지 나오고, 17시 이후 발표분은 4일 뒤까지 나온다.
|
||||
그 뒤 날짜는 `weather: null` 이다. 입력 화면에 "예보 없음"을 보여 주고 비 조건은 쓰지 않는다.
|
||||
격자·발표 시각별로 1시간 캐시한다. 2026-10-29 점검 스크립트로 이미 불러 본 API 다(`docs/research/data-check/`).
|
||||
- 구현(`backend/src/modules/weather/`, 2026-11-08): 위경도를 5km 격자로 바꾸고, 발표 10분 뒤부터 그 발표를 쓴다. 최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다.
|
||||
- 강수확률은 시간 범위 중 가장 높은 값이고, 기온은 범위 안 최저·최고, 하늘은 가장 잦은 상태다. 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다.
|
||||
예보가 시간 범위 끝까지 닿지 않으면 null 이다.
|
||||
- 입력 화면을 막지 않게 제한 시간 3초·2번 시도이고, 실패하면 던지지 않고 null 이다. 실패는 2분 동안 다시 부르지 않는다.
|
||||
하루 호출은 1,000번까지 센다(캐시 덕에 실제로는 하루 수십 번). 공공데이터포털 키(`PUBLIC_DATA_SERVICE_KEY`)를 장소 동기화와 함께 쓴다.
|
||||
- 실제 호출 확인: `docker exec wandergo-backend npm run weather:check -- [--lat=… --lng=…] [--start=10:00 --end=20:00]`(키가 필요해 사용자가 실행한다).
|
||||
2026-11-08 15:38 확인: 대구 도심 격자 89,90, 14시 발표로 11-08~11-11 예보(11일은 3시간 간격), 11-12 는 예보 없음, 기상청 호출 1번
|
||||
|
||||
### 3.4 후보 줄이기 (최대 40곳)
|
||||
|
||||
@@ -144,7 +154,8 @@ AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다.
|
||||
|
||||
- 1순위(확인한 곳)를 먼저, 그다음 거리순으로 둔다.
|
||||
- 분류마다 최대 10곳이다. 한 분류(예: 관광지)가 목록을 다 채우지 않게 한다.
|
||||
- 행사는 가까운 순서로 최대 8개다.
|
||||
- 행사는 가까운 순서로 최대 8개다. 장소와 행사를 합쳐 40곳 이하라, 장소는 40곳에서 행사 수를 뺀 만큼 넣는다.
|
||||
- 다시 만들기 규칙(§3.3, 이전 장소를 빼거나 뒤로 보내기)도 여기서 적용한다.
|
||||
- AI 에는 장소 id(uuid) 대신 짧은 번호(P01~P40, E01~E08)를 준다. 번호와 id 의 대응은 서버만 가진다.
|
||||
|
||||
### 3.5 AI 호출
|
||||
@@ -205,9 +216,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 +233,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 결과
|
||||
|
||||
@@ -239,10 +264,11 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| --- | --- |
|
||||
| `outcome` | `course` · `partial` |
|
||||
| `title`, `summary`, `notices` | AI |
|
||||
| `weather` | 기상청 — 날짜 · 강수확률 · 기온 · 발표 시각 |
|
||||
| `planNotices` | 코드 — 장소를 뺐음(`stop_removed`, 이름·이유) · 실내 후보가 모자람(`indoor_ratio_unmet`). 문구는 앱이 언어별로 만든다 |
|
||||
| `weather` | 기상청 — 날짜 · 강수확률(시간 범위 최고) · 강수 형태 · 하늘 · 기온(최저·최고) · 발표 시각(UTC) |
|
||||
| `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), `id`(장소·행사 id), 이름, 분류, 사진(라이선스 포함), `arrive` · `leave`(ISO 8601 UTC) · `minutes`, `reason`(AI), `facts`(그날 영업 구간 `hoursOnDate`·원문·출처·확인일, 반려 조건과 출처, 실내외, 행사 기간), `warnings`(hours_unknown · pet_unknown · event_time_unknown · event_last_day — 그날이 행사 마지막 날), `legToNext`(분 · 거리 · 이동 수단 · 추정 여부) |
|
||||
| `alternates[]` | 바꿀 장소의 자리(`forIndex`, stops 의 0부터)와 stop 과 같은 필드, 앞 장소에서 오는 이동(`legFromPrev`). **같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다** — 앱이 AI 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 |
|
||||
| `sources` | 출처 문구(한국관광공사 TourAPI · 공공누리 유형, 기상청 예보 발표 시각, KOPIS 등) |
|
||||
|
||||
- 결과의 사실 값은 생성 시점의 스냅샷이다. 저장한 코스는 그 뒤로 최신 장소 값을 읽는다(코스 설계에서 정한다).
|
||||
@@ -264,6 +290,11 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `PUT /api/course-generations/{id}/rating` | `{value: like · dislike}` — "이 코스, 마음에 드세요?" | RES_001, VAL_001 |
|
||||
|
||||
- `status` 는 `queued` · `running` · `succeeded` · `failed` · `canceled` 다. 생성이 실패해도 조회 자체는 200 이고, 이유는 `failure.code` 로 준다(문구는 에러 코드 사전).
|
||||
- 구현(2026-11-08, `backend/src/modules/course-generation/`)에서 정한 것:
|
||||
- `X-Device-Id` 가 없거나 UUID 가 아니면 VAL_001. 응답 언어는 `Accept-Language` 로 고른다(en 이면 영어, 그 밖은 한국어).
|
||||
- 생성 요청의 조건이 틀리면 VAL_001 이다 — 지난 날짜 · 30일보다 먼 날짜 · 2시간 미만 · 12시간 초과 · 오늘 이미 끝난 시간 · 대구 밖 현재 위치 · 없는 기준 장소 · 다른 기기의 이전 생성.
|
||||
- 같은 기기의 동시 요청은 기기 단위 잠금(`pg_advisory_xact_lock`)으로 하나씩 검사한다(진행 중 → AI_004, 횟수·시도 상한 → AI_001, 하루 비용 → AI_002).
|
||||
- 조회 응답에 내가 남긴 평가(`rating`)도 준다. 평가는 결과가 나온 생성만 받는다(아니면 VAL_001). 취소는 끝난 생성이면 그대로 돌려준다.
|
||||
- 저장은 코스 API(`POST /api/courses`, 코스 설계)에 `generationId` 와 바꾼 후보를 함께 보낸다. 로그인이 필요하다.
|
||||
- **요청 제한**: 생성은 분당 5회, 조건 해석은 분당 20회다. 진행 조회는 1~2초 간격이라 기본 한도(분당 100회) 대신 분당 300회로 따로 둔다.
|
||||
통신사 NAT 뒤에서는 여러 사용자가 같은 IP 를 쓰기 때문이다.
|
||||
@@ -297,7 +328,8 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `conditions` | jsonb |
|
||||
| `status`, `stage` | §3.1 |
|
||||
| `found_count`, `kept_count` | 진행 표시 숫자 |
|
||||
| `result` | jsonb(§3.7). 실패해도 AI 가 남긴 `notices` 는 여기에 둔다 |
|
||||
| `result` | jsonb(§3.7) |
|
||||
| `failure_notices` | jsonb — 실패 이유(AI 가 남긴 알림). `result` 대신 따로 둔다(2026-11-08) |
|
||||
| `failure_code` | AI_002 · AI_003 |
|
||||
| `counted` | 횟수를 차감했는지 |
|
||||
| `previous_id` | 다시 만들기라면 이전 생성 |
|
||||
@@ -309,6 +341,9 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
|
||||
- 인덱스: `(device_hash, created_at)`, `(user_id, created_at)`, `(status)`.
|
||||
- 하루 사용량과 비용은 이 테이블에서 센다. 집계 테이블은 따로 두지 않는다.
|
||||
- 구현하며 더한 것(2026-11-08): `started_at`(멈춘 작업 찾기), `failure_notices`.
|
||||
`conditions` 의 현재 위치(point)는 소수점 셋째 자리(약 100m)까지만 남긴다 — 생성이 끝나면 정확한 위치가 필요 없다.
|
||||
- 조건 해석은 이 테이블에 남지 않는다. 조건 해석 비용을 하루 상한에 넣는 기록은 5단계(Anthropic 연동)에서 더한다.
|
||||
|
||||
### 6.2 지원 지역
|
||||
|
||||
@@ -361,14 +396,15 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
|
||||
## 9. 평가 (`docs/ai-eval/`)
|
||||
|
||||
- **평가셋을 대구로 옮겼다(v2 초안, 2026-11-05).** v1 62개 중 60개가 서울·부산·제주 등 다른 지역이라 장소 DB(대구)로는 돌릴 수 없었다.
|
||||
- **평가셋을 대구로 옮겼다(v2, 2026-11-05 사용자 검토로 확정).** v1 62개 중 60개가 서울·부산·제주 등 다른 지역이라 장소 DB(대구)로는 돌릴 수 없었다.
|
||||
v1 은 지역을 넓힐 때 다시 쓴다. v2 는 [`course-eval-v2.jsonl`](../ai-eval/course-eval-v2.jsonl) 62개다.
|
||||
- 지역을 대구 동네(도심 · 수성못 · 앞산)와 대표 장소(팔공산 · 동촌유원지 · 이월드 · 서문시장 등)로 바꾸고, 날짜를 2026-11-05 기준으로 다시 잡았다.
|
||||
- 사례마다 사용자가 확인한 조건(`conditions`)과 문장에서 뽑아야 할 항목(`inferred`)을 적어 조건 해석도 함께 채점한다.
|
||||
- `expected_behavior` 는 흐름에 맞춰 나눈다. `ask_clarification` · `decline_out_of_scope`(8개)는 조건 해석 단계의 `issues` 로,
|
||||
`refuse_part` · `partial_with_notice` · `no_course` 는 생성 단계의 `notices` 로 시험한다.
|
||||
- 만들면서 확인한 데이터 한계(반려 동반 실내 0곳 · 시장은 큐레이션 필요 · 11월 대구 축제 없음)는 평가셋 README 에 적었다.
|
||||
- 사용자가 사는 지역이므로 실제로 할 법한 요청인지 사용자 검토를 받는다.
|
||||
- 검토에서 정한 것: 요청의 핵심을 못 맞추면 만들지 않고(`no_course` + 이유), 일부만 못 맞추면 맞출 수 있는 만큼 만들고 알린다(§3.5).
|
||||
'돈 안 쓰는 코스'는 식당·카페를 뺀다.
|
||||
- **후보 스냅샷을 고정한다.** 장소 DB 는 동기화로 계속 바뀐다. 첫 실행 때 사례별 후보 목록을 파일로 저장하고, 이후 모델 비교는 같은 후보로 한다.
|
||||
`requires` 에 적은 장소가 후보에 없으면(큐레이션 전) 그 사례는 '데이터 준비 안 됨'으로 따로 센다.
|
||||
- **채점**: 자동 채점(코드. §3.6 검증과 같은 함수) → AI 채점자(이유가 데이터와 맞는지 · 자연스러운지, 1–5점) → 사람 검토(무작위 10개).
|
||||
@@ -393,6 +429,7 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| 키 | 위치 | 기본값 | 설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ANTHROPIC_API_KEY` | `backend/.env.{env}` | — | 운영에서는 필수(없으면 기동 실패). 개발에서 없으면 AI API 가 `AI_002` 를 준다 |
|
||||
| `PUBLIC_DATA_SERVICE_KEY` | `backend/.env.{env}` | — | 이미 있는 공공데이터포털 키(장소 동기화). 날씨도 쓴다. 없으면 날씨만 '예보 없음'이다 |
|
||||
| `AI_COURSE_MODEL` | 루트 `.env` → compose | 평가 뒤 결정(Haiku 5.5 부터 시험) | 코스 생성 모델 |
|
||||
| `AI_CONDITION_MODEL` | 루트 `.env` → compose | `claude-haiku-5-5` | 조건 해석 모델 |
|
||||
| `AI_DAILY_LIMIT_GUEST` · `AI_DAILY_LIMIT_MEMBER` | 루트 `.env` → compose | 2 · 5 | §5 |
|
||||
@@ -400,6 +437,8 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `AI_TIMEOUT_MS` | 루트 `.env` → compose | 30000 | AI 호출 제한 시간 |
|
||||
|
||||
- 숫자는 `parsePositiveInt` 로 읽는다(빈 문자열 방어). 새 필수 값은 `env.validation.ts` 와 테스트에 함께 추가한다.
|
||||
- 4단계(2026-11-08)는 `AI_DAILY_LIMIT_*` · `AI_DAILY_BUDGET_USD` · `AI_TIMEOUT_MS` 를 `ConfigService` 로 읽고 없으면 위 기본값을 쓴다(형식은 `env.validation.ts` 가 본다).
|
||||
compose 로 넘기는 설정은 값을 바꿀 일이 생길 때 더한다 — 그 전에는 `backend/.env.{env}` 에 적으면 된다.
|
||||
- SDK(`@anthropic-ai/sdk`)는 컨테이너 안에서 설치하고, 잠금 파일을 npm 10 으로 확인한다(nestjs 스킬의 의존성 주의 항목).
|
||||
|
||||
## 12. MVP 에서 하지 않는 것
|
||||
@@ -430,10 +469,10 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
|
||||
|
||||
## 14. 구현 순서
|
||||
|
||||
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 초안을 만들었다
|
||||
2. 순수 로직 — 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트, 계절별 시간표를 날짜로 고르기(§3.3) (키 불필요)
|
||||
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로)
|
||||
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
|
||||
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
|
||||
2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝)
|
||||
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로). 2026-11-08 반영(§3.3)
|
||||
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다). 2026-11-08 반영 — 5단계 전까지 서버는 가짜 AI(`FakeCourseAiClient`)로 코스를 만든다
|
||||
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
|
||||
6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신
|
||||
7. Flutter 화면 8–10 연결
|
||||
|
||||
+12
-1
@@ -293,8 +293,19 @@ node dist/commands/place-sync.js tour-kor
|
||||
— 같은 구절의 "월~금 10:00~18:00" 은 여는 요일이다. 쉬는날 원문의 여는 날 안내("화~일 운영")도 휴무로 읽지 않는다.
|
||||
- 매주가 아닌 휴무(매월·매달·격주·몇째 주·N주차·마지막 주)는 구절마다 판단해 안내로만 남긴다. 괄호 안 주기는 앞 구절을 꾸민다("매주 월요일 (매월 2주, 4주)").
|
||||
- 시설별 쉬는날("[북카페] 일요일 [다목적홀] 주말", "서문시장 매월 … / 대구서문 야시장 매주 월요일~목요일")은 모든 시설이 쉬는 요일만 휴무로 둔다.
|
||||
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다.
|
||||
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다(→ 2026-11-06 날짜로 고른다).
|
||||
- 남은 한계: 영업시간 문장 속 시설별 휴관("[휘트니스] … 매주 일요일 휴관", 엘리바덴 월성점)은 시설 전체 휴무로 읽힌다.
|
||||
- 2026-11-06 계절별 시간표를 날짜의 달로 고르게 고쳤다(`reparse-hours`, 영업 중 판단이 바뀐 곳 30곳). `isOpenAt` 은 요일 대신 한국 날짜('YYYY-MM-DD')를 받는다.
|
||||
- 계절·달 머리말마다 시간표를 따로 둔다(`seasons`) — "[하절기(4월~10월)]", "하절기(4~10월) …", "- 3월~10월 …", "[1월~4월 / 9월~12월]", "10:00~23:00 (4월~11월)".
|
||||
그때까지 미확인이던 23곳이 판단되고, 1년 내내 같은 시간으로 읽혀 겨울에 영업 중으로 잘못 보이던 7곳(대구수목원 아침 8시·달성 한일우호관 17시·상주식당 12~3월 등)이 바로잡혔다.
|
||||
- 달을 적지 않은 하절기·동절기(7곳)는 기관마다 범위가 달라(대구 원문 15곳: 3~10월·4~9월·4~10월·4~11월 등) 하절기 4~9월, 동절기 12~2월만 확실하다고 본다.
|
||||
3·10·11월에는 두 계절 시간이 같은 시각만 판단한다(수창청춘맨숀 11월 18시~19시는 미확인).
|
||||
- 머리말 없는 기본 시간은 계절 시간이 없는 달·요일에 쓰고("10:00~17:00 [하절기(6~8월)] 10:00~13:00"), 시설 머리말 시간은 어느 계절이든 함께 연다.
|
||||
- 쉬는날의 시기별 휴무는 그 시기에만(`restSeasons`), 기간만 적은 쉬는날("매년 12월~3월", "동절기 (11월~2월)", "… / 12월~2월")은 그 기간 내내 닫는다(`closedPeriods`, 상주식당 등 3곳).
|
||||
"1월 1일" 같은 날짜와 예외 문구 속 달("성수기 7월~8월 … 제외")은 기간으로 읽지 않는다. 계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관).
|
||||
- 계절을 말하는데 기간을 읽지 못하면 시간표를 비워 판단하지 않는다. 다시 해석하기 전에 저장된 결과(`seasons` 없음)도 판단하지 않는다.
|
||||
- 남은 한계: 계절 안의 시설 머리말("[하절기] [A관] … [B관] …")은 읽지 못해 미확인으로 둔다. 쉬는날의 예외 기간(도학야영장 "매주 수요일 (※ 성수기 … 제외)")은 적용하지 않는다.
|
||||
케이블카 출발 시각처럼 범위가 아닌 시각은 읽지 않는다(앞산 케이블카는 '점포별 상이').
|
||||
- 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경).
|
||||
끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다).
|
||||
업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.
|
||||
|
||||
@@ -11,6 +11,10 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
|
||||
'AI_001': '오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
|
||||
'AI_002': '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
'AI_003': '조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
|
||||
'AI_004': '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -11,6 +11,10 @@ export const ERROR_CODE_MESSAGES = {
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
AI_001: '오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
|
||||
AI_002: '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
AI_003: '조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
|
||||
AI_004: '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
Reference in New Issue
Block a user