feat(backend): AI 코스 생성에 Anthropic 클라이언트와 프롬프트 v1, 조건 해석 연결
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m37s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m24s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m57s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m33s
CI / Frontend (lint · type-check · test) (push) Successful in 11m12s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m18s

4단계까지 서버는 가짜 AI 로 코스를 만들었다. 실제 모델(기본 Haiku 5.5)을 붙여
문장에서 조건을 읽고 후보 중 코스를 고르게 한다(설계 §14 5단계).

- 구조화 출력(JSON 스키마)으로 받고, 같은 시스템 프롬프트는 캐시에 올린다
- 조건 해석은 AI 가 값만 뽑고 기본값과 되물을 것은 코드가 정한다(unknown_place 추가)
- 조건 해석 비용과 응답 뒤 실패한 호출 비용도 하루 비용 상한에 넣는다
- AI_PROVIDER(fake 로 되돌림) · effort 설정, 모델은 가격표에 있어야 기동한다
- 키로 실제 호출을 확인하는 ai:check 명령

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #14.
This commit is contained in:
2026-11-11 17:16:00 +09:00
parent 7ed3411abb
commit 4dc5be72a4
33 changed files with 2862 additions and 89 deletions
+106 -4
View File
@@ -20,6 +20,10 @@ import {
type CourseDraftInput,
} from '@/modules/course-generation/ai/course-ai.client';
import { FakeCourseAiClient } from '@/modules/course-generation/ai/fake-course-ai.client';
import {
type ConditionExtraction,
emptyExtraction,
} from '@/modules/course-generation/condition-extraction';
import { hashDeviceId } from '@/modules/course-generation/device-id.decorator';
import { Place } from '@/modules/place/entities/place.entity';
import { WeatherService } from '@/modules/weather/weather.service';
@@ -37,17 +41,37 @@ const EMPTY = { lat: 35.6, lng: 128.36 };
const DEVICE_HEADER = 'X-Device-Id';
const TERMINAL = ['succeeded', 'failed', 'canceled'];
type Mode = 'normal' | 'block' | 'unavailable';
type Mode = 'normal' | 'block' | 'unavailable' | 'refused';
/** 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 한다 */
/** 응답을 받은 뒤 실패한 호출의 사용량(거절·형식 오류) */
const REFUSED_USAGE = {
model: 'claude-haiku-5-5',
promptVersion: 'course-v1',
inputTokens: 6_000,
outputTokens: 300,
cacheReadTokens: 0,
cacheWriteTokens: 0,
costUsd: 0.00075,
};
/**
* 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 하거나 응답을 받은 뒤 실패하게(refused) 한다.
* 조건 해석은 extraction 을 넣으면 그 값을 뽑은 것처럼 돌려준다.
*/
class ControllableAi implements CourseAiClient {
mode: Mode = 'normal';
extraction: ConditionExtraction | null = null;
private readonly fake = new FakeCourseAiClient();
draftCourse(input: CourseDraftInput) {
if (this.mode === 'unavailable') {
return Promise.reject(new AiUnavailableError('시험: AI 장애'));
}
if (this.mode === 'refused') {
return Promise.reject(
new AiUnavailableError('시험: 거절', REFUSED_USAGE),
);
}
if (this.mode === 'block') {
return new Promise<never>((_, reject) => {
input.signal.addEventListener('abort', () =>
@@ -58,8 +82,11 @@ class ControllableAi implements CourseAiClient {
return this.fake.draftCourse(input);
}
parseConditions(input: Parameters<CourseAiClient['parseConditions']>[0]) {
return this.fake.parseConditions(input);
async parseConditions(
input: Parameters<CourseAiClient['parseConditions']>[0],
) {
const out = await this.fake.parseConditions(input);
return this.extraction ? { ...out, extraction: this.extraction } : out;
}
}
@@ -226,6 +253,7 @@ describe('AI 코스 생성 API (e2e)', () => {
afterEach(() => {
ai.mode = 'normal';
ai.extraction = null;
});
it('X-Device-Id 가 없거나 UUID 가 아니면 VAL_001', async () => {
@@ -285,6 +313,60 @@ describe('AI 코스 생성 API (e2e)', () => {
expect(body(outside).error?.code).toBe('VAL_001');
});
it('조건 해석 — 말한 장소를 기준 장소로 찾고, 못 찾은 장소·대구 밖·짧은 시간은 issues 로 알린다', async () => {
const parse = (extraction: Partial<ConditionExtraction>) => {
ai.extraction = { ...emptyExtraction(), ...extraction };
return request(app.getHttpServer())
.post('/api/course-conditions')
.set(DEVICE_HEADER, device())
.send({ text: '시험 문장', baseArea: { type: 'point', ...C } })
.expect(200);
};
const found = body<{
conditions: { area: unknown };
inferred: string[];
issues: string[];
}>(
await parse({
area: {
mention: 'place',
neighborhood: null,
placeName: '이투이코스 3',
},
// 오늘이면 시험 시각에 따라 지난 시간(past_date)이 될 수 있어 내일로 둔다
date,
start: '14:00',
end: '15:00',
}),
).data;
expect(found.conditions.area).toEqual({
type: 'place',
placeId: placeIds[2],
});
expect(found.inferred).toEqual(['area', 'date', 'start', 'end']);
expect(found.issues).toEqual(['too_short']);
const unknown = body<{ conditions: { area: unknown }; issues: string[] }>(
await parse({
area: {
mention: 'place',
neighborhood: null,
placeName: '이투이 없는 곳',
},
}),
).data;
expect(unknown.conditions.area).toEqual({ type: 'point', ...C });
expect(unknown.issues).toEqual(['unknown_place']);
const outside = body<{ issues: string[] }>(
await parse({
area: { mention: 'outside', neighborhood: null, placeName: '경주' },
multiDay: true,
}),
).data;
expect(outside.issues).toEqual(['unsupported_area', 'multi_day']);
});
it('생성을 접수하고 진행을 조회하면 결과가 나오고, 그때 횟수를 차감한다', async () => {
const me = device();
const created = await request(app.getHttpServer())
@@ -424,6 +506,26 @@ describe('AI 코스 생성 API (e2e)', () => {
});
});
it('AI 가 답한 뒤 실패해도 그 비용은 생성 기록에 남긴다(하루 비용 상한에 들어간다)', async () => {
const me = device();
ai.mode = 'refused';
const created = await create(me).expect(201);
const id = body<{ id: string }>(created).data.id;
const done = await waitFor(me, id);
expect(done).toMatchObject({
status: 'failed',
failure: { code: 'AI_002' },
});
const [row] = await ds.query<
{ cost_usd: string; llm_calls: number; input_tokens: number }[]
>(
`SELECT cost_usd, llm_calls, input_tokens FROM ai_course_generation WHERE id = $1`,
[id],
);
expect(Number(row.cost_usd)).toBeCloseTo(0.00075, 6);
expect(row).toMatchObject({ llm_calls: 1, input_tokens: 6_000 });
});
it('지난 날짜·짧은 시간·남의 이전 생성은 VAL_001', async () => {
const me = device();
const past = await create(me, {