diff --git a/src/docs/asciidoc/app/retrospect-v2-conversation.adoc b/src/docs/asciidoc/app/retrospect-v2-conversation.adoc index 343180c1..74d36327 100644 --- a/src/docs/asciidoc/app/retrospect-v2-conversation.adoc +++ b/src/docs/asciidoc/app/retrospect-v2-conversation.adoc @@ -18,6 +18,7 @@ include::{snippets}/retrospect-v2/start/response-fields.adoc[] == 메시지 전송 동일한 `clientMessageId`와 내용으로 재전송하면 이미 완료된 응답을 반환합니다. +`inputType`은 `TEXT` 또는 `STT`이며 생략하면 `TEXT`로 저장됩니다. STT 변환 텍스트를 사용자가 수정한 뒤 전송할 때는 `STT`를 전달합니다. === Request diff --git a/src/docs/asciidoc/app/retrospect.adoc b/src/docs/asciidoc/app/retrospect.adoc index 21fb89d4..ddd6b4b1 100644 --- a/src/docs/asciidoc/app/retrospect.adoc +++ b/src/docs/asciidoc/app/retrospect.adoc @@ -26,6 +26,11 @@ include::{snippets}/retrospect/submit-answer/response-fields.adoc[] == 음성 답변 제출 +[WARNING] +==== +구버전 앱 호환용 API입니다. 신규 회고에서는 음성 답변 변환 후 사용자가 수정한 텍스트를 일반 메시지 API로 전송해야 합니다. +==== + === Request include::{snippets}/retrospect/submit-voice-answer/http-request.adoc[] @@ -300,4 +305,4 @@ include::{snippets}/retrospect/v2/find-by-id/path-parameters.adoc[] === Response include::{snippets}/retrospect/v2/find-by-id/http-response.adoc[] -include::{snippets}/retrospect/v2/find-by-id/response-fields.adoc[] \ No newline at end of file +include::{snippets}/retrospect/v2/find-by-id/response-fields.adoc[] diff --git a/src/main/kotlin/com/didit/adapter/integration/ai/ClovaSpeechClient.kt b/src/main/kotlin/com/didit/adapter/integration/ai/ClovaSpeechClient.kt index 4820cb27..becd1d04 100644 --- a/src/main/kotlin/com/didit/adapter/integration/ai/ClovaSpeechClient.kt +++ b/src/main/kotlin/com/didit/adapter/integration/ai/ClovaSpeechClient.kt @@ -63,7 +63,7 @@ class ClovaSpeechClient( runCatching { objectMapper.readValue(response, ClovaSpeechResponse::class.java) }.getOrElse { - throw SpeechTranscriptionFailedException("CLOVA Speech 응답 파싱 실패. response: $response") + throw SpeechTranscriptionFailedException("CLOVA Speech 응답 파싱 실패") } private fun validateResult(result: ClovaSpeechResponse) { diff --git a/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiSpeechClient.kt b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiSpeechClient.kt index 8fd563ea..8b024e0e 100644 --- a/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiSpeechClient.kt +++ b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiSpeechClient.kt @@ -67,7 +67,7 @@ class OpenAiSpeechClient( runCatching { objectMapper.readValue(response, OpenAiSpeechResponse::class.java) }.getOrElse { - throw SpeechTranscriptionFailedException("OpenAI STT 응답 파싱 실패. response: $response") + throw SpeechTranscriptionFailedException("OpenAI STT 응답 파싱 실패") } private fun createFilePart( diff --git a/src/main/kotlin/com/didit/adapter/webapi/exception/ApiControllerAdvice.kt b/src/main/kotlin/com/didit/adapter/webapi/exception/ApiControllerAdvice.kt index 5dc9b95c..0241d713 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/exception/ApiControllerAdvice.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/exception/ApiControllerAdvice.kt @@ -5,6 +5,7 @@ import com.didit.application.common.exception.ErrorCode import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ProblemDetail +import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice @@ -31,6 +32,19 @@ class ApiControllerAdvice { } } + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ProblemDetail { + log.warn("[VALIDATION] 요청 본문 파싱 실패 message={}", exception.message) + + return ProblemDetail + .forStatusAndDetail(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_REQUEST.detail) + .apply { + title = HttpStatus.BAD_REQUEST.reasonPhrase + setProperty("timestamp", OffsetDateTime.now().toString()) + setProperty("code", ErrorCode.INVALID_REQUEST.name) + } + } + @ExceptionHandler(BusinessException::class) fun handleBusinessException(exception: BusinessException): ProblemDetail { val errorCode = exception.errorCode diff --git a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt index 043736cb..455e4bc4 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt @@ -87,6 +87,8 @@ class RetrospectApi( } @RequireAuth + @Deprecated("Use the transcription endpoint and submit the edited text through the conversation API") + @Suppress("DEPRECATION") @PostMapping("/api/v1/retrospectives/{retrospectiveId}/answers/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun submitVoiceAnswer( @CurrentUserId userId: UUID, diff --git a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2Api.kt b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2Api.kt index d2f80742..21107fe6 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2Api.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2Api.kt @@ -42,7 +42,7 @@ class RetrospectConversationV2Api( ): SuccessResponse = SuccessResponse.of( SubmitConversationMessageV2Response.from( - conversation.submitMessage(retrospectiveId, userId, request.clientMessageId, request.content), + conversation.submitMessage(retrospectiveId, userId, request.clientMessageId, request.content, request.inputType), ), ) diff --git a/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/ConversationV2ApiDtos.kt b/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/ConversationV2ApiDtos.kt index 56d6123e..07070831 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/ConversationV2ApiDtos.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/ConversationV2ApiDtos.kt @@ -9,6 +9,7 @@ import com.didit.application.retrospect.dto.SubmitConversationMessageResult import com.didit.domain.retrospect.ConversationMessageType import com.didit.domain.retrospect.ConversationStatus import com.didit.domain.retrospect.ConversationTurnStatus +import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.Sender import com.didit.domain.retrospect.SummaryGenerationStatus import jakarta.validation.constraints.NotBlank @@ -19,6 +20,7 @@ data class SubmitConversationMessageV2Request( val clientMessageId: UUID, @field:NotBlank val content: String, + val inputType: InputType = InputType.TEXT, ) data class StartConversationV2Response( diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt index 69a1da61..9c1bd131 100644 --- a/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt @@ -14,6 +14,7 @@ import com.didit.application.retrospect.exception.RetrospectiveNotFoundException import com.didit.application.retrospect.exception.RetrospectiveNotInProgressException import com.didit.application.retrospect.exception.SpeechEmptyFileException import com.didit.application.retrospect.exception.SpeechEmptyResultException +import com.didit.application.retrospect.exception.SpeechTranscriptionFailedException import com.didit.application.retrospect.exception.SpeechUnsupportedFileException import com.didit.application.retrospect.exception.SummaryNotGeneratedException import com.didit.application.retrospect.provided.RetrospectiveFinder @@ -98,6 +99,7 @@ class RetrospectService( ): SubmitAnswerResponse = processAnswer(retrospectiveId, userId, content, InputType.TEXT) @Transactional + @Deprecated("Use transcribeVoiceAnswer and submit the edited text through the conversation API") override fun submitVoiceAnswer( retrospectiveId: UUID, userId: UUID, @@ -113,7 +115,7 @@ class RetrospectService( if (retrospective.isPending()) retrospective.startProgress() - val content = transcribe(audioBytes, filename) + val content = transcribe(retrospectiveId, audioBytes, filename) saveUserAnswer(retrospective, content, currentQuestionType, InputType.STT) return routeAnswer(retrospective, currentQuestionType).copy(content = content) @@ -129,7 +131,7 @@ class RetrospectService( val retrospective = retrospectiveFinder.findById(retrospectiveId, userId) validateRetrospectiveInProgress(retrospective, retrospectiveId) - return transcribe(audioBytes, filename) + return transcribe(retrospectiveId, audioBytes, filename) } @Transactional @@ -318,23 +320,54 @@ class RetrospectService( } private fun transcribe( + retrospectiveId: UUID, audioBytes: ByteArray, filename: String, ): String { - if (audioBytes.isEmpty()) throw SpeechEmptyFileException() - - val supportedExtensions = listOf("flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm") val extension = filename.substringAfterLast('.', "").lowercase() - - if (extension !in supportedExtensions) { - throw SpeechUnsupportedFileException(filename, null) + return try { + if (audioBytes.isEmpty()) throw SpeechEmptyFileException() + + val supportedExtensions = listOf("flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm") + if (extension !in supportedExtensions) { + throw SpeechUnsupportedFileException(filename, null) + } + + val text = + try { + speechClient.transcribe(audioBytes, filename).trim() + } catch (exception: SpeechTranscriptionFailedException) { + throw exception + } catch (exception: Exception) { + throw SpeechTranscriptionFailedException( + "providerFailure: ${exception::class.simpleName}", + exception, + ) + } + + if (text.isBlank()) throw SpeechEmptyResultException() + text + } catch (exception: Exception) { + if (exception is SpeechTranscriptionFailedException) { + logger.warn( + "음성 변환 실패 - retrospectiveId: {}, extension: {}, fileSize: {}, failureType: {}", + retrospectiveId, + extension, + audioBytes.size, + exception::class.simpleName, + exception, + ) + } else { + logger.warn( + "음성 변환 실패 - retrospectiveId: {}, extension: {}, fileSize: {}, failureType: {}", + retrospectiveId, + extension, + audioBytes.size, + exception::class.simpleName, + ) + } + throw exception } - - val text = speechClient.transcribe(audioBytes, filename).trim() - - if (text.isBlank()) throw SpeechEmptyResultException() - - return text } private fun validateRetrospectiveInProgress( @@ -343,6 +376,9 @@ class RetrospectService( ) { if (retrospective.isCompleted()) throw RetrospectiveAlreadyCompletedException(retrospectiveId) if (retrospective.isDeleted()) throw RetrospectiveNotInProgressException(retrospectiveId) + if (retrospective.isV2() && !retrospective.isConversationActive()) { + throw RetrospectiveNotInProgressException(retrospectiveId) + } } private fun saveUserAnswer( diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2Service.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2Service.kt index 1a884a03..1a2e4088 100644 --- a/src/main/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2Service.kt +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2Service.kt @@ -32,6 +32,7 @@ import com.didit.domain.retrospect.ChatMessage import com.didit.domain.retrospect.ConversationMessageType import com.didit.domain.retrospect.ConversationStatus import com.didit.domain.retrospect.ConversationTurnStatus +import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.MessageRelevance import com.didit.domain.retrospect.Retrospective import com.didit.domain.retrospect.RetrospectiveAnalysisEvidence @@ -98,10 +99,11 @@ class RetrospectiveConversationV2Service( userId: UUID, clientMessageId: UUID, content: String, + inputType: InputType, ): SubmitConversationMessageResult { val preparation = metrics.recordStage("conversation_v2", "prepare") { - prepareTurn(retrospectiveId, userId, clientMessageId, content) + prepareTurn(retrospectiveId, userId, clientMessageId, content, inputType) } preparation.cachedResult?.let { metrics.incrementConversationDuplicate() @@ -182,6 +184,7 @@ class RetrospectiveConversationV2Service( userId: UUID, clientMessageId: UUID, content: String, + inputType: InputType, ): TurnPreparation = transactionTemplate.execute { val retrospective = findV2ForUpdate(retrospectiveId, userId) @@ -190,7 +193,9 @@ class RetrospectiveConversationV2Service( val existing = turnRepository.findByRetrospectiveIdAndClientMessageId(retrospectiveId, clientMessageId) if (existing != null) { val userMessage = chatMessageRepository.findById(existing.userMessageId) ?: error("사용자 메시지를 찾을 수 없습니다.") - if (userMessage.content != content) throw DuplicateMessageContentMismatchException(clientMessageId) + if (userMessage.content != content || userMessage.inputType != inputType) { + throw DuplicateMessageContentMismatchException(clientMessageId) + } when (existing.status) { ConversationTurnStatus.PROCESSING -> throw ConversationTurnInProgressException(retrospectiveId) ConversationTurnStatus.COMPLETED -> @@ -219,7 +224,7 @@ class RetrospectiveConversationV2Service( } if (retrospective.isPending()) retrospective.startProgress() - val userMessage = ChatMessage.v2UserMessage(retrospective, content) + val userMessage = ChatMessage.v2UserMessage(retrospective, content, inputType) retrospective.addMessage(userMessage) retrospectiveRepository.save(retrospective) val turn = diff --git a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt index 450a6a0e..38d98565 100644 --- a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt +++ b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt @@ -64,10 +64,15 @@ class SpeechUnsupportedFileException( class SpeechTranscriptionFailedException( message: String, + cause: Throwable? = null, ) : BusinessException( RetrospectErrorCode.SPEECH_TRANSCRIPTION_FAILED, message, - ) + ) { + init { + if (cause != null) initCause(cause) + } +} class SpeechEmptyResultException : BusinessException(RetrospectErrorCode.SPEECH_EMPTY_RESULT) diff --git a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveConversationV2.kt b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveConversationV2.kt index 80694676..f5a3371d 100644 --- a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveConversationV2.kt +++ b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveConversationV2.kt @@ -4,6 +4,7 @@ import com.didit.application.retrospect.dto.ConversationV2Result import com.didit.application.retrospect.dto.FinishConversationV2Result import com.didit.application.retrospect.dto.StartConversationV2Result import com.didit.application.retrospect.dto.SubmitConversationMessageResult +import com.didit.domain.retrospect.InputType import java.util.UUID interface RetrospectiveConversationV2 { @@ -14,6 +15,14 @@ interface RetrospectiveConversationV2 { userId: UUID, clientMessageId: UUID, content: String, + ): SubmitConversationMessageResult = submitMessage(retrospectiveId, userId, clientMessageId, content, InputType.TEXT) + + fun submitMessage( + retrospectiveId: UUID, + userId: UUID, + clientMessageId: UUID, + content: String, + inputType: InputType, ): SubmitConversationMessageResult fun getConversation( diff --git a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegister.kt b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegister.kt index fa66d074..d400dfb5 100644 --- a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegister.kt +++ b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegister.kt @@ -14,6 +14,7 @@ interface RetrospectiveRegister { content: String, ): SubmitAnswerResponse + @Deprecated("Use transcribeVoiceAnswer and submit the edited text through the conversation API") fun submitVoiceAnswer( retrospectiveId: UUID, userId: UUID, diff --git a/src/main/kotlin/com/didit/domain/retrospect/ChatMessage.kt b/src/main/kotlin/com/didit/domain/retrospect/ChatMessage.kt index be007b58..443d8735 100644 --- a/src/main/kotlin/com/didit/domain/retrospect/ChatMessage.kt +++ b/src/main/kotlin/com/didit/domain/retrospect/ChatMessage.kt @@ -115,6 +115,12 @@ class ChatMessage( fun v2UserMessage( retrospective: Retrospective, content: String, + ): ChatMessage = v2UserMessage(retrospective, content, InputType.TEXT) + + fun v2UserMessage( + retrospective: Retrospective, + content: String, + inputType: InputType, ): ChatMessage { require(content.isNotBlank()) { "회고 내용은 비어 있을 수 없습니다." } return ChatMessage( @@ -122,7 +128,7 @@ class ChatMessage( sender = Sender.USER, content = content, questionType = QuestionType.V2_CHAT, - inputType = InputType.TEXT, + inputType = inputType, messageType = ConversationMessageType.CONVERSATION, includedInResult = false, ) diff --git a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt index c256a516..b4fa1d26 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt @@ -49,6 +49,7 @@ import java.time.LocalDate import java.time.LocalDateTime import java.util.UUID +@Suppress("DEPRECATION") class RetrospectApiTest : AuthenticatedRestDocsSupport() { private val retrospectiveRegister: RetrospectiveRegister = mock(RetrospectiveRegister::class.java) private val retrospectiveFinder: RetrospectiveFinder = mock(RetrospectiveFinder::class.java) diff --git a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2ApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2ApiTest.kt index de271c9d..11c4f734 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2ApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectConversationV2ApiTest.kt @@ -13,10 +13,12 @@ import com.didit.docs.AuthenticatedRestDocsSupport import com.didit.domain.retrospect.ConversationMessageType import com.didit.domain.retrospect.ConversationStatus import com.didit.domain.retrospect.ConversationTurnStatus +import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.Sender import com.didit.domain.retrospect.SummaryGenerationStatus import org.junit.jupiter.api.Test import org.mockito.Mockito.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document @@ -74,7 +76,7 @@ class RetrospectConversationV2ApiTest : AuthenticatedRestDocsSupport() { @Test fun `V2 대화 메시지 전송`() { val request = SubmitConversationMessageV2Request(clientMessageId, "배포 자동화 작업을 완료했습니다.") - whenever(conversation.submitMessage(retrospectiveId, userId, clientMessageId, request.content)).thenReturn( + whenever(conversation.submitMessage(retrospectiveId, userId, clientMessageId, request.content, request.inputType)).thenReturn( SubmitConversationMessageResult( turnId = turnId, userMessageId = userMessageId, @@ -101,6 +103,7 @@ class RetrospectConversationV2ApiTest : AuthenticatedRestDocsSupport() { requestFields( fieldWithPath("clientMessageId").type(JsonFieldType.STRING).description("중복 전송 방지용 클라이언트 메시지 ID"), fieldWithPath("content").type(JsonFieldType.STRING).description("사용자가 입력한 회고 내용"), + fieldWithPath("inputType").type(JsonFieldType.STRING).description("입력 출처. 생략 시 TEXT").optional(), ), responseFields( fieldWithPath("data.turnId").type(JsonFieldType.STRING).description("대화 턴 ID"), @@ -112,6 +115,51 @@ class RetrospectConversationV2ApiTest : AuthenticatedRestDocsSupport() { ) } + @Test + fun `V2 메시지 입력 타입을 생략하면 TEXT로 전달한다`() { + whenever(conversation.submitMessage(retrospectiveId, userId, clientMessageId, "직접 입력했습니다.", InputType.TEXT)) + .thenReturn(submitMessageResult()) + + mockMvc + .perform( + post("/api/v2/retrospectives/{retrospectiveId}/messages", retrospectiveId) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"clientMessageId":"$clientMessageId","content":"직접 입력했습니다."}""", + ), + ).andExpect(status().isOk) + + verify(conversation).submitMessage(retrospectiveId, userId, clientMessageId, "직접 입력했습니다.", InputType.TEXT) + } + + @Test + fun `V2 STT 메시지는 입력 타입을 STT로 전달한다`() { + val request = SubmitConversationMessageV2Request(clientMessageId, "음성 결과를 수정했습니다.", InputType.STT) + whenever(conversation.submitMessage(retrospectiveId, userId, clientMessageId, request.content, InputType.STT)) + .thenReturn(submitMessageResult()) + + mockMvc + .perform( + post("/api/v2/retrospectives/{retrospectiveId}/messages", retrospectiveId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isOk) + + verify(conversation).submitMessage(retrospectiveId, userId, clientMessageId, request.content, InputType.STT) + } + + @Test + fun `V2 메시지에 지원하지 않는 입력 타입을 전달하면 400을 반환한다`() { + mockMvc + .perform( + post("/api/v2/retrospectives/{retrospectiveId}/messages", retrospectiveId) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"clientMessageId":"$clientMessageId","content":"회고 내용","inputType":"VOICE"}""", + ), + ).andExpect(status().isBadRequest) + } + @Test fun `V2 대화 조회`() { whenever(conversation.getConversation(retrospectiveId, userId)).thenReturn( @@ -210,6 +258,14 @@ class RetrospectConversationV2ApiTest : AuthenticatedRestDocsSupport() { createdAt = now, ) + private fun submitMessageResult() = + SubmitConversationMessageResult( + turnId = turnId, + userMessageId = userMessageId, + assistantMessage = assistantMessage(), + readyToComplete = false, + ) + private fun startResponseFields() = arrayOf( fieldWithPath("data.retrospectiveId").type(JsonFieldType.STRING).description("회고 ID"), diff --git a/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt b/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt index b27ae7dd..886cc380 100644 --- a/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt +++ b/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt @@ -12,6 +12,7 @@ import com.didit.application.retrospect.exception.RetrospectiveNotFoundException import com.didit.application.retrospect.exception.RetrospectiveNotInProgressException import com.didit.application.retrospect.exception.SpeechEmptyFileException import com.didit.application.retrospect.exception.SpeechEmptyResultException +import com.didit.application.retrospect.exception.SpeechTranscriptionFailedException import com.didit.application.retrospect.exception.SpeechUnsupportedFileException import com.didit.application.retrospect.exception.SummaryNotGeneratedException import com.didit.application.retrospect.provided.RetrospectiveFinder @@ -40,9 +41,11 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.context.ApplicationEventPublisher +import org.springframework.web.client.ResourceAccessException import java.util.UUID @ExtendWith(MockitoExtension::class) +@Suppress("DEPRECATION") class RetrospectServiceTest { @Mock lateinit var retrospectiveRepository: RetrospectiveRepository @@ -348,6 +351,39 @@ class RetrospectServiceTest { verify(speechClient, never()).transcribe(any(), any()) } + @Test + fun `transcribeVoiceAnswer - V2 대화가 종료된 상태면 STT 호출 없이 예외가 발생한다`() { + val retro = Retrospective.createV2(userId).apply { finishConversation() } + whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) + + assertThrows { + retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, ByteArray(100) { 1 }, "voice.wav") + } + verify(speechClient, never()).transcribe(any(), any()) + } + + @Test + fun `transcribeVoiceAnswer - 삭제된 회고면 STT 호출 없이 예외가 발생한다`() { + val retro = Retrospective.createV2(userId).apply { softDelete() } + whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) + + assertThrows { + retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, ByteArray(100) { 1 }, "voice.wav") + } + verify(speechClient, never()).transcribe(any(), any()) + } + + @Test + fun `transcribeVoiceAnswer - 소유하지 않은 회고면 STT 호출 없이 예외가 발생한다`() { + whenever(retrospectiveFinder.findById(retrospectiveId, userId)) + .thenThrow(RetrospectiveNotFoundException(retrospectiveId)) + + assertThrows { + retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, ByteArray(100) { 1 }, "voice.wav") + } + verify(speechClient, never()).transcribe(any(), any()) + } + @Test fun `transcribeVoiceAnswer - 빈 파일이면 STT 호출 없이 예외가 발생한다`() { val retro = inProgressRetrospective() @@ -382,6 +418,42 @@ class RetrospectServiceTest { } } + @Test + fun `transcribeVoiceAnswer - 외부 STT 예외를 공통 변환 실패로 반환한다`() { + val retro = Retrospective.createV2(userId) + val audioBytes = ByteArray(100) { 1 } + val providerException = ResourceAccessException("network error") + whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) + whenever(speechClient.transcribe(audioBytes, "voice.wav")).thenThrow(providerException) + + val exception = + assertThrows { + retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, audioBytes, "voice.wav") + } + + assertThat(exception.cause).isSameAs(providerException) + verify(retrospectiveRepository, never()).save(any()) + } + + @Test + fun `transcribeVoiceAnswer - 실패 후 같은 파일로 재시도할 수 있다`() { + val retro = Retrospective.createV2(userId) + val audioBytes = ByteArray(100) { 1 } + whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) + whenever(speechClient.transcribe(audioBytes, "voice.wav")) + .thenThrow(ResourceAccessException("temporary network error")) + .thenReturn("재시도 성공") + + assertThrows { + retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, audioBytes, "voice.wav") + } + val result = retrospectService.transcribeVoiceAnswer(retrospectiveId, userId, audioBytes, "voice.wav") + + assertThat(result).isEqualTo("재시도 성공") + assertThat(retro.isPending()).isTrue() + verify(retrospectiveRepository, never()).save(any()) + } + @Test fun `skipDeepQuestion - 심화 질문을 스킵한다`() { val retro = inProgressRetrospective() diff --git a/src/test/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2ServiceTest.kt b/src/test/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2ServiceTest.kt index bef046f7..4d7147a3 100644 --- a/src/test/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2ServiceTest.kt +++ b/src/test/kotlin/com/didit/application/retrospect/RetrospectiveConversationV2ServiceTest.kt @@ -3,7 +3,9 @@ package com.didit.application.retrospect import com.didit.adapter.config.JpaAuditingConfig import com.didit.application.auth.provided.UserFinder import com.didit.application.retrospect.exception.ConversationAiFailedException +import com.didit.application.retrospect.exception.DuplicateMessageContentMismatchException import com.didit.application.retrospect.provided.RetrospectiveFinder +import com.didit.application.retrospect.required.ChatMessageRepository import com.didit.application.retrospect.required.ConversationAnalysisUpdate import com.didit.application.retrospect.required.ConversationTurnAIRequest import com.didit.application.retrospect.required.ConversationV2AIClient @@ -16,6 +18,7 @@ import com.didit.domain.auth.User import com.didit.domain.auth.UserRegisterRequest import com.didit.domain.retrospect.ConversationMessageType import com.didit.domain.retrospect.ConversationTurnStatus +import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.MessageRelevance import com.didit.domain.retrospect.RetrospectiveItemStatus import com.didit.domain.retrospect.RetrospectiveItemType @@ -57,6 +60,9 @@ class RetrospectiveConversationV2ServiceTest { @Autowired private lateinit var analysisItemRepository: RetrospectiveAnalysisItemRepository + @Autowired + private lateinit var chatMessageRepository: ChatMessageRepository + @MockitoBean private lateinit var retrospectiveFinder: RetrospectiveFinder @@ -135,6 +141,40 @@ class RetrospectiveConversationV2ServiceTest { verify(aiClient, times(2)).generateConversationTurn(any()) } + @Test + fun `STT 입력은 수정된 내용과 입력 타입을 저장하고 동일한 대화 흐름을 진행한다`() { + val started = service.start(userId) + whenever(aiClient.generateConversationTurn(any())).thenAnswer { invocation -> + retrospectiveResponse(invocation.getArgument(0)) + } + + service.submitMessage(started.retrospectiveId, userId, UUID.randomUUID(), "수정한 음성 회고입니다.", InputType.STT) + + val userMessage = + chatMessageRepository + .findAllByRetrospectiveIdOrderByCreatedAtAsc(started.retrospectiveId) + .single { it.sender.name == "USER" } + assertThat(userMessage.content).isEqualTo("수정한 음성 회고입니다.") + assertThat(userMessage.inputType).isEqualTo(InputType.STT) + verify(aiClient).generateConversationTurn(any()) + } + + @Test + fun `같은 메시지 ID의 입력 타입이 달라지면 중복 요청을 거절한다`() { + val started = service.start(userId) + val clientMessageId = UUID.randomUUID() + whenever(aiClient.generateConversationTurn(any())).thenAnswer { invocation -> + retrospectiveResponse(invocation.getArgument(0)) + } + service.submitMessage(started.retrospectiveId, userId, clientMessageId, "같은 내용", InputType.TEXT) + + assertThrows { + service.submitMessage(started.retrospectiveId, userId, clientMessageId, "같은 내용", InputType.STT) + } + + verify(aiClient, times(1)).generateConversationTurn(any()) + } + @Test fun `종료는 대화만 동결하고 회고 결과를 생성하지 않는다`() { val started = service.start(userId) diff --git a/src/test/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegisterTest.kt b/src/test/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegisterTest.kt index 36ce2b7f..05418a6a 100644 --- a/src/test/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegisterTest.kt +++ b/src/test/kotlin/com/didit/application/retrospect/provided/RetrospectiveRegisterTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.whenever import java.util.UUID @ExtendWith(MockitoExtension::class) +@Suppress("DEPRECATION") class RetrospectiveRegisterTest { @Mock lateinit var retrospectiveRegister: RetrospectiveRegister diff --git a/src/test/kotlin/com/didit/domain/retrospect/ChatMessageTest.kt b/src/test/kotlin/com/didit/domain/retrospect/ChatMessageTest.kt index 487d6173..b88efdd9 100644 --- a/src/test/kotlin/com/didit/domain/retrospect/ChatMessageTest.kt +++ b/src/test/kotlin/com/didit/domain/retrospect/ChatMessageTest.kt @@ -58,4 +58,12 @@ class ChatMessageTest { assertFalse(message.isSkipped) assertNull(message.inputType) } + + @Test + fun `v2UserMessage - 전달한 STT 입력 타입을 저장한다`() { + val message = ChatMessage.v2UserMessage(retrospective(), "수정한 음성 회고", InputType.STT) + + assertEquals(InputType.STT, message.inputType) + assertEquals("수정한 음성 회고", message.content) + } }