Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AI 분류 시 PostKeyword 데이터도 같이 생성 및 적용 #60

Merged
merged 21 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
31582b3
chore: apply auto organize
Marades Jul 17, 2024
4a8cdce
chore: add discord alarm
Marades Jul 17, 2024
6ca291e
chore: add createdAt to post list
Marades Jul 17, 2024
979b5bb
feat: Add post keywords by ai classification
Marades Jul 17, 2024
62dcd91
feat: discord 알림 추가
Marades Jul 20, 2024
2fb40c1
feat: 커스텀 폴더 및 post 초기화
Marades Jul 20, 2024
442fde6
feat: AI 요약 시 추출 keyword 저장 및 리팩토링
Marades Jul 20, 2024
48ee0f1
feat: post 조회 시 keyword 도 같이 반환
Marades Jul 20, 2024
1395eb1
feat: post 리턴 시 keyword붙이기 편하게 리팩토링
Marades Jul 20, 2024
409f934
feat: 폴더 내 링크 목록 정렬 적용
Marades Jul 20, 2024
6a6b692
chore: 로컬에서 에러 알림 제외
Marades Jul 20, 2024
0f8f169
chore: 로컬에서 디코 알림 제외
Marades Jul 20, 2024
f96f2e6
Merge branch 'develop' into feat/apply-post-keywords
Marades Jul 21, 2024
96fe6ed
chore: 폴더 리스폰스에 키워드 추가 작업 최신 브랜치 작업본에 반영
Marades Jul 21, 2024
b270fcd
feat: 폴더 삭제 시 post 삭제 로직 추가
JonghunAn Jul 22, 2024
6d38e33
refactor: 커스텀 폴더 전체 삭제 시 folderIdList 반환으로 변경
JonghunAn Jul 22, 2024
eda33ec
refactor: classification service export
JonghunAn Jul 22, 2024
b3a2a3b
refactor: classification module forwardRef
JonghunAn Jul 22, 2024
12d066c
feat: add delete classificationList at suggestedFolder delete
JonghunAn Jul 22, 2024
130b4af
feat: soft-delete classification rows
JonghunAn Jul 22, 2024
a21719d
Merge branch 'develop' into feat/apply-post-keywords
Marades Jul 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
85 changes: 25 additions & 60 deletions src/ai_handler.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@src/app.module';
import { AiService } from '@src/infrastructure/ai/ai.service';
import { PostAiStatus } from '@src/modules/posts/posts.constant';
import { Handler } from 'aws-lambda';
import { LambdaEventPayload } from './infrastructure/aws-lambda/type';
import { ClassficiationRepository } from './modules/classification/classification.repository';
import { MetricsRepository } from './modules/metrics/metrics.repository';
import { PostsRepository } from './modules/posts/posts.repository';
import { DatabaseModule } from './infrastructure';
import { AiClassificationPayload } from './infrastructure/aws-lambda/type';
import { AiClassificationModule } from './modules/ai-classification/ai-classification.module';
import { AiClassificationService } from './modules/ai-classification/ai-classification.service';

export const handler: Handler = async (event: LambdaEventPayload) => {
const app = await NestFactory.create(AppModule);
const aiService = app.get(AiService);
const classificationRepository = app.get(ClassficiationRepository);
const postRepository = app.get(PostsRepository);
const metricsRepository = app.get(MetricsRepository);
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: `.env.${process.env.NODE_ENV || 'local'}`,
}),
DatabaseModule,
AiClassificationModule,
],
providers: [AiClassificationService],
})
class WorkerModule {}

// Map - (Folder Name):(Folder ID)
const folderMapper = {};
// Build foldeMapper
event.folderList.forEach((folder) => {
folderMapper[folder.name] = folder.id;
});

// NOTE: AI 요약 요청
const start = process.hrtime();
const summarizeUrlContent = await aiService.summarizeLinkContent(
event.postContent,
Object.keys(folderMapper), // 중복성 줄이기 위해
);
const end = process.hrtime(start);
const timeSecond = end[0] + end[1] / 1e9;

const postId = event.postId;
let classificationId = null;
let postAiStatus = PostAiStatus.FAIL;
export const handler: Handler = async (event: AiClassificationPayload) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아용~~!!

const app = await NestFactory.create(WorkerModule);
const aiClassificationService = app.get(AiClassificationService);

