diff --git a/apps/api/src/entity/summary.entity.ts b/apps/api/src/entity/summary.entity.ts index 6cd3f050..030c4963 100644 --- a/apps/api/src/entity/summary.entity.ts +++ b/apps/api/src/entity/summary.entity.ts @@ -15,13 +15,10 @@ export class Summary { id: number; @Column('varchar') - name: string; + audioUrl: string; - @Column('varchar') - source: string; - - @Column('varchar') - summary: string; + @Column('json') + summaryText: string[]; @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) createdAt: Date; diff --git a/apps/api/src/stream/dto/createSummaryDto.ts b/apps/api/src/stream/dto/createSummaryDto.ts new file mode 100644 index 00000000..a109014c --- /dev/null +++ b/apps/api/src/stream/dto/createSummaryDto.ts @@ -0,0 +1,4 @@ +export class CreateSummaryDto { + ticleId: number; + audioUrl: string; +} diff --git a/apps/api/src/stream/stream.controller.ts b/apps/api/src/stream/stream.controller.ts index 1b8b603b..9d00f36f 100644 --- a/apps/api/src/stream/stream.controller.ts +++ b/apps/api/src/stream/stream.controller.ts @@ -1,4 +1,25 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; + +import { CreateSummaryDto } from './dto/createSummaryDto'; +import { StreamService } from './stream.service'; @Controller('stream') -export class StreamController {} +export class StreamController { + constructor(private readonly streamService: StreamService) {} + + @Post('audio') + async getAudioFileUrl(@Body() createSummaryDto: CreateSummaryDto) { + const summary = await this.streamService.createSummary(createSummaryDto); + + const fulltext = await this.streamService.transcribeAudio(createSummaryDto.audioUrl); + const summaryResult = await this.streamService.summaryAudio(fulltext); + + await this.streamService.updateSummaryText(summary, summaryResult); + } + + @Get('summary/:ticleId') + async getSummaryByTicleId(@Param('ticleId') ticleId: number) { + const text = await this.streamService.getSummaryText(ticleId); + return { summary: text }; + } +} diff --git a/apps/api/src/stream/stream.module.ts b/apps/api/src/stream/stream.module.ts index e00607e6..d0418a0f 100644 --- a/apps/api/src/stream/stream.module.ts +++ b/apps/api/src/stream/stream.module.ts @@ -1,8 +1,15 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Summary } from '@/entity/summary.entity'; +import { Ticle } from '@/entity/ticle.entity'; import { StreamController } from './stream.controller'; +import { StreamService } from './stream.service'; @Module({ + imports: [TypeOrmModule.forFeature([Ticle, Summary])], controllers: [StreamController], + providers: [StreamService], }) export class StreamModule {} diff --git a/apps/api/src/stream/stream.service.ts b/apps/api/src/stream/stream.service.ts new file mode 100644 index 00000000..77e1924f --- /dev/null +++ b/apps/api/src/stream/stream.service.ts @@ -0,0 +1,117 @@ +import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ErrorMessage } from '@repo/types'; + +import { Summary } from '@/entity/summary.entity'; +import { Ticle } from '@/entity/ticle.entity'; + +import { CreateSummaryDto } from './dto/createSummaryDto'; + +@Injectable() +export class StreamService { + constructor( + @InjectRepository(Summary) + private summaryRepository: Repository, + @InjectRepository(Ticle) + private ticleRepository: Repository, + private configService: ConfigService + ) {} + + async createSummary(createSummaryDto: CreateSummaryDto) { + const { ticleId, audioUrl } = createSummaryDto; + const ticle = await this.ticleRepository.findOne({ + where: { id: ticleId }, + }); + + if (!ticle) { + throw new NotFoundException(ErrorMessage.TICLE_NOT_FOUND); + } + + const summary = this.summaryRepository.create({ + audioUrl: audioUrl, + ticle: ticle, + }); + return await this.summaryRepository.save(summary); + } + + async transcribeAudio(remoteFileName: string) { + const speechEndpoint = this.configService.get('CLOVASPEECH_ENDPOINT'); + + const body = { + dataKey: remoteFileName, + params: { enable: true }, + language: 'ko-KR', + completion: 'sync', + fulltext: true, + }; + + try { + const response = await fetch(speechEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CLOVASPEECH-API-KEY': this.configService.get('CLOVASPEECH_API_KEY'), + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + return data.text; + } catch (error) { + console.error('Failed to transcribe audio:', error); + throw new InternalServerErrorException(ErrorMessage.FAILED_TO_TRANSCRIBE_AUDIO); + } + } + + async summaryAudio(text: string) { + const studioEndpoint = this.configService.get('CLOVASTUDIO_ENDPOINT'); + + const body = { + texts: [text], + segMinSize: 300, + includeAiFilters: true, + autoSentenceSplitter: true, + segCount: -1, + segMaxSize: 1000, + }; + + try { + const response = await fetch(studioEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-NCP-CLOVASTUDIO-API-KEY': this.configService.get('CLOVASTUDIO_API_KEY'), + 'X-NCP-APIGW-API-KEY': this.configService.get('CLOVASTUDIO_APIGW_API_KEY'), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error response:', errorText); + throw new Error(`API error: ${response.status} - ${response.statusText}`); + } + + const data = await response.json(); + + return data.result.text; + } catch (error) { + console.error('Failed to summarize audio:', error); + throw new InternalServerErrorException(ErrorMessage.FAILED_TO_SUMMARY_AUDIO); + } + } + + async getSummaryText(ticleId: number) { + const summary = await this.summaryRepository.findOne({ + where: { ticle: { id: ticleId } }, + }); + return summary.summaryText; + } + + async updateSummaryText(summary: Summary, summaryText: string[]) { + summary.summaryText = summaryText; + await this.summaryRepository.save(summary); + } +} diff --git a/packages/types/src/errorMessages.ts b/packages/types/src/errorMessages.ts index dbc432ac..adf24540 100644 --- a/packages/types/src/errorMessages.ts +++ b/packages/types/src/errorMessages.ts @@ -14,6 +14,8 @@ export const ErrorMessage = { FAILED_TO_CREATE_TICLE: '티클 생성에 실패했습니다', CANNOT_REQUEST_OWN_TICLE: '자신이 발표자인 티클에는 신청할 수 없습니다', TICLE_ALREADY_REQUESTED: '이미 신청한 티클입니다', + FAILED_TO_TRANSCRIBE_AUDIO: 'CLOVA SPEECH로 텍스트 변환에 실패했습니다', + FAILED_TO_SUMMARY_AUDIO: 'CLOVA STRUDIO로 요약에 실패했습니다', CANNOT_DELETE_OTHERS_TICLE: '다른 사람의 티클은 삭제할 수 없습니다', CANNOT_START_TICLE: '티클을 시작할 수 없습니다', CANNOT_END_TICLE: '티클을 종료할 수 없습니다',