Skip to content

Commit 8bf87b9

Browse files
authored
Merge pull request #223 from boostcampwm-2024/dev
6주차 배포 2
2 parents a67c175 + 8c311ae commit 8bf87b9

File tree

106 files changed

+1382
-570
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

106 files changed

+1382
-570
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const MAX_FILE_SIZE = 1024 * 1024 * 100; // 100MB
2+
export const ALLOW_AUDIO_FILE_FORMAT = ['.m4a', '.ogg', '.ac3', '.aac', '.mp3'];
3+
export const OPENAI_PROMPT = `- 당신은 텍스트 요약 어시스턴트입니다.
4+
- 주어진 텍스트를 분석하고 핵심 단어들을 추출해 대분류, 중분류, 소분류로 나눠주세요.
5+
- 각 하위 분류는 상위 분류에 연관되는 키워드여야 합니다.
6+
- 반드시 대분류는 한개여야 합니다.
7+
- 각 객체에는 핵심 단어를 나타내는 keyword와 자식요소를 나타내는 children이 있으며, children의 경우 객체들을 포함한 배열입니다.
8+
- children 배열에는 개별 요소를 나타내는 객체가 들어갑니다.
9+
- 개별 요소는 keyword (문자열), children (배열)을 가집니다.
10+
- 마지막 자식 요소 또한 children을 필수적으로 빈 배열을 가지고 있습니다.
11+
- keyword 는 짧고 간결하게 해주세요.
12+
- keyword의 갯수는 최대 60개로 제한을 둡니다.
13+
- children의 배열의 최대 길이는 15로 제한을 둡니다.
14+
- tree 구조의 최대 depth는 4입니다.
15+
- 불필요한 띄어쓰기와 줄바꿈 문자는 제거합니다.
16+
- \`\`\` json \`\`\` 은 빼고 결과를 출력합니다.`;

BE/apps/api-server/src/main.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,8 @@ async function bootstrap() {
1313
app.setGlobalPrefix('api');
1414
app.useGlobalPipes(
1515
new ValidationPipe({
16-
transform: true, //dto를 수정 가능하게(dto 기본값 들어가도록)
17-
transformOptions: {
18-
enableImplicitConversion: true, //Class-Validator Type에 맞게 자동형변환
19-
},
16+
transform: true,
2017
whitelist: true,
21-
forbidNonWhitelisted: true,
2218
}),
2319
);
2420

BE/apps/api-server/src/middlewares/token.refresh.middleware.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AiController } from './ai.controller';
3+
4+
describe('AiController', () => {
5+
let controller: AiController;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
controllers: [AiController],
10+
}).compile();
11+
12+
controller = module.get<AiController>(AiController);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(controller).toBeDefined();
17+
});
18+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Body, Controller, Logger, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
2+
import { AiService } from './ai.service';
3+
import { FileInterceptor } from '@nestjs/platform-express';
4+
import { AuthGuard } from '@nestjs/passport';
5+
import { User } from '../../decorators';
6+
import { MAX_FILE_SIZE } from 'apps/api-server/src/common/constant';
7+
import { AudioFileValidationPipe } from '../../pipes';
8+
import { AudioUploadDto } from './dto/audio.upload.dto';
9+
import { AiDto } from './dto/ai.dto';
10+
11+
@Controller('ai')
12+
export class AiController {
13+
private readonly logger = new Logger(AiController.name);
14+
constructor(private readonly aiService: AiService) {}
15+
16+
@Post('audio')
17+
@UseGuards(AuthGuard('jwt'))
18+
@UseInterceptors(FileInterceptor('aiAudio', { limits: { fileSize: MAX_FILE_SIZE } }))
19+
async uploadAudioFile(
20+
@UploadedFile(new AudioFileValidationPipe()) audioFile: Express.Multer.File,
21+
@User() user: { id: number; email: string },
22+
@Body() audioUploadDto: AudioUploadDto,
23+
) {
24+
this.logger.log(`User ${user.id} uploaded audio file`);
25+
await this.aiService.requestClovaSpeech(audioFile, audioUploadDto);
26+
return;
27+
}
28+
29+
@Post('openai')
30+
@UseGuards(AuthGuard('jwt'))
31+
async requestOpenAi(@Body() aiDto: AiDto) {
32+
await this.aiService.requestOpenAi(aiDto);
33+
return;
34+
}
35+
}

