From b33b26a28032282f3b93d940c1d2433dc34abd83 Mon Sep 17 00:00:00 2001 From: Seongtae Date: Mon, 29 Jan 2024 22:29:14 +0900 Subject: [PATCH] refactor: divide domain --- src/auth/auth.module.ts | 2 + src/auth/auth.service.ts | 2 +- src/auth/oauth.service.ts | 2 +- src/categories/category.controller.ts | 180 ++++++ .../category.entity.ts | 8 +- src/categories/category.module.ts | 31 + .../category.repository.ts | 6 +- src/categories/category.service.ts | 549 ++++++++++++++++++ .../dtos/category.dto.ts | 2 +- .../dtos/load-personal-categories.dto.ts | 4 +- src/collections/collections.module.ts | 2 +- src/collections/collections.service.ts | 2 +- src/collections/entities/collection.entity.ts | 2 +- src/contents/contents.controller.ts | 194 +------ src/contents/contents.module.ts | 25 +- src/contents/contents.service.spec.ts | 4 +- src/contents/contents.service.ts | 541 +---------------- src/contents/entities/content.entity.ts | 2 +- src/contents/util/category.util.ts | 4 +- src/database/typerom-config.service.ts | 2 +- src/test/test.controller.ts | 57 ++ src/test/test.module.ts | 8 + src/users/entities/user.entity.ts | 2 +- 23 files changed, 860 insertions(+), 771 deletions(-) create mode 100644 src/categories/category.controller.ts rename src/{contents/entities => categories}/category.entity.ts (84%) create mode 100644 src/categories/category.module.ts rename src/{contents/repository => categories}/category.repository.ts (95%) create mode 100644 src/categories/category.service.ts rename src/{contents => categories}/dtos/category.dto.ts (96%) rename src/{contents => categories}/dtos/load-personal-categories.dto.ts (80%) create mode 100644 src/test/test.controller.ts create mode 100644 src/test/test.module.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9a1a3d2..4b5662b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -13,6 +13,7 @@ import { UsersModule } from '../users/users.module'; import { OAuthUtil } from './util/oauth.util'; import { ContentsModule } from '../contents/contents.module'; import { OAuthService } from './oauth.service'; +import { CategoryModule } from '../categories/category.module'; const accessTokenExpiration = TWOHOUR; export const refreshTokenExpirationInCache = 60 * 60 * 24 * 365; // 1 year @@ -35,6 +36,7 @@ export const verifyEmailExpiration = 60 * 5; host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, }), + CategoryModule, ], controllers: [AuthController, OAuthController], providers: [ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 27e02f1..5db8160 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -32,7 +32,7 @@ import { KakaoAuthorizeOutput, LoginWithKakaoDto } from './dtos/kakao.dto'; import { googleUserInfo } from './dtos/google.dto'; import { customJwtService } from './jwt/jwt.service'; import { UserRepository } from '../users/repository/user.repository'; -import { CategoryRepository } from '../contents/repository/category.repository'; +import { CategoryRepository } from '../categories/category.repository'; import { OAuthUtil } from './util/oauth.util'; @Injectable() diff --git a/src/auth/oauth.service.ts b/src/auth/oauth.service.ts index 0daf9aa..19e064a 100644 --- a/src/auth/oauth.service.ts +++ b/src/auth/oauth.service.ts @@ -13,7 +13,7 @@ import { LoginOutput } from './dtos/login.dto'; import { Payload } from './jwt/jwt.payload'; import { customJwtService } from './jwt/jwt.service'; import { OAuthUtil } from './util/oauth.util'; -import { CategoryRepository } from '../contents/repository/category.repository'; +import { CategoryRepository } from '../categories/category.repository'; import { User } from '../users/entities/user.entity'; import { UserRepository } from '../users/repository/user.repository'; import * as CryptoJS from 'crypto-js'; diff --git a/src/categories/category.controller.ts b/src/categories/category.controller.ts new file mode 100644 index 0000000..0053fbe --- /dev/null +++ b/src/categories/category.controller.ts @@ -0,0 +1,180 @@ +import { + Controller, + UseGuards, + Post, + UseInterceptors, + Body, + Patch, + Delete, + Param, + ParseIntPipe, + Query, + ParseBoolPipe, + Get, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiCreatedResponse, + ApiConflictResponse, + ApiNotFoundResponse, + ApiOkResponse, +} from '@nestjs/swagger'; +import { EntityManager } from 'typeorm'; +import { AuthUser } from '../auth/auth-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt/jwt.guard'; +import { ErrorOutput } from '../common/dtos/output.dto'; +import { TransactionInterceptor } from '../common/interceptors/transaction.interceptor'; +import { TransactionManager } from '../common/transaction.decorator'; +import { + AddCategoryOutput, + AddCategoryBodyDto, + UpdateCategoryOutput, + UpdateCategoryBodyDto, + DeleteCategoryOutput, + AutoCategorizeOutput, +} from './dtos/category.dto'; +import { + LoadPersonalCategoriesOutput, + LoadFrequentCategoriesOutput, +} from './dtos/load-personal-categories.dto'; +import { User } from '../users/entities/user.entity'; +import { CategoryService } from './category.service'; + +@Controller('categories') +@ApiTags('Category') +@ApiBearerAuth('Authorization') +@UseGuards(JwtAuthGuard) +export class CategoryController { + constructor(private readonly categoryService: CategoryService) {} + + @ApiOperation({ + summary: '카테고리 추가', + description: '카테고리를 추가하는 메서드', + }) + @ApiCreatedResponse({ + description: '카테고리 추가 성공 여부를 반환한다.', + type: AddCategoryOutput, + }) + @ApiConflictResponse({ + description: '동일한 이름의 카테고리가 존재할 경우', + type: ErrorOutput, + }) + @ApiNotFoundResponse({ + description: '존재하지 않는 것일 경우', + type: ErrorOutput, + }) + @Post() + @UseInterceptors(TransactionInterceptor) + async addCategory( + @AuthUser() user: User, + @Body() addCategoryBody: AddCategoryBodyDto, + @TransactionManager() queryRunnerManager: EntityManager, + ): Promise { + return this.categoryService.addCategory( + user, + addCategoryBody, + queryRunnerManager, + ); + } + + @ApiOperation({ + summary: '카테고리 수정', + description: '카테고리 이름을 수정하는 메서드', + }) + @ApiCreatedResponse({ + description: '카테고리 수정 성공 여부를 반환한다.', + type: UpdateCategoryOutput, + }) + @Patch() + @UseInterceptors(TransactionInterceptor) + async updateCategory( + @AuthUser() user: User, + @Body() updateCategoryBody: UpdateCategoryBodyDto, + @TransactionManager() queryRunnerManager: EntityManager, + ): Promise { + return this.categoryService.updateCategory( + user, + updateCategoryBody, + queryRunnerManager, + ); + } + + @ApiOperation({ + summary: '카테고리 삭제', + description: '카테고리를 삭제하는 메서드', + }) + @ApiOkResponse({ + description: '카테고리 삭제 성공 여부를 반환한다.', + type: DeleteCategoryOutput, + }) + @ApiNotFoundResponse({ + description: '존재하지 않는 카테고리를 삭제하려고 할 경우', + type: ErrorOutput, + }) + @Delete(':categoryId') + @UseInterceptors(TransactionInterceptor) + async deleteCategory( + @AuthUser() user: User, + @Param('categoryId', new ParseIntPipe()) categoryId: number, + @Query('deleteContentFlag', new ParseBoolPipe()) deleteContentFlag: boolean, + @TransactionManager() queryRunnerManager: EntityManager, + ): Promise { + return this.categoryService.deleteCategory( + user, + categoryId, + deleteContentFlag, + queryRunnerManager, + ); + } + + @ApiOperation({ + summary: '자신의 카테고리 목록 조회', + description: '자신의 카테고리 목록을 조회하는 메서드', + }) + @ApiOkResponse({ + description: '카테고리 목록을 반환한다.', + type: LoadPersonalCategoriesOutput, + }) + @ApiBearerAuth('Authorization') + @UseGuards(JwtAuthGuard) + @Get() + async loadPersonalCategories( + @AuthUser() user: User, + ): Promise { + return this.categoryService.loadPersonalCategories(user); + } + + @ApiOperation({ + summary: '자주 저장한 카테고리 조회', + description: '자주 저장한 카테고리를 3개까지 조회하는 메서드', + }) + @ApiOkResponse({ + description: '자주 저장한 카테고리를 최대 3개까지 반환한다.', + type: LoadFrequentCategoriesOutput, + }) + @ApiBearerAuth('Authorization') + @UseGuards(JwtAuthGuard) + @Get('frequent') + async loadFrequentCategories( + @AuthUser() user: User, + ): Promise { + return this.categoryService.loadFrequentCategories(user); + } + + @ApiOperation({ + summary: '아티클 카테고리 자동 지정', + description: + '아티클에 적절한 카테고리를 유저의 카테고리 목록에서 찾는 메서드', + }) + @ApiBearerAuth('Authorization') + @UseGuards(JwtAuthGuard) + @Get('auto-categorize') + async autoCategorize( + @AuthUser() user: User, + @Query('link') link: string, + ): Promise { + return this.categoryService.autoCategorize(user, link); + } +} diff --git a/src/contents/entities/category.entity.ts b/src/categories/category.entity.ts similarity index 84% rename from src/contents/entities/category.entity.ts rename to src/categories/category.entity.ts index a532052..aa192bf 100644 --- a/src/contents/entities/category.entity.ts +++ b/src/categories/category.entity.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsString, Length } from 'class-validator'; import { Column, Entity, ManyToOne, OneToMany, RelationId } from 'typeorm'; -import { Content } from './content.entity'; -import { CoreEntity } from '../../common/entities/core.entity'; -import { Collection } from '../../collections/entities/collection.entity'; -import { User } from '../../users/entities/user.entity'; +import { Content } from '../contents/entities/content.entity'; +import { CoreEntity } from '../common/entities/core.entity'; +import { Collection } from '../collections/entities/collection.entity'; +import { User } from '../users/entities/user.entity'; export enum IconName { None = 'None', diff --git a/src/categories/category.module.ts b/src/categories/category.module.ts new file mode 100644 index 0000000..dc880e9 --- /dev/null +++ b/src/categories/category.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ContentsModule } from '../contents/contents.module'; +import { CategoryService } from './category.service'; +import { CategoryUtil } from '../contents/util/category.util'; +import { OpenaiModule } from '../openai/openai.module'; +import { UsersModule } from '../users/users.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Category } from './category.entity'; +import { CategoryController } from './category.controller'; +import { ContentRepository } from '../contents/repository/content.repository'; +import { CategoryRepository } from './category.repository'; +import { ContentUtil } from '../contents/util/content.util'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Category]), + ContentsModule, + OpenaiModule, + UsersModule, + ], + controllers: [CategoryController], + providers: [ + CategoryService, + CategoryUtil, + ContentRepository, + CategoryRepository, + ContentUtil, + ], + exports: [CategoryRepository], +}) +export class CategoryModule {} diff --git a/src/contents/repository/category.repository.ts b/src/categories/category.repository.ts similarity index 95% rename from src/contents/repository/category.repository.ts rename to src/categories/category.repository.ts index 7f9c3ac..6c77802 100644 --- a/src/contents/repository/category.repository.ts +++ b/src/categories/category.repository.ts @@ -4,9 +4,9 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Category } from '../entities/category.entity'; -import { CategoryUtil } from '../util/category.util'; -import { User } from '../../users/entities/user.entity'; +import { Category } from './category.entity'; +import { CategoryUtil } from '../contents/util/category.util'; +import { User } from '../users/entities/user.entity'; @Injectable() export class CategoryRepository extends Repository { diff --git a/src/categories/category.service.ts b/src/categories/category.service.ts new file mode 100644 index 0000000..ad2587c --- /dev/null +++ b/src/categories/category.service.ts @@ -0,0 +1,549 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import { + AddCategoryBodyDto, + AddCategoryOutput, + UpdateCategoryBodyDto, + UpdateCategoryOutput, + DeleteCategoryOutput, + RecentCategoryList, + RecentCategoryListWithSaveCount, + AutoCategorizeOutput, + AutoCategorizeBodyDto, +} from './dtos/category.dto'; +import { + LoadPersonalCategoriesOutput, + LoadFrequentCategoriesOutput, +} from './dtos/load-personal-categories.dto'; +import { Category } from './category.entity'; +import { Content } from '../contents/entities/content.entity'; +import { CategoryRepository } from './category.repository'; +import { ContentRepository } from '../contents/repository/content.repository'; +import { CategoryUtil } from '../contents/util/category.util'; +import { ContentUtil } 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'; + +@Injectable() +export class CategoryService { + constructor( + private readonly contentRepository: ContentRepository, + private readonly categoryRepository: CategoryRepository, + private readonly categoryUtil: CategoryUtil, + private readonly userRepository: UserRepository, + private readonly contentUtil: ContentUtil, + private readonly openaiService: OpenaiService, + ) {} + + async addCategory( + user: User, + { categoryName, iconName, parentId }: AddCategoryBodyDto, + queryRunnerManager: EntityManager, + ): Promise { + try { + const userInDb = await this.userRepository.findOneWithCategories(user.id); + + if (!userInDb) { + throw new NotFoundException('User not found'); + } + + const { categorySlug } = this.categoryUtil.generateSlug(categoryName); + + if (parentId) { + // category depth should be 3 + let currentParentId: number | undefined = parentId; + let parentCategory: Category | null; + for (let i = 0; i < 2; i++) { + parentCategory = await queryRunnerManager.findOne(Category, { + where: { id: currentParentId }, + }); + if (i == 1 && parentCategory?.parentId !== null) { + throw new ConflictException('Category depth should be 3'); + } + if (parentCategory?.parentId) + currentParentId = parentCategory?.parentId; + else break; + } + } else { + /** + * TODO: 유료 플랜 사용자이면 카테고리 개수 제한 없도록 추가 구성해야함. + */ + // if parentId is null, it means that category is root category + // root categories can't be more than 10 in one user + const isOverCategoryLimit = + await this.categoryRepository.isOverCategoryLimit(user); + if (isOverCategoryLimit) { + throw new ConflictException( + "Root categories can't be more than 10 in one user", + ); + } + } + + // check if category exists in user's categories(check if category name is duplicated in same level too) + const category = userInDb.categories?.find( + (category) => + category.slug === categorySlug && + (category.parentId === parentId || (!parentId && !category.parentId)), + ); + + // if category doesn't exist, create it + if (category) { + throw new ConflictException('Category already exists'); + } else { + // if parent category exists, get parent category + const parentCategory: Category | null = parentId + ? await queryRunnerManager.findOne(Category, { + where: { id: parentId }, + }) + : null; + // if parent category doesn't exist, throw error + if (!parentCategory && parentId) { + throw new NotFoundException('Parent category not found'); + } + + const category = await queryRunnerManager.save( + queryRunnerManager.create(Category, { + slug: categorySlug, + name: categoryName, + iconName, + parentId: parentCategory?.id, + user: userInDb, + }), + ); + + userInDb.categories?.push(category); + await queryRunnerManager.save(userInDb); + } + + return {}; + } catch (e) { + throw e; + } + } + + async updateCategory( + user: User, + { + categoryId, + name: categoryName, + iconName, + parentId, + }: UpdateCategoryBodyDto, + queryRunnerManager: EntityManager, + ): Promise { + try { + const userInDb = + await this.userRepository.findOneWithContentsAndCategories(user.id); + + // Check if user exists + if (!userInDb) { + throw new NotFoundException('User not found.'); + } + + const category = userInDb.categories?.find( + (category) => category.id === categoryId, + ); + + if (category) { + // Check if user has category with same slug + if (categoryName) { + const { categorySlug } = this.categoryUtil.generateSlug(categoryName); + if ( + userInDb.categories?.filter( + (category) => + category.slug === categorySlug && category.id !== categoryId, + )[0] + ) { + throw new NotFoundException( + 'Category with that name already exists in current user.', + ); + } + + // Update category + category.name = categoryName; + category.slug = categorySlug; + } + + if (iconName) { + category.iconName = iconName; + } + + if (parentId) { + // category depth should be 3 + let parentCategory = await this.categoryRepository.findOne({ + where: { id: parentId }, + }); + if (!parentCategory) { + throw new NotFoundException('Parent category not found.'); + } else if (parentCategory?.parentId !== null) { + parentCategory = await this.categoryRepository.findOne({ + where: { id: parentCategory.parentId }, + }); + if (parentCategory?.parentId !== null) { + throw new ConflictException('Category depth should be 3'); + } + } + + category.parentId = parentId; + } + + await queryRunnerManager.save(category); + } else { + throw new NotFoundException('Category not found.'); + } + + return {}; + } catch (e) { + throw e; + } + } + + async deleteCategory( + user: User, + categoryId: number, + deleteContentFlag: boolean, + queryRunnerManager: EntityManager, + ): Promise { + try { + const userInDb = + await this.userRepository.findOneWithContentsAndCategories(user.id); + + // Check if user exists + if (!userInDb) { + throw new NotFoundException('User not found.'); + } + + const category = userInDb.categories?.find( + (category) => category.id === categoryId, + ); + + if (!category) { + throw new NotFoundException('Category not found.'); + } + + /** + * 자식 카테고리가 있는 경우, 부모 카테고리와 연결 + * 단, 삭제되는 카테고리가 1단 카테고리인 경우 부모 카테고리가 없으므로 + * 자식 카테고리의 부모 카테고리를 null로 설정 + */ + + // find parent category + const parentCategory = category.parentId + ? await queryRunnerManager.findOneOrFail(Category, { + where: { id: category.parentId }, + }) + : undefined; + + // find children categories + const childrenCategories = await queryRunnerManager.find(Category, { + where: { parentId: categoryId }, + }); + + await queryRunnerManager.save( + childrenCategories.map((childrenCategory) => { + childrenCategory.parentId = parentCategory?.id ?? null; + return childrenCategory; + }), + ); + + /** + * delete content flag에 따른 분기처리 + */ + + // if deleteContentFlag is true, delete all contents in category + if (deleteContentFlag) { + await queryRunnerManager.delete(Content, { category }); + } + + // if deleteContentFlag is false, set all contents in category to parent category + else { + // find all contents in category with query builder + const contents = await this.contentRepository.findByCategoryId( + categoryId, + ); + + // set content's category to parent category + await queryRunnerManager.save( + contents.map((content) => { + content.category = parentCategory; + return content; + }), + ); + } + + await queryRunnerManager.delete(Category, { id: categoryId }); + + return {}; + } catch (e) { + throw e; + } + } + + async loadPersonalCategories( + user: User, + ): Promise { + try { + const { categories } = + await this.userRepository.findOneWithCategoriesOrFail(user.id); + + if (!categories) { + throw new NotFoundException('Categories not found.'); + } + + // make categories tree by parentid + const categoriesTree = + this.categoryUtil.generateCategoriesTree(categories); + + return { + categoriesTree, + }; + } catch (e) { + throw e; + } + } + + async loadFrequentCategories( + user: User, + ): Promise { + try { + // 로그 파일 내의 기록을 불러온다. + const recentCategoryList: RecentCategoryList[] = + this.categoryUtil.loadLogs(user.id); + + // 캐시 내의 카테고리 리스트를 최신 순으로 정렬하고, 동시에 저장된 횟수를 추가한다. + + let recentCategoriesWithSaveCount: RecentCategoryListWithSaveCount[] = []; + const frequentCategories: Category[] = []; + + // 3번째 카테고리까지 선정되거나, 더 이상 로그가 없을 때까지 매번 10개의 로그씩 확인한다. + let remainLogCount = recentCategoryList.length, + i = 0; + while (remainLogCount > 0) { + // 3개의 카테고리가 선정되었으면 루프를 종료한다. + if (frequentCategories.length >= 3) { + break; + } + + // 10개의 로그를 확인한다. + i += 10; + recentCategoriesWithSaveCount = + this.categoryUtil.makeCategoryListWithSaveCount( + recentCategoryList, + recentCategoriesWithSaveCount, + i, + ); + // 10개의 로그를 확인했으므로 남은 로그 수를 10개 감소시킨다. + remainLogCount -= 10; + + /* + * 10개의 로그를 확인하고, 만약 이전 호출에서 선정된 카테고리가 이번 호출에서도 선정되는 것을 방지하기위해 + * 이전 호출에서 선정된 카테고리를 제외한 카테고리 리스트를 만든다. + */ + recentCategoriesWithSaveCount = recentCategoriesWithSaveCount.filter( + (category) => + !frequentCategories.find( + (recentCategory) => recentCategory.id === category.categoryId, + ), + ); + + // 최근 저장 순 + const orderByDate: number[] = recentCategoriesWithSaveCount.map( + (category) => category.categoryId, + ); + // 저장된 횟수 순 + const orderBySaveCount: RecentCategoryListWithSaveCount[] = + recentCategoriesWithSaveCount.sort( + (a, b) => b.saveCount - a.saveCount, + ); + /* + * 2번째 카테고리까지 선정 기준 + * 1. 저장 횟수 순 + * 2. 저장 횟수 동일 시, 최근 저장 순 + */ + for (let i = 0; i < 2; i++) { + if (i < orderBySaveCount.length) { + const category = await this.categoryRepository.findOne({ + where: { id: orderBySaveCount[i].categoryId }, + }); + + // orderByDate에서 제거 + orderByDate.splice( + orderByDate.findIndex( + (categoryId) => categoryId === orderBySaveCount[i].categoryId, + ), + 1, + ); + + if (category) { + frequentCategories.push(category); + } + } + } + + /* + * 나머지 3-n 개 선정 기준 + * 1. 최근 저장 순 + */ + const N = 3 - frequentCategories.length; + for (let i = 0; i < N; i++) { + if (i < orderByDate.length) { + const category = await this.categoryRepository.findOne({ + where: { id: orderByDate[i] }, + }); + + if (category) { + frequentCategories.push(category); + } + } + } + } + + return { + frequentCategories, + }; + } catch (e) { + throw e; + } + } + + async autoCategorize( + user: User, + link: string, + ): Promise { + try { + const userInDb = await this.userRepository.findOneWithCategories(user.id); + if (!userInDb) { + throw new NotFoundException('User not found'); + } + + if (!userInDb.categories) { + throw new NotFoundException('Categories not found'); + } + const categories: string[] = []; + userInDb.categories.forEach((category) => { + if (!category.parentId) { + categories.push(category.name); + } + }); + const { title, siteName, description } = + await this.contentUtil.getLinkInfo(link); + + const content = await this.contentUtil.getLinkContent(link); + + let questionLines = [ + "You are a machine tasked with auto-categorizing articles based on information obtained through web scraping. You can only answer a single category name. Here is the article's information:", + ]; + + if (title) { + questionLines.push( + `The article in question is titled "${title.trim()}"`, + ); + } + + if (content) { + const contentLength = content.length / 2; + questionLines.push( + `The 150 characters of the article is, "${content + .replace(/\s/g, '') + .slice(contentLength - 150, contentLength + 150) + .trim()}"`, + ); + } + + if (description) { + questionLines.push(`The description is ${description.trim()}"`); + } + + if (siteName) { + questionLines.push(`The site's name is "${siteName.trim()}"`); + } + + // Add the category options to the end of the list + questionLines.push( + `Please provide the most suitable category among the following. Here is Category options: [${categories.join( + ', ', + )}, None]`, + ); + + // Join all lines together into a single string + const question = questionLines.join(' '); + console.log(question); + + const response = await this.openaiService.createChatCompletion({ + question, + }); + + return { category: response.choices[0].message?.content || 'None' }; + } catch (e) { + throw e; + } + } + + async autoCategorizeForTest( + autoCategorizeBody: AutoCategorizeBodyDto, + ): Promise { + try { + const { link, categories } = autoCategorizeBody; + const { title, siteName, description } = + await this.contentUtil.getLinkInfo(link); + + /** + * TODO: 본문 크롤링 개선 필요 + * 현재 p 태그만 크롤링하는데, 불필요한 내용이 포함되는 경우가 많음 + * 그러나 하나하나 예외 처리하는 방법을 제외하곤 방법을 못 찾은 상황 + */ + const content = await this.contentUtil.getLinkContent(link); + + let questionLines = [ + "You are a machine tasked with auto-categorizing articles based on information obtained through web scraping. You can only answer a single category name. Here is the article's information:", + ]; + + if (title) { + questionLines.push( + `The article in question is titled "${title.trim()}"`, + ); + } + + if (content) { + const contentLength = content.length / 2; + questionLines.push( + `The 150 characters of the article is, "${content + .replace(/\s/g, '') + .slice(contentLength - 150, contentLength + 150) + .trim()}"`, + ); + } + + if (description) { + questionLines.push(`The description is ${description.trim()}"`); + } + + if (siteName) { + questionLines.push(`The site's name is "${siteName.trim()}"`); + } + + // Add the category options to the end of the list + questionLines.push( + `Please provide the most suitable category among the following. Here is Category options: [${categories.join( + ', ', + )}, None]`, + ); + + // Join all lines together into a single string + const question = questionLines.join(' '); + + const response = await this.openaiService.createChatCompletion({ + question, + }); + + return { category: response.choices[0].message?.content || 'None' }; + } catch (e) { + throw e; + } + } +} diff --git a/src/contents/dtos/category.dto.ts b/src/categories/dtos/category.dto.ts similarity index 96% rename from src/contents/dtos/category.dto.ts rename to src/categories/dtos/category.dto.ts index 09fef2c..b17a39f 100644 --- a/src/contents/dtos/category.dto.ts +++ b/src/categories/dtos/category.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, PartialType, PickType } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import { CoreOutput } from '../../common/dtos/output.dto'; -import { Category, IconName } from '../entities/category.entity'; +import { Category, IconName } from '../category.entity'; export class AddCategoryBodyDto { @ApiProperty({ diff --git a/src/contents/dtos/load-personal-categories.dto.ts b/src/categories/dtos/load-personal-categories.dto.ts similarity index 80% rename from src/contents/dtos/load-personal-categories.dto.ts rename to src/categories/dtos/load-personal-categories.dto.ts index 2dbd725..d0ac178 100644 --- a/src/contents/dtos/load-personal-categories.dto.ts +++ b/src/categories/dtos/load-personal-categories.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { CoreOutput } from '../../common/dtos/output.dto'; -import { CategoryTreeNode } from '../../contents/dtos/category.dto'; -import { Category } from '../entities/category.entity'; +import { CategoryTreeNode } from './category.dto'; +import { Category } from '../category.entity'; export class LoadPersonalCategoriesOutput extends CoreOutput { @ApiProperty({ diff --git a/src/collections/collections.module.ts b/src/collections/collections.module.ts index c43f4ed..2694e0b 100644 --- a/src/collections/collections.module.ts +++ b/src/collections/collections.module.ts @@ -5,7 +5,7 @@ import { TypeOrmModule, } from '@nestjs/typeorm'; import { ContentsModule } from '../contents/contents.module'; -import { Category } from '../contents/entities/category.entity'; +import { Category } from '../categories/category.entity'; import { DataSource } from 'typeorm'; import { CollectionsController } from './collections.controller'; import { CollectionsService } from './collections.service'; diff --git a/src/collections/collections.service.ts b/src/collections/collections.service.ts index 032965c..1d74bcb 100644 --- a/src/collections/collections.service.ts +++ b/src/collections/collections.service.ts @@ -21,7 +21,7 @@ import { AddNestedContentOutput, } from './dtos/nested-content.dto'; import { toggleFavoriteOutput } from 'src/contents/dtos/content.dto'; -import { Category } from '../contents/entities/category.entity'; +import { Category } from '../categories/category.entity'; import { ContentsService } from '../contents/contents.service'; @Injectable() diff --git a/src/collections/entities/collection.entity.ts b/src/collections/entities/collection.entity.ts index 444c818..bcfbf68 100644 --- a/src/collections/entities/collection.entity.ts +++ b/src/collections/entities/collection.entity.ts @@ -4,7 +4,7 @@ import { CoreEntity } from '../../common/entities/core.entity'; import { NestedContent } from './nested-content.entity'; import { User } from '../../users/entities/user.entity'; import { Column, Entity, ManyToOne, OneToMany, RelationId } from 'typeorm'; -import { Category } from '../../contents/entities/category.entity'; +import { Category } from '../../categories/category.entity'; @Entity() export class Collection extends CoreEntity { diff --git a/src/contents/contents.controller.ts b/src/contents/contents.controller.ts index dabd7bd..73b3dbc 100644 --- a/src/contents/contents.controller.ts +++ b/src/contents/contents.controller.ts @@ -9,7 +9,6 @@ import { Patch, Get, UseInterceptors, - ParseBoolPipe, Query, } from '@nestjs/common'; import { @@ -29,32 +28,17 @@ import { TransactionInterceptor } from '../common/interceptors/transaction.inter import { TransactionManager } from '../common/transaction.decorator'; import { User } from '../users/entities/user.entity'; import { EntityManager } from 'typeorm'; -import { CategoryService, ContentsService } from './contents.service'; -import { - AddCategoryBodyDto, - AddCategoryOutput, - AutoCategorizeBodyDto, - AutoCategorizeOutput, - DeleteCategoryOutput, - UpdateCategoryBodyDto, - UpdateCategoryOutput, -} from './dtos/category.dto'; +import { ContentsService } from './contents.service'; import { AddContentBodyDto, AddContentOutput, AddMultipleContentsBodyDto, - checkReadFlagOutput, DeleteContentOutput, - SummarizeContentBodyDto, SummarizeContentOutput, toggleFavoriteOutput, UpdateContentBodyDto, UpdateContentOutput, } from './dtos/content.dto'; -import { - LoadFrequentCategoriesOutput, - LoadPersonalCategoriesOutput, -} from './dtos/load-personal-categories.dto'; import { ErrorOutput } from '../common/dtos/output.dto'; import { LoadFavoritesOutput, @@ -313,179 +297,3 @@ export class ContentsController { // return this.contentsService.testSummarizeContent(content); // } } - -@Controller('test') -@ApiTags('Test') -export class TestController { - constructor( - private readonly contentsService: ContentsService, - private readonly categoryService: CategoryService, - ) {} - - @ApiOperation({ - summary: '간편 문서 요약', - description: '성능 테스트를 위해 만든 간편 문서 요약 메서드', - }) - @ApiOkResponse({ - description: '간편 문서 요약 성공 여부를 반환한다.', - type: SummarizeContentOutput, - }) - @ApiBadRequestResponse({ - description: 'naver 서버에 잘못된 요청을 보냈을 경우', - type: ErrorOutput, - }) - @Post('summarize') - async testSummarizeContent( - @Body() content: SummarizeContentBodyDto, - ): Promise { - return this.contentsService.testSummarizeContent(content); - } - - @ApiOperation({ - summary: '아티클 카테고리 자동 지정 (테스트용)', - description: 'url을 넘기면 적절한 아티클 카테고리를 반환하는 메서드', - }) - @Post('auto-categorize') - async autoCategorize( - @Body() autoCategorizeBody: AutoCategorizeBodyDto, - ): Promise { - return this.categoryService.autoCategorizeForTest(autoCategorizeBody); - } -} - -@Controller('categories') -@ApiTags('Category') -@ApiBearerAuth('Authorization') -@UseGuards(JwtAuthGuard) -export class CategoryController { - constructor(private readonly categoryService: CategoryService) {} - - @ApiOperation({ - summary: '카테고리 추가', - description: '카테고리를 추가하는 메서드', - }) - @ApiCreatedResponse({ - description: '카테고리 추가 성공 여부를 반환한다.', - type: AddCategoryOutput, - }) - @ApiConflictResponse({ - description: '동일한 이름의 카테고리가 존재할 경우', - type: ErrorOutput, - }) - @ApiNotFoundResponse({ - description: '존재하지 않는 것일 경우', - type: ErrorOutput, - }) - @Post() - @UseInterceptors(TransactionInterceptor) - async addCategory( - @AuthUser() user: User, - @Body() addCategoryBody: AddCategoryBodyDto, - @TransactionManager() queryRunnerManager: EntityManager, - ): Promise { - return this.categoryService.addCategory( - user, - addCategoryBody, - queryRunnerManager, - ); - } - - @ApiOperation({ - summary: '카테고리 수정', - description: '카테고리 이름을 수정하는 메서드', - }) - @ApiCreatedResponse({ - description: '카테고리 수정 성공 여부를 반환한다.', - type: UpdateCategoryOutput, - }) - @Patch() - @UseInterceptors(TransactionInterceptor) - async updateCategory( - @AuthUser() user: User, - @Body() updateCategoryBody: UpdateCategoryBodyDto, - @TransactionManager() queryRunnerManager: EntityManager, - ): Promise { - return this.categoryService.updateCategory( - user, - updateCategoryBody, - queryRunnerManager, - ); - } - - @ApiOperation({ - summary: '카테고리 삭제', - description: '카테고리를 삭제하는 메서드', - }) - @ApiOkResponse({ - description: '카테고리 삭제 성공 여부를 반환한다.', - type: DeleteCategoryOutput, - }) - @ApiNotFoundResponse({ - description: '존재하지 않는 카테고리를 삭제하려고 할 경우', - type: ErrorOutput, - }) - @Delete(':categoryId') - @UseInterceptors(TransactionInterceptor) - async deleteCategory( - @AuthUser() user: User, - @Param('categoryId', new ParseIntPipe()) categoryId: number, - @Query('deleteContentFlag', new ParseBoolPipe()) deleteContentFlag: boolean, - @TransactionManager() queryRunnerManager: EntityManager, - ): Promise { - return this.categoryService.deleteCategory( - user, - categoryId, - deleteContentFlag, - queryRunnerManager, - ); - } - - @ApiOperation({ - summary: '자신의 카테고리 목록 조회', - description: '자신의 카테고리 목록을 조회하는 메서드', - }) - @ApiOkResponse({ - description: '카테고리 목록을 반환한다.', - type: LoadPersonalCategoriesOutput, - }) - @ApiBearerAuth('Authorization') - @UseGuards(JwtAuthGuard) - @Get() - async loadPersonalCategories( - @AuthUser() user: User, - ): Promise { - return this.categoryService.loadPersonalCategories(user); - } - - @ApiOperation({ - summary: '자주 저장한 카테고리 조회', - description: '자주 저장한 카테고리를 3개까지 조회하는 메서드', - }) - @ApiOkResponse({ - description: '자주 저장한 카테고리를 최대 3개까지 반환한다.', - type: LoadFrequentCategoriesOutput, - }) - @ApiBearerAuth('Authorization') - @UseGuards(JwtAuthGuard) - @Get('frequent') - async loadFrequentCategories( - @AuthUser() user: User, - ): Promise { - return this.categoryService.loadFrequentCategories(user); - } - - @ApiOperation({ - summary: '아티클 카테고리 자동 지정', - description: - '아티클에 적절한 카테고리를 유저의 카테고리 목록에서 찾는 메서드', - }) - @ApiBearerAuth('Authorization') - @UseGuards(JwtAuthGuard) - @Get('auto-categorize') - async autoCategorize( - @AuthUser() user: User, - @Query('link') link: string, - ): Promise { - return this.categoryService.autoCategorize(user, link); - } -} diff --git a/src/contents/contents.module.ts b/src/contents/contents.module.ts index 297938f..8e1364a 100644 --- a/src/contents/contents.module.ts +++ b/src/contents/contents.module.ts @@ -1,35 +1,26 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { - CategoryController, - ContentsController, - TestController, -} from './contents.controller'; -import { CategoryService, ContentsService } from './contents.service'; -import { Category } from './entities/category.entity'; +import { ContentsController } from './contents.controller'; +import { ContentsService } from './contents.service'; +import { Category } from '../categories/category.entity'; import { Content } from './entities/content.entity'; import { CategoryUtil } from './util/category.util'; import { ContentRepository } from './repository/content.repository'; -import { CategoryRepository } from './repository/category.repository'; +import { CategoryRepository } from '../categories/category.repository'; import { UsersModule } from '../users/users.module'; import { ContentUtil } from './util/content.util'; import { OpenaiModule } from '../openai/openai.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Content, Category]), - UsersModule, - OpenaiModule, - ], - controllers: [ContentsController, CategoryController, TestController], + imports: [TypeOrmModule.forFeature([Content]), UsersModule, OpenaiModule], + controllers: [ContentsController], providers: [ ContentsService, - CategoryService, ContentRepository, + ContentUtil, CategoryRepository, CategoryUtil, - ContentUtil, ], - exports: [ContentsService, CategoryRepository], + exports: [ContentsService], }) export class ContentsModule {} diff --git a/src/contents/contents.service.spec.ts b/src/contents/contents.service.spec.ts index 8a964b1..939a933 100644 --- a/src/contents/contents.service.spec.ts +++ b/src/contents/contents.service.spec.ts @@ -6,12 +6,12 @@ import { User, UserRole } from '../users/entities/user.entity'; import { DataSource, EntityManager, ObjectLiteral, Repository } from 'typeorm'; import { CategoryService, ContentsService } from './contents.service'; import { Content } from './entities/content.entity'; -import { Category } from './entities/category.entity'; +import { Category } from '../categories/category.entity'; import { CategoryRepository, customCategoryRepositoryMethods, } from './repository/category.old.repository'; -import { RecentCategoryList } from './dtos/category.dto'; +import { RecentCategoryList } from '../categories/dtos/category.dto'; const mockRepository = () => ({ // make as a function type that returns Object. diff --git a/src/contents/contents.service.ts b/src/contents/contents.service.ts index ae852fa..458e30f 100644 --- a/src/contents/contents.service.ts +++ b/src/contents/contents.service.ts @@ -1,53 +1,35 @@ import { BadRequestException, - ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { EntityManager } from 'typeorm'; -import { - AddCategoryBodyDto, - AddCategoryOutput, - RecentCategoryList, - DeleteCategoryOutput, - UpdateCategoryBodyDto, - UpdateCategoryOutput, - RecentCategoryListWithSaveCount, - AutoCategorizeBodyDto, - AutoCategorizeOutput, -} from './dtos/category.dto'; import { AddContentBodyDto, AddContentOutput, AddMultipleContentsBodyDto, - checkReadFlagOutput, DeleteContentOutput, SummarizeContentBodyDto, SummarizeContentOutput, toggleFavoriteOutput, UpdateContentBodyDto, } from './dtos/content.dto'; -import { - LoadFrequentCategoriesOutput, - LoadPersonalCategoriesOutput, -} from './dtos/load-personal-categories.dto'; import { LoadFavoritesOutput, LoadPersonalContentsOutput, } from './dtos/load-personal-contents.dto'; import { SummaryService } from '../summary/summary.service'; import { User } from '../users/entities/user.entity'; -import { Category } from './entities/category.entity'; +import { Category } from '../categories/category.entity'; import { Content } from './entities/content.entity'; import { LoadReminderCountOutput } from './dtos/load-personal-remider-count.dto'; import { UserRepository } from '../users/repository/user.repository'; import { ContentRepository } from './repository/content.repository'; import { CategoryUtil } from './util/category.util'; -import { CategoryRepository } from './repository/category.repository'; +import { CategoryRepository } from '../categories/category.repository'; import { ContentUtil } from './util/content.util'; -import { OpenaiService } from '../openai/openai.service'; import { GetLinkInfoResponseDto } from './dtos/get-link.response.dto'; @Injectable() @@ -443,522 +425,3 @@ export class ContentsService { } } } - -@Injectable() -export class CategoryService { - constructor( - private readonly contentRepository: ContentRepository, - private readonly categoryRepository: CategoryRepository, - private readonly categoryUtil: CategoryUtil, - private readonly userRepository: UserRepository, - private readonly contentUtil: ContentUtil, - private readonly openaiService: OpenaiService, - ) {} - - async addCategory( - user: User, - { categoryName, iconName, parentId }: AddCategoryBodyDto, - queryRunnerManager: EntityManager, - ): Promise { - try { - const userInDb = await this.userRepository.findOneWithCategories(user.id); - - if (!userInDb) { - throw new NotFoundException('User not found'); - } - - const { categorySlug } = this.categoryUtil.generateSlug(categoryName); - - if (parentId) { - // category depth should be 3 - let currentParentId: number | undefined = parentId; - let parentCategory: Category | null; - for (let i = 0; i < 2; i++) { - parentCategory = await queryRunnerManager.findOne(Category, { - where: { id: currentParentId }, - }); - if (i == 1 && parentCategory?.parentId !== null) { - throw new ConflictException('Category depth should be 3'); - } - if (parentCategory?.parentId) - currentParentId = parentCategory?.parentId; - else break; - } - } else { - /** - * TODO: 유료 플랜 사용자이면 카테고리 개수 제한 없도록 추가 구성해야함. - */ - // if parentId is null, it means that category is root category - // root categories can't be more than 10 in one user - const isOverCategoryLimit = - await this.categoryRepository.isOverCategoryLimit(user); - if (isOverCategoryLimit) { - throw new ConflictException( - "Root categories can't be more than 10 in one user", - ); - } - } - - // check if category exists in user's categories(check if category name is duplicated in same level too) - const category = userInDb.categories?.find( - (category) => - category.slug === categorySlug && - (category.parentId === parentId || (!parentId && !category.parentId)), - ); - - // if category doesn't exist, create it - if (category) { - throw new ConflictException('Category already exists'); - } else { - // if parent category exists, get parent category - const parentCategory: Category | null = parentId - ? await queryRunnerManager.findOne(Category, { - where: { id: parentId }, - }) - : null; - // if parent category doesn't exist, throw error - if (!parentCategory && parentId) { - throw new NotFoundException('Parent category not found'); - } - - const category = await queryRunnerManager.save( - queryRunnerManager.create(Category, { - slug: categorySlug, - name: categoryName, - iconName, - parentId: parentCategory?.id, - user: userInDb, - }), - ); - - userInDb.categories?.push(category); - await queryRunnerManager.save(userInDb); - } - - return {}; - } catch (e) { - throw e; - } - } - - async updateCategory( - user: User, - { - categoryId, - name: categoryName, - iconName, - parentId, - }: UpdateCategoryBodyDto, - queryRunnerManager: EntityManager, - ): Promise { - try { - const userInDb = - await this.userRepository.findOneWithContentsAndCategories(user.id); - - // Check if user exists - if (!userInDb) { - throw new NotFoundException('User not found.'); - } - - const category = userInDb.categories?.find( - (category) => category.id === categoryId, - ); - - if (category) { - // Check if user has category with same slug - if (categoryName) { - const { categorySlug } = this.categoryUtil.generateSlug(categoryName); - if ( - userInDb.categories?.filter( - (category) => - category.slug === categorySlug && category.id !== categoryId, - )[0] - ) { - throw new NotFoundException( - 'Category with that name already exists in current user.', - ); - } - - // Update category - category.name = categoryName; - category.slug = categorySlug; - } - - if (iconName) { - category.iconName = iconName; - } - - if (parentId) { - // category depth should be 3 - let parentCategory = await this.categoryRepository.findOne({ - where: { id: parentId }, - }); - if (!parentCategory) { - throw new NotFoundException('Parent category not found.'); - } else if (parentCategory?.parentId !== null) { - parentCategory = await this.categoryRepository.findOne({ - where: { id: parentCategory.parentId }, - }); - if (parentCategory?.parentId !== null) { - throw new ConflictException('Category depth should be 3'); - } - } - - category.parentId = parentId; - } - - await queryRunnerManager.save(category); - } else { - throw new NotFoundException('Category not found.'); - } - - return {}; - } catch (e) { - throw e; - } - } - - async deleteCategory( - user: User, - categoryId: number, - deleteContentFlag: boolean, - queryRunnerManager: EntityManager, - ): Promise { - try { - const userInDb = - await this.userRepository.findOneWithContentsAndCategories(user.id); - - // Check if user exists - if (!userInDb) { - throw new NotFoundException('User not found.'); - } - - const category = userInDb.categories?.find( - (category) => category.id === categoryId, - ); - - if (!category) { - throw new NotFoundException('Category not found.'); - } - - /** - * 자식 카테고리가 있는 경우, 부모 카테고리와 연결 - * 단, 삭제되는 카테고리가 1단 카테고리인 경우 부모 카테고리가 없으므로 - * 자식 카테고리의 부모 카테고리를 null로 설정 - */ - - // find parent category - const parentCategory = category.parentId - ? await queryRunnerManager.findOneOrFail(Category, { - where: { id: category.parentId }, - }) - : undefined; - - // find children categories - const childrenCategories = await queryRunnerManager.find(Category, { - where: { parentId: categoryId }, - }); - - await queryRunnerManager.save( - childrenCategories.map((childrenCategory) => { - childrenCategory.parentId = parentCategory?.id ?? null; - return childrenCategory; - }), - ); - - /** - * delete content flag에 따른 분기처리 - */ - - // if deleteContentFlag is true, delete all contents in category - if (deleteContentFlag) { - await queryRunnerManager.delete(Content, { category }); - } - - // if deleteContentFlag is false, set all contents in category to parent category - else { - // find all contents in category with query builder - const contents = await this.contentRepository.findByCategoryId( - categoryId, - ); - - // set content's category to parent category - await queryRunnerManager.save( - contents.map((content) => { - content.category = parentCategory; - return content; - }), - ); - } - - await queryRunnerManager.delete(Category, { id: categoryId }); - - return {}; - } catch (e) { - throw e; - } - } - - async loadPersonalCategories( - user: User, - ): Promise { - try { - const { categories } = - await this.userRepository.findOneWithCategoriesOrFail(user.id); - - if (!categories) { - throw new NotFoundException('Categories not found.'); - } - - // make categories tree by parentid - const categoriesTree = - this.categoryUtil.generateCategoriesTree(categories); - - return { - categoriesTree, - }; - } catch (e) { - throw e; - } - } - - async loadFrequentCategories( - user: User, - ): Promise { - try { - // 로그 파일 내의 기록을 불러온다. - const recentCategoryList: RecentCategoryList[] = - this.categoryUtil.loadLogs(user.id); - - // 캐시 내의 카테고리 리스트를 최신 순으로 정렬하고, 동시에 저장된 횟수를 추가한다. - - let recentCategoriesWithSaveCount: RecentCategoryListWithSaveCount[] = []; - const frequentCategories: Category[] = []; - - // 3번째 카테고리까지 선정되거나, 더 이상 로그가 없을 때까지 매번 10개의 로그씩 확인한다. - let remainLogCount = recentCategoryList.length, - i = 0; - while (remainLogCount > 0) { - // 3개의 카테고리가 선정되었으면 루프를 종료한다. - if (frequentCategories.length >= 3) { - break; - } - - // 10개의 로그를 확인한다. - i += 10; - recentCategoriesWithSaveCount = - this.categoryUtil.makeCategoryListWithSaveCount( - recentCategoryList, - recentCategoriesWithSaveCount, - i, - ); - // 10개의 로그를 확인했으므로 남은 로그 수를 10개 감소시킨다. - remainLogCount -= 10; - - /* - * 10개의 로그를 확인하고, 만약 이전 호출에서 선정된 카테고리가 이번 호출에서도 선정되는 것을 방지하기위해 - * 이전 호출에서 선정된 카테고리를 제외한 카테고리 리스트를 만든다. - */ - recentCategoriesWithSaveCount = recentCategoriesWithSaveCount.filter( - (category) => - !frequentCategories.find( - (recentCategory) => recentCategory.id === category.categoryId, - ), - ); - - // 최근 저장 순 - const orderByDate: number[] = recentCategoriesWithSaveCount.map( - (category) => category.categoryId, - ); - // 저장된 횟수 순 - const orderBySaveCount: RecentCategoryListWithSaveCount[] = - recentCategoriesWithSaveCount.sort( - (a, b) => b.saveCount - a.saveCount, - ); - /* - * 2번째 카테고리까지 선정 기준 - * 1. 저장 횟수 순 - * 2. 저장 횟수 동일 시, 최근 저장 순 - */ - for (let i = 0; i < 2; i++) { - if (i < orderBySaveCount.length) { - const category = await this.categoryRepository.findOne({ - where: { id: orderBySaveCount[i].categoryId }, - }); - - // orderByDate에서 제거 - orderByDate.splice( - orderByDate.findIndex( - (categoryId) => categoryId === orderBySaveCount[i].categoryId, - ), - 1, - ); - - if (category) { - frequentCategories.push(category); - } - } - } - - /* - * 나머지 3-n 개 선정 기준 - * 1. 최근 저장 순 - */ - const N = 3 - frequentCategories.length; - for (let i = 0; i < N; i++) { - if (i < orderByDate.length) { - const category = await this.categoryRepository.findOne({ - where: { id: orderByDate[i] }, - }); - - if (category) { - frequentCategories.push(category); - } - } - } - } - - return { - frequentCategories, - }; - } catch (e) { - throw e; - } - } - - async autoCategorize( - user: User, - link: string, - ): Promise { - try { - const userInDb = await this.userRepository.findOneWithCategories(user.id); - if (!userInDb) { - throw new NotFoundException('User not found'); - } - - if (!userInDb.categories) { - throw new NotFoundException('Categories not found'); - } - const categories: string[] = []; - userInDb.categories.forEach((category) => { - if (!category.parentId) { - categories.push(category.name); - } - }); - const { title, siteName, description } = - await this.contentUtil.getLinkInfo(link); - - const content = await this.contentUtil.getLinkContent(link); - - let questionLines = [ - "You are a machine tasked with auto-categorizing articles based on information obtained through web scraping. You can only answer a single category name. Here is the article's information:", - ]; - - if (title) { - questionLines.push( - `The article in question is titled "${title.trim()}"`, - ); - } - - if (content) { - const contentLength = content.length / 2; - questionLines.push( - `The 150 characters of the article is, "${content - .replace(/\s/g, '') - .slice(contentLength - 150, contentLength + 150) - .trim()}"`, - ); - } - - if (description) { - questionLines.push(`The description is ${description.trim()}"`); - } - - if (siteName) { - questionLines.push(`The site's name is "${siteName.trim()}"`); - } - - // Add the category options to the end of the list - questionLines.push( - `Please provide the most suitable category among the following. Here is Category options: [${categories.join( - ', ', - )}, None]`, - ); - - // Join all lines together into a single string - const question = questionLines.join(' '); - console.log(question); - - const response = await this.openaiService.createChatCompletion({ - question, - }); - - return { category: response.choices[0].message?.content || 'None' }; - } catch (e) { - throw e; - } - } - - async autoCategorizeForTest( - autoCategorizeBody: AutoCategorizeBodyDto, - ): Promise { - try { - const { link, categories } = autoCategorizeBody; - const { title, siteName, description } = - await this.contentUtil.getLinkInfo(link); - - /** - * TODO: 본문 크롤링 개선 필요 - * 현재 p 태그만 크롤링하는데, 불필요한 내용이 포함되는 경우가 많음 - * 그러나 하나하나 예외 처리하는 방법을 제외하곤 방법을 못 찾은 상황 - */ - const content = await this.contentUtil.getLinkContent(link); - - let questionLines = [ - "You are a machine tasked with auto-categorizing articles based on information obtained through web scraping. You can only answer a single category name. Here is the article's information:", - ]; - - if (title) { - questionLines.push( - `The article in question is titled "${title.trim()}"`, - ); - } - - if (content) { - const contentLength = content.length / 2; - questionLines.push( - `The 150 characters of the article is, "${content - .replace(/\s/g, '') - .slice(contentLength - 150, contentLength + 150) - .trim()}"`, - ); - } - - if (description) { - questionLines.push(`The description is ${description.trim()}"`); - } - - if (siteName) { - questionLines.push(`The site's name is "${siteName.trim()}"`); - } - - // Add the category options to the end of the list - questionLines.push( - `Please provide the most suitable category among the following. Here is Category options: [${categories.join( - ', ', - )}, None]`, - ); - - // Join all lines together into a single string - const question = questionLines.join(' '); - - const response = await this.openaiService.createChatCompletion({ - question, - }); - - return { category: response.choices[0].message?.content || 'None' }; - } catch (e) { - throw e; - } - } -} diff --git a/src/contents/entities/content.entity.ts b/src/contents/entities/content.entity.ts index 7350ca7..a6a88c8 100644 --- a/src/contents/entities/content.entity.ts +++ b/src/contents/entities/content.entity.ts @@ -9,7 +9,7 @@ import { CoreEntity } from '../../common/entities/core.entity'; import { User } from '../../users/entities/user.entity'; import { Column, Entity, ManyToOne, RelationId } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; -import { Category } from './category.entity'; +import { Category } from '../../categories/category.entity'; import { Transform } from 'class-transformer'; @Entity() diff --git a/src/contents/util/category.util.ts b/src/contents/util/category.util.ts index 3ac7e80..9b87b32 100644 --- a/src/contents/util/category.util.ts +++ b/src/contents/util/category.util.ts @@ -1,10 +1,10 @@ -import { Category } from '../entities/category.entity'; +import { Category } from '../../categories/category.entity'; import { CategorySlug, CategoryTreeNode, RecentCategoryList, RecentCategoryListWithSaveCount, -} from '../dtos/category.dto'; +} from '../../categories/dtos/category.dto'; import { User } from '../../users/entities/user.entity'; import { ConflictException, Injectable } from '@nestjs/common'; diff --git a/src/database/typerom-config.service.ts b/src/database/typerom-config.service.ts index e165ff8..d418189 100644 --- a/src/database/typerom-config.service.ts +++ b/src/database/typerom-config.service.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; import { Collection } from '../collections/entities/collection.entity'; import { NestedContent } from '../collections/entities/nested-content.entity'; -import { Category } from '../contents/entities/category.entity'; +import { Category } from '../categories/category.entity'; import { Content } from '../contents/entities/content.entity'; import { User } from '../users/entities/user.entity'; import { PaidPlan } from '../users/entities/paid-plan.entity'; diff --git a/src/test/test.controller.ts b/src/test/test.controller.ts new file mode 100644 index 0000000..fef8537 --- /dev/null +++ b/src/test/test.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Post, Body } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiOkResponse, + ApiBadRequestResponse, +} from '@nestjs/swagger'; +import { ErrorOutput } from '../common/dtos/output.dto'; +import { ContentsService } from '../contents/contents.service'; +import { + AutoCategorizeBodyDto, + AutoCategorizeOutput, +} from '../categories/dtos/category.dto'; +import { + SummarizeContentOutput, + SummarizeContentBodyDto, +} from '../contents/dtos/content.dto'; +import { CategoryService } from '../categories/category.service'; + +@Controller('test') +@ApiTags('Test') +export class TestController { + constructor( + private readonly contentsService: ContentsService, + private readonly categoryService: CategoryService, + ) {} + + @ApiOperation({ + summary: '간편 문서 요약', + description: '성능 테스트를 위해 만든 간편 문서 요약 메서드', + }) + @ApiOkResponse({ + description: '간편 문서 요약 성공 여부를 반환한다.', + type: SummarizeContentOutput, + }) + @ApiBadRequestResponse({ + description: 'naver 서버에 잘못된 요청을 보냈을 경우', + type: ErrorOutput, + }) + @Post('summarize') + async testSummarizeContent( + @Body() content: SummarizeContentBodyDto, + ): Promise { + return this.contentsService.testSummarizeContent(content); + } + + @ApiOperation({ + summary: '아티클 카테고리 자동 지정 (테스트용)', + description: 'url을 넘기면 적절한 아티클 카테고리를 반환하는 메서드', + }) + @Post('auto-categorize') + async autoCategorize( + @Body() autoCategorizeBody: AutoCategorizeBodyDto, + ): Promise { + return this.categoryService.autoCategorizeForTest(autoCategorizeBody); + } +} diff --git a/src/test/test.module.ts b/src/test/test.module.ts new file mode 100644 index 0000000..f973592 --- /dev/null +++ b/src/test/test.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CategoryModule } from '../categories/category.module'; +import { ContentsModule } from '../contents/contents.module'; + +@Module({ + imports: [CategoryModule, ContentsModule], +}) +export class TestModule {} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index da04aa7..6506b9f 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -11,7 +11,7 @@ import * as bcrypt from 'bcrypt'; import { IsBoolean, IsEmail, IsEnum, IsString, Matches } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { Content } from '../../contents/entities/content.entity'; -import { Category } from '../../contents/entities/category.entity'; +import { Category } from '../../categories/category.entity'; import { Collection } from '../../collections/entities/collection.entity'; import { CoreEntity } from '../../common/entities/core.entity'; import { PaidPlan } from './paid-plan.entity';