generated from ttipo/project-base
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
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:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user