BE/apps/api-server/src/modules/ai/ai.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
22
import { AiService } from './ai.service';
33
import { HttpModule } from '@nestjs/axios';
44
import { NodeModule } from '../node/node.module';
5+
import { AiController } from './ai.controller';
6+
import { AudioFileValidationPipe } from '../../pipes';
57

68
@Module({
79
imports: [HttpModule, NodeModule],
8-
providers: [AiService],
10+
providers: [AiService, AudioFileValidationPipe],
911
exports: [AiService],
12+
controllers: [AiController],
1013
})
1114
export class AiModule {}
Lines changed: 68 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,96 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
22
import { HttpService } from '@nestjs/axios';
33
import { firstValueFrom } from 'rxjs';
44
import { NodeService } from '../node/node.service';
55
import { ConfigService } from '@nestjs/config';
6-
import { RedisMessage } from '../subscriber/subscriber.service';
76
import { PublisherService } from '@app/publisher';
7+
import { AudioUploadDto } from './dto/audio.upload.dto';
8+
import { AiDto } from './dto/ai.dto';
9+
import OpenAI from 'openai';
10+
import { OpenAiRequestDto } from './dto/openai.request.dto';
11+
import { ClovaSpeechRequestDto } from './dto/clova.speech.request.dtd';
12+
import { plainToInstance } from 'class-transformer';
13+
import { OPENAI_PROMPT } from 'apps/api-server/src/common/constant';
14+
import { RedisService } from '@liaoliaots/nestjs-redis';
15+
import Redis from 'ioredis';
816

917
export interface TextAiResponse {
1018
keyword: string;
1119
children: TextAiResponse[];
1220
}
13-
const BAD_WORDS_REGEX =
14-
/[](?:[0-9]*|[0-9]+ *)[]|[]|[][0-9]*|?|[0-9]*|[]|[][0-9]*[]|[][0-9 ]*|[][0-9]+||[] *[]|[][]|[]|[]||[--]|(?:[] *[])| *[]|[][0-9]*[]|[--]|[믿]|[][0-9]*|[]|[][]|[][0-9]*|[]|[0-9]*[^-]|[][0-9]*[)|[0-9]*|[0-9]*|[-]|[0-9]*||[](?:||[0-9]*)|[0-9]*[]|[0-9]*[]|[^-]|[0-9]*(?:|)|[0-9]*|[0-9]*|[]|[-]{2,}[^-]|[-]{2,}|||[]|[0-9]*[]|[][]||[](?:[]|[])|||[]|(?:){2,}|[]|[][ ]?[-]+[]|[]|||(?<=[^\n])[]||[tT]l[qQ]kf|Wls|[]|[]|[]/;
15-
const CLOVA_X_PROMPT =
16-
'- 당신은 텍스트 요약 어시스턴트입니다.\r\n- 주어진 텍스트를 분석하고 핵심 단어들을 추출해 대분류, 중분류, 소분류로 나눠주세요.\n- 반드시 대분류는 한개여야 합니다.\r\n- JSON 트리 구조의 데이터로 만들어주세요.\r\n- 각 객체에는 핵심 단어를 나타내는 keyword와 부모자식요소를 나타내는 children이 있으며, children의 경우 객체들을 포함한 배열입니다. \r\n- 마지막 자식 요소 또한 children을 필수적으로 빈 배열([])을 가지고 있습니다.\n- 개별 요소는 keyword (문자열), children (배열)을 가집니다.\n- keyword 는 최대한 짧고 간결하게 해주세요.\r\n- children 배열에는 개별 요소를 나타내는 객체가 들어갑니다.\r\n- children을 통해 나타내는 트리 구조의 깊이는 2를 넘을 수 없습니다.\r\n- keyword는 최대 50개로 제한을 둡니다.\n- 띄어쓰기와 줄바꿈 문자는 제거합니다.\n- 데이터 형태는 아래와 같습니다.\n- "{"keyword": "점심메뉴", "children": [{"keyword": "중식","children": [{"keyword": "짜장면","children": []},{"keyword": "짬뽕","children": []},{"keyword": "탕수육","children": []},{"keyword": "깐풍기","children": []}]},{"keyword": "일식","children": [{"keyword": "초밥","children": []},{"keyword": "오꼬노미야끼","children": []},{"keyword": "장어덮밥","children": []}]},{"keyword": "양식","children": [{"keyword": "파스타","children": []},{"keyword": "스테이크","children": []}]},{"keyword": "한식","children": [{"keyword": "김치찌개 ","children": []}]}]}" 와 같은 데이터 처럼 마지막 자식 요소가 자식이 없어도 빈 배열을 가지고 있어야합니다.';
1721