// NOTE : 요약 성공 시 classification 생성, post 업데이트
if (summarizeUrlContent.success) {
const folderId = folderMapper[summarizeUrlContent.response.category];
const post = await postRepository.findPostByIdForAIClassification(postId);
const classification = await classificationRepository.createClassification(
post.url,
summarizeUrlContent.response.summary,
summarizeUrlContent.response.keywords,
folderId,
);
// Save metrics
await metricsRepository.createMetrics(
summarizeUrlContent.success,
timeSecond,
post.url,
post._id.toString(),
);
classificationId = classification._id.toString();
postAiStatus = PostAiStatus.SUCCESS;
}
await postRepository.updatePostClassificationForAIClassification(
postAiStatus,
postId,
classificationId,
summarizeUrlContent.response.summary,
);
const result = await aiClassificationService.execute(event);

// NOTE: cloud-watch 로그 확인용
console.log({
result: summarizeUrlContent.success ? 'success' : 'fail',
result: result.success ? 'success' : 'fail',
event: JSON.stringify(event, null, 2),
summarizeUrlContent: summarizeUrlContent,
summarizeUrlContent: result,
});
};
10 changes: 8 additions & 2 deletions src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import {
CommonResponseInterceptor,
createErrorObject,
RootExceptionFilter,
createErrorObject,
} from './common';
import { DiscordErrorWebhookProvider } from './infrastructure/discord/discord-error-webhook.provider';

export async function nestAppConfig<
T extends INestApplication = INestApplication,
Expand Down Expand Up @@ -51,7 +53,11 @@ export function nestResponseConfig<
function configFilterStandAlone<T extends INestApplication = INestApplication>(
app: T,
) {
app.useGlobalFilters(new RootExceptionFilter());
app.useGlobalFilters(
new RootExceptionFilter(
new DiscordErrorWebhookProvider(new ConfigService()),
),
);
}

