feat(backend): AI 코스 생성 API 골격과 생성 기록 테이블 추가 #13

Merged
ttipo merged 2 commits from feat/course-generation-api into main 2026-11-11 15:01:58 +09:00
39 changed files with 3963 additions and 13 deletions
Showing only changes of commit 09c8decb15 - Show all commits
+2
View File
@@ -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,
],
+23
View File
@@ -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,211 @@
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,
) {}
/** 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다 */
async onApplicationBootstrap(): Promise<void> {
const result = await this.generations.update(
{ status: In([...ACTIVE_STATUSES]) },
this.failedValues(),
);
if (result.affected) {
this.logger.warn(
`지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`,
);
}
}
/** 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;
}
@@ -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 });
}
+12
View File
@@ -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`;
}
@@ -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 로 남아 있으면 안 되는 키 */
@@ -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);
});
});
+20
View File
@@ -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,5 @@
import {
hoursOn,
isOpenAt,
parseOpeningHours,
type ParsedOpeningHours,
@@ -695,3 +696,43 @@ describe('parseOpeningHours', () => {
});
});
});
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) }]);
});
});
+29
View File
@@ -972,3 +972,32 @@ export function isOpenAt(
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 }));
}
+444
View File
@@ -0,0 +1,444 @@
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();
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();
ds = app.get(DataSource);
await ds.runMigrations();
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 () => {
await cleanup();
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');
});
});
+19 -5
View File
@@ -1,7 +1,7 @@
# AI 코스 생성 설계 (v0.2, 2026-11-05)
사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다.
코드는 §14 순서로 만들고 있다(2026-11-08 기준 3단계 날씨·공휴일까지). 장소·축제 데이터는 [장소 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 후보 찾기
@@ -262,10 +264,11 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
| --- | --- |
| `outcome` | `course` · `partial` |
| `title`, `summary`, `notices` | AI |
| `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_last_day — 그날이 행사 마지막 날), `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 등) |
- 결과의 사실 값은 생성 시점의 스냅샷이다. 저장한 코스는 그 뒤로 최신 장소 값을 읽는다(코스 설계에서 정한다).
@@ -287,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 를 쓰기 때문이다.
@@ -320,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` | 다시 만들기라면 이전 생성 |
@@ -332,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 지원 지역
@@ -425,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 에서 하지 않는 것
@@ -458,7 +472,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝)
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로). 2026-11-08 반영(§3.3)
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다). 2026-11-08 반영 — 5단계 전까지 서버는 가짜 AI(`FakeCourseAiClient`)로 코스를 만든다
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신
7. Flutter 화면 8–10 연결
@@ -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