Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 8bf87b9

Browse files
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-
letresult: string=response.data.result.message.content;
53+
constresponse=awaitopenai.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)

AltStyle によって変換されたページ (->オリジナル) /