// Enalbe Exception Filter with Sentry Connection
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AiModule } from './infrastructure/ai/ai.module';
import { AwsLambdaModule } from './infrastructure/aws-lambda/aws-lambda.module';
import { DiscordModule } from './infrastructure/discord/discord.module';
import { AuthModule } from './modules/auth/auth.module';
import { ClassificationModule } from './modules/classification/classification.module';
import { FoldersModule } from './modules/folders/folders.module';
Expand All @@ -25,6 +26,7 @@ import { UsersModule } from './modules/users/users.module';
envFilePath: `.env.${process.env.NODE_ENV || 'local'}`,
}),
DatabaseModule,
DiscordModule,
AiModule,
UsersModule,
ClassificationModule,
Expand Down
1 change: 1 addition & 0 deletions src/common/constant/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const IS_LOCAL = process.env.NODE_ENV === 'local';
33 changes: 32 additions & 1 deletion src/common/filter/base.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
HttpException,
} from '@nestjs/common';
import { captureException } from '@sentry/node';
import { DiscordErrorWebhookProvider } from '@src/infrastructure/discord/discord-error-webhook.provider';
import { Response } from 'express';
import { RootException, createException } from '../error';
import { ExceptionPayload, ICommonResponse } from '../types/type';
Expand All @@ -13,9 +14,16 @@ import { ExceptionPayload, ICommonResponse } from '../types/type';
export class RootExceptionFilter implements ExceptionFilter {
private unknownCode = 'Unknown';

catch(exception: any, host: ArgumentsHost) {
constructor(
private readonly discordErrorWebhookProvider: DiscordErrorWebhookProvider,
) {}

async catch(exception: any, host: ArgumentsHost) {
const context = host.switchToHttp();
const request = context.getRequest<Request>();
const response: Response = context.getResponse<Response>();
console.log(exception);

let targetException = exception;
let responseStatusCode = 500;
let responseErrorPayload: ExceptionPayload = {
Expand Down Expand Up @@ -62,6 +70,29 @@ export class RootExceptionFilter implements ExceptionFilter {
error: responseErrorPayload,
};

await this.handle(request, exception);

return response.status(responseStatusCode).json(exceptionResponse);
}

private async handle(request: Request, error: Error) {
const content = this.parseError(request, error);
await this.discordErrorWebhookProvider.send(content);
}

private parseError(request: Request, error: Error): string {
return `노드팀 채찍 맞아라~~ 🦹🏿‍♀️👹🦹🏿
에러 발생 API : ${request.method} ${request.url}

에러 메세지 : ${error.message}

에러 위치 : ${error.stack
.split('\n')
.slice(0, 2)
.map((message) => message.trim())
.join('\n')}

당장 고쳐서 올렷!
`;
}
}
9 changes: 0 additions & 9 deletions src/domains/folder.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/infrastructure/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { DatabaseModule } from '@src/infrastructure';

@Module({
imports: [DatabaseModule],
imports: [],
providers: [AiService],
exports: [AiService],
})
Expand Down
43 changes: 40 additions & 3 deletions src/infrastructure/ai/ai.service.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI, { OpenAIError, RateLimitError } from 'openai';
import { DiscordAIWebhookProvider } from '../discord/discord-ai-webhook.provider';
import { gptVersion } from './ai.constant';
import { SummarizeURLContentDto } from './dto';
import { summarizeURLContentFunctionFactory } from './functions';
import {
AiClassificationFunctionResult,
summarizeURLContentFunctionFactory,
} from './functions';
import { SummarizeURLContent } from './types/types';

@Injectable()
export class AiService {
private openai: OpenAI;

constructor(private readonly config: ConfigService) {
constructor(
private readonly config: ConfigService,
private readonly discordAIWebhookProvider: DiscordAIWebhookProvider,
) {
this.openai = new OpenAI({
apiKey: config.get<string>('OPENAI_API_KEY'),
apiKey: this.config.get<string>('OPENAI_API_KEY'),
});
}

async summarizeLinkContent(
content: string,
userFolderList: string[],
url: string,
temperature = 0.5,
): Promise<SummarizeURLContentDto> {
try {
Expand All @@ -28,8 +36,10 @@ export class AiService {
const promptResult = await this.invokeAISummary(
content,
folderLists,
url,
temperature,
);

// Function Call 결과
const summaryResult: SummarizeURLContent = JSON.parse(
promptResult.choices[0].message.tool_calls[0].function.arguments,
Expand Down Expand Up @@ -59,8 +69,11 @@ export class AiService {
private async invokeAISummary(
content: string,
folderList: string[],
url: string,
temperature: number,
) {
let elapsedTime: number = 0;
const startTime = new Date();
const promptResult = await this.openai.chat.completions.create(
{
model: gptVersion,
Expand Down Expand Up @@ -93,6 +106,30 @@ ${content}
maxRetries: 5,
},
);

elapsedTime = new Date().getTime() - startTime.getTime();

const functionResult: AiClassificationFunctionResult = JSON.parse(
promptResult.choices[0].message.tool_calls[0].function.arguments,
);

await this.discordAIWebhookProvider.send(
[
`**AI 요약 실행 시간: ${elapsedTime}ms**`,
`**Input**`,
`- URL : ${url}`,
`- 인풋 폴더 : [${folderList.join(', ')}]`,
`**Output**`,
`- 사용 모델 : ${promptResult.model}`,
`- 요약 : ${functionResult.summary}`,
`- 추출 키워드 : ${functionResult.keywords.join(', ')}`,
`- 매칭 폴더명 : ${functionResult.category}`,
`- Input Token : ${promptResult.usage.prompt_tokens}`,
`- Output Token : ${promptResult.usage.completion_tokens}`,
`- Total Token : ${promptResult.usage.total_tokens}`,
].join('\n'),
);

return promptResult;
}
}
6 changes: 6 additions & 0 deletions src/infrastructure/ai/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export type AiClassificationFunctionResult = {
summary: string;
keywords: string[];
category: string;
};

export function summarizeURLContentFunctionFactory(folderList: string[]) {
return {
name: 'summarizeURL',
Expand Down
6 changes: 3 additions & 3 deletions src/infrastructure/aws-lambda/aws-lambda.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LambdaEventPayload } from './type';
import { AiClassificationPayload } from './type';

@Injectable()
export class AwsLambdaService {
Expand All @@ -16,7 +16,7 @@ export class AwsLambdaService {

async invokeLambda(
lambdaFunctionName: string,
payload: LambdaEventPayload,
payload: AiClassificationPayload,
): Promise<void> {
const command = new InvokeCommand({
FunctionName: lambdaFunctionName,
Expand Down
4 changes: 3 additions & 1 deletion src/infrastructure/aws-lambda/type.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type LambdaEventPayload = {
export type AiClassificationPayload = {
postContent: string;
folderList: { id: string; name: string }[];
userId: string;
postId: string;
url: string;
};
17 changes: 17 additions & 0 deletions src/infrastructure/discord/discord-ai-webhook.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DiscordWebhookProvider } from './discord-webhook.provider';

@Injectable()
export class DiscordAIWebhookProvider extends DiscordWebhookProvider {
protected readonly webhookUrl: string;

constructor(private readonly configService: ConfigService) {
super();
this.webhookUrl = this.configService.get('DISCORD_AI_WEBHOOK_URL');
}

public async send(content: string) {
await super.send(this.webhookUrl, content);
}
}
17 changes: 17 additions & 0 deletions src/infrastructure/discord/discord-error-webhook.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DiscordWebhookProvider } from './discord-webhook.provider';

@Injectable()
export class DiscordErrorWebhookProvider extends DiscordWebhookProvider {
protected webhookUrl: string;

constructor(private readonly configService: ConfigService) {
super();
this.webhookUrl = this.configService.get('DISCORD_ERROR_WEBHOOK_URL');
}

public async send(content: string) {
await super.send(this.webhookUrl, content);
}
}
Loading