1822
@Injectable()
1923
export class AiService {
2024
private readonly logger = new Logger(AiService.name);
25+
private readonly redis: Redis | null;
2126
constructor(
2227
private readonly configService: ConfigService,
2328
private readonly httpService: HttpService,
2429
private readonly nodeService: NodeService,
2530
private readonly publisherService: PublisherService,
26-
) {}
27-
28-
async requestClovaX(data: RedisMessage['data']) {
29-
if (BAD_WORDS_REGEX.test(data.aiContent)) {
30-
this.publisherService.publish(
31-
'api-socket',
32-
JSON.stringify({ event: 'textAi', data: { error: '욕설이 포함되어 있습니다.' } }),
33-
);
34-
return;
35-
}
36-
37-
const URL = this.configService.get('CLOVA_URL');
38-
const headers = {
39-
'X-NCP-CLOVASTUDIO-API-KEY': this.configService.get('X_NCP_CLOVASTUDIO_API_KEY'),
40-
'X-NCP-APIGW-API-KEY': this.configService.get('X_NCP_APIGW_API_KEY'),
41-
'X-NCP-CLOVASTUDIO-REQUEST-ID': this.configService.get('X_NCP_CLOVASTUDIO_REQUEST_ID'),
42-
'Content-Type': 'application/json',
43-
};
44-
45-
const messages = [
46-
{
47-
role: 'system',
48-
content: CLOVA_X_PROMPT,
49-
},
50-
{
51-
role: 'user',
52-
content: data.aiContent,
53-
},
54-
];
31+
private readonly redisService: RedisService,
32+
) {
33+
this.redis = redisService.getOrThrow('general');
34+
}
5535

56-
const requestData = {
57-
messages,
58-
topP: 0.8,
59-
topK: 0,
60-
maxTokens: 2272,
61-
temperature: 0.06,
62-
repeatPenalty: 5.0,
63-
stopBefore: [],
64-
includeAiFilters: false,
65-
seed: 0,
66-
};
36+
async requestOpenAi(aiDto: AiDto) {
37+
try {
38+
const aiCount = await this.redis.hget(aiDto.connectionId, 'aiCount');
39+
if (Number(aiCount) <= 0) {
40+
this.publisherService.publish('api-socket', {
41+
event: 'textAiSocket',
42+
data: { error: 'AI 사용 횟수가 모두 소진되었습니다.', connectionId: aiDto.connectionId },
43+
});
44+
return;
45+
}
46+
const apiKey = this.configService.get('OPENAI_API_KEY');
47+
const openai = new OpenAI(apiKey);
6748

68-
const response = await firstValueFrom(this.httpService.post(URL, requestData, { headers }));
49+
const openAiRequestDto = new OpenAiRequestDto();
50+
openAiRequestDto.setPrompt(OPENAI_PROMPT);
51+
openAiRequestDto.setAiContent(aiDto.aiContent);
6952

70-
let result: string = response.data.result.message.content;
53+
const response = await openai.chat.completions.create(openAiRequestDto.toObject());
7154

72-
if (result[result.length - 1] !== '}') {
73-
result = result + '}';
55+
const result = JSON.parse(response.choices[0].message.content) as TextAiResponse;
56+
console.log(result);
57+
const nodeData = await this.nodeService.aiCreateNode(result, aiDto.mindmapId);
58+
this.publisherService.publish('api-socket', {
59+
event: 'textAiSocket',
60+
data: { nodeData, connectionId: aiDto.connectionId },
61+
});
62+
} catch (error) {
63+
this.logger.error('OPENAI 요청 에러 : ' + error);
64+
this.publisherService.publish('api-socket', {
65+
event: 'textAiSocket',
66+
data: { error: '텍스트 변환 요청에 실패했습니다.', connectionId: aiDto.connectionId },
67+
});
7468
}
69+
}
7570

76-
const resultJson = JSON.parse(result) as TextAiResponse;
77-
const nodeData = await this.nodeService.aiCreateNode(resultJson, Number(data.mindmapId));
71+
async requestClovaSpeech(audioFile: Express.Multer.File, audioUploadDto: AudioUploadDto) {
72+
try {
73+
const URL = this.configService.get('CLOVA_SPEECH_URL');
74+
const apiKey = this.configService.get('X_CLOVASPEECH_API_KEY');
75+
const formData = new ClovaSpeechRequestDto(apiKey, audioFile);
76+
const response = await firstValueFrom(
77+
this.httpService.post(URL, formData.getFormData(), { headers: formData.getHeaders() }),
78+
);
79+
const result = response.data.text;
7880

79-
this.publisherService.publish(
80-
'api-socket',
81-
JSON.stringify({ event: 'textAiSocket', data: { nodeData, connectionId: data.connectionId } }),
82-
);
81+
const aiDto = plainToInstance(AiDto, {
82+
aiContent: result,
83+
connectionId: audioUploadDto.connectionId,
84+
mindmapId: audioUploadDto.mindmapId,
85+
});
86+
await this.requestOpenAi(aiDto);
87+
} catch (error) {
88+
this.logger.error('CLOVA-SPEECH 요청 에러 : ' + error);
89+
this.publisherService.publish('api-socket', {
90+
event: 'textAiSocket',
91+
data: { error: '음성 변환 요청에 실패했습니다.', connectionId: audioUploadDto.connectionId },
92+
});
93+
throw new BadRequestException('음성 변환 요청에 실패했습니다.');
94+
}
8395
}
8496
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { IsNumber, IsString } from 'class-validator';
2+
3+
export class AiDto {
4+
@IsNumber()
5+
mindmapId: number;
6+
7+
@IsString()
8+
connectionId: string;
9+
10+
@IsString()
11+
aiContent: string;
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Type } from 'class-transformer';
2+
import { IsNumber, IsString } from 'class-validator';
3+
4+
export class AudioUploadDto {
5+
@IsNumber()
6+
@Type(() => Number)
7+
mindmapId: number;
8+
9+
@IsString()
10+
connectionId: string;
11+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export class ClovaSpeechRequestDto {
2+
private formData: FormData;
3+
private headers = {
4+
'X-CLOVASPEECH-API-KEY': '',
5+
'Content-Type': 'multipart/form-data',
6+
};
7+
private params = {
8+
completion: 'sync',
9+
diarization: { enable: false },
10+
language: 'ko-KR',
11+
};
12+
13+
constructor(apiKey: string, audioFile: Express.Multer.File) {
14+
this.headers['X-CLOVASPEECH-API-KEY'] = apiKey;
15+
16+
const blob = new Blob([audioFile.buffer], { type: audioFile.mimetype });
17+
this.formData = new FormData();
18+
this.formData.append('media', blob);
19+
this.formData.append('params', JSON.stringify(this.params));
20+
}
21+
22+
getFormData() {
23+
return this.formData;
24+
}
25+
26+
getHeaders() {
27+
return this.headers;
28+
}
29+
}

0 commit comments

Comments
 (0)