From 09c8decb15fcf38748538380b017dcc3383d6af0 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 11 Nov 2026 11:37:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20AI=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20=EA=B3=A8=EA=B2=A9=EA=B3=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=A1=9D=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계 4단계 — 키 없이 생성 흐름을 끝까지 돌려 볼 수 있게 API·테이블·에러 코드를 먼저 세우고, AI 자리는 가짜 클라이언트로 채웠다(5단계에서 실제 모델로 바꾼다). - 조건 해석·생성 시작·진행 조회·취소·평가·오늘 남은 횟수 API 6개 - ai_course_generation 테이블(마이그레이션)과 에러 코드 AI_001~004 - 기기 단위 하루 횟수(결과가 나온 생성만 차감)·시도 상한·하루 비용 상한·동시 생성 막기 - 프로세스 안 실행기: 동시 5건, 작업당 45초, 취소 시 AI 호출 중단, 기동·종료 때 끝나지 못한 작업은 실패(차감 없음)로 정리 - 후보 찾기 → 거르기·줄이기 → AI 초안 → 시간표 검증·고치기 → 결과 조립 - 요청 문장의 연락처는 저장 전에 가리고, 현재 위치는 약 100m 단위로만 남긴다 Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 2 + backend/src/common/errors/error-codes.ts | 23 + .../1794127708706-AddAiCourseGeneration.ts | 37 ++ .../course-generation/ai/course-ai.client.ts | 98 ++++ .../ai/fake-course-ai.client.spec.ts | 113 +++++ .../ai/fake-course-ai.client.ts | 130 +++++ .../course-generation/course-area.service.ts | 57 +++ .../course-candidates.service.ts | 186 ++++++++ .../course-conditions.service.ts | 98 ++++ .../course-conditions.spec.ts | 126 +++++ .../course-generation/course-conditions.ts | 142 ++++++ .../course-generation.constants.ts | 99 ++++ .../course-generation.controller.ts | 182 +++++++ .../course-generation.module.ts | 46 ++ .../course-generation.pipeline.ts | 249 ++++++++++ .../course-generation.runner.ts | 211 +++++++++ .../course-generation.service.ts | 179 +++++++ .../course-generation.types.ts | 50 ++ .../course-generation/course-quota.service.ts | 117 +++++ .../course-result.builder.spec.ts | 128 +++++ .../course-result.builder.ts | 177 +++++++ .../course-generation/course-settings.ts | 38 ++ .../device-id.decorator.spec.ts | 12 + .../course-generation/device-id.decorator.ts | 33 ++ .../dto/course-conditions.dto.ts | 199 ++++++++ .../dto/course-generation.dto.ts | 162 +++++++ .../dto/course-result.dto.ts | 317 +++++++++++++ .../entities/ai-course-generation.entity.ts | 146 ++++++ backend/src/modules/place/festival.service.ts | 10 +- backend/src/modules/place/place.sql.ts | 12 + backend/src/shared/config/env.validation.ts | 5 + backend/src/shared/utils/mask-contact.spec.ts | 26 + backend/src/shared/utils/mask-contact.ts | 20 + .../src/shared/utils/opening-hours.spec.ts | 41 ++ backend/src/shared/utils/opening-hours.ts | 29 ++ backend/test/course-generation.e2e-spec.ts | 444 ++++++++++++++++++ docs/design/ai-course.md | 24 +- flutter/lib/core/api/error_lexicon.g.dart | 4 + .../shared/constants/error-codes.generated.ts | 4 + 39 files changed, 3963 insertions(+), 13 deletions(-) create mode 100644 backend/src/migrations/1794127708706-AddAiCourseGeneration.ts create mode 100644 backend/src/modules/course-generation/ai/course-ai.client.ts create mode 100644 backend/src/modules/course-generation/ai/fake-course-ai.client.spec.ts create mode 100644 backend/src/modules/course-generation/ai/fake-course-ai.client.ts create mode 100644 backend/src/modules/course-generation/course-area.service.ts create mode 100644 backend/src/modules/course-generation/course-candidates.service.ts create mode 100644 backend/src/modules/course-generation/course-conditions.service.ts create mode 100644 backend/src/modules/course-generation/course-conditions.spec.ts create mode 100644 backend/src/modules/course-generation/course-conditions.ts create mode 100644 backend/src/modules/course-generation/course-generation.constants.ts create mode 100644 backend/src/modules/course-generation/course-generation.controller.ts create mode 100644 backend/src/modules/course-generation/course-generation.module.ts create mode 100644 backend/src/modules/course-generation/course-generation.pipeline.ts create mode 100644 backend/src/modules/course-generation/course-generation.runner.ts create mode 100644 backend/src/modules/course-generation/course-generation.service.ts create mode 100644 backend/src/modules/course-generation/course-generation.types.ts create mode 100644 backend/src/modules/course-generation/course-quota.service.ts create mode 100644 backend/src/modules/course-generation/course-result.builder.spec.ts create mode 100644 backend/src/modules/course-generation/course-result.builder.ts create mode 100644 backend/src/modules/course-generation/course-settings.ts create mode 100644 backend/src/modules/course-generation/device-id.decorator.spec.ts create mode 100644 backend/src/modules/course-generation/device-id.decorator.ts create mode 100644 backend/src/modules/course-generation/dto/course-conditions.dto.ts create mode 100644 backend/src/modules/course-generation/dto/course-generation.dto.ts create mode 100644 backend/src/modules/course-generation/dto/course-result.dto.ts create mode 100644 backend/src/modules/course-generation/entities/ai-course-generation.entity.ts create mode 100644 backend/src/shared/utils/mask-contact.spec.ts create mode 100644 backend/src/shared/utils/mask-contact.ts create mode 100644 backend/test/course-generation.e2e-spec.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 09a72bc..77e2186 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, ], diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b742aac..aa32c6b 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; export const ERROR_CODES = { diff --git a/backend/src/migrations/1794127708706-AddAiCourseGeneration.ts b/backend/src/migrations/1794127708706-AddAiCourseGeneration.ts new file mode 100644 index 0000000..e4cb12e --- /dev/null +++ b/backend/src/migrations/1794127708706-AddAiCourseGeneration.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/course-generation/ai/course-ai.client.ts b/backend/src/modules/course-generation/ai/course-ai.client.ts new file mode 100644 index 0000000..1d4db2f --- /dev/null +++ b/backend/src/modules/course-generation/ai/course-ai.client.ts @@ -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; + inferred: (keyof CourseConditions)[]; + issues: ConditionIssue[]; + usage: AiUsage; +} + +export interface CourseAiClient { + /** 후보 목록에서 장소·순서·머무는 시간·이유를 고른다 */ + draftCourse(input: CourseDraftInput): Promise; + /** 문장에서 지역·날짜·시간 같은 조건을 뽑는다(작은 모델) */ + parseConditions(input: ConditionParseInput): Promise; +} + +/** AI 를 쓸 수 없음 — 장애 · 제한 시간 초과 · 키 없음 → AI_002 */ +export class AiUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'AiUnavailableError'; + } +} diff --git a/backend/src/modules/course-generation/ai/fake-course-ai.client.spec.ts b/backend/src/modules/course-generation/ai/fake-course-ai.client.spec.ts new file mode 100644 index 0000000..4b4cde0 --- /dev/null +++ b/backend/src/modules/course-generation/ai/fake-course-ai.client.spec.ts @@ -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: [] }); + }); +}); diff --git a/backend/src/modules/course-generation/ai/fake-course-ai.client.ts b/backend/src/modules/course-generation/ai/fake-course-ai.client.ts new file mode 100644 index 0000000..c3eba3e --- /dev/null +++ b/backend/src/modules/course-generation/ai/fake-course-ai.client.ts @@ -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 { + 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(); + 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 { + const aborted = abortReason(input.signal); + if (aborted) return Promise.reject(aborted); + return Promise.resolve({ + extracted: {}, + inferred: [], + issues: [], + usage: ZERO_USAGE, + }); + } +} diff --git a/backend/src/modules/course-generation/course-area.service.ts b/backend/src/modules/course-generation/course-area.service.ts new file mode 100644 index 0000000..f37f796 --- /dev/null +++ b/backend/src/modules/course-generation/course-area.service.ts @@ -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, + ) {} + + async resolve(area: CourseArea): Promise { + 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' }, + }); + } + } +} diff --git a/backend/src/modules/course-generation/course-candidates.service.ts b/backend/src/modules/course-generation/course-candidates.service.ts new file mode 100644 index 0000000..5f26ac9 --- /dev/null +++ b/backend/src/modules/course-generation/course-candidates.service.ts @@ -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 = { + tour: '한국관광공사', + store: '소상공인시장진흥공단 상가(상권)정보', +}; +const EVENT_PROVIDERS: Record = { + 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; +} + +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, + @InjectRepository(FestivalEvent) + private readonly events: Repository, + ) {} + + async find( + center: LatLng, + date: string, + mobility: Mobility, + ): Promise { + 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(); + 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 }; + } +} diff --git a/backend/src/modules/course-generation/course-conditions.service.ts b/backend/src/modules/course-generation/course-conditions.service.ts new file mode 100644 index 0000000..bd2bb19 --- /dev/null +++ b/backend/src/modules/course-generation/course-conditions.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/backend/src/modules/course-generation/course-conditions.spec.ts b/backend/src/modules/course-generation/course-conditions.spec.ts new file mode 100644 index 0000000..2ff1fee --- /dev/null +++ b/backend/src/modules/course-generation/course-conditions.spec.ts @@ -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 { + 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); + }); +}); diff --git a/backend/src/modules/course-generation/course-conditions.ts b/backend/src/modules/course-generation/course-conditions.ts new file mode 100644 index 0000000..a6feefb --- /dev/null +++ b/backend/src/modules/course-generation/course-conditions.ts @@ -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) }, + }; +} diff --git a/backend/src/modules/course-generation/course-generation.constants.ts b/backend/src/modules/course-generation/course-generation.constants.ts new file mode 100644 index 0000000..99f1497 --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.constants.ts @@ -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; +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; diff --git a/backend/src/modules/course-generation/course-generation.controller.ts b/backend/src/modules/course-generation/course-generation.controller.ts new file mode 100644 index 0000000..a853858 --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.controller.ts @@ -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)); + } +} diff --git a/backend/src/modules/course-generation/course-generation.module.ts b/backend/src/modules/course-generation/course-generation.module.ts new file mode 100644 index 0000000..f720add --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.module.ts @@ -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 {} diff --git a/backend/src/modules/course-generation/course-generation.pipeline.ts b/backend/src/modules/course-generation/course-generation.pipeline.ts new file mode 100644 index 0000000..556577f --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.pipeline.ts @@ -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, + 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 { + 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 { + 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 { + return this.update(id, { stage }); + } + + /** running 일 때만 바꾼다 — 취소된 생성을 덮어쓰지 않는다 */ + private async update( + id: string, + values: QueryDeepPartialEntity, + ): Promise { + await this.generations.update({ id, status: 'running' }, values); + } +} diff --git a/backend/src/modules/course-generation/course-generation.runner.ts b/backend/src/modules/course-generation/course-generation.runner.ts new file mode 100644 index 0000000..077c8d9 --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.runner.ts @@ -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(); + private shuttingDown = false; + + constructor( + @InjectRepository(AiCourseGeneration) + private readonly generations: Repository, + private readonly pipeline: CourseGenerationPipeline, + ) {} + + /** 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다 */ + async onApplicationBootstrap(): Promise { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/backend/src/modules/course-generation/course-generation.service.ts b/backend/src/modules/course-generation/course-generation.service.ts new file mode 100644 index 0000000..a976c37 --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.service.ts @@ -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, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly quota: CourseQuotaService, + private readonly areas: CourseAreaService, + private readonly runner: CourseGenerationRunner, + ) {} + + quotaFor(deviceHash: string, now = new Date()): Promise { + return this.quota.view(deviceHash, now); + } + + /** + * 생성 접수 — 조건을 확인하고, 같은 기기의 요청을 직렬화한 트랜잭션에서 횟수·진행 중·비용을 본 뒤 기록을 만든다. + * 차감은 결과가 나왔을 때(succeeded) 한다. + */ + async create( + deviceHash: string, + dto: CreateCourseGenerationDto, + lang: CourseLang, + now = new Date(), + ): Promise { + 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 { + return this.toView(await this.owned(deviceHash, id), now); + } + + /** 취소 — 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다. 차감하지 않는다. 이미 끝났으면 그대로 돌려준다 */ + async cancel( + deviceHash: string, + id: string, + now = new Date(), + ): Promise { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/course-generation/course-generation.types.ts b/backend/src/modules/course-generation/course-generation.types.ts new file mode 100644 index 0000000..0eb3548 --- /dev/null +++ b/backend/src/modules/course-generation/course-generation.types.ts @@ -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; +} diff --git a/backend/src/modules/course-generation/course-quota.service.ts b/backend/src/modules/course-generation/course-quota.service.ts new file mode 100644 index 0000000..9059b9d --- /dev/null +++ b/backend/src/modules/course-generation/course-quota.service.ts @@ -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, + private readonly settings: CourseSettings, + ) {} + + /** 입력 화면의 "오늘 2번 만들 수 있어요" */ + async view(deviceHash: string, now: Date): Promise { + 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 { + return (await this.view(deviceHash, now)).remaining; + } + + /** + * 새 생성을 받아도 되는지 — 같은 기기의 요청을 직렬화한 트랜잭션 안에서 부른다. + * 진행 중이 있으면 AI_004, 하루 횟수나 시도 상한(횟수의 3배)을 넘으면 AI_001, 하루 비용 상한을 넘으면 AI_002. + */ + async assertCanCreate( + manager: EntityManager, + deviceHash: string, + now: Date, + ): Promise { + 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 { + 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(); + } +} diff --git a/backend/src/modules/course-generation/course-result.builder.spec.ts b/backend/src/modules/course-generation/course-result.builder.spec.ts new file mode 100644 index 0000000..916cc5c --- /dev/null +++ b/backend/src/modules/course-generation/course-result.builder.spec.ts @@ -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([ + '한국관광공사', + '소상공인시장진흥공단 상가(상권)정보', + '기상청 단기예보', + ]); + }); +}); diff --git a/backend/src/modules/course-generation/course-result.builder.ts b/backend/src/modules/course-generation/course-result.builder.ts new file mode 100644 index 0000000..6079fe4 --- /dev/null +++ b/backend/src/modules/course-generation/course-result.builder.ts @@ -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; + +export interface ResultInput { + plan: SucceededPlan; + title: string; + summary: string; + /** AI 가 남긴 알림 */ + notices: string[]; + resolved: ResolvedConditions; + weather: WindowForecast | null; + facts: Map; + /** 짧은 번호 → 이름(뺀 장소 알림에 쓴다) */ + namesByRef: Map; +} + +/** 그날 한국 시각(분) → 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 & { + warnings: StopWarning[]; + }, + input: ResultInput, +): Omit { + 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), + }; +} diff --git a/backend/src/modules/course-generation/course-settings.ts b/backend/src/modules/course-generation/course-settings.ts new file mode 100644 index 0000000..9e5f55e --- /dev/null +++ b/backend/src/modules/course-generation/course-settings.ts @@ -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(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); + } +} diff --git a/backend/src/modules/course-generation/device-id.decorator.spec.ts b/backend/src/modules/course-generation/device-id.decorator.spec.ts new file mode 100644 index 0000000..39e09ae --- /dev/null +++ b/backend/src/modules/course-generation/device-id.decorator.spec.ts @@ -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'), + ); + }); +}); diff --git a/backend/src/modules/course-generation/device-id.decorator.ts b/backend/src/modules/course-generation/device-id.decorator.ts new file mode 100644 index 0000000..4cb3063 --- /dev/null +++ b/backend/src/modules/course-generation/device-id.decorator.ts @@ -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(); + 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); + }, +); diff --git a/backend/src/modules/course-generation/dto/course-conditions.dto.ts b/backend/src/modules/course-generation/dto/course-conditions.dto.ts new file mode 100644 index 0000000..401e7f3 --- /dev/null +++ b/backend/src/modules/course-generation/dto/course-conditions.dto.ts @@ -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; +} diff --git a/backend/src/modules/course-generation/dto/course-generation.dto.ts b/backend/src/modules/course-generation/dto/course-generation.dto.ts new file mode 100644 index 0000000..604968c --- /dev/null +++ b/backend/src/modules/course-generation/dto/course-generation.dto.ts @@ -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; +} diff --git a/backend/src/modules/course-generation/dto/course-result.dto.ts b/backend/src/modules/course-generation/dto/course-result.dto.ts new file mode 100644 index 0000000..6315068 --- /dev/null +++ b/backend/src/modules/course-generation/dto/course-result.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/course-generation/entities/ai-course-generation.entity.ts b/backend/src/modules/course-generation/entities/ai-course-generation.entity.ts new file mode 100644 index 0000000..48891df --- /dev/null +++ b/backend/src/modules/course-generation/entities/ai-course-generation.entity.ts @@ -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; +} diff --git a/backend/src/modules/place/festival.service.ts b/backend/src/modules/place/festival.service.ts index 4d05384..f5e0302 100644 --- a/backend/src/modules/place/festival.service.ts +++ b/backend/src/modules/place/festival.service.ts @@ -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 = { 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 }); } diff --git a/backend/src/modules/place/place.sql.ts b/backend/src/modules/place/place.sql.ts index 64f6274..19112cb 100644 --- a/backend/src/modules/place/place.sql.ts +++ b/backend/src/modules/place/place.sql.ts @@ -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`; +} diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 8d26011..02770e8 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -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 로 남아 있으면 안 되는 키 */ diff --git a/backend/src/shared/utils/mask-contact.spec.ts b/backend/src/shared/utils/mask-contact.spec.ts new file mode 100644 index 0000000..66c5b8b --- /dev/null +++ b/backend/src/shared/utils/mask-contact.spec.ts @@ -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); + }); +}); diff --git a/backend/src/shared/utils/mask-contact.ts b/backend/src/shared/utils/mask-contact.ts new file mode 100644 index 0000000..e32a128 --- /dev/null +++ b/backend/src/shared/utils/mask-contact.ts @@ -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 = + /(? { }); }); }); + +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) }]); + }); +}); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts index 47c6d03..a021877 100644 --- a/backend/src/shared/utils/opening-hours.ts +++ b/backend/src/shared/utils/opening-hours.ts @@ -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 })); +} diff --git a/backend/test/course-generation.e2e-spec.ts b/backend/test/course-generation.e2e-spec.ts new file mode 100644 index 0000000..7ea6951 --- /dev/null +++ b/backend/test/course-generation.e2e-spec.ts @@ -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((_, reject) => { + input.signal.addEventListener('abort', () => + reject(new Error('시험: 끊김')), + ); + }); + } + return this.fake.draftCourse(input); + } + + parseConditions(input: Parameters[0]) { + return this.fake.parseConditions(input); + } +} + +interface Envelope { + 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 = (res: Response) => res.body as Envelope; + +describe('AI 코스 생성 API (e2e)', () => { + let app: INestApplication; + 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 = {}) => ({ + 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 = {}) => + 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 { + for (let i = 0; i < 100; i++) { + const res = await get(deviceId, id).expect(200); + const generation = body(res).data; + if (until(generation)) return generation; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`생성 ${id} 이 끝나지 않았다`); + } + + async function cleanup(): Promise { + 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(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(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'); + }); +}); diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md index 4908c7c..646d730 100644 --- a/docs/design/ai-course.md +++ b/docs/design/ai-course.md @@ -1,7 +1,7 @@ # AI 코스 생성 설계 (v0.2, 2026-11-05) 사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다. -코드는 §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 연결 diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index dad4a42..f6da1ea 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -11,6 +11,10 @@ const Map kErrorCodeMessages = { 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', + 'AI_001': '오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.', + 'AI_002': '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.', + 'AI_003': '조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.', + 'AI_004': '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 7071d62..64bfe92 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -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