From 9a169fd12f8d10e9ad707a03bdfd79cef6d5fbcc Mon Sep 17 00:00:00 2001 From: stae1102 Date: Sat, 8 Feb 2025 16:25:35 +0900 Subject: [PATCH 1/4] chore: add groq sdk --- package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 30 insertions(+) diff --git a/package-lock.json b/package-lock.json index f3ba1fd..6d28c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "cross-env": "^7.0.3", "crypto-js": "^4.1.1", "form-data": "^4.0.0", + "groq-sdk": "^0.15.0", "ioredis": "^5.3.2", "joi": "^17.6.0", "openai": "^4.80.0", @@ -7601,6 +7602,20 @@ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "dev": true }, + "node_modules/groq-sdk": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.15.0.tgz", + "integrity": "sha512-aYDEdr4qczx3cLCRRe+Beb37I7g/9bD5kHF+EEDxcrREWw1vKoRcfP3vHEkJB7Ud/8oOuF0scRwDpwWostTWuQ==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -19099,6 +19114,20 @@ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "dev": true }, + "groq-sdk": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.15.0.tgz", + "integrity": "sha512-aYDEdr4qczx3cLCRRe+Beb37I7g/9bD5kHF+EEDxcrREWw1vKoRcfP3vHEkJB7Ud/8oOuF0scRwDpwWostTWuQ==", + "requires": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", diff --git a/package.json b/package.json index 8dd65d1..d005428 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "cross-env": "^7.0.3", "crypto-js": "^4.1.1", "form-data": "^4.0.0", + "groq-sdk": "^0.15.0", "ioredis": "^5.3.2", "joi": "^17.6.0", "openai": "^4.80.0", From b047d8b1752f089eea017c28aab720d476c3ae9f Mon Sep 17 00:00:00 2001 From: stae1102 Date: Sat, 8 Feb 2025 16:26:30 +0900 Subject: [PATCH 2/4] feat: add groq service --- src/ai/ai.service.ts | 10 +++++ src/ai/groq/groq.service.ts | 37 +++++++++++++++++++ src/ai/openai.module.ts | 9 +++++ .../openai/dto/create-completion.dto.ts | 0 src/{ => ai}/openai/openai.service.spec.ts | 0 src/{ => ai}/openai/openai.service.ts | 37 ++++++++++++------- src/app.module.ts | 3 +- src/categories/category.module.ts | 4 +- src/categories/category.service.ts | 17 ++++----- src/contents/contents.module.ts | 4 +- src/openai/openai.module.ts | 8 ---- 11 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 src/ai/ai.service.ts create mode 100644 src/ai/groq/groq.service.ts create mode 100644 src/ai/openai.module.ts rename src/{ => ai}/openai/dto/create-completion.dto.ts (100%) rename src/{ => ai}/openai/openai.service.spec.ts (100%) rename src/{ => ai}/openai/openai.service.ts (51%) delete mode 100644 src/openai/openai.module.ts diff --git a/src/ai/ai.service.ts b/src/ai/ai.service.ts new file mode 100644 index 0000000..fc908f8 --- /dev/null +++ b/src/ai/ai.service.ts @@ -0,0 +1,10 @@ +export interface AiService { + chat(chatRequest: { + messages: any[]; + model: string; + temperature: number; + responseType: string; + }): Promise; +} + +export const AiService = Symbol('AiService'); diff --git a/src/ai/groq/groq.service.ts b/src/ai/groq/groq.service.ts new file mode 100644 index 0000000..0a29409 --- /dev/null +++ b/src/ai/groq/groq.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Groq from 'groq-sdk'; +import { AiService } from '../ai.service'; + +@Injectable() +export class GroqService implements AiService { + private readonly groq: Groq; + + constructor(private readonly configService: ConfigService) { + this.groq = new Groq({ + apiKey: this.configService.get('GROQ_API_KEY'), + }); + } + async chat({ + messages, + model, + temperature, + responseType, + }: { + messages: any[]; + model: string; + temperature: number; + responseType: string; + }): Promise { + const response = await this.groq.chat.completions.create({ + messages, + model, + temperature, + response_format: { + type: responseType as 'text' | 'json_object' | undefined, + }, + }); + + return response.choices[0].message.content; + } +} diff --git a/src/ai/openai.module.ts b/src/ai/openai.module.ts new file mode 100644 index 0000000..17761df --- /dev/null +++ b/src/ai/openai.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GroqService } from './groq/groq.service'; +import { AiService } from './ai.service'; + +@Module({ + providers: [{ provide: AiService, useClass: GroqService }], + exports: [{ provide: AiService, useClass: GroqService }], +}) +export class AiModule {} diff --git a/src/openai/dto/create-completion.dto.ts b/src/ai/openai/dto/create-completion.dto.ts similarity index 100% rename from src/openai/dto/create-completion.dto.ts rename to src/ai/openai/dto/create-completion.dto.ts diff --git a/src/openai/openai.service.spec.ts b/src/ai/openai/openai.service.spec.ts similarity index 100% rename from src/openai/openai.service.spec.ts rename to src/ai/openai/openai.service.spec.ts diff --git a/src/openai/openai.service.ts b/src/ai/openai/openai.service.ts similarity index 51% rename from src/openai/openai.service.ts rename to src/ai/openai/openai.service.ts index 739522a..1d0570a 100644 --- a/src/openai/openai.service.ts +++ b/src/ai/openai/openai.service.ts @@ -1,12 +1,16 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { CreateCompletionBodyDto } from './dto/create-completion.dto'; import OpenAI from 'openai'; -import { ChatCompletion } from 'openai/resources'; +import { + ResponseFormatJSONObject, + ResponseFormatJSONSchema, + ResponseFormatText, +} from 'openai/resources'; +import { AiService } from '../ai.service'; @Injectable() -export class OpenaiService { +export class OpenaiService implements AiService { private readonly openAIApi: OpenAI; constructor(private readonly configService: ConfigService) { this.openAIApi = new OpenAI({ @@ -15,26 +19,31 @@ export class OpenaiService { }); } - async createChatCompletion({ - question, + async chat({ + messages, model, temperature, responseType, - }: CreateCompletionBodyDto): Promise { + }: { + messages: any[]; + model: string; + temperature: number; + responseType: string; + }): Promise { try { const response = await this.openAIApi.chat.completions.create({ model: model || 'gpt-4o-mini', - messages: [ - { - role: 'user', - content: question, - }, - ], + messages, temperature: temperature || 0.1, - ...(responseType && { response_format: responseType }), + ...(responseType && { + response_format: responseType as unknown as + | ResponseFormatText + | ResponseFormatJSONObject + | ResponseFormatJSONSchema, + }), }); - return response; + return response.choices[0].message.content; } catch (e) { throw e; } diff --git a/src/app.module.ts b/src/app.module.ts index e95e8e9..fce6bb8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,7 +12,7 @@ import { CollectionsModule } from './collections/collections.module'; import { BatchModule } from './batch/batch.module'; import { SummaryModule } from './summary/summary.module'; import { TypeOrmConfigService } from './database/typerom-config.service'; -import { OpenaiModule } from './openai/openai.module'; +import { AiModule } from './ai/openai.module'; import { AppController } from './app.controller'; import { AopModule } from './common/aop/aop.module'; import { InfraModule } from './infra/infra.module'; @@ -106,7 +106,6 @@ import { InfraModule } from './infra/infra.module'; ? process.env.NAVER_CLOVA_SUMMARY_REQUEST_URL : '', }), - OpenaiModule, AopModule, InfraModule, ], diff --git a/src/categories/category.module.ts b/src/categories/category.module.ts index c1c173e..30369d9 100644 --- a/src/categories/category.module.ts +++ b/src/categories/category.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { ContentsModule } from '../contents/contents.module'; import { CategoryService } from './category.service'; -import { OpenaiModule } from '../openai/openai.module'; +import { AiModule } from '../ai/openai.module'; import { UsersModule } from '../users/users.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Category } from './category.entity'; @@ -14,7 +14,7 @@ import { CategoryV2Controller } from './v2/category.v2.controller'; imports: [ TypeOrmModule.forFeature([Category]), ContentsModule, - OpenaiModule, + AiModule, UsersModule, ], controllers: [CategoryController, CategoryV2Controller], diff --git a/src/categories/category.service.ts b/src/categories/category.service.ts index f18d365..3b466cf 100644 --- a/src/categories/category.service.ts +++ b/src/categories/category.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ConflictException, InternalServerErrorException, + Inject, } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { @@ -13,8 +14,6 @@ import { DeleteCategoryOutput, RecentCategoryList, RecentCategoryListWithSaveCount, - AutoCategorizeOutput, - AutoCategorizeBodyDto, } from './dtos/category.dto'; import { LoadPersonalCategoriesOutput, @@ -25,7 +24,6 @@ import { Content } from '../contents/entities/content.entity'; import { CategoryRepository } from './category.repository'; import { ContentRepository } from '../contents/repository/content.repository'; import { getLinkContent, getLinkInfo } from '../contents/util/content.util'; -import { OpenaiService } from '../openai/openai.service'; import { User } from '../users/entities/user.entity'; import { UserRepository } from '../users/repository/user.repository'; import { @@ -35,6 +33,7 @@ import { makeCategoryListWithSaveCount, } from './utils/category.util'; import { Transactional } from '../common/aop/transactional'; +import { AiService } from '../ai/ai.service'; @Injectable() export class CategoryService { @@ -42,7 +41,7 @@ export class CategoryService { private readonly contentRepository: ContentRepository, private readonly categoryRepository: CategoryRepository, private readonly userRepository: UserRepository, - private readonly openaiService: OpenaiService, + @Inject(AiService) private readonly aiService: AiService, ) {} @Transactional() @@ -476,15 +475,13 @@ Present your reply options in JSON format below. `; try { - const response = await this.openaiService.createChatCompletion({ - model: 'gpt-4o-mini', - question, + const categoryStr = await this.aiService.chat({ + model: 'llama3-8b-8192', + messages: [{ role: 'user', content: question }], temperature: 0, - responseType: { type: 'json_object' }, + responseType: 'json_object', }); - const categoryStr = response.choices[0].message?.content; - if (categoryStr) { const { id, name } = JSON.parse( categoryStr.replace(/^```json|```$/g, '').trim(), diff --git a/src/contents/contents.module.ts b/src/contents/contents.module.ts index 71e5f5e..9d0bc13 100644 --- a/src/contents/contents.module.ts +++ b/src/contents/contents.module.ts @@ -6,10 +6,10 @@ import { Content } from './entities/content.entity'; import { ContentRepository } from './repository/content.repository'; import { CategoryRepository } from '../categories/category.repository'; import { UsersModule } from '../users/users.module'; -import { OpenaiModule } from '../openai/openai.module'; +import { AiModule } from '../ai/openai.module'; @Module({ - imports: [TypeOrmModule.forFeature([Content]), UsersModule, OpenaiModule], + imports: [TypeOrmModule.forFeature([Content]), UsersModule, AiModule], controllers: [ContentsController], providers: [ContentsService, ContentRepository, CategoryRepository], exports: [ContentsService], diff --git a/src/openai/openai.module.ts b/src/openai/openai.module.ts deleted file mode 100644 index a18b9dc..0000000 --- a/src/openai/openai.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { OpenaiService } from './openai.service'; - -@Module({ - providers: [OpenaiService], - exports: [OpenaiService], -}) -export class OpenaiModule {} From 2c2544ee29eb7f2a10a515a3a8b76b4c87a8aecb Mon Sep 17 00:00:00 2001 From: stae1102 Date: Sun, 9 Feb 2025 10:20:09 +0900 Subject: [PATCH 3/4] feat: filter user by email --- src/auth/oauth.controller.ts | 9 ---- src/auth/oauth.service.ts | 63 +++++++++---------------- src/categories/category.service.ts | 7 +-- src/contents/util/content.util.ts | 1 - src/users/repository/user.repository.ts | 8 ++++ 5 files changed, 35 insertions(+), 53 deletions(-) diff --git a/src/auth/oauth.controller.ts b/src/auth/oauth.controller.ts index 4507cca..855f3e1 100644 --- a/src/auth/oauth.controller.ts +++ b/src/auth/oauth.controller.ts @@ -130,13 +130,4 @@ export class OAuthController { async appleLogin(@Query('code') code: string): Promise { return this.oauthService.appleLogin(code); } - - @ApiOperation({ - summary: '카카오 로그인 요청', - description: 'accessToken을 받아 카카오 로그인을 요청합니다.', - }) - @Post('kakao') - async createOneWithKakao(@Body() kakaoLoginRequest: KakaoLoginRequest) { - return this.oauthService.createOneWithKakao(kakaoLoginRequest); - } } diff --git a/src/auth/oauth.service.ts b/src/auth/oauth.service.ts index 08ae704..09cb43f 100644 --- a/src/auth/oauth.service.ts +++ b/src/auth/oauth.service.ts @@ -34,9 +34,12 @@ export class OAuthService { ) {} // OAuth Login - async oauthLogin(email: string): Promise { + async oauthLogin(email: string, provider: PROVIDER): Promise { try { - const user: User = await this.userRepository.findOneByOrFail({ email }); + const user: User = await this.userRepository.findOneByOrFail({ + email, + provider, + }); if (user) { const payload: Payload = this.jwtService.createPayload( user.email, @@ -98,9 +101,12 @@ export class OAuthService { throw new BadRequestException('Please Agree to share your email'); } - const user = await this.userRepository.findOneByEmail(email); + const user = await this.userRepository.findOneByEmailAndProvider( + email, + PROVIDER.KAKAO, + ); if (user) { - return this.oauthLogin(user.email); + return this.oauthLogin(user.email, PROVIDER.KAKAO); } // 회원가입인 경우 기본 카테고리 생성 작업 진행 @@ -115,51 +121,25 @@ export class OAuthService { await this.userRepository.createOne(newUser); await this.categoryRepository.createDefaultCategories(newUser); - return this.oauthLogin(newUser.email); + return this.oauthLogin(newUser.email, PROVIDER.KAKAO); } catch (e) { throw e; } } - async createOneWithKakao({ authorizationToken }: KakaoLoginDto) { - const { userInfo } = - await this.oauthUtil.getKakaoUserInfo(authorizationToken); - - const email = userInfo.kakao_account.email; - if (!email) { - throw new BadRequestException('Please Agree to share your email'); - } - - const user = await this.userRepository.findOneByEmail(email); - - if (user) { - return this.oauthLogin(user.email); - } - - // 회원가입인 경우 기본 카테고리 생성 작업 진행 - const newUser = User.of({ - email, - name: userInfo.kakao_account.profile.nickname, - profileImage: userInfo.kakao_account.profile?.profile_image_url, - password: this.encodePasswordFromEmail(email, process.env.KAKAO_JS_KEY), - provider: PROVIDER.KAKAO, - }); - - await this.userRepository.createOne(newUser); - await this.categoryRepository.createDefaultCategories(newUser); - return this.oauthLogin(newUser.email); - } - // Login with Google account info async googleOauth({ email, name, picture, }: googleUserInfo): Promise { - const user = await this.userRepository.findOneByEmail(email); + const user = await this.userRepository.findOneByEmailAndProvider( + email, + PROVIDER.GOOGLE, + ); if (user) { - return this.oauthLogin(user.email); + return this.oauthLogin(user.email, PROVIDER.GOOGLE); } // 회원가입인 경우 기본 카테고리 생성 작업 진행 @@ -177,7 +157,7 @@ export class OAuthService { await this.userRepository.createOne(newUser); await this.categoryRepository.createDefaultCategories(newUser); - return this.oauthLogin(newUser.email); + return this.oauthLogin(newUser.email, PROVIDER.GOOGLE); } private encodePasswordFromEmail(email: string, key?: string): string { @@ -225,10 +205,13 @@ export class OAuthService { const { sub: id, email } = this.jwtService.decode(data.id_token); - const user = await this.userRepository.findOneByEmail(email); + const user = await this.userRepository.findOneByEmailAndProvider( + email, + PROVIDER.APPLE, + ); if (user) { - return this.oauthLogin(user.email); + return this.oauthLogin(user.email, PROVIDER.APPLE); } const newUser = User.of({ @@ -244,6 +227,6 @@ export class OAuthService { await this.userRepository.createOne(newUser); await this.categoryRepository.createDefaultCategories(newUser); - return this.oauthLogin(newUser.email); + return this.oauthLogin(newUser.email, PROVIDER.APPLE); } } diff --git a/src/categories/category.service.ts b/src/categories/category.service.ts index 3b466cf..2231b57 100644 --- a/src/categories/category.service.ts +++ b/src/categories/category.service.ts @@ -432,9 +432,10 @@ export class CategoryService { }); }); - const { title, siteName, description } = await getLinkInfo(encodeURI(link)); - - const content = await getLinkContent(link); + const [{ title, siteName, description }, content] = await Promise.all([ + getLinkInfo(encodeURI(link)), + getLinkContent(link), + ]); const question = `You are a machine tasked with auto-categorizing articles based on information obtained through web scraping. diff --git a/src/contents/util/content.util.ts b/src/contents/util/content.util.ts index 21b1b95..b1c2f91 100644 --- a/src/contents/util/content.util.ts +++ b/src/contents/util/content.util.ts @@ -26,7 +26,6 @@ class OGCrawler { this.userAgent = options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'; - this.maxRedirects = options.maxRedirects || 5; this.cookies = options.cookies || 'CONSENT=YES+cb; Path=/; Domain=.youtube.com'; this.proxy = options.proxy; diff --git a/src/users/repository/user.repository.ts b/src/users/repository/user.repository.ts index c2a508d..084bf9b 100644 --- a/src/users/repository/user.repository.ts +++ b/src/users/repository/user.repository.ts @@ -2,6 +2,7 @@ import { DataSource, Repository } from 'typeorm'; import { User } from '../entities/user.entity'; import { Injectable } from '@nestjs/common'; import { GetOrCreateAccountBodyDto } from '../dtos/get-or-create-account.dto'; +import { PROVIDER } from '../constant/provider.constant'; @Injectable() export class UserRepository extends Repository { @@ -44,6 +45,13 @@ export class UserRepository extends Repository { return this.findOne({ where: { email } }); } + async findOneByEmailAndProvider( + email: string, + provider: PROVIDER, + ): Promise { + return this.findOne({ where: { email, provider } }); + } + async createOne(user: Partial): Promise { return this.save(user); } From f96ded7ba282acebf3d6cd85955e255e873ab98c Mon Sep 17 00:00:00 2001 From: stae1102 Date: Sun, 9 Feb 2025 10:20:23 +0900 Subject: [PATCH 4/4] feat: change email constraint --- src/users/entities/user.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index de03870..be74208 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -30,7 +30,7 @@ export class User extends CoreEntity { name: string; @ApiProperty({ example: 'ex@g.com', description: 'User Email' }) - @Column({ unique: true }) + @Column({ type: 'varchar' }) @IsEmail() email: string;