diff --git a/src/main/java/com/dokdok/ai/client/AiSttClient.java b/src/main/java/com/dokdok/ai/client/AiSttClient.java new file mode 100644 index 00000000..e6c0e5c4 --- /dev/null +++ b/src/main/java/com/dokdok/ai/client/AiSttClient.java @@ -0,0 +1,29 @@ +package com.dokdok.ai.client; + +import com.dokdok.global.response.ApiResponse; +import com.dokdok.retrospective.dto.response.RetrospectiveSummaryResponse; +import com.dokdok.ai.dto.SttRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@RequiredArgsConstructor +public class AiSttClient { + + private final WebClient aiWebClient; + + @Value("${ai.api.stt-path}") + private String sttPath; + + public ApiResponse requestStt(SttRequest request) { + return aiWebClient.post() + .uri(sttPath) + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } +} diff --git a/src/main/java/com/dokdok/ai/client/AiSummaryClient.java b/src/main/java/com/dokdok/ai/client/AiSummaryClient.java deleted file mode 100644 index e12a9e12..00000000 --- a/src/main/java/com/dokdok/ai/client/AiSummaryClient.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.dokdok.ai.client; - -import com.dokdok.ai.dto.TopicSummaryRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -@Component -@RequiredArgsConstructor -public class AiSummaryClient { - - private final WebClient aiWebClient; - - @Value("${ai.api.summary-path}") - private String summaryPath; - - public String requestTopicSummary(TopicSummaryRequest request) { - return aiWebClient.post() - .uri(summaryPath) - .bodyValue(request) - .retrieve() - .bodyToMono(String.class) - .block(); - } -} diff --git a/src/main/java/com/dokdok/ai/dto/SttRequest.java b/src/main/java/com/dokdok/ai/dto/SttRequest.java new file mode 100644 index 00000000..fe21e86e --- /dev/null +++ b/src/main/java/com/dokdok/ai/dto/SttRequest.java @@ -0,0 +1,19 @@ +package com.dokdok.ai.dto; + +import java.util.List; + +public record SttRequest( + Long jobId, + Long meetingId, + String filePath, + String language, + List preAnswers +) { + public record PreAnswer( + Long topicId, + String topicTitle, + Long userId, + String content + ) { + } +} diff --git a/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java b/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java deleted file mode 100644 index 86e3f46e..00000000 --- a/src/main/java/com/dokdok/ai/dto/TopicSummaryRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dokdok.ai.dto; - -import java.util.List; - -public record TopicSummaryRequest( - Long topicId, - String topicTitle, - List answers -) { - public record Answer( - Long userId, - String content - ) { - } -} diff --git a/src/main/java/com/dokdok/stt/api/SttApi.java b/src/main/java/com/dokdok/stt/api/SttApi.java new file mode 100644 index 00000000..5f22a1ea --- /dev/null +++ b/src/main/java/com/dokdok/stt/api/SttApi.java @@ -0,0 +1,111 @@ +package com.dokdok.stt.api; + +import com.dokdok.global.response.ApiResponse; +import com.dokdok.stt.dto.SttJobResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "STT", description = "STT 작업 관련 API") +@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/stt/jobs") +public interface SttApi { + + @Operation( + summary = "STT 작업 생성", + description = "오디오 파일 업로드 또는 사전의견만으로 STT 요약 작업을 생성합니다.", + parameters = { + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) + } + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "STT 작업 생성 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SttJobResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"잘못된 요청입니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"G002","message":"모임의 멤버가 아닙니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "약속을 찾을 수 없음", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"M001","message":"약속을 찾을 수 없습니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.","data":null} + """))) + }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> createJob( + @PathVariable Long gatheringId, + @PathVariable Long meetingId, + @RequestPart(value = "file", required = false) MultipartFile file + ); + + @Operation( + summary = "STT 작업 조회", + description = "STT 작업 상태/결과를 조회합니다.", + parameters = { + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "jobId", description = "STT 작업 식별자", in = ParameterIn.PATH, required = true) + } + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "STT 작업 조회 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SttJobResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"잘못된 요청입니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "약속 멤버가 아님", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"G002","message":"모임의 멤버가 아닙니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "작업을 찾을 수 없음", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E101","message":"작업을 찾을 수 없습니다.","data":null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @io.swagger.v3.oas.annotations.media.ExampleObject(value = """ + {"code":"E000","message":"서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.","data":null} + """))) + }) + @GetMapping(value = "/{jobId}", produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> getJob( + @PathVariable Long gatheringId, + @PathVariable Long meetingId, + @PathVariable Long jobId + ); +} diff --git a/src/main/java/com/dokdok/stt/controller/SttController.java b/src/main/java/com/dokdok/stt/controller/SttController.java new file mode 100644 index 00000000..df665aff --- /dev/null +++ b/src/main/java/com/dokdok/stt/controller/SttController.java @@ -0,0 +1,33 @@ +package com.dokdok.stt.controller; + +import com.dokdok.global.response.ApiResponse; +import com.dokdok.stt.api.SttApi; +import com.dokdok.stt.dto.SttJobResponse; +import com.dokdok.stt.service.SttJobService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class SttController implements SttApi { + + private final SttJobService sttJobService; + + @Override + public ResponseEntity> createJob( + Long gatheringId, + Long meetingId, + MultipartFile file + ) { + SttJobResponse response = sttJobService.createJob(gatheringId, meetingId, file); + return ApiResponse.created(response, "STT 작업이 생성되었습니다."); + } + + @Override + public ResponseEntity> getJob(Long gatheringId, Long meetingId, Long jobId) { + SttJobResponse response = sttJobService.getJob(gatheringId, meetingId, jobId); + return ApiResponse.success(response, "STT 작업 조회를 완료했습니다."); + } +} diff --git a/src/main/java/com/dokdok/stt/dto/SttJobResponse.java b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java new file mode 100644 index 00000000..d37b00ec --- /dev/null +++ b/src/main/java/com/dokdok/stt/dto/SttJobResponse.java @@ -0,0 +1,32 @@ +package com.dokdok.stt.dto; + +import com.dokdok.stt.entity.SttJob; +import com.dokdok.stt.entity.SttJobStatus; +import com.dokdok.stt.entity.SttSummary; + +import java.time.LocalDateTime; +import java.util.List; + +public record SttJobResponse( + Long jobId, + Long meetingId, + Long userId, + SttJobStatus status, + String summary, + List highlights, + String errorMessage, + LocalDateTime createdAt +) { + public static SttJobResponse from(SttJob job, SttSummary summary) { + return new SttJobResponse( + job.getId(), + job.getMeeting().getId(), + job.getUser().getId(), + job.getStatus(), + summary != null ? summary.getSummary() : null, + summary != null ? summary.getHighlights() : null, + job.getErrorMessage(), + job.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/dokdok/stt/entity/SttJob.java b/src/main/java/com/dokdok/stt/entity/SttJob.java new file mode 100644 index 00000000..4a0bb74e --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttJob.java @@ -0,0 +1,68 @@ +package com.dokdok.stt.entity; + +import com.dokdok.global.BaseTimeEntity; +import com.dokdok.meeting.entity.Meeting; +import com.dokdok.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "stt_job") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class SttJob extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stt_job_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id", nullable = false) + private Meeting meeting; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private SttJobStatus status = SttJobStatus.PENDING; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "original_filename", length = 255) + private String originalFilename; + + @Column(name = "content_type", length = 100) + private String contentType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "temp_file_path", length = 500) + private String tempFilePath; + + @Column(name = "language", length = 20) + @Builder.Default + private String language = "ko-KR"; + + public void markProcessing() { + this.status = SttJobStatus.PROCESSING; + } + + public void markDone() { + this.status = SttJobStatus.DONE; + this.errorMessage = null; + } + + public void markFailed(String errorMessage) { + this.status = SttJobStatus.FAILED; + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/com/dokdok/stt/entity/SttJobStatus.java b/src/main/java/com/dokdok/stt/entity/SttJobStatus.java new file mode 100644 index 00000000..259ccfce --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttJobStatus.java @@ -0,0 +1,8 @@ +package com.dokdok.stt.entity; + +public enum SttJobStatus { + PENDING, + PROCESSING, + DONE, + FAILED +} diff --git a/src/main/java/com/dokdok/stt/entity/SttSummary.java b/src/main/java/com/dokdok/stt/entity/SttSummary.java new file mode 100644 index 00000000..3a00aba9 --- /dev/null +++ b/src/main/java/com/dokdok/stt/entity/SttSummary.java @@ -0,0 +1,46 @@ +package com.dokdok.stt.entity; + +import com.dokdok.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; + +@Entity +@Table(name = "stt_summary") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class SttSummary extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stt_summary_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stt_job_id", nullable = false) + private SttJob sttJob; + + @Column(name = "summary", columnDefinition = "TEXT") + private String summary; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "highlights", columnDefinition = "jsonb") + private List highlights; +} diff --git a/src/main/java/com/dokdok/stt/repository/SttJobRepository.java b/src/main/java/com/dokdok/stt/repository/SttJobRepository.java new file mode 100644 index 00000000..57e10379 --- /dev/null +++ b/src/main/java/com/dokdok/stt/repository/SttJobRepository.java @@ -0,0 +1,10 @@ +package com.dokdok.stt.repository; + +import com.dokdok.stt.entity.SttJob; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SttJobRepository extends JpaRepository { + Optional findByIdAndMeetingId(Long id, Long meetingId); +} diff --git a/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java b/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java new file mode 100644 index 00000000..94fe24c5 --- /dev/null +++ b/src/main/java/com/dokdok/stt/repository/SttSummaryRepository.java @@ -0,0 +1,10 @@ +package com.dokdok.stt.repository; + +import com.dokdok.stt.entity.SttSummary; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SttSummaryRepository extends JpaRepository { + Optional findBySttJobId(Long sttJobId); +} diff --git a/src/main/java/com/dokdok/stt/service/SttJobService.java b/src/main/java/com/dokdok/stt/service/SttJobService.java new file mode 100644 index 00000000..061fb3c0 --- /dev/null +++ b/src/main/java/com/dokdok/stt/service/SttJobService.java @@ -0,0 +1,319 @@ +package com.dokdok.stt.service; + +import com.dokdok.ai.client.AiSttClient; +import com.dokdok.ai.dto.SttRequest; +import com.dokdok.global.exception.GlobalErrorCode; +import com.dokdok.global.exception.GlobalException; +import com.dokdok.gathering.service.GatheringValidator; +import com.dokdok.global.response.ApiResponse; +import com.dokdok.global.util.SecurityUtil; +import com.dokdok.meeting.entity.Meeting; +import com.dokdok.meeting.service.MeetingValidator; +import com.dokdok.retrospective.dto.response.RetrospectiveSummaryResponse; +import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; +import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; +import com.dokdok.stt.dto.SttJobResponse; +import com.dokdok.stt.entity.SttJob; +import com.dokdok.stt.entity.SttJobStatus; +import com.dokdok.stt.entity.SttSummary; +import com.dokdok.stt.repository.SttJobRepository; +import com.dokdok.stt.repository.SttSummaryRepository; +import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; +import com.dokdok.topic.repository.TopicRepository; +import com.dokdok.topic.repository.TopicAnswerRepository; +import com.dokdok.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SttJobService { + + private static final long MAX_FILE_SIZE = 50L * 1024L * 1024L; + private static final Set ALLOWED_EXTENSIONS = Set.of( + "mp3", "aac", "ac3", "ogg", "flac", "wav", "m4a" + ); + + private final MeetingValidator meetingValidator; + private final GatheringValidator gatheringValidator; + private final SttJobRepository sttJobRepository; + private final SttSummaryRepository sttSummaryRepository; + private final TopicRepository topicRepository; + private final TopicRetrospectiveSummaryRepository topicRetrospectiveSummaryRepository; + private final TopicAnswerRepository topicAnswerRepository; + private final AiSttClient aiSttClient; + + @Value("${stt.temp-dir:}") + private String tempDirProperty; + + @Transactional + public SttJobResponse createJob(Long gatheringId, Long meetingId, MultipartFile file) { + Long userId = SecurityUtil.getCurrentUserId(); + gatheringValidator.validateMembership(gatheringId, userId); + meetingValidator.validateMeetingInGathering(meetingId, gatheringId); + meetingValidator.validateMeeting(meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); + + Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); + User user = SecurityUtil.getCurrentUserEntity(); + + if (file != null && file.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + + List preAnswers = buildPreAnswers(meetingId); + if (file == null && preAnswers.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + + Path tempFilePath = null; + if (file != null) { + validateFile(file); + tempFilePath = saveToTemp(file); + } + SttJob job = SttJob.builder() + .meeting(meeting) + .user(user) + .originalFilename(file != null ? file.getOriginalFilename() : null) + .contentType(file != null ? file.getContentType() : null) + .fileSize(file != null ? file.getSize() : null) + .tempFilePath(tempFilePath != null ? tempFilePath.toString() : null) + .status(SttJobStatus.PROCESSING) + .build(); + sttJobRepository.save(job); + + SttSummary summary = null; + try { + log.info("STT pre-answers count: {}", preAnswers.size()); + ApiResponse apiResponse = aiSttClient.requestStt( + new SttRequest( + job.getId(), + meetingId, + tempFilePath != null ? tempFilePath.toString() : null, + "ko-KR", + preAnswers + ) + ); + RetrospectiveSummaryResponse response = apiResponse != null ? apiResponse.data() : null; + if (apiResponse == null) { + job.markFailed("STT response is empty"); + } else if (!"SUCCESS".equalsIgnoreCase(apiResponse.code())) { + String message = apiResponse.message() == null ? "STT failed" : apiResponse.message(); + job.markFailed(message); + } else if (response == null) { + job.markFailed("STT response is empty"); + } else { + int topicCount = response.topics() == null ? 0 : response.topics().size(); + List topicIds = response.topics() == null + ? List.of() + : response.topics().stream().map(RetrospectiveSummaryResponse.TopicSummaryResponse::topicId).toList(); + log.info("STT summary topics count: {}, topicIds={}", topicCount, topicIds); + job.markDone(); + summary = saveSummary(job, response); + saveRetrospectiveSummaries(meetingId, response); + } + } catch (WebClientResponseException e) { + job.markFailed("AI STT error: " + e.getStatusCode()); + log.error( + "AI STT request failed: status={}, body={}", + e.getStatusCode(), + e.getResponseBodyAsString(), + e + ); + } catch (Exception e) { + job.markFailed("AI STT error"); + log.error("AI STT request failed", e); + } finally { + deleteTempFile(tempFilePath); + } + + return SttJobResponse.from(job, summary); + } + + @Transactional(readOnly = true) + public SttJobResponse getJob(Long gatheringId, Long meetingId, Long jobId) { + Long userId = SecurityUtil.getCurrentUserId(); + gatheringValidator.validateMembership(gatheringId, userId); + meetingValidator.validateMeetingInGathering(meetingId, gatheringId); + meetingValidator.validateMeeting(meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); + + SttJob job = sttJobRepository.findByIdAndMeetingId(jobId, meetingId) + .orElseThrow(() -> new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE)); + + SttSummary summary = sttSummaryRepository.findBySttJobId(jobId).orElse(null); + return SttJobResponse.from(job, summary); + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new GlobalException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(GlobalErrorCode.FILE_SIZE_EXCEEDED); + } + String originalFilename = file.getOriginalFilename(); + String extension = extractExtension(originalFilename); + if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) { + throw new GlobalException(GlobalErrorCode.INVALID_FILE_TYPE); + } + } + + private String extractExtension(String filename) { + if (filename == null) { + return null; + } + int lastDot = filename.lastIndexOf('.'); + if (lastDot < 0 || lastDot == filename.length() - 1) { + return null; + } + return filename.substring(lastDot + 1).toLowerCase(); + } + + private Path saveToTemp(MultipartFile file) { + String tempRoot = tempDirProperty == null || tempDirProperty.isBlank() + ? System.getProperty("java.io.tmpdir") + : tempDirProperty; + Path tempDir = Paths.get(tempRoot, "dokdok-stt"); + try { + Files.createDirectories(tempDir); + String safeName = UUID.randomUUID() + "_" + sanitizeFilename(file.getOriginalFilename()); + Path tempFilePath = tempDir.resolve(safeName); + file.transferTo(tempFilePath.toFile()); + return tempFilePath; + } catch (IOException e) { + throw new GlobalException(GlobalErrorCode.FILE_UPLOAD_FAILED, e); + } + } + + private String sanitizeFilename(String filename) { + if (filename == null) { + return "audio"; + } + return filename.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private void deleteTempFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete temp STT file: {}", path, e); + } + } + + private SttSummary saveSummary(SttJob job, RetrospectiveSummaryResponse response) { + RetrospectiveSummaryResponse.TopicSummaryResponse topicSummary = extractTopicSummary(response); + if (topicSummary == null) { + return null; + } + String summaryText = topicSummary.summary(); + List highlights = topicSummary.keyPoints() == null + ? null + : topicSummary.keyPoints().stream() + .map(RetrospectiveSummaryResponse.KeyPointResponse::title) + .toList(); + + if ((summaryText == null || summaryText.isBlank()) + && (highlights == null || highlights.isEmpty())) { + return null; + } + + SttSummary summary = SttSummary.builder() + .sttJob(job) + .summary(summaryText) + .highlights(highlights) + .build(); + return sttSummaryRepository.save(summary); + } + + private RetrospectiveSummaryResponse.TopicSummaryResponse extractTopicSummary( + RetrospectiveSummaryResponse response + ) { + if (response.topics() == null || response.topics().isEmpty()) { + return null; + } + return response.topics().get(0); + } + + private void saveRetrospectiveSummaries( + Long meetingId, + RetrospectiveSummaryResponse response + ) { + if (response.topics() == null || response.topics().isEmpty()) { + return; + } + + RetrospectiveSummaryResponse.TopicSummaryResponse baseSummary = response.topics().stream() + .filter(topic -> topic.topicId() == null) + .findFirst() + .orElse(null); + if (baseSummary != null) { + List topics = topicRepository.findConfirmedTopics(meetingId); + for (Topic topic : topics) { + upsertTopicSummary(topic, baseSummary); + } + return; + } + + for (RetrospectiveSummaryResponse.TopicSummaryResponse topicResponse : response.topics()) { + Long topicId = topicResponse.topicId(); + if (topicId == null) { + continue; + } + Topic topic = topicRepository.findById(topicId).orElse(null); + if (topic == null || !meetingId.equals(topic.getMeeting().getId())) { + continue; + } + upsertTopicSummary(topic, topicResponse); + } + } + + private void upsertTopicSummary( + Topic topic, + RetrospectiveSummaryResponse.TopicSummaryResponse topicResponse + ) { + List keyPoints = topicResponse.keyPoints() == null + ? List.of() + : topicResponse.keyPoints().stream() + .map(kp -> new TopicRetrospectiveSummary.KeyPoint(kp.title(), kp.details())) + .toList(); + TopicRetrospectiveSummary summary = topicRetrospectiveSummaryRepository + .findByTopicId(topic.getId()) + .orElseGet(() -> TopicRetrospectiveSummary.builder() + .topic(topic) + .build()); + summary.update(topicResponse.summary(), keyPoints); + topicRetrospectiveSummaryRepository.save(summary); + } + + private List buildPreAnswers(Long meetingId) { + List answers = topicAnswerRepository.findByMeetingId(meetingId); + return answers.stream() + .filter(answer -> answer.getContent() != null && !answer.getContent().isBlank()) + .map(answer -> new SttRequest.PreAnswer( + answer.getTopic() != null ? answer.getTopic().getId() : null, + answer.getTopic() != null ? answer.getTopic().getTitle() : null, + answer.getUser() != null ? answer.getUser().getId() : null, + answer.getContent() + )) + .toList(); + } +} diff --git a/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java b/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java deleted file mode 100644 index 7663c1a5..00000000 --- a/src/main/java/com/dokdok/topic/api/TopicSummaryApi.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.dokdok.topic.api; - -import com.dokdok.global.response.ApiResponse; -import com.dokdok.topic.dto.response.TopicSummaryResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; - -@Tag(name = "토픽 요약", description = "토픽 요약 관련 API") -@RequestMapping("/api/gatherings/{gathering_id}/meetings/{meeting_id}/topics/{topic_id}/summaries") -public interface TopicSummaryApi { - - @Operation( - summary = "토픽 요약 요청 (AI 테스트) (developer: 양재웅)", - description = "AI 서버로 토픽 답변 요약 요청을 전달합니다.", - parameters = { - @Parameter(name = "gathering_id", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meeting_id", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topic_id", description = "토픽 식별자", in = ParameterIn.PATH, required = true) - } - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "토픽 요약 요청 성공", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicSummaryResponse.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "G002", "message": "입력값이 올바르지 않습니다.", "data": null} - """))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "모임 또는 약속의 멤버가 아님", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "M004", "message": "약속의 멤버가 아닙니다.", "data": null} - """))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "모임, 약속 또는 토픽을 찾을 수 없음", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = { - @ExampleObject( - name = "모임 없음", - value = """ - {"code": "GA001", "message": "모임을 찾을 수 없습니다.", "data": null} - """ - ), - @ExampleObject( - name = "약속 없음", - value = """ - {"code": "M001", "message": "약속을 찾을 수 없습니다.", "data": null} - """ - ), - @ExampleObject( - name = "토픽 없음", - value = """ - {"code": "E101", "message": "주제를 찾을 수 없습니다.", "data": null} - """ - ) - })), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value = """ - {"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null} - """))) - }) - @PostMapping(value = "/ai-test", produces = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity> requestTopicSummary( - @PathVariable("gathering_id") Long gatheringId, - @PathVariable("meeting_id") Long meetingId, - @PathVariable("topic_id") Long topicId - ); -} diff --git a/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java b/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java deleted file mode 100644 index 388f52cb..00000000 --- a/src/main/java/com/dokdok/topic/controller/TopicSummaryController.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.dokdok.topic.controller; - -import com.dokdok.global.response.ApiResponse; -import com.dokdok.topic.api.TopicSummaryApi; -import com.dokdok.topic.dto.response.TopicSummaryResponse; -import com.dokdok.topic.service.TopicSummaryService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class TopicSummaryController implements TopicSummaryApi { - - private final TopicSummaryService topicSummaryService; - - @Override - public ResponseEntity> requestTopicSummary( - Long gatheringId, - Long meetingId, - Long topicId - ) { - String response = topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - return ApiResponse.success(TopicSummaryResponse.from(response), "AI 요약 요청이 전달되었습니다."); - } -} diff --git a/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java b/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java deleted file mode 100644 index 0c7cdde0..00000000 --- a/src/main/java/com/dokdok/topic/dto/response/TopicSummaryResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.dokdok.topic.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "토픽 요약 응답") -public record TopicSummaryResponse( - @Schema(description = "AI 요약 결과 원문(JSON 문자열)", example = "{\"summary\":\"...\",\"highlights\":[\"...\"],\"keywords\":[\"...\"]}") - String result -) { - public static TopicSummaryResponse from(String result) { - return new TopicSummaryResponse(result); - } -} diff --git a/src/main/java/com/dokdok/topic/service/TopicSummaryService.java b/src/main/java/com/dokdok/topic/service/TopicSummaryService.java deleted file mode 100644 index 601ba357..00000000 --- a/src/main/java/com/dokdok/topic/service/TopicSummaryService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.dokdok.topic.service; - -import com.dokdok.ai.client.AiSummaryClient; -import com.dokdok.ai.dto.TopicSummaryRequest; -import com.dokdok.gathering.service.GatheringValidator; -import com.dokdok.global.util.SecurityUtil; -import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.entity.Topic; -import com.dokdok.topic.entity.TopicAnswer; -import com.dokdok.topic.repository.TopicAnswerRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class TopicSummaryService { - - private final GatheringValidator gatheringValidator; - private final MeetingValidator meetingValidator; - private final TopicValidator topicValidator; - private final TopicAnswerRepository topicAnswerRepository; - private final AiSummaryClient aiSummaryClient; - - public String requestTopicSummary(Long gatheringId, Long meetingId, Long topicId) { - Long userId = SecurityUtil.getCurrentUserId(); - gatheringValidator.validateGathering(gatheringId); - meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - meetingValidator.validateMeetingMember(meetingId, userId); - - Topic topic = topicValidator.getTopicInMeeting(topicId, meetingId); - List answers = topicAnswerRepository.findSubmittedByTopicId(topicId); - - List answerDtos = answers.stream() - .map(answer -> new TopicSummaryRequest.Answer( - answer.getUser().getId(), - answer.getContent() - )) - .filter(answer -> answer.content() != null && !answer.content().isBlank()) - .toList(); - - TopicSummaryRequest request = new TopicSummaryRequest( - topic.getId(), - topic.getTitle(), - answerDtos - ); - - return aiSummaryClient.requestTopicSummary(request); - } -} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index b7047d0d..cb823e48 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -99,3 +99,7 @@ ai: api: base-url: http://localhost:8000 summary-path: /summaries/topics + stt-path: /stt/summary + +stt: + temp-dir: ${STT_TEMP_DIR:} diff --git a/src/main/resources/application-local.yaml.example b/src/main/resources/application-local.yaml.example index 15b550b1..f4c49eec 100644 --- a/src/main/resources/application-local.yaml.example +++ b/src/main/resources/application-local.yaml.example @@ -43,9 +43,15 @@ server: session: timeout: 30m # 세션 타임아웃 30분 +ai: + api: + base-url: http://localhost:8000 + summary-path: /summaries/topics + stt-path: /stt/summary + # Swagger/OpenAPI Configuration springdoc: swagger-ui: servers: - url: http://localhost:8080 - description: 로컬 서버 \ No newline at end of file + description: 로컬 서버 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 74fd19c3..16791a2d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -27,3 +27,7 @@ ai: api: base-url: ${AI_API_BASE_URL:http://localhost:8000} summary-path: ${AI_API_SUMMARY_PATH:/summaries/topics} + stt-path: ${AI_API_STT_PATH:/stt/summary} + +stt: + temp-dir: ${STT_TEMP_DIR:} diff --git a/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java b/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java deleted file mode 100644 index ce2f9f15..00000000 --- a/src/test/java/com/dokdok/topic/service/TopicSummaryServiceTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.dokdok.topic.service; - -import com.dokdok.ai.client.AiSummaryClient; -import com.dokdok.ai.dto.TopicSummaryRequest; -import com.dokdok.gathering.service.GatheringValidator; -import com.dokdok.global.exception.GlobalException; -import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.entity.Topic; -import com.dokdok.topic.entity.TopicAnswer; -import com.dokdok.topic.repository.TopicAnswerRepository; -import com.dokdok.user.entity.User; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TopicSummaryServiceTest { - - @Mock - private GatheringValidator gatheringValidator; - - @Mock - private MeetingValidator meetingValidator; - - @Mock - private TopicValidator topicValidator; - - @Mock - private TopicAnswerRepository topicAnswerRepository; - - @Mock - private AiSummaryClient aiSummaryClient; - - @InjectMocks - private TopicSummaryService topicSummaryService; - - @BeforeEach - void setUpSecurityContext() { - User user = User.builder() - .id(1L) - .nickname("tester") - .kakaoId(1L) - .build(); - com.dokdok.oauth2.CustomOAuth2User principal = com.dokdok.oauth2.CustomOAuth2User.builder() - .user(user) - .attributes(java.util.Collections.emptyMap()) - .build(); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - principal, - null, - principal.getAuthorities() - ); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - @Test - @DisplayName("토픽 요약 요청 시 제출된 답변만 모아 AI에 전달한다") - void requestTopicSummary_buildsPayloadAndDelegatesToClient() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("데미안 토론").build(); - - User user3 = User.builder().id(3L).kakaoId(3L).build(); - User user4 = User.builder().id(4L).kakaoId(4L).build(); - User user5 = User.builder().id(5L).kakaoId(5L).build(); - - TopicAnswer answer1 = TopicAnswer.builder() - .topic(topic) - .user(user3) - .content("답변 1") - .isSubmitted(true) - .build(); - TopicAnswer answer2 = TopicAnswer.builder() - .topic(topic) - .user(user4) - .content(" ") - .isSubmitted(true) - .build(); - TopicAnswer answer3 = TopicAnswer.builder() - .topic(topic) - .user(user5) - .content("답변 3") - .isSubmitted(true) - .build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of(answer1, answer2, answer3)); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - String result = topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - - TopicSummaryRequest request = captor.getValue(); - assertThat(request.topicId()).isEqualTo(topicId); - assertThat(request.topicTitle()).isEqualTo("데미안 토론"); - assertThat(request.answers()).containsExactly( - new TopicSummaryRequest.Answer(3L, "답변 1"), - new TopicSummaryRequest.Answer(5L, "답변 3") - ); - assertThat(result).isEqualTo("ok"); - } - - @Test - @DisplayName("제출된 답변이 비어있으면 빈 answers로 AI 요청한다") - void requestTopicSummary_sendsEmptyAnswersWhenNoContent() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("빈 답변 토픽").build(); - - User user3 = User.builder().id(3L).kakaoId(3L).build(); - TopicAnswer answer1 = TopicAnswer.builder() - .topic(topic) - .user(user3) - .content(" ") - .isSubmitted(true) - .build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of(answer1)); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - assertThat(captor.getValue().answers()).isEmpty(); - } - - @Test - @DisplayName("제출된 답변이 없으면 빈 answers로 AI 요청한다") - void requestTopicSummary_sendsEmptyAnswersWhenNoSubmittedAnswer() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("빈 답변 토픽").build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of()); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenReturn("ok"); - - topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TopicSummaryRequest.class); - verify(aiSummaryClient).requestTopicSummary(captor.capture()); - assertThat(captor.getValue().answers()).isEmpty(); - } - - @Test - @DisplayName("인증 정보가 없으면 예외가 발생한다") - void requestTopicSummary_throwsWhenUnauthenticated() { - SecurityContextHolder.clearContext(); - - assertThatThrownBy(() -> topicSummaryService.requestTopicSummary(1L, 2L, 3L)) - .isInstanceOf(GlobalException.class); - } - - @Test - @DisplayName("AI 호출 실패 시 예외가 그대로 전파된다") - void requestTopicSummary_throwsWhenAiClientFails() { - Long gatheringId = 1L; - Long meetingId = 2L; - Long topicId = 5L; - Topic topic = Topic.builder().id(topicId).title("데미안 토론").build(); - - doNothing().when(gatheringValidator).validateGathering(gatheringId); - doNothing().when(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); - doNothing().when(meetingValidator).validateMeetingMember(meetingId, 1L); - when(topicValidator.getTopicInMeeting(topicId, meetingId)).thenReturn(topic); - when(topicAnswerRepository.findSubmittedByTopicId(topicId)) - .thenReturn(List.of()); - when(aiSummaryClient.requestTopicSummary(org.mockito.ArgumentMatchers.any())) - .thenThrow(new IllegalStateException("ai fail")); - - assertThatThrownBy(() -> topicSummaryService.requestTopicSummary(gatheringId, meetingId, topicId)) - .isInstanceOf(IllegalStateException.class); - } -}